├── .gitignore ├── Classes ├── Channels.cs ├── Streams.cs └── Utils.cs ├── PleXZattoo.csproj ├── Program.cs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # The following command works for downloading when using Git for Windows: 2 | # curl -LOf http://gist.githubusercontent.com/kmorcinek/2710267/raw/.gitignore 3 | # 4 | # Download this file using PowerShell v3 under Windows with the following comand: 5 | # Invoke-WebRequest https://gist.githubusercontent.com/kmorcinek/2710267/raw/ -OutFile .gitignore 6 | # 7 | # or wget: 8 | # wget --no-check-certificate http://gist.githubusercontent.com/kmorcinek/2710267/raw/.gitignore 9 | 10 | # User-specific files 11 | *.suo 12 | *.user 13 | *.sln.docstates 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Rr]elease/ 18 | x64/ 19 | [Bb]in/ 20 | [Oo]bj/ 21 | # build folder is nowadays used for build scripts and should not be ignored 22 | #build/ 23 | 24 | # NuGet Packages 25 | *.nupkg 26 | # The packages folder can be ignored because of Package Restore 27 | **/packages/* 28 | # except build/, which is used as an MSBuild target. 29 | !**/packages/build/ 30 | # Uncomment if necessary however generally it will be regenerated when needed 31 | #!**/packages/repositories.config 32 | 33 | # MSTest test Results 34 | [Tt]est[Rr]esult*/ 35 | [Bb]uild[Ll]og.* 36 | 37 | *_i.c 38 | *_p.c 39 | *.ilk 40 | *.meta 41 | *.obj 42 | *.pch 43 | *.pdb 44 | *.pgc 45 | *.pgd 46 | *.rsp 47 | *.sbr 48 | *.tlb 49 | *.tli 50 | *.tlh 51 | *.tmp 52 | *.tmp_proj 53 | *.log 54 | *.vspscc 55 | *.vssscc 56 | .builds 57 | *.pidb 58 | *.log 59 | *.scc 60 | 61 | # OS generated files # 62 | .DS_Store* 63 | Icon? 64 | 65 | # Visual C++ cache files 66 | ipch/ 67 | *.aps 68 | *.ncb 69 | *.opensdf 70 | *.sdf 71 | *.cachefile 72 | 73 | # Visual Studio profiler 74 | *.psess 75 | *.vsp 76 | *.vspx 77 | 78 | # Guidance Automation Toolkit 79 | *.gpState 80 | 81 | # ReSharper is a .NET coding add-in 82 | _ReSharper*/ 83 | *.[Rr]e[Ss]harper 84 | 85 | # TeamCity is a build add-in 86 | _TeamCity* 87 | 88 | # DotCover is a Code Coverage Tool 89 | *.dotCover 90 | 91 | # NCrunch 92 | *.ncrunch* 93 | .*crunch*.local.xml 94 | 95 | # Installshield output folder 96 | [Ee]xpress/ 97 | 98 | # DocProject is a documentation generator add-in 99 | DocProject/buildhelp/ 100 | DocProject/Help/*.HxT 101 | DocProject/Help/*.HxC 102 | DocProject/Help/*.hhc 103 | DocProject/Help/*.hhk 104 | DocProject/Help/*.hhp 105 | DocProject/Help/Html2 106 | DocProject/Help/html 107 | 108 | # Click-Once directory 109 | publish/ 110 | 111 | # Publish Web Output 112 | *.Publish.xml 113 | 114 | # Windows Azure Build Output 115 | csx 116 | *.build.csdef 117 | 118 | # Windows Store app package directory 119 | AppPackages/ 120 | 121 | # Others 122 | *.Cache 123 | ClientBin/ 124 | [Ss]tyle[Cc]op.* 125 | ~$* 126 | *~ 127 | *.dbmdl 128 | *.[Pp]ublish.xml 129 | *.pfx 130 | *.publishsettings 131 | modulesbin/ 132 | tempbin/ 133 | 134 | # EPiServer Site file (VPP) 135 | AppData/ 136 | 137 | # RIA/Silverlight projects 138 | Generated_Code/ 139 | 140 | # Backup & report files from converting an old project file to a newer 141 | # Visual Studio version. Backup files are not needed, because we have git ;-) 142 | _UpgradeReport_Files/ 143 | Backup*/ 144 | UpgradeLog*.XML 145 | UpgradeLog*.htm 146 | 147 | # vim 148 | *.txt~ 149 | *.swp 150 | *.swo 151 | 152 | # Temp files when opening LibreOffice on ubuntu 153 | .~lock.* 154 | 155 | # svn 156 | .svn 157 | 158 | # CVS - Source Control 159 | **/CVS/ 160 | 161 | # Remainings from resolving conflicts in Source Control 162 | *.orig 163 | 164 | # SQL Server files 165 | **/App_Data/*.mdf 166 | **/App_Data/*.ldf 167 | **/App_Data/*.sdf 168 | 169 | 170 | #LightSwitch generated files 171 | GeneratedArtifacts/ 172 | _Pvt_Extensions/ 173 | ModelManifest.xml 174 | 175 | # ========================= 176 | # Windows detritus 177 | # ========================= 178 | 179 | # Windows image file caches 180 | Thumbs.db 181 | ehthumbs.db 182 | 183 | # Folder config file 184 | Desktop.ini 185 | 186 | # Recycle Bin used on file shares 187 | $RECYCLE.BIN/ 188 | 189 | # Mac desktop service store files 190 | .DS_Store 191 | 192 | # SASS Compiler cache 193 | .sass-cache 194 | 195 | # Visual Studio 2014 CTP 196 | **/*.sln.ide 197 | 198 | # Visual Studio temp something 199 | .vs/ 200 | 201 | # dotnet stuff 202 | project.lock.json 203 | 204 | # VS 2015+ 205 | *.vc.vc.opendb 206 | *.vc.db 207 | 208 | # Rider 209 | .idea/ 210 | 211 | # Visual Studio Code 212 | .vscode/ 213 | 214 | # Output folder used by Webpack or other FE stuff 215 | **/node_modules/* 216 | **/wwwroot/* 217 | 218 | # SpecFlow specific 219 | *.feature.cs 220 | *.feature.xlsx.* 221 | *.Specs_*.html 222 | 223 | ##### 224 | # End of core ignore list, below put you custom 'per project' settings (patterns or path) 225 | ##### 226 | -------------------------------------------------------------------------------- /Classes/Channels.cs: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // To parse this JSON data, add NuGet 'Newtonsoft.Json' then do: 4 | // 5 | // using QuickType; 6 | // 7 | // var channelGroup = ChannelGroup.FromJson(jsonString); 8 | 9 | namespace PleXZattoo 10 | { 11 | using System; 12 | using System.Collections.Generic; 13 | 14 | using System.Globalization; 15 | using Newtonsoft.Json; 16 | using Newtonsoft.Json.Converters; 17 | 18 | public partial class Channels 19 | { 20 | [JsonProperty("channel_groups")] 21 | public List ChannelGroups { get; set; } 22 | 23 | [JsonProperty("success")] 24 | public bool Success { get; set; } 25 | } 26 | 27 | public partial class ChannelGroupElement 28 | { 29 | [JsonProperty("channels")] 30 | public List Channels { get; set; } 31 | 32 | [JsonProperty("name")] 33 | public string Name { get; set; } 34 | } 35 | 36 | public partial class Channel 37 | { 38 | [JsonProperty("display_alias")] 39 | public string DisplayAlias { get; set; } 40 | 41 | [JsonProperty("sharing")] 42 | public bool Sharing { get; set; } 43 | 44 | [JsonProperty("is_radio")] 45 | public bool IsRadio { get; set; } 46 | 47 | [JsonProperty("title")] 48 | public string Title { get; set; } 49 | 50 | [JsonProperty("cid")] 51 | public string Cid { get; set; } 52 | 53 | [JsonProperty("recording")] 54 | public bool Recording { get; set; } 55 | 56 | [JsonProperty("qualities")] 57 | public List Qualities { get; set; } 58 | 59 | [JsonProperty("recommendations")] 60 | public bool Recommendations { get; set; } 61 | 62 | [JsonProperty("selective_recall_seconds", NullValueHandling = NullValueHandling.Ignore)] 63 | public long? SelectiveRecallSeconds { get; set; } 64 | 65 | [JsonProperty("id")] 66 | public string Id { get; set; } 67 | 68 | [JsonProperty("aliases", NullValueHandling = NullValueHandling.Ignore)] 69 | public List Aliases { get; set; } 70 | } 71 | 72 | public partial class Quality 73 | { 74 | [JsonProperty("logo_black_84")] 75 | public string LogoBlack84 { get; set; } 76 | 77 | [JsonProperty("title")] 78 | public string Title { get; set; } 79 | 80 | [JsonProperty("stream_types")] 81 | public List StreamTypes { get; set; } 82 | 83 | [JsonProperty("level")] 84 | public Level Level { get; set; } 85 | 86 | [JsonProperty("logo_white_42")] 87 | public string LogoWhite42 { get; set; } 88 | 89 | [JsonProperty("logo_token")] 90 | public string LogoToken { get; set; } 91 | 92 | [JsonProperty("logo_black_42")] 93 | public string LogoBlack42 { get; set; } 94 | 95 | [JsonProperty("logo_white_84")] 96 | public string LogoWhite84 { get; set; } 97 | 98 | [JsonProperty("availability")] 99 | public Availability Availability { get; set; } 100 | 101 | [JsonProperty("drm_required", NullValueHandling = NullValueHandling.Ignore)] 102 | public bool? DrmRequired { get; set; } 103 | } 104 | 105 | public enum Availability { Available, Subscribable }; 106 | 107 | public enum Level { Hd, Sd }; 108 | 109 | public enum StreamType { Dash, DashPlayready, DashWidevine, Hds, Hls, Hls5, Hls5Fairplay, Hls7, Hls7Fairplay, SmoothPlayready }; 110 | 111 | public partial class Channels 112 | { 113 | public static Channels FromJson(string json) => JsonConvert.DeserializeObject(json, PleXZattoo.Converter.Settings); 114 | } 115 | 116 | public static class Serialize 117 | { 118 | public static string ToJson(this Channels self) => JsonConvert.SerializeObject(self, PleXZattoo.Converter.Settings); 119 | } 120 | 121 | internal static class Converter 122 | { 123 | public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings 124 | { 125 | MetadataPropertyHandling = MetadataPropertyHandling.Ignore, 126 | DateParseHandling = DateParseHandling.None, 127 | Converters = 128 | { 129 | AvailabilityConverter.Singleton, 130 | LevelConverter.Singleton, 131 | StreamTypeConverter.Singleton, 132 | new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } 133 | }, 134 | }; 135 | } 136 | 137 | internal class AvailabilityConverter : JsonConverter 138 | { 139 | public override bool CanConvert(Type t) => t == typeof(Availability) || t == typeof(Availability?); 140 | 141 | public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) 142 | { 143 | if (reader.TokenType == JsonToken.Null) return null; 144 | var value = serializer.Deserialize(reader); 145 | switch (value) 146 | { 147 | case "available": 148 | return Availability.Available; 149 | case "subscribable": 150 | return Availability.Subscribable; 151 | } 152 | throw new Exception("Cannot unmarshal type Availability"); 153 | } 154 | 155 | public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) 156 | { 157 | if (untypedValue == null) 158 | { 159 | serializer.Serialize(writer, null); 160 | return; 161 | } 162 | var value = (Availability)untypedValue; 163 | switch (value) 164 | { 165 | case Availability.Available: 166 | serializer.Serialize(writer, "available"); 167 | return; 168 | case Availability.Subscribable: 169 | serializer.Serialize(writer, "subscribable"); 170 | return; 171 | } 172 | throw new Exception("Cannot marshal type Availability"); 173 | } 174 | 175 | public static readonly AvailabilityConverter Singleton = new AvailabilityConverter(); 176 | } 177 | 178 | internal class LevelConverter : JsonConverter 179 | { 180 | public override bool CanConvert(Type t) => t == typeof(Level) || t == typeof(Level?); 181 | 182 | public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) 183 | { 184 | if (reader.TokenType == JsonToken.Null) return null; 185 | var value = serializer.Deserialize(reader); 186 | switch (value) 187 | { 188 | case "hd": 189 | return Level.Hd; 190 | case "sd": 191 | return Level.Sd; 192 | } 193 | throw new Exception("Cannot unmarshal type Level"); 194 | } 195 | 196 | public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) 197 | { 198 | if (untypedValue == null) 199 | { 200 | serializer.Serialize(writer, null); 201 | return; 202 | } 203 | var value = (Level)untypedValue; 204 | switch (value) 205 | { 206 | case Level.Hd: 207 | serializer.Serialize(writer, "hd"); 208 | return; 209 | case Level.Sd: 210 | serializer.Serialize(writer, "sd"); 211 | return; 212 | } 213 | throw new Exception("Cannot marshal type Level"); 214 | } 215 | 216 | public static readonly LevelConverter Singleton = new LevelConverter(); 217 | } 218 | 219 | internal class StreamTypeConverter : JsonConverter 220 | { 221 | public override bool CanConvert(Type t) => t == typeof(StreamType) || t == typeof(StreamType?); 222 | 223 | public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) 224 | { 225 | if (reader.TokenType == JsonToken.Null) return null; 226 | var value = serializer.Deserialize(reader); 227 | switch (value) 228 | { 229 | case "dash": 230 | return StreamType.Dash; 231 | case "dash_playready": 232 | return StreamType.DashPlayready; 233 | case "dash_widevine": 234 | return StreamType.DashWidevine; 235 | case "hds": 236 | return StreamType.Hds; 237 | case "hls": 238 | return StreamType.Hls; 239 | case "hls5": 240 | return StreamType.Hls5; 241 | case "hls5_fairplay": 242 | return StreamType.Hls5Fairplay; 243 | case "hls7": 244 | return StreamType.Hls7; 245 | case "hls7_fairplay": 246 | return StreamType.Hls7Fairplay; 247 | case "smooth_playready": 248 | return StreamType.SmoothPlayready; 249 | } 250 | throw new Exception("Cannot unmarshal type StreamType"); 251 | } 252 | 253 | public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) 254 | { 255 | if (untypedValue == null) 256 | { 257 | serializer.Serialize(writer, null); 258 | return; 259 | } 260 | var value = (StreamType)untypedValue; 261 | switch (value) 262 | { 263 | case StreamType.Dash: 264 | serializer.Serialize(writer, "dash"); 265 | return; 266 | case StreamType.DashPlayready: 267 | serializer.Serialize(writer, "dash_playready"); 268 | return; 269 | case StreamType.DashWidevine: 270 | serializer.Serialize(writer, "dash_widevine"); 271 | return; 272 | case StreamType.Hds: 273 | serializer.Serialize(writer, "hds"); 274 | return; 275 | case StreamType.Hls: 276 | serializer.Serialize(writer, "hls"); 277 | return; 278 | case StreamType.Hls5: 279 | serializer.Serialize(writer, "hls5"); 280 | return; 281 | case StreamType.Hls5Fairplay: 282 | serializer.Serialize(writer, "hls5_fairplay"); 283 | return; 284 | case StreamType.Hls7: 285 | serializer.Serialize(writer, "hls7"); 286 | return; 287 | case StreamType.Hls7Fairplay: 288 | serializer.Serialize(writer, "hls7_fairplay"); 289 | return; 290 | case StreamType.SmoothPlayready: 291 | serializer.Serialize(writer, "smooth_playready"); 292 | return; 293 | } 294 | throw new Exception("Cannot marshal type StreamType"); 295 | } 296 | 297 | public static readonly StreamTypeConverter Singleton = new StreamTypeConverter(); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /Classes/Streams.cs: -------------------------------------------------------------------------------- 1 | namespace PleXZattoo 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | 6 | using System.Globalization; 7 | using Newtonsoft.Json; 8 | using Newtonsoft.Json.Converters; 9 | 10 | public partial class Streams 11 | { 12 | [JsonProperty("success")] 13 | public bool Success { get; set; } 14 | 15 | [JsonProperty("stream")] 16 | public Stream Stream { get; set; } 17 | 18 | [JsonProperty("register_timeshift_allowed")] 19 | public bool RegisterTimeshiftAllowed { get; set; } 20 | 21 | [JsonProperty("register_timeshift")] 22 | public string RegisterTimeshift { get; set; } 23 | 24 | [JsonProperty("csid")] 25 | public string Csid { get; set; } 26 | 27 | [JsonProperty("unregistered_timeshift")] 28 | public string UnregisteredTimeshift { get; set; } 29 | } 30 | 31 | public partial class Stream 32 | { 33 | [JsonProperty("url")] 34 | public Uri Url { get; set; } 35 | 36 | [JsonProperty("rb_url")] 37 | public Uri RbUrl { get; set; } 38 | 39 | [JsonProperty("quality")] 40 | public string Quality { get; set; } 41 | 42 | [JsonProperty("watch_urls")] 43 | public List WatchUrls { get; set; } 44 | 45 | [JsonProperty("teletext_url")] 46 | public Uri TeletextUrl { get; set; } 47 | } 48 | 49 | public partial class WatchUrl 50 | { 51 | [JsonProperty("url")] 52 | public Uri Url { get; set; } 53 | 54 | [JsonProperty("maxrate")] 55 | public long Maxrate { get; set; } 56 | 57 | [JsonProperty("audio_channel")] 58 | public string AudioChannel { get; set; } 59 | } 60 | 61 | public partial class Streams 62 | { 63 | public static Streams FromJson(string json) => JsonConvert.DeserializeObject(json, PleXZattoo.Converter.Settings); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Classes/Utils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Net.Http; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using System.Threading.Tasks; 10 | using Serilog; 11 | 12 | namespace PleXZattoo 13 | { 14 | 15 | public class Util 16 | { 17 | 18 | 19 | public static async Task ProcessChannel(HttpClient client, Channel channel, string channelGroupName) 20 | { 21 | StringBuilder playlistentry = new StringBuilder(); 22 | 23 | string logo_black = channel.Qualities.First().LogoBlack84; 24 | 25 | Log.Debug("Getting Stream URL for {0}", channel.Cid); 26 | 27 | 28 | var content = new FormUrlEncodedContent(new[] 29 | { 30 | new KeyValuePair("cid", channel.Cid), 31 | new KeyValuePair("stream_type", "hls"), 32 | new KeyValuePair("https_watch_urls", "True"), 33 | new KeyValuePair("timeshift", "10800") 34 | }); 35 | 36 | var result = await client.PostAsync("https://zattoo.com/zapi/watch", content); 37 | 38 | result.EnsureSuccessStatusCode(); 39 | 40 | Streams channelStreams = Streams.FromJson(await result.Content.ReadAsStringAsync()); 41 | 42 | if (!channelStreams.Success) 43 | { 44 | throw new Exception("Failed to get channelStreams"); 45 | } 46 | 47 | string streamURL = await Util.GetHLSStreamURL(channelStreams); 48 | 49 | if (string.IsNullOrWhiteSpace(streamURL)) 50 | { 51 | throw new Exception("streamURL cannot be empty"); 52 | } 53 | 54 | Log.Debug("Successfully retrived streamURL => {0}", streamURL); 55 | 56 | 57 | playlistentry.AppendLine(string.Format("#EXTINF:-1 tvg-id=\"{0}\" tvg-name=\"{1}\" tvg-logo=\"http://images.zattic.com{2}\" group-title=\"{3}\", {4}", channel.Cid, channel.Title, logo_black, channelGroupName, channel.Title)); 58 | playlistentry.Append(streamURL); 59 | 60 | return playlistentry.ToString(); 61 | 62 | } 63 | public static async Task GetHLSStreamURL(Streams channelStreams) 64 | { 65 | 66 | string channelStreamURL = string.Empty; 67 | 68 | try 69 | { 70 | var baseAddress = new Uri("https://zattoo.com"); 71 | var cookieContainer = new CookieContainer(); 72 | using (var handler = new HttpClientHandler() { CookieContainer = cookieContainer }) 73 | using (var client = new HttpClient(handler) { BaseAddress = baseAddress }) 74 | { 75 | 76 | var result = await client.GetAsync(channelStreams.Stream.Url); 77 | 78 | result.EnsureSuccessStatusCode(); 79 | 80 | string body = await result.Content.ReadAsStringAsync(); 81 | 82 | //BANDWIDTH=5000000\n(.*[0-9]{3,4}-.*).m3u8\?(z32=[A-Z0-9]+) 83 | 84 | MatchCollection matches = Regex.Matches(await result.Content.ReadAsStringAsync(), @"\n(.*[0-9]{3,4}-.*).m3u8\?(z32=[A-Z0-9]+)"); 85 | 86 | if (matches.Count == 0 || matches[0].Success == false || string.IsNullOrWhiteSpace(matches[0].Value)) 87 | { 88 | throw new Exception("Invalid response"); 89 | } 90 | 91 | string streamUrlPathAndQuery = matches[0].Value.TrimStart(); 92 | 93 | channelStreamURL = string.Format("http://{0}/{1}", channelStreams.Stream.Url.Host, streamUrlPathAndQuery); 94 | 95 | } 96 | 97 | return channelStreamURL; 98 | 99 | } 100 | catch (Exception ex) 101 | { 102 | Log.Error(ex.Message); 103 | 104 | } 105 | 106 | return channelStreamURL; 107 | } 108 | 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /PleXZattoo.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | netcoreapp2.2 6 | 7.1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using System.Diagnostics; 4 | using System.Collections.Generic; 5 | using System.Text.RegularExpressions; 6 | using Serilog; 7 | using Serilog.Sinks.SystemConsole.Themes; 8 | using System.Net.Http; 9 | using System.Net; 10 | using Newtonsoft.Json.Linq; 11 | using Newtonsoft.Json; 12 | using System.Net.Http.Headers; 13 | using System.Text; 14 | using System.Linq; 15 | using Serilog.Core; 16 | using Serilog.Events; 17 | 18 | namespace PleXZattoo 19 | { 20 | class Program 21 | { 22 | static async Task Main(string[] args) 23 | { 24 | 25 | string out_file = String.Empty; 26 | string zattoo_username = String.Empty; 27 | string zattoo_password = String.Empty; 28 | bool debug = false; 29 | 30 | foreach (var arg in Environment.GetCommandLineArgs()) 31 | { 32 | 33 | if (arg.ToLower().StartsWith("/outfile:")) 34 | { 35 | out_file = arg.Replace("/outfile:", ""); 36 | } 37 | 38 | if (arg.ToLower().StartsWith("/zattoo-username:")) 39 | { 40 | zattoo_username = arg.Replace("/zattoo-username:", ""); 41 | } 42 | 43 | if (arg.ToLower().StartsWith("/zattoo-password:")) 44 | { 45 | zattoo_password = arg.Replace("/zattoo-password:", ""); 46 | } 47 | 48 | if (arg.ToLower().StartsWith("/debug")) 49 | { 50 | debug = true; 51 | } 52 | 53 | } 54 | 55 | try 56 | { 57 | 58 | var levelSwitch = new LoggingLevelSwitch(); 59 | 60 | Log.Logger = new LoggerConfiguration() 61 | .MinimumLevel.ControlledBy(levelSwitch) 62 | .WriteTo.Console(theme: AnsiConsoleTheme.Code) 63 | .CreateLogger(); 64 | 65 | if (debug){ 66 | levelSwitch.MinimumLevel = LogEventLevel.Debug; 67 | } 68 | else{ 69 | levelSwitch.MinimumLevel = LogEventLevel.Information; 70 | } 71 | 72 | 73 | if (string.IsNullOrEmpty(zattoo_username) | string.IsNullOrEmpty(zattoo_password)) 74 | { 75 | throw new Exception("Zattoo Credentials must be provied!"); 76 | } 77 | 78 | if (!zattoo_username.Contains("@")) 79 | { 80 | throw new Exception("Zattoo Username is not a valid E-Mail address"); 81 | } 82 | 83 | Log.Information("Loggin in..."); 84 | 85 | string appToken = string.Empty; 86 | string sessionCookie = string.Empty; 87 | 88 | var baseAddress = new Uri("https://zattoo.com"); 89 | var cookieContainer = new CookieContainer(); 90 | using (var handler = new HttpClientHandler() { CookieContainer = cookieContainer }) 91 | using (var client = new HttpClient(handler) { BaseAddress = baseAddress }) 92 | { 93 | 94 | client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 95 | client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.67 Safari/537.36"); 96 | 97 | Log.Debug("Fetching AppToken..."); 98 | 99 | var result = await client.PostAsync("/", new StringContent("")); 100 | result.EnsureSuccessStatusCode(); 101 | 102 | var match = Regex.Match(await result.Content.ReadAsStringAsync(), @"window\.appToken\s*=\s*'(.*)';"); 103 | 104 | if (match is null || match.Groups.Count < 2) 105 | { 106 | throw new Exception("failed to fetch AppToken"); 107 | } 108 | else 109 | { 110 | 111 | appToken = match.Groups[1].Value; 112 | } 113 | 114 | Log.Debug("Done => {0}", appToken); 115 | 116 | Log.Debug("Getting Session..."); 117 | 118 | var content = new FormUrlEncodedContent(new[] 119 | { 120 | new KeyValuePair("lang", "en"), 121 | new KeyValuePair("client_app_token", appToken), 122 | new KeyValuePair("uuid", "d7512e98-38a0-4f01-b820-5a5cf98141fe"), 123 | new KeyValuePair("format", "json") 124 | }); 125 | 126 | result = await client.PostAsync("https://zattoo.com/zapi/session/hello", content); 127 | 128 | result.EnsureSuccessStatusCode(); 129 | 130 | foreach (Cookie cookie in cookieContainer.GetCookies(baseAddress)) 131 | { 132 | 133 | if (cookie.Name.Equals("beaker.session.id")) 134 | { 135 | sessionCookie = cookie.Value; 136 | } 137 | } 138 | 139 | if (string.IsNullOrEmpty(sessionCookie)) 140 | { 141 | throw new Exception("Failed to get Session"); 142 | } 143 | 144 | Log.Debug("Session Cookie => {0}", sessionCookie); 145 | Log.Debug("Succesfully got Session"); 146 | 147 | content = new FormUrlEncodedContent(new[] 148 | { 149 | new KeyValuePair("login", zattoo_username), 150 | new KeyValuePair("password", zattoo_password) 151 | }); 152 | 153 | result = await client.PostAsync("https://zattoo.com/zapi/v2/account/login", content); 154 | 155 | result.EnsureSuccessStatusCode(); 156 | 157 | var data = (JObject)JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); 158 | 159 | //Log.Debug(data.ToString()); 160 | 161 | if (!data["success"].Value()) 162 | { 163 | throw new Exception("failed to login"); 164 | 165 | } 166 | 167 | Log.Information("Login successfull"); 168 | 169 | string powerGuideHash = string.Empty; 170 | 171 | if (data["session"]["power_guide_hash"] != null && !string.IsNullOrEmpty(data["session"]["power_guide_hash"].Value())) 172 | { 173 | powerGuideHash = data["session"]["power_guide_hash"].Value(); 174 | } 175 | else 176 | { 177 | throw new Exception("Failed to get powerGuideHash"); 178 | } 179 | 180 | Log.Debug("PowerGuideHash => {0}", powerGuideHash); 181 | 182 | Log.Information("Getting Channels..."); 183 | 184 | //https://$provider/zapi/v2/cached/channels/$powerid?details=False 185 | 186 | result = await client.GetAsync(string.Format("https://zattoo.com/zapi/v2/cached/channels/{0}?details=False", powerGuideHash)); 187 | 188 | result.EnsureSuccessStatusCode(); 189 | 190 | //Log.Debug(await result.Content.ReadAsStringAsync()); 191 | 192 | Channels channelGroups = Channels.FromJson(await result.Content.ReadAsStringAsync()); 193 | 194 | if (!channelGroups.Success) 195 | { 196 | throw new Exception("Failed to get Channels"); 197 | } 198 | 199 | Log.Information("Done - Found {0} Channel Groups", channelGroups.ChannelGroups.Count); 200 | 201 | Log.Information("Generating M3u...."); 202 | 203 | StringBuilder playlist = new StringBuilder(); 204 | 205 | playlist.AppendLine("#EXTM3U"); 206 | 207 | 208 | foreach (var channelgrp in channelGroups.ChannelGroups) 209 | { 210 | 211 | Log.Debug("Processing Channel Group {0}", channelgrp.Name); 212 | 213 | //Loop trough channels - skip radio and non availible channels 214 | foreach (var channel in channelgrp.Channels.Where(mychannel => mychannel.IsRadio == false && mychannel.Qualities.First().Availability == Availability.Available)) 215 | { 216 | 217 | int error_count = 0; 218 | string entry = string.Empty; 219 | 220 | Log.Debug("Processing {0} aka {1}", channel.Cid, channel.Title); 221 | 222 | while (error_count < 3 & string.IsNullOrEmpty(entry)) 223 | { 224 | 225 | try 226 | { 227 | entry = await Util.ProcessChannel(client, channel, channelgrp.Name); 228 | //ZAPI Rate Limit - Wait a bit 229 | await Task.Delay(1000); 230 | } 231 | catch (Exception ex) 232 | { 233 | error_count += 1; 234 | Log.Warning("[{0}/3]Failed to get HLS Stream Urlf for Channel {1} => {2}", error_count, channel.Cid, ex.Message); 235 | } 236 | 237 | } 238 | 239 | if (string.IsNullOrWhiteSpace(entry)) 240 | { 241 | Log.Error("Failed to process channel {0}", channel.Cid); 242 | } 243 | else 244 | { 245 | playlist.AppendLine(entry); 246 | } 247 | 248 | } 249 | 250 | } 251 | 252 | 253 | Log.Information("M3u8 generated! => Writing to disk..."); 254 | 255 | await System.IO.File.WriteAllTextAsync(out_file, playlist.ToString()); 256 | 257 | Log.Information("Done! - Bye"); 258 | 259 | } 260 | 261 | } 262 | catch (Exception ex) 263 | { 264 | Log.Error(ex.Message); 265 | } 266 | 267 | 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Usage 3 | ``` 4 | export DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER=false # may be needed on debian / ubuntu based distro's 5 | /opt/PleXZattoo/PleXZattoo /zattoo-username: \ 6 | /zattoo-password: \ 7 | /outfile:/opt/telly/channels.m3u8 8 | ``` 9 | ## Example Telly Config 10 | ``` 11 | # THIS SECTION IS REQUIRED ######################################################################## 12 | [Discovery] # most likely you won't need to change anything here 13 | Device-Auth = "telly123" # These settings are all related to how telly identifies 14 | Device-ID = 12345678 # itself to Plex. 15 | Device-UUID = "" 16 | Device-Firmware-Name = "hdhomeruntc_atsc" 17 | Device-Firmware-Version = "20150826" 18 | Device-Friendly-Name = "telly" 19 | Device-Manufacturer = "Silicondust" 20 | Device-Model-Number = "HDTC-2US" 21 | SSDP = true 22 | 23 | # Note on running multiple instances of telly 24 | # There are three things that make up a "key" for a given Telly Virtual Tuner: 25 | # Device-ID [required], Device-UUID [optional], and port [required] 26 | # When you configure your additional telly instances, change: 27 | # the Device-ID [above] AND 28 | # the Device-UUID [above, if you're entering one] AND 29 | # the port [below in the "Web" section] 30 | 31 | # THIS SECTION IS REQUIRED ######################################################################## 32 | [IPTV] 33 | Streams = 1 # number of simultaneous streams that telly virtual tuner will provide 34 | # This is often 1, but is set by your iptv provider; for example, 35 | # Vaders provides 5 36 | Starting-Channel = 10000 # When telly assigns channel numbers it will start here 37 | XMLTV-Channels = true # if true, any channel numbers specified in your M3U file will be used. 38 | FFMpeg = true # if this is uncommented, streams are buffered through ffmpeg; 39 | # ffmpeg must be installed and on your $PATH 40 | # if you want to use this with Docker, be sure you use the correct docker image 41 | # if you DO NOT WANT TO USE FFMPEG leave this commented; DO NOT SET IT TO FALSE [Issue #185] 42 | 43 | # THIS SECTION IS REQUIRED ######################################################################## 44 | [Log] 45 | Level = "info" # Only log messages at or above the given level. [debug, info, warn, error, fatal] 46 | Requests = true # Log HTTP requests made to telly 47 | 48 | # THIS SECTION IS REQUIRED ######################################################################## 49 | [Web] 50 | Base-Address = "192.168.0.2:6077" # Set this to the IP address of the machine telly runs on AS SEEN BY PLEX 51 | # telly will be telling Plex to connect to URLs at this address. 52 | Listen-Address = "0.0.0.0:6077" # this can stay as-is 53 | 54 | # THIS SECTION IS OPTIONAL ======================================================================== 55 | #[SchedulesDirect] # If you have a Schedules Direct account, fill in details and then 56 | # UNCOMMENT THIS SECTION 57 | # Username = "" # This is under construction; Vader is the only provider 58 | # Password = "" # that works with it fully at this time 59 | 60 | # AT LEAST ONE SOURCE IS REQUIRED ################################################################# 61 | # DELETE OR COMMENT OUT SOURCES THAT YOU ARE NOT USING ############################################ 62 | # NONE OF THESE EXAMPLES WORK AS-IS; IF YOU DON'T CHANGE IT, DELETE IT ############################ 63 | #[[Source]] 64 | # Name = "" # Name is optional and is used mostly for logging purposes 65 | # Provider = "Vaders" # named providers currently supported are "Vaders", "area51", "Iris" 66 | ## IF YOUR PROVIDER IS NOT ONE OF THE ABOVE, CONFIGURE IT AS A "Custom" PROVIDER; SEE BELOW 67 | # Username = "YOUR_IPTV_USERNAME" 68 | # Password = "YOUR_IPTV_PASSWORD" 69 | # # THE FOLLOWING KEYS ARE OPTIONAL IN THEORY, REQUIRED IN PRACTICE 70 | # Filter = "YOUR_FILTER_REGULAR_EXPRESSION" 71 | # Telly is written in Go, and uses the Go regular expression system, 72 | # which is limited compared to other regular expression parsers. 73 | # FilterKey = "group-title" # Telly applies the regular expression to the contents of this key in the M3U. 74 | # FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. 75 | # Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided 76 | 77 | #[[Source]] 78 | # Name = "" # Name is optional and is used mostly for logging purposes 79 | # Provider = "IPTV-EPG" # DO NOT CHANGE THIS IF YOU ARE USING THIS PROVIDER 80 | # Username = "M3U-Identifier" # From http://iptv-epg.com/[M3U-Identifier].m3u 81 | # Password = "XML-Identifier" # From http://iptv-epg.com/[XML-Identifier].xml 82 | # # NOTE: THOSE KEY NAMES DO NOT MAKE SENSE FOR THIS PROVIDER ################ 83 | # # THIS IS JUST AN IMPLEMENTATION DETAIL. JUST GO WITH IT. 84 | # # For this purpose, IPTV-EPG does not have a "username" and "password", HOWEVER, 85 | # # telly's scaffolding for a "Named provider" does. Rather than special-casing this provider, 86 | # # the username and password are used to hold the two required bits of information. 87 | # # THIS IS JUST AN IMPLEMENTATION DETAIL. JUST GO WITH IT. 88 | # # NOTE: THOSE KEY NAMES DO NOT MAKE SENSE FOR THIS PROVIDER ################ 89 | # # THE FOLLOWING KEYS ARE OPTIONAL HERE; IF YOU"RE USING IPTV-EPG YOU'VE PROBABLY DONE YOUR 90 | # # FILTERING THERE ALREADY 91 | # # Filter = "" 92 | # # FilterKey = "" 93 | # # FilterRaw = false 94 | # # Sort = "" 95 | 96 | [[Source]] 97 | Name = "Zattoo" # Name is optional and is used mostly for logging purposes 98 | Provider = "Custom" # DO NOT CHANGE THIS IF YOU ARE ENTERING URLS OR FILE PATHS 99 | # "Custom" is telly's internal identifier for this 'Provider' 100 | # If you change it to "NAMEOFPROVIDER" telly's reaction will be 101 | # "I don't recognize a provider called 'NAMEOFPROVIDER'." 102 | M3U = "/etc/telly/zattoo.m3u8" # This can be either URL or fully-qualified path. 103 | # This needs to be an M3Uplus file 104 | # IT CANNOT BE A STREAM ADDRESS 105 | # IT CANNOT BE AN M3U THAT LINKS TO ANOTHER M3U 106 | EPG = "https://github.com/sunsettrack4/xmltv_epg/raw/master/zattoo-epg-de.gz" # This can be either URL or fully qualified path. 107 | # THE FOLLOWING KEYS ARE OPTIONAL IN THEORY, REQUIRED IN PRACTICE 108 | # Filter = "YOUR_FILTER_REGULAR_EXPRESSION" 109 | # Telly is written in Go, and uses the Go regular expression system, 110 | # which is limited compared to other regular expression parsers. 111 | # FilterKey = "group-title" # Telly applies the regular expression to the contents of this key in the M3U. 112 | # FilterRaw = false # FilterRaw will run your regex on the entire line instead of just specific keys. 113 | # Sort = "group-title" # Sort will alphabetically sort your channels by the M3U key provided 114 | # END TELLY CONFIG ############################################################################### 115 | ``` 116 | 117 | ## Docker 118 | ``` 119 | telly: 120 | image: tellytv/telly:dev-ffmpeg 121 | container_name: telly 122 | restart: always 123 | ports: 124 | - "6077:6077" 125 | volumes: 126 | - /opt/telly/telly.config.toml:/etc/telly/telly.config.toml 127 | - /opt/telly/channels.m3u8:/etc/telly/zattoo.m3u8 128 | environment: 129 | - PGID=1000 130 | - PUID=1000 131 | - TZ=Europe/Berlin 132 | ``` 133 | 134 | 135 | 136 | --------------------------------------------------------------------------------