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
[](https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md)
[](https://tyrrrz.me/ukraine)
[](https://github.com/Tyrrrz/YoutubeDownloader/actions)
[](https://github.com/Tyrrrz/YoutubeDownloader/releases)
[](https://github.com/Tyrrrz/YoutubeDownloader/releases)
[](https://discord.gg/2SUWKFnHSm)
[](https://twitter.com/tyrrrz/status/1495972128977571848)
**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



================================================
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/AuthSetupView.axaml.cs
================================================
using System;
using System.Linq;
using Avalonia.Interactivity;
using Avalonia.WebView.Windows.Core;
using Microsoft.Web.WebView2.Core;
using WebViewCore.Events;
using YoutubeDownloader.Framework;
using YoutubeDownloader.ViewModels.Dialogs;
namespace YoutubeDownloader.Views.Dialogs;
public partial class AuthSetupView : UserControl
{
private const string HomePageUrl = "https://www.youtube.com";
private static readonly string LoginPageUrl =
$"https://accounts.google.com/ServiceLogin?continue={Uri.EscapeDataString(HomePageUrl)}";
private CoreWebView2? _coreWebView2;
public AuthSetupView() => InitializeComponent();
private void NavigateToLoginPage() => WebBrowser.Url = new Uri(LoginPageUrl);
private void LogOutButton_OnClick(object sender, RoutedEventArgs args)
{
DataContext.Cookies = null;
NavigateToLoginPage();
}
private void WebBrowser_OnLoaded(object sender, RoutedEventArgs args) => NavigateToLoginPage();
private void WebBrowser_OnWebViewCreated(object sender, WebViewCreatedEventArgs args)
{
if (!args.IsSucceed)
return;
var platformWebView = WebBrowser.PlatformWebView as WebView2Core;
var coreWebView2 = platformWebView?.CoreWebView2;
if (coreWebView2 is null)
return;
coreWebView2.Settings.AreDefaultContextMenusEnabled = false;
coreWebView2.Settings.AreDevToolsEnabled = false;
coreWebView2.Settings.IsGeneralAutofillEnabled = false;
coreWebView2.Settings.IsPasswordAutosaveEnabled = false;
coreWebView2.Settings.IsStatusBarEnabled = false;
coreWebView2.Settings.IsSwipeNavigationEnabled = false;
_coreWebView2 = coreWebView2;
}
private async void WebBrowser_OnNavigationStarting(
object? sender,
WebViewUrlLoadingEventArg args
)
{
if (_coreWebView2 is null)
return;
// Reset existing browser cookies if the user is attempting to log in (again)
if (string.Equals(args.Url?.AbsoluteUri, LoginPageUrl, StringComparison.OrdinalIgnoreCase))
_coreWebView2.CookieManager.DeleteAllCookies();
// Extract the cookies after being redirected to the home page (i.e. after logging in)
if (
args.Url?.AbsoluteUri.StartsWith(HomePageUrl, StringComparison.OrdinalIgnoreCase)
== true
)
{
var cookies = await _coreWebView2!.CookieManager.GetCookiesAsync(args.Url.AbsoluteUri);
DataContext.Cookies = cookies.Select(c => c.ToSystemNetCookie()).ToArray();
}
}
}
================================================
FILE: YoutubeDownloader/Views/Dialogs/DownloadMultipleSetupView.axaml
================================================
================================================
FILE: YoutubeDownloader/Views/Dialogs/DownloadMultipleSetupView.axaml.cs
================================================
using Avalonia.Interactivity;
using YoutubeDownloader.Framework;
using YoutubeDownloader.ViewModels.Dialogs;
namespace YoutubeDownloader.Views.Dialogs;
public partial class DownloadMultipleSetupView : UserControl
{
public DownloadMultipleSetupView() => InitializeComponent();
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>
DataContext.InitializeCommand.Execute(null);
}
================================================
FILE: YoutubeDownloader/Views/Dialogs/DownloadSingleSetupView.axaml
================================================
================================================
FILE: YoutubeDownloader/Views/Dialogs/DownloadSingleSetupView.axaml.cs
================================================
using Avalonia.Interactivity;
using YoutubeDownloader.Framework;
using YoutubeDownloader.ViewModels.Dialogs;
namespace YoutubeDownloader.Views.Dialogs;
public partial class DownloadSingleSetupView : UserControl
{
public DownloadSingleSetupView() => InitializeComponent();
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>
DataContext.InitializeCommand.Execute(null);
}
================================================
FILE: YoutubeDownloader/Views/Dialogs/MessageBoxView.axaml
================================================
True
================================================
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
================================================
================================================
FILE: YoutubeDownloader/Views/Dialogs/SettingsView.axaml.cs
================================================
using YoutubeDownloader.Framework;
using YoutubeDownloader.ViewModels.Dialogs;
namespace YoutubeDownloader.Views.Dialogs;
public partial class SettingsView : UserControl
{
public SettingsView() => InitializeComponent();
}
================================================
FILE: YoutubeDownloader/Views/MainView.axaml
================================================
================================================
FILE: YoutubeDownloader/Views/MainView.axaml.cs
================================================
using Avalonia.Interactivity;
using YoutubeDownloader.Framework;
using YoutubeDownloader.ViewModels;
namespace YoutubeDownloader.Views;
public partial class MainView : Window
{
public MainView() => InitializeComponent();
private void DialogHost_OnLoaded(object? sender, RoutedEventArgs args) =>
DataContext.InitializeCommand.Execute(null);
}
================================================
FILE: YoutubeDownloader/YoutubeDownloader.csproj
================================================
WinExe
..\favicon.ico
app.manifest
false
false
true
true
true
false
HimalayanPinkSalt
================================================
FILE: YoutubeDownloader/app.manifest
================================================
================================================
FILE: YoutubeDownloader.Core/Downloading/FFmpeg.cs
================================================
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace YoutubeDownloader.Core.Downloading;
public static class FFmpeg
{
public static string CliFileName { get; } =
OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
public static IEnumerable GetProbeDirectoryPaths()
{
yield return AppContext.BaseDirectory;
yield return Directory.GetCurrentDirectory();
// Process PATH
if (
Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) is
{ } processPaths
)
{
foreach (var path in processPaths)
if (!string.IsNullOrWhiteSpace(path))
yield return path;
}
// Registry-based PATH variables
if (OperatingSystem.IsWindows())
{
// User PATH
if (
Environment
.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User)
?.Split(Path.PathSeparator) is
{ } userPaths
)
{
foreach (var path in userPaths)
if (!string.IsNullOrWhiteSpace(path))
yield return path;
}
// System PATH
if (
Environment
.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine)
?.Split(Path.PathSeparator) is
{ } systemPaths
)
{
foreach (var path in systemPaths)
if (!string.IsNullOrWhiteSpace(path))
yield return path;
}
}
}
public static string? TryGetCliFilePath() =>
GetProbeDirectoryPaths()
.Distinct(StringComparer.Ordinal)
.Select(dirPath => Path.Combine(dirPath, CliFileName))
.FirstOrDefault(File.Exists);
public static bool IsBundled() =>
File.Exists(Path.Combine(AppContext.BaseDirectory, CliFileName));
}
================================================
FILE: YoutubeDownloader.Core/Downloading/FileNameTemplate.cs
================================================
using System;
using System.IO;
using YoutubeDownloader.Core.Utils.Extensions;
using YoutubeExplode.Videos;
using YoutubeExplode.Videos.Streams;
namespace YoutubeDownloader.Core.Downloading;
public static class FileNameTemplate
{
public static string Apply(
string template,
IVideo video,
Container container,
string? number = null
) =>
Path.EscapeFileName(
template
.Replace("$numc", number ?? "", StringComparison.Ordinal)
.Replace("$num", number is not null ? $"[{number}]" : "", StringComparison.Ordinal)
.Replace("$id", video.Id, StringComparison.Ordinal)
.Replace("$title", video.Title, StringComparison.Ordinal)
.Replace("$author", video.Author.ChannelTitle, StringComparison.Ordinal)
.Replace(
"$uploadDate",
(video as Video)?.UploadDate.ToString("yyyy-MM-dd") ?? "",
StringComparison.Ordinal
)
.Trim()
+ '.'
+ container.Name
);
}
================================================
FILE: YoutubeDownloader.Core/Downloading/VideoDownloadOption.cs
================================================
using System;
using System.Collections.Generic;
using System.Linq;
using YoutubeDownloader.Core.Utils.Extensions;
using YoutubeExplode.Videos.Streams;
namespace YoutubeDownloader.Core.Downloading;
public partial record VideoDownloadOption(
Container Container,
bool IsAudioOnly,
IReadOnlyList StreamInfos
)
{
public VideoQuality? VideoQuality { get; } =
StreamInfos.OfType().MaxBy(s => s.VideoQuality)?.VideoQuality;
}
public partial record VideoDownloadOption
{
internal static IReadOnlyList ResolveAll(
StreamManifest manifest,
bool includeLanguageSpecificAudioStreams = true
)
{
IEnumerable GetVideoAndAudioOptions()
{
var videoStreamInfos = manifest
.GetVideoStreams()
.OrderByDescending(v => v.VideoQuality);
foreach (var videoStreamInfo in videoStreamInfos)
{
// Muxed stream
if (videoStreamInfo is MuxedStreamInfo)
{
yield return new VideoDownloadOption(
videoStreamInfo.Container,
false,
[videoStreamInfo]
);
}
// Separate audio + video stream
else
{
var audioStreamInfos = manifest
.GetAudioStreams()
// Prefer audio streams with the same container
.OrderByDescending(s => s.Container == videoStreamInfo.Container)
.ThenByDescending(s => s is AudioOnlyStreamInfo)
.ThenByDescending(s => s.Bitrate)
.ToArray();
// Prefer language-specific audio streams, if available and if allowed
var languageSpecificAudioStreamInfos = includeLanguageSpecificAudioStreams
? audioStreamInfos
.Where(s => s.AudioLanguage is not null)
.DistinctBy(s => s.AudioLanguage)
// Default language first so it's encoded as the first audio track in the output file
.OrderByDescending(s => s.IsAudioLanguageDefault)
.ToArray()
: [];
// If there are language-specific streams, include them all
if (languageSpecificAudioStreamInfos.Any())
{
yield return new VideoDownloadOption(
videoStreamInfo.Container,
false,
[videoStreamInfo, .. languageSpecificAudioStreamInfos]
);
}
// If there are no language-specific streams, download the single best quality audio stream
else
{
var audioStreamInfo = audioStreamInfos
// Prefer audio streams in the default language (or non-language-specific streams)
.OrderByDescending(s => s.IsAudioLanguageDefault ?? true)
.FirstOrDefault();
if (audioStreamInfo is not null)
{
yield return new VideoDownloadOption(
videoStreamInfo.Container,
false,
[videoStreamInfo, audioStreamInfo]
);
}
}
}
}
}
IEnumerable GetAudioOnlyOptions()
{
// WebM-based audio-only containers
{
var audioStreamInfo = manifest
.GetAudioStreams()
// Prefer audio streams in the default language (or non-language-specific streams)
.OrderByDescending(s => s.IsAudioLanguageDefault ?? true)
// Prefer audio streams with the same container
.ThenByDescending(s => s.Container == Container.WebM)
.ThenByDescending(s => s is AudioOnlyStreamInfo)
.ThenByDescending(s => s.Bitrate)
.FirstOrDefault();
if (audioStreamInfo is not null)
{
yield return new VideoDownloadOption(Container.WebM, true, [audioStreamInfo]);
yield return new VideoDownloadOption(Container.Mp3, true, [audioStreamInfo]);
yield return new VideoDownloadOption(
new Container("ogg"),
true,
[audioStreamInfo]
);
}
}
// Mp4-based audio-only containers
{
var audioStreamInfo = manifest
.GetAudioStreams()
// Prefer audio streams in the default language (or non-language-specific streams)
.OrderByDescending(s => s.IsAudioLanguageDefault ?? true)
// Prefer audio streams with the same container
.ThenByDescending(s => s.Container == Container.Mp4)
.ThenByDescending(s => s is AudioOnlyStreamInfo)
.ThenByDescending(s => s.Bitrate)
.FirstOrDefault();
if (audioStreamInfo is not null)
{
yield return new VideoDownloadOption(Container.Mp4, true, [audioStreamInfo]);
}
}
}
// Deduplicate download options by video quality and container
var comparer = EqualityComparer.Create(
(x, y) => x?.VideoQuality == y?.VideoQuality && x?.Container == y?.Container,
x => HashCode.Combine(x.VideoQuality, x.Container)
);
var options = new HashSet(comparer);
options.AddRange(GetVideoAndAudioOptions());
options.AddRange(GetAudioOnlyOptions());
return options.ToArray();
}
}
================================================
FILE: YoutubeDownloader.Core/Downloading/VideoDownloadPreference.cs
================================================
using System;
using System.Collections.Generic;
using System.Linq;
using YoutubeExplode.Videos.Streams;
namespace YoutubeDownloader.Core.Downloading;
public record VideoDownloadPreference(
Container PreferredContainer,
VideoQualityPreference PreferredVideoQuality
)
{
public VideoDownloadOption? TryGetBestOption(IReadOnlyList options)
{
// Short-circuit for audio-only formats
if (PreferredContainer.IsAudioOnly)
return options.FirstOrDefault(o => o.Container == PreferredContainer);
var orderedOptions = options.OrderBy(o => o.VideoQuality).ToArray();
var preferredOption = PreferredVideoQuality switch
{
VideoQualityPreference.Highest => orderedOptions.LastOrDefault(o =>
o.Container == PreferredContainer
),
VideoQualityPreference.UpTo1080p => orderedOptions
.Where(o => o.VideoQuality?.MaxHeight <= 1080)
.LastOrDefault(o => o.Container == PreferredContainer),
VideoQualityPreference.UpTo720p => orderedOptions
.Where(o => o.VideoQuality?.MaxHeight <= 720)
.LastOrDefault(o => o.Container == PreferredContainer),
VideoQualityPreference.UpTo480p => orderedOptions
.Where(o => o.VideoQuality?.MaxHeight <= 480)
.LastOrDefault(o => o.Container == PreferredContainer),
VideoQualityPreference.UpTo360p => orderedOptions
.Where(o => o.VideoQuality?.MaxHeight <= 360)
.LastOrDefault(o => o.Container == PreferredContainer),
VideoQualityPreference.Lowest => orderedOptions.FirstOrDefault(o =>
o.Container == PreferredContainer
),
_ => throw new InvalidOperationException(
$"Unknown video quality preference '{PreferredVideoQuality}'."
),
};
return preferredOption
?? orderedOptions.FirstOrDefault(o => o.Container == PreferredContainer);
}
}
================================================
FILE: YoutubeDownloader.Core/Downloading/VideoDownloader.cs
================================================
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Gress;
using YoutubeDownloader.Core.Utils;
using YoutubeExplode;
using YoutubeExplode.Converter;
using YoutubeExplode.Videos;
using YoutubeExplode.Videos.ClosedCaptions;
namespace YoutubeDownloader.Core.Downloading;
public class VideoDownloader(IReadOnlyList? initialCookies = null) : IDisposable
{
private readonly YoutubeClient _youtube = new(Http.Client, initialCookies ?? []);
public async Task> GetDownloadOptionsAsync(
VideoId videoId,
bool includeLanguageSpecificAudioStreams = true,
CancellationToken cancellationToken = default
)
{
var manifest = await _youtube.Videos.Streams.GetManifestAsync(videoId, cancellationToken);
return VideoDownloadOption.ResolveAll(manifest, includeLanguageSpecificAudioStreams);
}
public async Task GetBestDownloadOptionAsync(
VideoId videoId,
VideoDownloadPreference preference,
bool includeLanguageSpecificAudioStreams = true,
CancellationToken cancellationToken = default
)
{
var options = await GetDownloadOptionsAsync(
videoId,
includeLanguageSpecificAudioStreams,
cancellationToken
);
return preference.TryGetBestOption(options)
?? throw new InvalidOperationException("No suitable download option found.");
}
public async Task DownloadVideoAsync(
string filePath,
IVideo video,
VideoDownloadOption downloadOption,
bool includeSubtitles = true,
string? ffmpegPath = null,
IProgress? progress = null,
CancellationToken cancellationToken = default
)
{
// Include subtitles in the output container
var trackInfos = new List();
if (includeSubtitles && !downloadOption.Container.IsAudioOnly)
{
var manifest = await _youtube.Videos.ClosedCaptions.GetManifestAsync(
video.Id,
cancellationToken
);
trackInfos.AddRange(manifest.Tracks);
}
var dirPath = Path.GetDirectoryName(filePath);
if (!string.IsNullOrWhiteSpace(dirPath))
Directory.CreateDirectory(dirPath);
await _youtube.Videos.DownloadAsync(
downloadOption.StreamInfos,
trackInfos,
new ConversionRequestBuilder(filePath)
.SetFFmpegPath(ffmpegPath ?? FFmpeg.TryGetCliFilePath() ?? "ffmpeg")
.SetContainer(downloadOption.Container)
.SetPreset(ConversionPreset.Medium)
.Build(),
progress?.ToDoubleBased(),
cancellationToken
);
}
public void Dispose() => _youtube.Dispose();
}
================================================
FILE: YoutubeDownloader.Core/Downloading/VideoQualityPreference.cs
================================================
using System;
namespace YoutubeDownloader.Core.Downloading;
public enum VideoQualityPreference
{
// ReSharper disable InconsistentNaming
Lowest,
UpTo360p,
UpTo480p,
UpTo720p,
UpTo1080p,
Highest,
// ReSharper restore InconsistentNaming
}
public static class VideoQualityPreferenceExtensions
{
extension(VideoQualityPreference preference)
{
public string GetDisplayName() =>
preference switch
{
VideoQualityPreference.Lowest => "Lowest quality",
VideoQualityPreference.UpTo360p => "≤ 360p",
VideoQualityPreference.UpTo480p => "≤ 480p",
VideoQualityPreference.UpTo720p => "≤ 720p",
VideoQualityPreference.UpTo1080p => "≤ 1080p",
VideoQualityPreference.Highest => "Highest quality",
_ => throw new ArgumentOutOfRangeException(nameof(preference)),
};
}
}
================================================
FILE: YoutubeDownloader.Core/Resolving/QueryResolver.cs
================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using YoutubeDownloader.Core.Utils;
using YoutubeExplode;
using YoutubeExplode.Channels;
using YoutubeExplode.Common;
using YoutubeExplode.Playlists;
using YoutubeExplode.Videos;
namespace YoutubeDownloader.Core.Resolving;
public class QueryResolver(IReadOnlyList? initialCookies = null) : IDisposable
{
private readonly YoutubeClient _youtube = new(Http.Client, initialCookies ?? []);
private readonly bool _isAuthenticated = initialCookies?.Any() == true;
private async Task TryResolvePlaylistAsync(
string query,
CancellationToken cancellationToken = default
)
{
if (PlaylistId.TryParse(query) is not { } playlistId)
return null;
// Skip personal system playlists if the user is not authenticated
var isPersonalSystemPlaylist =
playlistId == "WL" || playlistId == "LL" || playlistId == "LM";
if (isPersonalSystemPlaylist && !_isAuthenticated)
return null;
var playlist = await _youtube.Playlists.GetAsync(playlistId, cancellationToken);
var videos = await _youtube.Playlists.GetVideosAsync(playlistId, cancellationToken);
return new QueryResult(QueryResultKind.Playlist, $"Playlist: {playlist.Title}", videos);
}
private async Task TryResolveVideoAsync(
string query,
CancellationToken cancellationToken = default
)
{
if (VideoId.TryParse(query) is not { } videoId)
return null;
var video = await _youtube.Videos.GetAsync(videoId, cancellationToken);
return new QueryResult(QueryResultKind.Video, video.Title, [video]);
}
private async Task TryResolveChannelAsync(
string query,
CancellationToken cancellationToken = default
)
{
if (ChannelId.TryParse(query) is { } channelId)
{
var channel = await _youtube.Channels.GetAsync(channelId, cancellationToken);
var videos = await _youtube.Channels.GetUploadsAsync(channelId, cancellationToken);
return new QueryResult(QueryResultKind.Channel, $"Channel: {channel.Title}", videos);
}
if (ChannelHandle.TryParse(query) is { } channelHandle)
{
var channel = await _youtube.Channels.GetByHandleAsync(
channelHandle,
cancellationToken
);
var videos = await _youtube.Channels.GetUploadsAsync(channel.Id, cancellationToken);
return new QueryResult(QueryResultKind.Channel, $"Channel: {channel.Title}", videos);
}
if (UserName.TryParse(query) is { } userName)
{
var channel = await _youtube.Channels.GetByUserAsync(userName, cancellationToken);
var videos = await _youtube.Channels.GetUploadsAsync(channel.Id, cancellationToken);
return new QueryResult(QueryResultKind.Channel, $"Channel: {channel.Title}", videos);
}
if (ChannelSlug.TryParse(query) is { } channelSlug)
{
var channel = await _youtube.Channels.GetBySlugAsync(channelSlug, cancellationToken);
var videos = await _youtube.Channels.GetUploadsAsync(channel.Id, cancellationToken);
return new QueryResult(QueryResultKind.Channel, $"Channel: {channel.Title}", videos);
}
return null;
}
private async Task ResolveSearchAsync(
string query,
CancellationToken cancellationToken = default
)
{
var videos = await _youtube
.Search.GetVideosAsync(query, cancellationToken)
.CollectAsync(20);
return new QueryResult(QueryResultKind.Search, $"Search: {query}", videos);
}
public async Task ResolveAsync(
string query,
CancellationToken cancellationToken = default
)
{
// If the query starts with a question mark, it's always treated as a search query
if (query.StartsWith('?'))
return await ResolveSearchAsync(query[1..], cancellationToken);
return await TryResolvePlaylistAsync(query, cancellationToken)
?? await TryResolveVideoAsync(query, cancellationToken)
?? await TryResolveChannelAsync(query, cancellationToken)
?? await ResolveSearchAsync(query, cancellationToken);
}
public void Dispose() => _youtube.Dispose();
}
================================================
FILE: YoutubeDownloader.Core/Resolving/QueryResult.cs
================================================
using System;
using System.Collections.Generic;
using System.Linq;
using YoutubeExplode.Videos;
namespace YoutubeDownloader.Core.Resolving;
public record QueryResult(QueryResultKind Kind, string Title, IReadOnlyList Videos)
{
public static QueryResult Aggregate(IReadOnlyList results)
{
if (!results.Any())
throw new ArgumentException("Cannot aggregate empty results.", nameof(results));
return new QueryResult(
// Single query -> inherit kind, multiple queries -> aggregate
results.Count == 1
? results.Single().Kind
: QueryResultKind.Aggregate,
// Single query -> inherit title, multiple queries -> aggregate
results.Count == 1
? results.Single().Title
: $"{results.Count} queries",
// Combine all videos, deduplicate by ID
results.SelectMany(q => q.Videos).DistinctBy(v => v.Id).ToArray()
);
}
}
================================================
FILE: YoutubeDownloader.Core/Resolving/QueryResultKind.cs
================================================
namespace YoutubeDownloader.Core.Resolving;
public enum QueryResultKind
{
Video,
Playlist,
Channel,
Search,
Aggregate,
}
================================================
FILE: YoutubeDownloader.Core/Tagging/MediaFile.cs
================================================
using System;
using TagLib;
using TagFile = TagLib.File;
namespace YoutubeDownloader.Core.Tagging;
internal partial class MediaFile(TagFile file) : IDisposable
{
public void SetThumbnail(byte[] thumbnailData) =>
file.Tag.Pictures = [new Picture(thumbnailData)];
public void SetArtist(string artist) => file.Tag.Performers = [artist];
public void SetArtistSort(string artistSort) => file.Tag.PerformersSort = [artistSort];
public void SetTitle(string title) => file.Tag.Title = title;
public void SetAlbum(string album) => file.Tag.Album = album;
public void SetDescription(string description) => file.Tag.Description = description;
public void SetComment(string comment) => file.Tag.Comment = comment;
public void Save()
{
file.Tag.DateTagged = DateTime.Now;
file.Save();
}
public void Dispose() => file.Dispose();
}
internal partial class MediaFile
{
public static MediaFile Open(string filePath) => new(TagFile.Create(filePath));
}
================================================
FILE: YoutubeDownloader.Core/Tagging/MediaTagInjector.cs
================================================
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using YoutubeDownloader.Core.Utils;
using YoutubeDownloader.Core.Utils.Extensions;
using YoutubeExplode.Videos;
namespace YoutubeDownloader.Core.Tagging;
public class MediaTagInjector
{
private readonly MusicBrainzClient _musicBrainz = new();
private void InjectMiscMetadata(MediaFile mediaFile, IVideo video)
{
var description = (video as Video)?.Description;
if (!string.IsNullOrWhiteSpace(description))
mediaFile.SetDescription(description);
mediaFile.SetComment(
$"""
Downloaded using YoutubeDownloader (https://github.com/Tyrrrz/YoutubeDownloader)
Video: {video.Title}
Video URL: {video.Url}
Channel: {video.Author.ChannelTitle}
Channel URL: {video.Author.ChannelUrl}
"""
);
}
private async Task InjectMusicMetadataAsync(
MediaFile mediaFile,
IVideo video,
CancellationToken cancellationToken = default
)
{
var recordings = await _musicBrainz.SearchRecordingsAsync(video.Title, cancellationToken);
var recording = recordings.FirstOrDefault(r =>
// Recording title must be a part of the video title.
// Recording artist must be a part of the video title or channel title.
video.Title.Contains(r.Title, StringComparison.OrdinalIgnoreCase)
&& (
video.Title.Contains(r.Artist, StringComparison.OrdinalIgnoreCase)
|| video.Author.ChannelTitle.Contains(r.Artist, StringComparison.OrdinalIgnoreCase)
)
);
if (recording is null)
return;
mediaFile.SetArtist(recording.Artist);
mediaFile.SetTitle(recording.Title);
if (!string.IsNullOrWhiteSpace(recording.ArtistSort))
mediaFile.SetArtistSort(recording.ArtistSort);
if (!string.IsNullOrWhiteSpace(recording.Album))
mediaFile.SetAlbum(recording.Album);
}
private async Task InjectThumbnailAsync(
MediaFile mediaFile,
IVideo video,
CancellationToken cancellationToken = default
)
{
var thumbnailUrl =
video
.Thumbnails.Where(t =>
string.Equals(t.TryGetImageFormat(), "jpg", StringComparison.OrdinalIgnoreCase)
)
.OrderByDescending(t => t.Resolution.Area)
.Select(t => t.Url)
.FirstOrDefault()
?? $"https://i.ytimg.com/vi/{video.Id}/hqdefault.jpg";
mediaFile.SetThumbnail(
await Http.Client.GetByteArrayAsync(thumbnailUrl, cancellationToken)
);
}
public async Task InjectTagsAsync(
string filePath,
IVideo video,
CancellationToken cancellationToken = default
)
{
using var mediaFile = MediaFile.Open(filePath);
InjectMiscMetadata(mediaFile, video);
await InjectMusicMetadataAsync(mediaFile, video, cancellationToken);
await InjectThumbnailAsync(mediaFile, video, cancellationToken);
mediaFile.Save();
}
}
================================================
FILE: YoutubeDownloader.Core/Tagging/MusicBrainzClient.cs
================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using JsonExtensions.Http;
using JsonExtensions.Reading;
using YoutubeDownloader.Core.Utils;
namespace YoutubeDownloader.Core.Tagging;
internal class MusicBrainzClient
{
// 4 requests per second
private readonly ThrottleLock _throttleLock = new(TimeSpan.FromSeconds(1.0 / 4));
public async IAsyncEnumerable SearchRecordingsAsync(
string query,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
var url =
"https://musicbrainz.org/ws/2/recording/"
+ "?version=2"
+ "&fmt=json"
+ "&dismax=true"
+ "&limit=100"
+ $"&query={Uri.EscapeDataString(query)}";
await _throttleLock.WaitAsync(cancellationToken);
var json = await Http.Client.GetJsonAsync(url, cancellationToken);
var recordingsJson =
json.GetPropertyOrNull("recordings")?.EnumerateArrayOrNull() ?? default;
foreach (var recordingJson in recordingsJson)
{
var artist = recordingJson
.GetPropertyOrNull("artist-credit")
?.EnumerateArrayOrNull()
?.FirstOrDefault()
.GetPropertyOrNull("name")
?.GetNonWhiteSpaceStringOrNull();
if (string.IsNullOrWhiteSpace(artist))
continue;
var artistSort = recordingJson
.GetPropertyOrNull("artist-credit")
?.EnumerateArrayOrNull()
?.FirstOrDefault()
.GetPropertyOrNull("artist")
?.GetPropertyOrNull("sort-name")
?.GetNonWhiteSpaceStringOrNull();
var title = recordingJson.GetPropertyOrNull("title")?.GetNonWhiteSpaceStringOrNull();
if (string.IsNullOrWhiteSpace(title))
continue;
var album = recordingJson
.GetPropertyOrNull("releases")
?.EnumerateArrayOrNull()
?.FirstOrDefault()
.GetPropertyOrNull("title")
?.GetNonWhiteSpaceStringOrNull();
yield return new MusicBrainzRecording(artist, artistSort, title, album);
}
}
}
================================================
FILE: YoutubeDownloader.Core/Tagging/MusicBrainzRecording.cs
================================================
namespace YoutubeDownloader.Core.Tagging;
internal record MusicBrainzRecording(
string Artist,
string? ArtistSort,
string Title,
string? Album
);
================================================
FILE: YoutubeDownloader.Core/Utils/Extensions/AsyncCollectionExtensions.cs
================================================
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace YoutubeDownloader.Core.Utils.Extensions;
public static class AsyncCollectionExtensions
{
extension(IAsyncEnumerable asyncEnumerable)
{
private async ValueTask> CollectAsync()
{
var list = new List();
await foreach (var i in asyncEnumerable)
list.Add(i);
return list;
}
public ValueTaskAwaiter> GetAwaiter() =>
asyncEnumerable.CollectAsync().GetAwaiter();
}
}
================================================
FILE: YoutubeDownloader.Core/Utils/Extensions/CollectionExtensions.cs
================================================
using System.Collections.Generic;
namespace YoutubeDownloader.Core.Utils.Extensions;
public static class CollectionExtensions
{
extension(ICollection source)
{
public void AddRange(IEnumerable items)
{
foreach (var i in items)
source.Add(i);
}
}
}
================================================
FILE: YoutubeDownloader.Core/Utils/Extensions/GenericExtensions.cs
================================================
using System;
namespace YoutubeDownloader.Core.Utils.Extensions;
public static class GenericExtensions
{
extension(TIn input)
{
public TOut Pipe(Func transform) => transform(input);
}
}
================================================
FILE: YoutubeDownloader.Core/Utils/Extensions/PathExtensions.cs
================================================
using System.IO;
using System.Linq;
using System.Text;
namespace YoutubeDownloader.Core.Utils.Extensions;
public static class PathExtensions
{
extension(Path)
{
public static string EscapeFileName(string path)
{
var buffer = new StringBuilder(path.Length);
foreach (var c in path)
buffer.Append(!Path.GetInvalidFileNameChars().Contains(c) ? c : '_');
return buffer.ToString();
}
}
}
================================================
FILE: YoutubeDownloader.Core/Utils/Extensions/StringExtensions.cs
================================================
namespace YoutubeDownloader.Core.Utils.Extensions;
public static class StringExtensions
{
extension(string str)
{
public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null;
}
}
================================================
FILE: YoutubeDownloader.Core/Utils/Extensions/YoutubeExtensions.cs
================================================
using System.IO;
using YoutubeExplode.Common;
namespace YoutubeDownloader.Core.Utils.Extensions;
public static class YoutubeExtensions
{
extension(Thumbnail thumbnail)
{
public string? TryGetImageFormat() =>
Url.TryExtractFileName(thumbnail.Url)?.Pipe(Path.GetExtension)?.Trim('.');
}
}
================================================
FILE: YoutubeDownloader.Core/Utils/Http.cs
================================================
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
namespace YoutubeDownloader.Core.Utils;
public static class Http
{
public static HttpClient Client { get; } =
new()
{
DefaultRequestHeaders =
{
// Required by some of the services we're using
UserAgent =
{
new ProductInfoHeaderValue(
"YoutubeDownloader",
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)
),
},
},
};
}
================================================
FILE: YoutubeDownloader.Core/Utils/ThrottleLock.cs
================================================
using System;
using System.Threading;
using System.Threading.Tasks;
namespace YoutubeDownloader.Core.Utils;
public class ThrottleLock(TimeSpan interval) : IDisposable
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private DateTimeOffset _lastRequestInstant = DateTimeOffset.MinValue;
public async Task WaitAsync(CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
var timePassedSinceLastRequest = DateTimeOffset.Now - _lastRequestInstant;
var remainingTime = interval - timePassedSinceLastRequest;
if (remainingTime > TimeSpan.Zero)
await Task.Delay(remainingTime, cancellationToken);
_lastRequestInstant = DateTimeOffset.Now;
}
finally
{
_semaphore.Release();
}
}
public void Dispose() => _semaphore.Dispose();
}
================================================
FILE: YoutubeDownloader.Core/Utils/Url.cs
================================================
using System.Text.RegularExpressions;
using YoutubeDownloader.Core.Utils.Extensions;
namespace YoutubeDownloader.Core.Utils;
public static class Url
{
public static string? TryExtractFileName(string url) =>
Regex.Match(url, @".+/([^?]*)").Groups[1].Value.NullIfWhiteSpace();
}
================================================
FILE: YoutubeDownloader.Core/YoutubeDownloader.Core.csproj
================================================
================================================
FILE: YoutubeDownloader.sln
================================================
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.7.33920.267
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YoutubeDownloader", "YoutubeDownloader\YoutubeDownloader.csproj", "{AF6D645E-DDDD-4034-B644-D5328CC893C1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{131C2561-E5A1-43E8-BF38-40E2E23DB0A4}"
ProjectSection(SolutionItems) = preProject
Directory.Build.props = Directory.Build.props
Directory.Packages.props = Directory.Packages.props
License.txt = License.txt
Readme.md = Readme.md
global.json = global.json
NuGet.config = NuGet.config
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YoutubeDownloader.Core", "YoutubeDownloader.Core\YoutubeDownloader.Core.csproj", "{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{AF6D645E-DDDD-4034-B644-D5328CC893C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AF6D645E-DDDD-4034-B644-D5328CC893C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AF6D645E-DDDD-4034-B644-D5328CC893C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AF6D645E-DDDD-4034-B644-D5328CC893C1}.Release|Any CPU.Build.0 = Release|Any CPU
{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1455235F-4357-4DB9-BCC1-41A5A8B10AC5}
EndGlobalSection
EndGlobal
================================================
FILE: global.json
================================================
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}