diff --git a/LICENSE.txt b/LICENSE.txt
index 30832319..eaff5a43 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,6 +1,6 @@
The MIT License
-Copyright (c) 2019 lay295
+Copyright (c) lay295
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 4f6cf979..adfde98d 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@
-[**Readme in Spanish**](README_es.md)
+[**Readme in Spanish**](README_es.md)
+[**Readme in Turkish**](README_tr.md)
## Chat Render Example
@@ -159,6 +160,7 @@ cd TwitchDownloader
```
dotnet restore
```
+- Non-Windows devices may need to explicitly specify a project to restore, i.e. `dotnet restore TwitchDownloaderCLI`
4. a) Build the GUI:
```
dotnet publish TwitchDownloaderWPF -p:PublishProfile=Windows -p:DebugType=None -p:DebugSymbols=false
diff --git a/README_tr.md b/README_tr.md
new file mode 100644
index 00000000..0b2cb7cf
--- /dev/null
+++ b/README_tr.md
@@ -0,0 +1,198 @@
+
+
+
+
+
+
Twitch İndirici
+
+
+ Twitch VOD/Clip/Chat İndirici ve Chat Oynatıcı
+
+
+ Hata Bildir
+
+
+
+[**İspanyolca'da Oku**](README_es.md)
+[**İngilizce'de Oku**](README.md)
+
+## Chat Oynatma Örneği
+
+https://user-images.githubusercontent.com/1060681/197653099-c3fd12c2-f03a-4580-84e4-63ce3f36be8d.mp4
+
+## Neler Yapabilir?
+
+- Twitch VOD'larını İndir
+- Twitch Kliplerini İndir
+- VOD'lar ve Klipler için sohbeti, ya [tüm orijinal bilgileri içeren bir JSON olarak](https://pastebin.com/raw/YDgRe6X4), bir tarayıcı HTML dosyası olarak ya da [düz metin dosyası olarak](https://pastebin.com/raw/016azeQX) indirin.
+- Daha önce oluşturulmuş bir JSON sohbet dosyasının içeriğini güncelleyin ve başka bir biçimde kaydetme seçeneğiyle kaydedin.
+- Daha önce oluşturulmuş bir JSON sohbet dosyasını kullanarak sohbeti Twitter Twemoji veya Google Noto Color emojileri ve BTTV, FFZ, 7TV statik ve animasyonlu emojilerle oynatmak için kullanın.
+
+# GUI
+
+## Windows WPF
+
+![](https://i.imgur.com/bLegxGX.gif)
+
+### [Full WPF belgelerini buradan görüntüleyin](TwitchDownloaderWPF/README.md).
+
+### İşlevsellik
+
+Windows WPF GUI, programın tüm ana işlevlerini ve bazı ek yaşam kalitesi işlevlerini uygular:
+- Aynı anda çalıştırılacak birden fazla indirme/oynatma işini sıraya alın.
+- VOD/Klip bağlantıları listesinden indirme işlerinin bir listesini oluşturun.
+- Uygulamayı terk etmeden herhangi bir yayıcıdan birden fazla VOD/klip arayın ve indirin.
+
+### Çoklu Dil Desteği
+
+Windows WPF GUI, topluluk çevirileri sayesinde birçok dilde kullanılabilir. Daha fazla ayrıntı için [WPF README](TwitchDownloaderWPF/README.md)'nin [Yerelleştirme bölümüne](TwitchDownloaderWPF/README.md#localization) bakın.
+
+### Temalar
+
+Windows WPF GUI, hem açık hem de karanlık temalar ile gelir ve mevcut Windows temasına göre canlı olarak güncelleme seçeneği sunar. Ayrıca kullanıcı tarafından oluşturulan temaları destekler! Daha fazla ayrıntı için [WPF README](TwitchDownloaderWPF/README.md)'nin [Tema bölümüne](TwitchDownloaderWPF/README.md#theming) bakın.
+
+### Video Gösterimi
+
+https://www.youtube.com/watch?v=0W3MhfhnYjk
+(eski sürüm, aynı konsept)
+
+## Linux?
+
+***Nasıl cevireceğimi bilemedim terminal versionu var [githubda](https://github.com/mohad12211/twitch-downloader-gui) gidin ona bakın diyor kısaca birde [AUR'da](https://aur.archlinux.org/packages/twitch-downloader-gui) terminalin biraz süslü gui hali var ona bakabilirsniiz diyor.
+
+## MacOS?
+
+Malesef MacOS için henüz bir GUI mevcut değil :(
+
+# CLI
+
+### [Tüm CLI belgelerini buradan inceleyin](TwitchDownloaderCLI/README.md).
+
+CLI, ana program işlevlerini uygulayan ve Windows, Linux ve MacOS* üzerinde çalışan çapraz platformlu bir araçtır.
+
+*Sadece Intel Mac'ler test edilmiştir
+
+CLI ile, harici komut dosyalarını kullanarak video işleme işlemini otomatikleştirmek mümkündür. Örneğin, Windows'ta bir `.bat` dosyasına aşağıdaki kodu kopyalayarak bir VOD'u ve onun sohbetini indirebilir ve ardından sohbeti renderlayabilirsiniz.
+```bat
+@echo off
+set /p vodid="VOD Kimliğini Girin: "
+TwitchDownloaderCLI.exe videodownload --id %vodid% --ffmpeg-path "ffmpeg.exe" -o %vodid%.mp4
+TwitchDownloaderCLI.exe chatdownload --id %vodid% -o %vodid%_chat.json -E
+TwitchDownloaderCLI.exe chatrender -i %vodid%_chat.json -h 1080 -w 422 --framerate 30 --update-rate 0 --font-size 18 -o %vodid%_chat.mp4
+```
+
+## Windows - Başlangıç
+
+1. [Releases-Sürümler](https://github.com/lay295/TwitchDownloader/releases/) sayfasına gidin ve en son Windows sürümünü indirin veya [kaynaktan derleyin.](#building-from-source).
+2. `TwitchDownloaderCLI.exe`'yi çıkartın.
+3. Dosyayı çıkardığınız yerde terminal açın.
+4. FFmpeg'e sahip değilseniz,[Chocolatey package manager](https://community.chocolatey.org/) aracılığı ile indirebilir veya bağımsız bir dosya olarak [ffmpeg.org](https://ffmpeg.org/download.html) adresinden alabilir veya TwitchDownloaderCLI kullanarak alabilirsiniz. Şu komutu kullanarak indirebilirsiniz:
+```
+TwitchDownloaderCLI.exe ffmpeg --download
+```
+5. Artık indirme işlemine başlayabilirsiniz, örneğin:
+```
+TwitchDownloaderCLI.exe videodownload --id -o out.mp4
+```
+
+## Linux – Başlangıç
+
+1. Bazı dağıtımlar, Linux Alpine gibi, bazı diller için (Arapça, Farsça, Tayca vb.) yazı tiplerini eksik bulabilir. Bu durum sizin için geçerliyse, [Noto](https://fonts.google.com/noto/specimen/Noto+Sans) gibi ek yazı tipleri ailesi yükleyin veya dağıtımınızın yazı tipleri hakkındaki wiki sayfasını kontrol edin, çünkü bu özel senaryo için bir kurulum komutuna sahip olabilir, örneğin [Linux Alpine](https://wiki.alpinelinux.org/wiki/Fonts) yazı tipi sayfası gibi.
+2. `fontconfig` ve `libfontconfig1`'in yüklü olduğundan emin olun. Ubuntu'da `apt-get install fontconfig libfontconfig1` kullanabilirsiniz.
+3. [Sürümler](https://github.com/lay295/TwitchDownloader/releases/) sayfasına gidin ve Linux için en son ikili sürümü indirin, Arch Linux için [AUR Paketi](https://aur.archlinux.org/packages/twitch-downloader-bin/)ni alın veya [kaynaktan derleyin](#building-from-source).
+4. `TwitchDownloaderCLI`'yi çıkarın.
+5. Dosyayı çıkardığınız yere gidin ve terminalde çalıştırılabilir izinleri verin:
+```
+sudo chmod +x TwitchDownloaderCLI
+```
+6. a) Eğer FFmpeg'e sahip değilseniz, bunu dağıtım paket yöneticiniz aracılığıyla kurmalısınız. Ayrıca, [ffmpeg.org](https://ffmpeg.org/download.html) adresinden bağımsız bir dosya olarak veya TwitchDownloaderCLI kullanarak da edinebilirsiniz.
+```
+./TwitchDownloaderCLI ffmpeg --download
+```
+6. b) Bağımsız bir dosya olarak indirildiyse, ona çalıştırılabilir izinler vermelisiniz:
+```
+sudo chmod +x ffmpeg
+```
+7. Şimdi indiriciyi kullanmaya başlayabilirsiniz, örneğin:
+```
+./TwitchDownloaderCLI videodownload --id -o out.mp4
+```
+## MacOS – Başlangıç
+1. [Releases](https://github.com/lay295/TwitchDownloader/releases/) sayfasına gidin ve MacOS için en son sürümü indirin veya kaynaktan derleyin.
+2. `TwitchDownloaderCLI` dosyasını çıkarın.
+3. Dosyayı çıkardığınız yere terminalde çalıştırılabilir izinler verin.
+```
+chmod +x TwitchDownloaderCLI
+```
+4. a) Eğer FFmpeg'e sahip değilseniz, [Homebrew paket yöneticisi](https://brew.sh/) aracılığıyla kurabilirsiniz veya bağımsız bir dosya olarak [ffmpeg.org](https://ffmpeg.org/download.html) adresinden veya TwitchDownloaderCLI kullanarak edinebilirsiniz.
+```
+./TwitchDownloaderCLI ffmpeg --download
+```
+4. b) Bağımsız bir dosya olarak indirildiyse, ona çalıştırılabilir izinler vermelisiniz.
+```
+chmod +x ffmpeg
+```
+5. Şimdi indiriciyi kullanmaya başlayabilirsiniz, örneğin:
+```
+./TwitchDownloaderCLI videodownload --id -o out.mp4
+```
+
+# Kaynaktan derleme
+
+## Gereksinimler
+
+- [.NET 6.0.x SDK](https://dotnet.microsoft.com/en-us/download/dotnet/6.0)
+
+## Derleme Talimatları
+
+1. Depoyu klonlayın:
+```
+git clone https://github.com/lay295/TwitchDownloader.git
+```
+2. Çözüm klasörüne gidin:
+```
+cd TwitchDownloader
+```
+3. Çözümü geri yükleyin:
+```
+dotnet restore
+```
+4. a) GUI'yi oluşturun:
+```
+dotnet publish TwitchDownloaderWPF -p:PublishProfile=Windows -p:DebugType=None -p:DebugSymbols=false
+```
+4. b) CLI'yi oluşturun:
+```
+dotnet publish TwitchDownloaderCLI -p:PublishProfile= -p:DebugType=None -p:DebugSymbols=false
+```
+- Uygulanabilir Profiller: `Windows`, `Linux`, `LinuxAlpine`, `LinuxArm`, `LinuxArm64`, `MacOS`
+5. a) GUI derleme klasörüne gidin:
+```
+cd TwitchDownloaderWPF/bin/Release/net6.0-windows/publish/win-x64
+```
+5. b) CLI derleme klasörüne gidin:
+```
+cd TwitchDownloaderCLI/bin/Release/net6.0/publish
+```
+
+# Lisans
+
+[MIT](./LICENSE.txt)
+
+# Üçüncü Taraf Kredileri
+
+Sohbet Görüntülemeleri, [SkiaSharp ve HarfBuzzSharp](https://github.com/mono/SkiaSharp) tarafından oluşturulmuştur © Microsoft Corporation.
+
+Sohbet Görüntülemeleri işlenmesi ve Video İndirmeleri [FFmpeg](https://ffmpeg.org/) ile sonlandırılır © FFmpeg geliştiricileri.
+
+Sohbet Görüntülemeleri, [Noto Renkli Emoji](https://github.com/googlefonts/noto-emoji) tarafından kullanılabilir © Google ve katkıda bulunanlar.
+
+Sohbet Görüntülemeleri, [Twemoji](https://github.com/twitter/twemoji) tarafından kullanılabilir © Twitter ve katkıda bulunanlar.
+
+Paketlenmiş FFmpeg ikili dosyaları [gyan.dev](https://www.gyan.dev/ffmpeg/) adresinden alınmıştır © Gyan Doshi.
+
+Alınan FFmpeg ikili dosyaları çalışma zamanında [Xabe.FFmpeg.Downloader](https://github.com/tomaszzmuda/Xabe.FFmpeg) kullanılarak indirilir © Xabe.
+
+Sohbet HTML dışa aktarmaları, [Google Fonts API](https://fonts.google.com/) tarafından barındırılan _Inter_ yazı tipini kullanır © Google.
+
+Kullanılan tüm harici kütüphanelerin tam listesi için [THIRD-PARTY-LICENSES.txt](./TwitchDownloaderCore/Resources/THIRD-PARTY-LICENSES.txt) dosyasına bakınız.
diff --git a/TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs
index 2b30d6fe..95d1ea76 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs
@@ -2,7 +2,6 @@
namespace TwitchDownloaderCLI.Modes.Arguments
{
-
[Verb("cache", HelpText = "Manage the working cache")]
public class CacheArgs : ITwitchDownloaderArgs
{
diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs
index 66add8ca..8ae97aaa 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs
@@ -1,9 +1,9 @@
using CommandLine;
using TwitchDownloaderCore.Chat;
+using TwitchDownloaderCore.Tools;
namespace TwitchDownloaderCLI.Modes.Arguments
{
-
[Verb("chatdownload", HelpText = "Downloads the chat from a VOD or clip")]
public class ChatDownloadArgs : ITwitchDownloaderArgs
{
@@ -21,16 +21,16 @@ public class ChatDownloadArgs : ITwitchDownloaderArgs
[Option('e', "ending", HelpText = "Time in seconds to crop ending.")]
public double CropEndingTime { get; set; }
-
+
[Option('E', "embed-images", Default = false, HelpText = "Embed first party emotes, badges, and cheermotes into the chat download for offline rendering.")]
public bool EmbedData { get; set; }
[Option("bttv", Default = true, HelpText = "Enable BTTV embedding in chat download. Requires -E / --embed-images!")]
public bool? BttvEmotes { get; set; }
-
+
[Option("ffz", Default = true, HelpText = "Enable FFZ embedding in chat download. Requires -E / --embed-images!")]
public bool? FfzEmotes { get; set; }
-
+
[Option("stv", Default = true, HelpText = "Enable 7TV embedding in chat download. Requires -E / --embed-images!")]
public bool? StvEmotes { get; set; }
diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs
index 6ba9254e..b49a1e7d 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs
@@ -2,7 +2,6 @@
namespace TwitchDownloaderCLI.Modes.Arguments
{
-
[Verb("chatrender", HelpText = "Renders a chat JSON as a video")]
public class ChatRenderArgs : ITwitchDownloaderArgs
{
diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs
index bec5865e..507291cb 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs
@@ -1,9 +1,8 @@
using CommandLine;
-using TwitchDownloaderCore.Chat;
+using TwitchDownloaderCore.Tools;
namespace TwitchDownloaderCLI.Modes.Arguments
{
-
[Verb("chatupdate", HelpText = "Updates the embedded emotes, badges, bits, and crops of a chat download and/or converts a JSON chat to another format.")]
public class ChatUpdateArgs : ITwitchDownloaderArgs
{
diff --git a/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs
index b34c69da..6f07041c 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs
@@ -2,7 +2,6 @@
namespace TwitchDownloaderCLI.Modes.Arguments
{
-
[Verb("clipdownload", HelpText = "Downloads a clip from Twitch")]
public class ClipDownloadArgs : ITwitchDownloaderArgs
{
diff --git a/TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs
index 1979b8c9..8abe5d64 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs
@@ -2,7 +2,6 @@
namespace TwitchDownloaderCLI.Modes.Arguments
{
-
[Verb("ffmpeg", HelpText = "Manage standalone ffmpeg")]
public class FfmpegArgs : ITwitchDownloaderArgs
{
diff --git a/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs
index fe4e09fb..33de037e 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs
@@ -2,7 +2,6 @@
namespace TwitchDownloaderCLI.Modes.Arguments
{
-
[Verb("videodownload", HelpText = "Downloads a stream VOD from Twitch")]
public class VideoDownloadArgs : ITwitchDownloaderArgs
{
diff --git a/TwitchDownloaderCLI/Modes/DownloadChat.cs b/TwitchDownloaderCLI/Modes/DownloadChat.cs
index 9aa04edc..2b18b5c9 100644
--- a/TwitchDownloaderCLI/Modes/DownloadChat.cs
+++ b/TwitchDownloaderCLI/Modes/DownloadChat.cs
@@ -1,14 +1,12 @@
using System;
using System.IO;
-using System.Text.RegularExpressions;
using System.Threading;
using TwitchDownloaderCLI.Modes.Arguments;
-using TwitchDownloaderCLI.Tools;
using TwitchDownloaderCore;
using TwitchDownloaderCore.Chat;
using TwitchDownloaderCore.Options;
+using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Interfaces;
-using TwitchDownloaderCore.VideoPlatforms.Twitch.Downloaders;
namespace TwitchDownloaderCLI.Modes
{
@@ -32,9 +30,7 @@ private static ChatDownloadOptions GetDownloadOptions(ChatDownloadArgs inputOpti
Environment.Exit(1);
}
- var vodClipIdRegex = new Regex(@"(?<=^|(?:clips\.)?twitch\.tv\/(?:videos|\S+\/clip)?\/?)[\w-]+?(?=$|\?)");
- var vodClipIdMatch = vodClipIdRegex.Match(inputOptions.Id);
- if (!vodClipIdMatch.Success)
+ if (!UrlParse.TryParseVideoOrClipId(inputOptions.Id, out var videoPlatform, out var videoType, out var videoId))
{
Console.WriteLine("[ERROR] - Unable to parse Vod/Clip ID/URL.");
Environment.Exit(1);
@@ -51,7 +47,9 @@ private static ChatDownloadOptions GetDownloadOptions(ChatDownloadArgs inputOpti
".txt" or ".text" or "" => ChatFormat.Text,
_ => throw new NotSupportedException($"{fileExtension} is not a valid chat file extension.")
},
- Id = vodClipIdMatch.Value,
+ Id = videoId,
+ VideoPlatform = videoPlatform,
+ VideoType = videoType,
CropBeginning = inputOptions.CropBeginningTime > 0.0,
CropBeginningTime = inputOptions.CropBeginningTime,
CropEnding = inputOptions.CropEndingTime > 0.0,
diff --git a/TwitchDownloaderCLI/Modes/DownloadClip.cs b/TwitchDownloaderCLI/Modes/DownloadClip.cs
index 8b349be8..f8a5dc36 100644
--- a/TwitchDownloaderCLI/Modes/DownloadClip.cs
+++ b/TwitchDownloaderCLI/Modes/DownloadClip.cs
@@ -1,6 +1,5 @@
using System;
using System.IO;
-using System.Text.RegularExpressions;
using System.Threading;
using TwitchDownloaderCLI.Modes.Arguments;
using TwitchDownloaderCLI.Tools;
@@ -38,8 +37,7 @@ private static ClipDownloadOptions GetDownloadOptions(ClipDownloadArgs inputOpti
Environment.Exit(1);
}
- bool success = UrlParse.TryParseClip(inputOptions.Id, out VideoPlatform videoPlatform, out string videoId);
- if (!success)
+ if (!UrlParse.TryParseClip(inputOptions.Id, out var videoPlatform, out var videoId))
{
Console.WriteLine("[ERROR] - Unable to parse Clip ID/URL.");
Environment.Exit(1);
diff --git a/TwitchDownloaderCLI/Modes/DownloadVideo.cs b/TwitchDownloaderCLI/Modes/DownloadVideo.cs
index 52cb9135..ed2595c9 100644
--- a/TwitchDownloaderCLI/Modes/DownloadVideo.cs
+++ b/TwitchDownloaderCLI/Modes/DownloadVideo.cs
@@ -1,13 +1,12 @@
using System;
using System.IO;
-using System.Text.RegularExpressions;
using System.Threading;
using TwitchDownloaderCLI.Modes.Arguments;
using TwitchDownloaderCLI.Tools;
using TwitchDownloaderCore;
using TwitchDownloaderCore.Options;
+using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Interfaces;
-using TwitchDownloaderCore.VideoPlatforms.Twitch.Downloaders;
namespace TwitchDownloaderCLI.Modes
{
@@ -34,9 +33,7 @@ private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOp
Environment.Exit(1);
}
- var vodIdRegex = new Regex(@"(?<=^|twitch\.tv\/videos\/)\d+(?=$|\?)");
- var vodIdMatch = vodIdRegex.Match(inputOptions.Id);
- if (!vodIdMatch.Success)
+ if (UrlParse.TryParseVod(inputOptions.Id, out var videoPlatform, out var videoId))
{
Console.WriteLine("[ERROR] - Unable to parse Vod ID/URL.");
Environment.Exit(1);
@@ -46,7 +43,8 @@ private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOp
{
DownloadThreads = inputOptions.DownloadThreads,
ThrottleKib = inputOptions.ThrottleKib,
- Id = vodIdMatch.ValueSpan.ToString(),
+ Id = videoId,
+ VideoPlatform = videoPlatform,
Oauth = inputOptions.Oauth,
Filename = inputOptions.OutputFile,
Quality = inputOptions.Quality,
diff --git a/TwitchDownloaderCLI/Modes/UpdateChat.cs b/TwitchDownloaderCLI/Modes/UpdateChat.cs
index a4a4e213..926e92e6 100644
--- a/TwitchDownloaderCLI/Modes/UpdateChat.cs
+++ b/TwitchDownloaderCLI/Modes/UpdateChat.cs
@@ -4,8 +4,8 @@
using TwitchDownloaderCLI.Modes.Arguments;
using TwitchDownloaderCLI.Tools;
using TwitchDownloaderCore;
-using TwitchDownloaderCore.Chat;
using TwitchDownloaderCore.Options;
+using TwitchDownloaderCore.Tools;
namespace TwitchDownloaderCLI.Modes
{
@@ -54,7 +54,7 @@ private static ChatUpdateOptions GetUpdateOptions(ChatUpdateArgs inputOptions)
if (Path.GetFullPath(inputOptions.InputFile!) == Path.GetFullPath(inputOptions.OutputFile!))
{
- Console.WriteLine("[WARNING] - Output file path is identical to input file. This is not recommended in case something goes wrong. All data will be permanantly overwritten!");
+ Console.WriteLine("[WARNING] - Output file path is identical to input file. This is not recommended in case something goes wrong. All data will be permanently overwritten!");
}
ChatUpdateOptions updateOptions = new()
diff --git a/TwitchDownloaderCLI/Program.cs b/TwitchDownloaderCLI/Program.cs
index 63cbf55f..642830ba 100644
--- a/TwitchDownloaderCLI/Program.cs
+++ b/TwitchDownloaderCLI/Program.cs
@@ -12,12 +12,10 @@
namespace TwitchDownloaderCLI
{
- class Program
+ internal static class Program
{
private static void Main(string[] args)
{
- // Set the working dir to the app dir in case we inherited a different working dir
- Directory.SetCurrentDirectory(AppContext.BaseDirectory);
Environment.SetEnvironmentVariable("CURL_IMPERSONATE", "chrome110");
var preParsedArgs = PreParseArgs.Parse(args, Path.GetFileName(Environment.ProcessPath));
diff --git a/TwitchDownloaderCLI/Tools/FfmpegHandler.cs b/TwitchDownloaderCLI/Tools/FfmpegHandler.cs
index 003628ab..7a4bd379 100644
--- a/TwitchDownloaderCLI/Tools/FfmpegHandler.cs
+++ b/TwitchDownloaderCLI/Tools/FfmpegHandler.cs
@@ -1,7 +1,10 @@
using Mono.Unix;
using System;
+using System.Collections.Concurrent;
using System.IO;
+using System.Linq;
using System.Runtime.InteropServices;
+using System.Threading;
using TwitchDownloaderCLI.Modes.Arguments;
using Xabe.FFmpeg;
using Xabe.FFmpeg.Downloader;
@@ -24,8 +27,7 @@ private static void DownloadFfmpeg()
{
Console.Write("[INFO] - Downloading FFmpeg");
- var progressHandler = new Progress();
- progressHandler.ProgressChanged += XabeProgressHandler.OnProgressReceived;
+ using var progressHandler = new XabeProgressHandler();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
@@ -62,14 +64,42 @@ public static void DetectFfmpeg(string ffmpegPath)
Console.WriteLine("[ERROR] - Unable to find FFmpeg, exiting. You can download FFmpeg automatically with the command \"TwitchDownloaderCLI ffmpeg -d\"");
Environment.Exit(1);
}
- }
- internal static class XabeProgressHandler
- {
- internal static void OnProgressReceived(object sender, ProgressInfo e)
+ private sealed class XabeProgressHandler : IProgress, IDisposable
{
- var percent = (int)(e.DownloadedBytes / (double)e.TotalBytes * 100);
- Console.Write($"\r[INFO] - Downloading FFmpeg {percent}%");
+ private int _lastPercent = -1;
+ private readonly ConcurrentQueue _percentQueue = new();
+ private readonly Timer _timer;
+
+ public XabeProgressHandler()
+ {
+ _timer = new Timer(Callback, _percentQueue, 0, 100);
+
+ static void Callback(object state)
+ {
+ if (state is not ConcurrentQueue { IsEmpty: false } queue) return;
+
+ var currentPercent = queue.Max();
+ Console.Write($"\r[INFO] - Downloading FFmpeg {currentPercent}%");
+ }
+ }
+
+ public void Report(ProgressInfo value)
+ {
+ var percent = (int)(value.DownloadedBytes / (double)value.TotalBytes * 100);
+
+ if (percent > _lastPercent)
+ {
+ _lastPercent = percent;
+ _percentQueue.Enqueue(percent);
+ }
+ }
+
+ public void Dispose()
+ {
+ _timer?.Dispose();
+ _percentQueue.Clear();
+ }
}
}
}
\ No newline at end of file
diff --git a/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj b/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj
index b31c83b3..0be045d2 100644
--- a/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj
+++ b/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj
@@ -2,8 +2,8 @@
Exe
- 1.53.2
- Copyright © 2019 lay295 and contributors
+ 1.53.4
+ Copyright © lay295 and contributors
Download and render Twitch VODs, clips, and chats
MIT
AnyCPU;x64
@@ -16,7 +16,6 @@
-
diff --git a/TwitchDownloaderCore/Chat/ChatCompression.cs b/TwitchDownloaderCore/Chat/ChatCompression.cs
deleted file mode 100644
index 1f6bf04c..00000000
--- a/TwitchDownloaderCore/Chat/ChatCompression.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace TwitchDownloaderCore.Chat
-{
- // TODO: Add Bzip2 and possibly 7Zip support
- public enum ChatCompression
- {
- None,
- Gzip
- }
-}
diff --git a/TwitchDownloaderCore/Chat/ChatFormat.cs b/TwitchDownloaderCore/Chat/ChatFormat.cs
deleted file mode 100644
index 814e49e8..00000000
--- a/TwitchDownloaderCore/Chat/ChatFormat.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace TwitchDownloaderCore.Chat
-{
- public enum ChatFormat
- {
- Json,
- Text,
- Html
- }
-}
diff --git a/TwitchDownloaderCore/Chat/ChatHtml.cs b/TwitchDownloaderCore/Chat/ChatHtml.cs
index 7b5d06a6..0255b9a4 100644
--- a/TwitchDownloaderCore/Chat/ChatHtml.cs
+++ b/TwitchDownloaderCore/Chat/ChatHtml.cs
@@ -88,7 +88,7 @@ public static class ChatHtml
private static async Task BuildThirdPartyDictionary(ChatRoot chatRoot, bool embedData, Dictionary thirdEmoteData, CancellationToken cancellationToken)
{
- EmoteResponse emotes = await TwitchHelper.GetThirdPartyEmoteData(chatRoot.streamer.id, true, true, true, true, cancellationToken);
+ EmoteResponse emotes = await TwitchHelper.GetThirdPartyEmotesMetadata(chatRoot.streamer.id, true, true, true, true, cancellationToken);
List itemList = new();
itemList.AddRange(emotes.BTTV);
itemList.AddRange(emotes.FFZ);
diff --git a/TwitchDownloaderCore/Chat/ChatJson.cs b/TwitchDownloaderCore/Chat/ChatJson.cs
index b1704471..5bb6bcca 100644
--- a/TwitchDownloaderCore/Chat/ChatJson.cs
+++ b/TwitchDownloaderCore/Chat/ChatJson.cs
@@ -1,8 +1,10 @@
using System;
+using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
+using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
@@ -10,6 +12,7 @@
using TwitchDownloaderCore.Extensions;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Twitch;
+using TwitchDownloaderCore.VideoPlatforms.Twitch.Downloaders;
namespace TwitchDownloaderCore.Chat
{
@@ -25,7 +28,9 @@ public static class ChatJson
/// Asynchronously deserializes a chat json file.
///
/// A representation the deserialized chat json file.
- public static async Task DeserializeAsync(string filePath, bool getComments = true, bool getEmbeds = true, CancellationToken cancellationToken = new())
+ /// The file does not exist.
+ /// The file is not a valid chat format.
+ public static async Task DeserializeAsync(string filePath, bool getComments = true, bool onlyFirstAndLastComments = false, bool getEmbeds = true, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(filePath, nameof(filePath));
@@ -41,20 +46,9 @@ public static class ChatJson
AllowTrailingCommas = true
};
- await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
- switch (Path.GetExtension(filePath).ToLower())
+ await using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
- case ".gz":
- await using (var gs = new GZipStream(fs, CompressionMode.Decompress))
- {
- jsonDocument = await JsonDocument.ParseAsync(gs, deserializationOptions, cancellationToken);
- }
- break;
- case ".json":
- jsonDocument = await JsonDocument.ParseAsync(fs, deserializationOptions, cancellationToken);
- break;
- default:
- throw new NotSupportedException(Path.GetFileName(filePath) + " is not a valid chat format");
+ jsonDocument = await GetJsonDocumentAsync(fs, filePath, deserializationOptions, cancellationToken);
}
if (jsonDocument.RootElement.TryGetProperty("FileInfo", out JsonElement fileInfoElement))
@@ -64,7 +58,20 @@ public static class ChatJson
if (jsonDocument.RootElement.TryGetProperty("streamer", out JsonElement streamerElement))
{
- returnChatRoot.streamer = streamerElement.Deserialize(options: _jsonSerializerOptions);
+ if (returnChatRoot.FileInfo.Version > new ChatRootVersion(1, 0, 0))
+ {
+ returnChatRoot.streamer = streamerElement.Deserialize(options: _jsonSerializerOptions);
+ }
+ else
+ {
+ var legacyStreamer = streamerElement.Deserialize(options: _jsonSerializerOptions);
+ returnChatRoot.streamer = legacyStreamer.id.ValueKind switch
+ {
+ JsonValueKind.Number => new Streamer { name = legacyStreamer.name, id = legacyStreamer.id.GetInt32() },
+ JsonValueKind.String => new Streamer { name = legacyStreamer.name, id = int.Parse(legacyStreamer.id.GetString()!) },
+ _ => null // Fallback to UpgradeChatJson()
+ };
+ }
}
if (jsonDocument.RootElement.TryGetProperty("video", out JsonElement videoElement))
@@ -81,7 +88,9 @@ public static class ChatJson
{
if (jsonDocument.RootElement.TryGetProperty("comments", out JsonElement commentsElement))
{
- returnChatRoot.comments = commentsElement.Deserialize>(options: _jsonSerializerOptions);
+ returnChatRoot.comments = onlyFirstAndLastComments
+ ? commentsElement.DeserializeFirstAndLastFromList(options: _jsonSerializerOptions)
+ : commentsElement.Deserialize>(options: _jsonSerializerOptions);
}
}
@@ -120,7 +129,66 @@ public static class ChatJson
return returnChatRoot;
}
- private static async ValueTask UpgradeChatJson(ChatRoot chatRoot)
+ private static async Task GetJsonDocumentAsync(Stream stream, string filePath, JsonDocumentOptions deserializationOptions, CancellationToken cancellationToken = default)
+ {
+ if (!stream.CanSeek)
+ {
+ // We aren't able to verify the file type. Pretend it's JSON.
+ return await JsonDocument.ParseAsync(stream, deserializationOptions, cancellationToken);
+ }
+
+ const int RENT_LENGTH = 4;
+ var rentedBuffer = ArrayPool.Shared.Rent(RENT_LENGTH);
+ try
+ {
+ if (await stream.ReadAsync(rentedBuffer.AsMemory(0, RENT_LENGTH), cancellationToken) != RENT_LENGTH)
+ {
+ throw new EndOfStreamException($"{Path.GetFileName(filePath)} is not a valid chat format.");
+ }
+
+ stream.Seek(-RENT_LENGTH, SeekOrigin.Current);
+
+ // TODO: use list patterns when .NET 7+
+ // https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
+ switch (rentedBuffer[0], rentedBuffer[1], rentedBuffer[2], rentedBuffer[3])
+ {
+ case (0x1F, 0x8B, _, _): // https://docs.fileformat.com/compression/gz/#gz-file-header
+ {
+ await using var gs = new GZipStream(stream, CompressionMode.Decompress);
+ return await GetJsonDocumentAsync(gs, filePath, deserializationOptions, cancellationToken);
+ }
+ case (0x00, 0x00, 0xFE, 0xFF): // UTF-32 BE
+ case (0xFF, 0xFE, 0x00, 0x00): // UTF-32 LE
+ {
+ using var sr = new StreamReader(stream, Encoding.UTF32);
+ var jsonString = await sr.ReadToEndAsync();
+ return JsonDocument.Parse(jsonString.AsMemory(), deserializationOptions);
+ }
+ case (0xFE, 0xFF, _, _): // UTF-16 BE
+ case (0xFF, 0xFE, _, _): // UTF-16 LE
+ {
+ using var sr = new StreamReader(stream, Encoding.Unicode);
+ var jsonString = await sr.ReadToEndAsync();
+ return JsonDocument.Parse(jsonString.AsMemory(), deserializationOptions);
+ }
+ case (0xEF, 0xBB, 0xBF, _): // UTF-8
+ case ((byte)'{', _, _, _): // Starts with a '{', probably JSON
+ {
+ return await JsonDocument.ParseAsync(stream, deserializationOptions, cancellationToken);
+ }
+ default:
+ {
+ throw new NotSupportedException($"{Path.GetFileName(filePath)} is not a valid chat format.");
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(rentedBuffer);
+ }
+ }
+
+ private static async Task UpgradeChatJson(ChatRoot chatRoot)
{
const int MAX_STREAM_LENGTH = 172_800; // 48 hours in seconds. https://help.twitch.tv/s/article/broadcast-guidelines
chatRoot.video ??= new Video
@@ -149,6 +217,19 @@ private static async ValueTask UpgradeChatJson(ChatRoot chatRoot)
chatRoot.video.end = chatRoot.video.length;
chatRoot.video.duration = null;
}
+
+ // Fix incorrect bits_spent value on chats between v5 shutdown and the lay295#520 fix
+ if (chatRoot.comments.All(c => c.message.bits_spent == 0))
+ {
+ foreach (var comment in chatRoot.comments)
+ {
+ var bitMatch = TwitchChatDownloader.BitsRegex.Match(comment.message.body);
+ if (bitMatch.Success && int.TryParse(bitMatch.ValueSpan, out var result))
+ {
+ comment.message.bits_spent = result;
+ }
+ }
+ }
}
///
@@ -164,6 +245,7 @@ public static async Task SerializeAsync(string filePath, ChatRoot chatRoot, Chat
PlatformHelper.CreateDirectory(outputDirectory.FullName);
}
+ // TODO: Maybe add Bzip2 and 7z support
await using var fs = File.Create(filePath);
switch (compression)
{
@@ -171,14 +253,14 @@ public static async Task SerializeAsync(string filePath, ChatRoot chatRoot, Chat
await JsonSerializer.SerializeAsync(fs, chatRoot, _jsonSerializerOptions, cancellationToken);
break;
case ChatCompression.Gzip:
- await using (var gs = new GZipStream(fs, CompressionLevel.SmallestSize))
- {
- await JsonSerializer.SerializeAsync(gs, chatRoot, _jsonSerializerOptions, cancellationToken);
- }
+ {
+ await using var gs = new GZipStream(fs, CompressionLevel.SmallestSize);
+ await JsonSerializer.SerializeAsync(gs, chatRoot, _jsonSerializerOptions, cancellationToken);
break;
+ }
default:
throw new NotSupportedException($"{compression} is not a supported chat compression.");
}
}
}
-}
+}
\ No newline at end of file
diff --git a/TwitchDownloaderCore/Chat/TimestampFormat.cs b/TwitchDownloaderCore/Chat/TimestampFormat.cs
deleted file mode 100644
index 5386f812..00000000
--- a/TwitchDownloaderCore/Chat/TimestampFormat.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace TwitchDownloaderCore.Chat
-{
- public enum TimestampFormat
- {
- Utc,
- Relative,
- None,
- UtcFull
- }
-}
diff --git a/TwitchDownloaderCore/ChatRenderer.cs b/TwitchDownloaderCore/ChatRenderer.cs
index 23072098..07d0e034 100644
--- a/TwitchDownloaderCore/ChatRenderer.cs
+++ b/TwitchDownloaderCore/ChatRenderer.cs
@@ -5,6 +5,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -27,9 +28,7 @@ public sealed class ChatRenderer : IDisposable
public bool Disposed { get; private set; } = false;
public ChatRoot chatRoot { get; private set; } = new ChatRoot();
- private const string PURPLE = "#7B2CF2";
- private static readonly SKColor Purple = SKColor.Parse(PURPLE);
- private static readonly SKColor HighlightBackground = SKColor.Parse("#1A6B6B6E");
+ private static readonly SKColor Purple = SKColor.Parse("#7B2CF2");
private static readonly string[] DefaultUsernameColors = { "#FF0000", "#0000FF", "#00FF00", "#B22222", "#FF7F50", "#9ACD32", "#FF4500", "#2E8B57", "#DAA520", "#D2691E", "#5F9EA0", "#1E90FF", "#FF69B4", "#8A2BE2", "#00FF7F" };
private static readonly Regex RtlRegex = new("[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]", RegexOptions.Compiled);
@@ -389,7 +388,7 @@ private FfmpegProcess GetFfmpegProcess(int partNumber, bool isMask)
if (renderOptions.LogFfmpegOutput && _progress != null)
{
- process.ErrorDataReceived += (s, e) =>
+ process.ErrorDataReceived += (_, e) =>
{
if (e.Data != null)
{
@@ -618,15 +617,26 @@ private SKBitmap CombineImages(List<(SKImageInfo info, SKBitmap bitmap)> section
{
if (highlightType is HighlightType.PayingForward or HighlightType.ChannelPointHighlight)
{
- var colorString = highlightType is HighlightType.PayingForward ? "#26262C" : "#80808C";
- using var paint = new SKPaint { Color = SKColor.Parse(colorString) };
+ var accentColor = highlightType is HighlightType.PayingForward
+ ? new SKColor(0x26, 0x26, 0x2C, 0xFF) // #26262C (RRGGBB)
+ : new SKColor(0x80, 0x80, 0x8C, 0xFF); // #80808C (RRGGBB)
+
+ using var paint = new SKPaint { Color = accentColor };
finalCanvas.DrawRect(renderOptions.SidePadding, 0, renderOptions.AccentStrokeWidth, finalBitmapInfo.Height, paint);
}
else if (highlightType is not HighlightType.None)
{
- using var backgroundPaint = new SKPaint { Color = HighlightBackground };
+ const int OPAQUE_THRESHOLD = 245;
+ if (!(renderOptions.BackgroundColor.Alpha < OPAQUE_THRESHOLD ||
+ (renderOptions.AlternateMessageBackgrounds && renderOptions.AlternateBackgroundColor.Alpha < OPAQUE_THRESHOLD)))
+ {
+ // Draw the highlight background only if the message background is opaque enough
+ var backgroundColor = new SKColor(0x6B, 0x6B, 0x6E, 0x1A); // #1A6B6B6E (AARRGGBB)
+ using var backgroundPaint = new SKPaint { Color = backgroundColor };
+ finalCanvas.DrawRect(renderOptions.SidePadding, 0, finalBitmapInfo.Width - renderOptions.SidePadding * 2, finalBitmapInfo.Height, backgroundPaint);
+ }
+
using var accentPaint = new SKPaint { Color = Purple };
- finalCanvas.DrawRect(renderOptions.SidePadding, 0, finalBitmapInfo.Width - renderOptions.SidePadding * 2, finalBitmapInfo.Height, backgroundPaint);
finalCanvas.DrawRect(renderOptions.SidePadding, 0, renderOptions.AccentStrokeWidth, finalBitmapInfo.Height, accentPaint);
}
@@ -635,7 +645,6 @@ private SKBitmap CombineImages(List<(SKImageInfo info, SKBitmap bitmap)> section
finalCanvas.DrawBitmap(sectionImages[i].bitmap, 0, i * renderOptions.SectionHeight);
sectionImages[i].bitmap.Dispose();
}
-
}
sectionImages.Clear();
finalBitmap.SetImmutable();
@@ -695,6 +704,9 @@ private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitm
case HighlightType.SubscribedPrime:
DrawSubscribeMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint);
break;
+ case HighlightType.BitBadgeTierNotification:
+ DrawBitsBadgeTierMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint);
+ break;
case HighlightType.GiftedMany:
case HighlightType.GiftedSingle:
case HighlightType.GiftedAnonymous:
@@ -726,7 +738,7 @@ private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBit
drawPos.X += highlightIcon.Width + renderOptions.WordSpacing;
defaultPos.X = drawPos.X;
- DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, PURPLE);
+ DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, Purple);
AddImageSection(sectionImages, ref drawPos, defaultPos);
// Remove the commenter's name from the resub message
@@ -756,6 +768,41 @@ private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBit
DrawNonAccentedMessage(customResubMessage, sectionImages, emotePositionList, false, ref drawPos, ref defaultPos);
}
+ private void DrawBitsBadgeTierMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint)
+ {
+ using SKCanvas canvas = new(sectionImages.Last().bitmap);
+
+ canvas.DrawImage(highlightIcon, iconPoint.X, iconPoint.Y);
+ drawPos.X += highlightIcon.Width + renderOptions.WordSpacing;
+ defaultPos.X = drawPos.X;
+
+ if (comment.message.fragments.Count == 1)
+ {
+ DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, messageFont.Color);
+
+ var bitsBadgeVersion = comment.message.user_badges.FirstOrDefault(x => x._id == "bits")?.version;
+ if (bitsBadgeVersion is not null)
+ {
+ comment.message.body = bitsBadgeVersion.Length > 3
+ ? $"just earned a new {bitsBadgeVersion.AsSpan(0, bitsBadgeVersion.Length - 3)}K Bits badge!"
+ : $"just earned a new {bitsBadgeVersion} Bits badge!";
+ }
+ else
+ {
+ comment.message.body = "just earned a new Bits badge!";
+ }
+
+ comment.message.fragments[0].text = comment.message.body;
+ }
+ else
+ {
+ // This should never be possible, but just in case.
+ DrawUsername(comment, sectionImages, ref drawPos, defaultPos, true, messageFont.Color);
+ }
+
+ DrawMessage(comment, sectionImages, emotePositionList, false, ref drawPos, defaultPos);
+ }
+
private void DrawGiftMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint)
{
using SKCanvas canvas = new(sectionImages.Last().bitmap);
@@ -806,7 +853,7 @@ private void DrawFragmentPart(List<(SKImageInfo info, SKBitmap bitmap)> sectionI
DrawRegularMessage(sectionImages, emotePositionList, ref drawPos, defaultPos, bitsCount, fragmentPart, highlightWords);
}
- static bool TryGetTwitchEmote(List twitchEmoteList, ReadOnlySpan emoteName, out TwitchEmote twitchEmote)
+ static bool TryGetTwitchEmote(List twitchEmoteList, ReadOnlySpan emoteName, [NotNullWhen(true)] out TwitchEmote twitchEmote)
{
// Enumerating over a span is faster than a list
var emoteListSpan = CollectionsMarshal.AsSpan(twitchEmoteList);
@@ -819,7 +866,7 @@ static bool TryGetTwitchEmote(List twitchEmoteList, ReadOnlySpan sectio
DrawText(fragmentString, messageFont, true, sectionImages, ref drawPos, defaultPos, highlightWords);
}
- static bool TryGetCheerEmote(List cheerEmoteList, ReadOnlySpan prefix, out CheerEmote cheerEmote)
+ static bool TryGetCheerEmote(List cheerEmoteList, ReadOnlySpan prefix, [NotNullWhen(true)] out CheerEmote cheerEmote)
{
// Enumerating over a span is faster than a list
var cheerEmoteListSpan = CollectionsMarshal.AsSpan(cheerEmoteList);
@@ -1081,7 +1128,7 @@ static bool TryGetCheerEmote(List cheerEmoteList, ReadOnlySpan
}
}
- cheerEmote = default;
+ cheerEmote = null;
return false;
}
}
@@ -1117,7 +1164,7 @@ private void DrawFirstPartyEmote(List<(SKImageInfo info, SKBitmap bitmap)> secti
DrawText(fragment.text, messageFont, true, sectionImages, ref drawPos, defaultPos, highlightWords);
}
- static bool TryGetTwitchEmote(List twitchEmoteList, ReadOnlySpan emoteId, out TwitchEmote twitchEmote)
+ static bool TryGetTwitchEmote(List twitchEmoteList, ReadOnlySpan emoteId, [NotNullWhen(true)] out TwitchEmote twitchEmote)
{
// Enumerating over a span is faster than a list
var emoteListSpan = CollectionsMarshal.AsSpan(twitchEmoteList);
@@ -1130,7 +1177,7 @@ static bool TryGetTwitchEmote(List twitchEmoteList, ReadOnlySpan rtlText, SKPaint textFont
return measure.Width;
}
- private void DrawUsername(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, bool appendColon = true, string colorOverride = null)
+ private void DrawUsername(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, bool appendColon = true, SKColor? colorOverride = null)
{
- SKColor userColor = SKColor.Parse(colorOverride ?? comment.message.user_color ?? DefaultUsernameColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % DefaultUsernameColors.Length]);
+ var userColor = colorOverride ?? SKColor.Parse(comment.message.user_color ?? DefaultUsernameColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % DefaultUsernameColors.Length]);
if (colorOverride is null)
- userColor = GenerateUserColor(userColor, renderOptions.BackgroundColor, renderOptions);
+ userColor = AdjustColorVisibility(userColor, renderOptions.BackgroundColor, renderOptions);
using SKPaint userPaint = comment.commenter.display_name.Any(IsNotAscii)
? GetFallbackFont(comment.commenter.display_name.First(IsNotAscii)).Clone()
: nameFont.Clone();
userPaint.Color = userColor;
- string userName = comment.commenter.display_name + (appendColon ? ":" : "");
+ var userName = appendColon
+ ? comment.commenter.display_name + ":"
+ : comment.commenter.display_name;
+
DrawText(userName, userPaint, true, sectionImages, ref drawPos, defaultPos, false);
}
- private static SKColor GenerateUserColor(SKColor userColor, SKColor background_color, ChatRenderOptions renderOptions)
+ private static SKColor AdjustColorVisibility(SKColor userColor, SKColor backgroundColor, ChatRenderOptions renderOptions)
{
- background_color.ToHsl(out _, out _, out float backgroundBrightness);
+ backgroundColor.ToHsl(out _, out _, out float backgroundBrightness);
userColor.ToHsl(out float userHue, out float userSaturation, out float userBrightness);
if (backgroundBrightness < 25 || renderOptions.Outline)
@@ -1694,7 +1744,7 @@ private static bool IsRightToLeft(ReadOnlySpan message)
public async Task ParseJsonAsync(CancellationToken cancellationToken = new())
{
- chatRoot = await ChatJson.DeserializeAsync(renderOptions.InputFile, true, true, cancellationToken);
+ chatRoot = await ChatJson.DeserializeAsync(renderOptions.InputFile, true, false, true, cancellationToken);
return chatRoot;
}
diff --git a/TwitchDownloaderCore/ChatUpdater.cs b/TwitchDownloaderCore/ChatUpdater.cs
index 1e02ef9e..d67e2ca4 100644
--- a/TwitchDownloaderCore/ChatUpdater.cs
+++ b/TwitchDownloaderCore/ChatUpdater.cs
@@ -9,12 +9,14 @@
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Twitch;
using TwitchDownloaderCore.VideoPlatforms.Twitch.Downloaders;
+using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
namespace TwitchDownloaderCore
{
public sealed class ChatUpdater
{
public ChatRoot chatRoot { get; internal set; } = new();
+ private readonly object _cropChatRootLock = new();
private readonly ChatUpdateOptions _updateOptions;
@@ -26,11 +28,6 @@ public ChatUpdater(ChatUpdateOptions updateOptions)
"TwitchDownloader");
}
- private static class SharedObjects
- {
- internal static object CropChatRootLock = new();
- }
-
public async Task UpdateAsync(IProgress progress, CancellationToken cancellationToken)
{
chatRoot.FileInfo = new() { Version = ChatRootVersion.CurrentVersion, CreatedAt = chatRoot.FileInfo.CreatedAt, UpdatedAt = DateTime.Now };
@@ -41,10 +38,13 @@ public async Task UpdateAsync(IProgress progress, CancellationTo
// Dynamic step count setup
int currentStep = 0;
- int totalSteps = 1;
+ int totalSteps = 2;
if (_updateOptions.CropBeginning || _updateOptions.CropEnding) totalSteps++;
if (_updateOptions.EmbedMissing || _updateOptions.ReplaceEmbeds) totalSteps++;
+ currentStep++;
+ await UpdateVideoInfo(totalSteps, currentStep, progress, cancellationToken);
+
// If we are editing the chat crop
if (_updateOptions.CropBeginning || _updateOptions.CropEnding)
{
@@ -61,7 +61,7 @@ public async Task UpdateAsync(IProgress progress, CancellationTo
// Finally save the output to file!
progress.Report(new ProgressReport(ReportType.NewLineStatus, $"Writing Output File [{++currentStep}/{totalSteps}]"));
- progress.Report(new ProgressReport(totalSteps / currentStep));
+ progress.Report(new ProgressReport(currentStep * 100 / totalSteps));
switch (_updateOptions.OutputFormat)
{
@@ -79,17 +79,100 @@ public async Task UpdateAsync(IProgress progress, CancellationTo
}
}
+ private async Task UpdateVideoInfo(int totalSteps, int currentStep, IProgress progress, CancellationToken cancellationToken)
+ {
+ progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Updating Video Info [{currentStep}/{totalSteps}]"));
+ progress.Report(new ProgressReport(currentStep * 100 / totalSteps));
+
+ if (chatRoot.video.id.All(char.IsDigit))
+ {
+ var videoId = int.Parse(chatRoot.video.id);
+ VideoInfo videoInfo = null;
+ try
+ {
+ videoInfo = (await TwitchHelper.GetVideoInfo(videoId)).data.video;
+ }
+ catch { /* Eat the exception */ }
+
+ if (videoInfo is null)
+ {
+ progress.Report(new ProgressReport(ReportType.SameLineStatus, "Unable to fetch video info, deleted/expired VOD possibly?"));
+ return;
+ }
+
+ chatRoot.video.title = videoInfo.title;
+ chatRoot.video.description = videoInfo.description;
+ chatRoot.video.created_at = videoInfo.createdAt;
+ chatRoot.video.length = videoInfo.lengthSeconds;
+ chatRoot.video.viewCount = videoInfo.viewCount;
+ chatRoot.video.game = videoInfo.game.displayName;
+
+ var chaptersInfo = (await TwitchHelper.GetOrGenerateVideoChapters(videoId, videoInfo)).data.video.moments.edges;
+ foreach (var responseChapter in chaptersInfo)
+ {
+ chatRoot.video.chapters.Add(new VideoChapter
+ {
+ id = responseChapter.node.id,
+ startMilliseconds = responseChapter.node.positionMilliseconds,
+ lengthMilliseconds = responseChapter.node.durationMilliseconds,
+ _type = responseChapter.node._type,
+ description = responseChapter.node.description,
+ subDescription = responseChapter.node.subDescription,
+ thumbnailUrl = responseChapter.node.thumbnailURL,
+ gameId = responseChapter.node.details.game?.id,
+ gameDisplayName = responseChapter.node.details.game?.displayName,
+ gameBoxArtUrl = responseChapter.node.details.game?.boxArtURL
+ });
+ }
+ }
+ else
+ {
+ var clipId = chatRoot.video.id;
+ Clip clipInfo = null;
+ try
+ {
+ clipInfo = (await TwitchHelper.GetClipInfo(clipId)).data.clip;
+ }
+ catch { /* Eat the exception */ }
+
+ if (clipInfo is null)
+ {
+ progress.Report(new ProgressReport(ReportType.SameLineStatus, "Unable to fetch clip info, deleted possibly?"));
+ return;
+ }
+
+ chatRoot.video.title = clipInfo.title;
+ chatRoot.video.created_at = clipInfo.createdAt;
+ chatRoot.video.length = clipInfo.durationSeconds;
+ chatRoot.video.viewCount = clipInfo.viewCount;
+ chatRoot.video.game = clipInfo.game.displayName;
+
+ var clipChapter = TwitchHelper.GenerateClipChapter(clipInfo);
+ chatRoot.video.chapters.Add(new VideoChapter
+ {
+ id = clipChapter.node.id,
+ startMilliseconds = clipChapter.node.positionMilliseconds,
+ lengthMilliseconds = clipChapter.node.durationMilliseconds,
+ _type = clipChapter.node._type,
+ description = clipChapter.node.description,
+ subDescription = clipChapter.node.subDescription,
+ thumbnailUrl = clipChapter.node.thumbnailURL,
+ gameId = clipChapter.node.details.game?.id,
+ gameDisplayName = clipChapter.node.details.game?.displayName,
+ gameBoxArtUrl = clipChapter.node.details.game?.boxArtURL
+ });
+ }
+ }
+
private async Task UpdateChatCrop(int totalSteps, int currentStep, IProgress progress, CancellationToken cancellationToken)
{
progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Updating Chat Crop [{currentStep}/{totalSteps}]"));
- progress.Report(new ProgressReport(totalSteps / currentStep));
-
- chatRoot.video ??= new Video();
+ progress.Report(new ProgressReport(currentStep * 100 / totalSteps));
bool cropTaskVodExpired = false;
var cropTaskProgress = new Progress(report =>
{
- if (((string)report.Data).ToLower().Contains("vod is expired"))
+ if (((string)report.Data).Contains("vod is expired", StringComparison.OrdinalIgnoreCase))
{
// If the user is moving both crops in one command, we only want to propagate a 'vod expired/id corrupt' report once
if (cropTaskVodExpired)
@@ -146,7 +229,7 @@ private async Task UpdateChatCrop(int totalSteps, int currentStep, IProgress progress, CancellationToken cancellationToken)
{
progress.Report(new ProgressReport(ReportType.NewLineStatus, $"Updating Embeds [{currentStep}/{totalSteps}]"));
- progress.Report(new ProgressReport(totalSteps / currentStep));
+ progress.Report(new ProgressReport(currentStep * 100 / totalSteps));
chatRoot.embeddedData ??= new EmbeddedData();
@@ -272,7 +355,7 @@ private async Task ChatBeginningCropTask(IProgress progress, Can
}
// Adjust the crop parameter
- double beginningCropClamp = double.IsNegative(chatRoot.video.length) ? 172_800 : chatRoot.video.length; // Get length from chatroot or if negavite (N/A) max vod length (48 hours) in seconds. https://help.twitch.tv/s/article/broadcast-guidelines
+ double beginningCropClamp = double.IsNegative(chatRoot.video.length) ? 172_800 : chatRoot.video.length; // Get length from chatroot or if negative (N/A) max vod length (48 hours) in seconds. https://help.twitch.tv/s/article/broadcast-guidelines
chatRoot.video.start = Math.Min(Math.Max(_updateOptions.CropBeginningTime, 0.0), beginningCropClamp);
}
@@ -305,7 +388,7 @@ private async Task ChatEndingCropTask(IProgress progress, Cancel
}
// Adjust the crop parameter
- double endingCropClamp = double.IsNegative(chatRoot.video.length) ? 172_800 : chatRoot.video.length; // Get length from chatroot or if negavite (N/A) max vod length (48 hours) in seconds. https://help.twitch.tv/s/article/broadcast-guidelines
+ double endingCropClamp = double.IsNegative(chatRoot.video.length) ? 172_800 : chatRoot.video.length; // Get length from chatroot or if negative (N/A) max vod length (48 hours) in seconds. https://help.twitch.tv/s/article/broadcast-guidelines
chatRoot.video.end = Math.Min(Math.Max(_updateOptions.CropEndingTime, 0.0), endingCropClamp);
}
@@ -314,7 +397,7 @@ private async Task ChatEndingCropTask(IProgress progress, Cancel
TwitchChatDownloader chatDownloader = new TwitchChatDownloader(downloadOptions, new Progress());
await chatDownloader.DownloadAsync(cancellationToken);
- ChatRoot newChatRoot = await ChatJson.DeserializeAsync(inputFile, getComments: true, getEmbeds: false, cancellationToken);
+ ChatRoot newChatRoot = await ChatJson.DeserializeAsync(inputFile, getComments: true, onlyFirstAndLastComments: false, getEmbeds: false, cancellationToken);
// Append the new comment section
SortedSet commentsSet = new SortedSet(new SortedCommentComparer());
@@ -326,7 +409,7 @@ private async Task ChatEndingCropTask(IProgress progress, Cancel
}
}
- lock (SharedObjects.CropChatRootLock)
+ lock (_cropChatRootLock)
{
foreach (var comment in chatRoot.comments)
{
@@ -346,6 +429,7 @@ private ChatDownloadOptions GetCropDownloadOptions(string videoId, string tempFi
{
Id = videoId,
DownloadFormat = ChatFormat.Json, // json is required to parse as a new chatroot object
+ Compression = ChatCompression.Gzip,
Filename = tempFile,
CropBeginning = true,
CropBeginningTime = sectionStart,
@@ -362,7 +446,7 @@ private ChatDownloadOptions GetCropDownloadOptions(string videoId, string tempFi
public async Task ParseJsonAsync(CancellationToken cancellationToken = new())
{
- chatRoot = await ChatJson.DeserializeAsync(_updateOptions.InputFile, true, true, cancellationToken);
+ chatRoot = await ChatJson.DeserializeAsync(_updateOptions.InputFile, true, false, true, cancellationToken);
return chatRoot;
}
}
diff --git a/TwitchDownloaderCore/Extensions/StreamExtensions.cs b/TwitchDownloaderCore/Extensions/StreamExtensions.cs
index 35bb2c7b..214a86e9 100644
--- a/TwitchDownloaderCore/Extensions/StreamExtensions.cs
+++ b/TwitchDownloaderCore/Extensions/StreamExtensions.cs
@@ -42,5 +42,28 @@ public static async Task ProgressCopyToAsync(this Stream source, Stream destinat
ArrayPool.Shared.Return(rentedBuffer);
}
}
+
+ public static async Task CopyBytesToAsync(this Stream source, Stream destination, long byteCount, CancellationToken cancellationToken = default)
+ {
+ var rentedBuffer = ArrayPool.Shared.Rent(STREAM_DEFAULT_BUFFER_LENGTH);
+
+ try
+ {
+ long totalBytesRead = 0;
+ while (totalBytesRead < byteCount)
+ {
+ var bytesToCopy = (int)Math.Min(byteCount - totalBytesRead, STREAM_DEFAULT_BUFFER_LENGTH);
+
+ var bytesRead = await source.ReadAsync(rentedBuffer, 0, bytesToCopy, cancellationToken).ConfigureAwait(false);
+ await destination.WriteAsync(rentedBuffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
+
+ totalBytesRead += bytesRead;
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(rentedBuffer);
+ }
+ }
}
}
\ No newline at end of file
diff --git a/TwitchDownloaderCore/Options/ChatDownloadOptions.cs b/TwitchDownloaderCore/Options/ChatDownloadOptions.cs
index 4b6d0d25..2ccc7726 100644
--- a/TwitchDownloaderCore/Options/ChatDownloadOptions.cs
+++ b/TwitchDownloaderCore/Options/ChatDownloadOptions.cs
@@ -36,12 +36,6 @@ public string FileExtension
}
public string TempFolder { get; set; }
public VideoPlatform VideoPlatform { get; set; }
- public ChatDownloadType DownloadType { get; set; }
- }
-
- public enum ChatDownloadType
- {
- Clip,
- Video
+ public VideoType VideoType { get; set; }
}
}
diff --git a/TwitchDownloaderCore/Options/ChatRenderOptions.cs b/TwitchDownloaderCore/Options/ChatRenderOptions.cs
index 80a42ba1..b7d061f0 100644
--- a/TwitchDownloaderCore/Options/ChatRenderOptions.cs
+++ b/TwitchDownloaderCore/Options/ChatRenderOptions.cs
@@ -49,8 +49,8 @@ public string MaskFile
if (OutputFile == "" || GenerateMask == false)
return OutputFile;
- string extension = Path.GetExtension(OutputFile);
- int extensionIndex = OutputFile.LastIndexOf(extension);
+ string extension = Path.GetExtension(OutputFile)!;
+ int extensionIndex = OutputFile!.LastIndexOf(extension, StringComparison.Ordinal);
return string.Concat(OutputFile.AsSpan(0, extensionIndex), "_mask", extension);
}
}
diff --git a/TwitchDownloaderCore/Options/ChatUpdateOptions.cs b/TwitchDownloaderCore/Options/ChatUpdateOptions.cs
index 6b0eac01..5de5153f 100644
--- a/TwitchDownloaderCore/Options/ChatUpdateOptions.cs
+++ b/TwitchDownloaderCore/Options/ChatUpdateOptions.cs
@@ -1,4 +1,5 @@
using TwitchDownloaderCore.Chat;
+using TwitchDownloaderCore.Tools;
namespace TwitchDownloaderCore.Options
{
diff --git a/TwitchDownloaderCore/PlatformHelper.cs b/TwitchDownloaderCore/PlatformHelper.cs
index 106638d5..9cf3b645 100644
--- a/TwitchDownloaderCore/PlatformHelper.cs
+++ b/TwitchDownloaderCore/PlatformHelper.cs
@@ -2,12 +2,10 @@
using System;
using System.Collections.Generic;
using System.IO;
-using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Runtime.InteropServices;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TwitchDownloaderCore.Tools;
@@ -19,9 +17,9 @@
namespace TwitchDownloaderCore
{
- public class PlatformHelper
+ public static class PlatformHelper
{
- private static readonly HttpClient httpClient = new HttpClient();
+ private static readonly HttpClient HttpClient = new();
public static async Task GetClipInfo(VideoPlatform videoPlatform, string clipId)
{
if (videoPlatform == VideoPlatform.Twitch)
@@ -44,11 +42,11 @@ public static async Task GetClipInfo(VideoPlatform videoPlatform, st
throw new NotImplementedException();
}
- public static async Task GetVideoInfo(VideoPlatform videoPlatform, string videoId, string Oauth = "")
+ public static async Task GetVideoInfo(VideoPlatform videoPlatform, string videoId, string oauth = "")
{
if (videoPlatform == VideoPlatform.Twitch)
{
- return await TwitchHelper.GetVideoInfo(int.Parse(videoId));
+ return await TwitchHelper.GetVideoInfo(int.Parse(videoId), oauth);
}
if (videoPlatform == VideoPlatform.Kick)
@@ -91,12 +89,12 @@ public static void SetDirectoryPermissions(string path)
if (!Directory.Exists(cachePath))
PlatformHelper.CreateDirectory(cachePath);
- string filePath = Path.Combine(cachePath, imageId + "_" + imageScale + "." + imageType);
+ string filePath = Path.Combine(cachePath!, $"{imageId}_{imageScale}.{imageType}");
if (File.Exists(filePath))
{
try
{
- await using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ await using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
byte[] bytes = new byte[stream.Length];
stream.Seek(0, SeekOrigin.Begin);
_ = await stream.ReadAsync(bytes, cancellationToken);
@@ -134,40 +132,40 @@ public static void SetDirectoryPermissions(string path)
// Fallback to HTTP request
if (imageUrl.Contains("kick.com"))
{
- imageBytes = CurlImpersonate.GetCurlReponseBytes(imageUrl);
+ imageBytes = CurlImpersonate.GetCurlResponseBytes(imageUrl);
}
else
{
- imageBytes = await httpClient.GetByteArrayAsync(imageUrl, cancellationToken);
+ imageBytes = await HttpClient.GetByteArrayAsync(imageUrl, cancellationToken);
}
//Let's save this image to the cache
try
{
- using FileStream stream = File.Open(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
- await stream.WriteAsync(imageBytes, cancellationToken);
+ await using var fs = File.Open(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
+ await fs.WriteAsync(imageBytes, cancellationToken);
}
catch { }
return imageBytes;
}
- public static async Task GetStvEmoteData(int streamerId, List stvResponse, bool allowUnlistedEmotes, VideoPlatform videoPlatform)
+ public static async Task> GetStvEmotesMetadata(int streamerId, bool allowUnlistedEmotes, VideoPlatform videoPlatform, CancellationToken cancellationToken)
{
var globalEmoteRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("https://7tv.io/v3/emote-sets/global", UriKind.Absolute));
- using var globalEmoteResponse = await httpClient.SendAsync(globalEmoteRequest, HttpCompletionOption.ResponseHeadersRead);
+ using var globalEmoteResponse = await HttpClient.SendAsync(globalEmoteRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
globalEmoteResponse.EnsureSuccessStatusCode();
- var globalEmoteObject = await globalEmoteResponse.Content.ReadFromJsonAsync();
+ var globalEmoteObject = await globalEmoteResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
var stvEmotes = globalEmoteObject.emotes;
// Channel might not be registered on 7tv
try
{
- var streamerEmoteRequest = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://7tv.io/v3/users/{Enum.GetName(videoPlatform).ToLower()}/{streamerId}", UriKind.Absolute));
- using var streamerEmoteResponse = await httpClient.SendAsync(streamerEmoteRequest, HttpCompletionOption.ResponseHeadersRead);
+ var streamerEmoteRequest = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://7tv.io/v3/users/{Enum.GetName(videoPlatform)!.ToLower()}/{streamerId}", UriKind.Absolute));
+ using var streamerEmoteResponse = await HttpClient.SendAsync(streamerEmoteRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
streamerEmoteResponse.EnsureSuccessStatusCode();
- var streamerEmoteObject = await streamerEmoteResponse.Content.ReadFromJsonAsync();
+ var streamerEmoteObject = await streamerEmoteResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
// Channel might not have emotes setup
if (streamerEmoteObject.emote_set?.emotes != null)
{
@@ -176,6 +174,7 @@ public static async Task GetStvEmoteData(int streamerId, List
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { }
+ var returnList = new List();
foreach (var stvEmote in stvEmotes)
{
STVData emoteData = stvEmote.data;
@@ -214,9 +213,11 @@ public static async Task GetStvEmoteData(int streamerId, List
}
if (allowUnlistedEmotes || emoteIsListed)
{
- stvResponse.Add(emoteResponse);
+ returnList.Add(emoteResponse);
}
}
+
+ return returnList;
}
}
}
diff --git a/TwitchDownloaderCore/Resources/TD-License b/TwitchDownloaderCore/Resources/TD-License
index 726d8e0e..88a32149 100644
--- a/TwitchDownloaderCore/Resources/TD-License
+++ b/TwitchDownloaderCore/Resources/TD-License
@@ -30,7 +30,7 @@ License
The MIT License
- Copyright (c) 2019 lay295 and contributors
+ Copyright (c) lay295
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/TwitchDownloaderCore/Tools/CurlImpersonate.cs b/TwitchDownloaderCore/Tools/CurlImpersonate.cs
index 4eb0d755..54705d08 100644
--- a/TwitchDownloaderCore/Tools/CurlImpersonate.cs
+++ b/TwitchDownloaderCore/Tools/CurlImpersonate.cs
@@ -1,13 +1,10 @@
using CurlThin.Enums;
using CurlThin;
using System;
-using System.Collections.Generic;
-using System.Diagnostics;
+using System.Buffers;
using System.IO;
-using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
-using System.Threading.Tasks;
namespace TwitchDownloaderCore.Tools
{
@@ -15,13 +12,13 @@ public static class CurlImpersonate
{
static CURLcode global = CurlNative.Init();
- public static string GetCurlReponse(string url)
+ public static string GetCurlResponse(string url)
{
- string response = Encoding.UTF8.GetString(GetCurlReponseBytes(url));
+ string response = Encoding.UTF8.GetString(GetCurlResponseBytes(url));
return response;
}
- public static byte[] GetCurlReponseBytes(string url)
+ public static byte[] GetCurlResponseBytes(string url)
{
var easy = CurlNative.Easy.Init();
try
@@ -34,9 +31,18 @@ public static byte[] GetCurlReponseBytes(string url)
CurlNative.Easy.SetOpt(easy, CURLoption.WRITEFUNCTION, (data, size, nmemb, user) =>
{
var length = (int)size * (int)nmemb;
- var buffer = new byte[length];
- Marshal.Copy(data, buffer, 0, length);
- stream.Write(buffer, 0, length);
+
+ var buffer = ArrayPool.Shared.Rent(length);
+ try
+ {
+ Marshal.Copy(data, buffer, 0, length);
+ stream.Write(buffer, 0, length);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+
return (UIntPtr)length;
});
diff --git a/TwitchDownloaderCore/Tools/DownloadTools.cs b/TwitchDownloaderCore/Tools/DownloadTools.cs
index 7c416ae2..eafdb13f 100644
--- a/TwitchDownloaderCore/Tools/DownloadTools.cs
+++ b/TwitchDownloaderCore/Tools/DownloadTools.cs
@@ -16,10 +16,11 @@
namespace TwitchDownloaderCore.Tools
{
- public class DownloadTools
+ public static class DownloadTools
{
private static readonly HttpClient HttpClient = new();
- public static async Task DownloadClipFileTaskAsync(string url, string destinationFile, int throttleKib, IProgress progress, CancellationToken cancellationToken)
+
+ public static async Task DownloadFileAsync(string url, string destinationFile, int throttleKib, IProgress progress, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
@@ -42,6 +43,24 @@ public static async Task DownloadClipFileTaskAsync(string url, string destinatio
}
}
+ public static async Task DownloadVideoPartsAsync(VideoDownloadOptions downloadOptions, List videoPartsList, Uri baseUrl, string downloadFolder, double vodAge, int currentStep,
+ int totalSteps, IProgress progress, CancellationToken cancellationToken)
+ {
+ var partCount = videoPartsList.Count;
+ var videoPartsQueue = new ConcurrentQueue(videoPartsList);
+ var downloadTasks = new Task[downloadOptions.DownloadThreads];
+
+ for (var i = 0; i < downloadOptions.DownloadThreads; i++)
+ {
+ downloadTasks[i] = StartNewDownloadThread(videoPartsQueue, downloadOptions, baseUrl, downloadFolder, vodAge, cancellationToken);
+ }
+
+ var downloadExceptions = await WaitForDownloadThreads(downloadTasks, downloadOptions, videoPartsQueue, baseUrl, downloadFolder, vodAge, partCount, currentStep,
+ totalSteps, progress, cancellationToken);
+
+ LogDownloadThreadExceptions(downloadExceptions, progress);
+ }
+
public static List> GenerateCroppedVideoList(List> videoList, VideoDownloadOptions downloadOptions)
{
List> returnList = new List>(videoList);
@@ -68,8 +87,7 @@ public static List> GenerateCroppedVideoList(List endTime += x.Value);
+ double endTime = videoList.Sum(x => x.Value);
for (int i = returnList.Count - 1; i >= 0; i--)
{
@@ -88,14 +106,14 @@ public static List> GenerateCroppedVideoList(List downloadExceptions, IProgress progress)
+ public static void LogDownloadThreadExceptions(IReadOnlyCollection downloadExceptions, IProgress progress)
{
if (downloadExceptions.Count == 0)
return;
var culpritList = new List();
var sb = new StringBuilder();
- foreach (var downloadException in downloadExceptions.Values)
+ foreach (var downloadException in downloadExceptions)
{
var ex = downloadException switch
{
@@ -125,28 +143,64 @@ public static void LogDownloadThreadExceptions(Dictionary downlo
progress.Report(new ProgressReport(ReportType.Log, sb.ToString()));
}
- public static Task StartNewDownloadThread(ConcurrentQueue videoPartsQueue, VideoDownloadOptions downloadOptions, string baseUrl, string downloadFolder, double vodAge, CancellationToken cancellationToken)
+ public static Task StartNewDownloadThread(ConcurrentQueue videoPartsQueue, VideoDownloadOptions downloadOptions, Uri baseUrl, string downloadFolder, double vodAge, CancellationToken cancellationToken)
{
- return Task.Factory.StartNew(state =>
+ return Task.Factory.StartNew(
+ ExecuteDownloadThread,
+ new Tuple, HttpClient, Uri, string, double, VideoDownloadOptions, CancellationToken>(
+ videoPartsQueue, HttpClient, baseUrl, downloadFolder, vodAge, downloadOptions, cancellationToken),
+ cancellationToken,
+ TaskCreationOptions.LongRunning,
+ TaskScheduler.Current);
+
+ static void ExecuteDownloadThread(object state)
{
- var (partQueue, rootUrl, cacheFolder, videoAge, throttleKib, cancelToken) =
- (Tuple, string, string, double, int, CancellationToken>)state;
+ var (partQueue, httpClient, rootUrl, cacheFolder, videoAge, videoDownloadOptions, cancelToken) =
+ (Tuple, HttpClient, Uri, string, double, VideoDownloadOptions, CancellationToken>)state;
+
+ using var cts = new CancellationTokenSource();
+ cancelToken.Register(PropagateCancel, cts);
while (!partQueue.IsEmpty)
{
- if (partQueue.TryDequeue(out var request))
+ cancelToken.ThrowIfCancellationRequested();
+
+ string videoPart = null;
+ try
+ {
+ if (partQueue.TryDequeue(out videoPart))
+ {
+ DownloadVideoPart(httpClient, rootUrl, videoDownloadOptions, videoPart, cacheFolder, videoAge, videoDownloadOptions.ThrottleKib, cts);
+ }
+ }
+ catch
{
- DownloadVideoPartAsync(rootUrl, downloadOptions, request, cacheFolder, videoAge, throttleKib, cancelToken).GetAwaiter().GetResult();
+ if (videoPart != null && !cancelToken.IsCancellationRequested)
+ {
+ // Requeue the video part now instead of deferring to the verifier since we already know it's bad
+ partQueue.Enqueue(videoPart);
+ }
+
+ throw;
}
- Task.Delay(77, cancelToken).GetAwaiter().GetResult();
+ const int A_PRIME_NUMBER = 71;
+ Thread.Sleep(A_PRIME_NUMBER);
}
- }, new Tuple, string, string, double, int, CancellationToken>(
- videoPartsQueue, baseUrl, downloadFolder, vodAge, downloadOptions.ThrottleKib, cancellationToken),
- cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+ }
+
+ static void PropagateCancel(object tokenSourceToCancel)
+ {
+ try
+ {
+ ((CancellationTokenSource)tokenSourceToCancel)?.Cancel();
+ }
+ catch (ObjectDisposedException) { }
+ }
}
- public static async Task> WaitForDownloadThreads(Task[] tasks, VideoDownloadOptions downloadOptions, ConcurrentQueue videoPartsQueue, string baseUrl, string downloadFolder, double vodAge, int partCount, IProgress progress, CancellationToken cancellationToken)
+ public static async Task> WaitForDownloadThreads(Task[] tasks, VideoDownloadOptions downloadOptions, ConcurrentQueue videoPartsQueue, Uri baseUrl,
+ string downloadFolder, double vodAge, int partCount, int currentStep, int totalSteps, IProgress progress, CancellationToken cancellationToken)
{
var allThreadsExited = false;
var previousDoneCount = 0;
@@ -159,7 +213,7 @@ public static async Task> WaitForDownloadThreads(Task
{
previousDoneCount = videoPartsQueue.Count;
var percent = (int)((partCount - previousDoneCount) / (double)partCount * 100);
- progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Downloading {percent}% [2/5]"));
+ progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Downloading {percent}% [{currentStep}/{totalSteps}]"));
progress.Report(new ProgressReport(percent));
}
@@ -193,27 +247,29 @@ public static async Task> WaitForDownloadThreads(Task
throw new AggregateException("The download thread restart limit was reached.", downloadExceptions.Values);
}
- return downloadExceptions;
+ return downloadExceptions.Values;
}
- private static async Task DownloadVideoPartAsync(string baseUrl, VideoDownloadOptions downloadOptions, string videoPartName, string downloadFolder, double vodAge, int throttleKib, CancellationToken cancellationToken)
+ /// The may be canceled by this method.
+ private static void DownloadVideoPart(HttpClient httpClient, Uri baseUrl, VideoDownloadOptions downloadOptions, string videoPartName, string downloadFolder, double vodAge, int throttleKib, CancellationTokenSource cancellationTokenSource)
{
bool tryUnmute = vodAge < 24 && downloadOptions.VideoPlatform == VideoPlatform.Twitch;
int errorCount = 0;
int timeoutCount = 0;
+
while (true)
{
- cancellationToken.ThrowIfCancellationRequested();
+ cancellationTokenSource.Token.ThrowIfCancellationRequested();
try
{
if (tryUnmute && videoPartName.Contains("-muted"))
{
- await DownloadFileTaskAsync(baseUrl + videoPartName.Replace("-muted", ""), Path.Combine(downloadFolder, RemoveQueryString(videoPartName)), throttleKib, cancellationToken);
+ DownloadFile(httpClient, new Uri(baseUrl, videoPartName.Replace("-muted", "")), Path.Combine(downloadFolder, RemoveQueryString(videoPartName)), throttleKib, cancellationTokenSource);
}
else
{
- await DownloadFileTaskAsync(baseUrl + videoPartName, Path.Combine(downloadFolder, RemoveQueryString(videoPartName)), throttleKib, cancellationToken);
+ DownloadFile(httpClient, new Uri(baseUrl + videoPartName), Path.Combine(downloadFolder, RemoveQueryString(videoPartName)), throttleKib, cancellationTokenSource);
}
return;
@@ -224,68 +280,114 @@ private static async Task DownloadVideoPartAsync(string baseUrl, VideoDownloadOp
}
catch (HttpRequestException)
{
- if (++errorCount > 10)
+ const int MAX_RETRIES = 10;
+ if (++errorCount > MAX_RETRIES)
{
- throw new HttpRequestException($"Video part {videoPartName} failed after 10 retries");
+ throw new HttpRequestException($"Video part {videoPartName} failed after {MAX_RETRIES} retries");
}
- await Task.Delay(1_000 * errorCount, cancellationToken);
+ Thread.Sleep(1_000 * errorCount);
}
catch (TaskCanceledException ex) when (ex.Message.Contains("HttpClient.Timeout"))
{
- if (++timeoutCount > 3)
+ const int MAX_RETRIES = 3;
+ if (++timeoutCount > MAX_RETRIES)
{
- throw new HttpRequestException($"Video part {videoPartName} timed out 3 times");
+ throw new HttpRequestException($"Video part {videoPartName} timed out {MAX_RETRIES} times");
}
- await Task.Delay(5_000 * timeoutCount, cancellationToken);
+ Thread.Sleep(5_000 * timeoutCount);
}
+
+ cancellationTokenSource.Token.ThrowIfCancellationRequested();
}
}
- ///
- /// Downloads the requested to the without storing it in memory.
- ///
+ /// Downloads the requested to the without storing it in memory.
+ /// The to perform the download operation.
/// The url of the file to download.
/// The path to the file where download will be saved.
/// The maximum download speed in kibibytes per second, or -1 for no maximum.
- /// The cancellation token to cancel the operation.
- public static async Task DownloadFileTaskAsync(string url, string destinationFile, int throttleKib, CancellationToken cancellationToken = default)
+ /// A containing a to cancel the operation.
+ /// The may be canceled by this method.
+ private static void DownloadFile(HttpClient httpClient, Uri url, string destinationFile, int throttleKib, CancellationTokenSource cancellationTokenSource = null)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
- using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None;
+
+ using var response = httpClient.Send(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
+ // Why are we setting a CTS CancelAfter timer? See lay295#265
+ const int SIXTY_SECONDS = 60;
+ if (throttleKib == -1 || !response.Content.Headers.ContentLength.HasValue)
+ {
+ cancellationTokenSource?.CancelAfter(TimeSpan.FromSeconds(SIXTY_SECONDS));
+ }
+ else
+ {
+ const double ONE_KIBIBYTE = 1024;
+ cancellationTokenSource?.CancelAfter(TimeSpan.FromSeconds(Math.Max(
+ SIXTY_SECONDS,
+ response.Content.Headers.ContentLength!.Value / ONE_KIBIBYTE / throttleKib * 8 // Allow up to 8x the shortest download time given the thread bandwidth
+ )));
+ }
+
switch (throttleKib)
{
case -1:
{
- await using var fs = new FileStream(destinationFile, FileMode.Create, FileAccess.Write, FileShare.Read);
- await response.Content.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
+ using var fs = new FileStream(destinationFile, FileMode.Create, FileAccess.Write, FileShare.Read);
+ response.Content.CopyTo(fs, null, cancellationToken);
break;
}
default:
{
try
{
- await using var throttledStream = new ThrottledStream(await response.Content.ReadAsStreamAsync(cancellationToken), throttleKib);
- await using var fs = new FileStream(destinationFile, FileMode.Create, FileAccess.Write, FileShare.Read);
- await throttledStream.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
+ using var contentStream = response.Content.ReadAsStream(cancellationToken);
+ using var throttledStream = new ThrottledStream(contentStream, throttleKib);
+ using var fs = new FileStream(destinationFile, FileMode.Create, FileAccess.Write, FileShare.Read);
+ throttledStream.CopyTo(fs);
}
catch (IOException e) when (e.Message.Contains("EOF"))
{
- // The throttled stream throws when it reads an unexpected EOF, try again without the limiter
+ // If we get an exception for EOF, it may be related to the throttler. Try again without it.
// TODO: Log this somehow
- await Task.Delay(2_000, cancellationToken);
+ Thread.Sleep(2_000);
+ cancellationToken.ThrowIfCancellationRequested();
goto case -1;
}
break;
}
}
+
+ // Reset the cts timer so it can be reused for the next download on this thread.
+ // Is there a friendlier way to do this? Yes. Does it involve creating and destroying 4,000 CancellationTokenSources that are almost never cancelled? Also Yes.
+ cancellationTokenSource?.CancelAfter(TimeSpan.FromMilliseconds(uint.MaxValue - 1));
+ }
+
+ public static async Task VerifyDownloadedParts(VideoDownloadOptions downloadOptions, List videoParts, Uri baseUrl, string downloadFolder, double vodAge, int currentStep, int totalSteps,
+ IProgress progress, CancellationToken cancellationToken)
+ {
+ var failedParts = VerifyTransportStreams(videoParts, downloadFolder, currentStep, totalSteps, progress, cancellationToken);
+
+ if (failedParts.Count != 0)
+ {
+ if (failedParts.Count == videoParts.Count)
+ {
+ // Every video part returned corrupted, probably a false positive.
+ return;
+ }
+
+ progress.Report(new ProgressReport(ReportType.Log, $"The following parts will be redownloaded: {string.Join(", ", failedParts)}"));
+ await DownloadVideoPartsAsync(downloadOptions, failedParts, baseUrl, downloadFolder, vodAge, 3, 5, progress, cancellationToken);
+ }
}
- public static async Task VerifyDownloadedParts(VideoDownloadOptions downloadOptions, List videoParts, string baseUrl, string downloadFolder, double vodAge, IProgress progress, CancellationToken cancellationToken)
+ /// A list transport streams that failed to verify.
+ public static List VerifyTransportStreams(List videoParts, string downloadFolder, int currentStep, int totalSteps, IProgress progress, CancellationToken cancellationToken)
{
var failedParts = new List();
var partCount = videoParts.Count;
@@ -293,46 +395,40 @@ public static async Task VerifyDownloadedParts(VideoDownloadOptions downloadOpti
foreach (var part in videoParts)
{
- if (!VerifyVideoPart(downloadFolder, part))
+ var filePath = Path.Combine(downloadFolder, RemoveQueryString(part));
+ if (!VerifyTransportStream(filePath))
{
failedParts.Add(part);
}
doneCount++;
var percent = (int)(doneCount / (double)partCount * 100);
- progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Verifying Parts {percent}% [3/5]"));
+ progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Verifying Parts {percent}% [{currentStep}/{totalSteps}]"));
progress.Report(new ProgressReport(percent));
cancellationToken.ThrowIfCancellationRequested();
}
- if (failedParts.Count != 0)
- {
- if (failedParts.Count == videoParts.Count)
- {
- // Every video part returned corrupted, probably a false positive.
- return;
- }
-
- progress.Report(new ProgressReport(ReportType.Log, $"The following parts will be redownloaded: {string.Join(", ", failedParts)}"));
- await DownloadVideoPartsAsync(downloadOptions, failedParts, baseUrl, downloadFolder, vodAge, progress, cancellationToken);
- }
+ return failedParts;
}
- public static async Task DownloadVideoPartsAsync(VideoDownloadOptions downloadOptions, List videoPartsList, string baseUrl, string downloadFolder, double vodAge, IProgress progress, CancellationToken cancellationToken)
+ private static bool VerifyTransportStream(string filePath)
{
- var partCount = videoPartsList.Count;
- var videoPartsQueue = new ConcurrentQueue(videoPartsList);
- var downloadTasks = new Task[downloadOptions.DownloadThreads];
+ const int TS_PACKET_LENGTH = 188; // MPEG TS packets are made of a header and a body: [ 4B ][ 184B ] - https://tsduck.io/download/docs/mpegts-introduction.pdf
- for (var i = 0; i < downloadOptions.DownloadThreads; i++)
+ if (!File.Exists(filePath))
{
- downloadTasks[i] = DownloadTools.StartNewDownloadThread(videoPartsQueue, downloadOptions, baseUrl, downloadFolder, vodAge, cancellationToken);
+ return false;
}
- var downloadExceptions = await DownloadTools.WaitForDownloadThreads(downloadTasks, downloadOptions, videoPartsQueue, baseUrl, downloadFolder, vodAge, partCount, progress, cancellationToken);
+ using var fs = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ var fileLength = fs.Length;
+ if (fileLength == 0 || fileLength % TS_PACKET_LENGTH != 0)
+ {
+ return false;
+ }
- DownloadTools.LogDownloadThreadExceptions(downloadExceptions, progress);
+ return true;
}
public static async Task CombineVideoParts(string downloadFolder, List videoParts, IProgress progress, CancellationToken cancellationToken)
@@ -343,15 +439,19 @@ public static async Task CombineVideoParts(string downloadFolder, List v
int partCount = videoParts.Count;
int doneCount = 0;
+#if DEBUG
+ await using var outputStream = new FileStream(outputFile, FileMode.Create, FileAccess.Write, FileShare.Read);
+#else
await using var outputStream = new FileStream(outputFile, FileMode.Create, FileAccess.Write, FileShare.None);
+#endif
foreach (var part in videoParts)
{
await DriveHelper.WaitForDrive(outputDrive, progress, cancellationToken);
- string partFile = Path.Combine(downloadFolder, DownloadTools.RemoveQueryString(part));
+ string partFile = Path.Combine(downloadFolder, RemoveQueryString(part));
if (File.Exists(partFile))
{
- await using (var fs = File.Open(partFile, FileMode.Open, FileAccess.Read, FileShare.None))
+ await using (var fs = File.Open(partFile, FileMode.Open, FileAccess.Read, FileShare.Read))
{
await fs.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false);
}
@@ -447,26 +547,6 @@ private static void HandleFfmpegOutput(string output, Regex encodingTimeRegex, d
}
}
- private static bool VerifyVideoPart(string downloadFolder, string part)
- {
- const int TS_PACKET_LENGTH = 188; // MPEG TS packets are made of a header and a body: [ 4B ][ 184B ] - https://tsduck.io/download/docs/mpegts-introduction.pdf
-
- var partFile = Path.Combine(downloadFolder, DownloadTools.RemoveQueryString(part));
- if (!File.Exists(partFile))
- {
- return false;
- }
-
- using var fs = File.Open(partFile, FileMode.Open, FileAccess.Read, FileShare.None);
- var fileLength = fs.Length;
- if (fileLength == 0 || fileLength % TS_PACKET_LENGTH != 0)
- {
- return false;
- }
-
- return true;
- }
-
//Some old twitch VODs have files with a query string at the end such as 1.ts?offset=blah which isn't a valid filename
public static string RemoveQueryString(string inputString)
{
diff --git a/TwitchDownloaderCore/Tools/DriveHelper.cs b/TwitchDownloaderCore/Tools/DriveHelper.cs
index e83dbb8b..cac255a9 100644
--- a/TwitchDownloaderCore/Tools/DriveHelper.cs
+++ b/TwitchDownloaderCore/Tools/DriveHelper.cs
@@ -1,7 +1,5 @@
using System;
-using System.Collections.Generic;
using System.IO;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TwitchDownloaderCore.Options;
@@ -12,13 +10,10 @@ public static class DriveHelper
{
public static DriveInfo GetOutputDrive(string outputPath)
{
- // Cannot instantiate a null DriveInfo
- DriveInfo outputDrive = DriveInfo.GetDrives()[0];
+ var outputDrive = DriveInfo.GetDrives()[0];
- // Get the name of the drive we are writing to
foreach (var drive in DriveInfo.GetDrives())
{
- // If our output path starts with the drive name
if (outputPath.StartsWith(drive.Name))
{
// In Linux, the root drive is '/' while mounted drives are located in '/mnt/' or '/run/media/'
@@ -37,7 +32,7 @@ public static DriveInfo GetOutputDrive(string outputPath)
public static async Task WaitForDrive(DriveInfo drive, IProgress progress, CancellationToken cancellationToken)
{
- int driveNotReadyCount = 0;
+ var driveNotReadyCount = 0;
while (!drive.IsReady)
{
progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Waiting for output drive ({(driveNotReadyCount + 1) / 2f:F1}s)"));
@@ -55,8 +50,8 @@ public static void CheckAvailableStorageSpace(VideoDownloadOptions downloadOptio
var videoSizeInBytes = VideoSizeEstimator.EstimateVideoSize(bandwidth,
downloadOptions.CropBeginning ? TimeSpan.FromSeconds(downloadOptions.CropBeginningTime) : TimeSpan.Zero,
downloadOptions.CropEnding ? TimeSpan.FromSeconds(downloadOptions.CropEndingTime) : videoLength);
- var tempFolderDrive = DriveHelper.GetOutputDrive(downloadOptions.TempFolder);
- var destinationDrive = DriveHelper.GetOutputDrive(downloadOptions.Filename);
+ var tempFolderDrive = GetOutputDrive(downloadOptions.TempFolder);
+ var destinationDrive = GetOutputDrive(downloadOptions.Filename);
if (tempFolderDrive.Name == destinationDrive.Name)
{
diff --git a/TwitchDownloaderCore/Tools/Enums.cs b/TwitchDownloaderCore/Tools/Enums.cs
new file mode 100644
index 00000000..3696d158
--- /dev/null
+++ b/TwitchDownloaderCore/Tools/Enums.cs
@@ -0,0 +1,36 @@
+namespace TwitchDownloaderCore.Tools
+{
+ public enum VideoType
+ {
+ Video,
+ Clip
+ }
+
+ public enum VideoPlatform
+ {
+ Twitch,
+ Kick,
+ Youtube
+ }
+
+ public enum ChatCompression
+ {
+ None,
+ Gzip
+ }
+
+ public enum ChatFormat
+ {
+ Json,
+ Text,
+ Html
+ }
+
+ public enum TimestampFormat
+ {
+ Utc,
+ Relative,
+ None,
+ UtcFull
+ }
+}
\ No newline at end of file
diff --git a/TwitchDownloaderCore/Tools/FfmpegMetadata.cs b/TwitchDownloaderCore/Tools/FfmpegMetadata.cs
index e50f9d19..66d01c60 100644
--- a/TwitchDownloaderCore/Tools/FfmpegMetadata.cs
+++ b/TwitchDownloaderCore/Tools/FfmpegMetadata.cs
@@ -13,30 +13,37 @@ public static class FfmpegMetadata
{
private const string LINE_FEED = "\u000A";
- public static async Task SerializeAsync(string filePath, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount, double startOffsetSeconds = default, List videoMomentEdges = default, CancellationToken cancellationToken = default)
+ public static async Task SerializeAsync(string filePath, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount, string videoDescription = null,
+ double startOffsetSeconds = 0, IEnumerable videoMomentEdges = null, CancellationToken cancellationToken = default)
{
await using var fs = new FileStream(filePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None);
await using var sw = new StreamWriter(fs) { NewLine = LINE_FEED };
- await SerializeGlobalMetadata(sw, streamerName, videoId, videoTitle, videoCreation, viewCount);
+ await SerializeGlobalMetadata(sw, streamerName, videoId, videoTitle, videoCreation, viewCount, videoDescription);
await fs.FlushAsync(cancellationToken);
await SerializeChapters(sw, videoMomentEdges, startOffsetSeconds);
await fs.FlushAsync(cancellationToken);
}
- private static async Task SerializeGlobalMetadata(StreamWriter sw, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount)
+ private static async Task SerializeGlobalMetadata(StreamWriter sw, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount, string videoDescription)
{
await sw.WriteLineAsync(";FFMETADATA1");
await sw.WriteLineAsync($"title={SanitizeKeyValue(videoTitle)} ({SanitizeKeyValue(videoId)})");
await sw.WriteLineAsync($"artist={SanitizeKeyValue(streamerName)}");
await sw.WriteLineAsync($"date={videoCreation:yyyy}"); // The 'date' key becomes 'year' in most formats
- await sw.WriteLineAsync(@$"comment=Originally aired: {SanitizeKeyValue(videoCreation.ToString("u"))}\");
+ await sw.WriteAsync(@"comment=");
+ if (!string.IsNullOrWhiteSpace(videoDescription))
+ {
+ await sw.WriteLineAsync(@$"{SanitizeKeyValue(videoDescription.TrimEnd())}\");
+ await sw.WriteLineAsync(@"------------------------\");
+ }
+ await sw.WriteLineAsync(@$"Originally aired: {SanitizeKeyValue(videoCreation.ToString("u"))}\");
await sw.WriteLineAsync(@$"Video id: {SanitizeKeyValue(videoId)}\");
- await sw.WriteLineAsync($"Views: {viewCount}");
+ await sw.WriteLineAsync(@$"Views: {viewCount}");
}
- private static async Task SerializeChapters(StreamWriter sw, List videoMomentEdges, double startOffsetSeconds)
+ private static async Task SerializeChapters(StreamWriter sw, IEnumerable videoMomentEdges, double startOffsetSeconds)
{
if (videoMomentEdges is null)
{
@@ -71,7 +78,7 @@ private static string SanitizeKeyValue(string str)
return str;
}
- if (str.AsSpan().IndexOfAny(@"=;#\") == -1)
+ if (str.AsSpan().IndexOfAny(@$"=;#\{LINE_FEED}") == -1)
{
return str;
}
@@ -81,6 +88,7 @@ private static string SanitizeKeyValue(string str)
.Replace(";", @"\;")
.Replace("#", @"\#")
.Replace(@"\", @"\\")
+ .Replace(LINE_FEED, $@"\{LINE_FEED}")
.ToString();
}
}
diff --git a/TwitchDownloaderCore/Tools/FfmpegProcess.cs b/TwitchDownloaderCore/Tools/FfmpegProcess.cs
index c81a1bc0..668a9749 100644
--- a/TwitchDownloaderCore/Tools/FfmpegProcess.cs
+++ b/TwitchDownloaderCore/Tools/FfmpegProcess.cs
@@ -5,7 +5,5 @@ namespace TwitchDownloaderCore.Tools
public sealed class FfmpegProcess : Process
{
public string SavePath { get; init; }
-
- public FfmpegProcess() { }
}
}
\ No newline at end of file
diff --git a/TwitchDownloaderCore/Tools/HighlightIcons.cs b/TwitchDownloaderCore/Tools/HighlightIcons.cs
index 15e9e1a0..f23a4c40 100644
--- a/TwitchDownloaderCore/Tools/HighlightIcons.cs
+++ b/TwitchDownloaderCore/Tools/HighlightIcons.cs
@@ -18,27 +18,30 @@ public enum HighlightType
PayingForward,
ChannelPointHighlight,
Raid,
+ BitBadgeTierNotification,
Unknown
}
public sealed class HighlightIcons : IDisposable
{
- public bool Disposed { get; private set; } = false;
+ public bool Disposed { get; private set; }
private const string SUBSCRIBED_TIER_ICON_SVG = "m 32.599229,13.144498 c 1.307494,-2.80819 5.494049,-2.80819 6.80154,0 l 5.648628,12.140919 13.52579,1.877494 c 3.00144,0.418654 4.244522,3.893468 2.138363,5.967405 -3.357829,3.309501 -6.715662,6.618992 -10.073491,9.928491 L 53.07148,56.81637 c 0.524928,2.962772 -2.821092,5.162303 -5.545572,3.645496 L 36,54.043603 24.474093,60.461866 C 21.749613,61.975455 18.403591,59.779142 18.92852,56.81637 L 21.359942,43.058807 11.286449,33.130316 c -2.1061588,-2.073937 -0.863074,-5.548751 2.138363,-5.967405 l 13.52579,-1.877494 z";
private const string SUBSCRIBED_PRIME_ICON_SVG = "m 61.894653,21.663055 v 25.89488 c 0,3.575336 -2.898361,6.47372 -6.473664,6.47372 H 16.57901 c -3.573827,-0.0036 -6.470094,-2.89986 -6.473663,-6.47372 V 21.663055 L 23.052674,31.373635 36,18.426194 c 4.315772,4.315816 8.631553,8.631629 12.947323,12.947441 z";
private const string GIFTED_SINGLE_ICON_SVG = "m 55.187956,23.24523 h 6.395987 V 42.433089 H 58.38595 V 61.620947 H 13.614042 V 42.433089 H 10.416049 V 23.24523 h 6.395987 v -3.859957 c 0,-8.017328 9.689919,-12.0307888 15.359963,-6.363975 0.418936,0.418935 0.796298,0.879444 1.125692,1.371934 l 2.702305,4.055034 2.702305,-4.055034 a 8.9863623,8.9863139 0 0 1 1.125692,-1.371934 c 5.666845,-5.6668138 15.359963,-1.653353 15.359963,6.363975 z M 23.208023,19.385273 v 3.859957 h 8.301992 l -3.536982,-5.305444 a 2.6031666,2.6031528 0 0 0 -4.76501,1.445487 z m 25.583946,0 v 3.859957 h -8.301991 l 3.536983,-5.305444 a 2.6031666,2.6031528 0 0 1 4.765008,1.442286 z m 6.395987,10.255909 v 6.395951 H 39.19799 v -6.395951 z m -3.197992,25.58381 V 42.433089 H 39.19799 V 55.224992 Z M 32.802003,29.641182 v 6.395951 H 16.812036 v -6.395951 z m 0,12.791907 H 20.010028 v 12.791903 h 12.791975 z";
private const string GIFTED_MANY_ICON_URL = "https://static-cdn.jtvnw.net/subs-image-assets/gift-illus.png";
private const string GIFTED_ANONYMOUS_ICON_SVG = "m 54.571425,64.514958 a 4.3531428,4.2396967 0 0 1 -1.273998,-0.86096 l -1.203426,-1.172067 a 7.0051428,6.822584 0 0 0 -9.90229,0 c -3.417139,3.328092 -8.962569,3.328092 -12.383427,0 l -0.159707,-0.155553 a 7.1871427,6.9998405 0 0 0 -9.854005,-0.28216 l -1.894286,1.635103 a 4.9362858,4.8076423 0 0 1 -3.276,1.215474 H 10 V 32.337399 a 26.000001,25.322423 0 0 1 52,0 v 32.557396 h -5.627146 c -0.627714,0 -1.240569,-0.133847 -1.801429,-0.379837 z M 35.999996,14.249955 A 18.571428,18.087444 0 0 0 17.428572,32.337399 v 22.515245 a 14.619428,14.238435 0 0 1 17.471998,2.358609 l 0.163448,0.155554 c 0.516285,0.50645 1.355715,0.50645 1.875712,0 a 14.437428,14.061179 0 0 1 17.631712,-2.11623 V 32.337399 A 18.571428,18.087444 0 0 0 35.999996,14.249955 Z M 24.857142,35.954887 a 3.7142855,3.6174889 0 1 1 7.42857,0 3.7142855,3.6174889 0 0 1 -7.42857,0 z m 18.571432,-3.617488 a 3.7142859,3.6174892 0 1 0 0,7.234978 3.7142859,3.6174892 0 0 0 0,-7.234978 z";
+ private const string BIT_BADGE_TIER_NOTIFICATION_ICON_SVG = "M 14.242705,42.37453 36,11.292679 57.757295,42.37453 36,61.023641 Z M 22.566425,41.323963 36,22.13092 49.433577,41.317747 46.79162,43.580506 36,39.266345 25.205273,43.586723 22.566425,41.320854 Z";
- private static readonly Regex SubMessageRegex = new(@"^(subscribed (?:with Prime|at Tier \d)\. They've subscribed for \d?\d?\d months(?:, currently on a \d?\d?\d month streak)?! )(.+)$", RegexOptions.Compiled);
- private static readonly Regex GiftAnonymousRegex = new(@"^An anonymous user (?:gifted a|is gifting \d\d?\d?) Tier \d", RegexOptions.Compiled);
+ private static readonly Regex SubMessageRegex = new(@"^(subscribed (?:with Prime|at Tier \d)\. They've subscribed for \d{1,3} months(?:, currently on a \d{1,3} month streak)?! )(.+)$", RegexOptions.Compiled);
+ private static readonly Regex GiftAnonymousRegex = new(@"^An anonymous user (?:gifted a|is gifting \d{1,4}) Tier \d", RegexOptions.Compiled);
private SKImage _subscribedTierIcon;
private SKImage _subscribedPrimeIcon;
private SKImage _giftSingleIcon;
private SKImage _giftManyIcon;
private SKImage _giftAnonymousIcon;
+ private SKImage _bitBadgeTierNotificationIcon;
private readonly string _cachePath;
private readonly SKColor _purple;
@@ -54,8 +57,6 @@ public HighlightIcons(string cachePath, SKColor iconPurple, bool offline)
// If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck
public static HighlightType GetHighlightType(Comment comment)
{
- const string ANONYMOUS_GIFT_ACCOUNT_ID = "274598607"; // '274598607' is the id of the anonymous gift message account, display name: 'AnAnonymousGifter'
-
if (comment.message.body.Length == 0)
{
// This likely happens due to the 7TV extension letting users bypass the IRC message trimmer
@@ -104,6 +105,9 @@ public static HighlightType GetHighlightType(Comment comment)
}
}
+ if (bodySpan.Equals("bits badge tier notification ", StringComparison.Ordinal))
+ return HighlightType.BitBadgeTierNotification;
+
if (char.IsDigit(bodySpan[0]) && bodySpan.Contains("have joined!", StringComparison.Ordinal))
{
// TODO: use bodySpan when .NET 7
@@ -111,61 +115,29 @@ public static HighlightType GetHighlightType(Comment comment)
return HighlightType.Raid;
}
+ const string ANONYMOUS_GIFT_ACCOUNT_ID = "274598607"; // Display name is 'AnAnonymousGifter'
if (comment.commenter._id is ANONYMOUS_GIFT_ACCOUNT_ID && GiftAnonymousRegex.IsMatch(comment.message.body))
return HighlightType.GiftedAnonymous;
return HighlightType.None;
}
- /// A the requested icon or null if no icon exists for the highlight type
- /// The icon returned is NOT a copy and should not be manually disposed.
+ /// The requested icon or if no icon exists for the highlight type
+ /// The returned is NOT a copy and should not be manually disposed.
public SKImage GetHighlightIcon(HighlightType highlightType, SKColor textColor, double fontSize)
{
- // Return the needed icon from cache or generate if null
return highlightType switch
{
- HighlightType.SubscribedTier => _subscribedTierIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
- HighlightType.SubscribedPrime => _subscribedPrimeIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
- HighlightType.GiftedSingle => _giftSingleIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
- HighlightType.GiftedMany => _giftManyIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
- HighlightType.GiftedAnonymous => _giftAnonymousIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
+ HighlightType.SubscribedTier => _subscribedTierIcon ??= GenerateSvgIcon(SUBSCRIBED_TIER_ICON_SVG, textColor, fontSize),
+ HighlightType.SubscribedPrime => _subscribedPrimeIcon ??= GenerateSvgIcon(SUBSCRIBED_PRIME_ICON_SVG, _purple, fontSize),
+ HighlightType.GiftedSingle => _giftSingleIcon ??= GenerateSvgIcon(GIFTED_SINGLE_ICON_SVG, textColor, fontSize),
+ HighlightType.GiftedMany => _giftManyIcon ??= GenerateGiftedManyIcon(fontSize, _cachePath, _offline),
+ HighlightType.GiftedAnonymous => _giftAnonymousIcon ??= GenerateSvgIcon(GIFTED_ANONYMOUS_ICON_SVG, textColor, fontSize),
+ HighlightType.BitBadgeTierNotification => _bitBadgeTierNotificationIcon ??= GenerateSvgIcon(BIT_BADGE_TIER_NOTIFICATION_ICON_SVG, textColor, fontSize),
_ => null
};
}
- private SKImage GenerateHighlightIcon(HighlightType highlightType, SKColor textColor, double fontSize)
- {
- // Generate the needed icon
- var returnIcon = highlightType is HighlightType.GiftedMany
- ? GenerateGiftedManyIcon(fontSize, _cachePath, _offline)
- : GenerateSvgIcon(highlightType, _purple, textColor, fontSize);
-
- // Cache the icon
- switch (highlightType)
- {
- case HighlightType.SubscribedTier:
- _subscribedTierIcon = returnIcon;
- break;
- case HighlightType.SubscribedPrime:
- _subscribedPrimeIcon = returnIcon;
- break;
- case HighlightType.GiftedSingle:
- _giftSingleIcon = returnIcon;
- break;
- case HighlightType.GiftedMany:
- _giftManyIcon = returnIcon;
- break;
- case HighlightType.GiftedAnonymous:
- _giftAnonymousIcon = returnIcon;
- break;
- default:
- throw new NotSupportedException("The requested highlight icon does not exist.");
- }
-
- // Return the generated icon
- return returnIcon;
- }
-
private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath, bool offline)
{
//int newSize = (int)(fontSize / 0.2727); // 44*44px @ 12pt font // Doesn't work because our image sections aren't tall enough and I'm not rewriting that right now
@@ -192,36 +164,22 @@ private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath,
return SKImage.FromBitmap(resizedBitmap);
}
- private static SKImage GenerateSvgIcon(HighlightType highlightType, SKColor purple, SKColor textColor, double fontSize)
+ private static SKImage GenerateSvgIcon(string iconSvgString, SKColor iconColor, double fontSize)
{
using var tempBitmap = new SKBitmap(72, 72); // Icon SVG strings are scaled for 72x72
using var tempCanvas = new SKCanvas(tempBitmap);
- using var iconPath = SKPath.ParseSvgPathData(highlightType switch
- {
- HighlightType.SubscribedTier => SUBSCRIBED_TIER_ICON_SVG,
- HighlightType.SubscribedPrime => SUBSCRIBED_PRIME_ICON_SVG,
- HighlightType.GiftedSingle => GIFTED_SINGLE_ICON_SVG,
- HighlightType.GiftedAnonymous => GIFTED_ANONYMOUS_ICON_SVG,
- _ => throw new NotSupportedException("The requested icon svg path does not exist.")
- });
+ using var iconPath = SKPath.ParseSvgPathData(iconSvgString);
iconPath.FillType = SKPathFillType.EvenOdd;
- var iconColor = new SKPaint
+ var iconPaint = new SKPaint
{
- Color = highlightType switch
- {
- HighlightType.SubscribedTier => textColor,
- HighlightType.SubscribedPrime => purple,
- HighlightType.GiftedSingle => textColor,
- HighlightType.GiftedAnonymous => textColor,
- _ => throw new NotSupportedException("The requested icon color does not exist.")
- },
+ Color = iconColor,
IsAntialias = true,
LcdRenderText = true
};
- tempCanvas.DrawPath(iconPath, iconColor);
+ tempCanvas.DrawPath(iconPath, iconPaint);
var newSize = (int)(fontSize / 0.6); // 20*20px @ 12pt font
var imageInfo = new SKImageInfo(newSize, newSize);
var resizedBitmap = tempBitmap.Resize(imageInfo, SKFilterQuality.High);
@@ -309,6 +267,7 @@ private void Dispose(bool isDisposing)
_giftSingleIcon?.Dispose();
_giftManyIcon?.Dispose();
_giftAnonymousIcon?.Dispose();
+ _bitBadgeTierNotificationIcon?.Dispose();
// Set the root references to null to explicitly tell the garbage collector that the resources have been disposed
_subscribedTierIcon = null;
@@ -316,6 +275,7 @@ private void Dispose(bool isDisposing)
_giftSingleIcon = null;
_giftManyIcon = null;
_giftAnonymousIcon = null;
+ _bitBadgeTierNotificationIcon = null;
}
}
finally
diff --git a/TwitchDownloaderCore/Tools/JsonElementExtensions.cs b/TwitchDownloaderCore/Tools/JsonElementExtensions.cs
new file mode 100644
index 00000000..24f0e82e
--- /dev/null
+++ b/TwitchDownloaderCore/Tools/JsonElementExtensions.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using System.Text.Json;
+
+namespace TwitchDownloaderCore.Tools
+{
+ public static class JsonElementExtensions
+ {
+ public static List DeserializeFirstAndLastFromList(this JsonElement arrayElement, JsonSerializerOptions options = null)
+ {
+ // It's not the prettiest, but for arrays with thousands of objects it can save whole seconds and prevent tons of fragmented memory
+ var list = new List(2);
+ JsonElement lastElement = default;
+ foreach (var element in arrayElement.EnumerateArray())
+ {
+ if (list.Count == 0)
+ {
+ list.Add(element.Deserialize(options: options));
+ continue;
+ }
+
+ lastElement = element;
+ }
+
+ if (lastElement.ValueKind != JsonValueKind.Undefined)
+ {
+ list.Add(lastElement.Deserialize(options: options));
+ }
+
+ return list;
+ }
+ }
+}
\ No newline at end of file
diff --git a/TwitchDownloaderCore/Tools/ThrottledStream.cs b/TwitchDownloaderCore/Tools/ThrottledStream.cs
index e5c4bb32..087986a9 100644
--- a/TwitchDownloaderCore/Tools/ThrottledStream.cs
+++ b/TwitchDownloaderCore/Tools/ThrottledStream.cs
@@ -11,8 +11,8 @@ public class ThrottledStream : Stream
{
public readonly Stream BaseStream;
public readonly int MaximumBytesPerSecond;
- private readonly Stopwatch _watch = Stopwatch.StartNew();
- private long _totalBytesRead = 0;
+ private Stopwatch _watch;
+ private long _totalBytesRead;
///
/// Initializes a new instance of the class
@@ -79,6 +79,8 @@ private async Task GetBytesToReturnAsync(int count)
if (MaximumBytesPerSecond <= 0)
return count;
+ _watch ??= Stopwatch.StartNew();
+
var canSend = (long)(_watch.ElapsedMilliseconds * (MaximumBytesPerSecond / 1000.0));
var diff = (int)(canSend - _totalBytesRead);
diff --git a/TwitchDownloaderCore/Tools/UrlParse.cs b/TwitchDownloaderCore/Tools/UrlParse.cs
index d5a50914..ebc75e93 100644
--- a/TwitchDownloaderCore/Tools/UrlParse.cs
+++ b/TwitchDownloaderCore/Tools/UrlParse.cs
@@ -1,26 +1,22 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
+using System.Text.RegularExpressions;
namespace TwitchDownloaderCore.Tools
{
- public enum VideoPlatform
+ public static class UrlParse
{
- Twitch,
- Kick,
- Youtube
- }
+ // TODO: Use source generators when .NET7
+ private static readonly Regex TwitchVideoId = new(@"(?<=^|twitch\.tv\/videos\/)\d+(?=$|\?|\s)", RegexOptions.Compiled);
+ private static readonly Regex TwitchHighlightId = new(@"(?<=^|twitch\.tv\/\w+\/video\/)\d+(?=$|\?|\s)", RegexOptions.Compiled);
+ private static readonly Regex TwitchClipId = new(@"(?<=^|(?:clips\.)?twitch\.tv\/(?:\w+\/clip\/)?)[\w-]+?(?=$|\?|\s)", RegexOptions.Compiled);
+
+ private static readonly Regex KickVideoId = new(@"(?<=kick\.com\/video\/)[\w-]+", RegexOptions.Compiled);
+ private static readonly Regex KickClipId = new(@"(?<=kick\.com\/\S+\?clip=)[\w-]+", RegexOptions.Compiled);
+
+ public static readonly Regex UrlTimeCode = new(@"(?<=(?:\?|&)t=)\d+h\d+m\d+s(?=$|\?|\s)", RegexOptions.Compiled);
- public class UrlParse
- {
public static bool TryParseClip(string url, out VideoPlatform videoPlatform, out string videoId)
{
- var twitchClipIdRegex = new Regex(@"(?<=^|(?:clips\.)?twitch\.tv\/(?:\S+\/clip)?\/?)[\w-]+?(?=$|\?)");
- var twitchClipIdMatch = twitchClipIdRegex.Match(url);
-
+ var twitchClipIdMatch = TwitchClipId.Match(url);
if (twitchClipIdMatch.Success)
{
videoPlatform = VideoPlatform.Twitch;
@@ -28,9 +24,7 @@ public static bool TryParseClip(string url, out VideoPlatform videoPlatform, out
return true;
}
- var kickClipIdRegex = new Regex(@"(?<=kick\.com\/\S+\?clip=)[\w-]+");
- var kickClipIdMatch = kickClipIdRegex.Match(url);
-
+ var kickClipIdMatch = KickClipId.Match(url);
if (kickClipIdMatch.Success)
{
videoPlatform = VideoPlatform.Kick;
@@ -45,9 +39,7 @@ public static bool TryParseClip(string url, out VideoPlatform videoPlatform, out
public static bool TryParseVod(string url, out VideoPlatform videoPlatform, out string videoId)
{
- var twitchVodRegex = new Regex(@"(?<=^|twitch\.tv\/videos\/)\d+(?=$|\?)");
- var twitchVodIdMatch = twitchVodRegex.Match(url);
-
+ var twitchVodIdMatch = TwitchVideoId.Match(url);
if (twitchVodIdMatch.Success)
{
videoPlatform = VideoPlatform.Twitch;
@@ -55,9 +47,15 @@ public static bool TryParseVod(string url, out VideoPlatform videoPlatform, out
return true;
}
- var kickVodIdRegex = new Regex(@"(?<=kick\.com\/video\/)[\w-]+");
- var kickVodIdMatch = kickVodIdRegex.Match(url);
+ var twitchHighlightIdMatch = TwitchHighlightId.Match(url);
+ if (twitchHighlightIdMatch.Success)
+ {
+ videoPlatform = VideoPlatform.Twitch;
+ videoId = twitchVodIdMatch.Value;
+ return true;
+ }
+ var kickVodIdMatch = KickVideoId.Match(url);
if (kickVodIdMatch.Success)
{
videoPlatform = VideoPlatform.Kick;
@@ -69,5 +67,25 @@ public static bool TryParseVod(string url, out VideoPlatform videoPlatform, out
videoId = "Don't ignore return value";
return false;
}
+
+ public static bool TryParseVideoOrClipId(string url, out VideoPlatform videoPlatform, out VideoType videoType, out string videoId)
+ {
+ if (TryParseVod(url, out videoPlatform, out videoId))
+ {
+ videoType = VideoType.Video;
+ return true;
+ }
+
+ if (TryParseClip(url, out videoPlatform, out videoId))
+ {
+ videoType = VideoType.Clip;
+ return true;
+ }
+
+ videoPlatform = VideoPlatform.Twitch;
+ videoType = VideoType.Video;
+ videoId = "Don't ignore return value";
+ return false;
+ }
}
}
diff --git a/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs b/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs
index fa8d8890..eec8badb 100644
--- a/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs
+++ b/TwitchDownloaderCore/Tools/VideoSizeEstimator.cs
@@ -31,6 +31,5 @@ public static long EstimateVideoSize(int bandwidth, TimeSpan startTime, TimeSpan
var totalTime = endTime - startTime;
return (long)(bandwidth / 8d * totalTime.TotalSeconds);
}
-
}
}
\ No newline at end of file
diff --git a/TwitchDownloaderCore/TwitchDownloaderCore.csproj b/TwitchDownloaderCore/TwitchDownloaderCore.csproj
index 3e2c018d..8b88266a 100644
--- a/TwitchDownloaderCore/TwitchDownloaderCore.csproj
+++ b/TwitchDownloaderCore/TwitchDownloaderCore.csproj
@@ -10,6 +10,7 @@
AnyCPU;x64
default
true
+
@@ -23,13 +24,13 @@
-
+
+
-
-
-
-
-
+
+
+
+
diff --git a/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickChatDownloader.cs b/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickChatDownloader.cs
index cbd270a1..5bb45023 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickChatDownloader.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickChatDownloader.cs
@@ -14,8 +14,6 @@
using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
using TwitchDownloaderCore.VideoPlatforms.Twitch;
using System.Text.Json;
-using Microsoft.Extensions.FileSystemGlobbing.Internal;
-using System.Globalization;
namespace TwitchDownloaderCore.VideoPlatforms.Kick.Downloaders
{
@@ -69,7 +67,7 @@ private async Task> DownloadSection(int streamerId, DateTime video
}
string formattedTime = dateTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
- string response = await Task.Run(() => CurlImpersonate.GetCurlReponse($"https://kick.com/api/v2/channels/{streamerId}/messages?start_time={formattedTime}"));
+ string response = await Task.Run(() => CurlImpersonate.GetCurlResponse($"https://kick.com/api/v2/channels/{streamerId}/messages?start_time={formattedTime}"));
chatResponse = JsonSerializer.Deserialize(response);
Console.WriteLine(formattedTime);
@@ -229,8 +227,6 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
throw new NotImplementedException("Kick chat download as HTML is not currently supported");
}
- bool vodParsed = UrlParse.TryParseVod(downloadOptions.Id, out VideoPlatform videoPlatformVod, out string vodId);
-
ChatRoot chatRoot = new()
{
FileInfo = new ChatRootInfo { Version = ChatRootVersion.CurrentVersion, CreatedAt = DateTime.Now },
@@ -250,7 +246,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
int viewCount;
string game;
- if (downloadOptions.DownloadType == ChatDownloadType.Video)
+ if (downloadOptions.VideoType == VideoType.Video)
{
KickVideoResponse videoInfo = await KickHelper.GetVideoInfo(videoId);
if (videoInfo.id == 0)
@@ -272,7 +268,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
}
else
{
- throw new NotImplementedException();
+ throw new NotImplementedException("Downloading chats from Kick clips is not implemented.");
GqlClipResponse clipInfoResponse = await TwitchHelper.GetClipInfo(videoId);
if (clipInfoResponse.data.clip.video == null || clipInfoResponse.data.clip.videoOffsetSeconds == null)
{
@@ -369,6 +365,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
if (downloadOptions.EmbedData && downloadOptions.DownloadFormat is ChatFormat.Json or ChatFormat.Html)
{
//TODO: Implement emote embeds
+ _progress.Report(new ProgressReport(ReportType.Log, "Emote embeds are not yet implemented for Kick chats."));
}
_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Writing output file"));
@@ -388,7 +385,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
}
}
- static async Task[]> RunTasksWithLimitedConcurrency(int degreeOfParallelism, List>>> tasks)
+ private static async Task[]> RunTasksWithLimitedConcurrency(int degreeOfParallelism, List>>> tasks)
{
var semaphore = new SemaphoreSlim(degreeOfParallelism);
var taskList = new List>>();
diff --git a/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickClipDownloader.cs b/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickClipDownloader.cs
index 562ffa45..f9d9c680 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickClipDownloader.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickClipDownloader.cs
@@ -1,4 +1,5 @@
using System;
+using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -58,7 +59,7 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress)
if (!downloadOptions.EncodeMetadata && alreadyEncoded)
{
- await DownloadTools.DownloadClipFileTaskAsync(response.VideoUrl, downloadOptions.Filename, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken);
+ await DownloadTools.DownloadFileAsync(response.VideoUrl, downloadOptions.Filename, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken);
return;
}
@@ -80,14 +81,12 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress)
await using var outputStream = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None);
for (int i = 0; i < downloadUrls.Count; i++)
{
- string downloadPath = Path.Combine(tempDownloadFolder, Path.GetFileName(downloadUrls[i].DownloadUrl));
- await DownloadTools.DownloadClipFileTaskAsync(downloadUrls[i].DownloadUrl, downloadPath, downloadOptions.ThrottleKib, null, cancellationToken);
+ string downloadPath = Path.Combine(tempDownloadFolder, Path.GetFileName(downloadUrls[i].DownloadUrl)!);
+ await DownloadTools.DownloadFileAsync(downloadUrls[i].DownloadUrl, downloadPath, downloadOptions.ThrottleKib, null, cancellationToken);
await using (var fs = File.Open(downloadPath, FileMode.Open, FileAccess.Read, FileShare.None))
{
fs.Seek(downloadUrls[i].StartByteOffset, SeekOrigin.Begin);
- byte[] buffer = new byte[downloadUrls[i].ByteRangeLength + 1];
- await fs.ReadAsync(buffer, 0, downloadUrls[i].ByteRangeLength);
- await outputStream.WriteAsync(buffer, cancellationToken);
+ await fs.CopyBytesToAsync(outputStream, downloadUrls[i].ByteRangeLength, cancellationToken);
}
var percent = (int)((i+1) / (double)downloadUrls.Count * 100);
_progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Downloading Clip {percent}%"));
@@ -96,7 +95,7 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress)
}
else
{
- await DownloadTools.DownloadClipFileTaskAsync(response.VideoUrl, tempFile, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken);
+ await DownloadTools.DownloadFileAsync(response.VideoUrl, tempFile, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken);
}
_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Encoding Clip Metadata 0%"));
@@ -109,7 +108,7 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress)
}
finally
{
- File.Delete(tempFile);
+ Directory.Delete(tempDownloadFolder, true);
}
}
diff --git a/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickVideoDownloader.cs b/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickVideoDownloader.cs
index d02ce172..5b9d6f01 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickVideoDownloader.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Kick/Downloaders/KickVideoDownloader.cs
@@ -48,7 +48,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
ServicePointManager.DefaultConnectionLimit = downloadOptions.DownloadThreads;
IVideoInfo videoInfo = await KickHelper.GetVideoInfo(downloadOptions.Id);
var (playlistUrl, bandwidth) = GetPlaylistUrl(videoInfo);
- string baseUrl = playlistUrl.Substring(0, playlistUrl.LastIndexOf('/') + 1);
+ var baseUrl = new Uri(playlistUrl[..(playlistUrl.LastIndexOf('/') + 1)]);
var videoLength = TimeSpan.FromSeconds(videoInfo.Duration);
DriveHelper.CheckAvailableStorageSpace(downloadOptions, bandwidth, videoLength, _progress);
@@ -62,11 +62,11 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Downloading 0% [2/5]"));
- await DownloadTools.DownloadVideoPartsAsync(downloadOptions, videoPartsList, baseUrl, downloadFolder, 0, _progress, cancellationToken);
+ await DownloadTools.DownloadVideoPartsAsync(downloadOptions, videoPartsList, baseUrl, downloadFolder, 0, 2, 5, _progress, cancellationToken);
_progress.Report(new ProgressReport() { ReportType = ReportType.NewLineStatus, Data = "Verifying Parts 0% [3/5]" });
- await DownloadTools.VerifyDownloadedParts(downloadOptions, videoPartsList, baseUrl, downloadFolder, 0, _progress, cancellationToken);
+ await DownloadTools.VerifyDownloadedParts(downloadOptions, videoPartsList, baseUrl, downloadFolder, 0, 3, 5, _progress, cancellationToken);
_progress.Report(new ProgressReport() { ReportType = ReportType.NewLineStatus, Data = "Combining Parts 0% [4/5]" });
@@ -88,8 +88,8 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
double seekDuration = Math.Round(downloadOptions.CropEndingTime - seekTime);
string metadataPath = Path.Combine(downloadFolder, "metadata.txt");
- await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.StreamerName, downloadOptions.Id.ToString(), videoInfo.Title,
- videoInfo.CreatedAt, videoInfo.ViewCount, startOffset, null, cancellationToken);
+ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.StreamerName, downloadOptions.Id, videoInfo.Title,
+ videoInfo.CreatedAt, videoInfo.ViewCount, null, startOffset, null, cancellationToken);
var finalizedFileDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!;
if (!finalizedFileDirectory.Exists)
diff --git a/TwitchDownloaderCore/VideoPlatforms/Kick/KickHelper.cs b/TwitchDownloaderCore/VideoPlatforms/Kick/KickHelper.cs
index 9067dca5..e3147e09 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Kick/KickHelper.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Kick/KickHelper.cs
@@ -20,7 +20,7 @@ public class KickHelper
private static readonly HttpClient HttpClient = new();
public static async Task GetClipInfo(string clipId)
{
- string response = await Task.Run(() => CurlImpersonate.GetCurlReponse($"https://kick.com/api/v2/clips/{clipId}"));
+ string response = await Task.Run(() => CurlImpersonate.GetCurlResponse($"https://kick.com/api/v2/clips/{clipId}"));
KickClipResponse clipResponse = JsonSerializer.Deserialize(response);
if (clipResponse.clip == null)
@@ -97,7 +97,7 @@ public static List GetDownloadUrls(string url, string playlistD
public static async Task GetVideoInfo(string videoId)
{
- string response = await Task.Run(() => CurlImpersonate.GetCurlReponse($"https://kick.com/api/v1/video/{videoId}"));
+ string response = await Task.Run(() => CurlImpersonate.GetCurlResponse($"https://kick.com/api/v1/video/{videoId}"));
KickVideoResponse videoResponse = JsonSerializer.Deserialize(response);
if (videoResponse.id == 0)
@@ -114,12 +114,12 @@ public static async Task GetVideoInfo(string videoId)
{
if (playlist[i].Contains("#EXT-X-MEDIA"))
{
- string lastPart = playlist[i].Substring(playlist[i].IndexOf("NAME=\"") + 6);
+ string lastPart = playlist[i].Substring(playlist[i].IndexOf("NAME=\"", StringComparison.Ordinal) + 6);
string stringQuality = lastPart.Substring(0, lastPart.IndexOf('"'));
- var bandwidthStartIndex = playlist[i + 1].IndexOf("BANDWIDTH=") + 10;
- var bandwidthEndIndex = playlist[i + 1].Substring(bandwidthStartIndex).IndexOf(',');
- int.TryParse(playlist[i + 1].Substring(bandwidthStartIndex, bandwidthEndIndex), out var bandwidth);
+ var bandwidthStartIndex = playlist[i + 1].IndexOf("BANDWIDTH=", StringComparison.Ordinal) + 10;
+ var bandwidthEndIndex = playlist[i + 1].AsSpan(bandwidthStartIndex).IndexOf(',');
+ int.TryParse(playlist[i + 1].AsSpan(bandwidthStartIndex, bandwidthEndIndex), out var bandwidth);
videoResponse.VideoQualities.Add(new VideoQuality { Quality = stringQuality, SourceUrl = baseUrl + playlist[i + 2], Bandwidth = bandwidth });
}
@@ -254,14 +254,14 @@ where comments.Any(comment => Regex.IsMatch(comment.message.body, pattern))
{
cancellationToken.ThrowIfCancellationRequested();
- EmoteResponse emoteReponse = new();
+ EmoteResponse emoteResponse = new();
if (getStv)
{
- await PlatformHelper.GetStvEmoteData(streamerId, emoteReponse.STV, allowUnlistedEmotes, VideoPlatform.Kick);
+ emoteResponse.STV = await PlatformHelper.GetStvEmotesMetadata(streamerId, allowUnlistedEmotes, VideoPlatform.Kick, cancellationToken);
}
- return emoteReponse;
+ return emoteResponse;
}
}
}
\ No newline at end of file
diff --git a/TwitchDownloaderCore/VideoPlatforms/Kick/KickVideoResponse.cs b/TwitchDownloaderCore/VideoPlatforms/Kick/KickVideoResponse.cs
index 2f76041e..3e32953a 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Kick/KickVideoResponse.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Kick/KickVideoResponse.cs
@@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
using TwitchDownloaderCore.VideoPlatforms.Interfaces;
namespace TwitchDownloaderCore.VideoPlatforms.Kick
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/ChatRoot.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/ChatRoot.cs
index a23093ef..a3da9ad2 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/ChatRoot.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/ChatRoot.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Text.Json;
using System.Text.Json.Serialization;
using TwitchDownloaderCore.Tools;
@@ -12,6 +13,13 @@ public class Streamer
public int id { get; set; }
}
+ public class LegacyStreamer
+ {
+ public string name { get; set; }
+ /// Some old chats use a string instead of an integer.
+ public JsonElement id { get; set; }
+ }
+
[DebuggerDisplay("{display_name}")]
public class Commenter
{
@@ -194,6 +202,7 @@ public class VideoChapter
public class Video
{
public string title { get; set; }
+ public string description { get; set; }
public string id { get; set; }
public DateTime created_at { get; set; }
public double start { get; set; }
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/ChatRootInfo.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/ChatRootInfo.cs
index a5f43b4e..82efaaa6 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/ChatRootInfo.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/ChatRootInfo.cs
@@ -4,101 +4,63 @@ namespace TwitchDownloaderCore.VideoPlatforms.Twitch
{
public class ChatRootInfo
{
- public ChatRootVersion Version { get; init; } = new ChatRootVersion();
+ public ChatRootVersion Version { get; init; } = new();
public DateTime CreatedAt { get; init; } = DateTime.FromBinary(0);
public DateTime UpdatedAt { get; init; } = DateTime.FromBinary(0);
-
- public ChatRootInfo() { }
}
- public class ChatRootVersion
+ public record ChatRootVersion
{
- // Fields
- public int Major { get; set; } = 1;
- public int Minor { get; set; } = 0;
- public int Patch { get; set; } = 0;
+ public uint Major { get; init; }
+ public uint Minor { get; init; }
+ public uint Patch { get; init; }
public static ChatRootVersion CurrentVersion { get; } = new(1, 3, 2);
- // Constructors
///
/// Initializes a new object with the default version of 1.0.0
///
- public ChatRootVersion() { }
+ public ChatRootVersion()
+ {
+ Major = 1;
+ Minor = 0;
+ Patch = 0;
+ }
///
/// Initializes a new object with the version number of . .
///
- public ChatRootVersion(int major, int minor, int patch)
+ public ChatRootVersion(uint major, uint minor, uint patch)
{
Major = major;
Minor = minor;
Patch = patch;
}
- // Methods
public override string ToString()
=> $"{Major}.{Minor}.{Patch}";
public override int GetHashCode()
=> ToString().GetHashCode();
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0083:Use pattern matching")]
- public override bool Equals(object obj)
- {
- if (ReferenceEquals(this, obj))
- return true;
-
- if (!(obj is ChatRootVersion crv))
- return false;
-
- return this == crv;
- }
-
- // Operators
public static bool operator >(ChatRootVersion left, ChatRootVersion right)
{
if (left.Major > right.Major) return true;
- else if (left.Major == right.Major)
- {
- if (left.Minor > right.Minor) return true;
- else if (left.Minor == right.Minor)
- {
- if (left.Patch > right.Patch) return true;
- }
- }
- return false;
- }
+ if (left.Major < right.Major) return false;
- public static bool operator <(ChatRootVersion left, ChatRootVersion right)
- {
- if (left.Major < right.Major) return true;
- else if (left.Major == right.Major)
- {
- if (left.Minor < right.Minor) return true;
- else if (left.Minor == right.Minor)
- {
- if (left.Patch < right.Patch) return true;
- }
- }
- return false;
- }
+ if (left.Minor > right.Minor) return true;
+ if (left.Minor < right.Minor) return false;
- public static bool operator ==(ChatRootVersion left, ChatRootVersion right)
- {
- if (left.Major != right.Major) return false;
- if (left.Minor != right.Minor) return false;
- if (left.Patch != right.Patch) return false;
- return true;
+ return left.Patch > right.Patch;
}
- public static bool operator !=(ChatRootVersion left, ChatRootVersion right)
- => !(left == right);
+ public static bool operator <(ChatRootVersion left, ChatRootVersion right)
+ => right > left;
public static bool operator >=(ChatRootVersion left, ChatRootVersion right)
- => left > right || left == right;
+ => left == right || left > right;
public static bool operator <=(ChatRootVersion left, ChatRootVersion right)
- => left < right || left == right;
+ => left == right || left < right;
}
}
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchChatDownloader.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchChatDownloader.cs
index 97382f69..d3faec18 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchChatDownloader.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchChatDownloader.cs
@@ -12,7 +12,6 @@
using TwitchDownloaderCore.Options;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Interfaces;
-using TwitchDownloaderCore.VideoPlatforms.Twitch;
using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
namespace TwitchDownloaderCore.VideoPlatforms.Twitch.Downloaders
@@ -27,16 +26,11 @@ public sealed class TwitchChatDownloader : IChatDownloader
BaseAddress = new Uri("https://gql.twitch.tv/gql"),
DefaultRequestHeaders = { { "Client-ID", "kd1unb4b3q4t58fwlpcbzcbnm76a8fp" } }
};
- private static readonly Regex BitsRegex = new(
+
+ public static readonly Regex BitsRegex = new(
@"(?<=(?:\s|^)(?:4Head|Anon|Bi(?:bleThumb|tBoss)|bday|C(?:h(?:eer|arity)|orgo)|cheerwal|D(?:ansGame|oodleCheer)|EleGiggle|F(?:rankerZ|ailFish)|Goal|H(?:eyGuys|olidayCheer)|K(?:appa|reygasm)|M(?:rDestructoid|uxy)|NotLikeThis|P(?:arty|ride|JSalt)|RIPCheer|S(?:coops|h(?:owLove|amrock)|eemsGood|wiftRage|treamlabs)|TriHard|uni|VoHiYo))[1-9]\d?\d?\d?\d?\d?\d?(?=\s|$)",
RegexOptions.Compiled);
- private enum DownloadType
- {
- Clip,
- Video
- }
-
public TwitchChatDownloader(ChatDownloadOptions chatDownloadOptions, IProgress progress)
{
downloadOptions = chatDownloadOptions;
@@ -255,8 +249,6 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
throw new NullReferenceException("Null or empty video/clip ID");
}
- DownloadType downloadType = downloadOptions.Id.All(char.IsDigit) ? DownloadType.Video : DownloadType.Clip;
-
ChatRoot chatRoot = new()
{
FileInfo = new ChatRootInfo { Version = ChatRootVersion.CurrentVersion, CreatedAt = DateTime.Now },
@@ -277,7 +269,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
string game;
int connectionCount = downloadOptions.ConnectionCount;
- if (downloadType == DownloadType.Video)
+ if (downloadOptions.VideoType == VideoType.Video)
{
GqlVideoResponse videoInfoResponse = await TwitchHelper.GetVideoInfo(int.Parse(videoId));
if (videoInfoResponse.data.video == null)
@@ -287,6 +279,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
chatRoot.streamer.name = videoInfoResponse.data.video.owner.displayName;
chatRoot.streamer.id = int.Parse(videoInfoResponse.data.video.owner.id);
+ chatRoot.video.description = videoInfoResponse.data.video.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd();
videoTitle = videoInfoResponse.data.video.title;
videoCreatedAt = videoInfoResponse.data.video.createdAt;
videoStart = downloadOptions.CropBeginning ? downloadOptions.CropBeginningTime : 0.0;
@@ -295,7 +288,8 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
viewCount = videoInfoResponse.data.video.viewCount;
game = videoInfoResponse.data.video.game?.displayName ?? "Unknown";
- GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetVideoChapters(int.Parse(videoId));
+ GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetOrGenerateVideoChapters(int.Parse(videoId), videoInfoResponse.data.video);
+ chatRoot.video.chapters.EnsureCapacity(videoChapterResponse.data.video.moments.edges.Count);
foreach (var responseChapter in videoChapterResponse.data.video.moments.edges)
{
chatRoot.video.chapters.Add(new VideoChapter
@@ -336,6 +330,21 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
viewCount = clipInfoResponse.data.clip.viewCount;
game = clipInfoResponse.data.clip.game?.displayName ?? "Unknown";
connectionCount = 1;
+
+ var clipChapter = TwitchHelper.GenerateClipChapter(clipInfoResponse.data.clip);
+ chatRoot.video.chapters.Add(new VideoChapter
+ {
+ id = clipChapter.node.id,
+ startMilliseconds = clipChapter.node.positionMilliseconds,
+ lengthMilliseconds = clipChapter.node.durationMilliseconds,
+ _type = clipChapter.node._type,
+ description = clipChapter.node.description,
+ subDescription = clipChapter.node.subDescription,
+ thumbnailUrl = clipChapter.node.thumbnailURL,
+ gameId = clipChapter.node.details.game?.id,
+ gameDisplayName = clipChapter.node.details.game?.displayName,
+ gameBoxArtUrl = clipChapter.node.details.game?.boxArtURL
+ });
}
chatRoot.video.id = videoId;
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchClipDownloader.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchClipDownloader.cs
index 9dea3533..f3e2261e 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchClipDownloader.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchClipDownloader.cs
@@ -2,7 +2,6 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
-using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
@@ -10,6 +9,7 @@
using TwitchDownloaderCore.Options;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Interfaces;
+using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
namespace TwitchDownloaderCore.VideoPlatforms.Twitch.Downloaders
{
@@ -32,6 +32,7 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Fetching Clip Info"));
var downloadUrl = await GetDownloadUrl();
+ var clipInfo = await TwitchHelper.GetClipInfo(downloadOptions.Id);
cancellationToken.ThrowIfCancellationRequested();
@@ -52,7 +53,7 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress)
if (!downloadOptions.EncodeMetadata)
{
- await DownloadTools.DownloadClipFileTaskAsync(downloadUrl, downloadOptions.Filename, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken);
+ await DownloadTools.DownloadFileAsync(downloadUrl, downloadOptions.Filename, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken);
return;
}
@@ -64,12 +65,13 @@ void DownloadProgressHandler(StreamCopyProgress streamProgress)
var tempFile = Path.Combine(downloadOptions.TempFolder, $"clip_{DateTimeOffset.Now.ToUnixTimeMilliseconds()}_{Path.GetRandomFileName()}");
try
{
- await DownloadTools.DownloadClipFileTaskAsync(downloadUrl, tempFile, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken);
+ await DownloadTools.DownloadFileAsync(downloadUrl, tempFile, downloadOptions.ThrottleKib, new Progress(DownloadProgressHandler), cancellationToken);
_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Encoding Clip Metadata 0%"));
_progress.Report(new ProgressReport(0));
- await EncodeClipMetadata(tempFile, downloadOptions.Filename, cancellationToken);
+ var clipChapter = TwitchHelper.GenerateClipChapter(clipInfo.data.clip);
+ await EncodeClipWithMetadata(tempFile, downloadOptions.Filename, clipInfo.data.clip, clipChapter, cancellationToken);
_progress.Report(new ProgressReport(ReportType.SameLineStatus, "Encoding Clip Metadata 100%"));
_progress.Report(new ProgressReport(100));
@@ -98,7 +100,7 @@ private async Task GetDownloadUrl()
foreach (var quality in listLinks[0].data.clip.videoQualities)
{
- if (quality.quality + "p" + (quality.frameRate.ToString() == "30" ? "" : quality.frameRate.ToString()) == downloadOptions.Quality)
+ if (quality.quality + "p" + (quality.frameRate == 30 ? "" : quality.frameRate.ToString()) == downloadOptions.Quality)
{
downloadUrl = quality.sourceURL;
}
@@ -112,15 +114,14 @@ private async Task GetDownloadUrl()
return downloadUrl + "?sig=" + listLinks[0].data.clip.playbackAccessToken.signature + "&token=" + HttpUtility.UrlEncode(listLinks[0].data.clip.playbackAccessToken.value);
}
- private async Task EncodeClipMetadata(string inputFile, string destinationFile, CancellationToken cancellationToken)
+ private async Task EncodeClipWithMetadata(string inputFile, string destinationFile, Clip clipMetadata, VideoMomentEdge clipChapter, CancellationToken cancellationToken)
{
- var metadataFile = $"{Path.GetFileNameWithoutExtension(inputFile)}_metadata{Path.GetExtension(inputFile)}";
- var clipInfo = await TwitchHelper.GetClipInfo(downloadOptions.Id);
+ var metadataFile = $"{Path.GetFileName(inputFile)}_metadata.txt";
try
{
- await FfmpegMetadata.SerializeAsync(metadataFile, clipInfo.data.clip.broadcaster.displayName, downloadOptions.Id, clipInfo.data.clip.title, clipInfo.data.clip.createdAt,
- clipInfo.data.clip.viewCount, cancellationToken: cancellationToken);
+ await FfmpegMetadata.SerializeAsync(metadataFile, clipMetadata.broadcaster.displayName, downloadOptions.Id, clipMetadata.title, clipMetadata.createdAt, clipMetadata.viewCount,
+ videoMomentEdges: new[] { clipChapter }, cancellationToken: cancellationToken);
var process = new Process
{
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchVideoDownloader.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchVideoDownloader.cs
index ca5dc556..81338749 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchVideoDownloader.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/Downloaders/TwitchVideoDownloader.cs
@@ -55,10 +55,10 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
throw new NullReferenceException("Invalid VOD, deleted/expired VOD possibly?");
}
- GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetVideoChapters(int.Parse(downloadOptions.Id));
+ GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetOrGenerateVideoChapters(int.Parse(downloadOptions.Id), videoInfoResponse.data.video);
var (playlistUrl, bandwidth) = await GetPlaylistUrl();
- string baseUrl = playlistUrl.Substring(0, playlistUrl.LastIndexOf('/') + 1);
+ var baseUrl = new Uri(playlistUrl[..(playlistUrl.LastIndexOf('/') + 1)], UriKind.Absolute);
var videoLength = TimeSpan.FromSeconds(videoInfoResponse.data.video.lengthSeconds);
DriveHelper.CheckAvailableStorageSpace(downloadOptions, bandwidth, videoLength, _progress);
@@ -72,11 +72,11 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
_progress.Report(new ProgressReport(ReportType.NewLineStatus, "Downloading 0% [2/5]"));
- await DownloadTools.DownloadVideoPartsAsync(downloadOptions, videoPartsList, baseUrl, downloadFolder, vodAge, _progress, cancellationToken);
+ await DownloadTools.DownloadVideoPartsAsync(downloadOptions, videoPartsList, baseUrl, downloadFolder, vodAge, 2, 5, _progress, cancellationToken);
_progress.Report(new ProgressReport() { ReportType = ReportType.NewLineStatus, Data = "Verifying Parts 0% [3/5]" });
- await DownloadTools.VerifyDownloadedParts(downloadOptions, videoPartsList, baseUrl, downloadFolder, vodAge, _progress, cancellationToken);
+ await DownloadTools.VerifyDownloadedParts(downloadOptions, videoPartsList, baseUrl, downloadFolder, vodAge, 3, 5, _progress, cancellationToken);
_progress.Report(new ProgressReport() { ReportType = ReportType.NewLineStatus, Data = "Combining Parts 0% [4/5]" });
@@ -98,8 +98,9 @@ public async Task DownloadAsync(CancellationToken cancellationToken)
double seekDuration = Math.Round(downloadOptions.CropEndingTime - seekTime);
string metadataPath = Path.Combine(downloadFolder, "metadata.txt");
- await FfmpegMetadata.SerializeAsync(metadataPath, videoInfoResponse.data.video.owner.displayName, downloadOptions.Id.ToString(), videoInfoResponse.data.video.title,
- videoInfoResponse.data.video.createdAt, videoInfoResponse.data.video.viewCount, startOffset, videoChapterResponse.data.video.moments.edges, cancellationToken);
+ VideoInfo videoInfo = videoInfoResponse.data.video;
+ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, downloadOptions.Id, videoInfo.title, videoInfo.createdAt, videoInfo.viewCount,
+ videoInfo.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd(), startOffset, videoChapterResponse.data.video.moments.edges, cancellationToken);
var finalizedFileDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!;
if (!finalizedFileDirectory.Exists)
@@ -204,12 +205,12 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfoResponse.data.video.o
{
if (videoPlaylist[i].Contains("#EXT-X-MEDIA"))
{
- string lastPart = videoPlaylist[i].Substring(videoPlaylist[i].IndexOf("NAME=\"") + 6);
+ string lastPart = videoPlaylist[i].Substring(videoPlaylist[i].IndexOf("NAME=\"", StringComparison.Ordinal) + 6);
string stringQuality = lastPart.Substring(0, lastPart.IndexOf('"'));
- var bandwidthStartIndex = videoPlaylist[i + 1].IndexOf("BANDWIDTH=") + 10;
+ var bandwidthStartIndex = videoPlaylist[i + 1].IndexOf("BANDWIDTH=", StringComparison.Ordinal) + 10;
var bandwidthEndIndex = videoPlaylist[i + 1].IndexOf(',') - bandwidthStartIndex;
- int.TryParse(videoPlaylist[i + 1].Substring(bandwidthStartIndex, bandwidthEndIndex), out var bandwidth);
+ int.TryParse(videoPlaylist[i + 1].AsSpan(bandwidthStartIndex, bandwidthEndIndex), out var bandwidth);
if (!videoQualities.Any(x => x.Key.Equals(stringQuality)))
{
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/EmoteResponse.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/EmoteResponse.cs
index b1e051c8..7dc9bec3 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/EmoteResponse.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/EmoteResponse.cs
@@ -1,15 +1,11 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
+using System.Collections.Generic;
namespace TwitchDownloaderCore.VideoPlatforms.Twitch
{
public class EmoteResponse
{
- public List BTTV { get; set; } = new();
- public List FFZ { get; set; } = new();
- public List STV { get; set; } = new();
+ public List BTTV { get; set; }
+ public List FFZ { get; set; }
+ public List STV { get; set; }
}
}
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/EmoteResponseItem.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/EmoteResponseItem.cs
index 7407e89e..0d60e18e 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/EmoteResponseItem.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/EmoteResponseItem.cs
@@ -1,9 +1,3 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
namespace TwitchDownloaderCore.VideoPlatforms.Twitch
{
public class EmoteResponseItem
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlClipResponse.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlClipResponse.cs
index 7f959848..1637b3f5 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlClipResponse.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlClipResponse.cs
@@ -15,12 +15,6 @@ public class ClipVideo
public string id { get; set; }
}
- public class ClipGame
- {
- public string id { get; set; }
- public string displayName { get; set; }
- }
-
public class Clip
{
public string title { get; set; }
@@ -31,7 +25,7 @@ public class Clip
public int? videoOffsetSeconds { get; set; }
public ClipVideo video { get; set; }
public int viewCount { get; set; }
- public ClipGame game { get; set; }
+ public Game game { get; set; }
}
public class ClipData
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlClipSearchResponse.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlClipSearchResponse.cs
index 109dc8f4..5df32d6b 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlClipSearchResponse.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlClipSearchResponse.cs
@@ -48,6 +48,4 @@ public class GqlClipSearchResponse
public ClipSearchData data { get; set; }
public Extensions extensions { get; set; }
}
-
-
}
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlVideoResponse.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlVideoResponse.cs
index 5826b840..ef9d157a 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlVideoResponse.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/Gql/GqlVideoResponse.cs
@@ -11,12 +11,6 @@ public class VideoOwner
public string displayName { get; set; }
}
- public class VideoGame
- {
- public string id { get; set; }
- public string displayName { get; set; }
- }
-
public class VideoInfo
{
public string title { get; set; }
@@ -25,7 +19,12 @@ public class VideoInfo
public int lengthSeconds { get; set; }
public VideoOwner owner { get; set; }
public int viewCount { get; set; }
- public VideoGame game { get; set; }
+ public Game game { get; set; }
+ ///
+ /// Some values, such as newlines, are repeated twice for some reason.
+ /// This can be filtered out with: description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd()
+ ///
+ public string description { get; set; }
}
public class VideoData
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/StvEmoteFlags.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/StvEmoteFlags.cs
index 46ce7acb..437edd94 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/StvEmoteFlags.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/StvEmoteFlags.cs
@@ -13,7 +13,7 @@ public enum StvEmoteFlags
// Content Flags
- ContentSexual = 1 << 16, // Sexually Suggesive
+ ContentSexual = 1 << 16, // Sexually Suggestive
ContentEpilepsy = 1 << 17, // Rapid flashing
ContentEdgy = 1 << 18, // Edgy or distasteful, may be offensive to some users
ContentTwitchDisallowed = 1 << 24, // Not allowed specifically on the Twitch platform
diff --git a/TwitchDownloaderCore/VideoPlatforms/Twitch/TwitchHelper.cs b/TwitchDownloaderCore/VideoPlatforms/Twitch/TwitchHelper.cs
index fab40b1b..85c83a1a 100644
--- a/TwitchDownloaderCore/VideoPlatforms/Twitch/TwitchHelper.cs
+++ b/TwitchDownloaderCore/VideoPlatforms/Twitch/TwitchHelper.cs
@@ -1,5 +1,4 @@
-using NeoSmart.Unicode;
-using SkiaSharp;
+using SkiaSharp;
using System;
using System.Collections.Generic;
using System.IO;
@@ -8,7 +7,6 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
-using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -24,22 +22,22 @@ namespace TwitchDownloaderCore.VideoPlatforms.Twitch
public static class TwitchHelper
{
private static readonly HttpClient httpClient = new HttpClient();
- private static readonly string[] bttvZeroWidth = { "SoSnowy", "IceCold", "SantaHat", "TopHat", "ReinDeer", "CandyCane", "cvMask", "cvHazmat" };
+ private static readonly string[] BttvZeroWidth = { "SoSnowy", "IceCold", "SantaHat", "TopHat", "ReinDeer", "CandyCane", "cvMask", "cvHazmat" };
- public static async Task GetVideoInfo(int videoId, string Oauth = null)
+ public static async Task GetVideoInfo(int videoId, string oauth = null)
{
var request = new HttpRequestMessage()
{
RequestUri = new Uri("https://gql.twitch.tv/gql"),
Method = HttpMethod.Post,
- Content = new StringContent("{\"query\":\"query{video(id:\\\"" + videoId + "\\\"){title,thumbnailURLs(height:180,width:320),createdAt,lengthSeconds,owner{id,displayName},viewCount,game{id,displayName}}}\",\"variables\":{}}", Encoding.UTF8, "application/json")
+ Content = new StringContent("{\"query\":\"query{video(id:\\\"" + videoId + "\\\"){title,thumbnailURLs(height:180,width:320),createdAt,lengthSeconds,owner{id,displayName},viewCount,game{id,displayName,boxArtURL},description}}\",\"variables\":{}}", Encoding.UTF8, "application/json")
};
request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
GqlVideoResponse res = await response.Content.ReadFromJsonAsync();
- GqlVideoTokenResponse accessToken = await TwitchHelper.GetVideoToken(videoId, Oauth);
+ GqlVideoTokenResponse accessToken = await TwitchHelper.GetVideoToken(videoId, oauth);
if (accessToken is null)
{
throw new NullReferenceException("Invalid VOD, deleted/expired VOD possibly?");
@@ -56,12 +54,12 @@ public static async Task GetVideoInfo(int videoId, string Oaut
{
if (playlist[i].Contains("#EXT-X-MEDIA"))
{
- string lastPart = playlist[i].Substring(playlist[i].IndexOf("NAME=\"") + 6);
+ string lastPart = playlist[i].Substring(playlist[i].IndexOf("NAME=\"", StringComparison.Ordinal) + 6);
string stringQuality = lastPart.Substring(0, lastPart.IndexOf('"'));
- var bandwidthStartIndex = playlist[i + 1].IndexOf("BANDWIDTH=") + 10;
+ var bandwidthStartIndex = playlist[i + 1].IndexOf("BANDWIDTH=", StringComparison.Ordinal) + 10;
var bandwidthEndIndex = playlist[i + 1].IndexOf(',') - bandwidthStartIndex;
- int.TryParse(playlist[i + 1].Substring(bandwidthStartIndex, bandwidthEndIndex), out var bandwidth);
+ int.TryParse(playlist[i + 1].AsSpan(bandwidthStartIndex, bandwidthEndIndex), out var bandwidth);
res.VideoQualities.Add(new VideoQuality { Quality = stringQuality, SourceUrl = playlist[i + 2], Bandwidth = bandwidth });
}
@@ -104,7 +102,7 @@ public static async Task GetClipInfo(object clipId)
{
RequestUri = new Uri("https://gql.twitch.tv/gql"),
Method = HttpMethod.Post,
- Content = new StringContent("{\"query\":\"query{clip(slug:\\\"" + clipId + "\\\"){title,thumbnailURL,createdAt,durationSeconds,broadcaster{id,displayName},videoOffsetSeconds,video{id},viewCount,game{id,displayName}}}\",\"variables\":{}}", Encoding.UTF8, "application/json")
+ Content = new StringContent("{\"query\":\"query{clip(slug:\\\"" + clipId + "\\\"){title,thumbnailURL,createdAt,durationSeconds,broadcaster{id,displayName},videoOffsetSeconds,video{id},viewCount,game{id,displayName,boxArtURL}}}\",\"variables\":{}}", Encoding.UTF8, "application/json")
};
request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
@@ -154,83 +152,87 @@ public static async Task GetGqlClips(string channelName,
return await response.Content.ReadFromJsonAsync();
}
- public static async Task GetThirdPartyEmoteData(int streamerId, bool getBttv, bool getFfz, bool getStv, bool allowUnlistedEmotes, CancellationToken cancellationToken = new())
+ public static async Task GetThirdPartyEmotesMetadata(int streamerId, bool getBttv, bool getFfz, bool getStv, bool allowUnlistedEmotes, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- EmoteResponse emoteReponse = new();
+ EmoteResponse emoteResponse = new();
if (getBttv)
{
- await GetBttvEmoteData(streamerId, emoteReponse.BTTV);
+ emoteResponse.BTTV = await GetBttvEmotesMetadata(streamerId, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
if (getFfz)
{
- await GetFfzEmoteData(streamerId, emoteReponse.FFZ);
+ emoteResponse.FFZ = await GetFfzEmotesMetadata(streamerId, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
if (getStv)
{
- await PlatformHelper.GetStvEmoteData(streamerId, emoteReponse.STV, allowUnlistedEmotes, VideoPlatform.Twitch);
+ emoteResponse.STV = await PlatformHelper.GetStvEmotesMetadata(streamerId, allowUnlistedEmotes, VideoPlatform.Twitch, cancellationToken);
}
- return emoteReponse;
+ return emoteResponse;
}
- private static async Task GetBttvEmoteData(int streamerId, List bttvResponse)
+ private static async Task> GetBttvEmotesMetadata(int streamerId, CancellationToken cancellationToken)
{
var globalEmoteRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("https://api.betterttv.net/3/cached/emotes/global", UriKind.Absolute));
- using var globalEmoteResponse = await httpClient.SendAsync(globalEmoteRequest, HttpCompletionOption.ResponseHeadersRead);
+ using var globalEmoteResponse = await httpClient.SendAsync(globalEmoteRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
globalEmoteResponse.EnsureSuccessStatusCode();
- var BTTV = await globalEmoteResponse.Content.ReadFromJsonAsync>();
+ var BTTV = await globalEmoteResponse.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken);
//Channel might not have BTTV emotes
try
{
var channelEmoteRequest = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://api.betterttv.net/3/cached/users/twitch/{streamerId}", UriKind.Absolute));
- using var channelEmoteResponse = await httpClient.SendAsync(channelEmoteRequest, HttpCompletionOption.ResponseHeadersRead);
+ using var channelEmoteResponse = await httpClient.SendAsync(channelEmoteRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
channelEmoteResponse.EnsureSuccessStatusCode();
- var bttvChannel = await channelEmoteResponse.Content.ReadFromJsonAsync();
+ var bttvChannel = await channelEmoteResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
BTTV.AddRange(bttvChannel.channelEmotes);
BTTV.AddRange(bttvChannel.sharedEmotes);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { }
+ var returnList = new List();
foreach (var emote in BTTV)
{
string id = emote.id;
string name = emote.code;
string mime = emote.imageType;
string url = $"https://cdn.betterttv.net/emote/{id}/[scale]x";
- bttvResponse.Add(new EmoteResponseItem() { Id = id, Code = name, ImageType = mime, ImageUrl = url, IsZeroWidth = bttvZeroWidth.Contains(name) });
+ returnList.Add(new EmoteResponseItem() { Id = id, Code = name, ImageType = mime, ImageUrl = url, IsZeroWidth = BttvZeroWidth.Contains(name) });
}
+
+ return returnList;
}
- private static async Task GetFfzEmoteData(int streamerId, List ffzResponse)
+ private static async Task> GetFfzEmotesMetadata(int streamerId, CancellationToken cancellationToken)
{
var globalEmoteRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("https://api.betterttv.net/3/cached/frankerfacez/emotes/global", UriKind.Absolute));
- using var globalEmoteResponse = await httpClient.SendAsync(globalEmoteRequest, HttpCompletionOption.ResponseHeadersRead);
+ using var globalEmoteResponse = await httpClient.SendAsync(globalEmoteRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
globalEmoteResponse.EnsureSuccessStatusCode();
- var FFZ = await globalEmoteResponse.Content.ReadFromJsonAsync>();
+ var FFZ = await globalEmoteResponse.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken);
//Channel might not have FFZ emotes
try
{
var channelEmoteRequest = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://api.betterttv.net/3/cached/frankerfacez/users/twitch/{streamerId}", UriKind.Absolute));
- using var channelEmoteResponse = await httpClient.SendAsync(channelEmoteRequest, HttpCompletionOption.ResponseHeadersRead);
+ using var channelEmoteResponse = await httpClient.SendAsync(channelEmoteRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
channelEmoteResponse.EnsureSuccessStatusCode();
- var channelEmotes = await channelEmoteResponse.Content.ReadFromJsonAsync>();
+ var channelEmotes = await channelEmoteResponse.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken);
FFZ.AddRange(channelEmotes);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { }
+ var returnList = new List();
foreach (var emote in FFZ)
{
string id = emote.id.ToString();
@@ -239,8 +241,10 @@ private static async Task GetFfzEmoteData(int streamerId, List Regex.IsMatch(comment.message.body, pattern))
- select emote;
-
- foreach (var emote in emoteResponseItemsQuery)
- {
- try
- {
- TwitchEmote newEmote = new TwitchEmote(await PlatformHelper.GetImage(bttvFolder, emote.ImageUrl.Replace("[scale]", "2"), emote.Id, "2", emote.ImageType, cancellationToken), EmoteProvider.TwitchThirdParty, 2, emote.Id, emote.Code);
- if (emote.IsZeroWidth)
- newEmote.IsZeroWidth = true;
- returnList.Add(newEmote);
- alreadyAdded.Add(emote.Code);
- }
- catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { }
- }
+ await FetchEmoteImages(comments, emoteDataResponse.BTTV, returnList, alreadyAdded, bttvFolder, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
if (ffz)
{
- if (!Directory.Exists(ffzFolder))
- PlatformHelper.CreateDirectory(ffzFolder);
-
- var emoteResponseItemsQuery = from emote in emoteDataResponse.FFZ
- where !alreadyAdded.Contains(emote.Code)
- let pattern = $@"(?<=^|\s){Regex.Escape(emote.Code)}(?=$|\s)"
- where comments.Any(comment => Regex.IsMatch(comment.message.body, pattern))
- select emote;
-
- foreach (var emote in emoteResponseItemsQuery)
- {
- try
- {
- TwitchEmote newEmote = new TwitchEmote(await PlatformHelper.GetImage(ffzFolder, emote.ImageUrl.Replace("[scale]", "2"), emote.Id, "2", emote.ImageType, cancellationToken), EmoteProvider.TwitchThirdParty, 2, emote.Id, emote.Code);
- returnList.Add(newEmote);
- alreadyAdded.Add(emote.Code);
- }
- catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { }
- }
+ await FetchEmoteImages(comments, emoteDataResponse.FFZ, returnList, alreadyAdded, ffzFolder, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
if (stv)
{
- if (!Directory.Exists(stvFolder))
- PlatformHelper.CreateDirectory(stvFolder);
+ await FetchEmoteImages(comments, emoteDataResponse.STV, returnList, alreadyAdded, stvFolder, cancellationToken);
+ }
+
+ return returnList;
+
+ static async Task FetchEmoteImages(IReadOnlyCollection comments, IEnumerable emoteResponse, ICollection returnList,
+ ICollection alreadyAdded, string cacheFolder, CancellationToken cancellationToken)
+ {
+ if (!Directory.Exists(cacheFolder))
+ PlatformHelper.CreateDirectory(cacheFolder);
- var emoteResponseItemsQuery = from emote in emoteDataResponse.STV
- where !alreadyAdded.Contains(emote.Code)
- let pattern = $@"(?<=^|\s){Regex.Escape(emote.Code)}(?=$|\s)"
- where comments.Any(comment => Regex.IsMatch(comment.message.body, pattern))
- select emote;
+ IEnumerable emoteResponseQuery;
+ if (comments.Count == 0)
+ {
+ emoteResponseQuery = emoteResponse;
+ }
+ else
+ {
+ emoteResponseQuery = from emote in emoteResponse
+ where !alreadyAdded.Contains(emote.Code)
+ let pattern = $@"(?<=^|\s){Regex.Escape(emote.Code)}(?=$|\s)"
+ where comments.Any(comment => Regex.IsMatch(comment.message.body, pattern))
+ select emote;
+ }
- foreach (var emote in emoteResponseItemsQuery)
+ foreach (var emote in emoteResponseQuery)
{
try
{
- TwitchEmote newEmote = new TwitchEmote(await PlatformHelper.GetImage(stvFolder, emote.ImageUrl.Replace("[scale]", "2"), emote.Id, "2", emote.ImageType, cancellationToken), EmoteProvider.TwitchThirdParty, 2, emote.Id, emote.Code);
- if (emote.IsZeroWidth)
- newEmote.IsZeroWidth = true;
+ var imageData = await PlatformHelper.GetImage(cacheFolder, emote.ImageUrl.Replace("[scale]", "2"), emote.Id, "2", emote.ImageType, cancellationToken);
+ var newEmote = new TwitchEmote(imageData, EmoteProvider.TwitchThirdParty, 2, emote.Id, emote.Code);
+ newEmote.IsZeroWidth = emote.IsZeroWidth;
returnList.Add(newEmote);
alreadyAdded.Add(emote.Code);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { }
}
}
-
- return returnList;
}
public static async Task> GetEmotes(List comments, string cacheFolder, EmbeddedData embeddedData = null, bool offline = false, CancellationToken cancellationToken = default)
@@ -529,8 +509,7 @@ public static async Task> GetChatBadges(List comments,
foreach (var (version, data) in badge.versions)
{
- string[] id_parts = data.url.Split('/');
- string id = id_parts[id_parts.Length - 2];
+ string id = data.url.Split('/')[^2];
byte[] bytes = await PlatformHelper.GetImage(badgeFolder, data.url, id, "2", "png", cancellationToken);
versions.Add(version, new ChatBadgeData
{
@@ -616,7 +595,7 @@ public static async Task> GetEmojis(string cacheFol
return returnCache;
}
- public static async Task> GetBits(List comments, string cacheFolder, string channel_id = "", EmbeddedData embeddedData = null, bool offline = false, CancellationToken cancellationToken = default)
+ public static async Task> GetBits(List comments, string cacheFolder, string channelId = "", EmbeddedData embeddedData = null, bool offline = false, CancellationToken cancellationToken = default)
{
List returnList = new List();
List alreadyAdded = new List();
@@ -648,7 +627,7 @@ public static async Task> GetBits(List comments, strin
{
RequestUri = new Uri("https://gql.twitch.tv/gql"),
Method = HttpMethod.Post,
- Content = new StringContent("{\"query\":\"query{cheerConfig{groups{nodes{id, prefix, tiers{bits}}, templateURL}},user(id:\\\"" + channel_id + "\\\"){cheer{cheerGroups{nodes{id,prefix,tiers{bits}},templateURL}}}}\",\"variables\":{}}", Encoding.UTF8, "application/json")
+ Content = new StringContent("{\"query\":\"query{cheerConfig{groups{nodes{id, prefix, tiers{bits}}, templateURL}},user(id:\\\"" + channelId + "\\\"){cheer{cheerGroups{nodes{id,prefix,tiers{bits}},templateURL}}}}\",\"variables\":{}}", Encoding.UTF8, "application/json")
};
request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko");
using var cheerResponseMessage = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
@@ -722,8 +701,7 @@ public static void CleanupUnmanagedCacheFiles(string cacheFolder, IProgress dire
var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
const int TWENTY_FOUR_HOURS_MILLIS = 86_400_000;
- if (currentTime - downloadTime > TWENTY_FOUR_HOURS_MILLIS)
+ if (currentTime - downloadTime > TWENTY_FOUR_HOURS_MILLIS * 7)
{
try
{
@@ -820,6 +798,55 @@ public static async Task GetVideoChapters(int videoId)
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync();
}
- }
+ public static async Task GetOrGenerateVideoChapters(int videoId, VideoInfo videoInfo)
+ {
+ var chapterResponse = await GetVideoChapters(videoId);
+
+ // Video has only 1 chapter, generate a bogus video chapter with the information we have available.
+ if (chapterResponse.data.video.moments.edges.Count == 0)
+ {
+ chapterResponse.data.video.moments.edges.Add(
+ GenerateVideoMomentEdge(0, videoInfo.lengthSeconds, videoInfo.game?.id, videoInfo.game?.displayName, videoInfo.game?.displayName, videoInfo.game?.boxArtURL
+ ));
+ }
+
+ return chapterResponse;
+ }
+
+ public static VideoMomentEdge GenerateClipChapter(Clip clipInfo)
+ {
+ return GenerateVideoMomentEdge(0, clipInfo.durationSeconds, clipInfo.game?.id, clipInfo.game?.displayName, clipInfo.game?.displayName, clipInfo.game?.boxArtURL);
+ }
+
+ private static VideoMomentEdge GenerateVideoMomentEdge(int startSeconds, int lengthSeconds, string gameId = null, string gameDisplayName = null, string gameDescription = null, string gameBoxArtUrl = null)
+ {
+ gameId ??= "-1";
+ gameDisplayName ??= "Unknown";
+ gameDescription ??= "Unknown";
+ gameBoxArtUrl ??= "";
+
+ return new VideoMomentEdge
+ {
+ node = new VideoMoment
+ {
+ id = "",
+ _type = "GAME_CHANGE",
+ positionMilliseconds = startSeconds,
+ durationMilliseconds = lengthSeconds * 1000,
+ description = gameDescription,
+ subDescription = "",
+ details = new GameChangeMomentDetails
+ {
+ game = new Game
+ {
+ id = gameId,
+ displayName = gameDisplayName,
+ boxArtURL = gameBoxArtUrl.Replace("{width}", "40").Replace("{height}", "53")
+ }
+ }
+ }
+ };
+ }
+ }
}
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Behaviors/TextBoxTripleClickBehavior.cs b/TwitchDownloaderWPF/Behaviors/TextBoxTripleClickBehavior.cs
index d4f097be..77c9d880 100644
--- a/TwitchDownloaderWPF/Behaviors/TextBoxTripleClickBehavior.cs
+++ b/TwitchDownloaderWPF/Behaviors/TextBoxTripleClickBehavior.cs
@@ -41,8 +41,15 @@ private static (int start, int length) GetCurrentLine(TextBox textBox)
var caretPos = textBox.CaretIndex;
var text = textBox.Text;
- var start = text.LastIndexOf('\n', caretPos, caretPos);
- var end = text.IndexOf('\n', caretPos);
+ var start = -1;
+ var end = -1;
+
+ // CaretIndex can be negative for some reason.
+ if (caretPos >= 0)
+ {
+ start = text.LastIndexOf('\n', caretPos, caretPos);
+ end = text.IndexOf('\n', caretPos);
+ }
if (start == -1)
{
diff --git a/TwitchDownloaderWPF/MainWindow.xaml.cs b/TwitchDownloaderWPF/MainWindow.xaml.cs
index 1272e7b7..5cdd2ea0 100644
--- a/TwitchDownloaderWPF/MainWindow.xaml.cs
+++ b/TwitchDownloaderWPF/MainWindow.xaml.cs
@@ -3,9 +3,9 @@
using System.Diagnostics;
using System.IO;
using System.Net;
-using System.Runtime.InteropServices;
using System.Windows;
using TwitchDownloaderWPF.Properties;
+using Xabe.FFmpeg;
using Xabe.FFmpeg.Downloader;
namespace TwitchDownloaderWPF
@@ -72,11 +72,16 @@ private async void Window_Loaded(object sender, RoutedEventArgs e)
Environment.SetEnvironmentVariable("CURL_IMPERSONATE", "chrome110");
+ var currentVersion = Version.Parse("1.53.4");
+ Title = $"Twitch Downloader v{currentVersion}";
+
+ // TODO: extract FFmpeg handling to a dedicated service
if (!File.Exists("ffmpeg.exe"))
{
+ var oldTitle = Title;
try
{
- await FFmpegDownloader.GetLatestVersion(FFmpegVersion.Full);
+ await FFmpegDownloader.GetLatestVersion(FFmpegVersion.Full, new FfmpegDownloadProgress());
}
catch (Exception ex)
{
@@ -91,10 +96,10 @@ private async void Window_Loaded(object sender, RoutedEventArgs e)
MessageBox.Show(ex.ToString(), Translations.Strings.VerboseErrorOutput, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
+
+ Title = oldTitle;
}
- Version currentVersion = new Version("1.53.2");
- Title = $"Twitch Downloader v{currentVersion}";
AutoUpdater.InstalledVersion = currentVersion;
#if !DEBUG
if (AppContext.BaseDirectory.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)))
@@ -105,5 +110,32 @@ private async void Window_Loaded(object sender, RoutedEventArgs e)
AutoUpdater.Start("https://downloader-update.twitcharchives.workers.dev");
#endif
}
+
+ private class FfmpegDownloadProgress : IProgress
+ {
+ private int _lastPercent = -1;
+
+ public void Report(ProgressInfo value)
+ {
+ var percent = (int)(value.DownloadedBytes / (double)value.TotalBytes * 100);
+
+ if (percent > _lastPercent)
+ {
+ var window = Application.Current.MainWindow;
+ if (window is null) return;
+
+ _lastPercent = percent;
+
+ var oldTitle = window.Title;
+ if (oldTitle.IndexOf('-') == -1) oldTitle += " -";
+
+ window.Title = string.Concat(
+ oldTitle.AsSpan(0, oldTitle.IndexOf('-')),
+ "- ",
+ string.Format(Translations.Strings.StatusDownloaderFFmpeg, percent.ToString())
+ );
+ }
+ }
+ }
}
}
diff --git a/TwitchDownloaderWPF/Models/BooleanModel.cs b/TwitchDownloaderWPF/Models/BooleanModel.cs
index 807708c2..79f98741 100644
--- a/TwitchDownloaderWPF/Models/BooleanModel.cs
+++ b/TwitchDownloaderWPF/Models/BooleanModel.cs
@@ -2,12 +2,13 @@
namespace TwitchDownloaderWPF.Models
{
- [XmlRoot(ElementName = "Boolean", Namespace = "clr-namespace:System;assembly=mscorlib")]
- public class BooleanModel
- {
- [XmlAttribute(AttributeName = "Key", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml")]
- public string Key { get; set; }
- [XmlText(Type = typeof(bool))]
- public bool Value { get; set; }
- }
-}
+ [XmlRoot(ElementName = "Boolean", Namespace = "clr-namespace:System;assembly=mscorlib")]
+ public class BooleanModel
+ {
+ [XmlAttribute(AttributeName = "Key", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml")]
+ public string Key { get; set; }
+
+ [XmlText(Type = typeof(bool))]
+ public bool Value { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Models/ResourceDictionaryModel.cs b/TwitchDownloaderWPF/Models/ResourceDictionaryModel.cs
deleted file mode 100644
index c4a93fd5..00000000
--- a/TwitchDownloaderWPF/Models/ResourceDictionaryModel.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using System.Collections.Generic;
-using System.Xml.Serialization;
-
-namespace TwitchDownloaderWPF.Models
-{
- [XmlRoot(ElementName = "ResourceDictionary", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")]
- public class ResourceDictionaryModel
- {
- [XmlElement(ElementName = "SolidColorBrush", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")]
- public List SolidColorBrush { get; set; }
-
- [XmlElement(ElementName = "Boolean", Namespace = "clr-namespace:System;assembly=mscorlib")]
- public List Boolean { get; set; }
-
- [XmlAttribute(AttributeName = "xmlns")]
- public string Xmlns { get; set; }
-
- [XmlAttribute(AttributeName = "x", Namespace = "http://www.w3.org/2000/xmlns/")]
- public string X { get; set; }
-
- [XmlAttribute(AttributeName ="system", Namespace = "clr-namespace:System;assembly=mscorlib")]
- public string System { get; set; }
- }
-}
diff --git a/TwitchDownloaderWPF/Models/SolidBrushModel.cs b/TwitchDownloaderWPF/Models/SolidBrushModel.cs
index 90fd8ab9..39a79eca 100644
--- a/TwitchDownloaderWPF/Models/SolidBrushModel.cs
+++ b/TwitchDownloaderWPF/Models/SolidBrushModel.cs
@@ -2,12 +2,13 @@
namespace TwitchDownloaderWPF.Models
{
- [XmlRoot(ElementName = "SolidColorBrush", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")]
- public class SolidColorBrushModel
- {
- [XmlAttribute(AttributeName = "Key", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml")]
- public string Key { get; set; }
- [XmlAttribute(AttributeName = "Color")]
- public string Color { get; set; }
- }
-}
+ [XmlRoot(ElementName = "SolidColorBrush", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")]
+ public class SolidColorBrushModel
+ {
+ [XmlAttribute(AttributeName = "Key", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml")]
+ public string Key { get; set; }
+
+ [XmlAttribute(AttributeName = "Color")]
+ public string Color { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Models/ThemeResourceDictionaryModel.cs b/TwitchDownloaderWPF/Models/ThemeResourceDictionaryModel.cs
new file mode 100644
index 00000000..6e834d08
--- /dev/null
+++ b/TwitchDownloaderWPF/Models/ThemeResourceDictionaryModel.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using System.Xml.Serialization;
+
+namespace TwitchDownloaderWPF.Models
+{
+ [XmlRoot(ElementName = "ResourceDictionary", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")]
+ public class ThemeResourceDictionaryModel
+ {
+ [XmlElement(ElementName = "SolidColorBrush", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")]
+ public List SolidColorBrush { get; set; }
+
+ [XmlElement(ElementName = "Boolean", Namespace = "clr-namespace:System;assembly=mscorlib")]
+ public List Boolean { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/PageChatDownload.xaml b/TwitchDownloaderWPF/PageChatDownload.xaml
index f23bdf5a..c95fbf85 100644
--- a/TwitchDownloaderWPF/PageChatDownload.xaml
+++ b/TwitchDownloaderWPF/PageChatDownload.xaml
@@ -20,7 +20,6 @@
diff --git a/TwitchDownloaderWPF/PageChatDownload.xaml.cs b/TwitchDownloaderWPF/PageChatDownload.xaml.cs
index 0cfbd6fa..81b2b001 100644
--- a/TwitchDownloaderWPF/PageChatDownload.xaml.cs
+++ b/TwitchDownloaderWPF/PageChatDownload.xaml.cs
@@ -2,8 +2,6 @@
using System;
using System.Diagnostics;
using System.Globalization;
-using System.Linq;
-using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
@@ -11,27 +9,22 @@
using System.Windows.Input;
using System.Windows.Media.Imaging;
using TwitchDownloaderCore;
-using TwitchDownloaderCore.Chat;
using TwitchDownloaderCore.Extensions;
using TwitchDownloaderCore.Options;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Interfaces;
-using TwitchDownloaderCore.VideoPlatforms.Twitch;
-using TwitchDownloaderCore.VideoPlatforms.Twitch.Downloaders;
-using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
using TwitchDownloaderWPF.Properties;
using TwitchDownloaderWPF.Services;
using WpfAnimatedGif;
namespace TwitchDownloaderWPF
{
- public enum DownloadType { Clip, Video }
///
/// Interaction logic for PageChatDownload.xaml
///
public partial class PageChatDownload : Page
{
- public DownloadType downloadType;
+ public VideoType videoType;
public string downloadId;
public VideoPlatform platform;
public DateTime currentVideoTime;
@@ -109,40 +102,29 @@ private async Task GetVideoInfo()
MessageBox.Show(Translations.Strings.UnableToParseLinkMessage, Translations.Strings.UnableToParseLink, MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
-
- bool vodParsed = UrlParse.TryParseVod(textUrl.Text.Trim(), out VideoPlatform videoPlatformVod, out string vodId);
- bool clipParsed = UrlParse.TryParseClip(textUrl.Text.Trim(), out VideoPlatform videoPlatformClip, out string clipId);
- if (!vodParsed && !clipParsed)
+ if (!UrlParse.TryParseVideoOrClipId(textUrl.Text.Trim(), out platform, out videoType, out downloadId))
{
MessageBox.Show(Translations.Strings.InvalidVideoLinkIdMessage.Replace(@"\n", Environment.NewLine), Translations.Strings.InvalidVideoLinkId, MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
btnGetInfo.IsEnabled = false;
- downloadId = vodParsed ? vodId : clipId;
- downloadType = vodParsed ? DownloadType.Video : DownloadType.Clip;
- platform = vodParsed ? videoPlatformVod : videoPlatformClip;
try
{
- if (downloadType == DownloadType.Video)
+ if (videoType == VideoType.Video)
{
- IVideoInfo videoInfo = await PlatformHelper.GetVideoInfo(videoPlatformVod, downloadId);
+ IVideoInfo videoInfo = await PlatformHelper.GetVideoInfo(platform, downloadId);
- try
- {
- imgThumbnail.Source = await ThumbnailService.GetThumb(videoInfo.ThumbnailUrl);
- }
- catch
+ var thumbUrl = videoInfo.ThumbnailUrl;
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var image))
{
AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
- {
- imgThumbnail.Source = image;
- }
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out image);
}
+ imgThumbnail.Source = image;
+
vodLength = TimeSpan.FromSeconds(videoInfo.Duration);
textTitle.Text = videoInfo.Title;
textStreamer.Text = videoInfo.StreamerName;
@@ -151,7 +133,7 @@ private async Task GetVideoInfo()
currentVideoTime = Settings.Default.UTCVideoTime ? videoTime : videoTime.ToLocalTime();
viewCount = videoInfo.ViewCount;
game = videoInfo.Game ?? "Unknown";
- var urlTimeCodeMatch = Regex.Match(textUrl.Text, @"(?<=\?t=)\d+h\d+m\d+s");
+ var urlTimeCodeMatch = UrlParse.UrlTimeCode.Match(textUrl.Text);
if (urlTimeCodeMatch.Success)
{
var time = TimeSpanExtensions.ParseTimeCode(urlTimeCodeMatch.ValueSpan);
@@ -175,23 +157,18 @@ private async Task GetVideoInfo()
labelLength.Text = vodLength.ToString("c");
SetEnabled(true, false);
}
- else if (downloadType == DownloadType.Clip)
+ else if (videoType == VideoType.Clip)
{
- IVideoInfo clipInfo = await PlatformHelper.GetClipInfo(videoPlatformClip, downloadId);
+ IVideoInfo clipInfo = await PlatformHelper.GetClipInfo(platform, downloadId);
- try
- {
- imgThumbnail.Source = await ThumbnailService.GetThumb(clipInfo.ThumbnailUrl);
- }
- catch
+ var thumbUrl = clipInfo.ThumbnailUrl;
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var image))
{
AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
- {
- imgThumbnail.Source = image;
- }
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out image);
}
+ imgThumbnail.Source = image;
+
TimeSpan clipLength = TimeSpan.FromSeconds(clipInfo.Duration);
textStreamer.Text = clipInfo.StreamerName;
var clipCreatedAt = clipInfo.CreatedAt;
@@ -237,14 +214,6 @@ private void AppendLog(string message)
);
}
- public static string ValidateUrl(string text)
- {
- var vodClipIdMatch = Regex.Match(text, @"(?<=^|(?:clips\.)?twitch\.tv\/(?:videos|\S+\/clip)?\/?)[\w-]+?(?=$|\?)");
- return vodClipIdMatch.Success
- ? vodClipIdMatch.Value
- : null;
- }
-
public ChatDownloadOptions GetOptions(string filename)
{
ChatDownloadOptions options = new ChatDownloadOptions();
@@ -261,14 +230,14 @@ public ChatDownloadOptions GetOptions(string filename)
else if (radioCompressionGzip.IsChecked == true)
options.Compression = ChatCompression.Gzip;
- options.EmbedData = (bool)checkEmbed.IsChecked;
- options.BttvEmotes = (bool)checkBttvEmbed.IsChecked;
- options.FfzEmotes = (bool)checkFfzEmbed.IsChecked;
- options.StvEmotes = (bool)checkStvEmbed.IsChecked;
+ options.EmbedData = checkEmbed.IsChecked.GetValueOrDefault();
+ options.BttvEmotes = checkBttvEmbed.IsChecked.GetValueOrDefault();
+ options.FfzEmotes = checkFfzEmbed.IsChecked.GetValueOrDefault();
+ options.StvEmotes = checkStvEmbed.IsChecked.GetValueOrDefault();
options.Filename = filename;
options.ConnectionCount = (int)numChatDownloadConnections.Value;
options.VideoPlatform = platform;
- options.DownloadType = (downloadType == DownloadType.Clip) ? ChatDownloadType.Clip: ChatDownloadType.Video;
+ options.VideoType = videoType;
return options;
}
@@ -494,7 +463,7 @@ private async void SplitBtnDownload_Click(object sender, RoutedEventArgs e)
try
{
ChatDownloadOptions downloadOptions = GetOptions(saveFileDialog.FileName);
- if (downloadType == DownloadType.Video)
+ if (videoType == VideoType.Video)
{
if (checkCropStart.IsChecked == true)
{
@@ -583,12 +552,12 @@ private void BtnCancel_Click(object sender, RoutedEventArgs e)
private void checkCropStart_OnCheckStateChanged(object sender, RoutedEventArgs e)
{
- SetEnabledCropStart((bool)checkCropStart.IsChecked);
+ SetEnabledCropStart(checkCropStart.IsChecked.GetValueOrDefault());
}
private void checkCropEnd_OnCheckStateChanged(object sender, RoutedEventArgs e)
{
- SetEnabledCropEnd((bool)checkCropEnd.IsChecked);
+ SetEnabledCropEnd(checkCropEnd.IsChecked.GetValueOrDefault());
}
diff --git a/TwitchDownloaderWPF/PageChatRender.xaml b/TwitchDownloaderWPF/PageChatRender.xaml
index 3f7a2d25..c7560c89 100644
--- a/TwitchDownloaderWPF/PageChatRender.xaml
+++ b/TwitchDownloaderWPF/PageChatRender.xaml
@@ -20,7 +20,6 @@
diff --git a/TwitchDownloaderWPF/PageChatRender.xaml.cs b/TwitchDownloaderWPF/PageChatRender.xaml.cs
index 533ec657..185e4c40 100644
--- a/TwitchDownloaderWPF/PageChatRender.xaml.cs
+++ b/TwitchDownloaderWPF/PageChatRender.xaml.cs
@@ -96,13 +96,13 @@ public ChatRenderOptions GetOptions(string filename)
InputFile = textJson.Text,
BackgroundColor = backgroundColor,
AlternateBackgroundColor = altBackgroundColor,
- AlternateMessageBackgrounds = (bool)checkAlternateMessageBackgrounds.IsChecked,
+ AlternateMessageBackgrounds = checkAlternateMessageBackgrounds.IsChecked.GetValueOrDefault(),
ChatHeight = int.Parse(textHeight.Text),
ChatWidth = int.Parse(textWidth.Text),
- BttvEmotes = (bool)checkBTTV.IsChecked,
- FfzEmotes = (bool)checkFFZ.IsChecked,
- StvEmotes = (bool)checkSTV.IsChecked,
- Outline = (bool)checkOutline.IsChecked,
+ BttvEmotes = checkBTTV.IsChecked.GetValueOrDefault(),
+ FfzEmotes = checkFFZ.IsChecked.GetValueOrDefault(),
+ StvEmotes = checkSTV.IsChecked.GetValueOrDefault(),
+ Outline = checkOutline.IsChecked.GetValueOrDefault(),
Font = (string)comboFont.SelectedItem,
FontSize = numFontSize.Value,
UpdateRate = double.Parse(textUpdateTime.Text, CultureInfo.CurrentCulture),
@@ -118,22 +118,22 @@ public ChatRenderOptions GetOptions(string filename)
VerticalSpacingScale = double.Parse(textVerticalScale.Text, CultureInfo.CurrentCulture),
IgnoreUsersArray = textIgnoreUsersList.Text.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
BannedWordsArray = textBannedWordsList.Text.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
- Timestamp = (bool)checkTimestamp.IsChecked,
+ Timestamp = checkTimestamp.IsChecked.GetValueOrDefault(),
MessageColor = messageColor,
Framerate = int.Parse(textFramerate.Text),
InputArgs = CheckRenderSharpening.IsChecked == true ? textFfmpegInput.Text + " -filter_complex \"smartblur=lr=1:ls=-1.0\"" : textFfmpegInput.Text,
OutputArgs = textFfmpegOutput.Text,
MessageFontStyle = SKFontStyle.Normal,
UsernameFontStyle = SKFontStyle.Bold,
- GenerateMask = (bool)checkMask.IsChecked,
+ GenerateMask = checkMask.IsChecked.GetValueOrDefault(),
OutlineSize = 4 * double.Parse(textOutlineScale.Text, CultureInfo.CurrentCulture),
FfmpegPath = "ffmpeg",
TempFolder = Settings.Default.TempPath,
- SubMessages = (bool)checkSub.IsChecked,
- ChatBadges = (bool)checkBadge.IsChecked,
- Offline = (bool)checkOffline.IsChecked,
+ SubMessages = checkSub.IsChecked.GetValueOrDefault(),
+ ChatBadges = checkBadge.IsChecked.GetValueOrDefault(),
+ Offline = checkOffline.IsChecked.GetValueOrDefault(),
AllowUnlistedEmotes = true,
- DisperseCommentOffsets = (bool)checkDispersion.IsChecked,
+ DisperseCommentOffsets = checkDispersion.IsChecked.GetValueOrDefault(),
LogFfmpegOutput = true
};
if (RadioEmojiNotoColor.IsChecked == true)
@@ -287,29 +287,29 @@ private void ComboFormatOnSelectionChanged(object sender, SelectionChangedEventA
public void SaveSettings()
{
Settings.Default.Font = comboFont.SelectedItem.ToString();
- Settings.Default.Outline = (bool)checkOutline.IsChecked;
- Settings.Default.Timestamp = (bool)checkTimestamp.IsChecked;
- Settings.Default.BackgroundColorR = colorBackground.SelectedColor.Value.R;
- Settings.Default.BackgroundColorG = colorBackground.SelectedColor.Value.G;
- Settings.Default.BackgroundColorB = colorBackground.SelectedColor.Value.B;
- Settings.Default.BackgroundColorA = colorBackground.SelectedColor.Value.A;
- Settings.Default.AlternateBackgroundColorR = colorAlternateBackground.SelectedColor.Value.R;
- Settings.Default.AlternateBackgroundColorG = colorAlternateBackground.SelectedColor.Value.G;
- Settings.Default.AlternateBackgroundColorB = colorAlternateBackground.SelectedColor.Value.B;
- Settings.Default.AlternateBackgroundColorA = colorAlternateBackground.SelectedColor.Value.A;
- Settings.Default.FFZEmotes = (bool)checkFFZ.IsChecked;
- Settings.Default.BTTVEmotes = (bool)checkBTTV.IsChecked;
- Settings.Default.STVEmotes = (bool)checkSTV.IsChecked;
- Settings.Default.FontColorR = colorFont.SelectedColor.Value.R;
- Settings.Default.FontColorG = colorFont.SelectedColor.Value.G;
- Settings.Default.FontColorB = colorFont.SelectedColor.Value.B;
- Settings.Default.GenerateMask = (bool)checkMask.IsChecked;
- Settings.Default.ChatRenderSharpening = (bool)CheckRenderSharpening.IsChecked;
- Settings.Default.SubMessages = (bool)checkSub.IsChecked;
- Settings.Default.ChatBadges = (bool)checkBadge.IsChecked;
- Settings.Default.Offline = (bool)checkOffline.IsChecked;
- Settings.Default.DisperseCommentOffsets = (bool)checkDispersion.IsChecked;
- Settings.Default.AlternateMessageBackgrounds = (bool)checkAlternateMessageBackgrounds.IsChecked;
+ Settings.Default.Outline = checkOutline.IsChecked.GetValueOrDefault();
+ Settings.Default.Timestamp = checkTimestamp.IsChecked.GetValueOrDefault();
+ Settings.Default.BackgroundColorR = colorBackground.SelectedColor.GetValueOrDefault().R;
+ Settings.Default.BackgroundColorG = colorBackground.SelectedColor.GetValueOrDefault().G;
+ Settings.Default.BackgroundColorB = colorBackground.SelectedColor.GetValueOrDefault().B;
+ Settings.Default.BackgroundColorA = colorBackground.SelectedColor.GetValueOrDefault().A;
+ Settings.Default.AlternateBackgroundColorR = colorAlternateBackground.SelectedColor.GetValueOrDefault().R;
+ Settings.Default.AlternateBackgroundColorG = colorAlternateBackground.SelectedColor.GetValueOrDefault().G;
+ Settings.Default.AlternateBackgroundColorB = colorAlternateBackground.SelectedColor.GetValueOrDefault().B;
+ Settings.Default.AlternateBackgroundColorA = colorAlternateBackground.SelectedColor.GetValueOrDefault().A;
+ Settings.Default.FFZEmotes = checkFFZ.IsChecked.GetValueOrDefault();
+ Settings.Default.BTTVEmotes = checkBTTV.IsChecked.GetValueOrDefault();
+ Settings.Default.STVEmotes = checkSTV.IsChecked.GetValueOrDefault();
+ Settings.Default.FontColorR = colorFont.SelectedColor.GetValueOrDefault().R;
+ Settings.Default.FontColorG = colorFont.SelectedColor.GetValueOrDefault().G;
+ Settings.Default.FontColorB = colorFont.SelectedColor.GetValueOrDefault().B;
+ Settings.Default.GenerateMask = checkMask.IsChecked.GetValueOrDefault();
+ Settings.Default.ChatRenderSharpening = CheckRenderSharpening.IsChecked.GetValueOrDefault();
+ Settings.Default.SubMessages = checkSub.IsChecked.GetValueOrDefault();
+ Settings.Default.ChatBadges = checkBadge.IsChecked.GetValueOrDefault();
+ Settings.Default.Offline = checkOffline.IsChecked.GetValueOrDefault();
+ Settings.Default.DisperseCommentOffsets = checkDispersion.IsChecked.GetValueOrDefault();
+ Settings.Default.AlternateMessageBackgrounds = checkAlternateMessageBackgrounds.IsChecked.GetValueOrDefault();
if (comboFormat.SelectedItem != null)
{
Settings.Default.VideoContainer = ((VideoContainer)comboFormat.SelectedItem).Name;
@@ -376,21 +376,21 @@ private bool ValidateInputs()
try
{
- int.Parse(textHeight.Text);
- int.Parse(textWidth.Text);
- double.Parse(textUpdateTime.Text, CultureInfo.CurrentCulture);
- int.Parse(textFramerate.Text);
- double.Parse(textEmoteScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textBadgeScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textEmojiScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textVerticalScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textSidePaddingScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textSectionHeightScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textWordSpaceScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textEmoteSpaceScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textAccentStrokeScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textAccentIndentScale.Text, CultureInfo.CurrentCulture);
- double.Parse(textOutlineScale.Text, CultureInfo.CurrentCulture);
+ _ = int.Parse(textHeight.Text);
+ _ = int.Parse(textWidth.Text);
+ _ = double.Parse(textUpdateTime.Text, CultureInfo.CurrentCulture);
+ _ = int.Parse(textFramerate.Text);
+ _ = double.Parse(textEmoteScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textBadgeScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textEmojiScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textVerticalScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textSidePaddingScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textSectionHeightScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textWordSpaceScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textEmoteSpaceScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textAccentStrokeScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textAccentIndentScale.Text, CultureInfo.CurrentCulture);
+ _ = double.Parse(textOutlineScale.Text, CultureInfo.CurrentCulture);
}
catch (Exception ex)
{
diff --git a/TwitchDownloaderWPF/PageChatUpdate.xaml b/TwitchDownloaderWPF/PageChatUpdate.xaml
index adda5771..1eb270f3 100644
--- a/TwitchDownloaderWPF/PageChatUpdate.xaml
+++ b/TwitchDownloaderWPF/PageChatUpdate.xaml
@@ -20,7 +20,6 @@
diff --git a/TwitchDownloaderWPF/PageChatUpdate.xaml.cs b/TwitchDownloaderWPF/PageChatUpdate.xaml.cs
index 2d3cee4f..91ee3e7b 100644
--- a/TwitchDownloaderWPF/PageChatUpdate.xaml.cs
+++ b/TwitchDownloaderWPF/PageChatUpdate.xaml.cs
@@ -12,6 +12,7 @@
using TwitchDownloaderCore;
using TwitchDownloaderCore.Chat;
using TwitchDownloaderCore.Options;
+using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Twitch;
using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
using TwitchDownloaderWPF.Properties;
@@ -53,16 +54,34 @@ private async void btnBrowse_Click(object sender, RoutedEventArgs e)
textJson.Text = openFileDialog.FileName;
InputFile = openFileDialog.FileName;
- SetEnabled(true);
+ ChatJsonInfo = null;
+ imgThumbnail.Source = null;
+ SetEnabled(false);
if (Path.GetExtension(InputFile)!.ToLower() is not ".json" and not ".gz")
{
+ textJson.Text = "";
+ InputFile = "";
return;
}
- ChatJsonInfo = await ChatJson.DeserializeAsync(InputFile, true, false, CancellationToken.None);
- ChatJsonInfo.comments.RemoveRange(1, ChatJsonInfo.comments.Count - 2);
- GC.Collect();
+ try
+ {
+ ChatJsonInfo = await ChatJson.DeserializeAsync(InputFile, true, true, false, CancellationToken.None);
+ GC.Collect();
+ }
+ catch (Exception ex)
+ {
+ AppendLog(Translations.Strings.ErrorLog + ex.Message);
+ if (Settings.Default.VerboseErrors)
+ {
+ MessageBox.Show(ex.ToString(), Translations.Strings.VerboseErrorOutput, MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+
+ return;
+ }
+
+ SetEnabled(true);
var videoCreatedAt = ChatJsonInfo.video.created_at == default
? ChatJsonInfo.comments[0].created_at - TimeSpan.FromSeconds(ChatJsonInfo.comments[0].content_offset_seconds)
@@ -92,85 +111,81 @@ private async void btnBrowse_Click(object sender, RoutedEventArgs e)
ViewCount = ChatJsonInfo.video.viewCount;
Game = ChatJsonInfo.video.game ?? ChatJsonInfo.video.chapters.FirstOrDefault()?.gameDisplayName ?? "Unknown";
- if (VideoId.All(char.IsDigit))
+ try
{
- GqlVideoResponse videoInfo = await TwitchHelper.GetVideoInfo(int.Parse(VideoId));
- if (videoInfo.data.video == null)
+ if (VideoId.All(char.IsDigit))
{
- AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail + ": " + Translations.Strings.VodExpiredOrIdCorrupt);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
+ GqlVideoResponse videoInfo = await TwitchHelper.GetVideoInfo(int.Parse(VideoId));
+ if (videoInfo.data.video == null)
{
+ AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail + ": " + Translations.Strings.VodExpiredOrIdCorrupt);
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out var image);
imgThumbnail.Source = image;
+
+ numStartHour.Maximum = 48;
+ numEndHour.Maximum = 48;
}
- numStartHour.Maximum = 48;
- numEndHour.Maximum = 48;
- }
- else
- {
- VideoLength = TimeSpan.FromSeconds(videoInfo.data.video.lengthSeconds);
- labelLength.Text = VideoLength.ToString("c");
- numStartHour.Maximum = (int)VideoLength.TotalHours;
- numEndHour.Maximum = (int)VideoLength.TotalHours;
- ViewCount = videoInfo.data.video.viewCount;
- Game = videoInfo.data.video.game?.displayName;
-
- try
- {
- string thumbUrl = videoInfo.data.video.thumbnailURLs.FirstOrDefault();
- imgThumbnail.Source = await ThumbnailService.GetThumb(thumbUrl);
- }
- catch
+ else
{
- AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
+ VideoLength = TimeSpan.FromSeconds(videoInfo.data.video.lengthSeconds);
+ labelLength.Text = VideoLength.ToString("c");
+ numStartHour.Maximum = (int)VideoLength.TotalHours;
+ numEndHour.Maximum = (int)VideoLength.TotalHours;
+ ViewCount = videoInfo.data.video.viewCount;
+ Game = videoInfo.data.video.game?.displayName;
+
+ var thumbUrl = videoInfo.data.video.thumbnailURLs.FirstOrDefault();
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var image))
{
- imgThumbnail.Source = image;
+ AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail);
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out image);
}
- }
- }
- }
- else
- {
- if (VideoId != "-1")
- {
- numStartHour.Maximum = 0;
- numEndHour.Maximum = 0;
- }
- GqlClipResponse videoInfo = await TwitchHelper.GetClipInfo(VideoId);
- if (videoInfo.data.clip.video == null)
- {
- AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail + ": " + Translations.Strings.VodExpiredOrIdCorrupt);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
- {
+
imgThumbnail.Source = image;
}
}
else
{
- VideoLength = TimeSpan.FromSeconds(videoInfo.data.clip.durationSeconds);
- labelLength.Text = VideoLength.ToString("c");
- ViewCount = videoInfo.data.clip.viewCount;
- Game = videoInfo.data.clip.game?.displayName;
+ if (VideoId != "-1")
+ {
+ numStartHour.Maximum = 0;
+ numEndHour.Maximum = 0;
+ }
- try
+ GqlClipResponse videoInfo = await TwitchHelper.GetClipInfo(VideoId);
+ if (videoInfo.data.clip.video == null)
{
- string thumbUrl = videoInfo.data.clip.thumbnailURL;
- imgThumbnail.Source = await ThumbnailService.GetThumb(thumbUrl);
+ AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail + ": " + Translations.Strings.VodExpiredOrIdCorrupt);
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out var image);
+ imgThumbnail.Source = image;
}
- catch
+ else
{
- AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
+ VideoLength = TimeSpan.FromSeconds(videoInfo.data.clip.durationSeconds);
+ labelLength.Text = VideoLength.ToString("c");
+ ViewCount = videoInfo.data.clip.viewCount;
+ Game = videoInfo.data.clip.game?.displayName;
+
+ var thumbUrl = videoInfo.data.clip.thumbnailURL;
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var image))
{
- imgThumbnail.Source = image;
+ AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail);
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out image);
}
+
+ imgThumbnail.Source = image;
}
}
}
+ catch (Exception ex)
+ {
+ MessageBox.Show(Translations.Strings.UnableToGetInfoMessage, Translations.Strings.UnableToGetInfo, MessageBoxButton.OK, MessageBoxImage.Error);
+ AppendLog(Translations.Strings.ErrorLog + ex.Message);
+ if (Settings.Default.VerboseErrors)
+ {
+ MessageBox.Show(ex.ToString(), Translations.Strings.VerboseErrorOutput, MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
}
private void UpdateActionButtons(bool isUpdating)
@@ -253,22 +268,22 @@ public ChatUpdateOptions GetOptions(string outputFile)
{
ChatUpdateOptions options = new ChatUpdateOptions()
{
- EmbedMissing = (bool)checkEmbedMissing.IsChecked,
- ReplaceEmbeds = (bool)checkReplaceEmbeds.IsChecked,
- BttvEmotes = (bool)checkBttvEmbed.IsChecked,
- FfzEmotes = (bool)checkFfzEmbed.IsChecked,
- StvEmotes = (bool)checkStvEmbed.IsChecked,
+ EmbedMissing = checkEmbedMissing.IsChecked.GetValueOrDefault(),
+ ReplaceEmbeds = checkReplaceEmbeds.IsChecked.GetValueOrDefault(),
+ BttvEmotes = checkBttvEmbed.IsChecked.GetValueOrDefault(),
+ FfzEmotes = checkFfzEmbed.IsChecked.GetValueOrDefault(),
+ StvEmotes = checkStvEmbed.IsChecked.GetValueOrDefault(),
InputFile = textJson.Text,
OutputFile = outputFile,
CropBeginningTime = -1,
CropEndingTime = -1
};
- if ((bool)radioJson.IsChecked)
+ if (radioJson.IsChecked.GetValueOrDefault())
options.OutputFormat = ChatFormat.Json;
- else if ((bool)radioHTML.IsChecked)
+ else if (radioHTML.IsChecked.GetValueOrDefault())
options.OutputFormat = ChatFormat.Html;
- else if ((bool)radioText.IsChecked)
+ else if (radioText.IsChecked.GetValueOrDefault())
options.OutputFormat = ChatFormat.Text;
if (radioCompressionNone.IsChecked == true)
@@ -289,11 +304,11 @@ public ChatUpdateOptions GetOptions(string outputFile)
options.CropEndingTime = (int)Math.Round(end.TotalSeconds);
}
- if ((bool)radioTimestampUTC.IsChecked)
+ if (radioTimestampUTC.IsChecked.GetValueOrDefault())
options.TextTimestampFormat = TimestampFormat.Utc;
- else if ((bool)radioTimestampRelative.IsChecked)
+ else if (radioTimestampRelative.IsChecked.GetValueOrDefault())
options.TextTimestampFormat = TimestampFormat.Relative;
- else if ((bool)radioTimestampNone.IsChecked)
+ else if (radioTimestampNone.IsChecked.GetValueOrDefault())
options.TextTimestampFormat = TimestampFormat.None;
return options;
@@ -620,12 +635,12 @@ private void radioText_Checked(object sender, RoutedEventArgs e)
private void checkStart_OnCheckStateChanged(object sender, RoutedEventArgs e)
{
- SetEnabledCropStart((bool)checkStart.IsChecked);
+ SetEnabledCropStart(checkStart.IsChecked.GetValueOrDefault());
}
private void checkEnd_OnCheckStateChanged(object sender, RoutedEventArgs e)
{
- SetEnabledCropEnd((bool)checkEnd.IsChecked);
+ SetEnabledCropEnd(checkEnd.IsChecked.GetValueOrDefault());
}
private void MenuItemEnqueue_Click(object sender, RoutedEventArgs e)
diff --git a/TwitchDownloaderWPF/PageClipDownload.xaml b/TwitchDownloaderWPF/PageClipDownload.xaml
index 3d2c6903..c875b23e 100644
--- a/TwitchDownloaderWPF/PageClipDownload.xaml
+++ b/TwitchDownloaderWPF/PageClipDownload.xaml
@@ -19,7 +19,6 @@
diff --git a/TwitchDownloaderWPF/PageClipDownload.xaml.cs b/TwitchDownloaderWPF/PageClipDownload.xaml.cs
index a93f7099..fcc1907d 100644
--- a/TwitchDownloaderWPF/PageClipDownload.xaml.cs
+++ b/TwitchDownloaderWPF/PageClipDownload.xaml.cs
@@ -1,10 +1,7 @@
using Microsoft.Win32;
using System;
-using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
-using System.Linq;
-using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
@@ -15,8 +12,6 @@
using TwitchDownloaderCore.Options;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Interfaces;
-using TwitchDownloaderCore.VideoPlatforms.Twitch;
-using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
using TwitchDownloaderWPF.Properties;
using TwitchDownloaderWPF.Services;
using WpfAnimatedGif;
@@ -49,7 +44,7 @@ private async void btnGetInfo_Click(object sender, RoutedEventArgs e)
private async Task GetClipInfo()
{
bool parseSuccess = UrlParse.TryParseClip(textUrl.Text.Trim(), out VideoPlatform videoPlatform, out string videoId);
-
+
if (!parseSuccess || string.IsNullOrWhiteSpace(videoId))
{
MessageBox.Show(Translations.Strings.InvalidClipLinkIdMessage.Replace(@"\n", Environment.NewLine), Translations.Strings.InvalidClipLinkId, MessageBoxButton.OK, MessageBoxImage.Error);
@@ -65,20 +60,14 @@ private async Task GetClipInfo()
comboQuality.Items.Clear();
IVideoInfo clipInfo = await PlatformHelper.GetClipInfo(videoPlatform, clipId);
- try
- {
- string thumbUrl = clipInfo.ThumbnailUrl;
- imgThumbnail.Source = await ThumbnailService.GetThumb(thumbUrl);
- }
- catch
+ var thumbUrl = clipInfo.ThumbnailUrl;
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var image))
{
AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
- {
- imgThumbnail.Source = image;
- }
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out image);
}
+ imgThumbnail.Source = image;
+
clipLength = TimeSpan.FromSeconds(clipInfo.Duration);
textStreamer.Text = clipInfo.StreamerName;
var clipCreatedAt = clipInfo.CreatedAt;
@@ -226,7 +215,7 @@ private async void SplitBtnDownload_Click(object sender, RoutedEventArgs e)
try
{
var downloadProgress = new Progress(OnProgressChanged);
- ClipDownloaderFactory clipDownloaderFactory = new ClipDownloaderFactory(downloadProgress);
+ ClipDownloaderFactory clipDownloaderFactory = new ClipDownloaderFactory(downloadProgress);
await clipDownloaderFactory.Create(downloadOptions)
.DownloadAsync(_cancellationTokenSource.Token);
diff --git a/TwitchDownloaderWPF/PageQueue.xaml b/TwitchDownloaderWPF/PageQueue.xaml
index b83b611c..cfe0dca9 100644
--- a/TwitchDownloaderWPF/PageQueue.xaml
+++ b/TwitchDownloaderWPF/PageQueue.xaml
@@ -32,8 +32,8 @@
-
-
+
+
@@ -50,7 +50,7 @@
-
+
diff --git a/TwitchDownloaderWPF/PageQueue.xaml.cs b/TwitchDownloaderWPF/PageQueue.xaml.cs
index cad1c259..e4cd2ee1 100644
--- a/TwitchDownloaderWPF/PageQueue.xaml.cs
+++ b/TwitchDownloaderWPF/PageQueue.xaml.cs
@@ -1,13 +1,12 @@
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
+using System.Collections.ObjectModel;
using System.ComponentModel;
-using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using TwitchDownloaderWPF.TwitchTasks;
using TwitchDownloaderWPF.Properties;
using System.Diagnostics;
+using TwitchDownloaderCore.Tools;
namespace TwitchDownloaderWPF
{
@@ -175,13 +174,13 @@ private void btnUrlList_Click(object sender, RoutedEventArgs e)
private void btnVods_Click(object sender, RoutedEventArgs e)
{
- WindowMassDownload window = new WindowMassDownload(DownloadType.Video);
+ WindowMassDownload window = new WindowMassDownload(VideoType.Video);
window.ShowDialog();
}
private void btnClips_Click(object sender, RoutedEventArgs e)
{
- WindowMassDownload window = new WindowMassDownload(DownloadType.Clip);
+ WindowMassDownload window = new WindowMassDownload(VideoType.Clip);
window.ShowDialog();
}
@@ -204,7 +203,7 @@ private void btnCancelTask_Click(object sender, RoutedEventArgs e)
private void btnTaskError_Click(object sender, RoutedEventArgs e)
{
- if (!(sender is Button { DataContext: ITwitchTask task }))
+ if (sender is not Button { DataContext: ITwitchTask task })
{
return;
}
@@ -222,12 +221,12 @@ private void btnTaskError_Click(object sender, RoutedEventArgs e)
errorMessage = taskException.Exception.ToString();
}
- MessageBox.Show(errorMessage, Translations.Strings.TaskError, MessageBoxButton.OK, MessageBoxImage.Error);
+ MessageBox.Show(errorMessage, Translations.Strings.MessageBoxTitleError, MessageBoxButton.OK, MessageBoxImage.Error);
}
private void btnRemoveTask_Click(object sender, RoutedEventArgs e)
{
- if (!(sender is Button { DataContext: ITwitchTask task }))
+ if (sender is not Button { DataContext: ITwitchTask task })
{
return;
}
diff --git a/TwitchDownloaderWPF/PageVodDownload.xaml b/TwitchDownloaderWPF/PageVodDownload.xaml
index f554e9eb..ed2be6a8 100644
--- a/TwitchDownloaderWPF/PageVodDownload.xaml
+++ b/TwitchDownloaderWPF/PageVodDownload.xaml
@@ -21,7 +21,6 @@
diff --git a/TwitchDownloaderWPF/PageVodDownload.xaml.cs b/TwitchDownloaderWPF/PageVodDownload.xaml.cs
index 5d2ba5fa..4535082c 100644
--- a/TwitchDownloaderWPF/PageVodDownload.xaml.cs
+++ b/TwitchDownloaderWPF/PageVodDownload.xaml.cs
@@ -4,9 +4,7 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
-using System.Linq;
using System.Net;
-using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
@@ -19,9 +17,6 @@
using TwitchDownloaderCore.Options;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Interfaces;
-using TwitchDownloaderCore.VideoPlatforms.Twitch;
-using TwitchDownloaderCore.VideoPlatforms.Twitch.Downloaders;
-using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
using TwitchDownloaderWPF.Properties;
using TwitchDownloaderWPF.Services;
using WpfAnimatedGif;
@@ -33,7 +28,7 @@ namespace TwitchDownloaderWPF
///
public partial class PageVodDownload : Page
{
- public Dictionary videoQualities = new();
+ public readonly Dictionary videoQualities = new();
public string currentVideoId;
public VideoPlatform platform;
public DateTime currentVideoTime;
@@ -54,8 +49,8 @@ private void SetEnabled(bool isEnabled)
checkEnd.IsEnabled = isEnabled;
SplitBtnDownload.IsEnabled = isEnabled;
MenuItemEnqueue.IsEnabled = isEnabled;
- SetEnabledCropStart(isEnabled & (bool)checkStart.IsChecked);
- SetEnabledCropEnd(isEnabled & (bool)checkEnd.IsChecked);
+ SetEnabledCropStart(isEnabled & checkStart.IsChecked.GetValueOrDefault());
+ SetEnabledCropEnd(isEnabled & checkEnd.IsChecked.GetValueOrDefault());
}
private void SetEnabledCropStart(bool isEnabled)
@@ -102,26 +97,19 @@ private async Task GetVideoInfo()
{
videoInfo = await PlatformHelper.GetVideoInfo(videoPlatform, videoId, TextOauth.Text);
}
- catch (NullReferenceException ex)
+ catch (NullReferenceException ex)
{
if (ex.Message.Contains("Insufficient access"))
throw new NullReferenceException(Translations.Strings.InsufficientAccessMayNeedOauth);
}
- try
- {
- string thumbUrl = videoInfo.ThumbnailUrl;
- imgThumbnail.Source = await ThumbnailService.GetThumb(thumbUrl);
- }
- catch
+ var thumbUrl = videoInfo!.ThumbnailUrl;
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var image))
{
AppendLog(Translations.Strings.ErrorLog + Translations.Strings.UnableToFindThumbnail);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
- {
- imgThumbnail.Source = image;
- }
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out image);
}
+ imgThumbnail.Source = image;
comboQuality.Items.Clear();
videoQualities.Clear();
@@ -135,7 +123,7 @@ private async Task GetVideoInfo()
comboQuality.Items.Add(videoQuality.Quality);
}
}
-
+
comboQuality.SelectedIndex = 0;
vodLength = TimeSpan.FromSeconds(videoInfo.Duration);
@@ -144,7 +132,7 @@ private async Task GetVideoInfo()
var videoCreatedAt = videoInfo.CreatedAt;
textCreatedAt.Text = Settings.Default.UTCVideoTime ? videoCreatedAt.ToString(CultureInfo.CurrentCulture) : videoCreatedAt.ToLocalTime().ToString(CultureInfo.CurrentCulture);
currentVideoTime = Settings.Default.UTCVideoTime ? videoCreatedAt : videoCreatedAt.ToLocalTime();
- var urlTimeCodeMatch = Regex.Match(textUrl.Text, @"(?<=\?t=)\d+h\d+m\d+s");
+ var urlTimeCodeMatch = UrlParse.UrlTimeCode.Match(textUrl.Text);
if (urlTimeCodeMatch.Success)
{
var time = TimeSpanExtensions.ParseTimeCode(urlTimeCodeMatch.ValueSpan);
@@ -212,9 +200,9 @@ public VideoDownloadOptions GetOptions(string filename, string folder)
Oauth = TextOauth.Text,
Quality = GetQualityWithoutSize(comboQuality.Text).ToString(),
Id = currentVideoId,
- CropBeginning = (bool)checkStart.IsChecked,
+ CropBeginning = checkStart.IsChecked.GetValueOrDefault(),
CropBeginningTime = (int)(new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value).TotalSeconds),
- CropEnding = (bool)checkEnd.IsChecked,
+ CropEnding = checkEnd.IsChecked.GetValueOrDefault(),
CropEndingTime = (int)(new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value).TotalSeconds),
FfmpegPath = "ffmpeg",
TempFolder = Settings.Default.TempPath,
@@ -289,20 +277,9 @@ public void SetImage(string imageUri, bool isGif)
}
}
- private static int ValidateUrl(string text)
- {
- var vodIdMatch = Regex.Match(text, @"(?<=^|twitch\.tv\/videos\/)\d+(?=$|\?)");
- if (vodIdMatch.Success && int.TryParse(vodIdMatch.ValueSpan, out var vodId))
- {
- return vodId;
- }
-
- return -1;
- }
-
public bool ValidateInputs()
{
- if ((bool)checkStart.IsChecked)
+ if (checkStart.IsChecked.GetValueOrDefault())
{
var beginTime = new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value);
if (beginTime.TotalSeconds >= vodLength.TotalSeconds)
@@ -310,7 +287,7 @@ public bool ValidateInputs()
return false;
}
- if ((bool)checkEnd.IsChecked)
+ if (checkEnd.IsChecked.GetValueOrDefault())
{
var endTime = new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value);
if (endTime.TotalSeconds < beginTime.TotalSeconds)
@@ -377,14 +354,14 @@ private void Page_Loaded(object sender, RoutedEventArgs e)
private void checkStart_OnCheckStateChanged(object sender, RoutedEventArgs e)
{
- SetEnabledCropStart((bool)checkStart.IsChecked);
+ SetEnabledCropStart(checkStart.IsChecked.GetValueOrDefault());
UpdateVideoSizeEstimates();
}
private void checkEnd_OnCheckStateChanged(object sender, RoutedEventArgs e)
{
- SetEnabledCropEnd((bool)checkEnd.IsChecked);
+ SetEnabledCropEnd(checkEnd.IsChecked.GetValueOrDefault());
UpdateVideoSizeEstimates();
}
diff --git a/TwitchDownloaderWPF/Properties/AssemblyInfo.cs b/TwitchDownloaderWPF/Properties/AssemblyInfo.cs
index bade434f..2128e947 100644
--- a/TwitchDownloaderWPF/Properties/AssemblyInfo.cs
+++ b/TwitchDownloaderWPF/Properties/AssemblyInfo.cs
@@ -12,7 +12,7 @@
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("TwitchDownloader")]
-[assembly: AssemblyCopyright("Copyright © 2019 lay295 and contributors")]
+[assembly: AssemblyCopyright("Copyright © lay295 and contributors")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
diff --git a/TwitchDownloaderWPF/README.md b/TwitchDownloaderWPF/README.md
index d88e24a7..bedbb143 100644
--- a/TwitchDownloaderWPF/README.md
+++ b/TwitchDownloaderWPF/README.md
@@ -53,7 +53,7 @@ Downloads a clip from Twitch.
![Figure 2.1](Images/clipExample.png)
*Figure 2.1*
-To get started, input a valid link or ID to a clip. From there the the download options will unlock, allowing you to customize the job.
+To get started, input a valid link or ID to a clip. From there the download options will unlock, allowing you to customize the job.
**Quality**: Selects the quality of the clip before downloading.
diff --git a/TwitchDownloaderWPF/Services/AvailableCultures.cs b/TwitchDownloaderWPF/Services/AvailableCultures.cs
index 76a25c13..ffdc5cb9 100644
--- a/TwitchDownloaderWPF/Services/AvailableCultures.cs
+++ b/TwitchDownloaderWPF/Services/AvailableCultures.cs
@@ -24,6 +24,7 @@ public static class AvailableCultures
public static readonly Culture Polish;
public static readonly Culture Russian;
public static readonly Culture Turkish;
+ public static readonly Culture Ukrainian;
public static readonly Culture SimplifiedChinese;
public static readonly Culture[] All;
@@ -38,6 +39,7 @@ static AvailableCultures()
Polish = new Culture("pl-PL", "Polski"),
Russian = new Culture("ru-RU", "Русский"),
Turkish = new Culture("tr-TR", "Türkçe"),
+ Ukrainian = new Culture("uk-ua", "Українська"),
SimplifiedChinese = new Culture("zh-CN", "简体中文")
};
}
diff --git a/TwitchDownloaderWPF/Services/DefaultThemeService.cs b/TwitchDownloaderWPF/Services/DefaultThemeService.cs
index 1666ddd6..c766f06a 100644
--- a/TwitchDownloaderWPF/Services/DefaultThemeService.cs
+++ b/TwitchDownloaderWPF/Services/DefaultThemeService.cs
@@ -1,4 +1,5 @@
-using System.IO;
+using System;
+using System.IO;
using System.Linq;
using System.Reflection;
@@ -15,6 +16,8 @@ public static bool WriteIncludedThemes()
foreach (var themeResourcePath in themeResourcePaths)
{
using var themeStream = GetResourceStream(themeResourcePath);
+ if (themeStream is null) continue;
+
var themePathSplit = themeResourcePath.Split(".");
var themeName = themePathSplit[^2];
@@ -23,11 +26,11 @@ public static bool WriteIncludedThemes()
try
{
- using var fs = new FileStream(themeFullPath, FileMode.Create, FileAccess.Write, FileShare.Read, 4096);
+ using var fs = new FileStream(themeFullPath, FileMode.Create, FileAccess.Write, FileShare.Read);
themeStream.CopyTo(fs);
}
catch (IOException) { }
- catch (System.UnauthorizedAccessException) { }
+ catch (UnauthorizedAccessException) { }
catch (System.Security.SecurityException) { }
if (!File.Exists(themeFullPath))
diff --git a/TwitchDownloaderWPF/Services/NativeFunctions.cs b/TwitchDownloaderWPF/Services/NativeFunctions.cs
index cc698f8d..d4a4e08d 100644
--- a/TwitchDownloaderWPF/Services/NativeFunctions.cs
+++ b/TwitchDownloaderWPF/Services/NativeFunctions.cs
@@ -1,11 +1,13 @@
using System;
using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
namespace TwitchDownloaderWPF.Services
{
- public static class NativeFunctions
+ [SupportedOSPlatform("windows")]
+ public static unsafe class NativeFunctions
{
[DllImport("dwmapi.dll", EntryPoint = "DwmSetWindowAttribute", PreserveSig = true)]
- public static extern int SetWindowAttribute(IntPtr handle, int attribute, ref bool attributeValue, int attributeSize);
+ public static extern int SetWindowAttribute(IntPtr handle, int attribute, void* attributeValue, uint attributeSize);
}
}
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Services/ThemeService.cs b/TwitchDownloaderWPF/Services/ThemeService.cs
index 9aa8d28a..015a2cd1 100644
--- a/TwitchDownloaderWPF/Services/ThemeService.cs
+++ b/TwitchDownloaderWPF/Services/ThemeService.cs
@@ -1,8 +1,9 @@
using HandyControl.Data;
using System;
using System.IO;
-using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
using System.Windows;
+using System.Windows.Interop;
using System.Windows.Media;
using System.Xml.Serialization;
using TwitchDownloaderWPF.Models;
@@ -12,7 +13,10 @@ namespace TwitchDownloaderWPF.Services
{
public class ThemeService
{
- private const int TITLEBAR_THEME_ATTRIBUTE = 20;
+ private const int WINDOWS_1809_BUILD_NUMBER = 17763;
+ private const int WINDOWS_2004_INSIDER_BUILD_NUMBER = 18985;
+ private const int USE_IMMERSIVE_DARK_MODE_ATTRIBUTE_BEFORE_2004 = 19;
+ private const int USE_IMMERSIVE_DARK_MODE_ATTRIBUTE = 20;
private bool _darkAppTitleBar = false;
private bool _darkHandyControl = false;
@@ -24,8 +28,14 @@ public ThemeService(App app, WindowsThemeService windowsThemeService)
{
if (!Directory.Exists("Themes"))
{
- Directory.CreateDirectory("Themes");
+ try
+ {
+ Directory.CreateDirectory("Themes");
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
}
+
if (!DefaultThemeService.WriteIncludedThemes())
{
MessageBox.Show(Translations.Strings.ThemesFailedToWrite, Translations.Strings.ThemesFailedToWrite, MessageBoxButton.OK, MessageBoxImage.Information);
@@ -67,6 +77,7 @@ public void ChangeAppTheme()
{
newTheme = WindowsThemeService.GetWindowsTheme();
}
+
ChangeThemePath(newTheme);
var newSkin = _darkHandyControl ? SkinType.Dark : SkinType.Default;
@@ -78,18 +89,27 @@ public void ChangeAppTheme()
}
}
+ [SupportedOSPlatform("windows")]
public void SetTitleBarTheme(WindowCollection windows)
{
- // If windows 10 build is before 1903, it doesn't support dark title bars
- if (Environment.OSVersion.Version.Build < 18362)
- {
+ if (Environment.OSVersion.Version.Major < 10 || Environment.OSVersion.Version.Build < WINDOWS_1809_BUILD_NUMBER)
return;
- }
+
+ var shouldUseDarkTitleBar = Convert.ToInt32(_darkAppTitleBar);
+ var darkTitleBarAttribute = Environment.OSVersion.Version.Build < WINDOWS_2004_INSIDER_BUILD_NUMBER
+ ? USE_IMMERSIVE_DARK_MODE_ATTRIBUTE_BEFORE_2004
+ : USE_IMMERSIVE_DARK_MODE_ATTRIBUTE;
foreach (Window window in windows)
{
- var windowHandle = new System.Windows.Interop.WindowInteropHelper(window).Handle;
- NativeFunctions.SetWindowAttribute(windowHandle, TITLEBAR_THEME_ATTRIBUTE, ref _darkAppTitleBar, Marshal.SizeOf(_darkAppTitleBar));
+ var windowHandle = new WindowInteropHelper(window).Handle;
+ if (windowHandle == IntPtr.Zero)
+ continue;
+
+ unsafe
+ {
+ _ = NativeFunctions.SetWindowAttribute(windowHandle, darkTitleBarAttribute, &shouldUseDarkTitleBar, sizeof(int));
+ }
}
Window wnd = new()
@@ -100,12 +120,16 @@ public void SetTitleBarTheme(WindowCollection windows)
};
wnd.Show();
wnd.Close();
- // Dark title bar is a bit buggy, requires window resize or focus change to fully apply
+ // Dark title bar is a bit buggy, requires window redraw (focus change, resize, transparency change) to fully apply.
+ // We *could* send a repaint message to win32.dll, but this solution works and is way easier.
// Win11 might not have this issue but Win10 does so please leave this
}
private void ChangeThemePath(string newTheme)
{
+ if (!Directory.Exists("Themes"))
+ return;
+
var themeFiles = Directory.GetFiles("Themes", "*.xaml");
var newThemeString = Path.Combine("Themes", $"{newTheme}.xaml");
@@ -114,13 +138,18 @@ private void ChangeThemePath(string newTheme)
if (!newThemeString.Equals(themeFile, StringComparison.OrdinalIgnoreCase))
continue;
- var xmlReader = new XmlSerializer(typeof(ResourceDictionaryModel));
+ var xmlReader = new XmlSerializer(typeof(ThemeResourceDictionaryModel));
using var streamReader = new StreamReader(themeFile);
- var themeValues = (ResourceDictionaryModel)xmlReader.Deserialize(streamReader)!;
+ var themeValues = (ThemeResourceDictionaryModel)xmlReader.Deserialize(streamReader)!;
+ var brushConverter = new BrushConverter();
foreach (var solidBrush in themeValues.SolidColorBrush)
{
- _wpfApplication.Resources[solidBrush.Key] = (SolidColorBrush)new BrushConverter().ConvertFrom(solidBrush.Color);
+ try
+ {
+ _wpfApplication.Resources[solidBrush.Key] = (SolidColorBrush)brushConverter.ConvertFrom(solidBrush.Color);
+ }
+ catch (FormatException) { }
}
foreach (var boolean in themeValues.Boolean)
@@ -146,4 +175,4 @@ private void SetHandyControlTheme(SkinType newSkin)
_wpfApplication.Resources.MergedDictionaries[1].Source = new Uri($"pack://application:,,,/HandyControl;component/Themes/Theme.xaml", UriKind.Absolute);
}
}
-}
+}
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Services/ThumbnailService.cs b/TwitchDownloaderWPF/Services/ThumbnailService.cs
index 9d960af1..60670be5 100644
--- a/TwitchDownloaderWPF/Services/ThumbnailService.cs
+++ b/TwitchDownloaderWPF/Services/ThumbnailService.cs
@@ -1,5 +1,5 @@
-using System.Net.Http;
-using System.Threading.Tasks;
+using System;
+using System.Diagnostics.CodeAnalysis;
using System.Windows.Media.Imaging;
namespace TwitchDownloaderWPF.Services
@@ -7,27 +7,43 @@ namespace TwitchDownloaderWPF.Services
public static class ThumbnailService
{
public const string THUMBNAIL_MISSING_URL = @"https://vod-secure.twitch.tv/_404/404_processing_320x180.png";
- private static readonly HttpClient _httpClient = new();
- public static async Task GetThumb(string thumbUrl)
+ /// The was
+ public static BitmapImage GetThumb(string thumbUrl, BitmapCacheOption cacheOption = BitmapCacheOption.OnLoad)
{
- BitmapImage img = new BitmapImage();
- img.CacheOption = BitmapCacheOption.OnLoad;
+ ArgumentNullException.ThrowIfNull(thumbUrl);
+
+ var img = new BitmapImage { CacheOption = cacheOption };
img.BeginInit();
- img.StreamSource = await _httpClient.GetStreamAsync(thumbUrl);
+ img.UriSource = new Uri(thumbUrl);
img.EndInit();
+ img.DownloadCompleted += static (sender, _) =>
+ {
+ if (sender is BitmapImage { CanFreeze: true } image)
+ {
+ image.Freeze();
+ }
+ };
return img;
}
- public static async Task<(bool success, BitmapImage image)> TryGetThumb(string thumbUrl)
+ public static bool TryGetThumb(string thumbUrl, [NotNullWhen(true)] out BitmapImage thumbnail)
{
+ if (string.IsNullOrWhiteSpace(thumbUrl))
+ {
+ thumbnail = null;
+ return false;
+ }
+
try
{
- return (true, await GetThumb(thumbUrl));
+ thumbnail = GetThumb(thumbUrl);
+ return thumbnail != null;
}
catch
{
- return (false, null);
+ thumbnail = null;
+ return false;
}
}
}
diff --git a/TwitchDownloaderWPF/Services/WindowsThemeService.cs b/TwitchDownloaderWPF/Services/WindowsThemeService.cs
index 2d7cb366..aebcbd54 100644
--- a/TwitchDownloaderWPF/Services/WindowsThemeService.cs
+++ b/TwitchDownloaderWPF/Services/WindowsThemeService.cs
@@ -1,11 +1,14 @@
using Microsoft.Win32;
using System;
using System.Management;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
using System.Security.Principal;
using System.Windows;
namespace TwitchDownloaderWPF.Services
{
+ [SupportedOSPlatform("windows")]
public class WindowsThemeService : ManagementEventWatcher
{
public event EventHandler ThemeChanged;
@@ -14,14 +17,13 @@ public class WindowsThemeService : ManagementEventWatcher
private const string REGISTRY_KEY_NAME = "AppsUseLightTheme";
private const string LIGHT_THEME = "Light";
private const string DARK_THEME = "Dark";
+ private const int WINDOWS_1809_BUILD_NUMBER = 17763;
public WindowsThemeService()
{
- // If windows version is before windows 10 or the windows 10 build is before 1809, it doesn't have the app theme registry key
- if (Environment.OSVersion.Version.Major < 10 || Environment.OSVersion.Version.Build < 17763)
- {
+ // If the OS is older than Windows 10 1809 then it doesn't have the app theme registry key
+ if (Environment.OSVersion.Version.Major < 10 || Environment.OSVersion.Version.Build < WINDOWS_1809_BUILD_NUMBER)
return;
- }
var currentUser = WindowsIdentity.GetCurrent().User;
@@ -33,7 +35,14 @@ public WindowsThemeService()
Query = new EventQuery(windowsQuery);
EventArrived += WindowsThemeService_EventArrived;
- Start();
+ try
+ {
+ Start();
+ }
+ catch (ExternalException e)
+ {
+ MessageBox.Show(string.Format(Translations.Strings.UnableToStartWindowsThemeWatcher, $"0x{e.ErrorCode:x8}"), Translations.Strings.MessageBoxTitleError, MessageBoxButton.OK, MessageBoxImage.Error);
+ }
}
private void WindowsThemeService_EventArrived(object sender, EventArrivedEventArgs e)
diff --git a/TwitchDownloaderWPF/Translations/Strings.Designer.cs b/TwitchDownloaderWPF/Translations/Strings.Designer.cs
index 48fd96f9..2680bae6 100644
--- a/TwitchDownloaderWPF/Translations/Strings.Designer.cs
+++ b/TwitchDownloaderWPF/Translations/Strings.Designer.cs
@@ -1,6 +1,7 @@
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
@@ -419,6 +420,24 @@ public static string ClipLinkId {
}
}
+ ///
+ /// Looks up a localized string similar to Copy ID to clipboard.
+ ///
+ public static string CopyVideoIDToClipboard {
+ get {
+ return ResourceManager.GetString("CopyVideoIDToClipboard", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Copy URL to clipboard.
+ ///
+ public static string CopyVideoURLToClipboard {
+ get {
+ return ResourceManager.GetString("CopyVideoURLToClipboard", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to End.
///
@@ -941,6 +960,15 @@ public static string JsonFile {
}
}
+ ///
+ /// Looks up a localized string similar to Videos per page:.
+ ///
+ public static string LabelVideosPerPage {
+ get {
+ return ResourceManager.GetString("LabelVideosPerPage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Length:.
///
@@ -1004,6 +1032,15 @@ public static string MaximumThreadBandwidthTooltip {
}
}
+ ///
+ /// Looks up a localized string similar to Error.
+ ///
+ public static string MessageBoxTitleError {
+ get {
+ return ResourceManager.GetString("MessageBoxTitleError", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Dispersion .
///
@@ -1067,6 +1104,15 @@ public static string OfflineTooltip {
}
}
+ ///
+ /// Looks up a localized string similar to Open in browser.
+ ///
+ public static string OpenVideoInBrowser {
+ get {
+ return ResourceManager.GetString("OpenVideoInBrowser", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Outline:.
///
@@ -1364,6 +1410,15 @@ public static string StatusDone {
}
}
+ ///
+ /// Looks up a localized string similar to Downloading FFmpeg {0}%.
+ ///
+ public static string StatusDownloaderFFmpeg {
+ get {
+ return ResourceManager.GetString("StatusDownloaderFFmpeg", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Downloading.
///
@@ -1457,9 +1512,9 @@ public static string TaskCouldNotBeRemoved {
///
/// Looks up a localized string similar to Error.
///
- public static string TaskError {
+ public static string TaskErrorButton {
get {
- return ResourceManager.GetString("TaskError", resourceCulture);
+ return ResourceManager.GetString("TaskErrorButton", resourceCulture);
}
}
@@ -1805,6 +1860,15 @@ public static string UnableToParseLinkMessage {
}
}
+ ///
+ /// Looks up a localized string similar to Unable to start Windows application theme watcher. Error code: {0}.
+ ///
+ public static string UnableToStartWindowsThemeWatcher {
+ get {
+ return ResourceManager.GetString("UnableToStartWindowsThemeWatcher", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Unknown.
///
diff --git a/TwitchDownloaderWPF/Translations/Strings.es.resx b/TwitchDownloaderWPF/Translations/Strings.es.resx
index 652ddcd2..d40389e1 100644
--- a/TwitchDownloaderWPF/Translations/Strings.es.resx
+++ b/TwitchDownloaderWPF/Translations/Strings.es.resx
@@ -470,7 +470,7 @@
Cancelar
-
+
Error
@@ -766,4 +766,25 @@
Codificar metadatos:
-
+
+ Error
+
+
+ No se puede iniciar el observador de temas de la aplicación de Windows. Código de error: {0}
+
+
+ Vídeos por página:
+
+
+ Descarga FFmpeg {0}%
+
+
+ Copy ID to clipboard
+
+
+ Copy URL to clipboard
+
+
+ Open in browser
+
+
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Translations/Strings.fr.resx b/TwitchDownloaderWPF/Translations/Strings.fr.resx
index d9f89300..6be5233c 100644
--- a/TwitchDownloaderWPF/Translations/Strings.fr.resx
+++ b/TwitchDownloaderWPF/Translations/Strings.fr.resx
@@ -470,7 +470,7 @@
Annulation
-
+
Erreur
@@ -765,4 +765,25 @@
Inclure les métadonnées
+
+ Erreur
+
+
+ Impossible de démarrer l'observateur de thème de l'application Windows. Code d'erreur : {0}
+
+
+ Vidéos par page:
+
+
+ Downloading FFmpeg {0}%
+
+
+ Copier l'identifiant vidéo
+
+
+ Copier le lien vidéo
+
+
+ Ouvrir dans un navigateur web
+
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Translations/Strings.pl.resx b/TwitchDownloaderWPF/Translations/Strings.pl.resx
index 9ee24e8f..858d3e5d 100644
--- a/TwitchDownloaderWPF/Translations/Strings.pl.resx
+++ b/TwitchDownloaderWPF/Translations/Strings.pl.resx
@@ -470,7 +470,7 @@
Anuluj
-
+
Błąd
@@ -765,4 +765,25 @@
Encode Metadata:
+
+ Błąd
+
+
+ Unable to start Windows application theme watcher. Error code: {0}
+
+
+ Videos per page:
+
+
+ Downloading FFmpeg {0}%
+
+
+ Copy ID to clipboard
+
+
+ Copy URL to clipboard
+
+
+ Open in browser
+
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Translations/Strings.resx b/TwitchDownloaderWPF/Translations/Strings.resx
index 426d4b59..1f22a7b8 100644
--- a/TwitchDownloaderWPF/Translations/Strings.resx
+++ b/TwitchDownloaderWPF/Translations/Strings.resx
@@ -470,7 +470,7 @@
Cancel
-
+
Error
@@ -764,4 +764,25 @@
Encode Metadata:
+
+ Error
+
+
+ Unable to start Windows application theme watcher. Error code: {0}
+
+
+ Videos per page:
+
+
+ Downloading FFmpeg {0}%
+
+
+ Copy ID to clipboard
+
+
+ Copy URL to clipboard
+
+
+ Open in browser
+
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Translations/Strings.ru.resx b/TwitchDownloaderWPF/Translations/Strings.ru.resx
index 1d50d93b..a627640d 100644
--- a/TwitchDownloaderWPF/Translations/Strings.ru.resx
+++ b/TwitchDownloaderWPF/Translations/Strings.ru.resx
@@ -470,7 +470,7 @@
Отмена
-
+
Ошибка
@@ -765,4 +765,25 @@
Encode Metadata:
+
+ Ошибка
+
+
+ Unable to start Windows application theme watcher. Error code: {0}
+
+
+ Videos per page:
+
+
+ Downloading FFmpeg {0}%
+
+
+ Copy ID to clipboard
+
+
+ Copy URL to clipboard
+
+
+ Open in browser
+
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Translations/Strings.tr.resx b/TwitchDownloaderWPF/Translations/Strings.tr.resx
index f752f139..a2b08a71 100644
--- a/TwitchDownloaderWPF/Translations/Strings.tr.resx
+++ b/TwitchDownloaderWPF/Translations/Strings.tr.resx
@@ -471,7 +471,7 @@
İptal
-
+
Hata
@@ -740,30 +740,51 @@
C# standart TimeSpan biçim dizeleri
- An unknown error occurred
+ Bilinmeyen bir hata oluştu
- The task could not be removed
+ Görev kaldırılamadı
- Please cancel the task or wait for it to finish before removing it
+ Lütfen görevi iptal edin veya kaldırmadan önce bitmesini bekleyin
- Unable to download FFmpeg
+ FFmpeg indirilemiyor
- Unable to download FFmpeg. Please manually download it from {0} and place the file at {1}
+ FFmpeg indirilemiyor. Lütfen {0} adresinden manuel olarak indirin ve dosyayı {1} konumuna yerleştirin.
- Alt Background Color:
+ Alternatif Arka Plan Rengi:
Alternatif Arka Planlar Leave a trailing space
- Alternates the background color of every other chat message to help tell them apart.
+ Diğer sohbet mesajlarının arka plan rengini değiştirerek onları birbirinden ayırmaya yardımcı olur.
- Encode Metadata:
+ Meta Verileri Kodlayın:
+
+
+ Hata
+
+
+ Windows uygulama teması izleyicisi başlatılamıyor. Hata kodu: {0}
+
+
+ Sayfa başına video:
+
+
+ FFmpeg İndiriliyor {0}%
+
+
+ Copy ID to clipboard
+
+
+ Copy URL to clipboard
+
+
+ Open in browser
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Translations/Strings.uk.resx b/TwitchDownloaderWPF/Translations/Strings.uk.resx
new file mode 100644
index 00000000..d7bce07f
--- /dev/null
+++ b/TwitchDownloaderWPF/Translations/Strings.uk.resx
@@ -0,0 +1,789 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Прийняти
+
+
+ Додати в чергу
+
+
+ Ви вибрали альфа-канал (прозорість) для контейнера/кодека, який його не підтримує. Видаліть прозорість або закодуйте за допомогою MOV + RLE/PRORES або WEBM + VP8/VP9
+
+
+ Тема
+ Leave a trailing space
+
+
+ Натисніть, щоб дізнатися, як створити власну тему!
+
+
+ Доступні параметри:
+
+
+ Колір фону:
+
+
+ Заборонені слова
+ Leaving a trailing space
+
+
+ Список заборонених слів або фраз - через кому, пробіли навколо ком ігноруються, НЕ враховує регістр.
+
+
+ Огляд
+
+
+ BTTV смайлики:
+
+
+ Тека кешу:
+
+
+ Чати:
+
+
+ Фільтр значків у чаті:
+
+
+ значки у чаті:
+
+
+ Обрізати:
+
+
+ Завантаження чату
+
+
+ Завантаження чатів
+
+
+ Шрифт:
+
+
+ Розмір шрифту:
+
+
+ Висота:
+
+
+ Промальовування чату
+
+
+ Промальовування чатів
+
+
+ Оновлення чату
+
+
+ Оновлення чату
+
+
+ Ширина:
+
+
+ Очистити
+
+
+ Ви впевнені, що хочете очистити кеш?\nВам слід робити це лише у випадку, якщо програма працює некоректно
+
+
+ Завантаження кліпів
+
+
+ Завантаження кліпів
+
+
+ Посилання/ІН(ID) кліпу:
+
+
+ Кліпи:
+
+
+ Кінець
+
+
+ Початок
+
+
+ Обрізати відео:
+
+
+ форматування date_custom базується на
+
+
+ Стандартні рядки формату дати та часу як у C#
+
+
+ Підтвердити видалення
+
+
+ Часто користуєтеся програмою і хочете мене підтримати? Пригостіть мене кавою :)
+
+
+ Завантажити
+
+
+ З'єднання:
+
+
+ Завантажити шаблони назв файлів:
+
+
+ Формат завантаження:
+
+
+ Потоків завантаження:
+
+
+ Вбудовані зображення
+ Leave a trailing space
+
+
+ Вбудувати смайлики, значки та смайлики за бітси у файл завантаження - щоб потім відтворювати в автономному режимі. Корисно для архівування, розмір файлу буде більшим.
+
+
+ Вбудовані відсутні
+ Leave a trailing space
+
+
+ Вбудовує відсутні смайлики, значки та смайлики за бітси. Вже вбудовані зображення залишаться недоторканими.
+
+
+ В чергу завантаження
+
+
+ В чергу промальовування
+
+
+ В чергу оновлення
+
+
+ ПОМИЛКА:
+ Leave a trailing space
+
+
+ Вхідні аргументи:
+
+
+ Натисніть тут, щоб дізнатися про можливості FFmpeg
+
+
+ Вихідні аргументи:
+
+
+ {fps} {height} {width} {max_int} {save_path}
+ Do not translate
+
+
+ Скинути до типових
+
+
+ FFZ смайлики:
+
+
+ {title} {id} {date} {channel} {date_custom=""} {random_string} {crop_start} {crop_end} {crop_start_custom=""} {crop_end_custom=""} {length} {length_custom=""} {views} {game}
+ Do not translate
+
+
+ Колір шрифту:
+
+
+ Генерувати маску:
+
+
+ Отримати довідку
+
+
+ Сховати кнопку пожертв:
+
+
+ Список ігнорованих користувачів
+ Leave a trailing space
+
+
+ Список імен користувачів - через кому, пробіли навколо ком ігноруються, НЕ враховує регістр.
+
+
+ Не правильне посилання/ІН(ID) кліпу:
+
+
+ Будь ласка, введіть дійсне посилання/ІН(ID) кліпу\nПриклад:\nhttps://clips.twitch.tv/ImportantPlausibleMetalOSsloth\nImportantPlausibleMetalOSsloth
+
+
+ Неправильне вхідне значення
+
+
+ Неправильне значення початку або закінчення
+
+
+ Не правильне Посилання/ІН(ID) відео:
+
+
+ Будь ласка, введіть дійсне посилання/ІН(ID) відео\nПриклад:\nhttps://www.twitch.tv/videos/470741744\n470741744
+
+
+ JSON файл:
+
+
+ Довжина:
+
+
+ Список відео/кліпів(по одному на рядок)
+
+
+ Журнал:
+
+
+ Масове завантаження
+
+
+ OAuth (необов'язково)
+ Leave a trailing space
+
+
+ Потрібен тільки для відео які доступні для підписників. Усі сторонні токени OAuth не працюватимуть. Натисніть, щоб переглянути відео на YouTube про те, як отримати токен.
+
+
+ Офлайн
+ Leave a trailing space
+
+
+ Промальовувати чат, використовуючи тільки ресурси, вбудовані в json-файл чату.
+
+
+ Обрис:
+
+
+ Обмеження паралельних завдань
+
+
+ Часткове промальовування
+
+
+ Якість:
+
+
+ Промальовування
+
+
+ Це тільки для досвідчених користувачів. Якщо ви отримуєте помилку "кінець", це може бути пов'язано з наступними причинами.
+
+
+ Формат файлу:
+
+
+ Кадрова частота:
+
+
+ Кодек:
+
+
+ Кодування
+
+
+ FFmpeg
+
+
+ Основні
+
+
+ Предперегляд
+
+
+ Промальовування
+
+
+ Масштабування
+
+
+ Ширина і висота повинні бути рівними
+
+
+ Замінити вбудовані
+ Leave a trailing space
+
+
+ Замініть у файлі всі вбудовані смайлики, значки та смайлиик за бітси. Всі вбудовані зображення будуть замінені!
+
+
+ Пошук кліпу
+
+
+ Пошук відео
+
+
+ Вибрати усі
+
+
+ Вибрано:
+
+
+ Задати канал
+
+
+ Сортувати:
+
+
+ Готово
+
+
+ Завантаження
+
+
+ ПОМИЛКА
+
+
+ Не працює
+
+
+ Промальовування
+
+
+ Оновлення
+
+
+ Стрімер:
+
+
+ 7TV смайлики:
+
+
+ Повідоалення підписників:
+
+
+ Відмінити
+
+
+ Помилка
+
+
+ Черга завдань
+
+
+ Постачальник смайликів
+ Leave a trailing space
+
+
+ Також вбудуйте у файл смайлики сторонніх розробників. Розмір файлу буде значно більшим.
+
+
+ Формат позначки часу:
+
+
+ Нема
+
+
+ Відносно
+
+
+ Мітки часу:
+
+
+ UTC
+
+
+ Найкращі за весь час
+
+
+ Найкращі за 7 днів
+
+
+ Найкращі за 30 днів
+
+
+ Найкращі за 24 години
+
+
+ Не вдалося знайти мініатюру
+
+
+ Не вдалося отримати інформацію про кліп. Будь ласка, перевірте ІН(ID) кліпу та спробуйте ще раз.
+
+
+ Не вдалося отримати інформацію
+
+
+ Не вдається отримати інформацію про відео/кліп. Будь ласка, перевірте посилання та спробуйте ще раз
+
+
+ Не вдається отримати інформацію про відео. Будь ласка, переконайтеся, що посилання/ІН(ID) правильні та спробуйте ще раз.
+
+
+ Не вдається розібрати вхідні дані
+
+
+ Будь ласка, перевірте правильність введених даних
+
+
+ Не вдалося розібрати посилання
+
+
+ Будь ласка, перевірте посилання відео/кліп
+
+
+ Невідомо
+
+
+ Оновлення
+
+
+ Частота оновлення:
+
+
+ Перелік посилань
+
+
+ Розгорнутий вивід помилки
+
+
+ Розгорнуті помилки:
+
+
+ Створено:
+
+
+ Назва:
+
+
+ Посилання на кліп/відео:
+
+
+ Завантаження відео
+
+
+ Завантаження відео
+
+
+ Термін придатності відео закінчився або ІН(ID) невірний
+
+
+ Посилання/ІН(ID) на відео:
+
+
+ Відео:
+
+
+ Завантажити чат
+
+
+ Тека завантажень:
+
+
+ Завантажити відео
+
+
+ Мова
+ Leave a trailing space
+
+
+ Промальовування чату
+
+
+ Розмір відступів:
+
+
+ Розмір штрихів:
+
+
+ Розмір значків:
+
+
+ Розмір типових смайликів:
+
+
+ Розмір смайликів від постачальників:
+
+
+ Розмір відступів між смайликами:
+
+
+ Розмір відступів:
+
+
+ Розмір вистоти секції:
+
+
+ Розмір вертикального відступу:
+
+
+ Міжрядковий інтервал:
+
+
+ Файл не знайдено:
+ Leave a trailing space
+
+
+ Критична помилка
+
+
+ Тему не знайдено
+
+
+ {theme} не знайдено. Повертаємо тему до системної
+ Do not translate {theme}
+
+
+ Розсіювання
+ Leave a trailing space
+
+
+ У листопаді 2022 року в API Twitch було внесено зміни, завдяки яким повідомлення чату завантажуються лише за цілі секунди. Ця опція використовує додаткові метадані, щоб спробувати відновити повідомлення до моменту, коли вони були фактично надіслані. Це може призвести до зміни порядку повідомлень.
+
+
+ Стиснення:
+
+
+ Немає
+
+
+ Gzip
+ Do not translate
+
+
+ Деякі з включених тем не вдалося записати.
+
+
+ Загальні налаштування
+
+
+ Налаштування черги
+
+
+ Виберіть діапазон промальовування (у секундах)
+
+
+ Список посилань для масового завантаження
+
+
+ Масове завантаження відео
+
+
+ Масове завантаження кліпів
+
+
+ TwitchDownloaderWPF недоступно на вашій рідній мові? Натисніть, щоб дізнатися, як допомогти з перекладом!
+
+
+ Неправильний шлях до папки
+
+
+ Папка не існує
+
+
+ Скасовано
+
+
+ Скасування
+
+
+ Максимальна пропускна здатність
+ Leave a trailing space
+
+
+ Максимальна пропускна здатність, яку дозволяється використовувати новим потокам завантаження, в кілабайтах на секунду.
+
+
+ Різкість:
+
+
+ Локальний
+
+
+ Формат часу:
+
+
+ JSON не обрано
+
+
+ Недостатній доступ. Може знадобитися OAuth.
+
+
+ Постачальник типових смайликів:
+
+
+ Ґуґл
+
+
+ Твіттер
+
+
+ Ніхто
+
+
+ Ви вибрали генерування маски з непрозорим тлом. Зменшіть альфа кольору фону або вимкніть генерацію маски.
+
+
+ Розмір обрису:
+
+
+ crop_start_custom, crop_end_custom, та length_custom форматування базуються на
+
+
+ C# стандартні рядки форматування TimeSpan
+
+
+ Виникла невідома помилка
+
+
+ Завдання не вдалося видалити
+
+
+ Будь ласка, скасуйте завдання або дочекайтеся його завершення, перш ніж видалити
+
+
+ Не вдається завантажити FFmpeg
+
+
+ Не вдалося завантажити FFmpeg. Будь ласка, завантажте його вручну з {0} та розмістіть файл у {1}
+
+
+ Альтернативний колір тла:
+
+
+ Альтернативне тло Leave a trailing space
+
+
+ Змінює колір тла кожного іншого повідомлення чату, щоб допомогти відрізнити їх.
+
+
+ Кодування метаданих:
+
+
+ Помилка
+
+
+ Не вдається запустити переглядач тем програм для Windows. Код помилки: {0}
+
+
+ Відео на сторінку:
+
+
+ Завантаження FFmpeg {0}%
+
+
+ Copy ID to clipboard
+
+
+ Copy URL to clipboard
+
+
+ Open in browser
+
+
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/Translations/Strings.zh.resx b/TwitchDownloaderWPF/Translations/Strings.zh.resx
index 2f964568..fe1a87c8 100644
--- a/TwitchDownloaderWPF/Translations/Strings.zh.resx
+++ b/TwitchDownloaderWPF/Translations/Strings.zh.resx
@@ -470,7 +470,7 @@
取消
-
+
错误
@@ -764,4 +764,25 @@
Encode Metadata:
+
+ 错误
+
+
+ Unable to start Windows application theme watcher. Error code: {0}
+
+
+ Videos per page:
+
+
+ Downloading FFmpeg {0}%
+
+
+ Copy ID to clipboard
+
+
+ Copy URL to clipboard
+
+
+ Open in browser
+
\ No newline at end of file
diff --git a/TwitchDownloaderWPF/TwitchDownloaderWPF.csproj b/TwitchDownloaderWPF/TwitchDownloaderWPF.csproj
index cd1c329b..114b054e 100644
--- a/TwitchDownloaderWPF/TwitchDownloaderWPF.csproj
+++ b/TwitchDownloaderWPF/TwitchDownloaderWPF.csproj
@@ -19,8 +19,10 @@
false
true
true
- false
- AnyCPU;x64
+ false
+ AnyCPU;x64
+ true
+
icon.ico
@@ -62,7 +64,7 @@
-
+
@@ -95,31 +97,34 @@
Strings.resx
-
+
Strings.resx
-
+
Strings.resx
+
+ Strings.resx
+
+
+
+
+
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
-
-
-
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
-
-
-
+
+
+
diff --git a/TwitchDownloaderWPF/TwitchTasks/TaskData.cs b/TwitchDownloaderWPF/TwitchTasks/TaskData.cs
index 29f89b0b..5d2c8872 100644
--- a/TwitchDownloaderWPF/TwitchTasks/TaskData.cs
+++ b/TwitchDownloaderWPF/TwitchTasks/TaskData.cs
@@ -28,7 +28,7 @@ public string LengthFormatted
return $"{time.Minutes:D2}:{time.Seconds:D2}";
}
- return $"{time.Seconds:D2}s";
+ return $"{time.Seconds:D1}s";
}
}
}
diff --git a/TwitchDownloaderWPF/WindowMassDownload.xaml b/TwitchDownloaderWPF/WindowMassDownload.xaml
index 1beebb7c..53cf01f3 100644
--- a/TwitchDownloaderWPF/WindowMassDownload.xaml
+++ b/TwitchDownloaderWPF/WindowMassDownload.xaml
@@ -5,37 +5,47 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TwitchDownloaderWPF"
xmlns:behave="clr-namespace:TwitchDownloaderWPF.Behaviors"
+ xmlns:fa="http://schemas.fontawesome.com/icons/"
xmlns:lex="http://wpflocalizeextension.codeplex.com"
lex:LocalizeDictionary.DesignCulture=""
lex:ResxLocalizationProvider.DefaultAssembly="TwitchDownloaderWPF"
lex:ResxLocalizationProvider.DefaultDictionary="Strings"
- xmlns:emoji="clr-namespace:Emoji.Wpf;assembly=Emoji.Wpf"
+ xmlns:emoji="clr-namespace:Emoji.Wpf;assembly=Emoji.Wpf"
+ xmlns:gif="http://wpfanimatedgif.codeplex.com"
mc:Ignorable="d"
Title="Mass Downloader" MinHeight="250" Height="700" MinWidth="775" Width="1100" Loaded="Window_Loaded">
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
@@ -43,12 +53,31 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -64,14 +93,14 @@
-
-
+
+
-
+
diff --git a/TwitchDownloaderWPF/WindowMassDownload.xaml.cs b/TwitchDownloaderWPF/WindowMassDownload.xaml.cs
index 446339bb..ac834013 100644
--- a/TwitchDownloaderWPF/WindowMassDownload.xaml.cs
+++ b/TwitchDownloaderWPF/WindowMassDownload.xaml.cs
@@ -1,16 +1,18 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
-using System.Windows.Media.Imaging;
+using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Twitch;
using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
using TwitchDownloaderWPF.Properties;
+using TwitchDownloaderWPF.Services;
using TwitchDownloaderWPF.TwitchTasks;
namespace TwitchDownloaderWPF
@@ -20,23 +22,24 @@ namespace TwitchDownloaderWPF
///
public partial class WindowMassDownload : Window
{
- public DownloadType downloaderType { get; set; }
+ public VideoType downloaderType { get; set; }
public ObservableCollection videoList { get; set; } = new ObservableCollection();
- public List selectedItems = new List();
- public List cursorList = new List();
+ public readonly List selectedItems = new List();
+ public readonly List cursorList = new List();
public int cursorIndex = -1;
public string currentChannel = "";
public string period = "";
+ public int videoCount = 50;
- public WindowMassDownload(DownloadType Type)
+ public WindowMassDownload(VideoType type)
{
- downloaderType = Type;
+ downloaderType = type;
InitializeComponent();
itemList.ItemsSource = videoList;
- if (downloaderType == DownloadType.Video)
+ if (downloaderType == VideoType.Video)
{
- comboSort.Visibility = Visibility.Hidden;
- labelSort.Visibility = Visibility.Hidden;
+ ComboSortByDate.Visibility = Visibility.Hidden;
+ LabelSort.Visibility = Visibility.Hidden;
}
btnNext.IsEnabled = false;
btnPrev.IsEnabled = false;
@@ -58,47 +61,53 @@ private async Task ChangeCurrentChannel()
private async Task UpdateList()
{
- if (downloaderType == DownloadType.Video)
+ if (StatusImage != null) StatusImage.Visibility = Visibility.Visible;
+
+ if (string.IsNullOrWhiteSpace(currentChannel))
+ {
+ // Pretend we are doing something so the status icon has time to show
+ await Task.Delay(50);
+ videoList.Clear();
+ cursorList.Clear();
+ cursorIndex = -1;
+ if (StatusImage != null) StatusImage.Visibility = Visibility.Hidden;
+ return;
+ }
+
+ if (downloaderType == VideoType.Video)
{
string currentCursor = "";
if (cursorList.Count > 0 && cursorIndex >= 0)
{
currentCursor = cursorList[cursorIndex];
}
- GqlVideoSearchResponse res = await TwitchHelper.GetGqlVideos(currentChannel, currentCursor, 100);
+ GqlVideoSearchResponse res = await TwitchHelper.GetGqlVideos(currentChannel, currentCursor, videoCount);
videoList.Clear();
if (res.data.user != null)
{
foreach (var video in res.data.user.videos.edges)
{
- TaskData data = new TaskData();
- data.Title = video.node.title;
- data.Length = video.node.lengthSeconds;
- data.Id = video.node.id;
- data.Time = Settings.Default.UTCVideoTime ? video.node.createdAt : video.node.createdAt.ToLocalTime();
- data.Views = video.node.viewCount;
- data.Streamer = currentChannel;
- data.Game = video.node.game?.displayName ?? "Unknown";
- try
+ var thumbUrl = video.node.previewThumbnailURL;
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var thumbnail))
{
- var bitmapImage = new BitmapImage();
- bitmapImage.BeginInit();
- bitmapImage.UriSource = new Uri(video.node.previewThumbnailURL);
- bitmapImage.EndInit();
- data.Thumbnail = bitmapImage;
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out thumbnail);
}
- catch { }
- videoList.Add(data);
+
+ videoList.Add(new TaskData
+ {
+ Title = video.node.title,
+ Length = video.node.lengthSeconds,
+ Id = video.node.id,
+ Time = Settings.Default.UTCVideoTime ? video.node.createdAt : video.node.createdAt.ToLocalTime(),
+ Views = video.node.viewCount,
+ Streamer = currentChannel,
+ Game = video.node.game?.displayName ?? "Unknown",
+ Thumbnail = thumbnail
+ });
}
- if (res.data.user.videos.pageInfo.hasNextPage)
- btnNext.IsEnabled = true;
- else
- btnNext.IsEnabled = false;
- if (res.data.user.videos.pageInfo.hasPreviousPage)
- btnPrev.IsEnabled = true;
- else
- btnPrev.IsEnabled = false;
+ btnNext.IsEnabled = res.data.user.videos.pageInfo.hasNextPage;
+ btnPrev.IsEnabled = res.data.user.videos.pageInfo.hasPreviousPage;
if (res.data.user.videos.pageInfo.hasNextPage)
{
string newCursor = res.data.user.videos.edges[0].cursor;
@@ -114,40 +123,33 @@ private async Task UpdateList()
{
currentCursor = cursorList[cursorIndex];
}
- GqlClipSearchResponse res = await TwitchHelper.GetGqlClips(currentChannel, period, currentCursor, 50);
+ GqlClipSearchResponse res = await TwitchHelper.GetGqlClips(currentChannel, period, currentCursor, videoCount);
videoList.Clear();
if (res.data.user != null)
{
foreach (var clip in res.data.user.clips.edges)
{
- TaskData data = new TaskData();
- data.Title = clip.node.title;
- data.Length = clip.node.durationSeconds;
- data.Id = clip.node.slug;
- data.Time = Settings.Default.UTCVideoTime ? clip.node.createdAt : clip.node.createdAt.ToLocalTime();
- data.Views = clip.node.viewCount;
- data.Streamer = currentChannel;
- data.Game = clip.node.game?.displayName ?? "Unknown";
- try
+ var thumbUrl = clip.node.thumbnailURL;
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var thumbnail))
{
- var bitmapImage = new BitmapImage();
- bitmapImage.BeginInit();
- bitmapImage.UriSource = new Uri(clip.node.thumbnailURL);
- bitmapImage.EndInit();
- data.Thumbnail = bitmapImage;
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out thumbnail);
}
- catch { }
- videoList.Add(data);
+
+ videoList.Add(new TaskData
+ {
+ Title = clip.node.title,
+ Length = clip.node.durationSeconds,
+ Id = clip.node.slug,
+ Time = Settings.Default.UTCVideoTime ? clip.node.createdAt : clip.node.createdAt.ToLocalTime(),
+ Views = clip.node.viewCount,
+ Streamer = currentChannel,
+ Game = clip.node.game?.displayName ?? "Unknown",
+ Thumbnail = thumbnail
+ });
}
- if (res.data.user.clips.pageInfo.hasNextPage)
- btnNext.IsEnabled = true;
- else
- btnNext.IsEnabled = false;
- if (cursorIndex >= 0)
- btnPrev.IsEnabled = true;
- else
- btnPrev.IsEnabled = false;
+ btnNext.IsEnabled = res.data.user.clips.pageInfo.hasNextPage;
+ btnPrev.IsEnabled = cursorIndex >= 0;
if (res.data.user.clips.pageInfo.hasNextPage)
{
string newCursor = res.data.user.clips.edges.First(x => x.cursor != null).cursor;
@@ -156,20 +158,24 @@ private async Task UpdateList()
}
}
}
+
+ if (StatusImage != null) StatusImage.Visibility = Visibility.Hidden;
}
- private void Border_MouseUp(object sender, MouseButtonEventArgs e)
+ private void Border_OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
- Border border = sender as Border;
- if (selectedItems.Any(x => x.Id == ((TaskData)border.DataContext).Id))
+ if (sender is not Border border) return;
+ if (border.DataContext is not TaskData taskData) return;
+
+ if (selectedItems.Any(x => x.Id == taskData.Id))
{
border.Background = Brushes.Transparent;
- selectedItems.RemoveAll(x => x.Id == ((TaskData)border.DataContext).Id);
+ selectedItems.RemoveAll(x => x.Id == taskData.Id);
}
else
{
border.Background = Brushes.LightBlue;
- selectedItems.Add((TaskData)border.DataContext);
+ selectedItems.Add(taskData);
}
textCount.Text = selectedItems.Count.ToString();
}
@@ -192,13 +198,12 @@ private async void btnPrev_Click(object sender, RoutedEventArgs e)
private void Border_Initialized(object sender, EventArgs e)
{
- Border border = (Border)sender;
- if (border.DataContext != null)
+ if (sender is not Border border) return;
+ if (border.DataContext is not TaskData taskData) return;
+
+ if (selectedItems.Any(x => x.Id == taskData.Id))
{
- if (selectedItems.Any(x => x.Id == ((TaskData)border.DataContext).Id))
- {
- border.Background = Brushes.LightBlue;
- }
+ border.Background = Brushes.LightBlue;
}
}
@@ -207,15 +212,14 @@ private void btnQueue_Click(object sender, RoutedEventArgs e)
if (selectedItems.Count > 0)
{
WindowQueueOptions queue = new WindowQueueOptions(selectedItems);
- bool? queued = queue.ShowDialog();
- if (queued != null && (bool)queued)
+ if (queue.ShowDialog().GetValueOrDefault())
this.Close();
}
}
- private async void comboSort_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ private async void ComboSortByDate_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
- period = ((ComboBoxItem)comboSort.SelectedItem).Tag.ToString();
+ period = ((ComboBoxItem)ComboSortByDate.SelectedItem).Tag.ToString();
videoList.Clear();
cursorList.Clear();
cursorIndex = -1;
@@ -224,31 +228,33 @@ private async void comboSort_SelectionChanged(object sender, SelectionChangedEve
private void btnSelectAll_Click(object sender, RoutedEventArgs e)
{
- //I'm sure there is a much better way to do this. Could not find a way to itterate over each itemcontrol border
+ //I'm sure there is a much better way to do this. Could not find a way to iterate over each itemcontrol border
foreach (var video in videoList)
{
- if (!selectedItems.Any(x => x.Id == video.Id))
+ if (selectedItems.All(x => x.Id != video.Id))
{
selectedItems.Add(video);
}
}
- List oldData = videoList.ToList();
+ // Remove and re-add all of the items to trigger Border_Initialized
+ var oldData = videoList.ToArray();
videoList.Clear();
foreach (var item in oldData)
{
videoList.Add(item);
}
+
textCount.Text = selectedItems.Count.ToString();
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
- Title = downloaderType == DownloadType.Video
+ Title = downloaderType == VideoType.Video
? Translations.Strings.TitleVideoMassDownloader
: Translations.Strings.TitleClipMassDownloader;
App.RequestTitleBarChange();
- }
+ }
private async void TextChannel_OnKeyDown(object sender, KeyEventArgs e)
{
@@ -258,5 +264,52 @@ private async void TextChannel_OnKeyDown(object sender, KeyEventArgs e)
e.Handled = true;
}
}
+
+ private async void ComboVideoCount_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ videoCount = int.Parse((string)((ComboBoxItem)ComboVideoCount.SelectedValue).Content);
+ videoList.Clear();
+ cursorList.Clear();
+ cursorIndex = -1;
+ await UpdateList();
+ }
+
+ private void MenuItemCopyVideoID_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem { DataContext: TaskData taskData }) return;
+
+ var id = taskData.Id;
+ Clipboard.SetText(id);
+
+ e.Handled = true;
+ }
+
+ private void MenuItemCopyVideoUrl_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem { DataContext: TaskData taskData }) return;
+
+ var id = taskData.Id;
+ var url = id.All(char.IsDigit)
+ ? $"https://twitch.tv/videos/{id}"
+ : $"https://clips.twitch.tv/{id}";
+
+ Clipboard.SetText(url);
+
+ e.Handled = true;
+ }
+
+ private void MenuItemOpenInBrowser_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem { DataContext: TaskData taskData }) return;
+
+ var id = taskData.Id;
+ var url = id.All(char.IsDigit)
+ ? $"https://twitch.tv/videos/{id}"
+ : $"https://clips.twitch.tv/{id}";
+
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+
+ e.Handled = true;
+ }
}
}
diff --git a/TwitchDownloaderWPF/WindowQueueOptions.xaml b/TwitchDownloaderWPF/WindowQueueOptions.xaml
index 839afb5a..2117bbe7 100644
--- a/TwitchDownloaderWPF/WindowQueueOptions.xaml
+++ b/TwitchDownloaderWPF/WindowQueueOptions.xaml
@@ -14,7 +14,6 @@
diff --git a/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs b/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs
index d8d1a820..28af00d8 100644
--- a/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs
+++ b/TwitchDownloaderWPF/WindowQueueOptions.xaml.cs
@@ -5,8 +5,8 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
-using TwitchDownloaderCore.Chat;
using TwitchDownloaderCore.Options;
+using TwitchDownloaderCore.Tools;
using TwitchDownloaderWPF.Properties;
using TwitchDownloaderWPF.Services;
using TwitchDownloaderWPF.TwitchTasks;
@@ -18,26 +18,26 @@ namespace TwitchDownloaderWPF
///
public partial class WindowQueueOptions : Window
{
- // This file is absolutely attrocious, but fixing it would mean rewriting the entire GUI in a more abstract form
+ // This file is absolutely atrocious, but fixing it would mean rewriting the entire GUI in a more abstract form
- List dataList;
+ private readonly List _dataList;
+ private readonly Page _parentPage;
- Page parentPage { get; set; }
public WindowQueueOptions(Page page)
{
- parentPage = page;
+ _parentPage = page;
InitializeComponent();
string queueFolder = Settings.Default.QueueFolder;
if (Directory.Exists(queueFolder))
textFolder.Text = queueFolder;
- if (page is PageVodDownload || page is PageClipDownload)
+ if (page is PageVodDownload or PageClipDownload)
{
checkVideo.IsChecked = true;
checkVideo.IsEnabled = false;
}
- if (page is PageChatDownload)
+ else if (page is PageChatDownload chatPage)
{
checkVideo.Visibility = Visibility.Collapsed;
checkChat.IsChecked = true;
@@ -50,14 +50,13 @@ public WindowQueueOptions(Page page)
RadioCompressionNone.Visibility = Visibility.Collapsed;
RadioCompressionGzip.Visibility = Visibility.Collapsed;
checkEmbed.Visibility = Visibility.Collapsed;
- var chatPage = page as PageChatDownload;
- if (chatPage.radioJson.IsChecked != true)
+ if (!chatPage.radioJson.IsChecked.GetValueOrDefault())
{
checkRender.IsChecked = false;
checkRender.IsEnabled = false;
}
}
- if (page is PageChatUpdate)
+ else if (page is PageChatUpdate)
{
checkVideo.Visibility = Visibility.Collapsed;
checkChat.Visibility = Visibility.Collapsed;
@@ -71,7 +70,7 @@ public WindowQueueOptions(Page page)
checkEmbed.Visibility = Visibility.Collapsed;
checkRender.Visibility = Visibility.Collapsed;
}
- if (page is PageChatRender)
+ else if (page is PageChatRender)
{
checkVideo.Visibility = Visibility.Collapsed;
checkChat.Visibility = Visibility.Collapsed;
@@ -88,9 +87,9 @@ public WindowQueueOptions(Page page)
}
}
- public WindowQueueOptions(List DataList)
+ public WindowQueueOptions(List dataList)
{
- this.dataList = DataList;
+ _dataList = dataList;
InitializeComponent();
string queueFolder = Settings.Default.QueueFolder;
@@ -98,13 +97,12 @@ public WindowQueueOptions(List DataList)
textFolder.Text = queueFolder;
}
- private async void btnQueue_Click(object sender, RoutedEventArgs e)
+ private void btnQueue_Click(object sender, RoutedEventArgs e)
{
- if (parentPage != null)
+ if (_parentPage != null)
{
- if (parentPage is PageVodDownload)
+ if (_parentPage is PageVodDownload vodDownloadPage)
{
- PageVodDownload vodPage = (PageVodDownload)parentPage;
string folderPath = textFolder.Text;
if (!Directory.Exists(folderPath))
{
@@ -112,11 +110,16 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
return;
}
- VodDownloadTask downloadTask = new VodDownloadTask();
- VideoDownloadOptions downloadOptions = vodPage.GetOptions(null, textFolder.Text);
- downloadTask.DownloadOptions = downloadOptions;
- downloadTask.Info.Title = vodPage.textTitle.Text;
- downloadTask.Info.Thumbnail = vodPage.imgThumbnail.Source;
+ VideoDownloadOptions downloadOptions = vodDownloadPage.GetOptions(null, textFolder.Text);
+ VodDownloadTask downloadTask = new VodDownloadTask
+ {
+ DownloadOptions = downloadOptions,
+ Info =
+ {
+ Title = vodDownloadPage.textTitle.Text,
+ Thumbnail = vodDownloadPage.imgThumbnail.Source
+ }
+ };
downloadTask.ChangeStatus(TwitchTaskStatus.Ready);
lock (PageQueue.taskLock)
@@ -124,18 +127,17 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
PageQueue.taskList.Add(downloadTask);
}
- if ((bool)checkChat.IsChecked)
+ if (checkChat.IsChecked.GetValueOrDefault())
{
- ChatDownloadTask chatTask = new ChatDownloadTask();
ChatDownloadOptions chatOptions = MainWindow.pageChatDownload.GetOptions(null);
- chatOptions.Id = downloadOptions.Id.ToString();
+ chatOptions.Id = downloadOptions.Id;
if (radioJson.IsChecked == true)
chatOptions.DownloadFormat = ChatFormat.Json;
else if (radioHTML.IsChecked == true)
chatOptions.DownloadFormat = ChatFormat.Html;
else
chatOptions.DownloadFormat = ChatFormat.Text;
- chatOptions.EmbedData = (bool)checkEmbed.IsChecked;
+ chatOptions.EmbedData = checkEmbed.IsChecked.GetValueOrDefault();
chatOptions.Filename = Path.Combine(folderPath, Path.GetFileNameWithoutExtension(downloadOptions.Filename) + "." + chatOptions.DownloadFormat);
if (downloadOptions.CropBeginning)
@@ -150,9 +152,15 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
chatOptions.CropEndingTime = downloadOptions.CropEndingTime;
}
- chatTask.DownloadOptions = chatOptions;
- chatTask.Info.Title = vodPage.textTitle.Text;
- chatTask.Info.Thumbnail = vodPage.imgThumbnail.Source;
+ ChatDownloadTask chatTask = new ChatDownloadTask
+ {
+ DownloadOptions = chatOptions,
+ Info =
+ {
+ Title = vodDownloadPage.textTitle.Text,
+ Thumbnail = vodDownloadPage.imgThumbnail.Source
+ }
+ };
chatTask.ChangeStatus(TwitchTaskStatus.Ready);
lock (PageQueue.taskLock)
@@ -160,19 +168,25 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
PageQueue.taskList.Add(chatTask);
}
- if ((bool)checkRender.IsChecked && chatOptions.DownloadFormat == ChatFormat.Json)
+ if (checkRender.IsChecked.GetValueOrDefault() && chatOptions.DownloadFormat == ChatFormat.Json)
{
- ChatRenderTask renderTask = new ChatRenderTask();
ChatRenderOptions renderOptions = MainWindow.pageChatRender.GetOptions(Path.ChangeExtension(chatOptions.Filename.Replace(".gz", ""), '.' + MainWindow.pageChatRender.comboFormat.Text.ToLower()));
- if (renderOptions.OutputFile.Trim() == downloadOptions.Filename.Trim())
+ if (renderOptions.OutputFile.Trim() == downloadOptions.Filename!.Trim())
{
//Just in case VOD and chat paths are the same. Like the previous defaults
renderOptions.OutputFile = Path.ChangeExtension(chatOptions.Filename.Replace(".gz", ""), " - CHAT." + MainWindow.pageChatRender.comboFormat.Text.ToLower());
}
renderOptions.InputFile = chatOptions.Filename;
- renderTask.DownloadOptions = renderOptions;
- renderTask.Info.Title = vodPage.textTitle.Text;
- renderTask.Info.Thumbnail = vodPage.imgThumbnail.Source;
+
+ ChatRenderTask renderTask = new ChatRenderTask
+ {
+ DownloadOptions = renderOptions,
+ Info =
+ {
+ Title = vodDownloadPage.textTitle.Text,
+ Thumbnail = vodDownloadPage.imgThumbnail.Source
+ }
+ };
renderTask.ChangeStatus(TwitchTaskStatus.Waiting);
renderTask.DependantTask = chatTask;
@@ -186,9 +200,8 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
this.Close();
}
- if (parentPage is PageClipDownload)
+ if (_parentPage is PageClipDownload clipDownloadPage)
{
- PageClipDownload clipPage = (PageClipDownload)parentPage;
string folderPath = textFolder.Text;
if (!Directory.Exists(folderPath))
{
@@ -196,20 +209,30 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
return;
}
- ClipDownloadTask downloadTask = new ClipDownloadTask();
- ClipDownloadOptions downloadOptions = new ClipDownloadOptions();
- downloadOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateClip, clipPage.textTitle.Text, clipPage.clipId, clipPage.currentVideoTime, clipPage.textStreamer.Text, TimeSpan.Zero, clipPage.clipLength, clipPage.viewCount.ToString(), clipPage.game) + ".mp4");
- downloadOptions.Id = clipPage.clipId;
- downloadOptions.Quality = clipPage.comboQuality.Text;
- downloadOptions.ThrottleKib = Settings.Default.DownloadThrottleEnabled
- ? Settings.Default.MaximumBandwidthKib
- : -1;
- downloadOptions.TempFolder = Settings.Default.TempPath;
- downloadOptions.EncodeMetadata = clipPage.CheckMetadata.IsChecked!.Value;
- downloadOptions.FfmpegPath = "ffmpeg";
- downloadTask.DownloadOptions = downloadOptions;
- downloadTask.Info.Title = clipPage.textTitle.Text;
- downloadTask.Info.Thumbnail = clipPage.imgThumbnail.Source;
+ ClipDownloadOptions downloadOptions = new ClipDownloadOptions
+ {
+ Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateClip, clipDownloadPage.textTitle.Text, clipDownloadPage.clipId,
+ clipDownloadPage.currentVideoTime, clipDownloadPage.textStreamer.Text, TimeSpan.Zero, clipDownloadPage.clipLength,
+ clipDownloadPage.viewCount.ToString(), clipDownloadPage.game) + ".mp4"),
+ Id = clipDownloadPage.clipId,
+ Quality = clipDownloadPage.comboQuality.Text,
+ ThrottleKib = Settings.Default.DownloadThrottleEnabled
+ ? Settings.Default.MaximumBandwidthKib
+ : -1,
+ TempFolder = Settings.Default.TempPath,
+ EncodeMetadata = clipDownloadPage.CheckMetadata.IsChecked!.Value,
+ FfmpegPath = "ffmpeg"
+ };
+
+ ClipDownloadTask downloadTask = new ClipDownloadTask
+ {
+ DownloadOptions = downloadOptions,
+ Info =
+ {
+ Title = clipDownloadPage.textTitle.Text,
+ Thumbnail = clipDownloadPage.imgThumbnail.Source
+ }
+ };
downloadTask.ChangeStatus(TwitchTaskStatus.Ready);
lock (PageQueue.taskLock)
@@ -217,9 +240,8 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
PageQueue.taskList.Add(downloadTask);
}
- if ((bool)checkChat.IsChecked)
+ if (checkChat.IsChecked.GetValueOrDefault())
{
- ChatDownloadTask chatTask = new ChatDownloadTask();
ChatDownloadOptions chatOptions = MainWindow.pageChatDownload.GetOptions(null);
chatOptions.Id = downloadOptions.Id;
if (radioJson.IsChecked == true)
@@ -229,12 +251,20 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
else
chatOptions.DownloadFormat = ChatFormat.Text;
chatOptions.TimeFormat = TimestampFormat.Relative;
- chatOptions.EmbedData = (bool)checkEmbed.IsChecked;
- chatOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, downloadTask.Info.Title, chatOptions.Id, clipPage.currentVideoTime, clipPage.textStreamer.Text, TimeSpan.Zero, clipPage.clipLength, clipPage.viewCount.ToString(), clipPage.game) + "." + chatOptions.FileExtension);
+ chatOptions.EmbedData = checkEmbed.IsChecked.GetValueOrDefault();
+ chatOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, downloadTask.Info.Title, chatOptions.Id,
+ clipDownloadPage.currentVideoTime, clipDownloadPage.textStreamer.Text, TimeSpan.Zero, clipDownloadPage.clipLength,
+ clipDownloadPage.viewCount.ToString(), clipDownloadPage.game) + "." + chatOptions.FileExtension);
- chatTask.DownloadOptions = chatOptions;
- chatTask.Info.Title = clipPage.textTitle.Text;
- chatTask.Info.Thumbnail = clipPage.imgThumbnail.Source;
+ ChatDownloadTask chatTask = new ChatDownloadTask
+ {
+ DownloadOptions = chatOptions,
+ Info =
+ {
+ Title = clipDownloadPage.textTitle.Text,
+ Thumbnail = clipDownloadPage.imgThumbnail.Source
+ }
+ };
chatTask.ChangeStatus(TwitchTaskStatus.Ready);
lock (PageQueue.taskLock)
@@ -242,9 +272,8 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
PageQueue.taskList.Add(chatTask);
}
- if ((bool)checkRender.IsChecked && chatOptions.DownloadFormat == ChatFormat.Json)
+ if (checkRender.IsChecked.GetValueOrDefault() && chatOptions.DownloadFormat == ChatFormat.Json)
{
- ChatRenderTask renderTask = new ChatRenderTask();
ChatRenderOptions renderOptions = MainWindow.pageChatRender.GetOptions(Path.ChangeExtension(chatOptions.Filename.Replace(".gz", ""), '.' + MainWindow.pageChatRender.comboFormat.Text.ToLower()));
if (renderOptions.OutputFile.Trim() == downloadOptions.Filename.Trim())
{
@@ -252,11 +281,18 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
renderOptions.OutputFile = Path.ChangeExtension(chatOptions.Filename.Replace(".gz", ""), " - CHAT." + MainWindow.pageChatRender.comboFormat.Text.ToLower());
}
renderOptions.InputFile = chatOptions.Filename;
- renderTask.DownloadOptions = renderOptions;
- renderTask.Info.Title = clipPage.textTitle.Text;
- renderTask.Info.Thumbnail = clipPage.imgThumbnail.Source;
+
+ ChatRenderTask renderTask = new ChatRenderTask
+ {
+ DownloadOptions = renderOptions,
+ Info =
+ {
+ Title = clipDownloadPage.textTitle.Text,
+ Thumbnail = clipDownloadPage.imgThumbnail.Source
+ },
+ DependantTask = chatTask
+ };
renderTask.ChangeStatus(TwitchTaskStatus.Waiting);
- renderTask.DependantTask = chatTask;
lock (PageQueue.taskLock)
{
@@ -268,9 +304,8 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
this.Close();
}
- if (parentPage is PageChatDownload)
+ if (_parentPage is PageChatDownload chatDownloadPage)
{
- PageChatDownload chatPage = (PageChatDownload)parentPage;
string folderPath = textFolder.Text;
if (!Directory.Exists(folderPath))
{
@@ -278,17 +313,22 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
return;
}
- ChatDownloadTask chatTask = new ChatDownloadTask();
ChatDownloadOptions chatOptions = MainWindow.pageChatDownload.GetOptions(null);
- chatOptions.Id = chatPage.downloadId;
- chatOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, chatPage.textTitle.Text, chatOptions.Id, chatPage.currentVideoTime, chatPage.textStreamer.Text,
+ chatOptions.Id = chatDownloadPage.downloadId;
+ chatOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, chatDownloadPage.textTitle.Text, chatOptions.Id,chatDownloadPage.currentVideoTime, chatDownloadPage.textStreamer.Text,
chatOptions.CropBeginning ? TimeSpan.FromSeconds(chatOptions.CropBeginningTime) : TimeSpan.Zero,
- chatOptions.CropEnding ? TimeSpan.FromSeconds(chatOptions.CropEndingTime) : chatPage.vodLength,
- chatPage.viewCount.ToString(), chatPage.game) + "." + chatOptions.FileExtension);
+ chatOptions.CropEnding ? TimeSpan.FromSeconds(chatOptions.CropEndingTime) : chatDownloadPage.vodLength,
+ chatDownloadPage.viewCount.ToString(), chatDownloadPage.game) + "." + chatOptions.FileExtension);
- chatTask.DownloadOptions = chatOptions;
- chatTask.Info.Title = chatPage.textTitle.Text;
- chatTask.Info.Thumbnail = chatPage.imgThumbnail.Source;
+ ChatDownloadTask chatTask = new ChatDownloadTask
+ {
+ DownloadOptions = chatOptions,
+ Info =
+ {
+ Title = chatDownloadPage.textTitle.Text,
+ Thumbnail = chatDownloadPage.imgThumbnail.Source
+ }
+ };
chatTask.ChangeStatus(TwitchTaskStatus.Ready);
lock (PageQueue.taskLock)
@@ -296,16 +336,22 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
PageQueue.taskList.Add(chatTask);
}
- if ((bool)checkRender.IsChecked && chatOptions.DownloadFormat == ChatFormat.Json)
+ if (checkRender.IsChecked.GetValueOrDefault() && chatOptions.DownloadFormat == ChatFormat.Json)
{
- ChatRenderTask renderTask = new ChatRenderTask();
ChatRenderOptions renderOptions = MainWindow.pageChatRender.GetOptions(Path.ChangeExtension(chatOptions.Filename.Replace(".gz", ""), '.' + MainWindow.pageChatRender.comboFormat.Text.ToLower()));
renderOptions.InputFile = chatOptions.Filename;
- renderTask.DownloadOptions = renderOptions;
- renderTask.Info.Title = chatPage.textTitle.Text;
- renderTask.Info.Thumbnail = chatPage.imgThumbnail.Source;
+
+ ChatRenderTask renderTask = new ChatRenderTask
+ {
+ DownloadOptions = renderOptions,
+ Info =
+ {
+ Title = chatDownloadPage.textTitle.Text,
+ Thumbnail = chatDownloadPage.imgThumbnail.Source
+ },
+ DependantTask = chatTask
+ };
renderTask.ChangeStatus(TwitchTaskStatus.Waiting);
- renderTask.DependantTask = chatTask;
lock (PageQueue.taskLock)
{
@@ -316,9 +362,8 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
this.Close();
}
- if (parentPage is PageChatUpdate)
+ if (_parentPage is PageChatUpdate chatUpdatePage)
{
- PageChatUpdate chatPage = (PageChatUpdate)parentPage;
string folderPath = textFolder.Text;
if (!Directory.Exists(folderPath))
{
@@ -326,17 +371,22 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
return;
}
- ChatUpdateTask chatTask = new ChatUpdateTask();
ChatUpdateOptions chatOptions = MainWindow.pageChatUpdate.GetOptions(null);
- chatOptions.InputFile = chatPage.InputFile;
- chatOptions.OutputFile = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, chatPage.textTitle.Text, chatPage.VideoId, chatPage.VideoCreatedAt, chatPage.textStreamer.Text,
+ chatOptions.InputFile = chatUpdatePage.InputFile;
+ chatOptions.OutputFile = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, chatUpdatePage.textTitle.Text, chatUpdatePage.VideoId, chatUpdatePage.VideoCreatedAt, chatUpdatePage.textStreamer.Text,
chatOptions.CropBeginning ? TimeSpan.FromSeconds(chatOptions.CropBeginningTime) : TimeSpan.Zero,
- chatOptions.CropEnding ? TimeSpan.FromSeconds(chatOptions.CropEndingTime) : chatPage.VideoLength,
- chatPage.ViewCount.ToString(), chatPage.Game) + "." + chatOptions.FileExtension);
+ chatOptions.CropEnding ? TimeSpan.FromSeconds(chatOptions.CropEndingTime) : chatUpdatePage.VideoLength,
+ chatUpdatePage.ViewCount.ToString(), chatUpdatePage.Game) + "." + chatOptions.FileExtension);
- chatTask.UpdateOptions = chatOptions;
- chatTask.Info.Title = chatPage.textTitle.Text;
- chatTask.Info.Thumbnail = chatPage.imgThumbnail.Source;
+ ChatUpdateTask chatTask = new ChatUpdateTask
+ {
+ UpdateOptions = chatOptions,
+ Info =
+ {
+ Title = chatUpdatePage.textTitle.Text,
+ Thumbnail = chatUpdatePage.imgThumbnail.Source
+ }
+ };
chatTask.ChangeStatus(TwitchTaskStatus.Ready);
lock (PageQueue.taskLock)
@@ -347,11 +397,10 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
this.Close();
}
- if (parentPage is PageChatRender)
+ if (_parentPage is PageChatRender chatRenderPage)
{
- PageChatRender renderPage = (PageChatRender)parentPage;
string folderPath = textFolder.Text;
- foreach (string fileName in renderPage.FileNames)
+ foreach (string fileName in chatRenderPage.FileNames)
{
if (!Directory.Exists(folderPath))
{
@@ -359,15 +408,20 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
return;
}
- ChatRenderTask renderTask = new ChatRenderTask();
- string fileFormat = renderPage.comboFormat.SelectedItem.ToString();
+ string fileFormat = chatRenderPage.comboFormat.SelectedItem.ToString()!;
string filePath = Path.Combine(folderPath, Path.GetFileNameWithoutExtension(fileName) + "." + fileFormat.ToLower());
ChatRenderOptions renderOptions = MainWindow.pageChatRender.GetOptions(filePath);
renderOptions.InputFile = fileName;
- renderTask.DownloadOptions = renderOptions;
- renderTask.Info.Title = Path.GetFileNameWithoutExtension(filePath);
- var (success, image) = await ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL);
- if (success)
+ ChatRenderTask renderTask = new ChatRenderTask
+ {
+ DownloadOptions = renderOptions,
+ Info =
+ {
+ Title = Path.GetFileNameWithoutExtension(filePath)
+ }
+ };
+
+ if (ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out var image))
{
renderTask.Info.Thumbnail = image;
}
@@ -382,133 +436,165 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
}
}
}
- else
+ else if (_dataList.Count > 0)
{
- if (dataList.Count > 0)
- {
- string folderPath = textFolder.Text;
- if (!Directory.Exists(folderPath))
- {
- MessageBox.Show(Translations.Strings.InvaliFolderPathMessage, Translations.Strings.InvalidFolderPath, MessageBoxButton.OK, MessageBoxImage.Error);
- return;
- }
+ EnqueueDataList();
+ }
+ }
+
+ private void EnqueueDataList()
+ {
+ string folderPath = textFolder.Text;
+ if (!Directory.Exists(folderPath))
+ {
+ MessageBox.Show(Translations.Strings.InvaliFolderPathMessage, Translations.Strings.InvalidFolderPath, MessageBoxButton.OK, MessageBoxImage.Error);
+ return;
+ }
- for (int i = 0; i < dataList.Count; i++)
+ foreach (var taskData in _dataList)
+ {
+ if (checkVideo.IsChecked.GetValueOrDefault())
+ {
+ if (taskData.Id.All(char.IsDigit))
{
- var taskData = dataList[i];
- if ((bool)checkVideo.IsChecked)
+ VideoDownloadOptions downloadOptions = new VideoDownloadOptions
+ {
+ Oauth = Settings.Default.OAuth,
+ TempFolder = Settings.Default.TempPath,
+ Id = taskData.Id,
+ FfmpegPath = "ffmpeg",
+ CropBeginning = false,
+ CropEnding = false,
+ DownloadThreads = Settings.Default.VodDownloadThreads,
+ ThrottleKib = Settings.Default.DownloadThrottleEnabled
+ ? Settings.Default.MaximumBandwidthKib
+ : -1
+ };
+ downloadOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateVod, taskData.Title, taskData.Id, taskData.Time, taskData.Streamer,
+ downloadOptions.CropBeginning ? TimeSpan.FromSeconds(downloadOptions.CropBeginningTime) : TimeSpan.Zero,
+ downloadOptions.CropEnding ? TimeSpan.FromSeconds(downloadOptions.CropEndingTime) : TimeSpan.FromSeconds(taskData.Length),
+ taskData.Views.ToString(), taskData.Game) + ".mp4");
+
+ VodDownloadTask downloadTask = new VodDownloadTask
{
- if (taskData.Id.All(Char.IsDigit))
+ DownloadOptions = downloadOptions,
+ Info =
{
- VodDownloadTask downloadTask = new VodDownloadTask();
- VideoDownloadOptions downloadOptions = new VideoDownloadOptions();
- downloadOptions.Oauth = Settings.Default.OAuth;
- downloadOptions.TempFolder = Settings.Default.TempPath;
- downloadOptions.Id = taskData.Id;
- downloadOptions.FfmpegPath = "ffmpeg";
- downloadOptions.CropBeginning = false;
- downloadOptions.CropEnding = false;
- downloadOptions.DownloadThreads = Settings.Default.VodDownloadThreads;
- downloadOptions.ThrottleKib = Settings.Default.DownloadThrottleEnabled
- ? Settings.Default.MaximumBandwidthKib
- : -1;
- downloadOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateVod, taskData.Title, taskData.Id, taskData.Time, taskData.Streamer,
- downloadOptions.CropBeginning ? TimeSpan.FromSeconds(downloadOptions.CropBeginningTime) : TimeSpan.Zero,
- downloadOptions.CropEnding ? TimeSpan.FromSeconds(downloadOptions.CropEndingTime) : TimeSpan.FromSeconds(taskData.Length),
- taskData.Views.ToString(), taskData.Game) + ".mp4");
- downloadTask.DownloadOptions = downloadOptions;
- downloadTask.Info.Title = taskData.Title;
- downloadTask.Info.Thumbnail = taskData.Thumbnail;
- downloadTask.ChangeStatus(TwitchTaskStatus.Ready);
-
- lock (PageQueue.taskLock)
- {
- PageQueue.taskList.Add(downloadTask);
- }
+ Title = taskData.Title,
+ Thumbnail = taskData.Thumbnail
}
- else
+ };
+ downloadTask.ChangeStatus(TwitchTaskStatus.Ready);
+
+ lock (PageQueue.taskLock)
+ {
+ PageQueue.taskList.Add(downloadTask);
+ }
+ }
+ else
+ {
+ ClipDownloadOptions downloadOptions = new ClipDownloadOptions
+ {
+ Id = taskData.Id,
+ Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateClip, taskData.Title, taskData.Id, taskData.Time, taskData.Streamer,
+ TimeSpan.Zero, TimeSpan.FromSeconds(taskData.Length), taskData.Views.ToString(), taskData.Game) + ".mp4"),
+ ThrottleKib = Settings.Default.DownloadThrottleEnabled
+ ? Settings.Default.MaximumBandwidthKib
+ : -1,
+ TempFolder = Settings.Default.TempPath,
+ EncodeMetadata = Settings.Default.EncodeClipMetadata,
+ FfmpegPath = "ffmpeg"
+ };
+
+ ClipDownloadTask downloadTask = new ClipDownloadTask
+ {
+ DownloadOptions = downloadOptions,
+ Info =
{
- ClipDownloadTask downloadTask = new ClipDownloadTask();
- ClipDownloadOptions downloadOptions = new ClipDownloadOptions();
- downloadOptions.Id = taskData.Id;
- downloadOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateClip, taskData.Title, taskData.Id, taskData.Time, taskData.Streamer,
- TimeSpan.Zero, TimeSpan.FromSeconds(taskData.Length), taskData.Views.ToString(), taskData.Game) + ".mp4");
- downloadOptions.ThrottleKib = Settings.Default.DownloadThrottleEnabled
- ? Settings.Default.MaximumBandwidthKib
- : -1;
- downloadOptions.TempFolder = Settings.Default.TempPath;
- downloadOptions.EncodeMetadata = Settings.Default.EncodeClipMetadata;
- downloadOptions.FfmpegPath = "ffmpeg";
- downloadTask.DownloadOptions = downloadOptions;
- downloadTask.Info.Title = taskData.Title;
- downloadTask.Info.Thumbnail = taskData.Thumbnail;
- downloadTask.ChangeStatus(TwitchTaskStatus.Ready);
-
- lock (PageQueue.taskLock)
- {
- PageQueue.taskList.Add(downloadTask);
- }
+ Title = taskData.Title,
+ Thumbnail = taskData.Thumbnail
}
+ };
+ downloadTask.ChangeStatus(TwitchTaskStatus.Ready);
+
+ lock (PageQueue.taskLock)
+ {
+ PageQueue.taskList.Add(downloadTask);
}
+ }
+ }
- if ((bool)checkChat.IsChecked)
+ if (checkChat.IsChecked.GetValueOrDefault())
+ {
+ ChatDownloadOptions downloadOptions = new ChatDownloadOptions
+ {
+ Compression = RadioCompressionNone.IsChecked.GetValueOrDefault() ? ChatCompression.None : ChatCompression.Gzip,
+ EmbedData = checkEmbed.IsChecked.GetValueOrDefault(),
+ TimeFormat = TimestampFormat.Relative,
+ Id = taskData.Id,
+ CropBeginning = false,
+ CropEnding = false
+ };
+ if (radioJson.IsChecked == true)
+ downloadOptions.DownloadFormat = ChatFormat.Json;
+ else if (radioHTML.IsChecked == true)
+ downloadOptions.DownloadFormat = ChatFormat.Html;
+ else
+ downloadOptions.DownloadFormat = ChatFormat.Text;
+ downloadOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, taskData.Title, taskData.Id, taskData.Time, taskData.Streamer,
+ downloadOptions.CropBeginning ? TimeSpan.FromSeconds(downloadOptions.CropBeginningTime) : TimeSpan.Zero,
+ downloadOptions.CropEnding ? TimeSpan.FromSeconds(downloadOptions.CropEndingTime) : TimeSpan.FromSeconds(taskData.Length),
+ taskData.Views.ToString(), taskData.Game) + "." + downloadOptions.FileExtension);
+
+ ChatDownloadTask downloadTask = new ChatDownloadTask
+ {
+ DownloadOptions = downloadOptions,
+ Info =
{
- ChatDownloadTask downloadTask = new ChatDownloadTask();
- ChatDownloadOptions downloadOptions = new ChatDownloadOptions();
- if (radioJson.IsChecked == true)
- downloadOptions.DownloadFormat = ChatFormat.Json;
- else if (radioHTML.IsChecked == true)
- downloadOptions.DownloadFormat = ChatFormat.Html;
- else
- downloadOptions.DownloadFormat = ChatFormat.Text;
- downloadOptions.Compression = RadioCompressionNone.IsChecked == true ? ChatCompression.None : ChatCompression.Gzip;
- downloadOptions.EmbedData = (bool)checkEmbed.IsChecked;
- downloadOptions.TimeFormat = TimestampFormat.Relative;
- downloadOptions.Id = taskData.Id;
- downloadOptions.CropBeginning = false;
- downloadOptions.CropEnding = false;
- downloadOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateChat, taskData.Title, taskData.Id, taskData.Time, taskData.Streamer,
- downloadOptions.CropBeginning ? TimeSpan.FromSeconds(downloadOptions.CropBeginningTime) : TimeSpan.Zero,
- downloadOptions.CropEnding ? TimeSpan.FromSeconds(downloadOptions.CropEndingTime) : TimeSpan.FromSeconds(taskData.Length),
- taskData.Views.ToString(), taskData.Game) + "." + downloadOptions.FileExtension);
- downloadTask.DownloadOptions = downloadOptions;
- downloadTask.Info.Title = taskData.Title;
- downloadTask.Info.Thumbnail = taskData.Thumbnail;
- downloadTask.ChangeStatus(TwitchTaskStatus.Ready);
+ Title = taskData.Title,
+ Thumbnail = taskData.Thumbnail
+ }
+ };
+ downloadTask.ChangeStatus(TwitchTaskStatus.Ready);
- lock (PageQueue.taskLock)
- {
- PageQueue.taskList.Add(downloadTask);
- }
+ lock (PageQueue.taskLock)
+ {
+ PageQueue.taskList.Add(downloadTask);
+ }
- if ((bool)checkRender.IsChecked && downloadOptions.DownloadFormat == ChatFormat.Json)
+ if (checkRender.IsChecked.GetValueOrDefault() && downloadOptions.DownloadFormat == ChatFormat.Json)
+ {
+ ChatRenderOptions renderOptions =
+ MainWindow.pageChatRender.GetOptions(Path.ChangeExtension(downloadOptions.Filename.Replace(".gz", ""), '.' + MainWindow.pageChatRender.comboFormat.Text.ToLower()));
+ if (renderOptions.OutputFile.Trim() == downloadOptions.Filename.Trim())
+ {
+ //Just in case VOD and chat paths are the same. Like the previous defaults
+ renderOptions.OutputFile = Path.ChangeExtension(downloadOptions.Filename.Replace(".gz", ""), " - CHAT." + MainWindow.pageChatRender.comboFormat.Text.ToLower());
+ }
+ renderOptions.InputFile = downloadOptions.Filename;
+
+ ChatRenderTask renderTask = new ChatRenderTask
+ {
+ DownloadOptions = renderOptions,
+ Info =
{
- ChatRenderTask renderTask = new ChatRenderTask();
- ChatRenderOptions renderOptions = MainWindow.pageChatRender.GetOptions(Path.ChangeExtension(downloadOptions.Filename.Replace(".gz", ""), '.' + MainWindow.pageChatRender.comboFormat.Text.ToLower()));
- if (renderOptions.OutputFile.Trim() == downloadOptions.Filename.Trim())
- {
- //Just in case VOD and chat paths are the same. Like the previous defaults
- renderOptions.OutputFile = Path.ChangeExtension(downloadOptions.Filename.Replace(".gz", ""), " - CHAT." + MainWindow.pageChatRender.comboFormat.Text.ToLower());
- }
- renderOptions.InputFile = downloadOptions.Filename;
- renderTask.DownloadOptions = renderOptions;
- renderTask.Info.Title = taskData.Title;
- renderTask.Info.Thumbnail = taskData.Thumbnail;
- renderTask.ChangeStatus(TwitchTaskStatus.Waiting);
- renderTask.DependantTask = downloadTask;
-
- lock (PageQueue.taskLock)
- {
- PageQueue.taskList.Add(renderTask);
- }
- }
+ Title = taskData.Title,
+ Thumbnail = taskData.Thumbnail
+ },
+ DependantTask = downloadTask
+ };
+ renderTask.ChangeStatus(TwitchTaskStatus.Waiting);
+
+ lock (PageQueue.taskLock)
+ {
+ PageQueue.taskList.Add(renderTask);
}
}
-
- this.DialogResult = true;
- this.Close();
}
}
+
+ this.DialogResult = true;
+ this.Close();
}
private void btnFolder_Click(object sender, RoutedEventArgs e)
diff --git a/TwitchDownloaderWPF/WindowRangeSelect.xaml b/TwitchDownloaderWPF/WindowRangeSelect.xaml
index c9f45d2e..26e05f24 100644
--- a/TwitchDownloaderWPF/WindowRangeSelect.xaml
+++ b/TwitchDownloaderWPF/WindowRangeSelect.xaml
@@ -14,7 +14,6 @@
diff --git a/TwitchDownloaderWPF/WindowRangeSelect.xaml.cs b/TwitchDownloaderWPF/WindowRangeSelect.xaml.cs
index 61fd9d0a..13f64601 100644
--- a/TwitchDownloaderWPF/WindowRangeSelect.xaml.cs
+++ b/TwitchDownloaderWPF/WindowRangeSelect.xaml.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using TwitchDownloaderCore;
@@ -26,8 +27,8 @@ private void Window_Initialized(object sender, EventArgs e)
startSeconds = (int)Math.Floor(CurrentRender.chatRoot.video.start);
endSeconds = (int)Math.Ceiling(CurrentRender.chatRoot.video.end);
- numStart.Text = startSeconds.ToString();
- numEnd.Text = endSeconds.ToString();
+ numStart.Text = startSeconds.ToString(CultureInfo.CurrentCulture);
+ numEnd.Text = endSeconds.ToString(CultureInfo.CurrentCulture);
rangeTime.Maximum = endSeconds;
rangeTime.Minimum = startSeconds;
rangeTime.ValueEnd = endSeconds;
@@ -35,16 +36,16 @@ private void Window_Initialized(object sender, EventArgs e)
private void rangeTime_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
{
- numStart.Text = rangeTime.ValueStart.ToString();
- numEnd.Text = rangeTime.ValueEnd.ToString();
+ numStart.Text = rangeTime.ValueStart.ToString(CultureInfo.CurrentCulture);
+ numEnd.Text = rangeTime.ValueEnd.ToString(CultureInfo.CurrentCulture);
}
private void Button_Click(object sender, RoutedEventArgs e)
{
try
{
- startSeconds = int.Parse(numStart.Text);
- endSeconds = int.Parse(numEnd.Text);
+ startSeconds = int.Parse(numStart.Text, CultureInfo.CurrentCulture);
+ endSeconds = int.Parse(numEnd.Text, CultureInfo.CurrentCulture);
OK = true;
}
catch
@@ -54,32 +55,24 @@ private void Button_Click(object sender, RoutedEventArgs e)
this.Close();
}
- private void numStart_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e)
- {
-
- }
-
- private void numEnd_ValueChanged(object sender, HandyControl.Data.FunctionEventArgs e)
- {
-
- }
-
private void numStart_TextChanged(object sender, TextChangedEventArgs e)
{
try
{
- rangeTime.ValueStart = int.Parse(numStart.Text);
+ rangeTime.ValueStart = int.Parse(numStart.Text, CultureInfo.CurrentCulture);
}
- catch { }
+ catch (FormatException) { }
+ catch (OverflowException) { }
}
private void numEnd_TextChanged(object sender, TextChangedEventArgs e)
{
try
{
- rangeTime.ValueEnd = int.Parse(numEnd.Text);
+ rangeTime.ValueEnd = int.Parse(numEnd.Text, CultureInfo.CurrentCulture);
}
- catch { }
+ catch (FormatException) { }
+ catch (OverflowException) { }
}
private void Window_Loaded(object sender, RoutedEventArgs e)
diff --git a/TwitchDownloaderWPF/WindowSettings.xaml b/TwitchDownloaderWPF/WindowSettings.xaml
index fb4fc65e..fb18ece7 100644
--- a/TwitchDownloaderWPF/WindowSettings.xaml
+++ b/TwitchDownloaderWPF/WindowSettings.xaml
@@ -16,7 +16,6 @@
@@ -65,7 +64,7 @@
(?) :
-
+
diff --git a/TwitchDownloaderWPF/WindowSettings.xaml.cs b/TwitchDownloaderWPF/WindowSettings.xaml.cs
index dd4e7ab5..7eb419ac 100644
--- a/TwitchDownloaderWPF/WindowSettings.xaml.cs
+++ b/TwitchDownloaderWPF/WindowSettings.xaml.cs
@@ -50,14 +50,17 @@ private void Window_Initialized(object sender, EventArgs e)
CheckThrottleEnabled.IsChecked = Settings.Default.DownloadThrottleEnabled;
radioTimeFormatUTC.IsChecked = Settings.Default.UTCVideoTime;
- // Setup theme dropdown
- comboTheme.Items.Add("System"); // Cannot be localized
- string[] themeFiles = Directory.GetFiles("Themes", "*.xaml");
- foreach (string themeFile in themeFiles)
+ if (Directory.Exists("Themes"))
{
- comboTheme.Items.Add(Path.GetFileNameWithoutExtension(themeFile));
+ // Setup theme dropdown
+ comboTheme.Items.Add("System"); // Cannot be localized
+ string[] themeFiles = Directory.GetFiles("Themes", "*.xaml");
+ foreach (string themeFile in themeFiles)
+ {
+ comboTheme.Items.Add(Path.GetFileNameWithoutExtension(themeFile));
+ }
+ comboTheme.SelectedItem = Settings.Default.GuiTheme;
}
- comboTheme.SelectedItem = Settings.Default.GuiTheme;
// Setup culture dropdown
foreach (var culture in AvailableCultures.All)
@@ -116,11 +119,11 @@ private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs
Settings.Default.TemplateClip = textClipTemplate.Text;
Settings.Default.TemplateChat = textChatTemplate.Text;
Settings.Default.TempPath = textTempPath.Text;
- Settings.Default.HideDonation = (bool)checkDonation.IsChecked;
- Settings.Default.VerboseErrors = (bool)checkVerboseErrors.IsChecked;
+ Settings.Default.HideDonation = checkDonation.IsChecked.GetValueOrDefault();
+ Settings.Default.VerboseErrors = checkVerboseErrors.IsChecked.GetValueOrDefault();
Settings.Default.MaximumBandwidthKib = (int)NumMaximumBandwidth.Value;
- Settings.Default.UTCVideoTime = (bool)radioTimeFormatUTC.IsChecked;
- Settings.Default.DownloadThrottleEnabled = (bool)CheckThrottleEnabled.IsChecked;
+ Settings.Default.UTCVideoTime = radioTimeFormatUTC.IsChecked.GetValueOrDefault();
+ Settings.Default.DownloadThrottleEnabled = CheckThrottleEnabled.IsChecked.GetValueOrDefault();
Settings.Default.Save();
}
@@ -162,14 +165,9 @@ private void comboLocale_SelectionChanged(object sender, SelectionChangedEventAr
}
}
- private void CheckThrottleEnabled_Checked(object sender, RoutedEventArgs e)
- {
- NumMaximumBandwidth.IsEnabled = true;
- }
-
- private void CheckThrottleEnabled_Unchecked(object sender, RoutedEventArgs e)
+ private void CheckThrottleEnabled_CheckedChanged(object sender, RoutedEventArgs e)
{
- NumMaximumBandwidth.IsEnabled = false;
+ NumMaximumBandwidth.IsEnabled = CheckThrottleEnabled.IsChecked.GetValueOrDefault();
}
}
}
diff --git a/TwitchDownloaderWPF/WindowUrlList.xaml b/TwitchDownloaderWPF/WindowUrlList.xaml
index fdb1d966..2c06b2c0 100644
--- a/TwitchDownloaderWPF/WindowUrlList.xaml
+++ b/TwitchDownloaderWPF/WindowUrlList.xaml
@@ -4,16 +4,27 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TwitchDownloaderWPF"
+ xmlns:behave="clr-namespace:TwitchDownloaderWPF.Behaviors"
xmlns:lex="http://wpflocalizeextension.codeplex.com"
lex:LocalizeDictionary.DesignCulture=""
lex:ResxLocalizationProvider.DefaultAssembly="TwitchDownloaderWPF"
lex:ResxLocalizationProvider.DefaultDictionary="Strings"
mc:Ignorable="d"
Title="Mass Download URL List" MinHeight="590" Height="600" MinWidth="485" Width="500" Loaded="Window_Loaded">
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/TwitchDownloaderWPF/WindowUrlList.xaml.cs b/TwitchDownloaderWPF/WindowUrlList.xaml.cs
index 0f931c18..57d9c19a 100644
--- a/TwitchDownloaderWPF/WindowUrlList.xaml.cs
+++ b/TwitchDownloaderWPF/WindowUrlList.xaml.cs
@@ -3,10 +3,11 @@
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
-using System.Windows.Media.Imaging;
+using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.VideoPlatforms.Twitch;
using TwitchDownloaderCore.VideoPlatforms.Twitch.Gql;
using TwitchDownloaderWPF.Properties;
+using TwitchDownloaderWPF.Services;
using TwitchDownloaderWPF.TwitchTasks;
namespace TwitchDownloaderWPF
@@ -33,9 +34,7 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
foreach (var url in urlList)
{
- string id = PageChatDownload.ValidateUrl(url);
-
- if (id == "")
+ if (UrlParse.TryParseVideoOrClipId(url, out _, out _, out var id))
{
invalidList.Add(url);
}
@@ -58,7 +57,7 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
foreach (var id in idList)
{
- if (id.All(Char.IsDigit))
+ if (id.All(char.IsDigit))
{
Task task = TwitchHelper.GetVideoInfo(int.Parse(id));
taskVideoList.Add(task);
@@ -77,73 +76,76 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
await Task.WhenAll(taskVideoList.ToArray());
}
catch { }
- try
- {
- await Task.WhenAll(taskClipList.ToArray());
- }
- catch { }
- for (int i = 0; i < taskVideoList.Count; i++)
+ foreach (var task in taskVideoList)
{
- if (taskVideoList[i].IsCompleted)
+ if (!task.IsCompleted)
+ continue;
+
+ string id = taskDict[task.Id];
+ if (!task.IsFaulted)
{
- string id = taskDict[taskVideoList[i].Id];
- if (!taskVideoList[i].IsFaulted)
+ var videoInfo = task.Result.data.video;
+ var thumbUrl = videoInfo.thumbnailURLs.FirstOrDefault();
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var thumbnail))
{
- GqlVideoResponse data = taskVideoList[i].Result;
- TaskData newData = new TaskData();
- newData.Id = id;
- try
- {
- string thumbUrl = data.data.video.thumbnailURLs.FirstOrDefault();
- var bitmapImage = new BitmapImage();
- bitmapImage.BeginInit();
- bitmapImage.UriSource = new Uri(thumbUrl);
- bitmapImage.EndInit();
- newData.Thumbnail = bitmapImage;
- }
- catch { }
- newData.Title = data.data.video.title;
- newData.Streamer = data.data.video.owner.displayName;
- newData.Time = Settings.Default.UTCVideoTime ? data.data.video.createdAt : data.data.video.createdAt.ToLocalTime();
- dataList.Add(newData);
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out thumbnail);
}
- else
+
+ dataList.Add(new TaskData
{
- errorList.Add(idDict[id]);
- }
+ Id = id,
+ Thumbnail = thumbnail,
+ Title = videoInfo.title,
+ Streamer = videoInfo.owner.displayName,
+ Time = Settings.Default.UTCVideoTime ? videoInfo.createdAt : videoInfo.createdAt.ToLocalTime(),
+ Views = videoInfo.viewCount,
+ Game = videoInfo.game?.displayName ?? "Unknown",
+ Length = videoInfo.lengthSeconds
+ });
+ }
+ else
+ {
+ errorList.Add(idDict[id]);
}
}
- for (int i = 0; i < taskClipList.Count; i++)
+ try
+ {
+ await Task.WhenAll(taskClipList.ToArray());
+ }
+ catch { }
+
+ foreach (var task in taskClipList)
{
- if (taskClipList[i].IsCompleted)
+ if (!task.IsCompleted)
+ continue;
+
+ string id = taskDict[task.Id];
+ if (!task.IsFaulted)
{
- string id = taskDict[taskClipList[i].Id];
- if (!taskClipList[i].IsFaulted)
+ var clipInfo = task.Result.data.clip;
+ var thumbUrl = clipInfo.thumbnailURL;
+ if (!ThumbnailService.TryGetThumb(thumbUrl, out var thumbnail))
{
- GqlClipResponse data = taskClipList[i].Result;
- TaskData newData = new TaskData();
- newData.Id = id;
- try
- {
- string thumbUrl = data.data.clip.thumbnailURL;
- var bitmapImage = new BitmapImage();
- bitmapImage.BeginInit();
- bitmapImage.UriSource = new Uri(thumbUrl);
- bitmapImage.EndInit();
- newData.Thumbnail = bitmapImage;
- }
- catch { }
- newData.Title = data.data.clip.title;
- newData.Streamer = data.data.clip.broadcaster.displayName;
- newData.Time = Settings.Default.UTCVideoTime ? data.data.clip.createdAt : data.data.clip.createdAt.ToLocalTime();
- dataList.Add(newData);
+ _ = ThumbnailService.TryGetThumb(ThumbnailService.THUMBNAIL_MISSING_URL, out thumbnail);
}
- else
+
+ dataList.Add(new TaskData
{
- errorList.Add(idDict[id]);
- }
+ Id = id,
+ Thumbnail = thumbnail,
+ Title = clipInfo.title,
+ Streamer = clipInfo.broadcaster.displayName,
+ Time = Settings.Default.UTCVideoTime ? clipInfo.createdAt : clipInfo.createdAt.ToLocalTime(),
+ Views = clipInfo.viewCount,
+ Game = clipInfo.game?.displayName ?? "Unknown",
+ Length = clipInfo.durationSeconds
+ });
+ }
+ else
+ {
+ errorList.Add(idDict[id]);
}
}
@@ -154,17 +156,16 @@ private async void btnQueue_Click(object sender, RoutedEventArgs e)
}
WindowQueueOptions queue = new WindowQueueOptions(dataList);
- bool? queued = queue.ShowDialog();
- if (queued != null && (bool)queued)
+ if (queue.ShowDialog().GetValueOrDefault())
this.Close();
btnQueue.IsEnabled = true;
}
private void Window_Loaded(object sender, RoutedEventArgs e)
- {
+ {
Title = Translations.Strings.TitleUrlList;
- App.RequestTitleBarChange();
- }
+ App.RequestTitleBarChange();
+ }
}
-}
+}
\ No newline at end of file