├── .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 |
--------------------------------------------------------------------------------