├── ClipManager.csproj
├── Program.cs
└── README.md
/ClipManager.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | netcoreapp3.1
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Program.cs:
--------------------------------------------------------------------------------
1 | using Newtonsoft.Json.Linq;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.IO;
5 | using System.Linq;
6 | using System.Net.Http;
7 | using System.Net.Http.Headers;
8 | using System.Threading;
9 |
10 | namespace ClipManager
11 | {
12 | class ClipInfo
13 | {
14 | public string id { get; set; }
15 | public int view_count { get; set; }
16 | public string creator_name { get; set; }
17 | public string created_at { get; set; }
18 | public string title { get; set; }
19 | }
20 |
21 | class Options
22 | {
23 | ///
24 | /// Stop at view count, 0 is no limit
25 | ///
26 | public int UpperLimit { get; set; }
27 | public int LowerLimit { get; set; }
28 | public int DayInterval { get; set; }
29 | ///
30 | /// Flag for download
31 | ///
32 | public bool Download { get; set; }
33 |
34 | ///
35 | /// Flag for deleting
36 | ///
37 | public bool Delete { get; set; }
38 |
39 | ///
40 | /// Auth token
41 | ///
42 | public string Token { get; set; }
43 |
44 | public DateTime CurrentDate { get; set; }
45 | }
46 |
47 | class Program
48 | {
49 | static string TwitchClientID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
50 | static string TwitchToken;
51 | static string UserId;
52 | static string Login;
53 | static string RootPath = Environment.CurrentDirectory;
54 | static void Main(string[] args)
55 | {
56 | var options = LoadConfig();
57 | TwitchToken = options.Token;
58 |
59 | GetUserInfo();
60 | var folder = Path.Combine(RootPath, "downloads");
61 | if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
62 |
63 | var firstClip = GetFirstClip(Login);
64 | if (firstClip.Count < 1)
65 | {
66 | Console.WriteLine("No clips found");
67 | Console.WriteLine("Press any key to exit.");
68 | Console.ReadKey();
69 | return;
70 | }
71 | var lastDate = DateTime.Parse(firstClip[0].created_at).Date;
72 | options.CurrentDate = DateTime.Now.Date;
73 |
74 | Console.WriteLine($"Oldest Clip on {lastDate}");
75 | do
76 | {
77 | List clips = new List();
78 | string cursor = null;
79 | Console.WriteLine($"Getting batched clips for {options.CurrentDate} to {options.CurrentDate.AddDays(options.DayInterval)}");
80 | int count = 0;
81 | do
82 | {
83 | var newClips = GetClipsApi(UserId, options.CurrentDate, options.DayInterval, ref cursor);
84 | clips.AddRange(newClips);
85 | count += newClips.Count;
86 |
87 | Console.SetCursorPosition(0, Console.CursorTop);
88 | Console.Write($"Count {count}...");
89 | } while (!string.IsNullOrEmpty(cursor));
90 | Console.WriteLine("Complete.");
91 |
92 | var deleteClips = new List();
93 | foreach (var clip in clips)
94 | {
95 | if ((options.UpperLimit != 0 && options.UpperLimit < clip.view_count)
96 | || (options.LowerLimit != 0 && options.LowerLimit > clip.view_count))
97 | {
98 | Console.WriteLine($"{clip.id} with views {clip.view_count} out of bounds.");
99 | continue;
100 | }
101 |
102 | var fileName = SanitizeFile($"v{clip.view_count:00000000}[{clip.created_at}] {clip.title} by {clip.creator_name}-{clip.id}.mp4");
103 | var savePath = Path.Combine(folder, fileName);
104 | try
105 | {
106 | if (options.Download)
107 | {
108 | Console.Write($"Downloading {clip.id}.");
109 | string sourceUrl = GetClipUri(clip.id);
110 | Console.Write(".");
111 | DownloadClip(sourceUrl, savePath);
112 | Console.Write(".");
113 | Console.WriteLine("Complete");
114 | }
115 | if (options.Delete)
116 | deleteClips.Add(clip.id);
117 | }
118 | catch (Exception ex)
119 | {
120 | File.AppendAllText(Path.Combine(RootPath, "error.log"), $"{clip.id} download failed: {ex.Message}" + Environment.NewLine);
121 | Console.WriteLine("Failed");
122 | }
123 | if (options.Delete && deleteClips.Count > 10)
124 | {
125 | del();
126 | }
127 | }
128 | if (options.Delete && deleteClips.Count > 0)
129 | {
130 | del();
131 | }
132 |
133 | void del()
134 | {
135 | try
136 | {
137 | Console.Write($"Deleting {string.Join(',', deleteClips)}...");
138 | DeleteClips(deleteClips);
139 | Console.WriteLine("Complete.");
140 | }
141 | catch (Exception ex)
142 | {
143 | File.AppendAllText(Path.Combine(RootPath, "error.log"), $"{string.Join(',', deleteClips)} deleting failed: {ex.Message}" + Environment.NewLine);
144 | Console.WriteLine("Failed.");
145 | }
146 | deleteClips.Clear();
147 | }
148 |
149 | options.CurrentDate = options.CurrentDate.AddDays(-options.DayInterval);
150 | SaveConfig(options);
151 | } while (options.CurrentDate > lastDate);
152 |
153 | Console.WriteLine("Press any key to exit.");
154 | Console.ReadKey();
155 | }
156 |
157 | static Options LoadConfig()
158 | {
159 | var configPath = Path.Combine(RootPath, "appsettings.json");
160 | try
161 | {
162 | if (File.Exists(configPath))
163 | {
164 | Console.WriteLine("Session found resume? (y or n):");
165 | string input = Console.ReadLine();
166 | if (input.ToLower().StartsWith('y'))
167 | {
168 | using var fsr = new StreamReader(File.OpenRead(configPath));
169 | var config = JObject.Parse(fsr.ReadToEnd());
170 | return config.ToObject();
171 | }
172 | }
173 | }
174 | catch
175 | {
176 | Console.WriteLine("There was a problem loading the configuration");
177 | }
178 |
179 | if (File.Exists(configPath)) File.Delete(configPath);
180 | return GetConfig();
181 | }
182 |
183 | static Options GetConfig()
184 | {
185 | var options = new Options();
186 | Console.WriteLine("Paste in auth token:");
187 | options.Token = Console.ReadLine().Trim();
188 |
189 | Console.WriteLine("Download (y or n):");
190 | string input = Console.ReadLine();
191 | options.Download = input.ToLower().StartsWith('y');
192 |
193 | Console.WriteLine("Delete (y or n):");
194 | input = Console.ReadLine();
195 | options.Delete = input.ToLower().StartsWith('y');
196 |
197 | Console.WriteLine("Upper limit of view count processing (enter 0 for no limit):");
198 | input = Console.ReadLine();
199 | if (int.TryParse(input, out int ul))
200 | {
201 | options.UpperLimit = ul;
202 | }
203 | else
204 | {
205 | options.UpperLimit = 0;
206 | }
207 |
208 | Console.WriteLine("Lower limit of view count processing (enter 0 for no limit):");
209 | input = Console.ReadLine();
210 | if (int.TryParse(input, out int ll))
211 | {
212 | options.LowerLimit = ll;
213 | }
214 | else
215 | {
216 | options.LowerLimit = 0;
217 | }
218 |
219 | Console.WriteLine("Day intervals (amount of days to batch):");
220 | input = Console.ReadLine();
221 | if (int.TryParse(input, out int d))
222 | {
223 | options.DayInterval = d;
224 | }
225 | else
226 | {
227 | options.DayInterval = 7;
228 | }
229 |
230 | options.CurrentDate = DateTime.Now.Date;
231 |
232 | SaveConfig(options);
233 |
234 | return options;
235 | }
236 |
237 | static void SaveConfig(Options options)
238 | {
239 | var configPath = Path.Combine(RootPath, "appsettings.json");
240 | var config = JObject.FromObject(options);
241 |
242 | try
243 | {
244 | using var sw = new StreamWriter(File.OpenWrite(configPath));
245 | sw.Write(config.ToString());
246 | sw.Close();
247 | }
248 | catch
249 | {
250 | Console.WriteLine("There was a problem saving configuration");
251 | }
252 | }
253 |
254 | static void GetUserInfo()
255 | {
256 | try
257 | {
258 | var http = new HttpClient();
259 | http.DefaultRequestHeaders.Add("Client-ID", TwitchClientID);
260 | http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TwitchToken);
261 |
262 | var res = http.GetStringAsync($"https://api.twitch.tv/helix/users").GetAwaiter().GetResult();
263 | var jtok = JToken.Parse(res);
264 | UserId = jtok["data"][0]["id"].ToString();
265 | Login = jtok["data"][0]["login"].ToString();
266 | }
267 | catch (Exception ex)
268 | {
269 | throw new Exception($"[{DateTime.Now}] GetUserInfo failed.", ex);
270 | }
271 | }
272 |
273 | static void GetUserInfo(string login)
274 | {
275 | try
276 | {
277 | var http = new HttpClient();
278 | http.DefaultRequestHeaders.Add("Client-ID", TwitchClientID);
279 | http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TwitchToken);
280 |
281 | var res = http.GetStringAsync($"https://api.twitch.tv/helix/users?login={login}").GetAwaiter().GetResult();
282 | var jtok = JToken.Parse(res);
283 | UserId = jtok["data"][0]["id"].ToString();
284 | Login = jtok["data"][0]["login"].ToString();
285 | }
286 | catch (Exception ex)
287 | {
288 | throw new Exception($"[{DateTime.Now}] GetUserInfo failed.", ex);
289 | }
290 | }
291 |
292 | static string GetClipUri(string clipId)
293 | {
294 | var gql = new JArray
295 | {
296 | new JObject()
297 | {
298 | ["extensions"] = new JObject()
299 | {
300 | ["persistedQuery"] = new JObject()
301 | {
302 | ["version"] = 1,
303 | ["sha256Hash"] = "9bfcc0177bffc730bd5a5a89005869d2773480cf1738c592143b5173634b7d15"
304 | }
305 | },
306 | ["operationName"] = "VideoAccessToken_Clip",
307 | ["variables"] = new JObject()
308 | {
309 | ["slug"] = clipId
310 | }
311 | }
312 | };
313 | var content = gql.ToString(Newtonsoft.Json.Formatting.None);
314 | var http = GetHttpClient();
315 | var result = http.PostAsync("https://gql.twitch.tv/gql", new StringContent(content)).GetAwaiter().GetResult().Content.ReadAsStringAsync().GetAwaiter().GetResult();
316 | var json = JArray.Parse(result);
317 |
318 | var nullcheck = json.SelectToken("[0].data.clip")?.Type == JTokenType.Null;
319 | if (nullcheck)
320 | {
321 | File.AppendAllText(Path.Combine(RootPath, "error.log"), $"[{DateTime.Now}] {clipId} clip missing: payload: {result}" + Environment.NewLine);
322 | throw new Exception("Clip not found");
323 | }
324 |
325 | var sourceUrl = json.SelectToken("[0].data.clip.videoQualities[0].sourceURL")?.ToString();
326 | if (sourceUrl == null)
327 | {
328 | File.AppendAllText(Path.Combine(RootPath, "error.log"), $"[{DateTime.Now}] {clipId} download failed: payload: {result}" + Environment.NewLine);
329 | throw new Exception("Download failed");
330 | }
331 | return sourceUrl;
332 | }
333 |
334 | static void DeleteClips(IList clips)
335 | {
336 | var gql = new JArray
337 | {
338 | new JObject()
339 | {
340 | ["extensions"] = new JObject()
341 | {
342 | ["persistedQuery"] = new JObject()
343 | {
344 | ["version"] = 1,
345 | ["sha256Hash"] = "df142a7eec57c5260d274b92abddb0bd1229dc538341434c90367cf1f22d71c4"
346 | }
347 | },
348 | ["operationName"] = "Clips_DeleteClips",
349 | ["variables"] = new JObject()
350 | {
351 | ["input"] = new JObject()
352 | {
353 | ["slugs"] = new JArray(clips.ToArray())
354 | }
355 | }
356 | }
357 | };
358 | var content = gql.ToString(Newtonsoft.Json.Formatting.None);
359 | var http = GetHttpClient(true);
360 | var result = http.PostAsync("https://gql.twitch.tv/gql", new StringContent(content)).GetAwaiter().GetResult().Content.ReadAsStringAsync().GetAwaiter().GetResult();
361 | var json = JArray.Parse(result);
362 | if (result.Contains("error"))
363 | {
364 | File.AppendAllText(Path.Combine(RootPath, "error.log"), $"[{DateTime.Now}] {string.Join(", ", clips)} deleting failed: payload: {result}" + Environment.NewLine);
365 | throw new Exception("Delete Clips Failed");
366 | }
367 | }
368 |
369 | static void DownloadClip(string sourceUrl, string savePath)
370 | {
371 | try
372 | {
373 | using var http = new HttpClient();
374 | var stream = http.GetStreamAsync(sourceUrl).GetAwaiter().GetResult();
375 | if (File.Exists(savePath)) File.Delete(savePath);
376 | using var fs = new FileStream(savePath, FileMode.CreateNew);
377 | stream.CopyTo(fs);
378 | fs.Close();
379 | }
380 | catch (Exception ex)
381 | {
382 | throw new Exception($"[{DateTime.Now}] DownloadClip: There was a problem downloading {sourceUrl} to {savePath}", ex);
383 | }
384 | }
385 |
386 | static string SanitizeFile(string origFileName)
387 | {
388 | var invalids = Path.GetInvalidFileNameChars();
389 | return string.Join("_", origFileName.Split(invalids, StringSplitOptions.RemoveEmptyEntries)).TrimEnd('.');
390 | }
391 |
392 | static HttpClient GetHttpClient(bool authorize = false)
393 | {
394 | var http = new HttpClient();
395 | http.DefaultRequestHeaders.Add("Client-ID", TwitchClientID);
396 | if (authorize)
397 | http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", TwitchToken);
398 | return http;
399 | }
400 |
401 | #region "Unused"
402 |
403 | static IList GetClipsApi(string userId, DateTime start, int days, ref string cursor)
404 | {
405 | var http = new HttpClient();
406 | http.DefaultRequestHeaders.Add("Client-ID", TwitchClientID);
407 | http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TwitchToken);
408 |
409 | var end = start.AddDays(days);
410 | var uri = new UriBuilder($"https://api.twitch.tv/helix/clips");
411 | uri.Query = $"?broadcaster_id={userId}&first={100}&started_at={start:s}Z&ended_at={end:s}Z";
412 | if (!string.IsNullOrWhiteSpace(cursor))
413 | uri.Query += $"&after={cursor}";
414 | var result = http.GetAsync(uri.Uri).GetAwaiter().GetResult().Content.ReadAsStringAsync().GetAwaiter().GetResult();
415 | Thread.Sleep(1000);
416 | var json = JToken.Parse(result);
417 |
418 | cursor = json.SelectToken("pagination.cursor")?.ToString();
419 | return json.SelectToken("data")?.ToObject>();
420 | }
421 |
422 | static IList GetFirstClip(string login)
423 | {
424 | Console.WriteLine($"Getting First Clip");
425 | var gql = new JArray()
426 | {
427 | new JObject()
428 | {
429 | ["extensions"] = new JObject()
430 | {
431 | ["persistedQuery"] = new JObject()
432 | {
433 | ["version"] = 1,
434 | ["sha256Hash"] = "b73ad2bfaecfd30a9e6c28fada15bd97032c83ec77a0440766a56fe0bd632777"
435 | }
436 | },
437 | ["operationName"] = "ClipsCards__User",
438 | ["variables"] = new JObject()
439 | {
440 | ["login"] = login,
441 | ["limit"] = 1,
442 | ["criteria"] = new JObject()
443 | {
444 | ["sort"] = "CREATED_AT_ASC",
445 | ["filter"] = "ALL_TIME"
446 | }
447 | }
448 | }
449 | };
450 |
451 | var content = gql.ToString(Newtonsoft.Json.Formatting.None);
452 | var ghttp = new HttpClient();
453 | ghttp.DefaultRequestHeaders.Add("Client-ID", TwitchClientID);
454 | ghttp.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", TwitchToken);
455 |
456 | var result = ghttp.PostAsync("https://gql.twitch.tv/gql", new StringContent(content)).GetAwaiter().GetResult().Content.ReadAsStringAsync().GetAwaiter().GetResult();
457 | var json = JToken.Parse(result);
458 | var clips = new List();
459 | var edges = json.SelectToken("[0].data.user.clips.edges");
460 | if (edges == null)
461 | {
462 | File.AppendAllText(Path.Combine(RootPath, "error.log"), $"[{DateTime.Now}] getting clips failed: payload: {result}" + Environment.NewLine);
463 | }
464 | foreach (var edge in edges)
465 | {
466 | clips.Add(new ClipInfo
467 | {
468 | id = edge.SelectToken("node.slug")?.ToString(),
469 | title = edge.SelectToken("node.title")?.ToString(),
470 | creator_name = edge.SelectToken("node.curator.login")?.ToString(),
471 | created_at = edge.SelectToken("node.createdAt")?.ToString(),
472 | view_count = edge.SelectToken("node.viewCount")?.ToObject() ?? 0
473 | });
474 | }
475 | return clips;
476 | }
477 |
478 | #endregion
479 | }
480 | }
481 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sorry!
2 |
3 | Twitch deprecated the api's used in this project, and there's no need for the functionality anymore. I only made this out of a need and don't have any plans to update it or work on it. Code will be left up for educational purposes.
4 |
--------------------------------------------------------------------------------