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