Repository: Tyrrrz/YoutubeDownloader Branch: prime Commit: 9dbf1219d3b0 Files: 103 Total size: 255.6 KB Directory structure: gitextract_i1gh213a/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ └── config.yml │ ├── dependabot.yml │ └── workflows/ │ └── main.yml ├── .gitignore ├── Directory.Build.props ├── Directory.Packages.props ├── License.txt ├── NuGet.config ├── Readme.md ├── YoutubeDownloader/ │ ├── .gitignore │ ├── App.axaml │ ├── App.axaml.cs │ ├── Converters/ │ │ ├── EqualityConverter.cs │ │ ├── MarkdownToInlinesConverter.cs │ │ ├── VideoQualityPreferenceToStringConverter.cs │ │ ├── VideoToHighestQualityThumbnailUrlStringConverter.cs │ │ └── VideoToLowestQualityThumbnailUrlStringConverter.cs │ ├── Download-FFmpeg.ps1 │ ├── Framework/ │ │ ├── DialogManager.cs │ │ ├── DialogViewModelBase.cs │ │ ├── SnackbarManager.cs │ │ ├── ThemeVariant.cs │ │ ├── UserControl.cs │ │ ├── ViewManager.cs │ │ ├── ViewModelBase.cs │ │ ├── ViewModelManager.cs │ │ └── Window.cs │ ├── Localization/ │ │ ├── Language.cs │ │ ├── LocalizationManager.English.cs │ │ ├── LocalizationManager.French.cs │ │ ├── LocalizationManager.German.cs │ │ ├── LocalizationManager.Spanish.cs │ │ ├── LocalizationManager.Ukrainian.cs │ │ └── LocalizationManager.cs │ ├── Program.cs │ ├── Publish-MacOSBundle.ps1 │ ├── Services/ │ │ ├── SettingsService.AuthCookiesEncryptionConverter.cs │ │ ├── SettingsService.cs │ │ └── UpdateService.cs │ ├── StartOptions.cs │ ├── Utils/ │ │ ├── Disposable.cs │ │ ├── DisposableCollector.cs │ │ ├── Extensions/ │ │ │ ├── AvaloniaExtensions.cs │ │ │ ├── DirectoryExtensions.cs │ │ │ ├── DisposableExtensions.cs │ │ │ ├── EnvironmentExtensions.cs │ │ │ ├── NotifyPropertyChangedExtensions.cs │ │ │ ├── PathExtensions.cs │ │ │ └── ProcessExtensions.cs │ │ ├── NativeMethods.cs │ │ └── ResizableSemaphore.cs │ ├── ViewModels/ │ │ ├── Components/ │ │ │ ├── DashboardViewModel.cs │ │ │ ├── DownloadStatus.cs │ │ │ └── DownloadViewModel.cs │ │ ├── Dialogs/ │ │ │ ├── AuthSetupViewModel.cs │ │ │ ├── DownloadMultipleSetupViewModel.cs │ │ │ ├── DownloadSingleSetupViewModel.cs │ │ │ ├── MessageBoxViewModel.cs │ │ │ └── SettingsViewModel.cs │ │ └── MainViewModel.cs │ ├── Views/ │ │ ├── Components/ │ │ │ ├── DashboardView.axaml │ │ │ └── DashboardView.axaml.cs │ │ ├── Dialogs/ │ │ │ ├── AuthSetupView.axaml │ │ │ ├── AuthSetupView.axaml.cs │ │ │ ├── DownloadMultipleSetupView.axaml │ │ │ ├── DownloadMultipleSetupView.axaml.cs │ │ │ ├── DownloadSingleSetupView.axaml │ │ │ ├── DownloadSingleSetupView.axaml.cs │ │ │ ├── MessageBoxView.axaml │ │ │ ├── MessageBoxView.axaml.cs │ │ │ ├── SettingsView.axaml │ │ │ └── SettingsView.axaml.cs │ │ ├── MainView.axaml │ │ └── MainView.axaml.cs │ ├── YoutubeDownloader.csproj │ └── app.manifest ├── YoutubeDownloader.Core/ │ ├── Downloading/ │ │ ├── FFmpeg.cs │ │ ├── FileNameTemplate.cs │ │ ├── VideoDownloadOption.cs │ │ ├── VideoDownloadPreference.cs │ │ ├── VideoDownloader.cs │ │ └── VideoQualityPreference.cs │ ├── Resolving/ │ │ ├── QueryResolver.cs │ │ ├── QueryResult.cs │ │ └── QueryResultKind.cs │ ├── Tagging/ │ │ ├── MediaFile.cs │ │ ├── MediaTagInjector.cs │ │ ├── MusicBrainzClient.cs │ │ └── MusicBrainzRecording.cs │ ├── Utils/ │ │ ├── Extensions/ │ │ │ ├── AsyncCollectionExtensions.cs │ │ │ ├── CollectionExtensions.cs │ │ │ ├── GenericExtensions.cs │ │ │ ├── PathExtensions.cs │ │ │ ├── StringExtensions.cs │ │ │ └── YoutubeExtensions.cs │ │ ├── Http.cs │ │ ├── ThrottleLock.cs │ │ └── Url.cs │ └── YoutubeDownloader.Core.csproj ├── YoutubeDownloader.sln ├── favicon.icns └── global.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: 🐛 Bug report description: Report broken functionality. labels: [bug] body: - type: markdown attributes: value: | - Avoid generic or vague titles such as "Something's not working" or "A couple of problems" — be as descriptive as possible. - Keep your issue focused on one single problem. If you have multiple bug reports, please create a separate issue for each of them. - Issues should represent **complete and actionable** work items. If you are unsure about something or have a question, please start a [discussion](https://github.com/Tyrrrz/YoutubeDownloader/discussions/new) instead. - Remember that **YoutubeDownloader** is an open-source project funded by the community. If you find it useful, **please consider [donating](https://tyrrrz.me/donate) to support its development**. ___ - type: input attributes: label: Version description: Which version of the application does this bug affect? Make sure you're not using an outdated version. placeholder: v1.0.0 validations: required: true - type: input attributes: label: Platform description: Which platform do you experience this bug on? placeholder: Windows 11 validations: required: true - type: textarea attributes: label: Steps to reproduce description: > Minimum steps required to reproduce the bug, including prerequisites, application settings, video URL(s), or other relevant items. The information provided in this field must be readily actionable, meaning that anyone should be able to reproduce the bug by following these steps. placeholder: | Video or playlist URL: ... Download settings: - ... Application settings: - ... Steps: - Step 1 - Step 2 - Step 3 validations: required: true - type: textarea attributes: label: Details description: Clear and thorough explanation of the bug, including any additional information you may find relevant. placeholder: | - Expected behavior: ... - Actual behavior: ... validations: required: true - type: checkboxes attributes: label: Checklist description: Quick list of checks to ensure that everything is in order. options: - label: I have looked through existing issues to make sure that this bug has not been reported before required: true - label: I have provided a descriptive title for this issue required: true - label: I have made sure that this bug is reproducible on the latest version of the application required: true - label: I have provided all the information needed to reproduce this bug as efficiently as possible required: true - label: I have sponsored this project required: false - label: I have not read any of the above and just checked all the boxes to submit the issue required: false - type: markdown attributes: value: | If you are struggling to provide actionable reproduction steps, or if something else is preventing you from creating a complete bug report, please start a [discussion](https://github.com/Tyrrrz/YoutubeDownloader/discussions/new) instead. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: ⚠ Feature request url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests. - name: 📖 Documentation url: https://github.com/Tyrrrz/YoutubeDownloader/wiki about: Find usage guides and frequently asked questions. - name: 🗨 Discussions url: https://github.com/Tyrrrz/YoutubeDownloader/discussions/new about: Ask and answer questions. - name: 💬 Discord server url: https://discord.gg/2SUWKFnHSm about: Chat with the project community. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: github-actions directory: "/" schedule: interval: monthly labels: - enhancement groups: actions: patterns: - "*" - package-ecosystem: nuget directory: "/" schedule: interval: monthly labels: - enhancement groups: nuget: patterns: - "*" ================================================ FILE: .github/workflows/main.yml ================================================ name: main on: workflow_dispatch: push: branches: - prime tags: - "*" pull_request: branches: - prime env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true jobs: format: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install .NET uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 # Build the project separately to discern between build and format errors - name: Build run: > dotnet build -p:CSharpier_Bypass=true --configuration Release - name: Verify formatting id: verify run: > dotnet build -t:CSharpierFormat --configuration Release --no-restore - name: Report issues if: ${{ failure() && steps.verify.outcome == 'failure' }} run: echo "::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them." pack: strategy: matrix: rid: - win-arm64 - win-x86 - win-x64 - linux-arm64 # Linux x86 is not supported by .NET # - linux-x86 - linux-x64 - osx-arm64 - osx-x64 bundle-ffmpeg: - true - false include: - bundle-ffmpeg: true artifact-name-base: YoutubeDownloader - bundle-ffmpeg: false artifact-name-base: YoutubeDownloader.Bare runs-on: ${{ startsWith(matrix.rid, 'win-') && 'windows-latest' || startsWith(matrix.rid, 'osx-') && 'macos-latest' || 'ubuntu-latest' }} timeout-minutes: 10 permissions: actions: write contents: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install .NET uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Publish app run: > dotnet publish YoutubeDownloader -p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }} -p:CSharpier_Bypass=true -p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }} -p:DownloadFFmpeg=${{ matrix.bundle-ffmpeg }} -p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }} --output YoutubeDownloader/bin/publish --configuration Release --runtime ${{ matrix.rid }} --self-contained - name: Upload app binaries uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ matrix.artifact-name-base }}.${{ matrix.rid }} path: YoutubeDownloader/bin/publish if-no-files-found: error release: if: ${{ github.ref_type == 'tag' }} needs: - format - pack runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write steps: - name: Create release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: > gh release create ${{ github.ref_name }} --repo ${{ github.event.repository.full_name }} --title ${{ github.ref_name }} --generate-notes --verify-tag deploy: needs: release strategy: matrix: rid: - win-arm64 - win-x86 - win-x64 - linux-arm64 # Linux x86 is not supported by .NET # - linux-x86 - linux-x64 - osx-arm64 - osx-x64 bundle-ffmpeg: - true - false include: - bundle-ffmpeg: true artifact-name-base: YoutubeDownloader - bundle-ffmpeg: false artifact-name-base: YoutubeDownloader.Bare runs-on: ubuntu-latest timeout-minutes: 10 permissions: actions: read contents: write steps: - name: Download app binaries uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: ${{ matrix.artifact-name-base }}.${{ matrix.rid }} path: YoutubeDownloader/ - name: Set permissions if: ${{ !startsWith(matrix.rid, 'win-') }} run: | [ -f YoutubeDownloader/YoutubeDownloader ] && chmod +x YoutubeDownloader/YoutubeDownloader || true [ -f YoutubeDownloader/ffmpeg ] && chmod +x YoutubeDownloader/ffmpeg || true # macOS bundle [ -f YoutubeDownloader/YoutubeDownloader.app/Contents/MacOS/YoutubeDownloader ] && chmod +x YoutubeDownloader/YoutubeDownloader.app/Contents/MacOS/YoutubeDownloader || true [ -f YoutubeDownloader/YoutubeDownloader.app/Contents/MacOS/ffmpeg ] && chmod +x YoutubeDownloader/YoutubeDownloader.app/Contents/MacOS/ffmpeg || true - name: Create package # Change into the artifacts directory to avoid including the directory itself in the zip archive working-directory: YoutubeDownloader/ run: zip -r ../${{ matrix.artifact-name-base }}.${{ matrix.rid }}.zip . - name: Upload release asset env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: > gh release upload ${{ github.ref_name }} ${{ matrix.artifact-name-base }}.${{ matrix.rid }}.zip --repo ${{ github.event.repository.full_name }} notify: needs: deploy runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read steps: - name: Notify Discord uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3 with: url: ${{ secrets.DISCORD_WEBHOOK }} method: POST headers: | Content-Type: application/json; charset=UTF-8 body: | { "avatar_url": "https://raw.githubusercontent.com/${{ github.event.repository.full_name }}/${{ github.ref_name }}/favicon.png", "content": "[**${{ github.event.repository.name }}**](<${{ github.event.repository.html_url }}>) v${{ github.ref_name }} has been released!" } retry-count: 5 ================================================ FILE: .gitignore ================================================ # User-specific files .vs/ .idea/ *.suo *.user # Build results bin/ obj/ # Avalonia .avalonia-build-tasks/ # Test results TestResults/ ================================================ FILE: Directory.Build.props ================================================ net10.0 999.9.9-dev Tyrrrz Copyright (C) Oleksii Holub preview enable true false ================================================ FILE: Directory.Packages.props ================================================ true ================================================ FILE: License.txt ================================================ MIT License Copyright (c) 2018-2026 Oleksii Holub Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: NuGet.config ================================================ ================================================ FILE: Readme.md ================================================ # YoutubeDownloader [![Status](https://img.shields.io/badge/status-maintenance-ffd700.svg)](https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md) [![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://tyrrrz.me/ukraine) [![Build](https://img.shields.io/github/actions/workflow/status/Tyrrrz/YoutubeDownloader/main.yml?branch=prime)](https://github.com/Tyrrrz/YoutubeDownloader/actions) [![Release](https://img.shields.io/github/release/Tyrrrz/YoutubeDownloader.svg)](https://github.com/Tyrrrz/YoutubeDownloader/releases) [![Downloads](https://img.shields.io/github/downloads/Tyrrrz/YoutubeDownloader/total.svg)](https://github.com/Tyrrrz/YoutubeDownloader/releases) [![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm) [![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848)
Development of this project is entirely funded by the community. Consider donating to support!

Icon

**YoutubeDownloader** is an application that lets you download videos from YouTube. You can copy-paste URL of any video, playlist or channel and download it directly in a format of your choice. It also supports searching by keywords, which is helpful if you want to quickly look up and download videos. > [!NOTE] > This application uses [**YoutubeExplode**](https://github.com/Tyrrrz/YoutubeExplode) under the hood to interact with YouTube. > You can [read this article](https://tyrrrz.me/blog/reverse-engineering-youtube-revisited) to learn more about how it works. ## Terms of use[[?]](https://github.com/Tyrrrz/.github/blob/prime/docs/why-so-political.md) By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements: - You **condemn Russia and its military aggression against Ukraine** - You **recognize that Russia is an occupant that unlawfully invaded a sovereign state** - You **support Ukraine's territorial integrity, including its claims over temporarily occupied territories of Crimea and Donbas** - You **reject false narratives perpetuated by Russian state propaganda** To learn more about the war and how you can help, [click here](https://tyrrrz.me/ukraine). Glory to Ukraine! 🇺🇦 ## Download - 🟢 **[Stable release](https://github.com/Tyrrrz/YoutubeDownloader/releases/latest)** - 🟠 [CI build](https://github.com/Tyrrrz/YoutubeDownloader/actions/workflows/main.yml) > [!IMPORTANT] > To launch the app on MacOS, you need to first remove the downloaded file from quarantine. > You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine YoutubeDownloader.app`. > [!NOTE] > If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture. > [!NOTE] > **YoutubeDownloader** comes bundled with [FFmpeg](https://ffmpeg.org) which is used for processing videos. > You can also download a version of **YoutubeDownloader** that doesn't include FFmpeg (`YoutubeDownloader.Bare.*` builds) if you prefer to use your own installation. ## Features - Cross-platform graphical user interface - Download videos by URL - Download videos from playlists or channels - Download videos by search query - Selectable video quality and format - Automatically embed audio tracks in alternative languages - Automatically embed subtitles - Automatically inject media tags - Log in with a YouTube account to access private content ## Screenshots ![list](.assets/list.png) ![single](.assets/single.png) ![multiple](.assets/multiple.png) ================================================ FILE: YoutubeDownloader/.gitignore ================================================ /ffmpeg* ================================================ FILE: YoutubeDownloader/App.axaml ================================================ ================================================ FILE: YoutubeDownloader/App.axaml.cs ================================================ using System; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Platform; using AvaloniaWebView; using Material.Styles.Themes; using Microsoft.Extensions.DependencyInjection; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using YoutubeDownloader.Services; using YoutubeDownloader.Utils; using YoutubeDownloader.Utils.Extensions; using YoutubeDownloader.ViewModels; using YoutubeDownloader.ViewModels.Components; using YoutubeDownloader.ViewModels.Dialogs; using YoutubeDownloader.Views; namespace YoutubeDownloader; public class App : Application, IDisposable { private readonly DisposableCollector _eventRoot = new(); private readonly ServiceProvider _services; private readonly SettingsService _settingsService; private readonly MainViewModel _mainViewModel; private bool _isDisposed; public App() { var services = new ServiceCollection(); // Framework services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // Localization services.AddSingleton(); // Services services.AddSingleton(); services.AddSingleton(); // View models services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); _services = services.BuildServiceProvider(true); _settingsService = _services.GetRequiredService(); _mainViewModel = _services.GetRequiredService().CreateMainViewModel(); // Re-initialize the theme when the user changes it _eventRoot.Add( _settingsService.WatchProperty( o => o.Theme, () => { RequestedThemeVariant = _settingsService.Theme switch { ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light, ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark, _ => Avalonia.Styling.ThemeVariant.Default, }; InitializeTheme(); } ) ); } public override void Initialize() { base.Initialize(); AvaloniaXamlLoader.Load(this); } public override void RegisterServices() { base.RegisterServices(); AvaloniaWebViewBuilder.Initialize(config => config.IsInPrivateModeEnabled = true); } private void InitializeTheme() { var actualTheme = RequestedThemeVariant?.Key switch { "Light" => PlatformThemeVariant.Light, "Dark" => PlatformThemeVariant.Dark, _ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light, }; this.LocateMaterialTheme().CurrentTheme = actualTheme == PlatformThemeVariant.Light ? Theme.Create(Theme.Light, Color.Parse("#343838"), Color.Parse("#F9A825")) : Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825")); } public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainView { DataContext = _mainViewModel }; void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args) { if (sender is IControlledApplicationLifetime lifetime) lifetime.Exit -= OnExit; Dispose(); } // Although `App.Dispose()` is invoked from `Program.Main(...)`, on some platforms // it may be called too late in the shutdown lifecycle. Attach an exit // handler to ensure timely disposal as a safeguard. // https://github.com/Tyrrrz/YoutubeDownloader/issues/795 desktop.Exit += OnExit; } base.OnFrameworkInitializationCompleted(); // Set up initial custom theme colors InitializeTheme(); // Load settings _settingsService.Load(); } private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) => // Re-initialize the theme when the system theme changes InitializeTheme(); public void Dispose() { if (_isDisposed) return; _isDisposed = true; _eventRoot.Dispose(); _services.Dispose(); } } ================================================ FILE: YoutubeDownloader/Converters/EqualityConverter.cs ================================================ using System; using System.Collections.Generic; using System.Globalization; using Avalonia.Data.Converters; namespace YoutubeDownloader.Converters; public class EqualityConverter(bool isInverted) : IValueConverter { public static EqualityConverter IsEqual { get; } = new(false); public static EqualityConverter IsNotEqual { get; } = new(true); public object? Convert( object? value, Type targetType, object? parameter, CultureInfo culture ) => EqualityComparer.Default.Equals(value, parameter) != isInverted; public object ConvertBack( object? value, Type targetType, object? parameter, CultureInfo culture ) => throw new NotSupportedException(); } ================================================ FILE: YoutubeDownloader/Converters/MarkdownToInlinesConverter.cs ================================================ using System; using System.Globalization; using Avalonia.Controls.Documents; using Avalonia.Data.Converters; using Avalonia.Media; using Markdig; using Markdig.Syntax; using Markdig.Syntax.Inlines; using MarkdownInline = Markdig.Syntax.Inlines.Inline; namespace YoutubeDownloader.Converters; public class MarkdownToInlinesConverter : IValueConverter { public static readonly MarkdownToInlinesConverter Instance = new(); private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() .UseEmphasisExtras() .Build(); private static void ProcessInline( InlineCollection inlines, MarkdownInline markdownInline, FontWeight? fontWeight = null, FontStyle? fontStyle = null, TextDecorationCollection? textDecorations = null ) { switch (markdownInline) { case LiteralInline literal: { var run = new Run(literal.Content.ToString()); if (fontWeight is not null) run.FontWeight = fontWeight.Value; if (fontStyle is not null) run.FontStyle = fontStyle.Value; if (textDecorations is not null) run.TextDecorations = textDecorations; inlines.Add(run); break; } case LineBreakInline: { inlines.Add(new LineBreak()); break; } case EmphasisInline emphasis: { var newWeight = fontWeight; var newStyle = fontStyle; var newDecorations = textDecorations; switch (emphasis.DelimiterChar) { case '*' or '_' when emphasis.DelimiterCount == 2: newWeight = FontWeight.SemiBold; break; case '*' or '_': newStyle = FontStyle.Italic; break; case '~': newDecorations = TextDecorations.Strikethrough; break; case '+': newDecorations = TextDecorations.Underline; break; } foreach (var child in emphasis) ProcessInline(inlines, child, newWeight, newStyle, newDecorations); break; } case ContainerInline container: { foreach (var child in container) ProcessInline(inlines, child, fontWeight, fontStyle, textDecorations); break; } } } public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { var inlines = new InlineCollection(); if (value is not string { Length: > 0 } text) return inlines; var isFirstParagraph = true; foreach (var block in Markdown.Parse(text, MarkdownPipeline)) { if (block is not ParagraphBlock { Inline: not null } paragraph) continue; if (!isFirstParagraph) { // Insert a blank line between paragraphs inlines.Add(new LineBreak()); inlines.Add(new LineBreak()); } isFirstParagraph = false; foreach (var markdownInline in paragraph.Inline) ProcessInline(inlines, markdownInline); } return inlines; } public object? ConvertBack( object? value, Type targetType, object? parameter, CultureInfo culture ) => throw new NotSupportedException(); } ================================================ FILE: YoutubeDownloader/Converters/VideoQualityPreferenceToStringConverter.cs ================================================ using System; using System.Globalization; using Avalonia.Data.Converters; using YoutubeDownloader.Core.Downloading; namespace YoutubeDownloader.Converters; public class VideoQualityPreferenceToStringConverter : IValueConverter { public static VideoQualityPreferenceToStringConverter Instance { get; } = new(); public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { if (value is VideoQualityPreference preference) return preference.GetDisplayName(); return default(string); } public object ConvertBack( object? value, Type targetType, object? parameter, CultureInfo culture ) => throw new NotSupportedException(); } ================================================ FILE: YoutubeDownloader/Converters/VideoToHighestQualityThumbnailUrlStringConverter.cs ================================================ using System; using System.Globalization; using Avalonia.Data.Converters; using YoutubeExplode.Common; using YoutubeExplode.Videos; namespace YoutubeDownloader.Converters; public class VideoToHighestQualityThumbnailUrlStringConverter : IValueConverter { public static VideoToHighestQualityThumbnailUrlStringConverter Instance { get; } = new(); public object? Convert( object? value, Type targetType, object? parameter, CultureInfo culture ) => value is IVideo video ? video.Thumbnails.TryGetWithHighestResolution()?.Url : null; public object ConvertBack( object? value, Type targetType, object? parameter, CultureInfo culture ) => throw new NotSupportedException(); } ================================================ FILE: YoutubeDownloader/Converters/VideoToLowestQualityThumbnailUrlStringConverter.cs ================================================ using System; using System.Globalization; using System.Linq; using Avalonia.Data.Converters; using YoutubeExplode.Videos; namespace YoutubeDownloader.Converters; public class VideoToLowestQualityThumbnailUrlStringConverter : IValueConverter { public static VideoToLowestQualityThumbnailUrlStringConverter Instance { get; } = new(); public object? Convert( object? value, Type targetType, object? parameter, CultureInfo culture ) => value is IVideo video ? video.Thumbnails.MinBy(t => t.Resolution.Area)?.Url : null; public object ConvertBack( object? value, Type targetType, object? parameter, CultureInfo culture ) => throw new NotSupportedException(); } ================================================ FILE: YoutubeDownloader/Download-FFmpeg.ps1 ================================================ param ( [Parameter(Mandatory=$false)] [string]$Platform, [Parameter(Mandatory=$false)] [string]$OutputPath = $PSScriptRoot ) $ErrorActionPreference = "Stop" # If the platform is not specified, use the current OS/arch if (-not $Platform) { $arch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture if ($isWindows) { $Platform = "windows-$arch" } elseif ($isLinux) { $Platform = "linux-$arch" } elseif ($isMacOS) { $Platform = "osx-$arch" } else { throw "Unsupported platform" } } # Normalize platform identifier $Platform = $Platform.ToLower().Replace("win-", "windows-") # Identify the FFmpeg filename based on the platform $fileName = if ($Platform.Contains("windows-")) { "ffmpeg.exe" } else { "ffmpeg" } # If the output path is an existing directory, append the default file name for the platform if (Test-Path $OutputPath -PathType Container) { $OutputPath = Join-Path $OutputPath $fileName } # Delete the existing file if it exists if (Test-Path $OutputPath) { Remove-Item $OutputPath } # Download the archive Write-Host "Downloading FFmpeg for $Platform..." $http = New-Object System.Net.WebClient try { $http.DownloadFile("https://github.com/Tyrrrz/FFmpegBin/releases/download/7.1.2/ffmpeg-$Platform.zip", "$OutputPath.zip") } finally { $http.Dispose() } try { # Extract FFmpeg Add-Type -Assembly System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead("$OutputPath.zip") try { [IO.Compression.ZipFileExtensions]::ExtractToFile($zip.GetEntry($fileName), $OutputPath) } finally { $zip.Dispose() } Write-Host "Done downloading FFmpeg." } finally { # Clean up Remove-Item "$OutputPath.zip" -Force } ================================================ FILE: YoutubeDownloader/Framework/DialogManager.cs ================================================ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Platform.Storage; using DialogHostAvalonia; using YoutubeDownloader.Utils.Extensions; namespace YoutubeDownloader.Framework; public class DialogManager : IDisposable { private readonly SemaphoreSlim _dialogLock = new(1, 1); public async Task ShowDialogAsync(DialogViewModelBase dialog) { await _dialogLock.WaitAsync(); try { await DialogHost.Show( dialog, // It's fine to await in a void method here because it's an event handler // ReSharper disable once AsyncVoidLambda async (object _, DialogOpenedEventArgs args) => { await dialog.WaitForCloseAsync(); try { args.Session.Close(); } catch (InvalidOperationException) { // Dialog host is already processing a close operation } } ); // Yield to allow DialogHost to fully reset its state before // another dialog is shown (e.g. when dialogs are shown sequentially) await Task.Yield(); return dialog.DialogResult; } finally { _dialogLock.Release(); } } public async Task PromptOpenFilePathAsync( IReadOnlyList? fileTypes = null ) { var topLevel = Application.Current?.ApplicationLifetime?.TryGetTopLevel() ?? throw new ApplicationException("Could not find the top-level visual element."); var result = await topLevel.StorageProvider.OpenFilePickerAsync( new FilePickerOpenOptions { FileTypeFilter = fileTypes, AllowMultiple = false } ); var file = result.FirstOrDefault(); return file?.TryGetLocalPath() ?? file?.Path.ToString(); } public async Task PromptSaveFilePathAsync( IReadOnlyList? fileTypes = null, string defaultFilePath = "" ) { var topLevel = Application.Current?.ApplicationLifetime?.TryGetTopLevel() ?? throw new ApplicationException("Could not find the top-level visual element."); var file = await topLevel.StorageProvider.SaveFilePickerAsync( new FilePickerSaveOptions { FileTypeChoices = fileTypes, SuggestedFileName = defaultFilePath, DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.'), } ); return file?.TryGetLocalPath() ?? file?.Path.ToString(); } public async Task PromptDirectoryPathAsync(string defaultDirPath = "") { var topLevel = Application.Current?.ApplicationLifetime?.TryGetTopLevel() ?? throw new ApplicationException("Could not find the top-level visual element."); var result = await topLevel.StorageProvider.OpenFolderPickerAsync( new FolderPickerOpenOptions { AllowMultiple = false, SuggestedStartLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync( defaultDirPath ), } ); var directory = result.FirstOrDefault(); if (directory is null) return null; return directory.TryGetLocalPath() ?? directory.Path.ToString(); } public void Dispose() => _dialogLock.Dispose(); } ================================================ FILE: YoutubeDownloader/Framework/DialogViewModelBase.cs ================================================ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace YoutubeDownloader.Framework; public abstract partial class DialogViewModelBase : ViewModelBase { private readonly TaskCompletionSource _closeTcs = new( TaskCreationOptions.RunContinuationsAsynchronously ); [ObservableProperty] public partial T? DialogResult { get; set; } [RelayCommand] protected void Close(T dialogResult) { DialogResult = dialogResult; _closeTcs.TrySetResult(dialogResult); } public async Task WaitForCloseAsync() => await _closeTcs.Task; } public abstract class DialogViewModelBase : DialogViewModelBase; ================================================ FILE: YoutubeDownloader/Framework/SnackbarManager.cs ================================================ using System; using Avalonia.Threading; using Material.Styles.Controls; using Material.Styles.Models; namespace YoutubeDownloader.Framework; public class SnackbarManager { private readonly TimeSpan _defaultDuration = TimeSpan.FromSeconds(5); public void Notify(string message, TimeSpan? duration = null) => SnackbarHost.Post( new SnackbarModel(message, duration ?? _defaultDuration), null, DispatcherPriority.Normal ); public void Notify( string message, string actionText, Action actionHandler, TimeSpan? duration = null ) => SnackbarHost.Post( new SnackbarModel( message, duration ?? _defaultDuration, new SnackbarButtonModel { Text = actionText, Action = actionHandler } ), null, DispatcherPriority.Normal ); } ================================================ FILE: YoutubeDownloader/Framework/ThemeVariant.cs ================================================ namespace YoutubeDownloader.Framework; public enum ThemeVariant { System, Light, Dark, } ================================================ FILE: YoutubeDownloader/Framework/UserControl.cs ================================================ using System; using Avalonia.Controls; namespace YoutubeDownloader.Framework; public class UserControl : UserControl { public new TDataContext DataContext { get => base.DataContext is TDataContext dataContext ? dataContext : throw new InvalidCastException( $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." ); set => base.DataContext = value; } } ================================================ FILE: YoutubeDownloader/Framework/ViewManager.cs ================================================ using Avalonia.Controls; using Avalonia.Controls.Templates; using YoutubeDownloader.ViewModels; using YoutubeDownloader.ViewModels.Components; using YoutubeDownloader.ViewModels.Dialogs; using YoutubeDownloader.Views; using YoutubeDownloader.Views.Components; using YoutubeDownloader.Views.Dialogs; namespace YoutubeDownloader.Framework; public partial class ViewManager { private Control? TryCreateView(ViewModelBase viewModel) => viewModel switch { MainViewModel => new MainView(), DashboardViewModel => new DashboardView(), AuthSetupViewModel => new AuthSetupView(), DownloadMultipleSetupViewModel => new DownloadMultipleSetupView(), DownloadSingleSetupViewModel => new DownloadSingleSetupView(), MessageBoxViewModel => new MessageBoxView(), SettingsViewModel => new SettingsView(), _ => null, }; public Control? TryBindView(ViewModelBase viewModel) { var view = TryCreateView(viewModel); if (view is null) return null; view.DataContext ??= viewModel; return view; } } public partial class ViewManager : IDataTemplate { bool IDataTemplate.Match(object? data) => data is ViewModelBase; Control? ITemplate.Build(object? data) => data is ViewModelBase viewModel ? TryBindView(viewModel) : null; } ================================================ FILE: YoutubeDownloader/Framework/ViewModelBase.cs ================================================ using System; using CommunityToolkit.Mvvm.ComponentModel; namespace YoutubeDownloader.Framework; public abstract class ViewModelBase : ObservableObject, IDisposable { ~ViewModelBase() => Dispose(false); protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty); protected virtual void Dispose(bool disposing) { } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } } ================================================ FILE: YoutubeDownloader/Framework/ViewModelManager.cs ================================================ using System; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; using YoutubeDownloader.Core.Downloading; using YoutubeDownloader.Core.Utils.Extensions; using YoutubeDownloader.ViewModels; using YoutubeDownloader.ViewModels.Components; using YoutubeDownloader.ViewModels.Dialogs; using YoutubeExplode.Videos; namespace YoutubeDownloader.Framework; public class ViewModelManager(IServiceProvider services) { public MainViewModel CreateMainViewModel() => services.GetRequiredService(); public DashboardViewModel CreateDashboardViewModel() => services.GetRequiredService(); public AuthSetupViewModel CreateAuthSetupViewModel() => services.GetRequiredService(); public DownloadViewModel CreateDownloadViewModel( IVideo video, VideoDownloadOption downloadOption, string filePath ) { var viewModel = services.GetRequiredService(); viewModel.Video = video; viewModel.DownloadOption = downloadOption; viewModel.FilePath = filePath; return viewModel; } public DownloadViewModel CreateDownloadViewModel( IVideo video, VideoDownloadPreference downloadPreference, string filePath ) { var viewModel = services.GetRequiredService(); viewModel.Video = video; viewModel.DownloadPreference = downloadPreference; viewModel.FilePath = filePath; return viewModel; } public DownloadMultipleSetupViewModel CreateDownloadMultipleSetupViewModel( string title, IReadOnlyList availableVideos, bool preselectVideos = true ) { var viewModel = services.GetRequiredService(); viewModel.Title = title; viewModel.AvailableVideos = availableVideos; if (preselectVideos) viewModel.SelectedVideos.AddRange(availableVideos); return viewModel; } public DownloadSingleSetupViewModel CreateDownloadSingleSetupViewModel( IVideo video, IReadOnlyList availableDownloadOptions ) { var viewModel = services.GetRequiredService(); viewModel.Video = video; viewModel.AvailableDownloadOptions = availableDownloadOptions; return viewModel; } public MessageBoxViewModel CreateMessageBoxViewModel( string title, string message, string? okButtonText, string? cancelButtonText ) { var viewModel = services.GetRequiredService(); viewModel.Title = title; viewModel.Message = message; viewModel.DefaultButtonText = okButtonText; viewModel.CancelButtonText = cancelButtonText; return viewModel; } public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message) { var viewModel = services.GetRequiredService(); viewModel.Title = title; viewModel.Message = message; return viewModel; } public SettingsViewModel CreateSettingsViewModel() => services.GetRequiredService(); } ================================================ FILE: YoutubeDownloader/Framework/Window.cs ================================================ using System; using Avalonia.Controls; namespace YoutubeDownloader.Framework; public class Window : Window { public new TDataContext DataContext { get => base.DataContext is TDataContext dataContext ? dataContext : throw new InvalidCastException( $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." ); set => base.DataContext = value; } } ================================================ FILE: YoutubeDownloader/Localization/Language.cs ================================================ namespace YoutubeDownloader.Localization; public enum Language { System, English, Ukrainian, German, French, Spanish, } ================================================ FILE: YoutubeDownloader/Localization/LocalizationManager.English.cs ================================================ using System.Collections.Generic; namespace YoutubeDownloader.Localization; public partial class LocalizationManager { private static readonly IReadOnlyDictionary EnglishLocalization = new Dictionary { // Dashboard [nameof(QueryWatermark)] = "URL or search query", [nameof(QueryTooltip)] = "Any valid YouTube URL or ID is accepted. Prepend a question mark (?) to perform search by text.", [nameof(ProcessQueryTooltip)] = "Process query (Enter)", [nameof(AuthTooltip)] = "Authentication", [nameof(SettingsTooltip)] = "Settings", [nameof(DashboardPlaceholder)] = """ Copy-paste a **URL** or enter a **search query** to start downloading Press **Shift+Enter** to add multiple items """, [nameof(DownloadsFileColumnHeader)] = "File", [nameof(DownloadsStatusColumnHeader)] = "Status", [nameof(ContextMenuRemoveSuccessful)] = "Remove successful downloads", [nameof(ContextMenuRemoveInactive)] = "Remove inactive downloads", [nameof(ContextMenuRestartFailed)] = "Restart failed downloads", [nameof(ContextMenuCancelAll)] = "Cancel all downloads", [nameof(DownloadStatusEnqueued)] = "Pending...", [nameof(DownloadStatusCompleted)] = "Done", [nameof(DownloadStatusCanceled)] = "Canceled", [nameof(DownloadStatusFailed)] = "Failed", [nameof(ClickToCopyErrorTooltip)] = "Note: Click to copy this error message", [nameof(ShowFileTooltip)] = "Show file", [nameof(PlayTooltip)] = "Play", [nameof(CancelDownloadTooltip)] = "Cancel download", [nameof(RestartDownloadTooltip)] = "Restart download", // Settings [nameof(SettingsTitle)] = "Settings", [nameof(ThemeLabel)] = "Theme", [nameof(ThemeTooltip)] = "Preferred user interface theme", [nameof(LanguageLabel)] = "Language", [nameof(LanguageTooltip)] = "Preferred display language for the user interface", [nameof(AutoUpdateLabel)] = "Auto-update", [nameof(AutoUpdateTooltip)] = """ Perform automatic updates on every launch. **Warning:** it's recommended to leave this option enabled to ensure that the app is compatible with the latest version of YouTube. """, [nameof(PersistAuthLabel)] = "Persist authentication", [nameof(PersistAuthTooltip)] = """ Save authentication cookies to a file so that they can be persisted between sessions. **Warning**: although the cookies are stored with encryption, they may still be recovered by an attacker who has access to your system. """, [nameof(InjectAltLanguagesLabel)] = "Inject alternative languages", [nameof(InjectAltLanguagesTooltip)] = "Inject audio tracks in alternative languages (if available) into downloaded files", [nameof(InjectSubtitlesLabel)] = "Inject subtitles", [nameof(InjectSubtitlesTooltip)] = "Inject subtitles (if available) into downloaded files", [nameof(InjectTagsLabel)] = "Inject media tags", [nameof(InjectTagsTooltip)] = "Inject media tags (if available) into downloaded files", [nameof(SkipExistingFilesLabel)] = "Skip existing files", [nameof(SkipExistingFilesTooltip)] = "When downloading multiple videos, skip those that already have matching files in the output directory", [nameof(FileNameTemplateLabel)] = "File name template", [nameof(FileNameTemplateTooltip)] = """ Template used for generating file names for downloaded videos. Available tokens: **$num** — video's position in the list (if applicable) **$id** — video ID **$title** — video title **$author** — video author """, [nameof(ParallelLimitLabel)] = "Parallel limit", [nameof(ParallelLimitTooltip)] = "How many downloads can be active at the same time", [nameof(FFmpegPathLabel)] = "FFmpeg path", [nameof(FFmpegPathTooltip)] = "Path to the FFmpeg executable. Leave empty to use auto-detection.", [nameof(FFmpegPathWatermark)] = "Auto-detect", [nameof(FFmpegPathResetTooltip)] = "Reset to auto-detection", [nameof(FFmpegPathBrowseTooltip)] = "Browse for FFmpeg executable", // Auth Setup [nameof(AuthenticationTitle)] = "Authentication", [nameof(AuthenticatedText)] = "You are currently authenticated", [nameof(LogOutButton)] = "Log out", [nameof(LoadingText)] = "Loading...", // Download Single Setup [nameof(CopyMenuItem)] = "Copy", [nameof(LiveLabel)] = "Live", [nameof(AudioLabel)] = "Audio", [nameof(FormatLabel)] = "Format", // Download Multiple Setup [nameof(ContainerLabel)] = "Container", [nameof(VideoQualityLabel)] = "Video quality", // Common buttons [nameof(CloseButton)] = "CLOSE", [nameof(DownloadButton)] = "DOWNLOAD", [nameof(CancelButton)] = "CANCEL", [nameof(SettingsButton)] = "SETTINGS", // Dialog messages [nameof(UkraineSupportTitle)] = "Thank you for supporting Ukraine!", [nameof(UkraineSupportMessage)] = """ As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom. Click LEARN MORE to find ways that you can help. """, [nameof(LearnMoreButton)] = "LEARN MORE", [nameof(UnstableBuildTitle)] = "Unstable build warning", [nameof(UnstableBuildMessage)] = """ You're using a development build of {0}. These builds are not thoroughly tested and may contain bugs. Auto-updates are disabled for development builds. Click SEE RELEASES if you want to download a stable release instead. """, [nameof(SeeReleasesButton)] = "SEE RELEASES", [nameof(FFmpegMissingTitle)] = "FFmpeg is missing", [nameof(FFmpegMissingMessage)] = """ FFmpeg is required for {0} to work. Please download it and make it available in the application directory or on the system PATH, or configure the location in settings. Alternatively, you can also download a version of {0} that has FFmpeg bundled with it. Look for release assets that are NOT marked as *.Bare. Click DOWNLOAD to go to the FFmpeg download page. """, [nameof(FFmpegPathMissingMessage)] = """ FFmpeg is required for this app to work, but the configured path does not exist: {0} Please update the FFmpeg path in settings or clear it to use auto-detection. """, [nameof(FFmpegMissingSearchedLabel)] = "Searched for '{0}' in the following directories:", [nameof(NothingFoundTitle)] = "Nothing found", [nameof(NothingFoundMessage)] = "Couldn't find any videos based on the query or URL you provided", [nameof(ErrorTitle)] = "Error", [nameof(UpdateDownloadingMessage)] = "Downloading update to {0} v{1}...", [nameof(UpdateReadyMessage)] = "Update has been downloaded and will be installed when you exit", [nameof(UpdateInstallNowButton)] = "INSTALL NOW", [nameof(UpdateFailedMessage)] = "Failed to perform application update", }; } ================================================ FILE: YoutubeDownloader/Localization/LocalizationManager.French.cs ================================================ using System.Collections.Generic; namespace YoutubeDownloader.Localization; public partial class LocalizationManager { private static readonly IReadOnlyDictionary FrenchLocalization = new Dictionary< string, string > { // Dashboard [nameof(QueryWatermark)] = "URL ou requête de recherche", [nameof(QueryTooltip)] = "Toute URL ou ID YouTube valide est acceptée. Ajoutez un point d'interrogation (?) pour rechercher par texte.", [nameof(ProcessQueryTooltip)] = "Traiter la requête (Entrée)", [nameof(AuthTooltip)] = "Authentification", [nameof(SettingsTooltip)] = "Paramètres", [nameof(DashboardPlaceholder)] = """ Collez une **URL** ou entrez une **requête de recherche** pour commencer Appuyez sur **Shift+Entrée** pour ajouter plusieurs éléments """, [nameof(DownloadsFileColumnHeader)] = "Fichier", [nameof(DownloadsStatusColumnHeader)] = "Statut", [nameof(ContextMenuRemoveSuccessful)] = "Supprimer les téléchargements réussis", [nameof(ContextMenuRemoveInactive)] = "Supprimer les téléchargements inactifs", [nameof(ContextMenuRestartFailed)] = "Relancer les téléchargements échoués", [nameof(ContextMenuCancelAll)] = "Annuler tous les téléchargements", [nameof(DownloadStatusEnqueued)] = "En attente...", [nameof(DownloadStatusCompleted)] = "Terminé", [nameof(DownloadStatusCanceled)] = "Annulé", [nameof(DownloadStatusFailed)] = "Échec", [nameof(ClickToCopyErrorTooltip)] = "Note : Cliquez pour copier ce message d'erreur", [nameof(ShowFileTooltip)] = "Afficher le fichier", [nameof(PlayTooltip)] = "Lire", [nameof(CancelDownloadTooltip)] = "Annuler le téléchargement", [nameof(RestartDownloadTooltip)] = "Relancer le téléchargement", // Settings [nameof(SettingsTitle)] = "Paramètres", [nameof(ThemeLabel)] = "Thème", [nameof(ThemeTooltip)] = "Thème d'interface préféré", [nameof(LanguageLabel)] = "Langue", [nameof(LanguageTooltip)] = "Langue d'affichage préférée pour l'interface utilisateur", [nameof(AutoUpdateLabel)] = "Mise à jour automatique", [nameof(AutoUpdateTooltip)] = """ Effectuer des mises à jour automatiques à chaque démarrage. **Avertissement :** il est recommandé de laisser cette option activée pour assurer la compatibilité avec la dernière version de YouTube. """, [nameof(PersistAuthLabel)] = "Conserver l'authentification", [nameof(PersistAuthTooltip)] = """ Enregistrer les cookies d'authentification dans un fichier pour les conserver entre les sessions. **Avertissement** : bien que les cookies soient stockés avec chiffrement, ils peuvent toujours être récupérés par un attaquant ayant accès à votre système. """, [nameof(InjectAltLanguagesLabel)] = "Injecter les langues alternatives", [nameof(InjectAltLanguagesTooltip)] = "Injecter des pistes audio en langues alternatives (si disponibles) dans les fichiers téléchargés", [nameof(InjectSubtitlesLabel)] = "Injecter les sous-titres", [nameof(InjectSubtitlesTooltip)] = "Injecter les sous-titres (si disponibles) dans les fichiers téléchargés", [nameof(InjectTagsLabel)] = "Injecter les balises média", [nameof(InjectTagsTooltip)] = "Injecter les balises média (si disponibles) dans les fichiers téléchargés", [nameof(SkipExistingFilesLabel)] = "Ignorer les fichiers existants", [nameof(SkipExistingFilesTooltip)] = "Lors du téléchargement de plusieurs vidéos, ignorer celles qui ont déjà des fichiers correspondants dans le répertoire de sortie", [nameof(FileNameTemplateLabel)] = "Modèle de nom de fichier", [nameof(FileNameTemplateTooltip)] = """ Modèle utilisé pour générer les noms de fichiers des vidéos téléchargées. Jetons disponibles : **$num** — position de la vidéo dans la liste (si applicable) **$id** — ID de la vidéo **$title** — titre de la vidéo **$author** — auteur de la vidéo """, [nameof(ParallelLimitLabel)] = "Limite parallèle", [nameof(ParallelLimitTooltip)] = "Combien de téléchargements peuvent être actifs en même temps", [nameof(FFmpegPathLabel)] = "Chemin FFmpeg", [nameof(FFmpegPathTooltip)] = "Chemin vers l'exécutable FFmpeg. Laisser vide pour la détection automatique.", [nameof(FFmpegPathWatermark)] = "Auto", [nameof(FFmpegPathResetTooltip)] = "Réinitialiser la détection automatique", [nameof(FFmpegPathBrowseTooltip)] = "Parcourir l'exécutable FFmpeg", // Auth Setup [nameof(AuthenticationTitle)] = "Authentification", [nameof(AuthenticatedText)] = "Vous êtes actuellement authentifié", [nameof(LogOutButton)] = "Se déconnecter", [nameof(LoadingText)] = "Chargement...", // Download Single Setup [nameof(CopyMenuItem)] = "Copier", [nameof(LiveLabel)] = "En direct", [nameof(AudioLabel)] = "Audio", [nameof(FormatLabel)] = "Format", // Download Multiple Setup [nameof(ContainerLabel)] = "Conteneur", [nameof(VideoQualityLabel)] = "Qualité vidéo", // Common buttons [nameof(CloseButton)] = "FERMER", [nameof(DownloadButton)] = "TÉLÉCHARGER", [nameof(CancelButton)] = "ANNULER", [nameof(SettingsButton)] = "PARAMÈTRES", // Dialog messages [nameof(UkraineSupportTitle)] = "Merci de soutenir l'Ukraine !", [nameof(UkraineSupportMessage)] = """ Alors que la Russie mène une guerre génocidaire contre mon pays, je suis reconnaissant envers tous ceux qui continuent à soutenir l'Ukraine dans notre combat pour la liberté. Cliquez sur EN SAVOIR PLUS pour trouver des moyens d'aider. """, [nameof(LearnMoreButton)] = "EN SAVOIR PLUS", [nameof(UnstableBuildTitle)] = "Avertissement : build instable", [nameof(UnstableBuildMessage)] = """ Vous utilisez une version de développement de {0}. Ces versions ne sont pas rigoureusement testées et peuvent contenir des bugs. Les mises à jour automatiques sont désactivées pour les versions de développement. Cliquez sur VOIR LES VERSIONS pour télécharger une version stable. """, [nameof(SeeReleasesButton)] = "VOIR LES VERSIONS", [nameof(FFmpegMissingTitle)] = "FFmpeg est manquant", [nameof(FFmpegMissingMessage)] = """ FFmpeg est requis pour que {0} fonctionne. Veuillez le télécharger et le rendre disponible dans le répertoire de l'application ou dans le PATH système, ou configurer son emplacement dans les paramètres. Alternativement, vous pouvez télécharger une version de {0} avec FFmpeg intégré. Cherchez les fichiers de version qui ne sont PAS marqués *.Bare. Cliquez sur TÉLÉCHARGER pour accéder à la page de téléchargement de FFmpeg. """, [nameof(FFmpegPathMissingMessage)] = """ FFmpeg est requis pour cette application, mais le chemin configuré n'existe pas : {0} Veuillez mettre à jour le chemin FFmpeg dans les paramètres ou le vider pour utiliser la détection automatique. """, [nameof(FFmpegMissingSearchedLabel)] = "'{0}' recherché dans les répertoires suivants :", [nameof(NothingFoundTitle)] = "Rien trouvé", [nameof(NothingFoundMessage)] = "Impossible de trouver des vidéos correspondant à la requête ou l'URL fournie", [nameof(ErrorTitle)] = "Erreur", [nameof(UpdateDownloadingMessage)] = "Téléchargement de la mise à jour {0} v{1}...", [nameof(UpdateReadyMessage)] = "La mise à jour a été téléchargée et sera installée à la fermeture", [nameof(UpdateInstallNowButton)] = "INSTALLER MAINTENANT", [nameof(UpdateFailedMessage)] = "Échec de la mise à jour de l'application", }; } ================================================ FILE: YoutubeDownloader/Localization/LocalizationManager.German.cs ================================================ using System.Collections.Generic; namespace YoutubeDownloader.Localization; public partial class LocalizationManager { private static readonly IReadOnlyDictionary GermanLocalization = new Dictionary< string, string > { // Dashboard [nameof(QueryWatermark)] = "URL oder Suchanfrage", [nameof(QueryTooltip)] = "Jede gültige YouTube-URL oder -ID wird akzeptiert. Stellen Sie ein Fragezeichen (?) voran, um nach Text zu suchen.", [nameof(ProcessQueryTooltip)] = "Anfrage verarbeiten (Enter)", [nameof(AuthTooltip)] = "Authentifizierung", [nameof(SettingsTooltip)] = "Einstellungen", [nameof(DashboardPlaceholder)] = """ **URL** einfügen oder **Suchanfrage** eingeben um den Download zu starten Drücken Sie **Shift+Enter** um mehrere Einträge hinzuzufügen """, [nameof(DownloadsFileColumnHeader)] = "Datei", [nameof(DownloadsStatusColumnHeader)] = "Status", [nameof(ContextMenuRemoveSuccessful)] = "Erfolgreiche Downloads entfernen", [nameof(ContextMenuRemoveInactive)] = "Inaktive Downloads entfernen", [nameof(ContextMenuRestartFailed)] = "Fehlgeschlagene Downloads neu starten", [nameof(ContextMenuCancelAll)] = "Alle Downloads abbrechen", [nameof(DownloadStatusEnqueued)] = "Ausstehend...", [nameof(DownloadStatusCompleted)] = "Fertig", [nameof(DownloadStatusCanceled)] = "Abgebrochen", [nameof(DownloadStatusFailed)] = "Fehlgeschlagen", [nameof(ClickToCopyErrorTooltip)] = "Hinweis: Klicken zum Kopieren der Fehlermeldung", [nameof(ShowFileTooltip)] = "Datei anzeigen", [nameof(PlayTooltip)] = "Abspielen", [nameof(CancelDownloadTooltip)] = "Download abbrechen", [nameof(RestartDownloadTooltip)] = "Download neu starten", // Settings [nameof(SettingsTitle)] = "Einstellungen", [nameof(ThemeLabel)] = "Design", [nameof(ThemeTooltip)] = "Bevorzugtes Oberflächendesign", [nameof(LanguageLabel)] = "Sprache", [nameof(LanguageTooltip)] = "Bevorzugte Anzeigesprache für die Benutzeroberfläche", [nameof(AutoUpdateLabel)] = "Automatische Updates", [nameof(AutoUpdateTooltip)] = """ Automatische Updates bei jedem Start durchführen. **Hinweis:** Es wird empfohlen, diese Option aktiviert zu lassen, um die Kompatibilität mit der neuesten YouTube-Version zu gewährleisten. """, [nameof(PersistAuthLabel)] = "Authentifizierung speichern", [nameof(PersistAuthTooltip)] = """ Authentifizierungs-Cookies in einer Datei speichern für sitzungsübergreifende Persistenz. **Warnung**: Die Cookies werden mit Verschlüsselung gespeichert, können aber dennoch von einem Angreifer mit Zugriff auf Ihr System wiederhergestellt werden. """, [nameof(InjectAltLanguagesLabel)] = "Alternative Sprachen einbetten", [nameof(InjectAltLanguagesTooltip)] = "Audiotracks in alternativen Sprachen (falls verfügbar) in heruntergeladene Dateien einbetten", [nameof(InjectSubtitlesLabel)] = "Untertitel einbetten", [nameof(InjectSubtitlesTooltip)] = "Untertitel (falls verfügbar) in heruntergeladene Dateien einbetten", [nameof(InjectTagsLabel)] = "Medien-Tags einbetten", [nameof(InjectTagsTooltip)] = "Medien-Tags (falls verfügbar) in heruntergeladene Dateien einbetten", [nameof(SkipExistingFilesLabel)] = "Vorhandene Dateien überspringen", [nameof(SkipExistingFilesTooltip)] = "Beim Herunterladen mehrerer Videos solche überspringen, für die bereits passende Dateien im Ausgabeverzeichnis vorhanden sind", [nameof(FileNameTemplateLabel)] = "Dateinamen-Vorlage", [nameof(FileNameTemplateTooltip)] = """ Vorlage für die Generierung von Dateinamen heruntergeladener Videos. Verfügbare Token: **$num** — Position des Videos in der Liste (falls zutreffend) **$id** — Video-ID **$title** — Videotitel **$author** — Videoautor """, [nameof(ParallelLimitLabel)] = "Paralleles Limit", [nameof(ParallelLimitTooltip)] = "Wie viele Downloads gleichzeitig aktiv sein können", [nameof(FFmpegPathLabel)] = "FFmpeg-Pfad", [nameof(FFmpegPathTooltip)] = "Pfad zur FFmpeg-Programmdatei. Leer lassen für automatische Erkennung.", [nameof(FFmpegPathWatermark)] = "Auto", [nameof(FFmpegPathResetTooltip)] = "Zurücksetzen auf automatische Erkennung", [nameof(FFmpegPathBrowseTooltip)] = "FFmpeg-Programmdatei suchen", // Auth Setup [nameof(AuthenticationTitle)] = "Authentifizierung", [nameof(AuthenticatedText)] = "Sie sind derzeit authentifiziert", [nameof(LogOutButton)] = "Abmelden", [nameof(LoadingText)] = "Laden...", // Download Single Setup [nameof(CopyMenuItem)] = "Kopieren", [nameof(LiveLabel)] = "Live", [nameof(AudioLabel)] = "Audio", [nameof(FormatLabel)] = "Format", // Download Multiple Setup [nameof(ContainerLabel)] = "Container", [nameof(VideoQualityLabel)] = "Videoqualität", // Common buttons [nameof(CloseButton)] = "SCHLIESSEN", [nameof(DownloadButton)] = "HERUNTERLADEN", [nameof(CancelButton)] = "ABBRECHEN", [nameof(SettingsButton)] = "EINSTELLUNGEN", // Dialog messages [nameof(UkraineSupportTitle)] = "Danke für Ihre Unterstützung der Ukraine!", [nameof(UkraineSupportMessage)] = """ Während Russland einen Vernichtungskrieg gegen mein Land führt, bin ich jedem dankbar, der weiterhin zur Ukraine in unserem Kampf für die Freiheit steht. Klicken Sie auf MEHR ERFAHREN um Wege zu finden, wie Sie helfen können. """, [nameof(LearnMoreButton)] = "MEHR ERFAHREN", [nameof(UnstableBuildTitle)] = "Warnung: Instabiler Build", [nameof(UnstableBuildMessage)] = """ Sie verwenden einen Entwicklungs-Build von {0}. Diese Builds wurden nicht gründlich getestet und können Fehler enthalten. Automatische Updates sind für Entwicklungs-Builds deaktiviert. Klicken Sie auf RELEASES ANZEIGEN um stattdessen einen stabilen Release herunterzuladen. """, [nameof(SeeReleasesButton)] = "RELEASES ANZEIGEN", [nameof(FFmpegMissingTitle)] = "FFmpeg fehlt", [nameof(FFmpegMissingMessage)] = """ FFmpeg wird benötigt damit {0} funktioniert. Bitte laden Sie es herunter und machen Sie es im Anwendungsverzeichnis oder im System-PATH verfügbar, oder konfigurieren Sie den Speicherort in den Einstellungen. Alternativ können Sie auch eine Version von {0} herunterladen, die FFmpeg enthält. Suchen Sie nach Release-Dateien, die NICHT mit *.Bare markiert sind. Klicken Sie auf HERUNTERLADEN um zur FFmpeg-Downloadseite zu gelangen. """, [nameof(FFmpegPathMissingMessage)] = """ FFmpeg wird für diese App benötigt, aber der konfigurierte Pfad existiert nicht: {0} Bitte aktualisieren Sie den FFmpeg-Pfad in den Einstellungen oder löschen Sie ihn zur automatischen Erkennung. """, [nameof(FFmpegMissingSearchedLabel)] = "'{0}' wurde in folgenden Verzeichnissen gesucht:", [nameof(NothingFoundTitle)] = "Nichts gefunden", [nameof(NothingFoundMessage)] = "Es konnten keine Videos basierend auf der angegebenen Anfrage oder URL gefunden werden", [nameof(ErrorTitle)] = "Fehler", [nameof(UpdateDownloadingMessage)] = "Update für {0} v{1} wird heruntergeladen...", [nameof(UpdateReadyMessage)] = "Update wurde heruntergeladen und wird beim Beenden installiert", [nameof(UpdateInstallNowButton)] = "JETZT INSTALLIEREN", [nameof(UpdateFailedMessage)] = "Anwendungsupdate konnte nicht durchgeführt werden", }; } ================================================ FILE: YoutubeDownloader/Localization/LocalizationManager.Spanish.cs ================================================ using System.Collections.Generic; namespace YoutubeDownloader.Localization; public partial class LocalizationManager { private static readonly IReadOnlyDictionary SpanishLocalization = new Dictionary { // Dashboard [nameof(QueryWatermark)] = "URL o consulta de búsqueda", [nameof(QueryTooltip)] = "Se acepta cualquier URL o ID de YouTube válido. Antepone un signo de interrogación (?) para buscar por texto.", [nameof(ProcessQueryTooltip)] = "Procesar consulta (Enter)", [nameof(AuthTooltip)] = "Autenticación", [nameof(SettingsTooltip)] = "Configuración", [nameof(DashboardPlaceholder)] = """ Pega una **URL** o ingresa una **consulta de búsqueda** para comenzar Presiona **Shift+Enter** para agregar múltiples elementos """, [nameof(DownloadsFileColumnHeader)] = "Archivo", [nameof(DownloadsStatusColumnHeader)] = "Estado", [nameof(ContextMenuRemoveSuccessful)] = "Eliminar descargas exitosas", [nameof(ContextMenuRemoveInactive)] = "Eliminar descargas inactivas", [nameof(ContextMenuRestartFailed)] = "Reiniciar descargas fallidas", [nameof(ContextMenuCancelAll)] = "Cancelar todas las descargas", [nameof(DownloadStatusEnqueued)] = "Pendiente...", [nameof(DownloadStatusCompleted)] = "Listo", [nameof(DownloadStatusCanceled)] = "Cancelado", [nameof(DownloadStatusFailed)] = "Fallido", [nameof(ClickToCopyErrorTooltip)] = "Nota: Haz clic para copiar este mensaje de error", [nameof(ShowFileTooltip)] = "Mostrar archivo", [nameof(PlayTooltip)] = "Reproducir", [nameof(CancelDownloadTooltip)] = "Cancelar descarga", [nameof(RestartDownloadTooltip)] = "Reiniciar descarga", // Settings [nameof(SettingsTitle)] = "Configuración", [nameof(ThemeLabel)] = "Tema", [nameof(ThemeTooltip)] = "Tema de interfaz preferido", [nameof(LanguageLabel)] = "Idioma", [nameof(LanguageTooltip)] = "Idioma de visualización preferido para la interfaz de usuario", [nameof(AutoUpdateLabel)] = "Actualización automática", [nameof(AutoUpdateTooltip)] = """ Realizar actualizaciones automáticas en cada inicio. **Advertencia:** se recomienda dejar esta opción habilitada para asegurar la compatibilidad con la última versión de YouTube. """, [nameof(PersistAuthLabel)] = "Conservar autenticación", [nameof(PersistAuthTooltip)] = """ Guardar las cookies de autenticación en un archivo para persistirlas entre sesiones. **Advertencia**: aunque las cookies se almacenan con cifrado, un atacante con acceso a su sistema podría recuperarlas. """, [nameof(InjectAltLanguagesLabel)] = "Insertar idiomas alternativos", [nameof(InjectAltLanguagesTooltip)] = "Insertar pistas de audio en idiomas alternativos (si están disponibles) en los archivos descargados", [nameof(InjectSubtitlesLabel)] = "Insertar subtítulos", [nameof(InjectSubtitlesTooltip)] = "Insertar subtítulos (si están disponibles) en los archivos descargados", [nameof(InjectTagsLabel)] = "Insertar etiquetas multimedia", [nameof(InjectTagsTooltip)] = "Insertar etiquetas multimedia (si están disponibles) en los archivos descargados", [nameof(SkipExistingFilesLabel)] = "Omitir archivos existentes", [nameof(SkipExistingFilesTooltip)] = "Al descargar múltiples videos, omitir los que ya tengan archivos correspondientes en el directorio de salida", [nameof(FileNameTemplateLabel)] = "Plantilla de nombre de archivo", [nameof(FileNameTemplateTooltip)] = """ Plantilla para generar nombres de archivo de los videos descargados. Tokens disponibles: **$num** — posición del video en la lista (si aplica) **$id** — ID del video **$title** — título del video **$author** — autor del video """, [nameof(ParallelLimitLabel)] = "Límite paralelo", [nameof(ParallelLimitTooltip)] = "Cuántas descargas pueden estar activas al mismo tiempo", [nameof(FFmpegPathLabel)] = "Ruta de FFmpeg", [nameof(FFmpegPathTooltip)] = "Ruta al ejecutable de FFmpeg. Dejar vacío para detección automática.", [nameof(FFmpegPathWatermark)] = "Auto", [nameof(FFmpegPathResetTooltip)] = "Restablecer detección automática", [nameof(FFmpegPathBrowseTooltip)] = "Buscar ejecutable de FFmpeg", // Auth Setup [nameof(AuthenticationTitle)] = "Autenticación", [nameof(AuthenticatedText)] = "Actualmente estás autenticado", [nameof(LogOutButton)] = "Cerrar sesión", [nameof(LoadingText)] = "Cargando...", // Download Single Setup [nameof(CopyMenuItem)] = "Copiar", [nameof(LiveLabel)] = "En vivo", [nameof(AudioLabel)] = "Audio", [nameof(FormatLabel)] = "Formato", // Download Multiple Setup [nameof(ContainerLabel)] = "Contenedor", [nameof(VideoQualityLabel)] = "Calidad de video", // Common buttons [nameof(CloseButton)] = "CERRAR", [nameof(DownloadButton)] = "DESCARGAR", [nameof(CancelButton)] = "CANCELAR", [nameof(SettingsButton)] = "AJUSTES", // Dialog messages [nameof(UkraineSupportTitle)] = "¡Gracias por apoyar a Ucrania!", [nameof(UkraineSupportMessage)] = """ Mientras Rusia libra una guerra genocida contra mi país, estoy agradecido con todos los que continúan apoyando a Ucrania en nuestra lucha por la libertad. Haz clic en MÁS INFORMACIÓN para encontrar formas en que puedes ayudar. """, [nameof(LearnMoreButton)] = "MÁS INFORMACIÓN", [nameof(UnstableBuildTitle)] = "Advertencia: versión inestable", [nameof(UnstableBuildMessage)] = """ Estás usando una versión de desarrollo de {0}. Estas versiones no han sido probadas exhaustivamente y pueden contener errores. Las actualizaciones automáticas están desactivadas para versiones de desarrollo. Haz clic en VER LANZAMIENTOS para descargar una versión estable. """, [nameof(SeeReleasesButton)] = "VER LANZAMIENTOS", [nameof(FFmpegMissingTitle)] = "Falta FFmpeg", [nameof(FFmpegMissingMessage)] = """ FFmpeg es necesario para que {0} funcione. Descárgalo y ponlo disponible en el directorio de la aplicación o en el PATH del sistema, o configura la ubicación en los ajustes. Alternativamente, puedes descargar una versión de {0} que incluye FFmpeg. Busca los archivos de lanzamiento que NO estén marcados como *.Bare. Haz clic en DESCARGAR para ir a la página de descarga de FFmpeg. """, [nameof(FFmpegPathMissingMessage)] = """ FFmpeg es necesario para esta aplicación, pero la ruta configurada no existe: {0} Por favor, actualiza la ruta de FFmpeg en los ajustes o bórrala para usar la detección automática. """, [nameof(FFmpegMissingSearchedLabel)] = "Se buscó '{0}' en los siguientes directorios:", [nameof(NothingFoundTitle)] = "Nada encontrado", [nameof(NothingFoundMessage)] = "No se encontraron videos basados en la consulta o URL proporcionada", [nameof(ErrorTitle)] = "Error", [nameof(UpdateDownloadingMessage)] = "Descargando actualización de {0} v{1}...", [nameof(UpdateReadyMessage)] = "La actualización se ha descargado y se instalará al salir", [nameof(UpdateInstallNowButton)] = "INSTALAR AHORA", [nameof(UpdateFailedMessage)] = "Error al realizar la actualización de la aplicación", }; } ================================================ FILE: YoutubeDownloader/Localization/LocalizationManager.Ukrainian.cs ================================================ using System.Collections.Generic; namespace YoutubeDownloader.Localization; public partial class LocalizationManager { private static readonly IReadOnlyDictionary UkrainianLocalization = new Dictionary { // Dashboard [nameof(QueryWatermark)] = "URL або пошуковий запит", [nameof(QueryTooltip)] = "Приймається будь-який дійсний URL або ID YouTube. Додайте знак питання (?) для пошуку за текстом.", [nameof(ProcessQueryTooltip)] = "Виконати запит (Enter)", [nameof(AuthTooltip)] = "Автентифікація", [nameof(SettingsTooltip)] = "Налаштування", [nameof(DashboardPlaceholder)] = """ Вставте **URL** або введіть **пошуковий запит** для завантаження Натисніть **Shift+Enter**, щоб додати декілька елементів """, [nameof(DownloadsFileColumnHeader)] = "Файл", [nameof(DownloadsStatusColumnHeader)] = "Статус", [nameof(ContextMenuRemoveSuccessful)] = "Видалити успішні завантаження", [nameof(ContextMenuRemoveInactive)] = "Видалити неактивні завантаження", [nameof(ContextMenuRestartFailed)] = "Перезапустити невдалі завантаження", [nameof(ContextMenuCancelAll)] = "Скасувати всі завантаження", [nameof(DownloadStatusEnqueued)] = "В черзі...", [nameof(DownloadStatusCompleted)] = "Готово", [nameof(DownloadStatusCanceled)] = "Скасовано", [nameof(DownloadStatusFailed)] = "Помилка", [nameof(ClickToCopyErrorTooltip)] = "Примітка: натисніть, щоб скопіювати повідомлення", [nameof(ShowFileTooltip)] = "Показати файл", [nameof(PlayTooltip)] = "Відтворити", [nameof(CancelDownloadTooltip)] = "Скасувати завантаження", [nameof(RestartDownloadTooltip)] = "Перезапустити завантаження", // Settings [nameof(SettingsTitle)] = "Налаштування", [nameof(ThemeLabel)] = "Тема", [nameof(ThemeTooltip)] = "Бажана тема інтерфейсу", [nameof(LanguageLabel)] = "Мова", [nameof(LanguageTooltip)] = "Бажана мова відображення інтерфейсу користувача", [nameof(AutoUpdateLabel)] = "Авто-оновлення", [nameof(AutoUpdateTooltip)] = """ Виконувати автоматичні оновлення при кожному запуску. **Увага:** рекомендується залишити цю опцію увімкненою для сумісності з останньою версією YouTube. """, [nameof(PersistAuthLabel)] = "Зберігати автентифікацію", [nameof(PersistAuthTooltip)] = """ Зберігати файли cookie у файлі для збереження між сеансами. **Увага**: хоча cookies зберігаються із шифруванням, зловмисник з доступом до вашої системи може їх відновити. """, [nameof(InjectAltLanguagesLabel)] = "Вставляти альтернативні мови", [nameof(InjectAltLanguagesTooltip)] = "Вставляти аудіодоріжки альтернативними мовами (якщо доступні) у завантажені файли", [nameof(InjectSubtitlesLabel)] = "Вставляти субтитри", [nameof(InjectSubtitlesTooltip)] = "Вставляти субтитри (якщо доступні) у завантажені файли", [nameof(InjectTagsLabel)] = "Вставляти медіатеги", [nameof(InjectTagsTooltip)] = "Вставляти медіатеги (якщо доступні) у завантажені файли", [nameof(SkipExistingFilesLabel)] = "Пропускати наявні файли", [nameof(SkipExistingFilesTooltip)] = "При завантаженні кількох відео пропускати ті, для яких вже є відповідні файли", [nameof(FileNameTemplateLabel)] = "Шаблон імені файлу", [nameof(FileNameTemplateTooltip)] = """ Шаблон для генерації імен файлів завантажених відео. Доступні токени: **$num** — позиція відео у списку (якщо застосовно) **$id** — ідентифікатор відео **$title** — назва відео **$author** — автор відео """, [nameof(ParallelLimitLabel)] = "Ліміт паралелізації", [nameof(ParallelLimitTooltip)] = "Скільки завантажень може бути активними одночасно", [nameof(FFmpegPathLabel)] = "Шлях FFmpeg", [nameof(FFmpegPathTooltip)] = "Шлях до виконуваного файлу FFmpeg. Залиште порожнім для автоматичного визначення.", [nameof(FFmpegPathWatermark)] = "Авто", [nameof(FFmpegPathResetTooltip)] = "Скинути до автоматичного визначення", [nameof(FFmpegPathBrowseTooltip)] = "Вибрати файл FFmpeg", // Auth Setup [nameof(AuthenticationTitle)] = "Автентифікація", [nameof(AuthenticatedText)] = "Ви автентифіковані", [nameof(LogOutButton)] = "Вийти", [nameof(LoadingText)] = "Завантаження...", // Download Single Setup [nameof(CopyMenuItem)] = "Копіювати", [nameof(LiveLabel)] = "Живе", [nameof(AudioLabel)] = "Аудіо", [nameof(FormatLabel)] = "Формат", // Download Multiple Setup [nameof(ContainerLabel)] = "Контейнер", [nameof(VideoQualityLabel)] = "Якість відео", // Common buttons [nameof(CloseButton)] = "ЗАКРИТИ", [nameof(DownloadButton)] = "ЗАВАНТАЖИТИ", [nameof(CancelButton)] = "СКАСУВАТИ", [nameof(SettingsButton)] = "НАЛАШТУВАННЯ", // Dialog messages [nameof(UkraineSupportTitle)] = "Дякуємо за підтримку України!", [nameof(UkraineSupportMessage)] = """ Поки Росія веде геноцидну війну проти моєї країни, я вдячний кожному, хто продовжує підтримувати Україну у нашій боротьбі за свободу. Натисніть ДІЗНАТИСЬ БІЛЬШЕ, щоб знайти способи допомогти. """, [nameof(LearnMoreButton)] = "ДІЗНАТИСЬ БІЛЬШЕ", [nameof(UnstableBuildTitle)] = "Попередження про нестабільну збірку", [nameof(UnstableBuildMessage)] = """ Ви використовуєте збірку розробки {0}. Ці збірки не пройшли ретельного тестування та можуть містити помилки. Авто-оновлення вимкнено для збірок розробки. Натисніть ПЕРЕГЛЯНУТИ РЕЛІЗИ, щоб завантажити стабільний реліз. """, [nameof(SeeReleasesButton)] = "ПЕРЕГЛЯНУТИ РЕЛІЗИ", [nameof(FFmpegMissingTitle)] = "FFmpeg відсутній", [nameof(FFmpegMissingMessage)] = """ FFmpeg потрібен для роботи {0}. Завантажте його та зробіть доступним у каталозі програми або у системному PATH, або вкажіть розташування у налаштуваннях. Альтернативно, ви можете завантажити версію {0} з вбудованим FFmpeg. Шукайте ресурси релізу, які НЕ позначені як *.Bare. Натисніть ЗАВАНТАЖИТИ, щоб перейти на сторінку завантаження FFmpeg. """, [nameof(FFmpegPathMissingMessage)] = """ FFmpeg потрібен для роботи програми, але вказаний шлях не існує: {0} Будь ласка, оновіть шлях FFmpeg у налаштуваннях або очистіть його для автовизначення. """, [nameof(FFmpegMissingSearchedLabel)] = "Шукали '{0}' у таких директоріях:", [nameof(NothingFoundTitle)] = "Нічого не знайдено", [nameof(NothingFoundMessage)] = "Не вдалося знайти відео за вказаним запитом або URL", [nameof(ErrorTitle)] = "Помилка", [nameof(UpdateDownloadingMessage)] = "Завантаження оновлення {0} v{1}...", [nameof(UpdateReadyMessage)] = "Оновлення завантажено та буде встановлено після виходу", [nameof(UpdateInstallNowButton)] = "ВСТАНОВИТИ ЗАРАЗ", [nameof(UpdateFailedMessage)] = "Не вдалося виконати оновлення програми", }; } ================================================ FILE: YoutubeDownloader/Localization/LocalizationManager.cs ================================================ using System; using System.Globalization; using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; using YoutubeDownloader.Services; using YoutubeDownloader.Utils; using YoutubeDownloader.Utils.Extensions; namespace YoutubeDownloader.Localization; public partial class LocalizationManager : ObservableObject, IDisposable { private readonly DisposableCollector _eventRoot = new(); public LocalizationManager(SettingsService settingsService) { _eventRoot.Add( settingsService.WatchProperty( o => o.Language, () => Language = settingsService.Language, true ) ); _eventRoot.Add( this.WatchProperty( o => o.Language, () => { foreach (var propertyName in EnglishLocalization.Keys) OnPropertyChanged(propertyName); } ) ); } [ObservableProperty] public partial Language Language { get; set; } = Language.System; private string Get([CallerMemberName] string? key = null) { if (string.IsNullOrWhiteSpace(key)) return string.Empty; var localization = Language switch { Language.System => CultureInfo.CurrentUICulture.ThreeLetterISOLanguageName.ToLowerInvariant() switch { "ukr" => UkrainianLocalization, "deu" => GermanLocalization, "fra" => FrenchLocalization, "spa" => SpanishLocalization, _ => EnglishLocalization, }, Language.Ukrainian => UkrainianLocalization, Language.German => GermanLocalization, Language.French => FrenchLocalization, Language.Spanish => SpanishLocalization, _ => EnglishLocalization, }; if ( localization.TryGetValue(key, out var value) // English is used as a fallback || EnglishLocalization.TryGetValue(key, out value) ) { return value; } return $"Missing localization for '{key}'"; } public void Dispose() => _eventRoot.Dispose(); } public partial class LocalizationManager { // ---- Dashboard ---- public string QueryWatermark => Get(); public string QueryTooltip => Get(); public string ProcessQueryTooltip => Get(); public string AuthTooltip => Get(); public string SettingsTooltip => Get(); public string DashboardPlaceholder => Get(); public string DownloadsFileColumnHeader => Get(); public string DownloadsStatusColumnHeader => Get(); public string ContextMenuRemoveSuccessful => Get(); public string ContextMenuRemoveInactive => Get(); public string ContextMenuRestartFailed => Get(); public string ContextMenuCancelAll => Get(); public string DownloadStatusEnqueued => Get(); public string DownloadStatusCompleted => Get(); public string DownloadStatusCanceled => Get(); public string DownloadStatusFailed => Get(); public string ClickToCopyErrorTooltip => Get(); public string ShowFileTooltip => Get(); public string PlayTooltip => Get(); public string CancelDownloadTooltip => Get(); public string RestartDownloadTooltip => Get(); // ---- Settings ---- public string SettingsTitle => Get(); public string ThemeLabel => Get(); public string ThemeTooltip => Get(); public string LanguageLabel => Get(); public string LanguageTooltip => Get(); public string AutoUpdateLabel => Get(); public string AutoUpdateTooltip => Get(); public string PersistAuthLabel => Get(); public string PersistAuthTooltip => Get(); public string InjectAltLanguagesLabel => Get(); public string InjectAltLanguagesTooltip => Get(); public string InjectSubtitlesLabel => Get(); public string InjectSubtitlesTooltip => Get(); public string InjectTagsLabel => Get(); public string InjectTagsTooltip => Get(); public string SkipExistingFilesLabel => Get(); public string SkipExistingFilesTooltip => Get(); public string FileNameTemplateLabel => Get(); public string FileNameTemplateTooltip => Get(); public string ParallelLimitLabel => Get(); public string ParallelLimitTooltip => Get(); public string FFmpegPathLabel => Get(); public string FFmpegPathTooltip => Get(); public string FFmpegPathWatermark => Get(); public string FFmpegPathResetTooltip => Get(); public string FFmpegPathBrowseTooltip => Get(); // ---- Auth Setup ---- public string AuthenticationTitle => Get(); public string AuthenticatedText => Get(); public string LogOutButton => Get(); public string LoadingText => Get(); // ---- Download Single Setup ---- public string CopyMenuItem => Get(); public string LiveLabel => Get(); public string AudioLabel => Get(); public string FormatLabel => Get(); // ---- Download Multiple Setup ---- public string ContainerLabel => Get(); public string VideoQualityLabel => Get(); // ---- Common buttons ---- public string CloseButton => Get(); public string DownloadButton => Get(); public string CancelButton => Get(); public string SettingsButton => Get(); // ---- Dialog messages ---- public string UkraineSupportTitle => Get(); public string UkraineSupportMessage => Get(); public string LearnMoreButton => Get(); public string UnstableBuildTitle => Get(); public string UnstableBuildMessage => Get(); public string SeeReleasesButton => Get(); public string FFmpegMissingTitle => Get(); public string FFmpegMissingMessage => Get(); public string FFmpegPathMissingMessage => Get(); public string FFmpegMissingSearchedLabel => Get(); public string NothingFoundTitle => Get(); public string NothingFoundMessage => Get(); public string ErrorTitle => Get(); public string UpdateDownloadingMessage => Get(); public string UpdateReadyMessage => Get(); public string UpdateInstallNowButton => Get(); public string UpdateFailedMessage => Get(); } ================================================ FILE: YoutubeDownloader/Program.cs ================================================ using System; using System.Reflection; using Avalonia; using Avalonia.WebView.Desktop; using YoutubeDownloader.Utils; namespace YoutubeDownloader; public static class Program { private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly(); public static string Name { get; } = Assembly.GetName().Name ?? "YoutubeDownloader"; public static Version Version { get; } = Assembly.GetName().Version ?? new Version(0, 0, 0); public static string VersionString { get; } = Version.ToString(3); public static bool IsDevelopmentBuild { get; } = Version.Major is <= 0 or >= 999; public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/YoutubeDownloader"; public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases"; public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure().UsePlatformDetect().LogToTrace().UseDesktopWebView(); [STAThread] public static int Main(string[] args) { // Build and run the app var builder = BuildAvaloniaApp(); try { return builder.StartWithClassicDesktopLifetime(args); } catch (Exception ex) { if (OperatingSystem.IsWindows()) _ = NativeMethods.Windows.MessageBox(0, ex.ToString(), "Fatal Error", 0x10); throw; } finally { // Clean up after application shutdown if (builder.Instance is IDisposable disposableApp) disposableApp.Dispose(); } } } ================================================ FILE: YoutubeDownloader/Publish-MacOSBundle.ps1 ================================================ param( [Parameter(Mandatory=$true)] [string]$PublishDirPath, [Parameter(Mandatory=$true)] [string]$IconsFilePath, [Parameter(Mandatory=$true)] [string]$FullVersion, [Parameter(Mandatory=$true)] [string]$ShortVersion ) $ErrorActionPreference = "Stop" # Setup paths $tempDirPath = Join-Path $PublishDirPath "../publish-macos-app-temp" $bundleName = "YoutubeDownloader.app" $bundleDirPath = Join-Path $tempDirPath $bundleName $contentsDirPath = Join-Path $bundleDirPath "Contents" $macosDirPath = Join-Path $contentsDirPath "MacOS" $resourcesDirPath = Join-Path $contentsDirPath "Resources" try { # Initialize the bundle's directory structure New-Item -Path $bundleDirPath -ItemType Directory -Force New-Item -Path $contentsDirPath -ItemType Directory -Force New-Item -Path $macosDirPath -ItemType Directory -Force New-Item -Path $resourcesDirPath -ItemType Directory -Force # Copy icons into the .app's Resources folder Copy-Item -Path $IconsFilePath -Destination (Join-Path $resourcesDirPath "AppIcon.icns") -Force # Generate the Info.plist metadata file with the app information $plistContent = @" CFBundleDisplayName YoutubeDownloader CFBundleName YoutubeDownloader CFBundleExecutable YoutubeDownloader NSHumanReadableCopyright © Oleksii Holub CFBundleIdentifier me.Tyrrrz.YoutubeDownloader CFBundleSpokenName YoutubeDownloader CFBundleIconFile AppIcon CFBundleIconName AppIcon CFBundleVersion $FullVersion CFBundleShortVersionString $ShortVersion NSHighResolutionCapable CFBundlePackageType APPL "@ Set-Content -Path (Join-Path $contentsDirPath "Info.plist") -Value $plistContent # Delete the previous bundle if it exists if (Test-Path (Join-Path $PublishDirPath $bundleName)) { Remove-Item -Path (Join-Path $PublishDirPath $bundleName) -Recurse -Force } # Move all files from the publish directory into the MacOS directory Get-ChildItem -Path $PublishDirPath | ForEach-Object { Move-Item -Path $_.FullName -Destination $macosDirPath -Force } # Move the final bundle into the publish directory for upload Move-Item -Path $bundleDirPath -Destination $PublishDirPath -Force } finally { # Clean up the temporary directory Remove-Item -Path $tempDirPath -Recurse -Force } ================================================ FILE: YoutubeDownloader/Services/SettingsService.AuthCookiesEncryptionConverter.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using YoutubeDownloader.Utils.Extensions; namespace YoutubeDownloader.Services; public partial class SettingsService { private class AuthCookiesEncryptionConverter : JsonConverter?> { private static readonly Lazy Key = new(() => Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(Environment.TryGetMachineId() ?? string.Empty), Encoding.UTF8.GetBytes(ThisAssembly.Project.EncryptionSalt), 600_000, HashAlgorithmName.SHA256, 16 ) ); public override IReadOnlyList? Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) { if (reader.TokenType != JsonTokenType.String) return null; var value = reader.GetString(); if (string.IsNullOrWhiteSpace(value)) return null; try { var encryptedData = Convert.FromHexString(value); var cookieData = new byte[encryptedData.AsSpan(28).Length]; // Layout: nonce (12 bytes) | tag (16 bytes) | cipher using var aes = new AesGcm(Key.Value, 16); aes.Decrypt( encryptedData.AsSpan(0, 12), encryptedData.AsSpan(28), encryptedData.AsSpan(12, 16), cookieData ); return JsonSerializer .Deserialize>(cookieData) ?.Select(c => new Cookie(c.Name, c.Value, c.Path, c.Domain)) .ToArray(); } catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException or IndexOutOfRangeException or JsonException ) { return null; } } public override void Write( Utf8JsonWriter writer, IReadOnlyList? value, JsonSerializerOptions options ) { if (value is null || value.Count == 0) { writer.WriteNullValue(); return; } var cookieData = Encoding.UTF8.GetBytes( JsonSerializer.Serialize( value.Select(c => new CookieData(c.Name, c.Value, c.Path, c.Domain)) ) ); var encryptedData = new byte[28 + cookieData.Length]; // Nonce RandomNumberGenerator.Fill(encryptedData.AsSpan(0, 12)); // Layout: nonce (12 bytes) | tag (16 bytes) | cipher using var aes = new AesGcm(Key.Value, 16); aes.Encrypt( encryptedData.AsSpan(0, 12), cookieData, encryptedData.AsSpan(28), encryptedData.AsSpan(12, 16) ); writer.WriteStringValue(Convert.ToHexStringLower(encryptedData)); } private record CookieData(string Name, string Value, string Path, string Domain); } } ================================================ FILE: YoutubeDownloader/Services/SettingsService.cs ================================================ using System; using System.Collections.Generic; using System.Net; using System.Text.Json; using System.Text.Json.Serialization; using Cogwheel; using CommunityToolkit.Mvvm.ComponentModel; using YoutubeDownloader.Core.Downloading; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using Container = YoutubeExplode.Videos.Streams.Container; namespace YoutubeDownloader.Services; [ObservableObject] public partial class SettingsService() : SettingsBase(StartOptions.Current.SettingsPath, SerializerContext.Default) { [ObservableProperty] public partial bool IsUkraineSupportMessageEnabled { get; set; } = true; [ObservableProperty] public partial ThemeVariant Theme { get; set; } [ObservableProperty] public partial Language Language { get; set; } [ObservableProperty] public partial bool IsAutoUpdateEnabled { get; set; } = true; [ObservableProperty] public partial bool IsAuthPersisted { get; set; } = true; [ObservableProperty] public partial string? FFmpegFilePath { get; set; } [ObservableProperty] public partial bool ShouldInjectLanguageSpecificAudioStreams { get; set; } = true; [ObservableProperty] public partial bool ShouldInjectSubtitles { get; set; } = true; [ObservableProperty] public partial bool ShouldInjectTags { get; set; } = true; [ObservableProperty] public partial bool ShouldSkipExistingFiles { get; set; } [ObservableProperty] public partial string FileNameTemplate { get; set; } = "$title"; [ObservableProperty] public partial int ParallelLimit { get; set; } = 2; [ObservableProperty] [JsonConverter(typeof(AuthCookiesEncryptionConverter))] public partial IReadOnlyList? LastAuthCookies { get; set; } [ObservableProperty] [JsonConverter(typeof(ContainerJsonConverter))] public partial Container LastContainer { get; set; } = Container.Mp4; [ObservableProperty] public partial VideoQualityPreference LastVideoQualityPreference { get; set; } = VideoQualityPreference.Highest; public override void Save() { // Clear the cookies if they are not supposed to be persisted var lastAuthCookies = LastAuthCookies; if (!IsAuthPersisted) LastAuthCookies = null; base.Save(); LastAuthCookies = lastAuthCookies; } } public partial class SettingsService { private class ContainerJsonConverter : JsonConverter { public override Container Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) { Container? result = null; if (reader.TokenType == JsonTokenType.StartObject) { while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) { if ( reader.TokenType == JsonTokenType.PropertyName && reader.GetString() == "Name" && reader.Read() && reader.TokenType == JsonTokenType.String ) { var name = reader.GetString(); if (!string.IsNullOrWhiteSpace(name)) result = new Container(name); } } } return result ?? throw new InvalidOperationException( $"Invalid JSON for type '{typeToConvert.FullName}'." ); } public override void Write( Utf8JsonWriter writer, Container value, JsonSerializerOptions options ) { writer.WriteStartObject(); writer.WriteString("Name", value.Name); writer.WriteEndObject(); } } } public partial class SettingsService { [JsonSerializable(typeof(SettingsService))] private partial class SerializerContext : JsonSerializerContext; } ================================================ FILE: YoutubeDownloader/Services/UpdateService.cs ================================================ using System; using System.Runtime.InteropServices; using System.Threading.Tasks; using Onova; using Onova.Exceptions; using Onova.Services; using YoutubeDownloader.Core.Downloading; namespace YoutubeDownloader.Services; public class UpdateService(SettingsService settingsService) : IDisposable { private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows() ? new UpdateManager( new GithubPackageResolver( "Tyrrrz", "YoutubeDownloader", // Examples: // YoutubeDownloader.win-arm64.zip // YoutubeDownloader.win-x64.zip // YoutubeDownloader.linux-x64.zip // YoutubeDownloader.Bare.linux-x64.zip FFmpeg.IsBundled() ? $"YoutubeDownloader.{RuntimeInformation.RuntimeIdentifier}.zip" : $"YoutubeDownloader.Bare.{RuntimeInformation.RuntimeIdentifier}.zip" ), new ZipPackageExtractor() ) : null; private Version? _updateVersion; private bool _updatePrepared; private bool _updaterLaunched; public async Task CheckForUpdatesAsync() { if (_updateManager is null) return null; if (!settingsService.IsAutoUpdateEnabled) return null; var check = await _updateManager.CheckForUpdatesAsync(); return check.CanUpdate ? check.LastVersion : null; } public async Task PrepareUpdateAsync(Version version) { if (_updateManager is null) return; if (!settingsService.IsAutoUpdateEnabled) return; try { await _updateManager.PrepareUpdateAsync(_updateVersion = version); _updatePrepared = true; } catch (UpdaterAlreadyLaunchedException) { // Ignore race conditions } catch (LockFileNotAcquiredException) { // Ignore race conditions } } public void FinalizeUpdate(bool needRestart) { if (_updateManager is null) return; if (!settingsService.IsAutoUpdateEnabled) return; if (_updateVersion is null || !_updatePrepared || _updaterLaunched) return; try { _updateManager.LaunchUpdater(_updateVersion, needRestart); _updaterLaunched = true; } catch (UpdaterAlreadyLaunchedException) { // Ignore race conditions } catch (LockFileNotAcquiredException) { // Ignore race conditions } } public void Dispose() => _updateManager?.Dispose(); } ================================================ FILE: YoutubeDownloader/StartOptions.cs ================================================ using System; using System.IO; namespace YoutubeDownloader; public partial class StartOptions { public required string SettingsPath { get; init; } } public partial class StartOptions { public static StartOptions Current { get; } = new() { SettingsPath = Environment.GetEnvironmentVariable("YOUTUBEDOWNLOADER_SETTINGS_PATH") is { } path && !string.IsNullOrWhiteSpace(path) ? Path.EndsInDirectorySeparator(path) || Directory.Exists(path) ? Path.Combine(path, "Settings.dat") : path : Path.Combine(AppContext.BaseDirectory, "Settings.dat"), }; } ================================================ FILE: YoutubeDownloader/Utils/Disposable.cs ================================================ using System; namespace YoutubeDownloader.Utils; internal class Disposable(Action dispose) : IDisposable { public static IDisposable Create(Action dispose) => new Disposable(dispose); public void Dispose() => dispose(); } ================================================ FILE: YoutubeDownloader/Utils/DisposableCollector.cs ================================================ using System; using System.Collections.Generic; using YoutubeDownloader.Utils.Extensions; namespace YoutubeDownloader.Utils; internal class DisposableCollector : IDisposable { private readonly object _lock = new(); private readonly List _items = []; public void Add(IDisposable item) { lock (_lock) { _items.Add(item); } } public void Dispose() { lock (_lock) { _items.DisposeAll(); _items.Clear(); } } } ================================================ FILE: YoutubeDownloader/Utils/Extensions/AvaloniaExtensions.cs ================================================ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.VisualTree; namespace YoutubeDownloader.Utils.Extensions; internal static class AvaloniaExtensions { extension(IApplicationLifetime lifetime) { public Window? TryGetMainWindow() => lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime ? desktopLifetime.MainWindow : null; public TopLevel? TryGetTopLevel() => lifetime.TryGetMainWindow() ?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel; public bool TryShutdown(int exitCode = 0) { if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) { return desktopLifetime.TryShutdown(exitCode); } if (lifetime is IControlledApplicationLifetime controlledLifetime) { controlledLifetime.Shutdown(exitCode); return true; } return false; } } } ================================================ FILE: YoutubeDownloader/Utils/Extensions/DirectoryExtensions.cs ================================================ using System.IO; namespace YoutubeDownloader.Utils.Extensions; internal static class DirectoryExtensions { extension(Directory) { public static void CreateDirectoryForFile(string filePath) { var dirPath = Path.GetDirectoryName(filePath); if (string.IsNullOrWhiteSpace(dirPath)) return; Directory.CreateDirectory(dirPath); } } } ================================================ FILE: YoutubeDownloader/Utils/Extensions/DisposableExtensions.cs ================================================ using System; using System.Collections.Generic; using System.Linq; namespace YoutubeDownloader.Utils.Extensions; internal static class DisposableExtensions { extension(IEnumerable disposables) { public void DisposeAll() { var exceptions = default(List); foreach (var disposable in disposables) { try { disposable.Dispose(); } catch (Exception ex) { (exceptions ??= []).Add(ex); } } if (exceptions?.Any() == true) throw new AggregateException(exceptions); } } } ================================================ FILE: YoutubeDownloader/Utils/Extensions/EnvironmentExtensions.cs ================================================ using System; using System.IO; namespace YoutubeDownloader.Utils.Extensions; internal static class EnvironmentExtensions { extension(Environment) { public static string? TryGetMachineId() { // Windows: stable GUID written during OS installation if (OperatingSystem.IsWindows()) { try { using var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( @"SOFTWARE\Microsoft\Cryptography" ); if ( regKey?.GetValue("MachineGuid") is string guid && !string.IsNullOrWhiteSpace(guid) ) return guid; } catch { } } else { // Unix: /etc/machine-id (set once by systemd at first boot) foreach (var path in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" }) { try { var id = File.ReadAllText(path).Trim(); if (!string.IsNullOrWhiteSpace(id)) return id; } catch { } } } // Last-resort fallback try { return Environment.MachineName; } catch { return null; } } } } ================================================ FILE: YoutubeDownloader/Utils/Extensions/NotifyPropertyChangedExtensions.cs ================================================ using System; using System.ComponentModel; using System.Linq.Expressions; using System.Reflection; namespace YoutubeDownloader.Utils.Extensions; internal static class NotifyPropertyChangedExtensions { extension(TOwner owner) where TOwner : INotifyPropertyChanged { public IDisposable WatchProperty( Expression> propertyExpression, Action callback, bool watchInitialValue = false ) { var memberExpression = propertyExpression.Body as MemberExpression; if (memberExpression?.Member is not PropertyInfo property) throw new ArgumentException("Provided expression must reference a property."); void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) { if ( string.IsNullOrWhiteSpace(args.PropertyName) || string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal) ) { callback(); } } owner.PropertyChanged += OnPropertyChanged; if (watchInitialValue) callback(); return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); } public IDisposable WatchAllProperties(Action callback, bool watchInitialValues = false) { void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback(); owner.PropertyChanged += OnPropertyChanged; if (watchInitialValues) callback(); return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); } } } ================================================ FILE: YoutubeDownloader/Utils/Extensions/PathExtensions.cs ================================================ using System.IO; namespace YoutubeDownloader.Utils.Extensions; internal static class PathExtensions { extension(Path) { public static string EnsureUniqueFilePath(string baseFilePath, int maxRetries = 100) { if (!File.Exists(baseFilePath)) return baseFilePath; var baseDirPath = Path.GetDirectoryName(baseFilePath); var baseFileNameWithoutExtension = Path.GetFileNameWithoutExtension(baseFilePath); var baseFileExtension = Path.GetExtension(baseFilePath); for (var i = 1; i <= maxRetries; i++) { var fileName = $"{baseFileNameWithoutExtension} ({i}){baseFileExtension}"; var filePath = !string.IsNullOrWhiteSpace(baseDirPath) ? Path.Combine(baseDirPath, fileName) : fileName; if (!File.Exists(filePath)) return filePath; } return baseFilePath; } } } ================================================ FILE: YoutubeDownloader/Utils/Extensions/ProcessExtensions.cs ================================================ using System.Collections.Generic; using System.Diagnostics; namespace YoutubeDownloader.Utils.Extensions; internal static class ProcessExtensions { extension(Process) { public static void Start(string path, IReadOnlyList? arguments = null) { using var process = new Process(); process.StartInfo = new ProcessStartInfo(path); if (arguments is not null) { foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument); } process.Start(); } public static void StartShellExecute(string path, IReadOnlyList? arguments = null) { using var process = new Process(); process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true }; if (arguments is not null) { foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument); } process.Start(); } } } ================================================ FILE: YoutubeDownloader/Utils/NativeMethods.cs ================================================ using System.Runtime.InteropServices; namespace YoutubeDownloader.Utils; internal static class NativeMethods { public static class Windows { [DllImport("user32.dll", SetLastError = true)] public static extern int MessageBox(nint hWnd, string text, string caption, uint type); } } ================================================ FILE: YoutubeDownloader/Utils/ResizableSemaphore.cs ================================================ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace YoutubeDownloader.Utils; // Like a regular semaphore, but the max count can be changed at any point internal partial class ResizableSemaphore : IDisposable { private readonly object _lock = new(); private readonly Queue _waiters = new(); private readonly CancellationTokenSource _cts = new(); private bool _isDisposed; private int _maxCount = int.MaxValue; private int _count; public int MaxCount { get { lock (_lock) { return _maxCount; } } set { lock (_lock) { _maxCount = value; Refresh(); } } } private void Refresh() { lock (_lock) { // Provide access to pending waiters, as long as max count allows while (_count < MaxCount && _waiters.TryDequeue(out var waiter)) { // Don't increment the count if the waiter has already been // completed before (most likely by getting canceled). if (waiter.TrySetResult()) _count++; } } } public async Task AcquireAsync(CancellationToken cancellationToken = default) { if (_isDisposed) throw new ObjectDisposedException(GetType().Name); var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using (_cts.Token.Register(() => waiter.TrySetCanceled(_cts.Token))) await using (cancellationToken.Register(() => waiter.TrySetCanceled(cancellationToken))) { // Add the waiter to the queue lock (_lock) { _waiters.Enqueue(waiter); Refresh(); } // Wait until this waiter has been given access await waiter.Task; return new AcquiredAccess(this); } } private void Release() { lock (_lock) { _count--; Refresh(); } } public void Dispose() { if (!_isDisposed) { _cts.Cancel(); _cts.Dispose(); } _isDisposed = true; } } internal partial class ResizableSemaphore { private class AcquiredAccess(ResizableSemaphore semaphore) : IDisposable { private bool _isDisposed; public void Dispose() { if (!_isDisposed) { semaphore.Release(); } _isDisposed = true; } } } ================================================ FILE: YoutubeDownloader/ViewModels/Components/DashboardViewModel.cs ================================================ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Gress; using Gress.Completable; using YoutubeDownloader.Core.Downloading; using YoutubeDownloader.Core.Resolving; using YoutubeDownloader.Core.Tagging; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using YoutubeDownloader.Services; using YoutubeDownloader.Utils; using YoutubeDownloader.Utils.Extensions; using YoutubeExplode.Exceptions; namespace YoutubeDownloader.ViewModels.Components; public partial class DashboardViewModel : ViewModelBase { private readonly ViewModelManager _viewModelManager; private readonly SnackbarManager _snackbarManager; private readonly DialogManager _dialogManager; private readonly SettingsService _settingsService; private readonly DisposableCollector _eventRoot = new(); private readonly ResizableSemaphore _downloadSemaphore = new(); private readonly AutoResetProgressMuxer _progressMuxer; public DashboardViewModel( ViewModelManager viewModelManager, SnackbarManager snackbarManager, DialogManager dialogManager, LocalizationManager localizationManager, SettingsService settingsService ) { _viewModelManager = viewModelManager; _snackbarManager = snackbarManager; _dialogManager = dialogManager; LocalizationManager = localizationManager; _settingsService = settingsService; _progressMuxer = Progress.CreateMuxer().WithAutoReset(); _eventRoot.Add( _settingsService.WatchProperty( o => o.ParallelLimit, () => _downloadSemaphore.MaxCount = _settingsService.ParallelLimit, true ) ); _eventRoot.Add( Progress.WatchProperty( o => o.Current, () => OnPropertyChanged(nameof(IsProgressIndeterminate)) ) ); } public LocalizationManager LocalizationManager { get; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))] [NotifyCanExecuteChangedFor(nameof(ProcessQueryCommand))] [NotifyCanExecuteChangedFor(nameof(ShowAuthSetupCommand))] [NotifyCanExecuteChangedFor(nameof(ShowSettingsCommand))] public partial bool IsBusy { get; set; } public ProgressContainer Progress { get; } = new(); public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ProcessQueryCommand))] public partial string? Query { get; set; } public ObservableCollection Downloads { get; } = []; private bool CanShowAuthSetup() => !IsBusy; [RelayCommand(CanExecute = nameof(CanShowAuthSetup))] private async Task ShowAuthSetupAsync() => await _dialogManager.ShowDialogAsync(_viewModelManager.CreateAuthSetupViewModel()); private bool CanShowSettings() => !IsBusy; [RelayCommand(CanExecute = nameof(CanShowSettings))] private async Task ShowSettingsAsync() => await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel()); private async void EnqueueDownload(DownloadViewModel download, int position = 0) { Downloads.Insert(position, download); var progress = _progressMuxer.CreateInput(); try { using var downloader = new VideoDownloader(_settingsService.LastAuthCookies); var tagInjector = new MediaTagInjector(); using var access = await _downloadSemaphore.AcquireAsync(download.CancellationToken); download.Status = DownloadStatus.Started; var downloadOption = download.DownloadOption ?? await downloader.GetBestDownloadOptionAsync( download.Video!.Id, download.DownloadPreference!, _settingsService.ShouldInjectLanguageSpecificAudioStreams, download.CancellationToken ); await downloader.DownloadVideoAsync( download.FilePath!, download.Video!, downloadOption, _settingsService.ShouldInjectSubtitles, _settingsService.FFmpegFilePath, download.Progress.Merge(progress), download.CancellationToken ); if (_settingsService.ShouldInjectTags) { try { await tagInjector.InjectTagsAsync( download.FilePath!, download.Video!, download.CancellationToken ); } catch { // Media tagging is not critical } } download.Status = DownloadStatus.Completed; } catch (Exception ex) { try { // Delete the incompletely downloaded file if (!string.IsNullOrWhiteSpace(download.FilePath)) File.Delete(download.FilePath); } catch { // Ignore } download.Status = ex is OperationCanceledException ? DownloadStatus.Canceled : DownloadStatus.Failed; // Short error message for YouTube-related errors, full for others download.ErrorMessage = ex is YoutubeExplodeException ? ex.Message : ex.ToString(); } finally { progress.ReportCompletion(); download.Dispose(); } } private bool CanProcessQuery() => !IsBusy && !string.IsNullOrWhiteSpace(Query); [RelayCommand(CanExecute = nameof(CanProcessQuery))] private async Task ProcessQueryAsync() { if (string.IsNullOrWhiteSpace(Query)) return; IsBusy = true; // Small weight so as to not offset any existing download operations var progress = _progressMuxer.CreateInput(0.01); try { using var resolver = new QueryResolver(_settingsService.LastAuthCookies); // Split queries by newlines var queries = Query.Split( '\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ); // Process individual queries var queryResults = new List(); foreach (var (i, query) in queries.Index()) { try { queryResults.Add(await resolver.ResolveAsync(query)); } // If it's not the only query in the list, don't interrupt the process // and report the error via an async notification instead of a sync dialog. // https://github.com/Tyrrrz/YoutubeDownloader/issues/563 catch (YoutubeExplodeException ex) when (ex is VideoUnavailableException or PlaylistUnavailableException && queries.Length > 1 ) { _snackbarManager.Notify(ex.Message); } progress.Report(Percentage.FromFraction((i + 1.0) / queries.Length)); } // Aggregate results var queryResult = QueryResult.Aggregate(queryResults); // Single video result if (queryResult.Videos.Count == 1) { var video = queryResult.Videos.Single(); using var downloader = new VideoDownloader(_settingsService.LastAuthCookies); var downloadOptions = await downloader.GetDownloadOptionsAsync( video.Id, _settingsService.ShouldInjectLanguageSpecificAudioStreams ); var download = await _dialogManager.ShowDialogAsync( _viewModelManager.CreateDownloadSingleSetupViewModel(video, downloadOptions) ); if (download is null) return; EnqueueDownload(download); Query = ""; } // Multiple videos else if (queryResult.Videos.Count > 1) { var downloads = await _dialogManager.ShowDialogAsync( _viewModelManager.CreateDownloadMultipleSetupViewModel( queryResult.Title, queryResult.Videos, // Pre-select videos if they come from a single query and not from search queryResult.Kind is not QueryResultKind.Search and not QueryResultKind.Aggregate ) ); if (downloads is null) return; foreach (var download in downloads) EnqueueDownload(download); Query = ""; } // No videos found else { await _dialogManager.ShowDialogAsync( _viewModelManager.CreateMessageBoxViewModel( LocalizationManager.NothingFoundTitle, LocalizationManager.NothingFoundMessage ) ); } } catch (Exception ex) { await _dialogManager.ShowDialogAsync( _viewModelManager.CreateMessageBoxViewModel( LocalizationManager.ErrorTitle, // Short error message for YouTube-related errors, full for others ex is YoutubeExplodeException ? ex.Message : ex.ToString() ) ); } finally { progress.ReportCompletion(); IsBusy = false; } } private void RemoveDownload(DownloadViewModel download) { Downloads.Remove(download); download.CancelCommand.Execute(null); download.Dispose(); } [RelayCommand] private void RemoveSuccessfulDownloads() { foreach (var download in Downloads.ToArray()) { if (download.Status == DownloadStatus.Completed) RemoveDownload(download); } } [RelayCommand] private void RemoveInactiveDownloads() { foreach (var download in Downloads.ToArray()) { if ( download.Status is DownloadStatus.Completed or DownloadStatus.Failed or DownloadStatus.Canceled ) RemoveDownload(download); } } [RelayCommand] private void RestartDownload(DownloadViewModel download) { var position = Math.Max(0, Downloads.IndexOf(download)); RemoveDownload(download); var newDownload = download.DownloadOption is not null ? _viewModelManager.CreateDownloadViewModel( download.Video!, download.DownloadOption, download.FilePath! ) : _viewModelManager.CreateDownloadViewModel( download.Video!, download.DownloadPreference!, download.FilePath! ); EnqueueDownload(newDownload, position); } [RelayCommand] private void RestartFailedDownloads() { foreach (var download in Downloads.ToArray()) { if (download.Status == DownloadStatus.Failed) RestartDownload(download); } } [RelayCommand] private void CancelAllDownloads() { foreach (var download in Downloads) download.CancelCommand.Execute(null); } protected override void Dispose(bool disposing) { if (disposing) { CancelAllDownloads(); _eventRoot.Dispose(); _downloadSemaphore.Dispose(); } base.Dispose(disposing); } } ================================================ FILE: YoutubeDownloader/ViewModels/Components/DownloadStatus.cs ================================================ namespace YoutubeDownloader.ViewModels.Components; public enum DownloadStatus { Enqueued, Started, Completed, Failed, Canceled, } ================================================ FILE: YoutubeDownloader/ViewModels/Components/DownloadViewModel.cs ================================================ using System; using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Gress; using YoutubeDownloader.Core.Downloading; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using YoutubeDownloader.Utils; using YoutubeDownloader.Utils.Extensions; using YoutubeExplode.Videos; namespace YoutubeDownloader.ViewModels.Components; public partial class DownloadViewModel : ViewModelBase { private readonly ViewModelManager _viewModelManager; private readonly DialogManager _dialogManager; private readonly DisposableCollector _eventRoot = new(); private readonly CancellationTokenSource _cancellationTokenSource = new(); private bool _isDisposed; public DownloadViewModel( ViewModelManager viewModelManager, DialogManager dialogManager, LocalizationManager localizationManager ) { _viewModelManager = viewModelManager; _dialogManager = dialogManager; LocalizationManager = localizationManager; _eventRoot.Add( Progress.WatchProperty( o => o.Current, () => OnPropertyChanged(nameof(IsProgressIndeterminate)) ) ); } public LocalizationManager LocalizationManager { get; } [ObservableProperty] public partial IVideo? Video { get; set; } [ObservableProperty] public partial VideoDownloadOption? DownloadOption { get; set; } [ObservableProperty] public partial VideoDownloadPreference? DownloadPreference { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(FileName))] public partial string? FilePath { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCanceledOrFailed))] [NotifyCanExecuteChangedFor(nameof(CancelCommand))] [NotifyCanExecuteChangedFor(nameof(ShowFileCommand))] [NotifyCanExecuteChangedFor(nameof(OpenFileCommand))] public partial DownloadStatus Status { get; set; } = DownloadStatus.Enqueued; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(CopyErrorMessageCommand))] public partial string? ErrorMessage { get; set; } public CancellationToken CancellationToken => _cancellationTokenSource.Token; public string? FileName => Path.GetFileName(FilePath); public ProgressContainer Progress { get; } = new(); public bool IsProgressIndeterminate => Progress.Current.Fraction is <= 0 or >= 1; public bool IsCanceledOrFailed => Status is DownloadStatus.Canceled or DownloadStatus.Failed; private bool CanCancel() => Status is DownloadStatus.Enqueued or DownloadStatus.Started; [RelayCommand(CanExecute = nameof(CanCancel))] private void Cancel() { if (_isDisposed) return; _cancellationTokenSource.Cancel(); } private bool CanShowFile() => Status == DownloadStatus.Completed // This only works on Windows currently && OperatingSystem.IsWindows(); [RelayCommand(CanExecute = nameof(CanShowFile))] private async Task ShowFileAsync() { if (string.IsNullOrWhiteSpace(FilePath)) return; try { // Navigate to the file in Windows Explorer Process.Start("explorer", ["/select,", FilePath]); } catch (Exception ex) { await _dialogManager.ShowDialogAsync( _viewModelManager.CreateMessageBoxViewModel( LocalizationManager.ErrorTitle, ex.Message ) ); } } private bool CanOpenFile() => Status == DownloadStatus.Completed; [RelayCommand(CanExecute = nameof(CanOpenFile))] private async Task OpenFileAsync() { if (string.IsNullOrWhiteSpace(FilePath)) return; try { Process.StartShellExecute(FilePath); } catch (Exception ex) { await _dialogManager.ShowDialogAsync( _viewModelManager.CreateMessageBoxViewModel( LocalizationManager.ErrorTitle, ex.Message ) ); } } [RelayCommand] private async Task CopyErrorMessageAsync() { if (string.IsNullOrWhiteSpace(ErrorMessage)) return; if (Application.Current?.ApplicationLifetime?.TryGetTopLevel()?.Clipboard is { } clipboard) await clipboard.SetTextAsync(ErrorMessage); } protected override void Dispose(bool disposing) { if (disposing) { _eventRoot.Dispose(); _cancellationTokenSource.Dispose(); _isDisposed = true; } base.Dispose(disposing); } } ================================================ FILE: YoutubeDownloader/ViewModels/Dialogs/AuthSetupViewModel.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Net; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using YoutubeDownloader.Services; using YoutubeDownloader.Utils; using YoutubeDownloader.Utils.Extensions; namespace YoutubeDownloader.ViewModels.Dialogs; public class AuthSetupViewModel : DialogViewModelBase { private readonly SettingsService _settingsService; private readonly DisposableCollector _eventRoot = new(); public AuthSetupViewModel( LocalizationManager localizationManager, SettingsService settingsService ) { LocalizationManager = localizationManager; _settingsService = settingsService; _eventRoot.Add( _settingsService.WatchProperty( o => o.LastAuthCookies, () => { OnPropertyChanged(nameof(Cookies)); OnPropertyChanged(nameof(IsAuthenticated)); } ) ); } public LocalizationManager LocalizationManager { get; } public IReadOnlyList? Cookies { get => _settingsService.LastAuthCookies; set => _settingsService.LastAuthCookies = value; } public bool IsAuthenticated => Cookies?.Any() == true && // None of the '__SECURE' cookies should be expired Cookies .Where(c => c.Name.StartsWith("__SECURE", StringComparison.OrdinalIgnoreCase)) .All(c => !c.Expired && c.Expires.ToUniversalTime() > DateTime.UtcNow); protected override void Dispose(bool disposing) { if (disposing) { _eventRoot.Dispose(); } base.Dispose(disposing); } } ================================================ FILE: YoutubeDownloader/ViewModels/Dialogs/DownloadMultipleSetupViewModel.cs ================================================ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using YoutubeDownloader.Core.Downloading; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using YoutubeDownloader.Services; using YoutubeDownloader.Utils.Extensions; using YoutubeDownloader.ViewModels.Components; using YoutubeExplode.Videos; using YoutubeExplode.Videos.Streams; namespace YoutubeDownloader.ViewModels.Dialogs; public partial class DownloadMultipleSetupViewModel( ViewModelManager viewModelManager, DialogManager dialogManager, LocalizationManager localizationManager, SettingsService settingsService ) : DialogViewModelBase> { public LocalizationManager LocalizationManager { get; } = localizationManager; [ObservableProperty] public partial string? Title { get; set; } [ObservableProperty] public partial IReadOnlyList? AvailableVideos { get; set; } [ObservableProperty] public partial Container SelectedContainer { get; set; } = Container.Mp4; [ObservableProperty] public partial VideoQualityPreference SelectedVideoQualityPreference { get; set; } = VideoQualityPreference.Highest; public ObservableCollection SelectedVideos { get; } = []; public IReadOnlyList AvailableContainers { get; } = [Container.Mp4, Container.WebM, Container.Mp3, new("ogg")]; public IReadOnlyList AvailableVideoQualityPreferences { get; } = // Without .AsEnumerable(), the below line throws a compile-time error starting with .NET SDK v9.0.200 Enum.GetValues().AsEnumerable().Reverse().ToArray(); [RelayCommand] private void Initialize() { SelectedContainer = settingsService.LastContainer; SelectedVideoQualityPreference = settingsService.LastVideoQualityPreference; SelectedVideos.CollectionChanged += (_, _) => ConfirmCommand.NotifyCanExecuteChanged(); } [RelayCommand] private async Task CopyTitleAsync() { if (Application.Current?.ApplicationLifetime?.TryGetTopLevel()?.Clipboard is { } clipboard) await clipboard.SetTextAsync(Title); } private bool CanConfirm() => SelectedVideos.Any(); [RelayCommand(CanExecute = nameof(CanConfirm))] private async Task ConfirmAsync() { var dirPath = await dialogManager.PromptDirectoryPathAsync(); if (string.IsNullOrWhiteSpace(dirPath)) return; var downloads = new List(); for (var i = 0; i < SelectedVideos.Count; i++) { var video = SelectedVideos[i]; var baseFilePath = Path.Combine( dirPath, FileNameTemplate.Apply( settingsService.FileNameTemplate, video, SelectedContainer, (i + 1).ToString().PadLeft(SelectedVideos.Count.ToString().Length, '0') ) ); if (settingsService.ShouldSkipExistingFiles && File.Exists(baseFilePath)) continue; var filePath = Path.EnsureUniqueFilePath(baseFilePath); // Download does not start immediately, so lock in the file path to avoid conflicts Directory.CreateDirectoryForFile(filePath); await File.WriteAllBytesAsync(filePath, []); downloads.Add( viewModelManager.CreateDownloadViewModel( video, new VideoDownloadPreference(SelectedContainer, SelectedVideoQualityPreference), filePath ) ); } settingsService.LastContainer = SelectedContainer; settingsService.LastVideoQualityPreference = SelectedVideoQualityPreference; Close(downloads); } } ================================================ FILE: YoutubeDownloader/ViewModels/Dialogs/DownloadSingleSetupViewModel.cs ================================================ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia; using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using YoutubeDownloader.Core.Downloading; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using YoutubeDownloader.Services; using YoutubeDownloader.Utils.Extensions; using YoutubeDownloader.ViewModels.Components; using YoutubeExplode.Videos; namespace YoutubeDownloader.ViewModels.Dialogs; public partial class DownloadSingleSetupViewModel( ViewModelManager viewModelManager, DialogManager dialogManager, LocalizationManager localizationManager, SettingsService settingsService ) : DialogViewModelBase { public LocalizationManager LocalizationManager { get; } = localizationManager; [ObservableProperty] public partial IVideo? Video { get; set; } [ObservableProperty] public partial IReadOnlyList? AvailableDownloadOptions { get; set; } [ObservableProperty] public partial VideoDownloadOption? SelectedDownloadOption { get; set; } [RelayCommand] private void Initialize() { SelectedDownloadOption = AvailableDownloadOptions?.FirstOrDefault(o => o.Container == settingsService.LastContainer ); } [RelayCommand] private async Task CopyTitleAsync() { if (Application.Current?.ApplicationLifetime?.TryGetTopLevel()?.Clipboard is { } clipboard) await clipboard.SetTextAsync(Video?.Title); } [RelayCommand] private async Task ConfirmAsync() { if (Video is null || SelectedDownloadOption is null) return; var container = SelectedDownloadOption.Container; var filePath = await dialogManager.PromptSaveFilePathAsync( [ new FilePickerFileType($"{container.Name} file") { Patterns = [$"*.{container.Name}"], }, ], FileNameTemplate.Apply(settingsService.FileNameTemplate, Video, container) ); if (string.IsNullOrWhiteSpace(filePath)) return; // Download does not start immediately, so lock in the file path to avoid conflicts Directory.CreateDirectoryForFile(filePath); await File.WriteAllBytesAsync(filePath, []); settingsService.LastContainer = container; Close(viewModelManager.CreateDownloadViewModel(Video, SelectedDownloadOption, filePath)); } } ================================================ FILE: YoutubeDownloader/ViewModels/Dialogs/MessageBoxViewModel.cs ================================================ using CommunityToolkit.Mvvm.ComponentModel; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; namespace YoutubeDownloader.ViewModels.Dialogs; public partial class MessageBoxViewModel : DialogViewModelBase { public MessageBoxViewModel(LocalizationManager localizationManager) { LocalizationManager = localizationManager; DefaultButtonText = LocalizationManager.CloseButton; CancelButtonText = LocalizationManager.CancelButton; } public LocalizationManager LocalizationManager { get; } [ObservableProperty] public partial string? Title { get; set; } [ObservableProperty] public partial string? Message { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))] [NotifyPropertyChangedFor(nameof(ButtonsCount))] public partial string? DefaultButtonText { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))] [NotifyPropertyChangedFor(nameof(ButtonsCount))] public partial string? CancelButtonText { get; set; } public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText); public bool IsCancelButtonVisible => !string.IsNullOrWhiteSpace(CancelButtonText); public int ButtonsCount => (IsDefaultButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0); } ================================================ FILE: YoutubeDownloader/ViewModels/Dialogs/SettingsViewModel.cs ================================================ using System; using System.Collections.Generic; using System.Threading.Tasks; using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.Input; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using YoutubeDownloader.Services; using YoutubeDownloader.Utils; using YoutubeDownloader.Utils.Extensions; namespace YoutubeDownloader.ViewModels.Dialogs; public partial class SettingsViewModel : DialogViewModelBase { private readonly DialogManager _dialogManager; private readonly SettingsService _settingsService; private readonly DisposableCollector _eventRoot = new(); public SettingsViewModel( DialogManager dialogManager, LocalizationManager localizationManager, SettingsService settingsService ) { _dialogManager = dialogManager; LocalizationManager = localizationManager; _settingsService = settingsService; _eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged)); } public LocalizationManager LocalizationManager { get; } public IReadOnlyList AvailableThemes { get; } = Enum.GetValues(); public ThemeVariant Theme { get => _settingsService.Theme; set => _settingsService.Theme = value; } public IReadOnlyList AvailableLanguages { get; } = Enum.GetValues(); public Language Language { get => _settingsService.Language; set => _settingsService.Language = value; } public bool IsAutoUpdateEnabled { get => _settingsService.IsAutoUpdateEnabled; set => _settingsService.IsAutoUpdateEnabled = value; } public bool IsAuthPersisted { get => _settingsService.IsAuthPersisted; set => _settingsService.IsAuthPersisted = value; } public string? FFmpegFilePath { get => _settingsService.FFmpegFilePath; set => _settingsService.FFmpegFilePath = !string.IsNullOrWhiteSpace(value) ? value : null; } public bool ShouldInjectLanguageSpecificAudioStreams { get => _settingsService.ShouldInjectLanguageSpecificAudioStreams; set => _settingsService.ShouldInjectLanguageSpecificAudioStreams = value; } public bool ShouldInjectSubtitles { get => _settingsService.ShouldInjectSubtitles; set => _settingsService.ShouldInjectSubtitles = value; } public bool ShouldInjectTags { get => _settingsService.ShouldInjectTags; set => _settingsService.ShouldInjectTags = value; } public bool ShouldSkipExistingFiles { get => _settingsService.ShouldSkipExistingFiles; set => _settingsService.ShouldSkipExistingFiles = value; } public string FileNameTemplate { get => _settingsService.FileNameTemplate; set => _settingsService.FileNameTemplate = value; } public int ParallelLimit { get => _settingsService.ParallelLimit; set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10); } [RelayCommand] private async Task BrowseFFmpegFilePathAsync() { var fileTypes = OperatingSystem.IsWindows() ? new[] { new FilePickerFileType("FFmpeg executable") { Patterns = ["*.exe"] }, FilePickerFileTypes.All, } : null; var filePath = await _dialogManager.PromptOpenFilePathAsync(fileTypes); if (string.IsNullOrWhiteSpace(filePath)) return; FFmpegFilePath = filePath; } [RelayCommand] private void ResetFFmpegFilePath() => FFmpegFilePath = null; protected override void Dispose(bool disposing) { if (disposing) { _eventRoot.Dispose(); } base.Dispose(disposing); } } ================================================ FILE: YoutubeDownloader/ViewModels/MainViewModel.cs ================================================ using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia; using CommunityToolkit.Mvvm.Input; using YoutubeDownloader.Core.Downloading; using YoutubeDownloader.Framework; using YoutubeDownloader.Localization; using YoutubeDownloader.Services; using YoutubeDownloader.Utils.Extensions; using YoutubeDownloader.ViewModels.Components; namespace YoutubeDownloader.ViewModels; public partial class MainViewModel( ViewModelManager viewModelManager, DialogManager dialogManager, SnackbarManager snackbarManager, LocalizationManager localizationManager, SettingsService settingsService, UpdateService updateService ) : ViewModelBase { public string Title { get; } = $"{Program.Name} v{Program.VersionString}"; public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel(); private async Task ShowUkraineSupportMessageAsync() { if (!settingsService.IsUkraineSupportMessageEnabled) return; var dialog = viewModelManager.CreateMessageBoxViewModel( localizationManager.UkraineSupportTitle, localizationManager.UkraineSupportMessage, localizationManager.LearnMoreButton, localizationManager.CloseButton ); // Disable this message in the future settingsService.IsUkraineSupportMessageEnabled = false; settingsService.Save(); if (await dialogManager.ShowDialogAsync(dialog) == true) Process.StartShellExecute("https://tyrrrz.me/ukraine?source=youtubedownloader"); } private async Task ShowDevelopmentBuildMessageAsync() { if (!Program.IsDevelopmentBuild) return; // If debugging, the user is likely a developer if (Debugger.IsAttached) return; var dialog = viewModelManager.CreateMessageBoxViewModel( localizationManager.UnstableBuildTitle, string.Format(localizationManager.UnstableBuildMessage, Program.Name), localizationManager.SeeReleasesButton, localizationManager.CloseButton ); if (await dialogManager.ShowDialogAsync(dialog) == true) Process.StartShellExecute(Program.ProjectReleasesUrl); } private async Task ShowFFmpegMissingMessageAsync() { if (settingsService.FFmpegFilePath is { } ffmpegFilePath) { // Explicit path set — only show the dialog if the file is missing if (File.Exists(ffmpegFilePath)) return; var dialog = viewModelManager.CreateMessageBoxViewModel( localizationManager.FFmpegMissingTitle, string.Format(localizationManager.FFmpegPathMissingMessage, ffmpegFilePath), localizationManager.SettingsButton, localizationManager.CloseButton ); if (await dialogManager.ShowDialogAsync(dialog) == true) await dialogManager.ShowDialogAsync(viewModelManager.CreateSettingsViewModel()); } else { // No explicit path — fall back to auto-detection check if (FFmpeg.TryGetCliFilePath() is not null) return; var dialog = viewModelManager.CreateMessageBoxViewModel( localizationManager.FFmpegMissingTitle, $""" {string.Format(localizationManager.FFmpegMissingMessage, Program.Name)} ―――――――――――――――――――――――――――――――――――――――――― {string.Format(localizationManager.FFmpegMissingSearchedLabel, FFmpeg.CliFileName)} {string.Join( Environment.NewLine, FFmpeg.GetProbeDirectoryPaths().Distinct(StringComparer.Ordinal).Select(d => $"- {d}" ) )} """, localizationManager.DownloadButton, localizationManager.CloseButton ); if (await dialogManager.ShowDialogAsync(dialog) == true) Process.StartShellExecute("https://ffmpeg.org/download.html"); } if (Application.Current?.ApplicationLifetime?.TryShutdown(3) != true) Environment.Exit(3); } private async Task CheckForUpdatesAsync() { try { var updateVersion = await updateService.CheckForUpdatesAsync(); if (updateVersion is null) return; snackbarManager.Notify( string.Format( localizationManager.UpdateDownloadingMessage, Program.Name, updateVersion ) ); await updateService.PrepareUpdateAsync(updateVersion); snackbarManager.Notify( localizationManager.UpdateReadyMessage, localizationManager.UpdateInstallNowButton, () => { updateService.FinalizeUpdate(true); if (Application.Current?.ApplicationLifetime?.TryShutdown(2) != true) Environment.Exit(2); } ); } catch { // Failure to update shouldn't crash the application snackbarManager.Notify(localizationManager.UpdateFailedMessage); } } [RelayCommand] private async Task InitializeAsync() { await ShowUkraineSupportMessageAsync(); await ShowDevelopmentBuildMessageAsync(); await ShowFFmpegMissingMessageAsync(); await CheckForUpdatesAsync(); } protected override void Dispose(bool disposing) { if (disposing) { // Save settings settingsService.Save(); // Finalize pending updates updateService.FinalizeUpdate(false); } base.Dispose(disposing); } } ================================================ FILE: YoutubeDownloader/Views/Components/DashboardView.axaml ================================================ ================================================ FILE: YoutubeDownloader/Views/Components/DashboardView.axaml.cs ================================================ using Avalonia; using Avalonia.Input; using Avalonia.Interactivity; using YoutubeDownloader.Framework; using YoutubeDownloader.ViewModels.Components; namespace YoutubeDownloader.Views.Components; public partial class DashboardView : UserControl { public DashboardView() { InitializeComponent(); // Bind the event with the tunnel strategy to handle keys that take part in writing text QueryTextBox.AddHandler(KeyDownEvent, QueryTextBox_OnKeyDown, RoutingStrategies.Tunnel); } private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) => QueryTextBox.Focus(); private void QueryTextBox_OnKeyDown(object? sender, KeyEventArgs args) { // When pressing Enter without Shift, execute the default button command // instead of adding a new line. if (args.Key == Key.Enter && args.KeyModifiers != KeyModifiers.Shift) { args.Handled = true; ProcessQueryButton.Command?.Execute(ProcessQueryButton.CommandParameter); } } private void StatusTextBlock_OnPointerReleased(object sender, PointerReleasedEventArgs args) { if (sender is IDataContextProvider { DataContext: DownloadViewModel dataContext }) dataContext.CopyErrorMessageCommand.Execute(null); } } ================================================ FILE: YoutubeDownloader/Views/Dialogs/AuthSetupView.axaml ================================================ ================================================ FILE: YoutubeDownloader/Views/Dialogs/MessageBoxView.axaml.cs ================================================ using YoutubeDownloader.Framework; using YoutubeDownloader.ViewModels.Dialogs; namespace YoutubeDownloader.Views.Dialogs; public partial class MessageBoxView : UserControl { public MessageBoxView() => InitializeComponent(); } ================================================ FILE: YoutubeDownloader/Views/Dialogs/SettingsView.axaml ================================================