├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── config.yml │ ├── feature_request.yaml │ └── other.yaml └── workflows │ ├── build-pr.yml │ └── build.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── README_es.md ├── README_it.md ├── README_ja.md ├── README_pt-br.md ├── README_ru.md ├── README_tr.md ├── README_zh-cn.md ├── README_zh-tw.md ├── TwitchDownloaderCLI.Tests ├── GlobalUsings.cs ├── ModelTests │ └── TimeDurationTests.cs └── TwitchDownloaderCLI.Tests.csproj ├── TwitchDownloaderCLI ├── Models │ ├── Enums.cs │ └── TimeDuration.cs ├── Modes │ ├── Arguments │ │ ├── CacheArgs.cs │ │ ├── ChatDownloadArgs.cs │ │ ├── ChatRenderArgs.cs │ │ ├── ChatUpdateArgs.cs │ │ ├── ClipDownloadArgs.cs │ │ ├── FfmpegArgs.cs │ │ ├── IFileCollisionArgs.cs │ │ ├── ITwitchDownloaderArgs.cs │ │ ├── InfoArgs.cs │ │ ├── TsMergeArgs.cs │ │ ├── UpdateArgs.cs │ │ └── VideoDownloadArgs.cs │ ├── CacheHandler.cs │ ├── DownloadChat.cs │ ├── DownloadClip.cs │ ├── DownloadVideo.cs │ ├── FfmpegHandler.cs │ ├── InfoHandler.cs │ ├── MergeTs.cs │ ├── RenderChat.cs │ ├── UpdateChat.cs │ └── UpdateHandler.cs ├── Program.cs ├── Properties │ ├── PublishProfiles │ │ ├── Linux.pubxml │ │ ├── LinuxAlpine.pubxml │ │ ├── LinuxArm.pubxml │ │ ├── LinuxArm64.pubxml │ │ ├── MacOS.pubxml │ │ ├── MacOSArm64.pubxml │ │ └── Windows.pubxml │ └── launchSettings.json ├── README.md ├── Tools │ ├── CliTaskProgress.cs │ ├── FileCollisionHandler.cs │ ├── PathUtils.cs │ ├── PreParseArgs.cs │ └── UserPrompt.cs └── TwitchDownloaderCLI.csproj ├── TwitchDownloaderCore.Tests ├── ExtensionTests │ ├── ReadOnlySpanCountTests.cs │ ├── ReadOnlySpanTryReplaceNonEscapedTests.cs │ ├── ReadOnlySpanUnEscapedIndexOfAnyTests.cs │ ├── ReadOnlySpanUnEscapedIndexOfTests.cs │ ├── StringBuilderExtensionTests.cs │ └── StringReplaceAnyTests.cs ├── GlobalUsings.cs ├── ModelTests │ ├── M3U8Tests.cs │ └── M3U8VideoQualitiesTests.cs ├── ServiceTests │ └── FilenameServiceTests.cs ├── ToolTests │ ├── HighlightIconsTests.cs │ ├── IdParseTests.cs │ ├── TimeSpanHFormatTests.cs │ └── UrlTimeCodeTests.cs └── TwitchDownloaderCore.Tests.csproj ├── TwitchDownloaderCore ├── Chat │ ├── ChatHtml.cs │ ├── ChatJson.cs │ ├── ChatText.cs │ └── EmojiVendor.cs ├── ChatDownloader.cs ├── ChatRenderer.cs ├── ChatUpdater.cs ├── ClipDownloader.cs ├── Extensions │ ├── BufferExtensions.cs │ ├── JsonElementExtensions.cs │ ├── LinqExtensions.cs │ ├── M3U8Extensions.cs │ ├── RandomExtensions.cs │ ├── ReadOnlySpanExtensions.cs │ ├── SKCanvasExtensions.cs │ ├── SKColorExtensions.cs │ ├── SKPaintExtensions.cs │ ├── StreamExtensions.cs │ ├── StringBuilderExtensions.cs │ ├── StringExtensions.cs │ └── VersionExtensions.cs ├── Interfaces │ ├── ITaskLogger.cs │ └── ITaskProgress.cs ├── Models │ ├── ClipVideoQualities.cs │ ├── ClipVideoQuality.cs │ ├── Enums.cs │ ├── FfmpegProcess.cs │ ├── Interfaces │ │ ├── IVideoQualities.cs │ │ └── IVideoQuality.cs │ ├── M3U8.cs │ ├── M3U8Parse.cs │ ├── M3U8VideoQualities.cs │ ├── M3U8VideoQuality.cs │ ├── Resolution.cs │ ├── StubTaskProgress.cs │ ├── ThrottledStream.cs │ └── VideoQualities.cs ├── Options │ ├── ChatDownloadOptions.cs │ ├── ChatRenderOptions.cs │ ├── ChatUpdateOptions.cs │ ├── ClipDownloadOptions.cs │ ├── TsMergeOptions.cs │ └── VideoDownloadOptions.cs ├── Properties │ ├── PublishProfiles │ │ └── FolderProfile.pubxml │ ├── Resources.Designer.cs │ └── Resources.resx ├── Resources │ ├── TD-License │ ├── THIRD-PARTY-LICENSES.txt │ ├── chat-template.html │ ├── interbold.otf │ ├── interregular.otf │ ├── noto-emoji-2.038.zip │ └── twemoji-14.0.0.zip ├── Services │ ├── CacheDirectoryService.cs │ └── FilenameService.cs ├── Tools │ ├── ClipQualityComparer.cs │ ├── ClipVideoQualityComparer.cs │ ├── CommentIdEqualityComparer.cs │ ├── CommentOffsetComparer.cs │ ├── CoreLicensor.cs │ ├── DownloadTools.cs │ ├── DriveHelper.cs │ ├── FfmpegConcatList.cs │ ├── FfmpegMetadata.cs │ ├── HighlightIcons.cs │ ├── IdParse.cs │ ├── M3U8StreamQualityComparer.cs │ ├── TimeSpanHFormat.cs │ ├── TwitchRegex.cs │ ├── UrlTimeCode.cs │ ├── VideoDownloadState.cs │ ├── VideoDownloadThread.cs │ └── VideoSizeEstimator.cs ├── TsMerger.cs ├── TwitchDownloaderCore.csproj ├── TwitchHelper.cs ├── TwitchObjects │ ├── Api │ │ ├── BTTVChannelEmoteResponse.cs │ │ ├── FFZEmote.cs │ │ ├── STVChannelEmoteResponse.cs │ │ └── STVGlobalEmoteResponse.cs │ ├── ChatBadge.cs │ ├── ChatRoot.cs │ ├── ChatRootInfo.cs │ ├── CheerEmote.cs │ ├── CommentSection.cs │ ├── EmoteResponse.cs │ ├── EmoteResponseItem.cs │ ├── Gql │ │ ├── GqlBadgeResponse.cs │ │ ├── GqlCheerResponse.cs │ │ ├── GqlClipResponse.cs │ │ ├── GqlClipSearchResponse.cs │ │ ├── GqlClipTokenResponse.cs │ │ ├── GqlCommentResponse.cs │ │ ├── GqlShareClipRenderStatusResponse.cs │ │ ├── GqlUserIdResponse.cs │ │ ├── GqlUserInfoResponse.cs │ │ ├── GqlVideoChapterResponse.cs │ │ ├── GqlVideoResponse.cs │ │ ├── GqlVideoSearchResponse.cs │ │ └── GqlVideoTokenResponse.cs │ ├── StvEmoteFlags.cs │ ├── TwitchEmote.cs │ └── UpdateFrame.cs └── VideoDownloader.cs ├── TwitchDownloaderWPF.sln ├── TwitchDownloaderWPF ├── App.config ├── App.xaml ├── App.xaml.cs ├── Behaviors │ ├── TextBoxTripleClickBehavior.cs │ └── WindowIntegrityCheckBehavior.cs ├── Extensions │ └── TextBoxExtensions.cs ├── Images │ ├── HACKERMANS.gif │ ├── Logo.png │ ├── chatdownload1Example.png │ ├── chatdownload2Example.png │ ├── chatrender1Example.png │ ├── chatrender2Example.png │ ├── chatrender3Example.png │ ├── chatrender4Example.png │ ├── chatrender5Example.png │ ├── chatupdateExample.png │ ├── clipExample.png │ ├── donate.png │ ├── enqueueExample.png │ ├── massclipExample.png │ ├── massurlExample.png │ ├── massvodExample.png │ ├── peepoSad.png │ ├── ppHop.gif │ ├── ppOverheat.gif │ ├── ppStretch.gif │ ├── rangeExample.png │ ├── settings.png │ ├── settingsExample.png │ ├── taskqueueExample.png │ └── vodExample.png ├── MainWindow.xaml ├── MainWindow.xaml.cs ├── Models │ ├── BooleanModel.cs │ ├── CollisionBehavior.cs │ ├── LogLevel.cs │ ├── SolidBrushModel.cs │ └── ThemeResourceDictionaryModel.cs ├── OpenTK.dll.config ├── PageChatDownload.xaml ├── PageChatDownload.xaml.cs ├── PageChatRender.xaml ├── PageChatRender.xaml.cs ├── PageChatUpdate.xaml ├── PageChatUpdate.xaml.cs ├── PageClipDownload.xaml ├── PageClipDownload.xaml.cs ├── PageQueue.xaml ├── PageQueue.xaml.cs ├── PageVodDownload.xaml ├── PageVodDownload.xaml.cs ├── Properties │ ├── AssemblyInfo.cs │ ├── CustomFfmpegArgs.cs │ ├── PublishProfiles │ │ └── Windows.pubxml │ ├── Settings.Designer.cs │ └── Settings.settings ├── README.md ├── README_ja.md ├── README_pt-br.md ├── Services │ ├── AvailableCultures.cs │ ├── ClipboardService.cs │ ├── CultureService.cs │ ├── DefaultThemeService.cs │ ├── FileCollisionService.cs │ ├── FileService.cs │ ├── NativeFunctions.cs │ ├── ThemeService.cs │ ├── ThumbnailService.cs │ └── WindowsThemeService.cs ├── Themes │ ├── Dark.xaml │ ├── Light.xaml │ └── README.txt ├── Translations │ ├── Strings.Designer.cs │ ├── Strings.es.resx │ ├── Strings.fr.resx │ ├── Strings.it.resx │ ├── Strings.ja.resx │ ├── Strings.pl.resx │ ├── Strings.pt-br.resx │ ├── Strings.resx │ ├── Strings.ru.resx │ ├── Strings.tr.resx │ ├── Strings.uk.resx │ ├── Strings.zh-cn.resx │ └── Strings.zh-tw.resx ├── TwitchDownloaderWPF.csproj ├── TwitchTasks │ ├── ChatDownloadTask.cs │ ├── ChatRenderTask.cs │ ├── ChatUpdateTask.cs │ ├── ClipDownloadTask.cs │ ├── TaskData.cs │ ├── TwitchTask.cs │ ├── TwitchTaskStatus.cs │ └── VodDownloadTask.cs ├── Utils │ ├── LiveVideoMonitor.cs │ └── WpfTaskProgress.cs ├── WindowMassDownload.xaml ├── WindowMassDownload.xaml.cs ├── WindowOldVideoCacheManager.xaml ├── WindowOldVideoCacheManager.xaml.cs ├── WindowQueueOptions.xaml ├── WindowQueueOptions.xaml.cs ├── WindowRangeSelect.xaml ├── WindowRangeSelect.xaml.cs ├── WindowSettings.xaml ├── WindowSettings.xaml.cs ├── WindowUrlList.xaml ├── WindowUrlList.xaml.cs └── icon.ico └── WHERE IS THE EXE.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | *.cs text eol=crlf 6 | *.xaml text eol=crlf 7 | 8 | ############################################################################### 9 | # Set default behavior for command prompt diff. 10 | # 11 | # This is need for earlier builds of msysgit that does not have it on by 12 | # default for csharp files. 13 | # Note: This is only used by command line 14 | ############################################################################### 15 | #*.cs diff=csharp 16 | 17 | ############################################################################### 18 | # Set the merge driver for project and solution files 19 | # 20 | # Merging from the command prompt will add diff markers to the files if there 21 | # are conflicts (Merging from VS is not affected by the settings below, in VS 22 | # the diff markers are never inserted). Diff markers may cause the following 23 | # file extensions to fail to load in VS. An alternative would be to treat 24 | # these files as binary and thus will always conflict and require user 25 | # intervention with every merge. To do so, just uncomment the entries below 26 | ############################################################################### 27 | #*.sln merge=binary 28 | #*.csproj merge=binary 29 | #*.vbproj merge=binary 30 | #*.vcxproj merge=binary 31 | #*.vcproj merge=binary 32 | #*.dbproj merge=binary 33 | #*.fsproj merge=binary 34 | #*.lsproj merge=binary 35 | #*.wixproj merge=binary 36 | #*.modelproj merge=binary 37 | #*.sqlproj merge=binary 38 | #*.wwaproj merge=binary 39 | 40 | ############################################################################### 41 | # behavior for image files 42 | # 43 | # image files are treated as binary by default. 44 | ############################################################################### 45 | #*.jpg binary 46 | #*.png binary 47 | #*.gif binary 48 | 49 | ############################################################################### 50 | # diff behavior for common document formats 51 | # 52 | # Convert binary document formats to text before diffing them. This feature 53 | # is only available from the command line. Turn it on by uncommenting the 54 | # entries below. 55 | ############################################################################### 56 | #*.doc diff=astextplain 57 | #*.DOC diff=astextplain 58 | #*.docx diff=astextplain 59 | #*.DOCX diff=astextplain 60 | #*.dot diff=astextplain 61 | #*.DOT diff=astextplain 62 | #*.pdf diff=astextplain 63 | #*.PDF diff=astextplain 64 | #*.rtf diff=astextplain 65 | #*.RTF diff=astextplain 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | This is for reporting bugs. 9 | [Suggest a new feature here](https://github.com/lay295/TwitchDownloader/issues/new?labels=enhancement&template=feature_request.yaml) 10 | - type: checkboxes 11 | id: checklist 12 | attributes: 13 | label: Checklist 14 | options: 15 | - label: I have checked the [issue page](https://github.com/lay295/TwitchDownloader/issues?q=is%3Aissue) for duplicates 16 | required: true 17 | - label: I am running the latest version ([download here](https://github.com/lay295/TwitchDownloader/releases/latest)) 18 | required: true 19 | - type: dropdown 20 | id: edition 21 | attributes: 22 | label: Edition 23 | description: Which edition of the program are you using? 24 | options: 25 | - Select an Option 26 | - Windows GUI Application 27 | - Command Line Interface 28 | - Both 29 | - Unsure 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: issue_text 34 | attributes: 35 | label: Describe your issue here 36 | placeholder: When I try to download a VOD it gives me a 403 error 37 | validations: 38 | required: true 39 | - type: textarea 40 | id: related_information 41 | attributes: 42 | label: Add any related files or extra information here 43 | placeholder: "Here is the VOD id: 1694846567" 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | This is for suggesting new features. 9 | [File a bug report here](https://github.com/lay295/TwitchDownloader/issues/new?labels=bug&template=bug_report.yaml) 10 | - type: checkboxes 11 | id: checklist 12 | attributes: 13 | label: Checklist 14 | options: 15 | - label: I have checked the [issue page](https://github.com/lay295/TwitchDownloader/issues?q=is%3Aissue) for duplicates 16 | required: true 17 | - label: I am running the latest version ([download here](https://github.com/lay295/TwitchDownloader/releases/latest)) 18 | required: true 19 | - type: textarea 20 | id: request_text 21 | attributes: 22 | label: Write your feature request here 23 | placeholder: Please add support for downloading streams while they are live 24 | validations: 25 | required: true 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other.yaml: -------------------------------------------------------------------------------- 1 | name: Other 2 | description: Anything else you may have to say 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | This is a generic issue. 8 | If you are experiencing a problem or have an idea that could improve the program, [file the accurate issue](https://github.com/lay295/TwitchDownloader/issues/new/choose) 9 | - type: checkboxes 10 | id: checklist 11 | attributes: 12 | label: Checklist 13 | options: 14 | - label: I have checked the [issue page](https://github.com/lay295/TwitchDownloader/issues?q=is%3Aissue) for duplicates 15 | required: true 16 | - type: textarea 17 | id: request_text 18 | attributes: 19 | label: Write stuff here 20 | description: This could be a question, or a poem about this program, it's up to you. 21 | placeholder: Woah this program is amazing and fixed my life thank you!! 22 | validations: 23 | required: true 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) lay295 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /TwitchDownloaderCLI.Tests/ModelTests/TimeDurationTests.cs: -------------------------------------------------------------------------------- 1 | using TwitchDownloaderCLI.Models; 2 | using static System.TimeSpan; 3 | 4 | namespace TwitchDownloaderCLI.Tests.ModelTests 5 | { 6 | public class TimeDurationTests 7 | { 8 | [Theory] 9 | [InlineData("200ms", 200 * TicksPerMillisecond)] 10 | [InlineData("55", 55 * TicksPerSecond)] 11 | [InlineData("0.2", 2 * TicksPerSecond / 10)] 12 | [InlineData("23.189", 23189 * TicksPerSecond / 1000)] 13 | [InlineData("55s", 55 * TicksPerSecond)] 14 | [InlineData("17m", 17 * TicksPerMinute)] 15 | [InlineData("31h", 31 * TicksPerHour)] 16 | [InlineData("0:09:27", 9 * TicksPerMinute + 27 * TicksPerSecond)] 17 | [InlineData("11:30", 11 * TicksPerHour + 30 * TicksPerMinute)] 18 | [InlineData("12:03:45", 12 * TicksPerHour + 3 * TicksPerMinute + 45 * TicksPerSecond)] 19 | [InlineData("39:23:02", 39 * TicksPerHour + 23 * TicksPerMinute + 2 * TicksPerSecond)] 20 | [InlineData("47:22:08.123", 47 * TicksPerHour + 22 * TicksPerMinute + 8 * TicksPerSecond + 123 * TicksPerMillisecond)] 21 | [InlineData("47:22:08.12345", 47 * TicksPerHour + 22 * TicksPerMinute + 8 * TicksPerSecond + 123 * TicksPerMillisecond)] 22 | [InlineData("1.2:3:4.5", 1 * TicksPerDay + 2 * TicksPerHour + 3 * TicksPerMinute + 4 * TicksPerSecond + 500 * TicksPerMillisecond)] 23 | [InlineData("2:03:54:27.26", 2 * TicksPerDay + 3 * TicksPerHour + 54 * TicksPerMinute + 27 * TicksPerSecond + 260 * TicksPerMillisecond)] 24 | public void CorrectlyParsesTimeStrings(string input, long expectedTicks) 25 | { 26 | var expected = new TimeDuration(expectedTicks); 27 | 28 | var actual = new TimeDuration(input); 29 | 30 | Assert.Equal(expected, actual); 31 | } 32 | 33 | [Theory] 34 | [InlineData("")] 35 | [InlineData(" ")] 36 | [InlineData("100 s")] 37 | [InlineData("123d")] 38 | [InlineData("0:12345")] 39 | public void ThrowsOnBadFormat(string input) 40 | { 41 | Assert.ThrowsAny(() => TimeDuration.Parse(input)); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI.Tests/TwitchDownloaderCLI.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 10 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | all 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Models/Enums.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCLI.Models 4 | { 5 | [Flags] 6 | internal enum LogLevel 7 | { 8 | All = Status | Verbose | Info | Warning | Error | Ffmpeg, 9 | None = 1 << 0, 10 | Status = 1 << 1, 11 | Verbose = 1 << 2, 12 | Info = 1 << 3, 13 | Warning = 1 << 4, 14 | Error = 1 << 5, 15 | Ffmpeg = 1 << 6, 16 | } 17 | 18 | public enum OverwriteBehavior 19 | { 20 | Overwrite, 21 | Exit, 22 | Rename, 23 | Prompt, 24 | } 25 | 26 | public enum InfoPrintFormat 27 | { 28 | Raw, 29 | Table, 30 | M3U8, 31 | M3U = M3U8, 32 | Json 33 | } 34 | 35 | public enum UserPromptResult 36 | { 37 | Unknown, 38 | Yes, 39 | No, 40 | Cancel, 41 | } 42 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/CacheArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | 4 | namespace TwitchDownloaderCLI.Modes.Arguments 5 | { 6 | [Verb("cache", HelpText = "Manage the working cache")] 7 | internal sealed class CacheArgs : ITwitchDownloaderArgs 8 | { 9 | [Option('c', "clear", Default = false, Required = false, HelpText = "Clears the default cache folder.")] 10 | public bool ClearCache { get; set; } 11 | 12 | [Option("force-clear", Default = false, Required = false, HelpText = "Clears the default cache folder, bypassing the confirmation prompt")] 13 | public bool ForceClearCache { get; set; } 14 | 15 | // Interface args 16 | public bool? ShowBanner { get; set; } 17 | public LogLevel LogLevel { get; set; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | using TwitchDownloaderCore.Models; 4 | 5 | namespace TwitchDownloaderCLI.Modes.Arguments 6 | { 7 | [Verb("chatdownload", HelpText = "Downloads the chat from a VOD or clip")] 8 | internal sealed class ChatDownloadArgs : IFileCollisionArgs, ITwitchDownloaderArgs 9 | { 10 | [Option('u', "id", Required = true, HelpText = "The ID or URL of the VOD or clip to download that chat of.")] 11 | public string Id { get; set; } 12 | 13 | [Option('o', "output", Required = true, HelpText = "Path to output file. File extension will be used to determine download type. Valid extensions are: .json, .html, and .txt.")] 14 | public string OutputFile { get; set; } 15 | 16 | [Option("compression", Default = ChatCompression.None, HelpText = "Compresses an output json chat file using a specified compression, usually resulting in 40-90% size reductions. Valid values are: None, Gzip.")] 17 | public ChatCompression Compression { get; set; } 18 | 19 | [Option('b', "beginning", HelpText = "Time to trim beginning. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")] 20 | public TimeDuration TrimBeginningTime { get; set; } 21 | 22 | [Option('e', "ending", HelpText = "Time to trim ending. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")] 23 | public TimeDuration TrimEndingTime { get; set; } 24 | 25 | [Option('E', "embed-images", Default = false, HelpText = "Embed first party emotes, badges, and cheermotes into the chat download for offline rendering.")] 26 | public bool EmbedData { get; set; } 27 | 28 | [Option("bttv", Default = true, HelpText = "Enable BTTV embedding in chat download. Requires -E / --embed-images!")] 29 | public bool? BttvEmotes { get; set; } 30 | 31 | [Option("ffz", Default = true, HelpText = "Enable FFZ embedding in chat download. Requires -E / --embed-images!")] 32 | public bool? FfzEmotes { get; set; } 33 | 34 | [Option("stv", Default = true, HelpText = "Enable 7TV embedding in chat download. Requires -E / --embed-images!")] 35 | public bool? StvEmotes { get; set; } 36 | 37 | [Option("timestamp-format", Default = TimestampFormat.Relative, HelpText = "Sets the timestamp format for .txt chat logs. Valid values are: Utc, UtcFull, Relative, and None")] 38 | public TimestampFormat TimeFormat { get; set; } 39 | 40 | [Option('t', "threads", Default = 4, HelpText = "Number of parallel download threads. Large values may result in IP rate limiting.")] 41 | public int DownloadThreads { get; set; } 42 | 43 | [Option("temp-path", Default = "", HelpText = "Path to temporary folder to use for cache.")] 44 | public string TempFolder { get; set; } 45 | 46 | // Interface args 47 | public OverwriteBehavior OverwriteBehavior { get; set; } 48 | public bool? ShowBanner { get; set; } 49 | public LogLevel LogLevel { get; set; } 50 | } 51 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/ClipDownloadArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | 4 | namespace TwitchDownloaderCLI.Modes.Arguments 5 | { 6 | [Verb("clipdownload", HelpText = "Downloads a clip from Twitch")] 7 | internal sealed class ClipDownloadArgs : IFileCollisionArgs, ITwitchDownloaderArgs 8 | { 9 | [Option('u', "id", Required = true, HelpText = "The ID or URL of the clip to download.")] 10 | public string Id { get; set; } 11 | 12 | [Option('o', "output", Required = true, HelpText = "Path to output file.")] 13 | public string OutputFile { get; set; } 14 | 15 | [Option('q', "quality", HelpText = "The quality the program will attempt to download.")] 16 | public string Quality { get; set; } 17 | 18 | [Option("bandwidth", Default = -1, HelpText = "The maximum bandwidth the clip downloader is allowed to use in kibibytes per second (KiB/s), or -1 for no maximum.")] 19 | public int ThrottleKib { get; set; } 20 | 21 | [Option("encode-metadata", Default = true, HelpText = "Uses FFmpeg to add metadata to the clip output file.")] 22 | public bool? EncodeMetadata { get; set; } 23 | 24 | [Option("ffmpeg-path", HelpText = "Path to FFmpeg executable.")] 25 | public string FfmpegPath { get; set; } 26 | 27 | [Option("temp-path", Default = "", HelpText = "Path to temporary caching folder.")] 28 | public string TempFolder { get; set; } 29 | 30 | // Interface args 31 | public OverwriteBehavior OverwriteBehavior { get; set; } 32 | public bool? ShowBanner { get; set; } 33 | public LogLevel LogLevel { get; set; } 34 | } 35 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/FfmpegArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | 4 | namespace TwitchDownloaderCLI.Modes.Arguments 5 | { 6 | [Verb("ffmpeg", HelpText = "Manage standalone ffmpeg")] 7 | internal sealed class FfmpegArgs : ITwitchDownloaderArgs 8 | { 9 | [Option('d', "download", Default = false, Required = false, HelpText = "Downloads FFmpeg as a standalone file.")] 10 | public bool DownloadFfmpeg { get; set; } 11 | 12 | // Interface args 13 | public bool? ShowBanner { get; set; } 14 | public LogLevel LogLevel { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/IFileCollisionArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | 4 | namespace TwitchDownloaderCLI.Modes.Arguments 5 | { 6 | internal interface IFileCollisionArgs 7 | { 8 | [Option("collision", Default = OverwriteBehavior.Prompt, HelpText = "Sets the handling of output file name collisions. Valid values are: Overwrite, Exit, Rename, Prompt.")] 9 | public OverwriteBehavior OverwriteBehavior { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/ITwitchDownloaderArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | 4 | namespace TwitchDownloaderCLI.Modes.Arguments 5 | { 6 | internal interface ITwitchDownloaderArgs 7 | { 8 | [Option("banner", Default = true, HelpText = "Displays a banner containing version and copyright information.")] 9 | public bool? ShowBanner { get; set; } 10 | 11 | [Option("log-level", Default = LogLevel.Status | LogLevel.Info | LogLevel.Warning | LogLevel.Error, HelpText = "Sets the log level flags. Applicable values are: None, Status, Verbose, Info, Warning, Error, Ffmpeg.")] 12 | public LogLevel LogLevel { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/InfoArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | 4 | namespace TwitchDownloaderCLI.Modes.Arguments 5 | { 6 | [Verb("info", HelpText = "Prints stream information about a VOD or clip to stdout")] 7 | internal sealed class InfoArgs : ITwitchDownloaderArgs 8 | { 9 | [Option('u', "id", Required = true, HelpText = "The ID or URL of the VOD or clip to print the stream info about.")] 10 | public string Id { get; set; } 11 | 12 | [Option('f', "format", Default = InfoPrintFormat.Table, HelpText = "The format in which the information should be printed. When using table format, use a terminal that supports ANSI escape sequences for best results. Valid values are: Raw, Table, and M3U/M3U8")] 13 | public InfoPrintFormat Format { get; set; } 14 | 15 | [Option("use-utf8", Default = true, HelpText = "Ensures UTF-8 encoding is used when writing results to standard output.")] 16 | public bool? UseUtf8 { get; set; } 17 | 18 | [Option("oauth", HelpText = "OAuth access token to access subscriber only VODs. DO NOT SHARE THIS WITH ANYONE.")] 19 | public string Oauth { get; set; } 20 | 21 | // Interface args 22 | public bool? ShowBanner { get; set; } 23 | public LogLevel LogLevel { get; set; } 24 | } 25 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/TsMergeArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | 4 | namespace TwitchDownloaderCLI.Modes.Arguments 5 | { 6 | [Verb("tsmerge", HelpText = "Concatenates multiple .ts/.tsv/.tsa/.m2t/.m2ts (MPEG Transport Stream) files into a single file")] 7 | internal sealed class TsMergeArgs : IFileCollisionArgs, ITwitchDownloaderArgs 8 | { 9 | [Option('i', "input", Required = true, HelpText = "Path a text file containing the absolute paths of the files to concatenate, separated by newlines. M3U/M3U8 is also supported.")] 10 | public string InputList { get; set; } 11 | 12 | [Option('o', "output", Required = true, HelpText = "Path to output file.")] 13 | public string OutputFile { get; set; } 14 | 15 | // Interface args 16 | public OverwriteBehavior OverwriteBehavior { get; set; } 17 | public bool? ShowBanner { get; set; } 18 | public LogLevel LogLevel { get; set; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/UpdateArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | 4 | namespace TwitchDownloaderCLI.Modes.Arguments 5 | { 6 | [Verb("update", HelpText = "Manages updating the CLI")] 7 | internal sealed class UpdateArgs : ITwitchDownloaderArgs 8 | { 9 | [Option('f', "force", Default = false, HelpText = "Bypasses the confirmation prompt.")] 10 | public bool ForceUpdate { get; set; } 11 | 12 | [Option('k', "keep-update", Default = false, HelpText = "Retain the downloaded update zip file instead of deleting it after the update is finished.")] 13 | public bool KeepArchive { get; set; } 14 | 15 | // Interface args 16 | public bool? ShowBanner { get; set; } 17 | public LogLevel LogLevel { get; set; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/Arguments/VideoDownloadArgs.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using TwitchDownloaderCLI.Models; 3 | using TwitchDownloaderCore.Models; 4 | 5 | namespace TwitchDownloaderCLI.Modes.Arguments 6 | { 7 | [Verb("videodownload", HelpText = "Downloads a stream VOD from Twitch")] 8 | internal sealed class VideoDownloadArgs : IFileCollisionArgs, ITwitchDownloaderArgs 9 | { 10 | [Option('u', "id", Required = true, HelpText = "The ID or URL of the VOD to download.")] 11 | public string Id { get; set; } 12 | 13 | [Option('o', "output", Required = true, HelpText = "Path to output file. File extension will be used to determine download type. Valid extensions are: .mp4 and .m4a.")] 14 | public string OutputFile { get; set; } 15 | 16 | [Option('q', "quality", HelpText = "The quality the program will attempt to download.")] 17 | public string Quality { get; set; } 18 | 19 | [Option('b', "beginning", HelpText = "Time to trim beginning. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")] 20 | public TimeDuration TrimBeginningTime { get; set; } 21 | 22 | [Option('e', "ending", HelpText = "Time to trim ending. Can be milliseconds (#ms), seconds (#s), minutes (#m), hours (#h), or time (##:##:##).")] 23 | public TimeDuration TrimEndingTime { get; set; } 24 | 25 | [Option('t', "threads", Default = 4, HelpText = "Number of parallel download threads. Large values may result in IP rate limiting.")] 26 | public int DownloadThreads { get; set; } 27 | 28 | [Option("bandwidth", Default = -1, HelpText = "The maximum bandwidth a thread will be allowed to use in kibibytes per second (KiB/s), or -1 for no maximum.")] 29 | public int ThrottleKib { get; set; } 30 | 31 | [Option("trim-mode", Default = VideoTrimMode.Exact, HelpText = "Sets the trim handling. Videos trimmed with exact trim may rarely experience video/audio stuttering within the first/last few seconds. Safe trimming is guaranteed to not stutter but may result in a slightly longer video. Valid values are: Safe, Exact")] 32 | public VideoTrimMode TrimMode { get; set; } 33 | 34 | [Option("oauth", HelpText = "OAuth access token to download subscriber only VODs. DO NOT SHARE THIS WITH ANYONE.")] 35 | public string Oauth { get; set; } 36 | 37 | [Option("ffmpeg-path", HelpText = "Path to FFmpeg executable.")] 38 | public string FfmpegPath { get; set; } 39 | 40 | [Option("temp-path", Default = "", HelpText = "Path to temporary caching folder.")] 41 | public string TempFolder { get; set; } 42 | 43 | // Interface args 44 | public OverwriteBehavior OverwriteBehavior { get; set; } 45 | public bool? ShowBanner { get; set; } 46 | public LogLevel LogLevel { get; set; } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/CacheHandler.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using TwitchDownloaderCLI.Models; 3 | using TwitchDownloaderCLI.Modes.Arguments; 4 | using TwitchDownloaderCLI.Tools; 5 | using TwitchDownloaderCore.Interfaces; 6 | using TwitchDownloaderCore.Services; 7 | 8 | namespace TwitchDownloaderCLI.Modes 9 | { 10 | internal static class CacheHandler 11 | { 12 | public static void ParseArgs(CacheArgs args) 13 | { 14 | using var progress = new CliTaskProgress(args.LogLevel); 15 | 16 | if (args.ForceClearCache) 17 | { 18 | ClearTempCache(progress); 19 | } 20 | else if (args.ClearCache) 21 | { 22 | PromptClearCache(progress); 23 | } 24 | 25 | // TODO: Add option to print out cache information (i.e. individual sub-directory size, maybe in table form?) 26 | // TODO: Add interactive cache delete mode (i.e. loop over each sub-directory with Yes/No delete prompts) 27 | // TODO: Allow the user to specify a cache folder so it can be managed with the aforementioned tools 28 | } 29 | 30 | private static void PromptClearCache(ITaskProgress progress) 31 | { 32 | var promptResult = UserPrompt.ShowYesNo("Are you sure you want to clear the cache? This should really only be done if the program isn't working correctly.", progress); 33 | if (promptResult is UserPromptResult.Yes) 34 | { 35 | ClearTempCache(progress); 36 | } 37 | } 38 | 39 | private static void ClearTempCache(ITaskProgress progress) 40 | { 41 | var baseDirectory = Path.GetTempPath(); 42 | var defaultCacheDirectory = CacheDirectoryService.GetCacheDirectory(baseDirectory); 43 | 44 | if (!Directory.Exists(defaultCacheDirectory)) 45 | { 46 | progress.LogInfo("No cache to clear."); 47 | return; 48 | } 49 | 50 | progress.LogInfo("Clearing cache..."); 51 | 52 | if (!CacheDirectoryService.ClearCacheDirectory(baseDirectory, out var exception)) 53 | { 54 | progress.LogError($"Failed to clear cache: {exception.Message}"); 55 | return; 56 | } 57 | 58 | progress.LogInfo("Cache cleared successfully."); 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/DownloadClip.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading; 4 | using TwitchDownloaderCLI.Modes.Arguments; 5 | using TwitchDownloaderCLI.Tools; 6 | using TwitchDownloaderCore; 7 | using TwitchDownloaderCore.Interfaces; 8 | using TwitchDownloaderCore.Options; 9 | using TwitchDownloaderCore.Tools; 10 | 11 | namespace TwitchDownloaderCLI.Modes 12 | { 13 | internal static class DownloadClip 14 | { 15 | internal static void Download(ClipDownloadArgs inputOptions) 16 | { 17 | using var progress = new CliTaskProgress(inputOptions.LogLevel); 18 | 19 | if (inputOptions.EncodeMetadata == true) 20 | { 21 | FfmpegHandler.DetectFfmpeg(inputOptions.FfmpegPath, progress); 22 | } 23 | 24 | var collisionHandler = new FileCollisionHandler(inputOptions, progress); 25 | var downloadOptions = GetDownloadOptions(inputOptions, collisionHandler, progress); 26 | 27 | var clipDownloader = new ClipDownloader(downloadOptions, progress); 28 | clipDownloader.DownloadAsync(new CancellationToken()).Wait(); 29 | } 30 | 31 | private static ClipDownloadOptions GetDownloadOptions(ClipDownloadArgs inputOptions, FileCollisionHandler collisionHandler, ITaskLogger logger) 32 | { 33 | if (inputOptions.Id is null) 34 | { 35 | logger.LogError("Clip ID/URL cannot be null!"); 36 | Environment.Exit(1); 37 | } 38 | 39 | var clipIdMatch = IdParse.MatchClipId(inputOptions.Id); 40 | if (clipIdMatch is not { Success: true }) 41 | { 42 | logger.LogError("Unable to parse Clip ID/URL."); 43 | Environment.Exit(1); 44 | } 45 | 46 | ClipDownloadOptions downloadOptions = new() 47 | { 48 | Id = clipIdMatch.Value, 49 | Filename = inputOptions.OutputFile, 50 | Quality = inputOptions.Quality, 51 | ThrottleKib = inputOptions.ThrottleKib, 52 | FfmpegPath = string.IsNullOrWhiteSpace(inputOptions.FfmpegPath) ? FfmpegHandler.FfmpegExecutableName : Path.GetFullPath(inputOptions.FfmpegPath), 53 | EncodeMetadata = inputOptions.EncodeMetadata!.Value, 54 | TempFolder = inputOptions.TempFolder, 55 | FileCollisionCallback = collisionHandler.HandleCollisionCallback, 56 | }; 57 | 58 | return downloadOptions; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Modes/MergeTs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using TwitchDownloaderCLI.Modes.Arguments; 3 | using TwitchDownloaderCLI.Tools; 4 | using TwitchDownloaderCore; 5 | using TwitchDownloaderCore.Options; 6 | 7 | namespace TwitchDownloaderCLI.Modes 8 | { 9 | internal static class MergeTs 10 | { 11 | internal static void Merge(TsMergeArgs inputOptions) 12 | { 13 | using var progress = new CliTaskProgress(inputOptions.LogLevel); 14 | 15 | progress.LogInfo("The TS merger is experimental and is subject to change without notice in future releases."); 16 | 17 | var collisionHandler = new FileCollisionHandler(inputOptions, progress); 18 | var mergeOptions = GetMergeOptions(inputOptions, collisionHandler); 19 | 20 | var tsMerger = new TsMerger(mergeOptions, progress); 21 | tsMerger.MergeAsync(new CancellationToken()).Wait(); 22 | } 23 | 24 | private static TsMergeOptions GetMergeOptions(TsMergeArgs inputOptions, FileCollisionHandler collisionHandler) 25 | { 26 | TsMergeOptions mergeOptions = new() 27 | { 28 | OutputFile = inputOptions.OutputFile, 29 | InputFile = inputOptions.InputList, 30 | FileCollisionCallback = collisionHandler.HandleCollisionCallback, 31 | }; 32 | 33 | return mergeOptions; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Properties/PublishProfiles/Linux.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | FileSystem 8 | Release 9 | Any CPU 10 | net6.0 11 | bin\Release\net6.0\publish\Linux 12 | linux-x64 13 | true 14 | True 15 | True 16 | true 17 | 18 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Properties/PublishProfiles/LinuxAlpine.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | x64 9 | bin\Release\net6.0\publish\LinuxAlpine 10 | FileSystem 11 | net6.0 12 | linux-musl-x64 13 | true 14 | True 15 | False 16 | True 17 | true 18 | 19 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Properties/PublishProfiles/LinuxArm.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | Any CPU 9 | bin\Release\net6.0\publish\LinuxArm 10 | FileSystem 11 | net6.0 12 | linux-arm 13 | true 14 | True 15 | True 16 | true 17 | 18 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Properties/PublishProfiles/LinuxArm64.pubxml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | FileSystem 8 | Release 9 | Any CPU 10 | net6.0 11 | bin\Release\net6.0\publish\LinuxArm64 12 | linux-arm64 13 | true 14 | True 15 | True 16 | true 17 | 18 | 19 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Properties/PublishProfiles/MacOS.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | Any CPU 9 | bin\Release\net6.0\publish\MacOS 10 | FileSystem 11 | net6.0 12 | osx-x64 13 | true 14 | True 15 | True 16 | true 17 | true 18 | 19 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Properties/PublishProfiles/MacOSArm64.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | Any CPU 9 | bin\Release\net6.0\publish\MacOSArm64 10 | FileSystem 11 | net6.0 12 | osx-arm64 13 | true 14 | True 15 | True 16 | true 17 | true 18 | 19 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Properties/PublishProfiles/Windows.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | x64 9 | bin\Release\net6.0\publish\Windows 10 | FileSystem 11 | net6.0 12 | win-x64 13 | true 14 | True 15 | False 16 | True 17 | true 18 | 19 | -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "TwitchDownloaderCLI": { 4 | "commandName": "Project", 5 | "commandLineArgs": "chatrender -i chat.json --badge-filter 255 -o chat.mp4" 6 | }, 7 | "WSL": { 8 | "commandName": "WSL2", 9 | "distributionName": "" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Tools/FileCollisionHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using TwitchDownloaderCLI.Models; 4 | using TwitchDownloaderCLI.Modes.Arguments; 5 | using TwitchDownloaderCore.Interfaces; 6 | using TwitchDownloaderCore.Services; 7 | 8 | namespace TwitchDownloaderCLI.Tools 9 | { 10 | internal class FileCollisionHandler 11 | { 12 | private readonly IFileCollisionArgs _collisionArgs; 13 | private readonly ITaskLogger _logger; 14 | 15 | public FileCollisionHandler(IFileCollisionArgs collisionArgs, ITaskLogger logger) 16 | { 17 | _collisionArgs = collisionArgs; 18 | _logger = logger; 19 | } 20 | 21 | public FileInfo HandleCollisionCallback(FileInfo fileInfo) 22 | { 23 | return _collisionArgs.OverwriteBehavior switch 24 | { 25 | OverwriteBehavior.Overwrite => fileInfo, 26 | OverwriteBehavior.Exit => Exit(fileInfo), 27 | OverwriteBehavior.Rename => FilenameService.GetNonCollidingName(fileInfo), 28 | OverwriteBehavior.Prompt => PromptUser(fileInfo), 29 | _ => throw new ArgumentOutOfRangeException(nameof(_collisionArgs.OverwriteBehavior), _collisionArgs.OverwriteBehavior, null) 30 | }; 31 | } 32 | 33 | private FileInfo Exit(FileInfo fileInfo) 34 | { 35 | _logger.LogInfo($"The file '{fileInfo.FullName}' already exists, exiting."); 36 | Environment.Exit(1); 37 | return null; 38 | } 39 | 40 | private FileInfo PromptUser(FileInfo fileInfo) 41 | { 42 | // Deliberate use of Console.WriteLine instead of logger. Do not change. 43 | Console.WriteLine($"The file '{fileInfo.FullName}' already exists."); 44 | 45 | while (true) 46 | { 47 | Console.Write("[O] Overwrite / [R] Rename / [E] Exit: "); 48 | 49 | var userInput = Console.ReadLine(); 50 | if (userInput is null) 51 | { 52 | Console.WriteLine(); 53 | _logger.LogError("Could not read user input. Please specify the desired collision behavior with the CLI argument and try again."); 54 | Environment.Exit(1); 55 | } 56 | 57 | switch (userInput.Trim().ToLower()) 58 | { 59 | case "o" or "overwrite": 60 | return fileInfo; 61 | case "e" or "exit": 62 | Environment.Exit(1); 63 | break; 64 | case "r" or "rename": 65 | return FilenameService.GetNonCollidingName(fileInfo); 66 | } 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Tools/PathUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace TwitchDownloaderCLI.Tools 5 | { 6 | internal static class PathUtils 7 | { 8 | // https://stackoverflow.com/a/3856090/12204538 9 | public static bool ExistsOnPATH(string fileName) 10 | { 11 | return GetFileOnPATH(fileName) != null; 12 | } 13 | 14 | /// The path to as specified in the PATH environment variable, or if was not found 15 | public static string GetFileOnPATH(string fileName) 16 | { 17 | if (File.Exists(fileName)) 18 | { 19 | return Path.GetFullPath(fileName); 20 | } 21 | 22 | var environmentPath = Environment.GetEnvironmentVariable("PATH")!; // environment variable is case sensitive on Linux 23 | foreach (var path in environmentPath.Split(Path.PathSeparator)) 24 | { 25 | var fullPath = Path.Combine(path, fileName!); 26 | if (File.Exists(fullPath)) 27 | { 28 | return fullPath; 29 | } 30 | } 31 | 32 | return null; 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/Tools/UserPrompt.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using TwitchDownloaderCLI.Models; 3 | using TwitchDownloaderCore.Interfaces; 4 | 5 | namespace TwitchDownloaderCLI.Tools 6 | { 7 | public static class UserPrompt 8 | { 9 | public static UserPromptResult ShowYesNo(string message, ITaskLogger logger = null) 10 | { 11 | Console.WriteLine(message); 12 | 13 | while (true) 14 | { 15 | Console.Write("[Y] Yes / [N] No: "); 16 | 17 | var userInput = Console.ReadLine(); 18 | if (userInput is null) 19 | { 20 | Console.WriteLine(); 21 | LogError("Could not read user input.", logger); 22 | return UserPromptResult.Unknown; 23 | } 24 | 25 | switch (userInput.Trim().ToLower()) 26 | { 27 | case "y" or "yes": 28 | return UserPromptResult.Yes; 29 | case "n" or "no": 30 | return UserPromptResult.No; 31 | } 32 | } 33 | } 34 | 35 | private static void LogError(string message, ITaskLogger logger = null) 36 | { 37 | if (logger is null) 38 | { 39 | Console.WriteLine($"[ERROR] - {message}"); 40 | return; 41 | } 42 | 43 | logger.LogError(message); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /TwitchDownloaderCLI/TwitchDownloaderCLI.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | 1.55.8 6 | Copyright © lay295 and contributors 7 | Download and render Twitch VODs, clips, and chats 8 | MIT 9 | AnyCPU;x64 10 | net6.0 11 | 10 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /TwitchDownloaderCore.Tests/ExtensionTests/ReadOnlySpanCountTests.cs: -------------------------------------------------------------------------------- 1 | using TwitchDownloaderCore.Extensions; 2 | 3 | namespace TwitchDownloaderCore.Tests.ExtensionTests 4 | { 5 | public class ReadOnlySpanCountTests 6 | { 7 | [Fact] 8 | public void ReturnsNegativeOneWhenNotPresent() 9 | { 10 | ReadOnlySpan str = "SORRY FOR THE TRAFFIC NaM"; 11 | const int EXPECTED = -1; 12 | 13 | var actual = str.Count('L'); 14 | 15 | Assert.Equal(EXPECTED, actual); 16 | } 17 | 18 | [Fact] 19 | public void ReturnsNegativeOneForEmptyString() 20 | { 21 | ReadOnlySpan str = ""; 22 | const int EXPECTED = -1; 23 | 24 | var actual = str.Count('L'); 25 | 26 | Assert.Equal(EXPECTED, actual); 27 | } 28 | 29 | [Theory] 30 | [InlineData('S', 1)] 31 | [InlineData('R', 4)] 32 | [InlineData('a', 1)] 33 | [InlineData('F', 3)] 34 | [InlineData('M', 1)] 35 | public void ReturnsCorrectCharacterCount(char character, int expectedCount) 36 | { 37 | ReadOnlySpan str = "SORRY FOR THE TRAFFIC NaM"; 38 | 39 | var actual = str.Count(character); 40 | 41 | Assert.Equal(expectedCount, actual); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore.Tests/ExtensionTests/StringBuilderExtensionTests.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using TwitchDownloaderCore.Extensions; 3 | 4 | namespace TwitchDownloaderCore.Tests.ExtensionTests 5 | { 6 | public class StringBuilderExtensionTests 7 | { 8 | [Theory] 9 | [InlineData("Foo", "o", "F")] 10 | [InlineData("Foo\r\n", "\r\n", "Foo")] 11 | [InlineData("oo", "o", "")] 12 | [InlineData("Foo", "L", "Foo")] 13 | [InlineData("Foo", "oL", "F")] 14 | public void CorrectlyTrimsCharacters(string baseString, string trimChars, string expectedResult) 15 | { 16 | var sb = new StringBuilder(baseString); 17 | 18 | sb.TrimEnd(trimChars); 19 | 20 | Assert.Equal(expectedResult, sb.ToString()); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore.Tests/ExtensionTests/StringReplaceAnyTests.cs: -------------------------------------------------------------------------------- 1 | using TwitchDownloaderCore.Extensions; 2 | 3 | namespace TwitchDownloaderCore.Tests.ExtensionTests 4 | { 5 | // ReSharper disable StringLiteralTypo 6 | public class StringReplaceAnyTests 7 | { 8 | [Fact] 9 | public void MatchesMultipleStringReplaceUses() 10 | { 11 | const string STRING = "SORRY FOR TRAFFIC NaM."; 12 | const string OLD_CHARS = "FRM"; 13 | const char NEW_CHAR = 'L'; 14 | 15 | var replaceResult1 = STRING.Replace(OLD_CHARS[0], NEW_CHAR); 16 | var replaceResult2 = replaceResult1.Replace(OLD_CHARS[1], NEW_CHAR); 17 | var replaceResult3 = replaceResult2.Replace(OLD_CHARS[2], NEW_CHAR); 18 | 19 | var replaceAnyResult = replaceResult2.ReplaceAny(OLD_CHARS, NEW_CHAR); 20 | 21 | Assert.Equal(replaceResult3, replaceAnyResult); 22 | } 23 | 24 | [Fact] 25 | public void CorrectlyReplacesAnyCharacter() 26 | { 27 | const string STRING = "SORRY FOR TRAFFIC NaM."; 28 | const string OLD_CHARS = "FRM"; 29 | const char NEW_CHAR = 'L'; 30 | const string EXPECTED = "SOLLY LOL TLALLIC NaL."; 31 | 32 | var result = STRING.ReplaceAny(OLD_CHARS, NEW_CHAR); 33 | 34 | Assert.Equal(EXPECTED, result); 35 | } 36 | 37 | [Fact] 38 | public void ReturnsOriginalString_WhenEmpty() 39 | { 40 | const string STRING = ""; 41 | const string OLD_CHARS = ""; 42 | const char NEW_CHAR = 'L'; 43 | 44 | var result = STRING.ReplaceAny(OLD_CHARS, NEW_CHAR); 45 | 46 | Assert.Same(STRING, result); 47 | } 48 | 49 | [Fact] 50 | public void ReturnsOriginalString_WhenOldCharsNotPresent() 51 | { 52 | const string STRING = "SORRY FOR TRAFFIC NaM."; 53 | const string OLD_CHARS = "PogU"; 54 | const char NEW_CHAR = 'L'; 55 | 56 | var result = STRING.ReplaceAny(OLD_CHARS, NEW_CHAR); 57 | 58 | Assert.Same(STRING, result); 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /TwitchDownloaderCore.Tests/ToolTests/UrlTimeCodeTests.cs: -------------------------------------------------------------------------------- 1 | using TwitchDownloaderCore.Tools; 2 | 3 | namespace TwitchDownloaderCore.Tests.ToolTests 4 | { 5 | public class UrlTimeCodeTests 6 | { 7 | [Theory] 8 | [InlineData("12s",0, 0, 0, 12)] 9 | [InlineData("13m12s", 0, 0, 13 ,12)] 10 | [InlineData("14h13m12s", 0, 14, 13, 12)] 11 | [InlineData("15d14h13m12s", 15, 14, 13, 12)] 12 | public void ParsesTimeCodeCorrectly(string timeCode, int days, int hours, int minutes, int seconds) 13 | { 14 | var result = UrlTimeCode.Parse(timeCode); 15 | 16 | Assert.Equal(days, result.Days); 17 | Assert.Equal(hours, result.Hours); 18 | Assert.Equal(minutes, result.Minutes); 19 | Assert.Equal(seconds, result.Seconds); 20 | } 21 | 22 | [Fact] 23 | public void ReturnsZeroForInvalidTimeCode() 24 | { 25 | const string INVALID_TIME_CODE = "123abc"; 26 | var expected = TimeSpan.Zero; 27 | 28 | var result = UrlTimeCode.Parse(INVALID_TIME_CODE); 29 | 30 | Assert.Equal(expected, result); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore.Tests/TwitchDownloaderCore.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 10 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/Chat/ChatText.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using TwitchDownloaderCore.Models; 5 | using TwitchDownloaderCore.Tools; 6 | using TwitchDownloaderCore.TwitchObjects; 7 | 8 | namespace TwitchDownloaderCore.Chat 9 | { 10 | public static class ChatText 11 | { 12 | /// 13 | /// Serializes a chat plain text file. 14 | /// 15 | public static async Task SerializeAsync(Stream outputStream, ChatRoot chatRoot, TimestampFormat timeFormat) 16 | { 17 | await using var sw = new StreamWriter(outputStream, leaveOpen: true); 18 | foreach (var comment in chatRoot.comments) 19 | { 20 | var username = comment.commenter.display_name; 21 | var message = comment.message.body; 22 | if (timeFormat == TimestampFormat.Utc) 23 | { 24 | var time = comment.created_at; 25 | await sw.WriteLineAsync($"[{time:yyyy'-'MM'-'dd HH':'mm':'ss 'UTC'}] {username}: {message}"); 26 | } 27 | else if (timeFormat == TimestampFormat.UtcFull) 28 | { 29 | var time = comment.created_at; 30 | await sw.WriteLineAsync($"[{time:yyyy'-'MM'-'dd HH':'mm':'ss.fff 'UTC'}] {username}: {message}"); 31 | } 32 | else if (timeFormat == TimestampFormat.Relative) 33 | { 34 | var time = TimeSpan.FromSeconds(comment.content_offset_seconds); 35 | if (time.Ticks < 24 * TimeSpan.TicksPerHour) 36 | { 37 | await sw.WriteLineAsync(@$"[{time:h\:mm\:ss}] {username}: {message}"); 38 | } 39 | else 40 | { 41 | await sw.WriteLineAsync(string.Create(TimeSpanHFormat.ReusableInstance, @$"[{time:H\:mm\:ss}] {username}: {message}")); 42 | } 43 | } 44 | else if (timeFormat == TimestampFormat.None) 45 | { 46 | await sw.WriteLineAsync($"{username}: {message}"); 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/Chat/EmojiVendor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using TwitchDownloaderCore.Properties; 4 | 5 | namespace TwitchDownloaderCore.Chat 6 | { 7 | public enum EmojiVendor 8 | { 9 | None, 10 | TwitterTwemoji, 11 | GoogleNotoColor 12 | } 13 | 14 | public static class EmojiVendorExtensions 15 | { 16 | private const string NOT_SUPPORTED_MESSAGE = "The requested emoji vendor is not implemented"; 17 | 18 | public static string EmojiFolder(this EmojiVendor vendor) 19 | { 20 | return vendor switch 21 | { 22 | EmojiVendor.TwitterTwemoji => "twemoji", 23 | EmojiVendor.GoogleNotoColor => "noto-color", 24 | _ => throw new NotSupportedException(NOT_SUPPORTED_MESSAGE) 25 | }; 26 | } 27 | 28 | public static MemoryStream MemoryStream(this EmojiVendor vendor) 29 | { 30 | return vendor switch 31 | { 32 | EmojiVendor.TwitterTwemoji => new MemoryStream(Resources.twemoji_14_0_0), 33 | EmojiVendor.GoogleNotoColor => new MemoryStream(Resources.noto_emoji_2_038), 34 | _ => throw new NotSupportedException(NOT_SUPPORTED_MESSAGE) 35 | }; 36 | } 37 | 38 | public static int EmojiCount(this EmojiVendor vendor) 39 | { 40 | return vendor switch 41 | { 42 | EmojiVendor.TwitterTwemoji => 3680, 43 | EmojiVendor.GoogleNotoColor => 3689, 44 | _ => throw new NotSupportedException(NOT_SUPPORTED_MESSAGE) 45 | }; 46 | } 47 | 48 | public static string AssetPath(this EmojiVendor vendor) 49 | { 50 | return vendor switch 51 | { 52 | EmojiVendor.TwitterTwemoji => Path.Combine("twemoji-14.0.0", "assets", "72x72"), 53 | EmojiVendor.GoogleNotoColor => Path.Combine("noto-emoji-2.038", "png", "72"), 54 | _ => throw new NotSupportedException(NOT_SUPPORTED_MESSAGE) 55 | }; 56 | } 57 | 58 | public static char UnicodeSequenceSeparator(this EmojiVendor vendor) 59 | { 60 | return vendor switch 61 | { 62 | EmojiVendor.TwitterTwemoji => '-', 63 | EmojiVendor.GoogleNotoColor => '_', 64 | _ => throw new NotSupportedException(NOT_SUPPORTED_MESSAGE) 65 | }; 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/BufferExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Text; 4 | using SkiaSharp; 5 | using Buffer = HarfBuzzSharp.Buffer; 6 | 7 | namespace TwitchDownloaderCore.Extensions 8 | { 9 | public static class BufferExtensions 10 | { 11 | public static void Add(this Buffer buffer, ReadOnlySpan text, SKTextEncoding textEncoding) 12 | { 13 | switch (textEncoding) 14 | { 15 | // Encoding.GetBytes(ReadOnlySpan, Span) internally allocates arrays, so we may as well use ArrayPools to reduce the GC footprint 16 | case SKTextEncoding.Utf8: 17 | { 18 | var byteCount = Encoding.UTF8.GetByteCount(text); 19 | var encodedBytes = ArrayPool.Shared.Rent(byteCount); 20 | 21 | var textChars = ArrayPool.Shared.Rent(text.Length); 22 | text.CopyTo(textChars); 23 | 24 | Encoding.UTF8.GetBytes(textChars, 0, text.Length, encodedBytes, 0); 25 | buffer.AddUtf8(encodedBytes.AsSpan(0, byteCount)); 26 | 27 | ArrayPool.Shared.Return(encodedBytes); 28 | ArrayPool.Shared.Return(textChars); 29 | break; 30 | } 31 | case SKTextEncoding.Utf16: 32 | buffer.AddUtf16(text); 33 | break; 34 | case SKTextEncoding.Utf32: 35 | { 36 | var byteCount = Encoding.UTF32.GetByteCount(text); 37 | var encodedBytes = ArrayPool.Shared.Rent(byteCount); 38 | 39 | var textChars = ArrayPool.Shared.Rent(text.Length); 40 | text.CopyTo(textChars); 41 | 42 | Encoding.UTF32.GetBytes(textChars, 0, text.Length, encodedBytes, 0); 43 | buffer.AddUtf32(encodedBytes.AsSpan(0, byteCount)); 44 | 45 | ArrayPool.Shared.Return(encodedBytes); 46 | ArrayPool.Shared.Return(textChars); 47 | break; 48 | } 49 | default: 50 | throw new NotSupportedException("TextEncoding of type GlyphId is not supported."); 51 | } 52 | 53 | buffer.GuessSegmentProperties(); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/JsonElementExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json; 3 | 4 | namespace TwitchDownloaderCore.Extensions 5 | { 6 | public static class JsonElementExtensions 7 | { 8 | public static List DeserializeFirstAndLastFromList(this JsonElement arrayElement, JsonSerializerOptions options = null) 9 | { 10 | // It's not the prettiest, but for arrays with thousands of objects it can save whole seconds and prevent tons of fragmented memory 11 | var list = new List(2); 12 | JsonElement lastElement = default; 13 | foreach (var element in arrayElement.EnumerateArray()) 14 | { 15 | if (list.Count == 0) 16 | { 17 | list.Add(element.Deserialize(options: options)); 18 | continue; 19 | } 20 | 21 | lastElement = element; 22 | } 23 | 24 | if (lastElement.ValueKind != JsonValueKind.Undefined) 25 | { 26 | list.Add(lastElement.Deserialize(options: options)); 27 | } 28 | 29 | return list; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/LinqExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace TwitchDownloaderCore.Extensions 6 | { 7 | public static class LinqExtensions 8 | { 9 | public static IEnumerable WhereOnlyIf(this IEnumerable enumerable, Func predicate, bool shouldFilter) 10 | { 11 | if (shouldFilter) 12 | { 13 | return enumerable.Where(predicate); 14 | } 15 | 16 | return enumerable; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/RandomExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.Extensions 4 | { 5 | public static class RandomExtensions 6 | { 7 | public static double NextDouble(this Random random, double min, double max) 8 | { 9 | return random.NextDouble() * (max - min) + min; 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/SKCanvasExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using SkiaSharp; 3 | 4 | namespace TwitchDownloaderCore.Extensions 5 | { 6 | // ReSharper disable once InconsistentNaming 7 | public static class SKCanvasExtensions 8 | { 9 | public static void DrawText(this SKCanvas canvas, ReadOnlySpan text, float x, float y, SKPaint paint) 10 | { 11 | if (paint.TextAlign != SKTextAlign.Left) 12 | { 13 | var num = paint.MeasureText(text); 14 | if (paint.TextAlign == SKTextAlign.Center) 15 | num *= 0.5f; 16 | x -= num; 17 | } 18 | 19 | using var text1 = SKTextBlob.Create(text, paint.AsFont()); 20 | if (text1 == null) 21 | return; 22 | 23 | canvas.DrawText(text1, x, y, paint); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/SKColorExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Numerics; 3 | using SkiaSharp; 4 | 5 | namespace TwitchDownloaderCore.Extensions 6 | { 7 | // ReSharper disable once InconsistentNaming 8 | public static class SKColorExtensions 9 | { 10 | public static SKColor Lerp(this SKColor from, SKColor to, float factor) 11 | { 12 | var result = Vector4.Lerp(ToVector4(from), ToVector4(to), factor); 13 | return FromVector4(result); 14 | 15 | static Vector4 ToVector4(SKColor color) 16 | { 17 | var colorF = color.ToSKColorF(); 18 | return new Vector4(colorF.Red, colorF.Green, colorF.Blue, colorF.Alpha); 19 | } 20 | 21 | static SKColor FromVector4(Vector4 color) 22 | { 23 | var colorF = new SKColorF(color.X, color.Y, color.Z, color.W); 24 | return colorF.ToSKColor(); 25 | } 26 | } 27 | 28 | // ReSharper disable once InconsistentNaming 29 | public static SKColorF ToSKColorF(this SKColor color) 30 | { 31 | return new SKColorF((float)color.Red / byte.MaxValue, (float)color.Green / byte.MaxValue, (float)color.Blue / byte.MaxValue, (float)color.Alpha / byte.MaxValue); 32 | } 33 | 34 | // ReSharper disable once InconsistentNaming 35 | public static SKColor ToSKColor(this SKColorF color) 36 | { 37 | return new SKColor((byte)(color.Red * byte.MaxValue), (byte)(color.Green * byte.MaxValue), (byte)(color.Blue * byte.MaxValue), (byte)(color.Alpha * byte.MaxValue)); 38 | } 39 | 40 | // https://www.w3.org/TR/WCAG21/#dfn-relative-luminance 41 | public static double RelativeLuminance(this SKColor color) 42 | { 43 | var colorF = color.ToSKColorF(); 44 | return 0.2126 * ConvertColor(colorF.Red) + 0.7152 * ConvertColor(colorF.Green) + 0.0722 * ConvertColor(colorF.Blue); 45 | 46 | static double ConvertColor(float v) 47 | { 48 | return v <= 0.04045 49 | ? v / 12.92 50 | : Math.Pow((v + 0.055) / 1.055, 2.4); 51 | } 52 | } 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/SKPaintExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using SkiaSharp; 4 | using SkiaSharp.HarfBuzz; 5 | 6 | namespace TwitchDownloaderCore.Extensions 7 | { 8 | // ReSharper disable once InconsistentNaming 9 | public static class SKPaintExtensions 10 | { 11 | private static readonly MethodInfo GetFontMethodInfo = typeof(SKPaint).GetMethod("GetFont", BindingFlags.NonPublic | BindingFlags.Instance); 12 | private static readonly Func GetFontDelegate = (Func)Delegate.CreateDelegate(typeof(Func), GetFontMethodInfo); 13 | 14 | /// A reference to the held internally by the . 15 | /// The returned should NOT be disposed of. 16 | public static SKFont AsFont(this SKPaint paint) 17 | { 18 | return GetFontDelegate.Invoke(paint); 19 | } 20 | 21 | // Heavily modified from SkiaSharp.HarfBuzz.CanvasExtensions.DrawShapedText 22 | public static SKPath GetShapedTextPath(this SKPaint paint, ReadOnlySpan text, float x, float y) 23 | { 24 | var returnPath = new SKPath(); 25 | 26 | if (text.IsEmpty || text.IsWhiteSpace()) 27 | return returnPath; 28 | 29 | using var shaper = new SKShaper(paint.Typeface); 30 | using var buffer = new HarfBuzzSharp.Buffer(); 31 | buffer.Add(text, paint.TextEncoding); 32 | var result = shaper.Shape(buffer, x, y, paint); 33 | 34 | var glyphSpan = result.Codepoints.AsSpan(); 35 | var pointSpan = result.Points.AsSpan(); 36 | 37 | var xOffset = 0.0f; 38 | if (paint.TextAlign != SKTextAlign.Left) 39 | { 40 | var width = result.Width; 41 | if (paint.TextAlign == SKTextAlign.Center) 42 | width *= 0.5f; 43 | xOffset -= width; 44 | } 45 | 46 | // We cannot dispose because it is a reference, not a clone. 47 | var font = paint.AsFont(); 48 | for (var i = 0; i < pointSpan.Length; i++) 49 | { 50 | using var glyphPath = font.GetGlyphPath((ushort)glyphSpan[i]); 51 | if (glyphPath.IsEmpty) 52 | continue; 53 | 54 | var point = pointSpan[i]; 55 | glyphPath.Transform(new SKMatrix( 56 | 1, 0, point.X + xOffset, 57 | 0, 1, point.Y, 58 | 0, 0, 1 59 | )); 60 | returnPath.AddPath(glyphPath); 61 | } 62 | 63 | return returnPath; 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/StreamExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.IO; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace TwitchDownloaderCore.Extensions 8 | { 9 | public record struct StreamCopyProgress(long SourceLength, long BytesCopied); 10 | 11 | public static class StreamExtensions 12 | { 13 | // The default size from Stream.GetCopyBufferSize() is 81,920. 14 | private const int STREAM_DEFAULT_BUFFER_LENGTH = 81_920; 15 | 16 | public static async Task ProgressCopyToAsync(this Stream source, Stream destination, long? sourceLength, IProgress progress = null, 17 | CancellationToken cancellationToken = default) 18 | { 19 | if (!sourceLength.HasValue || progress is null) 20 | { 21 | await source.CopyToAsync(destination, cancellationToken); 22 | return; 23 | } 24 | 25 | var rentedBuffer = ArrayPool.Shared.Rent(STREAM_DEFAULT_BUFFER_LENGTH); 26 | 27 | long totalBytesRead = 0; 28 | try 29 | { 30 | int bytesRead; 31 | while ((bytesRead = await source.ReadAsync(rentedBuffer, 0, rentedBuffer.Length, cancellationToken).ConfigureAwait(false)) != 0) 32 | { 33 | await destination.WriteAsync(rentedBuffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); 34 | 35 | totalBytesRead += bytesRead; 36 | progress.Report(new StreamCopyProgress(sourceLength.Value, totalBytesRead)); 37 | } 38 | } 39 | finally 40 | { 41 | ArrayPool.Shared.Return(rentedBuffer, true); 42 | } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/StringBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | 4 | namespace TwitchDownloaderCore.Extensions 5 | { 6 | public static class StringBuilderExtensions 7 | { 8 | public static StringBuilder TrimEnd(this StringBuilder sb, ReadOnlySpan trimChars) 9 | { 10 | var trimLength = 0; 11 | while (sb.Length - trimLength > 0 && trimChars.Contains(sb[^(trimLength + 1)])) 12 | { 13 | trimLength++; 14 | } 15 | 16 | return sb.Remove(sb.Length - trimLength, trimLength); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.Extensions 4 | { 5 | public static class StringExtensions 6 | { 7 | public static string ReplaceAny(this string str, ReadOnlySpan oldChars, char newChar) 8 | { 9 | if (string.IsNullOrEmpty(str)) 10 | { 11 | return str; 12 | } 13 | 14 | var index = str.AsSpan().IndexOfAny(oldChars); 15 | if (index == -1) 16 | { 17 | return str; 18 | } 19 | 20 | const ushort MAX_STACK_SIZE = 512; 21 | var span = str.Length <= MAX_STACK_SIZE 22 | ? stackalloc char[str.Length] 23 | : str.ToCharArray(); 24 | 25 | // Unfortunately this cannot be inlined with the previous statement because a ternary is required for the stackalloc to compile 26 | if (str.Length <= MAX_STACK_SIZE) 27 | str.CopyTo(span); 28 | 29 | var tempSpan = span; 30 | do 31 | { 32 | tempSpan[index] = newChar; 33 | tempSpan = tempSpan[(index + 1)..]; 34 | 35 | index = tempSpan.IndexOfAny(oldChars); 36 | if (index == -1) 37 | break; 38 | } while (true); 39 | 40 | return span.ToString(); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Extensions/VersionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.Extensions 4 | { 5 | public static class VersionExtensions 6 | { 7 | public static Version StripRevisionIfDefault(this Version version) => version.Revision < 1 ? new Version(version.Major, version.Minor, version.Build) : version; 8 | } 9 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Interfaces/ITaskLogger.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace TwitchDownloaderCore.Interfaces 4 | { 5 | public interface ITaskLogger 6 | { 7 | void LogVerbose(string logMessage); 8 | void LogVerbose(DefaultInterpolatedStringHandler logMessage); 9 | void LogInfo(string logMessage); 10 | void LogInfo(DefaultInterpolatedStringHandler logMessage); 11 | void LogWarning(string logMessage); 12 | void LogWarning(DefaultInterpolatedStringHandler logMessage); 13 | void LogError(string logMessage); 14 | void LogError(DefaultInterpolatedStringHandler logMessage); 15 | void LogFfmpeg(string logMessage); 16 | } 17 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Interfaces/ITaskProgress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.Interfaces 4 | { 5 | // TODO: Add StringSyntaxAttributes when .NET 7+ 6 | public interface ITaskProgress : ITaskLogger 7 | { 8 | void SetStatus(string status); 9 | void SetTemplateStatus(string status, int initialPercent); 10 | void SetTemplateStatus(string status, int initialPercent, TimeSpan initialTime1, TimeSpan initialTime2); 11 | void ReportProgress(int percent); 12 | void ReportProgress(int percent, TimeSpan time1, TimeSpan time2); 13 | } 14 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/ClipVideoQualities.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TwitchDownloaderCore.Extensions; 5 | using TwitchDownloaderCore.Models.Interfaces; 6 | using ClipQuality = TwitchDownloaderCore.TwitchObjects.Gql.ShareClipRenderStatusVideoQuality; 7 | 8 | namespace TwitchDownloaderCore.Models 9 | { 10 | public sealed class ClipVideoQualities : VideoQualities, IVideoQualities 11 | { 12 | public ClipVideoQualities(IReadOnlyList> qualities) 13 | { 14 | Qualities = qualities; 15 | } 16 | 17 | public override IVideoQuality GetQuality(string qualityString) 18 | { 19 | if (TryGetQuality(qualityString, out var quality1)) 20 | { 21 | return quality1; 22 | } 23 | 24 | foreach (var quality in Qualities) 25 | { 26 | var framerate = (int)Math.Round(quality.Framerate); 27 | var framerateString = qualityString!.EndsWith('p') && framerate == 30 28 | ? "" 29 | : framerate.ToString("F0"); 30 | 31 | if ($"{quality.Item.quality}p{framerateString}" == qualityString) 32 | { 33 | return quality; 34 | } 35 | } 36 | 37 | return null; 38 | } 39 | 40 | public override IVideoQuality BestQuality() 41 | { 42 | if (Qualities is null) 43 | { 44 | return null; 45 | } 46 | 47 | var bestQuality = Qualities.FirstOrDefault(x => x.IsSource); 48 | 49 | bestQuality ??= Qualities 50 | .WhereOnlyIf(x => x.Resolution.Width > x.Resolution.Height, Qualities.All(x => x.Resolution.HasWidth)) 51 | .MaxBy(x => x.Resolution.Height); 52 | 53 | bestQuality ??= Qualities.MaxBy(x => x.Resolution.Height); 54 | 55 | return bestQuality ?? Qualities.FirstOrDefault(); 56 | } 57 | 58 | public override IVideoQuality WorstQuality() 59 | { 60 | if (Qualities is null) 61 | { 62 | return null; 63 | } 64 | 65 | var worstQuality = Qualities 66 | .WhereOnlyIf(x => x.Resolution.Width > x.Resolution.Height, Qualities.All(x => x.Resolution.HasWidth)) 67 | .MinBy(x => x.Resolution.Height); 68 | 69 | worstQuality ??= Qualities.MinBy(x => x.Resolution.Height); 70 | 71 | return worstQuality ?? Qualities.LastOrDefault(); 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/ClipVideoQuality.cs: -------------------------------------------------------------------------------- 1 | using TwitchDownloaderCore.Models.Interfaces; 2 | using ClipQuality = TwitchDownloaderCore.TwitchObjects.Gql.ShareClipRenderStatusVideoQuality; 3 | 4 | namespace TwitchDownloaderCore.Models 5 | { 6 | public sealed record ClipVideoQuality : IVideoQuality 7 | { 8 | public ClipQuality Item { get; } 9 | 10 | public string Name { get; } 11 | 12 | public Resolution Resolution { get; } 13 | 14 | public decimal Framerate { get; } 15 | 16 | public bool IsSource { get; } 17 | 18 | public string Path { get; } 19 | 20 | public ClipVideoQuality(ClipQuality item, string name, Resolution resolution, bool isSource) 21 | { 22 | Item = item; 23 | Name = name; 24 | Resolution = resolution; 25 | Framerate = item.frameRate; 26 | IsSource = isSource; 27 | Path = item.sourceURL; 28 | } 29 | 30 | public override string ToString() => Name; 31 | } 32 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/Enums.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderCore.Models 2 | { 3 | // TODO: Add Bzip2 and possibly 7Zip support 4 | public enum ChatCompression 5 | { 6 | None, 7 | Gzip 8 | } 9 | 10 | public enum ChatFormat 11 | { 12 | Json, 13 | Text, 14 | Html 15 | } 16 | 17 | public enum TimestampFormat 18 | { 19 | Utc, 20 | Relative, 21 | None, 22 | UtcFull 23 | } 24 | 25 | public enum VideoTrimMode 26 | { 27 | Safe, 28 | Exact 29 | } 30 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/FfmpegProcess.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace TwitchDownloaderCore.Models 4 | { 5 | public sealed class FfmpegProcess : Process 6 | { 7 | public string SavePath { get; init; } 8 | } 9 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/Interfaces/IVideoQualities.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace TwitchDownloaderCore.Models.Interfaces 5 | { 6 | public interface IVideoQualities : IEnumerable> 7 | { 8 | public IReadOnlyList> Qualities { get; } 9 | 10 | [return: MaybeNull] 11 | public IVideoQuality GetQuality([AllowNull] string qualityString); 12 | 13 | [return: MaybeNull] 14 | public IVideoQuality BestQuality(); 15 | 16 | [return: MaybeNull] 17 | public IVideoQuality WorstQuality(); 18 | } 19 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/Interfaces/IVideoQuality.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderCore.Models.Interfaces 2 | { 3 | public interface IVideoQuality 4 | { 5 | public TItem Item { get; } 6 | 7 | public string Name { get; } 8 | 9 | public Resolution Resolution { get; } 10 | 11 | public decimal Framerate { get; } 12 | 13 | public bool IsSource { get; } 14 | 15 | public string Path { get; } 16 | 17 | public string ToString() => Name; 18 | } 19 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/M3U8VideoQuality.cs: -------------------------------------------------------------------------------- 1 | using TwitchDownloaderCore.Extensions; 2 | using TwitchDownloaderCore.Models.Interfaces; 3 | 4 | namespace TwitchDownloaderCore.Models 5 | { 6 | public sealed record M3U8VideoQuality : IVideoQuality 7 | { 8 | public M3U8.Stream Item { get; } 9 | 10 | public string Name { get; } 11 | 12 | public Resolution Resolution { get; } 13 | 14 | public decimal Framerate { get; } 15 | 16 | public bool IsSource { get; } 17 | 18 | public string Path { get; } 19 | 20 | internal M3U8VideoQuality(M3U8.Stream item, string name) 21 | { 22 | Item = item; 23 | Name = name; 24 | Resolution = item.StreamInfo.Resolution; 25 | Framerate = item.StreamInfo.Framerate; 26 | IsSource = item.IsSource(); 27 | Path = item.Path; 28 | } 29 | 30 | public override string ToString() => Name; 31 | } 32 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/Resolution.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderCore.Models 2 | { 3 | public readonly record struct Resolution(uint Width, uint Height) 4 | { 5 | public Resolution(uint height) : this(0, height) { } 6 | 7 | public bool HasWidth => Width > 0; 8 | 9 | public override string ToString() => $"{Width}x{Height}"; 10 | 11 | public static implicit operator Resolution((uint width, uint height) tuple) => new(tuple.width, tuple.height); 12 | 13 | public static implicit operator Resolution(M3U8.Stream.ExtStreamInfo.StreamResolution sr) => new(sr.Width, sr.Height); 14 | 15 | public static implicit operator M3U8.Stream.ExtStreamInfo.StreamResolution(Resolution res) => new(res.Width, res.Height); 16 | } 17 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Models/StubTaskProgress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.CompilerServices; 3 | using TwitchDownloaderCore.Interfaces; 4 | 5 | namespace TwitchDownloaderCore.Models 6 | { 7 | public class StubTaskProgress : ITaskProgress 8 | { 9 | public static readonly StubTaskProgress Instance = new(); 10 | 11 | private StubTaskProgress() { } 12 | 13 | public void LogVerbose(string logMessage) { } 14 | 15 | public void LogVerbose(DefaultInterpolatedStringHandler logMessage) { } 16 | 17 | public void LogInfo(string logMessage) { } 18 | 19 | public void LogInfo(DefaultInterpolatedStringHandler logMessage) { } 20 | 21 | public void LogWarning(string logMessage) { } 22 | 23 | public void LogWarning(DefaultInterpolatedStringHandler logMessage) { } 24 | 25 | public void LogError(string logMessage) { } 26 | 27 | public void LogError(DefaultInterpolatedStringHandler logMessage) { } 28 | 29 | public void LogFfmpeg(string logMessage) { } 30 | 31 | public void SetStatus(string status) { } 32 | 33 | public void SetTemplateStatus(string status, int initialPercent) { } 34 | 35 | public void SetTemplateStatus(string status, int initialPercent, TimeSpan initialTime1, TimeSpan initialTime2) { } 36 | 37 | public void ReportProgress(int percent) { } 38 | 39 | public void ReportProgress(int percent, TimeSpan time1, TimeSpan time2) { } 40 | } 41 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Options/ChatDownloadOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using TwitchDownloaderCore.Models; 4 | 5 | namespace TwitchDownloaderCore.Options 6 | { 7 | public class ChatDownloadOptions 8 | { 9 | public ChatFormat DownloadFormat { get; set; } = ChatFormat.Json; 10 | public string Id { get; set; } 11 | public string Filename { get; set; } 12 | public ChatCompression Compression { get; set; } = ChatCompression.None; 13 | public bool TrimBeginning { get; set; } 14 | public double TrimBeginningTime { get; set; } 15 | public bool TrimEnding { get; set; } 16 | public double TrimEndingTime { get; set; } 17 | public bool EmbedData { get; set; } 18 | public bool BttvEmotes { get; set; } 19 | public bool FfzEmotes { get; set; } 20 | public bool StvEmotes { get; set; } 21 | public int DownloadThreads { get; set; } = 1; 22 | public TimestampFormat TimeFormat { get; set; } 23 | public string FileExtension 24 | { 25 | get 26 | { 27 | return string.Concat( 28 | DownloadFormat switch 29 | { 30 | ChatFormat.Json => ".json", 31 | ChatFormat.Html => ".html", 32 | ChatFormat.Text => ".txt", 33 | _ => "" 34 | }, 35 | Compression switch 36 | { 37 | ChatCompression.None => "", 38 | ChatCompression.Gzip => ".gz", 39 | _ => "" 40 | } 41 | ); 42 | } 43 | } 44 | public string TempFolder { get; set; } 45 | public Func FileCollisionCallback { get; set; } = info => info; 46 | public bool DelayDownload { get; set; } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/Options/ChatUpdateOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using TwitchDownloaderCore.Models; 4 | 5 | namespace TwitchDownloaderCore.Options 6 | { 7 | public class ChatUpdateOptions 8 | { 9 | public string InputFile { get; set; } 10 | public string OutputFile { get; set; } 11 | public ChatCompression Compression { get; set; } = ChatCompression.None; 12 | public ChatFormat OutputFormat { get; set; } = ChatFormat.Json; 13 | public bool EmbedMissing { get; set; } 14 | public bool ReplaceEmbeds { get; set; } 15 | public bool TrimBeginning { get; set; } 16 | public double TrimBeginningTime { get; set; } 17 | public bool TrimEnding { get; set; } 18 | public double TrimEndingTime { get; set; } 19 | public bool BttvEmotes { get; set; } 20 | public bool FfzEmotes { get; set; } 21 | public bool StvEmotes { get; set; } 22 | public TimestampFormat TextTimestampFormat { get; set; } 23 | public string FileExtension 24 | { 25 | get 26 | { 27 | return string.Concat( 28 | OutputFormat switch 29 | { 30 | ChatFormat.Json => ".json", 31 | ChatFormat.Html => ".html", 32 | ChatFormat.Text => ".txt", 33 | _ => "" 34 | }, 35 | Compression switch 36 | { 37 | ChatCompression.None => "", 38 | ChatCompression.Gzip => ".gz", 39 | _ => "" 40 | } 41 | ); 42 | } 43 | } 44 | public string TempFolder { get; set; } 45 | public Func FileCollisionCallback { get; set; } = info => info; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/Options/ClipDownloadOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace TwitchDownloaderCore.Options 5 | { 6 | public class ClipDownloadOptions 7 | { 8 | public string Id { get; set; } 9 | public string Quality { get; set; } 10 | public string Filename { get; set; } 11 | public int ThrottleKib { get; set; } 12 | public string TempFolder { get; set; } 13 | public bool EncodeMetadata { get; set; } 14 | public string FfmpegPath { get; set; } 15 | public Func FileCollisionCallback { get; set; } = info => info; 16 | } 17 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Options/TsMergeOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace TwitchDownloaderCore.Options 5 | { 6 | public class TsMergeOptions 7 | { 8 | public string OutputFile { get; set; } 9 | public string InputFile { get; set; } 10 | public Func FileCollisionCallback { get; set; } = info => info; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/Options/VideoDownloadOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using TwitchDownloaderCore.Models; 4 | 5 | namespace TwitchDownloaderCore.Options 6 | { 7 | public class VideoDownloadOptions 8 | { 9 | public long Id { get; set; } 10 | public string Quality { get; set; } 11 | public string Filename { get; set; } 12 | public bool TrimBeginning { get; set; } 13 | public TimeSpan TrimBeginningTime { get; set; } 14 | public bool TrimEnding { get; set; } 15 | public TimeSpan TrimEndingTime { get; set; } 16 | public int DownloadThreads { get; set; } 17 | public int ThrottleKib { get; set; } 18 | public string Oauth { get; set; } 19 | public string FfmpegPath { get; set; } 20 | public string TempFolder { get; set; } 21 | public Func CacheCleanerCallback { get; set; } 22 | public Func FileCollisionCallback { get; set; } = info => info; 23 | public VideoTrimMode TrimMode { get; set; } 24 | public bool DelayDownload { get; set; } 25 | } 26 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Properties/PublishProfiles/FolderProfile.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | Any CPU 9 | bin\Release\net6.0\publish\ 10 | FileSystem 11 | net6.0 12 | 13 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/Resources/TD-License: -------------------------------------------------------------------------------- 1 | Definitions 2 | 3 | - "TwitchDownloader" 4 | Any of the TwitchDownloaderCLI, TwitchDownloaderCore, and TwitchDownloaderWPF 5 | libraries. 6 | 7 | - "You" (or "Your") 8 | An individual or Legal Entity exercising permissions granted by this License. 9 | 10 | Notice 11 | 12 | This notice MUST be included UNMODIFIED with all copies, modifications, and 13 | redistributions of the TwitchDownloader libraries unless explicit written 14 | permission from a maintainer is given. 15 | 16 | The TwitchDownloader libraries are provided free of charge under the MIT 17 | license. As per this license, you are permitted to utilize, modify, 18 | and redistribute the TwitchDownloader libraries in any form under the 19 | conditions: 20 | - This notice is included with any source files; and 21 | - This notice is included with any compiled binaries; and 22 | - The third party licenses file is updated to reflect to any modifications 23 | made. 24 | 25 | TwitchDownloader utilizes several third party libraries and resources provided 26 | under various licenses. The full list of third party components and licenses 27 | can be found in the file THIRD-PARTY-LICENSES.txt. 28 | 29 | License 30 | 31 | The MIT License 32 | 33 | Copyright (c) lay295 34 | 35 | Permission is hereby granted, free of charge, to any person obtaining a copy 36 | of this software and associated documentation files (the "Software"), to deal 37 | in the Software without restriction, including without limitation the rights 38 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 39 | copies of the Software, and to permit persons to whom the Software is 40 | furnished to do so, subject to the following conditions: 41 | 42 | The above copyright notice and this permission notice shall be included in all 43 | copies or substantial portions of the Software. 44 | 45 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 46 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 47 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 48 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 49 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 50 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 51 | SOFTWARE. -------------------------------------------------------------------------------- /TwitchDownloaderCore/Resources/interbold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderCore/Resources/interbold.otf -------------------------------------------------------------------------------- /TwitchDownloaderCore/Resources/interregular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderCore/Resources/interregular.otf -------------------------------------------------------------------------------- /TwitchDownloaderCore/Resources/noto-emoji-2.038.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderCore/Resources/noto-emoji-2.038.zip -------------------------------------------------------------------------------- /TwitchDownloaderCore/Resources/twemoji-14.0.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderCore/Resources/twemoji-14.0.0.zip -------------------------------------------------------------------------------- /TwitchDownloaderCore/Services/CacheDirectoryService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.IO; 4 | 5 | namespace TwitchDownloaderCore.Services 6 | { 7 | public static class CacheDirectoryService 8 | { 9 | private const string CACHE_DIRECTORY_SUFFIX = "TwitchDownloader"; 10 | 11 | public static string GetCacheDirectory([AllowNull] string baseDirectory) 12 | { 13 | if (string.IsNullOrWhiteSpace(baseDirectory)) 14 | baseDirectory = Path.GetTempPath(); 15 | 16 | baseDirectory = Path.GetFullPath(baseDirectory); 17 | 18 | if (new DirectoryInfo(baseDirectory).Name == CACHE_DIRECTORY_SUFFIX) 19 | { 20 | return baseDirectory; 21 | } 22 | 23 | return Path.Combine(baseDirectory, CACHE_DIRECTORY_SUFFIX); 24 | } 25 | 26 | public static bool ClearCacheDirectory([AllowNull] string baseDirectory, out Exception exception) 27 | { 28 | var cacheDirectory = GetCacheDirectory(baseDirectory); 29 | if (!Directory.Exists(cacheDirectory)) 30 | { 31 | exception = null; 32 | return true; 33 | } 34 | 35 | try 36 | { 37 | Directory.Delete(cacheDirectory, true); 38 | exception = null; 39 | return true; 40 | } 41 | catch (Exception ex) 42 | { 43 | exception = ex; 44 | return false; 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/ClipQualityComparer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TwitchDownloaderCore.Tools 5 | { 6 | public class ClipQualityComparer : IComparer 7 | { 8 | public int Compare(TwitchObjects.Gql.ClipVideoQuality x, TwitchObjects.Gql.ClipVideoQuality y) 9 | { 10 | if (x is null) 11 | { 12 | if (y is null) return 0; 13 | return -1; 14 | } 15 | 16 | if (y is null) return 1; 17 | 18 | if (int.TryParse(x.quality, out var xQuality) | int.TryParse(y.quality, out var yQuality)) 19 | { 20 | if (xQuality < yQuality) return 1; 21 | if (xQuality > yQuality) return -1; 22 | 23 | if (x.frameRate < y.frameRate) return 1; 24 | if (x.frameRate > y.frameRate) return -1; 25 | return 0; 26 | } 27 | 28 | return Math.Clamp(string.Compare(x.quality, y.quality, StringComparison.Ordinal), -1, 1) * -1; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/ClipVideoQualityComparer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using TwitchDownloaderCore.TwitchObjects.Gql; 4 | 5 | namespace TwitchDownloaderCore.Tools 6 | { 7 | public class ClipVideoQualityComparer : IComparer 8 | { 9 | public int Compare(ShareClipRenderStatusVideoQuality x, ShareClipRenderStatusVideoQuality y) 10 | { 11 | if (x is null) 12 | { 13 | if (y is null) return 0; 14 | return -1; 15 | } 16 | 17 | if (y is null) return 1; 18 | 19 | if (int.TryParse(x.quality, out var xQuality) | int.TryParse(y.quality, out var yQuality)) 20 | { 21 | if (xQuality < yQuality) return 1; 22 | if (xQuality > yQuality) return -1; 23 | 24 | if (x.frameRate < y.frameRate) return 1; 25 | if (x.frameRate > y.frameRate) return -1; 26 | return 0; 27 | } 28 | 29 | return Math.Clamp(string.Compare(x.quality, y.quality, StringComparison.Ordinal), -1, 1) * -1; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/CommentIdEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using TwitchDownloaderCore.TwitchObjects; 3 | 4 | namespace TwitchDownloaderCore.Tools 5 | { 6 | public class CommentIdEqualityComparer : IEqualityComparer 7 | { 8 | public bool Equals(Comment x, Comment y) 9 | { 10 | if (x is null) return y is null; 11 | if (y is null) return false; 12 | 13 | return x._id.Equals(y._id); 14 | } 15 | 16 | public int GetHashCode(Comment obj) => obj._id.GetHashCode(); 17 | } 18 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/CommentOffsetComparer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using TwitchDownloaderCore.TwitchObjects; 3 | 4 | namespace TwitchDownloaderCore.Tools 5 | { 6 | internal class CommentOffsetComparer : IComparer 7 | { 8 | // Modified from double.CompareTo 9 | // ReSharper disable once CompareOfFloatsByEqualityOperator 10 | public int Compare(Comment x, Comment y) 11 | { 12 | if (x is null) 13 | { 14 | if (y is null) return 0; 15 | return -1; 16 | } 17 | 18 | if (y is null) return 1; 19 | 20 | // In the off chance that it causes problems with old chats, we will first compare offsets before comparing creation dates. 21 | var xOffset = x.content_offset_seconds; 22 | var yOffset = y.content_offset_seconds; 23 | 24 | if (xOffset < yOffset) return -1; 25 | if (xOffset > yOffset) return 1; 26 | if (xOffset == yOffset) 27 | { 28 | // Offset seconds are equal, compare the creation dates 29 | var xCreatedAt = x.created_at; 30 | var yCreatedAt = y.created_at; 31 | 32 | if (xCreatedAt < yCreatedAt) return -1; 33 | if (xCreatedAt > yCreatedAt) return 1; 34 | if (xCreatedAt == yCreatedAt) return 1; // Returning 0 would result in y being discarded by the sorter 35 | } 36 | 37 | // At least one of the values is NaN. 38 | if (double.IsNaN(xOffset)) 39 | return double.IsNaN(yOffset) ? 0 : -1; 40 | 41 | return 1; 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/CoreLicensor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Security.Cryptography; 4 | 5 | namespace TwitchDownloaderCore.Tools 6 | { 7 | // This file should not be modified without explicit permission 8 | public static class CoreLicensor 9 | { 10 | public static void EnsureFilesExist(string baseDir) 11 | { 12 | if (string.IsNullOrEmpty(baseDir)) 13 | { 14 | baseDir = Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory; 15 | } 16 | 17 | using (var ms = new MemoryStream(Properties.Resources.copyright)) 18 | { 19 | var filePath = Path.Combine(baseDir, "COPYRIGHT.txt"); 20 | TryCopyIfDifferent(ms, filePath); 21 | } 22 | 23 | using (var ms = new MemoryStream(Properties.Resources.third_party_licenses)) 24 | { 25 | var filePath = Path.Combine(baseDir, "THIRD-PARTY-LICENSES.txt"); 26 | TryCopyIfDifferent(ms, filePath); 27 | } 28 | } 29 | 30 | private static void TryCopyIfDifferent(Stream resourceStream, string filePath) 31 | { 32 | try 33 | { 34 | using var sha256 = SHA256.Create(); 35 | 36 | var resourceHash = sha256.ComputeHash(resourceStream); 37 | 38 | byte[] fileHash; 39 | using (var fs = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read)) 40 | { 41 | fileHash = sha256.ComputeHash(fs); 42 | } 43 | 44 | if (!resourceHash.AsSpan().SequenceEqual(fileHash)) 45 | { 46 | resourceStream.Seek(0, SeekOrigin.Begin); 47 | using var fs = File.Create(filePath); 48 | resourceStream.CopyTo(fs); 49 | } 50 | } 51 | catch (IOException) { } 52 | catch (UnauthorizedAccessException) { } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/DriveHelper.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using TwitchDownloaderCore.Interfaces; 5 | 6 | namespace TwitchDownloaderCore.Tools 7 | { 8 | public static class DriveHelper 9 | { 10 | public static DriveInfo GetOutputDrive(string outputPath) 11 | { 12 | var outputDrive = DriveInfo.GetDrives()[0]; 13 | 14 | foreach (var drive in DriveInfo.GetDrives()) 15 | { 16 | if (outputPath.StartsWith(drive.Name)) 17 | { 18 | // In Linux, the root drive is '/' while mounted drives are located in '/mnt/' or '/run/media/' 19 | // So we need to do a length check to not misinterpret a mounted drive as the root drive 20 | if (drive.Name.Length < outputDrive.Name.Length) 21 | { 22 | continue; 23 | } 24 | 25 | outputDrive = drive; 26 | } 27 | } 28 | 29 | return outputDrive; 30 | } 31 | 32 | public static async Task WaitForDrive(DriveInfo drive, ITaskLogger logger, CancellationToken cancellationToken) 33 | { 34 | var driveNotReadyCount = 0; 35 | while (!drive.IsReady) 36 | { 37 | logger.LogInfo($"Waiting for output drive ({(driveNotReadyCount + 1) / 2f:F1}s)"); 38 | await Task.Delay(500, cancellationToken); 39 | 40 | if (++driveNotReadyCount >= 20) 41 | { 42 | throw new DriveNotFoundException("The output drive disconnected for 10 or more consecutive seconds."); 43 | } 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/FfmpegConcatList.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | using System.IO; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using TwitchDownloaderCore.Models; 7 | 8 | namespace TwitchDownloaderCore.Tools 9 | { 10 | // https://www.ffmpeg.org/ffmpeg-formats.html#concat-1 11 | public static class FfmpegConcatList 12 | { 13 | private const string LINE_FEED = "\u000A"; 14 | 15 | public static async Task SerializeAsync(string filePath, IEnumerable playlist, StreamIds streamIds, CancellationToken cancellationToken = default) 16 | { 17 | await using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read); 18 | await using var sw = new StreamWriter(fs) { NewLine = LINE_FEED }; 19 | 20 | await sw.WriteLineAsync("ffconcat version 1.0"); 21 | 22 | foreach (var stream in playlist) 23 | { 24 | cancellationToken.ThrowIfCancellationRequested(); 25 | 26 | await sw.WriteAsync("file '"); 27 | await sw.WriteAsync(DownloadTools.RemoveQueryString(stream.Path)); 28 | await sw.WriteLineAsync('\''); 29 | 30 | foreach (var id in streamIds.Ids) 31 | { 32 | await sw.WriteLineAsync("stream"); 33 | await sw.WriteLineAsync($"exact_stream_id {id}"); 34 | } 35 | 36 | await sw.WriteAsync("duration "); 37 | await sw.WriteLineAsync(stream.PartInfo.Duration.ToString(CultureInfo.InvariantCulture)); 38 | } 39 | } 40 | 41 | public record StreamIds 42 | { 43 | public static readonly StreamIds TransportStream = new("0x100", "0x101", "0x102"); 44 | public static readonly StreamIds Mp4 = new("0x1", "0x2"); 45 | public static readonly StreamIds None = new(); 46 | 47 | private StreamIds(params string[] ids) 48 | { 49 | Ids = ids; 50 | } 51 | 52 | public IEnumerable Ids { get; } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/IdParse.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Linq; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace TwitchDownloaderCore.Tools 6 | { 7 | public static class IdParse 8 | { 9 | // TODO: Use source generators when .NET7 10 | private static readonly Regex VideoId = new(@"(?<=^|twitch\.tv\/videos\/)\d+(?=\/?(?:$|\?))", RegexOptions.Compiled); 11 | private static readonly Regex HighlightId = new(@"(?<=^|twitch\.tv\/\w+\/v(?:ideo)?\/)\d+(?=\/?(?:$|\?))", RegexOptions.Compiled); 12 | private static readonly Regex ClipId = new(@"(?<=^|(?:clips\.)?twitch\.tv\/(?:\w+\/clip\/)?)[\w-]+?(?=\/?(?:$|\?))", RegexOptions.Compiled); 13 | 14 | /// A of the video's id or . 15 | [return: MaybeNull] 16 | public static Match MatchVideoId(string text) 17 | { 18 | text = text.Trim(); 19 | 20 | var videoIdMatch = VideoId.Match(text); 21 | if (videoIdMatch.Success) 22 | { 23 | return videoIdMatch; 24 | } 25 | 26 | var highlightIdMatch = HighlightId.Match(text); 27 | if (highlightIdMatch.Success) 28 | { 29 | return highlightIdMatch; 30 | } 31 | 32 | return null; 33 | } 34 | 35 | /// A of the clip's id or . 36 | [return: MaybeNull] 37 | public static Match MatchClipId(string text) 38 | { 39 | text = text.Trim(); 40 | 41 | var clipIdMatch = ClipId.Match(text); 42 | if (clipIdMatch.Success && !clipIdMatch.Value.All(char.IsDigit)) 43 | { 44 | return clipIdMatch; 45 | } 46 | 47 | return null; 48 | } 49 | 50 | /// A of the video/clip's id or . 51 | [return: MaybeNull] 52 | public static Match MatchVideoOrClipId(string text) 53 | { 54 | text = text.Trim(); 55 | 56 | var videoIdMatch = MatchVideoId(text); 57 | if (videoIdMatch is { Success: true }) 58 | { 59 | return videoIdMatch; 60 | } 61 | 62 | var clipIdMatch = MatchClipId(text); 63 | if (clipIdMatch is { Success: true }) 64 | { 65 | return clipIdMatch; 66 | } 67 | 68 | return null; 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/M3U8StreamQualityComparer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using TwitchDownloaderCore.Extensions; 3 | using TwitchDownloaderCore.Models; 4 | 5 | namespace TwitchDownloaderCore.Tools 6 | { 7 | public class M3U8StreamQualityComparer : IComparer 8 | { 9 | public int Compare(M3U8.Stream x, M3U8.Stream y) 10 | { 11 | if (x?.StreamInfo is null) 12 | { 13 | if (y?.StreamInfo is null) return 0; 14 | return -1; 15 | } 16 | 17 | if (y?.StreamInfo is null) return 1; 18 | 19 | if (x.IsSource()) return -1; 20 | if (y.IsSource()) return 1; 21 | 22 | var xResolution = x.StreamInfo.Resolution; 23 | var yResolution = y.StreamInfo.Resolution; 24 | var xTotalPixels = xResolution.Width * xResolution.Height; 25 | var yTotalPixels = yResolution.Width * yResolution.Height; 26 | 27 | if (xTotalPixels < yTotalPixels) return 1; 28 | if (xTotalPixels > yTotalPixels) return -1; 29 | 30 | var xFramerate = x.StreamInfo.Framerate; 31 | var yFramerate = y.StreamInfo.Framerate; 32 | 33 | if (xFramerate < yFramerate) return 1; 34 | if (xFramerate > yFramerate) return -1; 35 | 36 | var xBandwidth = x.StreamInfo.Bandwidth; 37 | var yBandwidth = y.StreamInfo.Bandwidth; 38 | 39 | if (xBandwidth < yBandwidth) return 1; 40 | if (xBandwidth > yBandwidth) return -1; 41 | 42 | return 1; 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/TwitchRegex.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace TwitchDownloaderCore.Tools 4 | { 5 | public static class TwitchRegex 6 | { 7 | public static readonly Regex UrlTimeCode = new(@"(?<=(?:\?|&)t=)\d+h\d+m\d+s(?=$|\?|\s)", RegexOptions.Compiled); 8 | public static readonly Regex BitsRegex = new( 9 | @"(?<=(?:\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{0,6}(?=\s|$)", 10 | RegexOptions.Compiled); 11 | } 12 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/UrlTimeCode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.Tools 4 | { 5 | public static class UrlTimeCode 6 | { 7 | /// 8 | /// Converts the span representation of a time interval in the format of '2d21h11m9s' to its equivalent. 9 | /// 10 | /// A span containing the characters that represent the time interval to convert. 11 | /// The equivalent to the time interval contained in the span. 12 | public static TimeSpan Parse(ReadOnlySpan input) 13 | { 14 | var dayIndex = input.IndexOf('d'); 15 | var hourIndex = input.IndexOf('h'); 16 | var minuteIndex = input.IndexOf('m'); 17 | var secondIndex = input.IndexOf('s'); 18 | var returnTimespan = TimeSpan.Zero; 19 | 20 | if (dayIndex != -1 && int.TryParse(input[..dayIndex], out var days)) 21 | { 22 | returnTimespan = returnTimespan.Add(TimeSpan.FromDays(days)); 23 | } 24 | 25 | dayIndex++; 26 | 27 | if (hourIndex != -1 && int.TryParse(input[dayIndex..hourIndex], out var hours)) 28 | { 29 | returnTimespan = returnTimespan.Add(TimeSpan.FromHours(hours)); 30 | } 31 | 32 | hourIndex++; 33 | 34 | if (minuteIndex != -1 && int.TryParse(input[hourIndex..minuteIndex], out var minutes)) 35 | { 36 | returnTimespan = returnTimespan.Add(TimeSpan.FromMinutes(minutes)); 37 | } 38 | 39 | minuteIndex++; 40 | 41 | if (secondIndex != -1 && int.TryParse(input[minuteIndex..secondIndex], out var seconds)) 42 | { 43 | returnTimespan = returnTimespan.Add(TimeSpan.FromSeconds(seconds)); 44 | } 45 | 46 | return returnTimespan; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/VideoDownloadState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | using TwitchDownloaderCore.Models; 7 | 8 | namespace TwitchDownloaderCore.Tools 9 | { 10 | public class VideoDownloadState 11 | { 12 | public class PartState 13 | { 14 | public long ExpectedFileSize; 15 | public byte DownloadAttempts; 16 | public bool TryUnmute; 17 | /// 18 | /// The state of the part to fetch the audio from. Null if not applicable. 19 | /// 20 | /// Not yet implemented. 21 | /// 22 | public UnmutedPartState UnmutedPart; 23 | } 24 | 25 | public class UnmutedPartState 26 | { 27 | public long ExpectedFileSize; 28 | public string FileName; 29 | } 30 | 31 | public IReadOnlyList AllQualities { get; } 32 | 33 | public string DownloadQuality { get; } 34 | 35 | public ConcurrentQueue PartQueue { get; } 36 | 37 | public Dictionary PartStates { get; } 38 | 39 | public Uri BaseUrl { get; } 40 | 41 | public string HeaderFile { get; } 42 | 43 | public long HeaderFileSize { get; } 44 | 45 | public DateTimeOffset VodAirDate { get; } 46 | 47 | public int PartCount => PartStates.Count; 48 | 49 | public VideoDownloadState(IEnumerable allQualityPaths, IReadOnlyCollection playlist, Range videoListCrop, Uri baseUrl, string headerFile, DateTimeOffset vodAirDate) 50 | { 51 | AllQualities = allQualityPaths.ToArray(); 52 | DownloadQuality = AllQualities.FirstOrDefault(x => baseUrl.AbsolutePath.AsSpan().TrimEnd('/').EndsWith(x)) ?? baseUrl.Segments.Last(); 53 | 54 | var orderedParts = playlist 55 | .Take(videoListCrop) 56 | .Select(x => x.Path) 57 | .OrderBy(x => !x.Contains("-muted")); // Prioritize downloading muted segments 58 | 59 | PartQueue = new ConcurrentQueue(orderedParts); 60 | 61 | var vodAge = DateTimeOffset.UtcNow - vodAirDate; 62 | PartStates = new Dictionary(PartQueue.Count); 63 | foreach (var part in PartQueue) 64 | { 65 | PartStates[part] = new PartState { TryUnmute = vodAge <= TimeSpan.FromHours(24) }; 66 | } 67 | 68 | BaseUrl = baseUrl; 69 | 70 | HeaderFile = headerFile; 71 | if (!string.IsNullOrWhiteSpace(headerFile) && new FileInfo(headerFile) is { Exists: true } hFi) 72 | { 73 | HeaderFileSize = hFi.Length; 74 | } 75 | 76 | VodAirDate = vodAirDate; 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/Tools/VideoSizeEstimator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.Tools 4 | { 5 | public static class VideoSizeEstimator 6 | { 7 | public static string StringifyByteCount(long sizeInBytes) 8 | { 9 | const long ONE_KIBIBYTE = 1024; 10 | const long ONE_MEBIBYTE = ONE_KIBIBYTE * 1024; 11 | const long ONE_GIBIBYTE = ONE_MEBIBYTE * 1024; 12 | 13 | return sizeInBytes switch 14 | { 15 | < 1 => "", 16 | 17 | < ONE_KIBIBYTE => $"{sizeInBytes}B", 18 | 19 | < 100 * ONE_KIBIBYTE => $"{(float)sizeInBytes / ONE_KIBIBYTE:F2}KiB", 20 | < ONE_MEBIBYTE => $"{(float)sizeInBytes / ONE_KIBIBYTE:F1}KiB", 21 | 22 | < 100 * ONE_MEBIBYTE => $"{(float)sizeInBytes / ONE_MEBIBYTE:F2}MiB", 23 | < ONE_GIBIBYTE => $"{(float)sizeInBytes / ONE_MEBIBYTE:F1}MiB", 24 | 25 | < 100 * ONE_GIBIBYTE => $"{(float)sizeInBytes / ONE_GIBIBYTE:F2}GiB", 26 | _ => $"{(float)sizeInBytes / ONE_GIBIBYTE:F1}GiB", 27 | }; 28 | } 29 | 30 | /// An estimate of the final video download size in bytes. 31 | public static long EstimateVideoSize(int bandwidth, TimeSpan startTime, TimeSpan endTime) 32 | { 33 | if (bandwidth < 1) 34 | return 0; 35 | if (endTime < startTime) 36 | return 0; 37 | 38 | var totalTime = endTime - startTime; 39 | return (long)(bandwidth / 8d * totalTime.TotalSeconds); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchDownloaderCore.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | https://github.com/lay295/TwitchDownloader 6 | true 7 | MIT 8 | Lewis Pardo 9 | 1.1.6 10 | AnyCPU;x64 11 | 10 12 | true 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Always 40 | COPYRIGHT.txt 41 | false 42 | 43 | 44 | Always 45 | THIRD-PARTY-LICENSES.txt 46 | false 47 | 48 | 49 | 50 | 51 | 52 | True 53 | True 54 | Resources.resx 55 | 56 | 57 | 58 | 59 | 60 | ResXFileCodeGenerator 61 | Resources.Designer.cs 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Api/BTTVChannelEmoteResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects.Api 4 | { 5 | public class BTTVChannelEmoteResponse 6 | { 7 | public string id { get; set; } 8 | public List bots { get; set; } 9 | public string avatar { get; set; } 10 | public List channelEmotes { get; set; } 11 | public List sharedEmotes { get; set; } 12 | } 13 | public class BTTVEmote 14 | { 15 | public string id { get; set; } 16 | public string code { get; set; } 17 | public string imageType { get; set; } 18 | public string userId { get; set; } 19 | public User user { get; set; } 20 | } 21 | 22 | public class User 23 | { 24 | public string id { get; set; } 25 | public string name { get; set; } 26 | public string displayName { get; set; } 27 | public string providerId { get; set; } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Api/FFZEmote.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects.Api 4 | { 5 | public class FFZEmote 6 | { 7 | public int id { get; set; } 8 | public FFZUser user { get; set; } 9 | public string code { get; set; } 10 | public FFZImages images { get; set; } 11 | public string imageType { get; set; } 12 | public bool animated { get; set; } 13 | public bool modifier { get; set; } 14 | } 15 | 16 | public class FFZImages 17 | { 18 | [JsonPropertyName("1x")] 19 | public string _1x { get; set; } 20 | 21 | [JsonPropertyName("2x")] 22 | public string _2x { get; set; } 23 | 24 | [JsonPropertyName("4x")] 25 | public string _4x { get; set; } 26 | } 27 | 28 | public class FFZUser 29 | { 30 | public int id { get; set; } 31 | public string name { get; set; } 32 | public string displayName { get; set; } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Api/STVChannelEmoteResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects.Api 4 | { 5 | public class STVConnection 6 | { 7 | public string id { get; set; } 8 | public string platform { get; set; } 9 | public string username { get; set; } 10 | public string display_name { get; set; } 11 | public long linked_at { get; set; } 12 | public int emote_capacity { get; set; } 13 | public object emote_set_id { get; set; } 14 | public STVEmoteSet emote_set { get; set; } 15 | } 16 | 17 | public class STVEditor 18 | { 19 | public string id { get; set; } 20 | public int permissions { get; set; } 21 | public bool visible { get; set; } 22 | public object added_at { get; set; } 23 | } 24 | 25 | public class STVEmoteSet 26 | { 27 | public string id { get; set; } 28 | public string name { get; set; } 29 | public List tags { get; set; } 30 | public bool immutable { get; set; } 31 | public bool privileged { get; set; } 32 | public List emotes { get; set; } 33 | public int emote_count { get; set; } 34 | public int capacity { get; set; } 35 | public STVOwner owner { get; set; } 36 | } 37 | 38 | public class STVChannelEmoteResponse 39 | { 40 | public string id { get; set; } 41 | public string platform { get; set; } 42 | public string username { get; set; } 43 | public string display_name { get; set; } 44 | public long linked_at { get; set; } 45 | public int emote_capacity { get; set; } 46 | public object emote_set_id { get; set; } 47 | public STVEmoteSet emote_set { get; set; } 48 | public STVUser user { get; set; } 49 | } 50 | 51 | public class STVUser 52 | { 53 | public string id { get; set; } 54 | public string username { get; set; } 55 | public string display_name { get; set; } 56 | public long created_at { get; set; } 57 | public string avatar_url { get; set; } 58 | public string biography { get; set; } 59 | public STVStyle style { get; set; } 60 | public List editors { get; set; } 61 | public List roles { get; set; } 62 | public List connections { get; set; } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Api/STVGlobalEmoteResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects.Api 4 | { 5 | public class STVData 6 | { 7 | public string id { get; set; } 8 | public string name { get; set; } 9 | public StvEmoteFlags flags { get; set; } 10 | public int lifecycle { get; set; } 11 | public bool listed { get; set; } 12 | public bool animated { get; set; } 13 | public STVOwner owner { get; set; } 14 | public STVHost host { get; set; } 15 | public List tags { get; set; } 16 | } 17 | 18 | public class STVEmote 19 | { 20 | public string id { get; set; } 21 | public string name { get; set; } 22 | //These flags, not currently respected 23 | //https://github.com/SevenTV/Common/blob/4139fcc3eb8d79003573b26b552ef112ec85b8df/structures/v3/type.emoteset.go#L66-L72 24 | public int flags { get; set; } 25 | public ulong timestamp { get; set; } 26 | public string actor_id { get; set; } 27 | public STVData data { get; set; } 28 | public string origin_id { get; set; } 29 | } 30 | 31 | public class STVFile 32 | { 33 | public string name { get; set; } 34 | public string static_name { get; set; } 35 | public int width { get; set; } 36 | public int height { get; set; } 37 | public int frame_count { get; set; } 38 | public int size { get; set; } 39 | public string format { get; set; } 40 | } 41 | 42 | public class STVHost 43 | { 44 | public string url { get; set; } 45 | public List files { get; set; } 46 | } 47 | 48 | public class StvOrigin 49 | { 50 | public string id { get; set; } 51 | public int weight { get; set; } 52 | public List slices { get; set; } 53 | } 54 | 55 | public class STVOwner 56 | { 57 | public string id { get; set; } 58 | public string username { get; set; } 59 | public string display_name { get; set; } 60 | public string avatar_url { get; set; } 61 | public STVStyle style { get; set; } 62 | public List role_ids { get; set; } 63 | } 64 | 65 | public class STVGlobalEmoteResponse 66 | { 67 | public string id { get; set; } 68 | public string name { get; set; } 69 | public List tags { get; set; } 70 | public bool immutable { get; set; } 71 | public bool privileged { get; set; } 72 | public List emotes { get; set; } 73 | public int emote_count { get; set; } 74 | public int capacity { get; set; } 75 | public List origins { get; set; } 76 | public STVOwner owner { get; set; } 77 | } 78 | 79 | public class STVStyle 80 | { 81 | public int? color { get; set; } 82 | public string paint_id { get; set; } 83 | public string badge_id { get; set; } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/ChatRootInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects 4 | { 5 | public class ChatRootInfo 6 | { 7 | public ChatRootVersion Version { get; init; } = new(); 8 | public DateTime CreatedAt { get; init; } = DateTime.FromBinary(0); 9 | public DateTime UpdatedAt { get; init; } = DateTime.FromBinary(0); 10 | } 11 | 12 | public record ChatRootVersion 13 | { 14 | public uint Major { get; init; } 15 | public uint Minor { get; init; } 16 | public uint Patch { get; init; } 17 | 18 | public static ChatRootVersion CurrentVersion { get; } = new(1, 4, 0); 19 | 20 | /// 21 | /// Initializes a new object with the default version of 1.0.0 22 | /// 23 | public ChatRootVersion() 24 | { 25 | Major = 1; 26 | Minor = 0; 27 | Patch = 0; 28 | } 29 | 30 | /// 31 | /// Initializes a new object with the version number of .. 32 | /// 33 | public ChatRootVersion(uint major, uint minor, uint patch) 34 | { 35 | Major = major; 36 | Minor = minor; 37 | Patch = patch; 38 | } 39 | 40 | public override string ToString() 41 | => $"{Major}.{Minor}.{Patch}"; 42 | 43 | public override int GetHashCode() 44 | => HashCode.Combine(Major, Minor, Patch); 45 | 46 | public static bool operator >(ChatRootVersion left, ChatRootVersion right) 47 | { 48 | if (left.Major > right.Major) return true; 49 | if (left.Major < right.Major) return false; 50 | 51 | if (left.Minor > right.Minor) return true; 52 | if (left.Minor < right.Minor) return false; 53 | 54 | return left.Patch > right.Patch; 55 | } 56 | 57 | public static bool operator <(ChatRootVersion left, ChatRootVersion right) 58 | => right > left; 59 | 60 | public static bool operator >=(ChatRootVersion left, ChatRootVersion right) 61 | => left == right || left > right; 62 | 63 | public static bool operator <=(ChatRootVersion left, ChatRootVersion right) 64 | => left == right || left < right; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/CheerEmote.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | 6 | namespace TwitchDownloaderCore.TwitchObjects 7 | { 8 | [DebuggerDisplay("{prefix}")] 9 | public sealed class CheerEmote : IDisposable 10 | { 11 | public bool Disposed { get; private set; } = false; 12 | public string prefix { get; set; } 13 | public List> tierList { get; set; } = new List>(); 14 | 15 | public KeyValuePair getTier(int value) 16 | { 17 | KeyValuePair returnPair = tierList.First(); 18 | foreach (KeyValuePair tierPair in tierList) 19 | { 20 | if (tierPair.Key > value) 21 | break; 22 | returnPair = tierPair; 23 | } 24 | 25 | return returnPair; 26 | } 27 | 28 | public void Resize(double newScale) 29 | { 30 | for (int i = 0; i < tierList.Count; i++) 31 | { 32 | tierList[i].Value.Resize(newScale); 33 | } 34 | } 35 | 36 | #region ImplementIDisposable 37 | 38 | public void Dispose() 39 | { 40 | Dispose(true); 41 | } 42 | 43 | private void Dispose(bool isDisposing) 44 | { 45 | try 46 | { 47 | if (Disposed) 48 | { 49 | return; 50 | } 51 | 52 | if (isDisposing) 53 | { 54 | foreach (var (_, emote) in tierList) 55 | { 56 | emote?.Dispose(); 57 | } 58 | } 59 | } 60 | finally 61 | { 62 | Disposed = true; 63 | } 64 | } 65 | 66 | #endregion 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/CommentSection.cs: -------------------------------------------------------------------------------- 1 | using SkiaSharp; 2 | using System.Collections.Generic; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects 5 | { 6 | public class CommentSection 7 | { 8 | public SKBitmap Image { get; set; } 9 | public List<(Point drawPoint, TwitchEmote emote)> Emotes { get; set; } 10 | public int CommentIndex { get; set; } 11 | } 12 | 13 | public struct Point 14 | { 15 | public int X { get; set; } 16 | public int Y { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/EmoteResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects 4 | { 5 | public class EmoteResponse 6 | { 7 | public List BTTV { get; set; } 8 | public List FFZ { get; set; } 9 | public List STV { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/EmoteResponseItem.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderCore.TwitchObjects 2 | { 3 | public class EmoteResponseItem 4 | { 5 | public string Id { get; set; } 6 | public string Code { get; set; } 7 | public string ImageType { get; set; } 8 | public string ImageUrl { get; set; } 9 | public bool IsZeroWidth { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlBadgeResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects.Gql 5 | { 6 | public class GqlGlobalBadgeResponse 7 | { 8 | public TwitchGlobalChatBadgeData data { get; set; } 9 | } 10 | 11 | public class GqlSubBadgeResponse 12 | { 13 | public TwitchUserChatBadgeData data { get; set; } 14 | } 15 | public class TwitchUserChatBadgeData 16 | { 17 | public TwitchChatBadgeUser user { get; set; } 18 | } 19 | public class TwitchChatBadgeUser 20 | { 21 | [JsonPropertyName("broadcastBadges")] 22 | public List badges { get; set; } 23 | } 24 | 25 | public class TwitchGlobalChatBadgeData 26 | { 27 | public List badges { get; set; } 28 | } 29 | 30 | public class TwitchBadgeSet 31 | { 32 | public Dictionary versions { get; set; } 33 | } 34 | 35 | public class TwitchChatBadge 36 | { 37 | [JsonPropertyName("imageURL")] 38 | public string image_url_2x { get; set; } 39 | public string description { get; set; } 40 | public string title { get; set; } 41 | [JsonPropertyName("setID")] 42 | public string name { get; set; } 43 | public string version { get; set; } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlCheerResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects.Gql 4 | { 5 | public class Tier 6 | { 7 | public int bits { get; set; } 8 | } 9 | 10 | public class CheerNode 11 | { 12 | public string id { get; set; } 13 | public string prefix { get; set; } 14 | public List tiers { get; set; } 15 | } 16 | 17 | public class CheerGroup 18 | { 19 | public List nodes { get; set; } 20 | public string templateURL { get; set; } 21 | } 22 | 23 | public class CheerConfig 24 | { 25 | public List groups { get; set; } 26 | } 27 | 28 | public class Cheer 29 | { 30 | public List cheerGroups { get; set; } 31 | } 32 | 33 | public class CheerUser 34 | { 35 | public Cheer cheer { get; set; } 36 | } 37 | 38 | public class CheerData 39 | { 40 | public CheerConfig cheerConfig { get; set; } 41 | public CheerUser user { get; set; } 42 | } 43 | 44 | public class GqlCheerResponse 45 | { 46 | public CheerData data { get; set; } 47 | public Extensions extensions { get; set; } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlClipResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects.Gql 4 | { 5 | public class ClipBroadcaster 6 | { 7 | public string id { get; set; } 8 | public string displayName { get; set; } 9 | public string login { get; set; } 10 | } 11 | 12 | public class ClipCurator 13 | { 14 | public string id { get; set; } 15 | public string displayName { get; set; } 16 | public string login { get; set; } 17 | } 18 | 19 | public class ClipVideo 20 | { 21 | public string id { get; set; } 22 | } 23 | 24 | public class Clip 25 | { 26 | public string title { get; set; } 27 | public string thumbnailURL { get; set; } 28 | public DateTime createdAt { get; set; } 29 | public ClipCurator curator { get; set; } 30 | public int durationSeconds { get; set; } 31 | public ClipBroadcaster broadcaster { get; set; } 32 | public int? videoOffsetSeconds { get; set; } 33 | public ClipVideo video { get; set; } 34 | public int viewCount { get; set; } 35 | public Game game { get; set; } 36 | } 37 | 38 | public class ClipData 39 | { 40 | public Clip clip { get; set; } 41 | } 42 | 43 | public class GqlClipResponse 44 | { 45 | public ClipData data { get; set; } 46 | public Extensions extensions { get; set; } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlClipSearchResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects.Gql 5 | { 6 | public class ClipNodeGame 7 | { 8 | public string id { get; set; } 9 | public string displayName { get; set; } 10 | } 11 | 12 | public class ClipNodeCurator 13 | { 14 | public string id { get; set; } 15 | public string displayName { get; set; } 16 | } 17 | 18 | public class ClipNode 19 | { 20 | public string id { get; set; } 21 | public string slug { get; set; } 22 | public string title { get; set; } 23 | public DateTime createdAt { get; set; } 24 | public ClipNodeCurator curator { get; set; } 25 | public int durationSeconds { get; set; } 26 | public string thumbnailURL { get; set; } 27 | public int viewCount { get; set; } 28 | public ClipNodeGame game { get; set; } 29 | } 30 | 31 | public class Edge 32 | { 33 | public string cursor { get; set; } 34 | public ClipNode node { get; set; } 35 | } 36 | 37 | public class Clips 38 | { 39 | public List edges { get; set; } 40 | public PageInfo pageInfo { get; set; } 41 | } 42 | 43 | public class ClipUser 44 | { 45 | public Clips clips { get; set; } 46 | } 47 | 48 | public class ClipSearchData 49 | { 50 | public ClipUser user { get; set; } 51 | } 52 | 53 | public class GqlClipSearchResponse 54 | { 55 | public ClipSearchData data { get; set; } 56 | public Extensions extensions { get; set; } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlClipTokenResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderCore.TwitchObjects.Gql 2 | { 3 | public class ClipToken 4 | { 5 | public string id { get; set; } 6 | public PlaybackAccessToken playbackAccessToken { get; set; } 7 | public ClipVideoQuality[] videoQualities { get; set; } 8 | } 9 | 10 | public class ClipTokenData 11 | { 12 | public ClipToken clip { get; set; } 13 | } 14 | 15 | public class PlaybackAccessToken 16 | { 17 | public string signature { get; set; } 18 | public string value { get; set; } 19 | } 20 | 21 | public class GqlClipTokenResponse 22 | { 23 | public ClipTokenData data { get; set; } 24 | public Extensions extensions { get; set; } 25 | } 26 | 27 | public class ClipVideoQuality 28 | { 29 | public decimal frameRate { get; set; } 30 | public string quality { get; set; } 31 | public string sourceURL { get; set; } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlCommentResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects.Gql 5 | { 6 | public class CommentChannel 7 | { 8 | public string id { get; set; } 9 | } 10 | 11 | public class CommentCommenter 12 | { 13 | public string id { get; set; } 14 | public string login { get; set; } 15 | public string displayName { get; set; } 16 | } 17 | 18 | public class CommentComments 19 | { 20 | public List edges { get; set; } 21 | public CommentPageInfo pageInfo { get; set; } 22 | } 23 | 24 | public class Creator 25 | { 26 | public string id { get; set; } 27 | public CommentChannel channel { get; set; } 28 | } 29 | 30 | public class CommentData 31 | { 32 | public CommentVideo video { get; set; } 33 | } 34 | 35 | public class CommentEdge 36 | { 37 | public string cursor { get; set; } 38 | public CommentNode node { get; set; } 39 | } 40 | 41 | public class CommentFragment 42 | { 43 | public CommentEmote emote { get; set; } 44 | public string text { get; set; } 45 | } 46 | 47 | public class CommentEmote 48 | { 49 | public string id { get; set; } 50 | public string emoteID { get; set; } 51 | public int from { get; set; } 52 | } 53 | 54 | public class CommentMessage 55 | { 56 | public List fragments { get; set; } 57 | public List userBadges { get; set; } 58 | public string userColor { get; set; } 59 | } 60 | 61 | public class CommentNode 62 | { 63 | public string id { get; set; } 64 | public CommentCommenter commenter { get; set; } 65 | public int contentOffsetSeconds { get; set; } 66 | public DateTime createdAt { get; set; } 67 | public CommentMessage message { get; set; } 68 | } 69 | 70 | public class CommentPageInfo 71 | { 72 | public bool hasNextPage { get; set; } 73 | public bool hasPreviousPage { get; set; } 74 | } 75 | 76 | public class GqlCommentResponse 77 | { 78 | public CommentData data { get; set; } 79 | public Extensions extensions { get; set; } 80 | } 81 | 82 | public class CommentUserBadge 83 | { 84 | public string id { get; set; } 85 | public string setID { get; set; } 86 | public string version { get; set; } 87 | } 88 | 89 | public class CommentVideo 90 | { 91 | public string id { get; set; } 92 | public Creator creator { get; set; } 93 | public CommentComments comments { get; set; } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlUserIdResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderCore.TwitchObjects.Gql 2 | { 3 | public class UserId 4 | { 5 | public string id { get; set; } 6 | } 7 | 8 | public class UserIdData 9 | { 10 | public UserId[] users { get; set; } 11 | } 12 | 13 | public class GqlUserIdResponse 14 | { 15 | public UserIdData data { get; set; } 16 | public Extensions extensions { get; set; } 17 | } 18 | } -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlUserInfoResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects.Gql 5 | { 6 | public class UserInfoData 7 | { 8 | public List users { get; set; } 9 | } 10 | 11 | public class GqlUserInfoResponse 12 | { 13 | public UserInfoData data { get; set; } 14 | public Extensions extensions { get; set; } 15 | } 16 | 17 | public class User 18 | { 19 | public string id { get; set; } 20 | public string displayName { get; set; } 21 | public string login { get; set; } 22 | public DateTime createdAt { get; set; } 23 | public DateTime updatedAt { get; set; } 24 | public string description { get; set; } 25 | public string profileImageURL { get; set; } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoChapterResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects.Gql 5 | { 6 | public class VideoMomentEdgeVideo 7 | { 8 | public string id { get; set; } 9 | public int lengthSeconds { get; set; } 10 | } 11 | 12 | public class Game 13 | { 14 | public string id { get; set; } 15 | public string displayName { get; set; } 16 | public string boxArtURL { get; set; } 17 | } 18 | 19 | public class GameChangeMomentDetails 20 | { 21 | public Game game { get; set; } 22 | } 23 | 24 | public class VideoMoment 25 | { 26 | public VideoMomentConnection moments { get; set; } // seemingly always blank. Oauth does not seem to make a difference 27 | public string id { get; set; } 28 | public int durationMilliseconds { get; set; } 29 | public int positionMilliseconds { get; set; } 30 | [JsonPropertyName("type")] 31 | public string _type { get; set; } 32 | public string description { get; set; } 33 | public string subDescription { get; set; } 34 | public string thumbnailURL { get; set; } 35 | public GameChangeMomentDetails details { get; set; } 36 | public VideoMomentEdgeVideo video { get; set; } 37 | } 38 | 39 | public class VideoMomentEdge 40 | { 41 | public VideoMoment node { get; set; } 42 | } 43 | 44 | public class VideoMomentConnection 45 | { 46 | public List edges { get; set; } 47 | } 48 | 49 | public class ChapterVideo 50 | { 51 | public string id { get; set; } 52 | public VideoMomentConnection moments { get; set; } 53 | } 54 | 55 | public class ChapterData 56 | { 57 | public ChapterVideo video { get; set; } 58 | } 59 | 60 | public class GqlVideoChapterResponse 61 | { 62 | public ChapterData data { get; set; } 63 | public Extensions extensions { get; set; } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects.Gql 5 | { 6 | public class VideoOwner 7 | { 8 | public string id { get; set; } 9 | public string displayName { get; set; } 10 | public string login { get; set; } 11 | } 12 | 13 | public class VideoInfo 14 | { 15 | public string title { get; set; } 16 | public List thumbnailURLs { get; set; } 17 | public DateTime createdAt { get; set; } 18 | public int lengthSeconds { get; set; } 19 | public VideoOwner owner { get; set; } 20 | public int viewCount { get; set; } 21 | public Game game { get; set; } 22 | /// 23 | /// Some values, such as newlines, are repeated twice for some reason. 24 | /// This can be filtered out with: description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd() 25 | /// 26 | public string description { get; set; } 27 | public string status { get; set; } 28 | } 29 | 30 | public class VideoData 31 | { 32 | public VideoInfo video { get; set; } 33 | } 34 | 35 | public class GqlVideoResponse 36 | { 37 | public VideoData data { get; set; } 38 | public Extensions extensions { get; set; } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoSearchResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects.Gql 5 | { 6 | public class VideoNodeGame 7 | { 8 | public string id { get; set; } 9 | public string displayName { get; set; } 10 | } 11 | 12 | public class VideoNode 13 | { 14 | public string title { get; set; } 15 | public string id { get; set; } 16 | public int lengthSeconds { get; set; } 17 | public string previewThumbnailURL { get; set; } 18 | public DateTime createdAt { get; set; } 19 | public int viewCount { get; set; } 20 | public VideoNodeGame game { get; set; } 21 | } 22 | 23 | public class VideoEdge 24 | { 25 | public VideoNode node { get; set; } 26 | public string cursor { get; set; } 27 | } 28 | 29 | public class PageInfo 30 | { 31 | public bool hasNextPage { get; set; } 32 | public bool hasPreviousPage { get; set; } 33 | } 34 | 35 | public class Videos 36 | { 37 | public List edges { get; set; } 38 | public PageInfo pageInfo { get; set; } 39 | public int totalCount { get; set; } 40 | } 41 | 42 | public class VideoUser 43 | { 44 | public Videos videos { get; set; } 45 | } 46 | 47 | public class VideoSearchData 48 | { 49 | public VideoUser user { get; set; } 50 | } 51 | 52 | public class Extensions 53 | { 54 | public int durationMilliseconds { get; set; } 55 | public string requestID { get; set; } 56 | } 57 | 58 | public class GqlVideoSearchResponse 59 | { 60 | public VideoSearchData data { get; set; } 61 | public Extensions extensions { get; set; } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/Gql/GqlVideoTokenResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderCore.TwitchObjects.Gql 2 | { 3 | public class GqlVideoData 4 | { 5 | public VideoPlaybackAccessToken videoPlaybackAccessToken { get; set; } 6 | } 7 | 8 | public class GqlVideoTokenResponse 9 | { 10 | public GqlVideoData data { get; set; } 11 | public Extensions extensions { get; set; } 12 | } 13 | 14 | public class VideoPlaybackAccessToken 15 | { 16 | public string value { get; set; } 17 | public string signature { get; set; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/StvEmoteFlags.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderCore.TwitchObjects 4 | { 5 | // https://github.com/SevenTV/Common/blob/4139fcc3eb8d79003573b26b552ef112ec85b8df/structures/v3/type.emote.go#L49 6 | [Flags] 7 | public enum StvEmoteFlags 8 | { 9 | Private = 1 << 0, // The emote is private and can only be accessed by its owner, editors and moderators 10 | Authentic = 1 << 1, // The emote was verified to be an original creation by the uploader 11 | ZeroWidth = 1 << 8, // The emote is recommended to be enabled as Zero-Width 12 | 13 | // Content Flags 14 | 15 | ContentSexual = 1 << 16, // Sexually Suggestive 16 | ContentEpilepsy = 1 << 17, // Rapid flashing 17 | ContentEdgy = 1 << 18, // Edgy or distasteful, may be offensive to some users 18 | ContentTwitchDisallowed = 1 << 24, // Not allowed specifically on the Twitch platform 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /TwitchDownloaderCore/TwitchObjects/UpdateFrame.cs: -------------------------------------------------------------------------------- 1 | using SkiaSharp; 2 | using System.Collections.Generic; 3 | 4 | namespace TwitchDownloaderCore.TwitchObjects 5 | { 6 | public class UpdateFrame 7 | { 8 | public SKBitmap Image { get; set; } 9 | public List Comments { get; set; } = new List(); 10 | public int CommentIndex { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/App.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Windows; 4 | using System.Windows.Threading; 5 | using TwitchDownloaderWPF.Properties; 6 | using TwitchDownloaderWPF.Services; 7 | 8 | namespace TwitchDownloaderWPF 9 | { 10 | /// 11 | /// Interaction logic for App.xaml 12 | /// 13 | public partial class App : Application 14 | { 15 | public static ThemeService ThemeServiceSingleton { get; private set; } 16 | public static CultureService CultureServiceSingleton { get; private set; } 17 | 18 | protected override void OnStartup(StartupEventArgs e) 19 | { 20 | base.OnStartup(e); 21 | 22 | Current.DispatcherUnhandledException += Current_DispatcherUnhandledException; 23 | AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; 24 | 25 | // Set the working dir to the app dir in case we inherited a different working dir 26 | Directory.SetCurrentDirectory(AppContext.BaseDirectory); 27 | 28 | CultureServiceSingleton = new CultureService(); 29 | RequestCultureChange(); 30 | 31 | var windowsThemeService = new WindowsThemeService(); 32 | ThemeServiceSingleton = new ThemeService(this, windowsThemeService); 33 | 34 | MainWindow = new MainWindow 35 | { 36 | WindowStartupLocation = WindowStartupLocation.CenterScreen 37 | }; 38 | MainWindow.Show(); 39 | } 40 | 41 | private static void Current_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) 42 | { 43 | var ex = e.Exception; 44 | MessageBox.Show(ex.ToString(), Translations.Strings.FatalError, MessageBoxButton.OK, MessageBoxImage.Error); 45 | 46 | Current?.Shutdown(); 47 | } 48 | 49 | private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) 50 | { 51 | var ex = (Exception)e.ExceptionObject; 52 | MessageBox.Show(ex.ToString(), Translations.Strings.FatalError, MessageBoxButton.OK, MessageBoxImage.Error); 53 | 54 | Current?.Shutdown(); 55 | } 56 | 57 | public static void RequestAppThemeChange(bool forceRepaint = false) 58 | => ThemeServiceSingleton.ChangeAppTheme(forceRepaint); 59 | 60 | public static void RequestTitleBarChange() 61 | => ThemeServiceSingleton.SetTitleBarTheme(Current.Windows); 62 | 63 | public static void RequestCultureChange() 64 | => CultureServiceSingleton.SetApplicationCulture(Settings.Default.GuiCulture); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Behaviors/TextBoxTripleClickBehavior.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | using System.Windows.Controls; 3 | using System.Windows.Input; 4 | 5 | namespace TwitchDownloaderWPF.Behaviors 6 | { 7 | public class TextBoxTripleClickBehavior : DependencyObject 8 | { 9 | public static readonly DependencyProperty TripleClickSelectLineProperty = DependencyProperty.RegisterAttached( 10 | nameof(TripleClickSelectLine), typeof(bool), typeof(TextBoxTripleClickBehavior), new PropertyMetadata(false, OnPropertyChanged)); 11 | 12 | private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 13 | { 14 | if (d is not TextBox textBox) 15 | { 16 | return; 17 | } 18 | 19 | var enable = (bool)e.NewValue; 20 | if (enable) 21 | { 22 | textBox.PreviewMouseLeftButtonDown += OnTextBoxMouseDown; 23 | } 24 | else 25 | { 26 | textBox.PreviewMouseLeftButtonDown -= OnTextBoxMouseDown; 27 | } 28 | } 29 | 30 | private static void OnTextBoxMouseDown(object sender, MouseButtonEventArgs e) 31 | { 32 | if (e.ClickCount == 3 && sender is TextBox textBox) 33 | { 34 | var (start, length) = GetCurrentLine(textBox); 35 | textBox.Select(start, length); 36 | } 37 | } 38 | 39 | private static (int start, int length) GetCurrentLine(TextBox textBox) 40 | { 41 | var caretPos = textBox.CaretIndex; 42 | var text = textBox.Text; 43 | 44 | var start = -1; 45 | var end = -1; 46 | 47 | // CaretIndex can be negative for some reason. 48 | if (caretPos >= 0) 49 | { 50 | start = text.LastIndexOf('\n', caretPos, caretPos); 51 | end = text.IndexOf('\n', caretPos); 52 | } 53 | 54 | if (start == -1) 55 | { 56 | start = 0; 57 | } 58 | 59 | if (end == -1) 60 | { 61 | end = text.Length; 62 | } 63 | 64 | return (start, end - start); 65 | } 66 | 67 | public bool TripleClickSelectLine 68 | { 69 | get => (bool)GetValue(TripleClickSelectLineProperty); 70 | set => SetValue(TripleClickSelectLineProperty, value); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Behaviors/WindowIntegrityCheckBehavior.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | using TwitchDownloaderCore.Tools; 3 | 4 | namespace TwitchDownloaderWPF.Behaviors 5 | { 6 | public class WindowIntegrityCheckBehavior : DependencyObject 7 | { 8 | public static readonly DependencyProperty IntegrityCheckProperty = DependencyProperty.RegisterAttached( 9 | nameof(IntegrityCheck), typeof(bool), typeof(WindowIntegrityCheckBehavior), new PropertyMetadata(false, OnPropertyChanged)); 10 | 11 | private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 12 | { 13 | if (d is not Window) 14 | { 15 | return; 16 | } 17 | 18 | CoreLicensor.EnsureFilesExist(null); 19 | } 20 | 21 | public bool IntegrityCheck 22 | { 23 | get => (bool)GetValue(IntegrityCheckProperty); 24 | set => SetValue(IntegrityCheckProperty, value); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Extensions/TextBoxExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Windows.Controls; 3 | 4 | namespace TwitchDownloaderWPF.Extensions 5 | { 6 | public static class TextBoxExtensions 7 | { 8 | public static bool TryInsertAtCaret([AllowNull] this TextBox textBox, string textToInsert) 9 | { 10 | if (textBox is null || string.IsNullOrEmpty(textToInsert)) 11 | { 12 | return false; 13 | } 14 | 15 | var caretPos = textBox.CaretIndex; 16 | if (caretPos < 0) 17 | { 18 | return false; 19 | } 20 | 21 | if (textBox.IsSelectionActive) 22 | { 23 | textBox.Text = textBox.Text.Remove(caretPos, textBox.SelectionLength); 24 | } 25 | 26 | textBox.Text = textBox.Text.Insert(caretPos, textToInsert); 27 | textBox.CaretIndex = caretPos + textToInsert.Length; 28 | return true; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/HACKERMANS.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/HACKERMANS.gif -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/Logo.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/chatdownload1Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/chatdownload1Example.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/chatdownload2Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/chatdownload2Example.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/chatrender1Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/chatrender1Example.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/chatrender2Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/chatrender2Example.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/chatrender3Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/chatrender3Example.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/chatrender4Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/chatrender4Example.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/chatrender5Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/chatrender5Example.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/chatupdateExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/chatupdateExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/clipExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/clipExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/donate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/donate.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/enqueueExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/enqueueExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/massclipExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/massclipExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/massurlExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/massurlExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/massvodExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/massvodExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/peepoSad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/peepoSad.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/ppHop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/ppHop.gif -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/ppOverheat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/ppOverheat.gif -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/ppStretch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/ppStretch.gif -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/rangeExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/rangeExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/settings.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/settingsExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/settingsExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/taskqueueExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/taskqueueExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Images/vodExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lay295/TwitchDownloader/983457e8911be7d0f27d9e895cdd5b9822c52c7c/TwitchDownloaderWPF/Images/vodExample.png -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Models/BooleanModel.cs: -------------------------------------------------------------------------------- 1 | using System.Xml.Serialization; 2 | 3 | namespace TwitchDownloaderWPF.Models 4 | { 5 | [XmlRoot(ElementName = "Boolean", Namespace = "clr-namespace:System;assembly=mscorlib")] 6 | public class BooleanModel 7 | { 8 | [XmlAttribute(AttributeName = "Key", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml")] 9 | public string Key { get; set; } 10 | 11 | [XmlText(Type = typeof(bool))] 12 | public bool Value { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Models/CollisionBehavior.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderWPF.Models 2 | { 3 | internal enum CollisionBehavior 4 | { 5 | Prompt, 6 | Overwrite, 7 | Rename, 8 | Cancel 9 | } 10 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Models/LogLevel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TwitchDownloaderWPF.Models 4 | { 5 | [Flags] 6 | internal enum LogLevel 7 | { 8 | None = 0, 9 | Verbose = 1 << 0, 10 | Info = 1 << 1, 11 | Warning = 1 << 2, 12 | Error = 1 << 3, 13 | Ffmpeg = 1 << 4, 14 | } 15 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Models/SolidBrushModel.cs: -------------------------------------------------------------------------------- 1 | using System.Xml.Serialization; 2 | 3 | namespace TwitchDownloaderWPF.Models 4 | { 5 | [XmlRoot(ElementName = "SolidColorBrush", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")] 6 | public class SolidColorBrushModel 7 | { 8 | [XmlAttribute(AttributeName = "Key", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml")] 9 | public string Key { get; set; } 10 | 11 | [XmlAttribute(AttributeName = "Color")] 12 | public string Color { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Models/ThemeResourceDictionaryModel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Xml.Serialization; 3 | 4 | namespace TwitchDownloaderWPF.Models 5 | { 6 | [XmlRoot(ElementName = "ResourceDictionary", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")] 7 | public class ThemeResourceDictionaryModel 8 | { 9 | [XmlElement(ElementName = "SolidColorBrush", Namespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation")] 10 | public List SolidColorBrush { get; set; } 11 | 12 | [XmlElement(ElementName = "Boolean", Namespace = "clr-namespace:System;assembly=mscorlib")] 13 | public List Boolean { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/OpenTK.dll.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Resources; 3 | using System.Runtime.CompilerServices; 4 | using System.Runtime.InteropServices; 5 | using System.Windows; 6 | 7 | // General Information about an assembly is controlled through the following 8 | // set of attributes. Change these attribute values to modify the information 9 | // associated with an assembly. 10 | [assembly: AssemblyTitle("TwitchDownloader")] 11 | [assembly: AssemblyDescription("Download and render Twitch VODs, clips, and chats")] 12 | [assembly: AssemblyConfiguration("")] 13 | [assembly: AssemblyCompany("")] 14 | [assembly: AssemblyProduct("TwitchDownloader")] 15 | [assembly: AssemblyCopyright("Copyright © lay295 and contributors")] 16 | [assembly: AssemblyTrademark("")] 17 | [assembly: AssemblyCulture("")] 18 | 19 | // Setting ComVisible to false makes the types in this assembly not visible 20 | // to COM components. If you need to access a type in this assembly from 21 | // COM, set the ComVisible attribute to true on that type. 22 | [assembly: ComVisible(false)] 23 | 24 | //In order to begin building localizable applications, set 25 | //CultureYouAreCodingWith in your .csproj file 26 | //inside a . For example, if you are using US english 27 | //in your source files, set the to en-US. Then uncomment 28 | //the NeutralResourceLanguage attribute below. Update the "en-US" in 29 | //the line below to match the UICulture setting in the project file. 30 | 31 | //[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] 32 | 33 | 34 | [assembly: ThemeInfo( 35 | ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located 36 | //(used if a resource is not found in the page, 37 | // or application resource dictionaries) 38 | ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located 39 | //(used if a resource is not found in the page, 40 | // app, or any theme specific resource dictionaries) 41 | )] 42 | 43 | 44 | // Version information for an assembly consists of the following four values: 45 | // 46 | // Major Version 47 | // Minor Version 48 | // Build Number 49 | // Revision 50 | // 51 | // You can specify all the values or you can default the Build and Revision Numbers 52 | // by using the '*' as shown below: 53 | // [assembly: AssemblyVersion("1.0.*")] 54 | [assembly: AssemblyVersion("1.55.8")] 55 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Properties/CustomFfmpegArgs.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderWPF.Properties 2 | { 3 | public class CustomFfmpegArgs 4 | { 5 | public string CodecName { get; set; } 6 | public string ContainerName { get; set; } 7 | public string InputArgs { get; set; } 8 | public string OutputArgs { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Properties/PublishProfiles/Windows.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | x64 9 | bin\Release\net6.0-windows\publish\win-x64\ 10 | FileSystem 11 | net6.0-windows 12 | win-x64 13 | true 14 | True 15 | False 16 | 17 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Services/AvailableCultures.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderWPF.Services 2 | { 3 | public record struct Culture(string Code, string NativeName); 4 | 5 | public static class AvailableCultures 6 | { 7 | // Notes for translators: 8 | // 9 | // Please create a new record for your culture and place it in the 'All' array in alphabetical order according to the culture code. 10 | // The order of the 'All' array is the order that cultures will appear in the language dropdown menu. 11 | // 12 | // If you do not know the code for your culture, you can find it using the Visual Studio ResX Resource Manager extension 13 | // https://marketplace.visualstudio.com/items?itemName=TomEnglert.ResXManager 14 | // For JetBrains Rider users, the localization manager's "Add Culture" dialogue contains culture codes 15 | // Or alternatively it can probably be found it here: 16 | // http://www.codedigest.com/CodeDigest/207-Get-All-Language-Country-Code-List-for-all-Culture-in-C---ASP-Net.aspx 17 | // Or it can be found by combining the ISO 639-3 language name with the ISO 3166-1 alpha-2 country name. 18 | // ISO 639-3: https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags 19 | // ISO 3166-1 alpha-2: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements 20 | 21 | public static readonly Culture English; 22 | public static readonly Culture Spanish; 23 | public static readonly Culture French; 24 | public static readonly Culture Italian; 25 | public static readonly Culture Japanese; 26 | public static readonly Culture Polish; 27 | public static readonly Culture Russian; 28 | public static readonly Culture Turkish; 29 | public static readonly Culture Ukrainian; 30 | public static readonly Culture SimplifiedChinese; 31 | public static readonly Culture TraditionalChinese; 32 | 33 | public static readonly Culture[] All; 34 | 35 | // ReSharper disable StringLiteralTypo 36 | static AvailableCultures() 37 | { 38 | All = new[] 39 | { 40 | English = new Culture("en-US", "English"), 41 | Spanish = new Culture("es-ES", "Español"), 42 | French = new Culture("fr-FR", "Français"), 43 | Italian = new Culture("it-it", "Italiano"), 44 | Japanese = new Culture("ja-JP", "日本語"), 45 | Polish = new Culture("pl-PL", "Polski"), 46 | Polish = new Culture("pt-BR", "Português (Brasil)"), 47 | Russian = new Culture("ru-RU", "Русский"), 48 | Turkish = new Culture("tr-TR", "Türkçe"), 49 | Ukrainian = new Culture("uk-ua", "Українська"), 50 | SimplifiedChinese = new Culture("zh-CN", "简体中文"), 51 | TraditionalChinese = new Culture("zh-TW", "繁體中文"), 52 | }; 53 | } 54 | // ReSharper restore StringLiteralTypo 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Services/ClipboardService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.Versioning; 3 | using System.Windows; 4 | 5 | namespace TwitchDownloaderWPF.Services 6 | { 7 | public static class ClipboardService 8 | { 9 | [SupportedOSPlatform("windows")] 10 | public static bool TrySetText(string text, out Exception exception) 11 | { 12 | try 13 | { 14 | // Clipboard.SetText will throw ExternalException if it cannot set the clipboard text or does not 15 | // receive a confirmation from COM. This is only documented on Clipboard.SetDataObject. 16 | Clipboard.SetText(text); 17 | } 18 | catch (Exception e) 19 | { 20 | exception = e; 21 | // Clipboard.SetText seems to throw despite succeeding more often than it fails. Blindly return true for now. 22 | return true; 23 | // return false; 24 | } 25 | 26 | exception = null; 27 | return true; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Services/CultureService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Threading; 4 | using WPFLocalizeExtension.Engine; 5 | 6 | namespace TwitchDownloaderWPF.Services 7 | { 8 | public class CultureService 9 | { 10 | public event EventHandler CultureChanged; 11 | 12 | public void SetApplicationCulture(string culture) 13 | { 14 | try 15 | { 16 | var newCulture = CultureInfo.GetCultureInfo(culture); 17 | LocalizeDictionary.Instance.SetCurrentThreadCulture = true; 18 | LocalizeDictionary.Instance.Culture = newCulture; 19 | CultureInfo.DefaultThreadCurrentCulture = newCulture; 20 | CultureInfo.DefaultThreadCurrentUICulture = newCulture; 21 | Thread.CurrentThread.CurrentCulture = newCulture; 22 | Thread.CurrentThread.CurrentUICulture = newCulture; 23 | 24 | CultureChanged?.Invoke(this, newCulture); 25 | } 26 | catch (CultureNotFoundException) { } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Services/DefaultThemeService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Reflection; 5 | 6 | namespace TwitchDownloaderWPF.Services 7 | { 8 | public static class DefaultThemeService 9 | { 10 | public static bool WriteIncludedThemes() 11 | { 12 | var success = true; 13 | var resourceNames = GetResourceNames(); 14 | var themeResourcePaths = resourceNames.Where(i => i.StartsWith($"{nameof(TwitchDownloaderWPF)}.Themes.")); 15 | 16 | foreach (var themeResourcePath in themeResourcePaths) 17 | { 18 | using var themeStream = GetResourceStream(themeResourcePath); 19 | if (themeStream is null) continue; 20 | 21 | var themePathSplit = themeResourcePath.Split("."); 22 | 23 | var themeName = themePathSplit[^2]; 24 | var themeExtension = themePathSplit[^1]; 25 | var themeFullPath = Path.Combine("Themes", $"{themeName}.{themeExtension}"); 26 | 27 | try 28 | { 29 | using var fs = new FileStream(themeFullPath, FileMode.Create, FileAccess.Write, FileShare.Read); 30 | themeStream.CopyTo(fs); 31 | } 32 | catch (IOException) { } 33 | catch (UnauthorizedAccessException) { } 34 | catch (System.Security.SecurityException) { } 35 | 36 | if (!File.Exists(themeFullPath)) 37 | { 38 | success = false; 39 | } 40 | } 41 | 42 | return success; 43 | } 44 | 45 | private static string[] GetResourceNames() 46 | { 47 | var assembly = Assembly.GetExecutingAssembly(); 48 | return assembly.GetManifestResourceNames(); 49 | } 50 | 51 | private static Stream GetResourceStream(string resourcePath) 52 | { 53 | var assembly = Assembly.GetExecutingAssembly(); 54 | return assembly.GetManifestResourceStream(resourcePath); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Services/FileService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.Versioning; 5 | 6 | namespace TwitchDownloaderWPF.Services 7 | { 8 | public static class FileService 9 | { 10 | [SupportedOSPlatform("windows")] 11 | public static void OpenExplorerForFile(FileInfo fileInfo) 12 | { 13 | var directoryInfo = fileInfo.Directory; 14 | if (directoryInfo is null || !directoryInfo.Exists) 15 | { 16 | return; 17 | } 18 | 19 | fileInfo.Refresh(); 20 | var args = fileInfo.Exists 21 | ? $"/select,\"{fileInfo.FullName}\"" 22 | : $"\"{directoryInfo.FullName}\""; 23 | 24 | Process.Start(new ProcessStartInfo 25 | { 26 | FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), 27 | Arguments = args, 28 | UseShellExecute = true, 29 | WorkingDirectory = directoryInfo.FullName 30 | }); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Services/NativeFunctions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.InteropServices; 3 | using System.Runtime.Versioning; 4 | 5 | namespace TwitchDownloaderWPF.Services 6 | { 7 | [SupportedOSPlatform("windows")] 8 | public static unsafe class NativeFunctions 9 | { 10 | [DllImport("dwmapi.dll", EntryPoint = "DwmSetWindowAttribute", PreserveSig = true)] 11 | public static extern int SetWindowAttribute(IntPtr handle, int attribute, void* attributeValue, uint attributeSize); 12 | 13 | [DllImport("user32.dll", EntryPoint = "GetForegroundWindow", PreserveSig = true)] 14 | public static extern IntPtr GetForegroundWindow(); 15 | 16 | [DllImport("user32.dll", EntryPoint = "FlashWindowEx", PreserveSig = true)] 17 | [return: MarshalAs(UnmanagedType.Bool)] 18 | public static extern bool FlashWindowEx([In] ref FlashWInfo info); 19 | 20 | // https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-flashwinfo 21 | [StructLayout(LayoutKind.Sequential)] 22 | public record struct FlashWInfo 23 | { 24 | public uint StructSize; 25 | public IntPtr WindowHandle; 26 | public uint Flags; 27 | public uint FlashCount; 28 | public uint Timeout; 29 | 30 | // ReSharper disable InconsistentNaming 31 | public const uint FLASHW_STOP = 0; 32 | public const uint FLASHW_CAPTION = 1; 33 | public const uint FLASHW_TRAY = 2; 34 | public const uint FLASHW_ALL = 3; 35 | public const uint FLASHW_TIMER = 4; 36 | public const uint FLASHW_TIMERNOFG = 12; 37 | // ReSharper restore InconsistentNaming 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Services/ThumbnailService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Windows.Media.Imaging; 4 | 5 | namespace TwitchDownloaderWPF.Services 6 | { 7 | public static class ThumbnailService 8 | { 9 | internal const string THUMBNAIL_MISSING_URL = @"https://vod-secure.twitch.tv/_404/404_processing_320x180.png"; 10 | 11 | /// The was 12 | public static BitmapImage GetThumb(string thumbUrl, BitmapCacheOption cacheOption = BitmapCacheOption.OnLoad) 13 | { 14 | ArgumentNullException.ThrowIfNull(thumbUrl); 15 | 16 | var img = new BitmapImage { CacheOption = cacheOption }; 17 | img.BeginInit(); 18 | img.UriSource = new Uri(thumbUrl); 19 | img.EndInit(); 20 | img.DownloadCompleted += static (sender, _) => 21 | { 22 | if (sender is BitmapImage { CanFreeze: true } image) 23 | { 24 | image.Freeze(); 25 | } 26 | }; 27 | return img; 28 | } 29 | 30 | public static bool TryGetThumb(string thumbUrl, [NotNullWhen(true)] out BitmapImage thumbnail) 31 | { 32 | if (string.IsNullOrWhiteSpace(thumbUrl)) 33 | { 34 | thumbnail = null; 35 | return false; 36 | } 37 | 38 | try 39 | { 40 | thumbnail = GetThumb(thumbUrl); 41 | return thumbnail != null; 42 | } 43 | catch 44 | { 45 | thumbnail = null; 46 | return false; 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Themes/Dark.xaml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | true 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Themes/Light.xaml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | false 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Themes/README.txt: -------------------------------------------------------------------------------- 1 | This app supports user created themes! 2 | 3 | To get started, simply duplicate one of the included themes and open it with your favorite 4 | text editor. 5 | 6 | 7 | HandyControl elements do not provide full theming support, however they do provide a dark 8 | variant, hence the dedicated boolean key. 9 | 10 | 11 | The Boolean keys control several things in the application, such as the title bar theme and 12 | the HandyControl element theme. 13 | 14 | 15 | The SolidColorBrush keys control the color of the application and app elements, such as the 16 | app background, text, and border colors. 17 | 18 | 19 | The 'Inner' keys are used to add depth to double-recursive elements. 20 | The following diagram illustrates this: 21 | 22 | /---------------------------[-][#][x] 23 | | AppBackground | 24 | | /-------------------------------\ | 25 | | | AppElementBackground | | 26 | | | /---------------------------\ | | 27 | | | | AppInnerElementBackground | | | 28 | | | | | | | 29 | | | \---------------------------/ | | 30 | | | | | 31 | | \-------------------------------/ | 32 | | | 33 | \-----------------------------------/ 34 | 35 | In this case AppElementBackground is being used by a frame, while AppInnerElementBackground 36 | is being used by a bordered label, blank image background, or similar. 37 | 38 | 39 | If you created a theme and would like for it to be included with the program, feel free to 40 | make a pull request on the github! https://github.com/lay295/TwitchDownloader/pulls 41 | 42 | 43 | Important Notes: 44 | 45 | 'Dark.xaml' and 'Light.xaml' will always be overwritten on application launch. 46 | 47 | File names are read in a non-case sensitive manner, meaning 'Dark.xaml' and 'dark.xaml' 48 | cannot be differentiated. 49 | 50 | Don't forget to edit the author comment at the top of the theme file! -------------------------------------------------------------------------------- /TwitchDownloaderWPF/TwitchTasks/ChatUpdateTask.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using TwitchDownloaderCore; 5 | using TwitchDownloaderCore.Options; 6 | using TwitchDownloaderWPF.Utils; 7 | 8 | namespace TwitchDownloaderWPF.TwitchTasks 9 | { 10 | internal class ChatUpdateTask : TwitchTask 11 | { 12 | public ChatUpdateOptions UpdateOptions { get; init; } 13 | public override string TaskType { get; } = Translations.Strings.ChatUpdate; 14 | public override string OutputFile => UpdateOptions.OutputFile; 15 | 16 | public override void Reinitialize() 17 | { 18 | Progress = 0; 19 | TokenSource = new CancellationTokenSource(); 20 | Exception = null; 21 | CanReinitialize = false; 22 | ChangeStatus(TwitchTaskStatus.Ready); 23 | } 24 | 25 | public override bool CanRun() 26 | { 27 | return Status == TwitchTaskStatus.Ready; 28 | } 29 | 30 | public override async Task RunAsync() 31 | { 32 | if (TokenSource.IsCancellationRequested) 33 | { 34 | TokenSource.Dispose(); 35 | ChangeStatus(TwitchTaskStatus.Canceled); 36 | CanReinitialize = true; 37 | return; 38 | } 39 | 40 | var progress = new WpfTaskProgress(i => Progress = i, s => DisplayStatus = s); 41 | ChatUpdater updater = new ChatUpdater(UpdateOptions, progress); 42 | ChangeStatus(TwitchTaskStatus.Running); 43 | try 44 | { 45 | await updater.ParseJsonAsync(TokenSource.Token); 46 | await updater.UpdateAsync(TokenSource.Token); 47 | if (TokenSource.IsCancellationRequested) 48 | { 49 | ChangeStatus(TwitchTaskStatus.Canceled); 50 | CanReinitialize = true; 51 | } 52 | else 53 | { 54 | progress.ReportProgress(100); 55 | ChangeStatus(TwitchTaskStatus.Finished); 56 | } 57 | } 58 | catch (Exception ex) when (ex is OperationCanceledException or TaskCanceledException && TokenSource.IsCancellationRequested) 59 | { 60 | ChangeStatus(TwitchTaskStatus.Canceled); 61 | CanReinitialize = true; 62 | } 63 | catch (Exception ex) 64 | { 65 | ChangeStatus(TwitchTaskStatus.Failed); 66 | Exception = ex; 67 | CanReinitialize = true; 68 | } 69 | TokenSource.Dispose(); 70 | GC.Collect(-1, GCCollectionMode.Default, false); 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/TwitchTasks/ClipDownloadTask.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using TwitchDownloaderCore; 5 | using TwitchDownloaderCore.Options; 6 | using TwitchDownloaderWPF.Utils; 7 | 8 | namespace TwitchDownloaderWPF.TwitchTasks 9 | { 10 | internal class ClipDownloadTask : TwitchTask 11 | { 12 | public ClipDownloadOptions DownloadOptions { get; init; } 13 | public override string TaskType { get; } = Translations.Strings.ClipDownload; 14 | public override string OutputFile => DownloadOptions.Filename; 15 | 16 | public override void Reinitialize() 17 | { 18 | Progress = 0; 19 | TokenSource = new CancellationTokenSource(); 20 | Exception = null; 21 | CanReinitialize = false; 22 | ChangeStatus(TwitchTaskStatus.Ready); 23 | } 24 | 25 | public override bool CanRun() 26 | { 27 | return Status == TwitchTaskStatus.Ready; 28 | } 29 | 30 | public override async Task RunAsync() 31 | { 32 | if (TokenSource.IsCancellationRequested) 33 | { 34 | TokenSource.Dispose(); 35 | ChangeStatus(TwitchTaskStatus.Canceled); 36 | CanReinitialize = true; 37 | return; 38 | } 39 | 40 | var progress = new WpfTaskProgress(i => Progress = i, s => DisplayStatus = s); 41 | ClipDownloader downloader = new ClipDownloader(DownloadOptions, progress); 42 | ChangeStatus(TwitchTaskStatus.Running); 43 | try 44 | { 45 | await downloader.DownloadAsync(TokenSource.Token); 46 | if (TokenSource.IsCancellationRequested) 47 | { 48 | ChangeStatus(TwitchTaskStatus.Canceled); 49 | CanReinitialize = true; 50 | } 51 | else 52 | { 53 | progress.ReportProgress(100); 54 | ChangeStatus(TwitchTaskStatus.Finished); 55 | } 56 | } 57 | catch (Exception ex) when (ex is OperationCanceledException or TaskCanceledException && TokenSource.IsCancellationRequested) 58 | { 59 | ChangeStatus(TwitchTaskStatus.Canceled); 60 | CanReinitialize = true; 61 | } 62 | catch (Exception ex) 63 | { 64 | ChangeStatus(TwitchTaskStatus.Failed); 65 | Exception = ex; 66 | CanReinitialize = true; 67 | } 68 | TokenSource.Dispose(); 69 | GC.Collect(-1, GCCollectionMode.Default, false); 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /TwitchDownloaderWPF/TwitchTasks/TaskData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Windows.Media; 3 | 4 | namespace TwitchDownloaderWPF.TwitchTasks 5 | { 6 | public class TaskData 7 | { 8 | public string Id { get; set; } 9 | public string StreamerName { get; set; } 10 | public string StreamerId { get; set; } 11 | public string ClipperName { get; set; } 12 | public string ClipperId { get; set; } 13 | public string Title { get; set; } 14 | public ImageSource Thumbnail { get; set; } 15 | public DateTime Time { get; set; } 16 | public int Length { get; set; } 17 | public int Views { get; set; } 18 | public string Game { get; set; } 19 | public string LengthFormatted 20 | { 21 | get 22 | { 23 | TimeSpan time = TimeSpan.FromSeconds(Length); 24 | if ((int)time.TotalHours > 0) 25 | { 26 | return $"{(int)time.TotalHours}:{time.Minutes:D2}:{time.Seconds:D2}"; 27 | } 28 | 29 | if ((int)time.TotalMinutes > 0) 30 | { 31 | return $"{time.Minutes:D2}:{time.Seconds:D2}"; 32 | } 33 | 34 | return $"{time.Seconds:D1}s"; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/TwitchTasks/TwitchTaskStatus.cs: -------------------------------------------------------------------------------- 1 | namespace TwitchDownloaderWPF.TwitchTasks 2 | { 3 | public enum TwitchTaskStatus 4 | { 5 | Waiting, 6 | Ready, 7 | Running, 8 | Failed, 9 | Finished, 10 | Stopping, 11 | Canceled 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/Utils/LiveVideoMonitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using TwitchDownloaderCore; 4 | using TwitchDownloaderCore.Extensions; 5 | using TwitchDownloaderCore.Interfaces; 6 | 7 | namespace TwitchDownloaderWPF.Utils 8 | { 9 | internal class LiveVideoMonitor 10 | { 11 | private DateTimeOffset _nextTimeToCheck; 12 | private bool _lastCheck; 13 | private int _consecutiveErrors; 14 | private readonly long _videoId; 15 | private readonly ITaskLogger _logger; 16 | 17 | public LiveVideoMonitor(long videoId, ITaskLogger logger = null) 18 | { 19 | _videoId = videoId; 20 | _logger = logger; 21 | } 22 | 23 | private static double GenerateNextRandomInterval() 24 | { 25 | const int SECONDS_LOWER_BOUND = 30; 26 | const int SECONDS_UPPER_BOUND = 34; 27 | return Random.Shared.NextDouble(SECONDS_LOWER_BOUND, SECONDS_UPPER_BOUND); 28 | } 29 | 30 | public async Task IsVideoRecording() 31 | { 32 | if (DateTimeOffset.UtcNow > _nextTimeToCheck) 33 | { 34 | try 35 | { 36 | var videoResponse = await TwitchHelper.GetVideoInfo(_videoId); 37 | _lastCheck = videoResponse.data.video.status == "RECORDING"; 38 | _consecutiveErrors = 0; 39 | } 40 | catch (Exception ex) 41 | { 42 | const int MAX_ERRORS = 6; 43 | _consecutiveErrors++; 44 | 45 | _logger?.LogVerbose($"Error while getting monitor info for {_videoId}: {ex.Message} {MAX_ERRORS - _consecutiveErrors} retries left."); 46 | if (_consecutiveErrors >= MAX_ERRORS) 47 | { 48 | _logger?.LogError($"Error while getting monitor info for {_videoId}: {ex.Message} Assuming video is not live."); 49 | return false; 50 | } 51 | } 52 | 53 | _nextTimeToCheck = DateTimeOffset.UtcNow.AddSeconds(GenerateNextRandomInterval()); 54 | } 55 | 56 | return _lastCheck; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /TwitchDownloaderWPF/WindowRangeSelect.xaml: -------------------------------------------------------------------------------- 1 |  14 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |