[
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yml",
    "content": "name: 🐛 Bug report\ndescription: Report broken functionality.\nlabels: [bug]\n\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        - Avoid generic or vague titles such as \"Something's not working\" or \"A couple of problems\" — be as descriptive as possible.\n        - Keep your issue focused on one single problem. If you have multiple bug reports, please create a separate issue for each of them.\n        - 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.\n        - 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**.\n\n        ___\n\n  - type: input\n    attributes:\n      label: Version\n      description: Which version of the application does this bug affect? Make sure you're not using an outdated version.\n      placeholder: v1.0.0\n    validations:\n      required: true\n\n  - type: input\n    attributes:\n      label: Platform\n      description: Which platform do you experience this bug on?\n      placeholder: Windows 11\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Steps to reproduce\n      description: >\n        Minimum steps required to reproduce the bug, including prerequisites, application settings, video URL(s), or other relevant items.\n        The information provided in this field must be readily actionable, meaning that anyone should be able to reproduce the bug by following these steps.\n      placeholder: |\n        Video or playlist URL: ...\n\n        Download settings:\n        - ...\n\n        Application settings:\n        - ...\n\n        Steps:\n        - Step 1\n        - Step 2\n        - Step 3\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Details\n      description: Clear and thorough explanation of the bug, including any additional information you may find relevant.\n      placeholder: |\n        - Expected behavior: ...\n        - Actual behavior: ...\n    validations:\n      required: true\n\n  - type: checkboxes\n    attributes:\n      label: Checklist\n      description: Quick list of checks to ensure that everything is in order.\n      options:\n        - label: I have looked through existing issues to make sure that this bug has not been reported before\n          required: true\n        - label: I have provided a descriptive title for this issue\n          required: true\n        - label: I have made sure that this bug is reproducible on the latest version of the application\n          required: true\n        - label: I have provided all the information needed to reproduce this bug as efficiently as possible\n          required: true\n        - label: I have sponsored this project\n          required: false\n        - label: I have not read any of the above and just checked all the boxes to submit the issue\n          required: false\n\n  - type: markdown\n    attributes:\n      value: |\n        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.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: ⚠ Feature request\n    url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md\n    about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.\n  - name: 📖 Documentation\n    url: https://github.com/Tyrrrz/YoutubeDownloader/wiki\n    about: Find usage guides and frequently asked questions.\n  - name: 🗨 Discussions\n    url: https://github.com/Tyrrrz/YoutubeDownloader/discussions/new\n    about: Ask and answer questions.\n  - name: 💬 Discord server\n    url: https://discord.gg/2SUWKFnHSm\n    about: Chat with the project community.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: \"/\"\n    schedule:\n      interval: monthly\n    labels:\n      - enhancement\n    groups:\n      actions:\n        patterns:\n          - \"*\"\n  - package-ecosystem: nuget\n    directory: \"/\"\n    schedule:\n      interval: monthly\n    labels:\n      - enhancement\n    groups:\n      nuget:\n        patterns:\n          - \"*\"\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: main\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - prime\n    tags:\n      - \"*\"\n  pull_request:\n    branches:\n      - prime\n\nenv:\n  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true\n  DOTNET_NOLOGO: true\n  DOTNET_CLI_TELEMETRY_OPTOUT: true\n\njobs:\n  format:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    permissions:\n      contents: read\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n      - name: Install .NET\n        uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0\n\n      # Build the project separately to discern between build and format errors\n      - name: Build\n        run: >\n          dotnet build\n          -p:CSharpier_Bypass=true\n          --configuration Release\n\n      - name: Verify formatting\n        id: verify\n        run: >\n          dotnet build\n          -t:CSharpierFormat\n          --configuration Release\n          --no-restore\n\n      - name: Report issues\n        if: ${{ failure() && steps.verify.outcome == 'failure' }}\n        run: echo \"::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them.\"\n\n  pack:\n    strategy:\n      matrix:\n        rid:\n          - win-arm64\n          - win-x86\n          - win-x64\n          - linux-arm64\n          # Linux x86 is not supported by .NET\n          # - linux-x86\n          - linux-x64\n          - osx-arm64\n          - osx-x64\n        bundle-ffmpeg:\n          - true\n          - false\n        include:\n          - bundle-ffmpeg: true\n            artifact-name-base: YoutubeDownloader\n          - bundle-ffmpeg: false\n            artifact-name-base: YoutubeDownloader.Bare\n\n    runs-on: ${{ startsWith(matrix.rid, 'win-') && 'windows-latest' || startsWith(matrix.rid, 'osx-') && 'macos-latest' || 'ubuntu-latest' }}\n    timeout-minutes: 10\n\n    permissions:\n      actions: write\n      contents: read\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n      - name: Install .NET\n        uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0\n\n      - name: Publish app\n        run: >\n          dotnet publish YoutubeDownloader\n          -p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}\n          -p:CSharpier_Bypass=true\n          -p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}\n          -p:DownloadFFmpeg=${{ matrix.bundle-ffmpeg }}\n          -p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}\n          --output YoutubeDownloader/bin/publish\n          --configuration Release\n          --runtime ${{ matrix.rid }}\n          --self-contained\n\n      - name: Upload app binaries\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0\n        with:\n          name: ${{ matrix.artifact-name-base }}.${{ matrix.rid }}\n          path: YoutubeDownloader/bin/publish\n          if-no-files-found: error\n\n  release:\n    if: ${{ github.ref_type == 'tag' }}\n\n    needs:\n      - format\n      - pack\n\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    permissions:\n      contents: write\n\n    steps:\n      - name: Create release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: >\n          gh release create ${{ github.ref_name }}\n          --repo ${{ github.event.repository.full_name }}\n          --title ${{ github.ref_name }}\n          --generate-notes\n          --verify-tag\n\n  deploy:\n    needs: release\n\n    strategy:\n      matrix:\n        rid:\n          - win-arm64\n          - win-x86\n          - win-x64\n          - linux-arm64\n          # Linux x86 is not supported by .NET\n          # - linux-x86\n          - linux-x64\n          - osx-arm64\n          - osx-x64\n        bundle-ffmpeg:\n          - true\n          - false\n        include:\n          - bundle-ffmpeg: true\n            artifact-name-base: YoutubeDownloader\n          - bundle-ffmpeg: false\n            artifact-name-base: YoutubeDownloader.Bare\n\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    permissions:\n      actions: read\n      contents: write\n\n    steps:\n      - name: Download app binaries\n        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0\n        with:\n          name: ${{ matrix.artifact-name-base }}.${{ matrix.rid }}\n          path: YoutubeDownloader/\n\n      - name: Set permissions\n        if: ${{ !startsWith(matrix.rid, 'win-') }}\n        run: |\n          [ -f YoutubeDownloader/YoutubeDownloader ] && chmod +x YoutubeDownloader/YoutubeDownloader || true\n          [ -f YoutubeDownloader/ffmpeg ] && chmod +x YoutubeDownloader/ffmpeg || true\n\n          # macOS bundle\n          [ -f YoutubeDownloader/YoutubeDownloader.app/Contents/MacOS/YoutubeDownloader ] && chmod +x YoutubeDownloader/YoutubeDownloader.app/Contents/MacOS/YoutubeDownloader || true\n          [ -f YoutubeDownloader/YoutubeDownloader.app/Contents/MacOS/ffmpeg ] && chmod +x YoutubeDownloader/YoutubeDownloader.app/Contents/MacOS/ffmpeg || true\n\n      - name: Create package\n        # Change into the artifacts directory to avoid including the directory itself in the zip archive\n        working-directory: YoutubeDownloader/\n        run: zip -r ../${{ matrix.artifact-name-base }}.${{ matrix.rid }}.zip .\n\n      - name: Upload release asset\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: >\n          gh release upload ${{ github.ref_name }}\n          ${{ matrix.artifact-name-base }}.${{ matrix.rid }}.zip\n          --repo ${{ github.event.repository.full_name }}\n\n  notify:\n    needs: deploy\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    permissions:\n      contents: read\n\n    steps:\n      - name: Notify Discord\n        uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3\n        with:\n          url: ${{ secrets.DISCORD_WEBHOOK }}\n          method: POST\n          headers: |\n            Content-Type: application/json; charset=UTF-8\n          body: |\n            {\n              \"avatar_url\": \"https://raw.githubusercontent.com/${{ github.event.repository.full_name }}/${{ github.ref_name }}/favicon.png\",\n              \"content\": \"[**${{ github.event.repository.name }}**](<${{ github.event.repository.html_url }}>) v${{ github.ref_name }} has been released!\"\n            }\n          retry-count: 5\n"
  },
  {
    "path": ".gitignore",
    "content": "# User-specific files\n.vs/\n.idea/\n*.suo\n*.user\n\n# Build results\nbin/\nobj/\n\n# Avalonia\n.avalonia-build-tasks/\n\n# Test results\nTestResults/\n"
  },
  {
    "path": "Directory.Build.props",
    "content": "<Project>\n\n  <PropertyGroup>\n    <TargetFramework>net10.0</TargetFramework>\n    <Version>999.9.9-dev</Version>\n    <Company>Tyrrrz</Company>\n    <Copyright>Copyright (C) Oleksii Holub</Copyright>\n    <LangVersion>preview</LangVersion>\n    <Nullable>enable</Nullable>\n    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>\n    <ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>\n  </PropertyGroup>\n\n</Project>"
  },
  {
    "path": "Directory.Packages.props",
    "content": "<Project>\n  <PropertyGroup>\n    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>\n  </PropertyGroup>\n\n  <ItemGroup>\n    <PackageVersion Include=\"AsyncImageLoader.Avalonia\" Version=\"3.6.0\" />\n    <PackageVersion Include=\"Avalonia\" Version=\"11.3.0\" />\n    <PackageVersion Include=\"Avalonia.Controls.DataGrid\" Version=\"11.3.0\" />\n    <PackageVersion Include=\"Avalonia.Desktop\" Version=\"11.3.0\" />\n    <PackageVersion Include=\"Avalonia.Diagnostics\" Version=\"11.3.0\" />\n    <PackageVersion Include=\"Cogwheel\" Version=\"2.1.0\" />\n    <PackageVersion Include=\"CommunityToolkit.Mvvm\" Version=\"8.4.0\" />\n    <PackageVersion Include=\"CSharpier.MsBuild\" Version=\"1.2.6\" />\n    <PackageVersion Include=\"Deorcify\" Version=\"1.1.0\" />\n    <PackageVersion Include=\"ThisAssembly.Project\" Version=\"2.1.2\" />\n    <PackageVersion Include=\"DialogHost.Avalonia\" Version=\"0.10.4\" />\n    <PackageVersion Include=\"Gress\" Version=\"2.1.1\" />\n    <PackageVersion Include=\"JsonExtensions\" Version=\"1.2.0\" />\n    <PackageVersion Include=\"Markdig\" Version=\"1.1.0\" />\n    <PackageVersion Include=\"Material.Avalonia\" Version=\"3.9.2\" />\n    <PackageVersion Include=\"Material.Avalonia.DataGrid\" Version=\"3.9.2\" />\n    <PackageVersion Include=\"Material.Icons.Avalonia\" Version=\"2.2.0\" />\n    <PackageVersion Include=\"Microsoft.Extensions.DependencyInjection\" Version=\"9.0.3\" />\n    <PackageVersion Include=\"Onova\" Version=\"2.6.13\" />\n    <PackageVersion Include=\"TagLibSharp\" Version=\"2.3.0\" />\n    <PackageVersion Include=\"WebView.Avalonia\" Version=\"11.0.0.1\" />\n    <PackageVersion Include=\"WebView.Avalonia.Desktop\" Version=\"11.0.0.1\" />\n    <PackageVersion Include=\"YoutubeExplode\" Version=\"6.5.7\" />\n    <PackageVersion Include=\"YoutubeExplode.Converter\" Version=\"6.5.7\" />\n  </ItemGroup>\n</Project>\n"
  },
  {
    "path": "License.txt",
    "content": "MIT License\n\nCopyright (c) 2018-2026 Oleksii Holub\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "NuGet.config",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n  <packageSources>\n    <clear />\n    <add key=\"nuget.org\" value=\"https://api.nuget.org/v3/index.json\" protocolVersion=\"3\" />\n  </packageSources>\n  <config>\n    <add key=\"defaultPushSource\" value=\"https://api.nuget.org/v3/index.json\" />\n  </config>\n</configuration>\n"
  },
  {
    "path": "Readme.md",
    "content": "# YoutubeDownloader\n\n[![Status](https://img.shields.io/badge/status-maintenance-ffd700.svg)](https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md)\n[![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://tyrrrz.me/ukraine)\n[![Build](https://img.shields.io/github/actions/workflow/status/Tyrrrz/YoutubeDownloader/main.yml?branch=prime)](https://github.com/Tyrrrz/YoutubeDownloader/actions)\n[![Release](https://img.shields.io/github/release/Tyrrrz/YoutubeDownloader.svg)](https://github.com/Tyrrrz/YoutubeDownloader/releases)\n[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/YoutubeDownloader/total.svg)](https://github.com/Tyrrrz/YoutubeDownloader/releases)\n[![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm)\n[![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848)\n\n<table>\n    <tr>\n        <td width=\"99999\" align=\"center\">Development of this project is entirely funded by the community. <b><a href=\"https://tyrrrz.me/donate\">Consider donating to support!</a></b></td>\n    </tr>\n</table>\n\n<p align=\"center\">\n    <img src=\"favicon.png\" alt=\"Icon\" />\n</p>\n\n**YoutubeDownloader** is an application that lets you download videos from YouTube.\nYou can copy-paste URL of any video, playlist or channel and download it directly in a format of your choice.\nIt also supports searching by keywords, which is helpful if you want to quickly look up and download videos.\n\n> [!NOTE]\n> This application uses [**YoutubeExplode**](https://github.com/Tyrrrz/YoutubeExplode) under the hood to interact with YouTube.\n> You can [read this article](https://tyrrrz.me/blog/reverse-engineering-youtube-revisited) to learn more about how it works.\n\n## Terms of use<sup>[[?]](https://github.com/Tyrrrz/.github/blob/prime/docs/why-so-political.md)</sup>\n\nBy 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:\n\n- You **condemn Russia and its military aggression against Ukraine**\n- You **recognize that Russia is an occupant that unlawfully invaded a sovereign state**\n- You **support Ukraine's territorial integrity, including its claims over temporarily occupied territories of Crimea and Donbas**\n- You **reject false narratives perpetuated by Russian state propaganda**\n\nTo learn more about the war and how you can help, [click here](https://tyrrrz.me/ukraine). Glory to Ukraine! 🇺🇦\n\n## Download\n\n- 🟢 **[Stable release](https://github.com/Tyrrrz/YoutubeDownloader/releases/latest)**\n- 🟠 [CI build](https://github.com/Tyrrrz/YoutubeDownloader/actions/workflows/main.yml)\n\n> [!IMPORTANT]\n> To launch the app on MacOS, you need to first remove the downloaded file from quarantine.\n> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine YoutubeDownloader.app`.\n\n> [!NOTE]\n> 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.\n\n> [!NOTE]\n> **YoutubeDownloader** comes bundled with [FFmpeg](https://ffmpeg.org) which is used for processing videos.\n> You can also download a version of **YoutubeDownloader** that doesn't include FFmpeg (`YoutubeDownloader.Bare.*` builds) if you prefer to use your own installation.\n\n## Features\n\n- Cross-platform graphical user interface\n- Download videos by URL\n- Download videos from playlists or channels\n- Download videos by search query\n- Selectable video quality and format\n- Automatically embed audio tracks in alternative languages\n- Automatically embed subtitles\n- Automatically inject media tags\n- Log in with a YouTube account to access private content\n\n## Screenshots\n\n![list](.assets/list.png)\n![single](.assets/single.png)\n![multiple](.assets/multiple.png)\n"
  },
  {
    "path": "YoutubeDownloader/.gitignore",
    "content": "/ffmpeg*"
  },
  {
    "path": "YoutubeDownloader/App.axaml",
    "content": "<Application\n    x:Class=\"YoutubeDownloader.App\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\"\n    xmlns:dialogHostAvalonia=\"clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia\"\n    xmlns:framework=\"clr-namespace:YoutubeDownloader.Framework\"\n    xmlns:materialAssists=\"clr-namespace:Material.Styles.Assists;assembly=Material.Styles\"\n    xmlns:materialControls=\"clr-namespace:Material.Styles.Controls;assembly=Material.Styles\"\n    xmlns:materialIcons=\"clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia\"\n    xmlns:materialStyles=\"clr-namespace:Material.Styles.Themes;assembly=Material.Styles\"\n    Name=\"YoutubeDownloader\"\n    ActualThemeVariantChanged=\"Application_OnActualThemeVariantChanged\">\n    <Application.DataTemplates>\n        <framework:ViewManager />\n    </Application.DataTemplates>\n\n    <Application.Styles>\n        <!--  This theme is used as a stub to pre-load default resources, the actual colors are set through code  -->\n        <materialStyles:MaterialTheme\n            BaseTheme=\"Light\"\n            PrimaryColor=\"Grey\"\n            SecondaryColor=\"DeepOrange\" />\n        <materialIcons:MaterialIconStyles />\n        <dialogHostAvalonia:DialogHostStyles />\n\n        <!--  Combo box  -->\n        <Style Selector=\"ComboBox\">\n            <Setter Property=\"FontSize\" Value=\"14\" />\n\n            <Style Selector=\"^ /template/ Panel#PART_RootPanel\">\n                <Setter Property=\"Height\" Value=\"22\" />\n            </Style>\n\n            <Style Selector=\"^ /template/ ToggleButton\">\n                <Style Selector=\"^:checked, ^:unchecked\">\n                    <Setter Property=\"Margin\" Value=\"0\" />\n                    <Setter Property=\"CornerRadius\" Value=\"0\" />\n\n                    <Style Selector=\"^ ContentPresenter#contentPresenter\">\n                        <Setter Property=\"Margin\" Value=\"12,8\" />\n                    </Style>\n                </Style>\n            </Style>\n        </Style>\n\n        <!--  Context menu  -->\n        <Style Selector=\"ContextMenu\">\n            <Setter Property=\"BorderBrush\" Value=\"{DynamicResource MaterialDividerBrush}\" />\n            <Setter Property=\"BorderThickness\" Value=\"1\" />\n        </Style>\n\n        <!--  Data grid  -->\n        <Style Selector=\"DataGrid\">\n            <Setter Property=\"BorderBrush\" Value=\"{DynamicResource MaterialDividerBrush}\" />\n            <Setter Property=\"AutoGenerateColumns\" Value=\"False\" />\n            <Setter Property=\"CanUserReorderColumns\" Value=\"False\" />\n            <Setter Property=\"CanUserResizeColumns\" Value=\"False\" />\n            <Setter Property=\"CanUserSortColumns\" Value=\"True\" />\n            <Setter Property=\"IsReadOnly\" Value=\"True\" />\n            <Setter Property=\"SelectionMode\" Value=\"Single\" />\n        </Style>\n\n        <Style Selector=\"DataGridColumnHeader\">\n            <Setter Property=\"AreSeparatorsVisible\" Value=\"False\" />\n        </Style>\n\n        <Style Selector=\"DataGridRow\">\n            <Style Selector=\"^:selected /template/ Rectangle#BackgroundRectangle\">\n                <Setter Property=\"IsVisible\" Value=\"False\" />\n            </Style>\n            <Style Selector=\"^:pointerover /template/ Rectangle#BackgroundRectangle\">\n                <Setter Property=\"IsVisible\" Value=\"False\" />\n            </Style>\n        </Style>\n\n        <!--  Dialog host  -->\n        <Style Selector=\"dialogHostAvalonia|DialogHost\">\n            <Setter Property=\"DialogMargin\" Value=\"0\" />\n        </Style>\n\n        <Style Selector=\"dialogHostAvalonia|DialogOverlayPopupHost\">\n            <Setter Property=\"Margin\" Value=\"48\" />\n            <Setter Property=\"Background\" Value=\"{DynamicResource MaterialPaperBrush}\" />\n        </Style>\n\n        <!--  Snack bar host  -->\n        <Style Selector=\"materialControls|SnackbarHost\">\n            <Setter Property=\"SnackbarHorizontalAlignment\" Value=\"Stretch\" />\n            <Setter Property=\"VerticalContentAlignment\" Value=\"Center\" />\n\n            <Style Selector=\"^ /template/ ItemsControl#PART_SnackbarHostItemsContainer materialControls|Card\">\n                <Setter Property=\"Background\" Value=\"{DynamicResource MaterialDarkBackgroundBrush}\" />\n                <Setter Property=\"Foreground\" Value=\"{DynamicResource MaterialDarkForegroundBrush}\" />\n            </Style>\n\n            <Style Selector=\"^ /template/ ItemsControl#PART_SnackbarHostItemsContainer Button\">\n                <Setter Property=\"Foreground\" Value=\"{DynamicResource SecondaryHueMidBrush}\" />\n            </Style>\n        </Style>\n\n        <!--  Progress bar  -->\n        <Style Selector=\"ProgressBar\">\n            <Setter Property=\"Minimum\" Value=\"0\" />\n            <Setter Property=\"Maximum\" Value=\"1\" />\n            <Setter Property=\"Foreground\" Value=\"{DynamicResource MaterialSecondaryMidBrush}\" />\n            <Setter Property=\"materialAssists:TransitionAssist.DisableTransitions\" Value=\"True\" />\n\n            <Style Selector=\"^:horizontal\">\n                <Setter Property=\"MinHeight\" Value=\"0\" />\n            </Style>\n        </Style>\n\n        <!--  Slider  -->\n        <Style Selector=\"Slider\">\n            <Style Selector=\"^ /template/ ProgressBar#PART_ProgressLayer\">\n                <Style Selector=\"^:horizontal\">\n                    <Style Selector=\"^ Panel#PART_InnerPanel\">\n                        <Setter Property=\"Height\" Value=\"2\" />\n\n                        <Style Selector=\"^ Border#PART_InactiveState\">\n                            <Setter Property=\"Margin\" Value=\"0\" />\n                            <Setter Property=\"Height\" Value=\"2\" />\n                        </Style>\n\n                        <Style Selector=\"^ Border#PART_Indicator\">\n                            <Setter Property=\"Margin\" Value=\"0\" />\n                        </Style>\n                    </Style>\n                </Style>\n            </Style>\n\n            <Style Selector=\"^ /template/ Track#PART_Track\">\n                <Style Selector=\"^:horizontal\">\n                    <Setter Property=\"Margin\" Value=\"4,0\" />\n                </Style>\n\n                <Style Selector=\"^ Border#PART_HoverEffect\">\n                    <Setter Property=\"Width\" Value=\"24\" />\n                    <Setter Property=\"Height\" Value=\"24\" />\n                </Style>\n\n                <Style Selector=\"^ Border#PART_ThumbGrip\">\n                    <Setter Property=\"Width\" Value=\"12\" />\n                    <Setter Property=\"Height\" Value=\"12\" />\n                </Style>\n            </Style>\n        </Style>\n\n        <!--  Run  -->\n        <Style Selector=\"Run\">\n            <Setter Property=\"BaselineAlignment\" Value=\"Center\" />\n        </Style>\n\n        <!--  Text box  -->\n        <Style Selector=\"TextBox\">\n            <Setter Property=\"FontSize\" Value=\"14\" />\n        </Style>\n\n        <!--  Toggle switch  -->\n        <Style Selector=\"ToggleSwitch\">\n            <Setter Property=\"materialAssists:ToggleSwitchAssist.SwitchThumbOffBackground\" Value=\"{DynamicResource ToggleBackgroundBrush}\" />\n        </Style>\n\n        <!--  Tooltip  -->\n        <Style Selector=\"ToolTip\">\n            <Setter Property=\"TextElement.FontSize\" Value=\"14\" />\n            <Setter Property=\"TextElement.FontWeight\" Value=\"Normal\" />\n            <Setter Property=\"TextElement.FontStyle\" Value=\"Normal\" />\n            <Setter Property=\"TextElement.FontStretch\" Value=\"Normal\" />\n        </Style>\n    </Application.Styles>\n\n    <Application.Resources>\n        <ResourceDictionary>\n            <ResourceDictionary.ThemeDictionaries>\n                <ResourceDictionary x:Key=\"Default\">\n                    <SolidColorBrush x:Key=\"ToggleBackgroundBrush\" Color=\"#FFFFFF\" />\n                </ResourceDictionary>\n                <ResourceDictionary x:Key=\"Dark\">\n                    <SolidColorBrush x:Key=\"ToggleBackgroundBrush\" Color=\"#8E8E8E\" />\n                </ResourceDictionary>\n            </ResourceDictionary.ThemeDictionaries>\n\n            <!--  Text box  -->\n            <ControlTheme\n                x:Key=\"CompactTextBox\"\n                BasedOn=\"{StaticResource {x:Type TextBox}}\"\n                TargetType=\"{x:Type TextBox}\">\n                <Styles>\n                    <Style Selector=\"TextBox\">\n                        <Setter Property=\"Height\" Value=\"22\" />\n\n                        <Style Selector=\"^ /template/ Panel#PART_TextFieldPanel\">\n                            <Setter Property=\"MinHeight\" Value=\"0\" />\n                        </Style>\n\n                        <Style Selector=\"^ /template/ Panel#PART_TextContainer\">\n                            <Setter Property=\"Margin\" Value=\"0\" />\n                        </Style>\n                    </Style>\n                </Styles>\n            </ControlTheme>\n        </ResourceDictionary>\n    </Application.Resources>\n</Application>"
  },
  {
    "path": "YoutubeDownloader/App.axaml.cs",
    "content": "using System;\nusing Avalonia;\nusing Avalonia.Controls.ApplicationLifetimes;\nusing Avalonia.Markup.Xaml;\nusing Avalonia.Media;\nusing Avalonia.Platform;\nusing AvaloniaWebView;\nusing Material.Styles.Themes;\nusing Microsoft.Extensions.DependencyInjection;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing YoutubeDownloader.Services;\nusing YoutubeDownloader.Utils;\nusing YoutubeDownloader.Utils.Extensions;\nusing YoutubeDownloader.ViewModels;\nusing YoutubeDownloader.ViewModels.Components;\nusing YoutubeDownloader.ViewModels.Dialogs;\nusing YoutubeDownloader.Views;\n\nnamespace YoutubeDownloader;\n\npublic class App : Application, IDisposable\n{\n    private readonly DisposableCollector _eventRoot = new();\n\n    private readonly ServiceProvider _services;\n    private readonly SettingsService _settingsService;\n    private readonly MainViewModel _mainViewModel;\n\n    private bool _isDisposed;\n\n    public App()\n    {\n        var services = new ServiceCollection();\n\n        // Framework\n        services.AddSingleton<DialogManager>();\n        services.AddSingleton<SnackbarManager>();\n        services.AddSingleton<ViewManager>();\n        services.AddSingleton<ViewModelManager>();\n\n        // Localization\n        services.AddSingleton<LocalizationManager>();\n\n        // Services\n        services.AddSingleton<SettingsService>();\n        services.AddSingleton<UpdateService>();\n\n        // View models\n        services.AddTransient<MainViewModel>();\n        services.AddTransient<DashboardViewModel>();\n        services.AddTransient<DownloadViewModel>();\n        services.AddTransient<AuthSetupViewModel>();\n        services.AddTransient<DownloadMultipleSetupViewModel>();\n        services.AddTransient<DownloadSingleSetupViewModel>();\n        services.AddTransient<MessageBoxViewModel>();\n        services.AddTransient<SettingsViewModel>();\n\n        _services = services.BuildServiceProvider(true);\n        _settingsService = _services.GetRequiredService<SettingsService>();\n        _mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();\n\n        // Re-initialize the theme when the user changes it\n        _eventRoot.Add(\n            _settingsService.WatchProperty(\n                o => o.Theme,\n                () =>\n                {\n                    RequestedThemeVariant = _settingsService.Theme switch\n                    {\n                        ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,\n                        ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,\n                        _ => Avalonia.Styling.ThemeVariant.Default,\n                    };\n\n                    InitializeTheme();\n                }\n            )\n        );\n    }\n\n    public override void Initialize()\n    {\n        base.Initialize();\n\n        AvaloniaXamlLoader.Load(this);\n    }\n\n    public override void RegisterServices()\n    {\n        base.RegisterServices();\n\n        AvaloniaWebViewBuilder.Initialize(config => config.IsInPrivateModeEnabled = true);\n    }\n\n    private void InitializeTheme()\n    {\n        var actualTheme = RequestedThemeVariant?.Key switch\n        {\n            \"Light\" => PlatformThemeVariant.Light,\n            \"Dark\" => PlatformThemeVariant.Dark,\n            _ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light,\n        };\n\n        this.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme =\n            actualTheme == PlatformThemeVariant.Light\n                ? Theme.Create(Theme.Light, Color.Parse(\"#343838\"), Color.Parse(\"#F9A825\"))\n                : Theme.Create(Theme.Dark, Color.Parse(\"#E8E8E8\"), Color.Parse(\"#F9A825\"));\n    }\n\n    public override void OnFrameworkInitializationCompleted()\n    {\n        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)\n        {\n            desktop.MainWindow = new MainView { DataContext = _mainViewModel };\n\n            void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args)\n            {\n                if (sender is IControlledApplicationLifetime lifetime)\n                    lifetime.Exit -= OnExit;\n\n                Dispose();\n            }\n\n            // Although `App.Dispose()` is invoked from `Program.Main(...)`, on some platforms\n            // it may be called too late in the shutdown lifecycle. Attach an exit\n            // handler to ensure timely disposal as a safeguard.\n            // https://github.com/Tyrrrz/YoutubeDownloader/issues/795\n            desktop.Exit += OnExit;\n        }\n\n        base.OnFrameworkInitializationCompleted();\n\n        // Set up initial custom theme colors\n        InitializeTheme();\n\n        // Load settings\n        _settingsService.Load();\n    }\n\n    private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>\n        // Re-initialize the theme when the system theme changes\n        InitializeTheme();\n\n    public void Dispose()\n    {\n        if (_isDisposed)\n            return;\n\n        _isDisposed = true;\n\n        _eventRoot.Dispose();\n        _services.Dispose();\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Converters/EqualityConverter.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing Avalonia.Data.Converters;\n\nnamespace YoutubeDownloader.Converters;\n\npublic class EqualityConverter(bool isInverted) : IValueConverter\n{\n    public static EqualityConverter IsEqual { get; } = new(false);\n    public static EqualityConverter IsNotEqual { get; } = new(true);\n\n    public object? Convert(\n        object? value,\n        Type targetType,\n        object? parameter,\n        CultureInfo culture\n    ) => EqualityComparer<object>.Default.Equals(value, parameter) != isInverted;\n\n    public object ConvertBack(\n        object? value,\n        Type targetType,\n        object? parameter,\n        CultureInfo culture\n    ) => throw new NotSupportedException();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Converters/MarkdownToInlinesConverter.cs",
    "content": "using System;\nusing System.Globalization;\nusing Avalonia.Controls.Documents;\nusing Avalonia.Data.Converters;\nusing Avalonia.Media;\nusing Markdig;\nusing Markdig.Syntax;\nusing Markdig.Syntax.Inlines;\nusing MarkdownInline = Markdig.Syntax.Inlines.Inline;\n\nnamespace YoutubeDownloader.Converters;\n\npublic class MarkdownToInlinesConverter : IValueConverter\n{\n    public static readonly MarkdownToInlinesConverter Instance = new();\n\n    private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()\n        .UseEmphasisExtras()\n        .Build();\n\n    private static void ProcessInline(\n        InlineCollection inlines,\n        MarkdownInline markdownInline,\n        FontWeight? fontWeight = null,\n        FontStyle? fontStyle = null,\n        TextDecorationCollection? textDecorations = null\n    )\n    {\n        switch (markdownInline)\n        {\n            case LiteralInline literal:\n            {\n                var run = new Run(literal.Content.ToString());\n\n                if (fontWeight is not null)\n                    run.FontWeight = fontWeight.Value;\n                if (fontStyle is not null)\n                    run.FontStyle = fontStyle.Value;\n                if (textDecorations is not null)\n                    run.TextDecorations = textDecorations;\n\n                inlines.Add(run);\n                break;\n            }\n\n            case LineBreakInline:\n            {\n                inlines.Add(new LineBreak());\n                break;\n            }\n\n            case EmphasisInline emphasis:\n            {\n                var newWeight = fontWeight;\n                var newStyle = fontStyle;\n                var newDecorations = textDecorations;\n\n                switch (emphasis.DelimiterChar)\n                {\n                    case '*' or '_' when emphasis.DelimiterCount == 2:\n                        newWeight = FontWeight.SemiBold;\n                        break;\n                    case '*' or '_':\n                        newStyle = FontStyle.Italic;\n                        break;\n                    case '~':\n                        newDecorations = TextDecorations.Strikethrough;\n                        break;\n                    case '+':\n                        newDecorations = TextDecorations.Underline;\n                        break;\n                }\n\n                foreach (var child in emphasis)\n                    ProcessInline(inlines, child, newWeight, newStyle, newDecorations);\n\n                break;\n            }\n\n            case ContainerInline container:\n            {\n                foreach (var child in container)\n                    ProcessInline(inlines, child, fontWeight, fontStyle, textDecorations);\n\n                break;\n            }\n        }\n    }\n\n    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)\n    {\n        var inlines = new InlineCollection();\n        if (value is not string { Length: > 0 } text)\n            return inlines;\n\n        var isFirstParagraph = true;\n        foreach (var block in Markdown.Parse(text, MarkdownPipeline))\n        {\n            if (block is not ParagraphBlock { Inline: not null } paragraph)\n                continue;\n\n            if (!isFirstParagraph)\n            {\n                // Insert a blank line between paragraphs\n                inlines.Add(new LineBreak());\n                inlines.Add(new LineBreak());\n            }\n\n            isFirstParagraph = false;\n\n            foreach (var markdownInline in paragraph.Inline)\n                ProcessInline(inlines, markdownInline);\n        }\n\n        return inlines;\n    }\n\n    public object? ConvertBack(\n        object? value,\n        Type targetType,\n        object? parameter,\n        CultureInfo culture\n    ) => throw new NotSupportedException();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Converters/VideoQualityPreferenceToStringConverter.cs",
    "content": "﻿using System;\nusing System.Globalization;\nusing Avalonia.Data.Converters;\nusing YoutubeDownloader.Core.Downloading;\n\nnamespace YoutubeDownloader.Converters;\n\npublic class VideoQualityPreferenceToStringConverter : IValueConverter\n{\n    public static VideoQualityPreferenceToStringConverter Instance { get; } = new();\n\n    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)\n    {\n        if (value is VideoQualityPreference preference)\n            return preference.GetDisplayName();\n\n        return default(string);\n    }\n\n    public object ConvertBack(\n        object? value,\n        Type targetType,\n        object? parameter,\n        CultureInfo culture\n    ) => throw new NotSupportedException();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Converters/VideoToHighestQualityThumbnailUrlStringConverter.cs",
    "content": "﻿using System;\nusing System.Globalization;\nusing Avalonia.Data.Converters;\nusing YoutubeExplode.Common;\nusing YoutubeExplode.Videos;\n\nnamespace YoutubeDownloader.Converters;\n\npublic class VideoToHighestQualityThumbnailUrlStringConverter : IValueConverter\n{\n    public static VideoToHighestQualityThumbnailUrlStringConverter Instance { get; } = new();\n\n    public object? Convert(\n        object? value,\n        Type targetType,\n        object? parameter,\n        CultureInfo culture\n    ) => value is IVideo video ? video.Thumbnails.TryGetWithHighestResolution()?.Url : null;\n\n    public object ConvertBack(\n        object? value,\n        Type targetType,\n        object? parameter,\n        CultureInfo culture\n    ) => throw new NotSupportedException();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Converters/VideoToLowestQualityThumbnailUrlStringConverter.cs",
    "content": "﻿using System;\nusing System.Globalization;\nusing System.Linq;\nusing Avalonia.Data.Converters;\nusing YoutubeExplode.Videos;\n\nnamespace YoutubeDownloader.Converters;\n\npublic class VideoToLowestQualityThumbnailUrlStringConverter : IValueConverter\n{\n    public static VideoToLowestQualityThumbnailUrlStringConverter Instance { get; } = new();\n\n    public object? Convert(\n        object? value,\n        Type targetType,\n        object? parameter,\n        CultureInfo culture\n    ) => value is IVideo video ? video.Thumbnails.MinBy(t => t.Resolution.Area)?.Url : null;\n\n    public object ConvertBack(\n        object? value,\n        Type targetType,\n        object? parameter,\n        CultureInfo culture\n    ) => throw new NotSupportedException();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Download-FFmpeg.ps1",
    "content": "param (\n    [Parameter(Mandatory=$false)]\n    [string]$Platform,\n\n    [Parameter(Mandatory=$false)]\n    [string]$OutputPath = $PSScriptRoot\n)\n\n$ErrorActionPreference = \"Stop\"\n\n# If the platform is not specified, use the current OS/arch\nif (-not $Platform) {\n    $arch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture\n\n    if ($isWindows) {\n        $Platform = \"windows-$arch\"\n    } elseif ($isLinux) {\n        $Platform = \"linux-$arch\"\n    } elseif ($isMacOS) {\n        $Platform = \"osx-$arch\"\n    } else {\n        throw \"Unsupported platform\"\n    }\n}\n\n# Normalize platform identifier\n$Platform = $Platform.ToLower().Replace(\"win-\", \"windows-\")\n\n# Identify the FFmpeg filename based on the platform\n$fileName = if ($Platform.Contains(\"windows-\")) { \"ffmpeg.exe\" } else { \"ffmpeg\" }\n\n# If the output path is an existing directory, append the default file name for the platform\nif (Test-Path $OutputPath -PathType Container) {\n    $OutputPath = Join-Path $OutputPath $fileName\n}\n\n# Delete the existing file if it exists\nif (Test-Path $OutputPath) {\n    Remove-Item $OutputPath\n}\n\n# Download the archive\nWrite-Host \"Downloading FFmpeg for $Platform...\"\n$http = New-Object System.Net.WebClient\ntry {\n    $http.DownloadFile(\"https://github.com/Tyrrrz/FFmpegBin/releases/download/7.1.2/ffmpeg-$Platform.zip\", \"$OutputPath.zip\")\n} finally {\n    $http.Dispose()\n}\n\ntry {\n    # Extract FFmpeg\n    Add-Type -Assembly System.IO.Compression.FileSystem\n    $zip = [IO.Compression.ZipFile]::OpenRead(\"$OutputPath.zip\")\n    try {\n        [IO.Compression.ZipFileExtensions]::ExtractToFile($zip.GetEntry($fileName), $OutputPath)\n    } finally {\n        $zip.Dispose()\n    }\n\n    Write-Host \"Done downloading FFmpeg.\"\n} finally {\n    # Clean up\n    Remove-Item \"$OutputPath.zip\" -Force\n}"
  },
  {
    "path": "YoutubeDownloader/Framework/DialogManager.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Avalonia;\nusing Avalonia.Platform.Storage;\nusing DialogHostAvalonia;\nusing YoutubeDownloader.Utils.Extensions;\n\nnamespace YoutubeDownloader.Framework;\n\npublic class DialogManager : IDisposable\n{\n    private readonly SemaphoreSlim _dialogLock = new(1, 1);\n\n    public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)\n    {\n        await _dialogLock.WaitAsync();\n        try\n        {\n            await DialogHost.Show(\n                dialog,\n                // It's fine to await in a void method here because it's an event handler\n                // ReSharper disable once AsyncVoidLambda\n                async (object _, DialogOpenedEventArgs args) =>\n                {\n                    await dialog.WaitForCloseAsync();\n\n                    try\n                    {\n                        args.Session.Close();\n                    }\n                    catch (InvalidOperationException)\n                    {\n                        // Dialog host is already processing a close operation\n                    }\n                }\n            );\n\n            // Yield to allow DialogHost to fully reset its state before\n            // another dialog is shown (e.g. when dialogs are shown sequentially)\n            await Task.Yield();\n\n            return dialog.DialogResult;\n        }\n        finally\n        {\n            _dialogLock.Release();\n        }\n    }\n\n    public async Task<string?> PromptOpenFilePathAsync(\n        IReadOnlyList<FilePickerFileType>? fileTypes = null\n    )\n    {\n        var topLevel =\n            Application.Current?.ApplicationLifetime?.TryGetTopLevel()\n            ?? throw new ApplicationException(\"Could not find the top-level visual element.\");\n\n        var result = await topLevel.StorageProvider.OpenFilePickerAsync(\n            new FilePickerOpenOptions { FileTypeFilter = fileTypes, AllowMultiple = false }\n        );\n\n        var file = result.FirstOrDefault();\n        return file?.TryGetLocalPath() ?? file?.Path.ToString();\n    }\n\n    public async Task<string?> PromptSaveFilePathAsync(\n        IReadOnlyList<FilePickerFileType>? fileTypes = null,\n        string defaultFilePath = \"\"\n    )\n    {\n        var topLevel =\n            Application.Current?.ApplicationLifetime?.TryGetTopLevel()\n            ?? throw new ApplicationException(\"Could not find the top-level visual element.\");\n\n        var file = await topLevel.StorageProvider.SaveFilePickerAsync(\n            new FilePickerSaveOptions\n            {\n                FileTypeChoices = fileTypes,\n                SuggestedFileName = defaultFilePath,\n                DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.'),\n            }\n        );\n\n        return file?.TryGetLocalPath() ?? file?.Path.ToString();\n    }\n\n    public async Task<string?> PromptDirectoryPathAsync(string defaultDirPath = \"\")\n    {\n        var topLevel =\n            Application.Current?.ApplicationLifetime?.TryGetTopLevel()\n            ?? throw new ApplicationException(\"Could not find the top-level visual element.\");\n\n        var result = await topLevel.StorageProvider.OpenFolderPickerAsync(\n            new FolderPickerOpenOptions\n            {\n                AllowMultiple = false,\n                SuggestedStartLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(\n                    defaultDirPath\n                ),\n            }\n        );\n\n        var directory = result.FirstOrDefault();\n        if (directory is null)\n            return null;\n\n        return directory.TryGetLocalPath() ?? directory.Path.ToString();\n    }\n\n    public void Dispose() => _dialogLock.Dispose();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Framework/DialogViewModelBase.cs",
    "content": "﻿using System.Threading.Tasks;\nusing CommunityToolkit.Mvvm.ComponentModel;\nusing CommunityToolkit.Mvvm.Input;\n\nnamespace YoutubeDownloader.Framework;\n\npublic abstract partial class DialogViewModelBase<T> : ViewModelBase\n{\n    private readonly TaskCompletionSource<T> _closeTcs = new(\n        TaskCreationOptions.RunContinuationsAsynchronously\n    );\n\n    [ObservableProperty]\n    public partial T? DialogResult { get; set; }\n\n    [RelayCommand]\n    protected void Close(T dialogResult)\n    {\n        DialogResult = dialogResult;\n        _closeTcs.TrySetResult(dialogResult);\n    }\n\n    public async Task<T> WaitForCloseAsync() => await _closeTcs.Task;\n}\n\npublic abstract class DialogViewModelBase : DialogViewModelBase<bool?>;\n"
  },
  {
    "path": "YoutubeDownloader/Framework/SnackbarManager.cs",
    "content": "﻿using System;\nusing Avalonia.Threading;\nusing Material.Styles.Controls;\nusing Material.Styles.Models;\n\nnamespace YoutubeDownloader.Framework;\n\npublic class SnackbarManager\n{\n    private readonly TimeSpan _defaultDuration = TimeSpan.FromSeconds(5);\n\n    public void Notify(string message, TimeSpan? duration = null) =>\n        SnackbarHost.Post(\n            new SnackbarModel(message, duration ?? _defaultDuration),\n            null,\n            DispatcherPriority.Normal\n        );\n\n    public void Notify(\n        string message,\n        string actionText,\n        Action actionHandler,\n        TimeSpan? duration = null\n    ) =>\n        SnackbarHost.Post(\n            new SnackbarModel(\n                message,\n                duration ?? _defaultDuration,\n                new SnackbarButtonModel { Text = actionText, Action = actionHandler }\n            ),\n            null,\n            DispatcherPriority.Normal\n        );\n}\n"
  },
  {
    "path": "YoutubeDownloader/Framework/ThemeVariant.cs",
    "content": "﻿namespace YoutubeDownloader.Framework;\n\npublic enum ThemeVariant\n{\n    System,\n    Light,\n    Dark,\n}\n"
  },
  {
    "path": "YoutubeDownloader/Framework/UserControl.cs",
    "content": "﻿using System;\nusing Avalonia.Controls;\n\nnamespace YoutubeDownloader.Framework;\n\npublic class UserControl<TDataContext> : UserControl\n{\n    public new TDataContext DataContext\n    {\n        get =>\n            base.DataContext is TDataContext dataContext\n                ? dataContext\n                : throw new InvalidCastException(\n                    $\"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'.\"\n                );\n        set => base.DataContext = value;\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Framework/ViewManager.cs",
    "content": "﻿using Avalonia.Controls;\nusing Avalonia.Controls.Templates;\nusing YoutubeDownloader.ViewModels;\nusing YoutubeDownloader.ViewModels.Components;\nusing YoutubeDownloader.ViewModels.Dialogs;\nusing YoutubeDownloader.Views;\nusing YoutubeDownloader.Views.Components;\nusing YoutubeDownloader.Views.Dialogs;\n\nnamespace YoutubeDownloader.Framework;\n\npublic partial class ViewManager\n{\n    private Control? TryCreateView(ViewModelBase viewModel) =>\n        viewModel switch\n        {\n            MainViewModel => new MainView(),\n            DashboardViewModel => new DashboardView(),\n            AuthSetupViewModel => new AuthSetupView(),\n            DownloadMultipleSetupViewModel => new DownloadMultipleSetupView(),\n            DownloadSingleSetupViewModel => new DownloadSingleSetupView(),\n            MessageBoxViewModel => new MessageBoxView(),\n            SettingsViewModel => new SettingsView(),\n            _ => null,\n        };\n\n    public Control? TryBindView(ViewModelBase viewModel)\n    {\n        var view = TryCreateView(viewModel);\n        if (view is null)\n            return null;\n\n        view.DataContext ??= viewModel;\n\n        return view;\n    }\n}\n\npublic partial class ViewManager : IDataTemplate\n{\n    bool IDataTemplate.Match(object? data) => data is ViewModelBase;\n\n    Control? ITemplate<object?, Control?>.Build(object? data) =>\n        data is ViewModelBase viewModel ? TryBindView(viewModel) : null;\n}\n"
  },
  {
    "path": "YoutubeDownloader/Framework/ViewModelBase.cs",
    "content": "﻿using System;\nusing CommunityToolkit.Mvvm.ComponentModel;\n\nnamespace YoutubeDownloader.Framework;\n\npublic abstract class ViewModelBase : ObservableObject, IDisposable\n{\n    ~ViewModelBase() => Dispose(false);\n\n    protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty);\n\n    protected virtual void Dispose(bool disposing) { }\n\n    public void Dispose()\n    {\n        Dispose(true);\n        GC.SuppressFinalize(this);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Framework/ViewModelManager.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing Microsoft.Extensions.DependencyInjection;\nusing YoutubeDownloader.Core.Downloading;\nusing YoutubeDownloader.Core.Utils.Extensions;\nusing YoutubeDownloader.ViewModels;\nusing YoutubeDownloader.ViewModels.Components;\nusing YoutubeDownloader.ViewModels.Dialogs;\nusing YoutubeExplode.Videos;\n\nnamespace YoutubeDownloader.Framework;\n\npublic class ViewModelManager(IServiceProvider services)\n{\n    public MainViewModel CreateMainViewModel() => services.GetRequiredService<MainViewModel>();\n\n    public DashboardViewModel CreateDashboardViewModel() =>\n        services.GetRequiredService<DashboardViewModel>();\n\n    public AuthSetupViewModel CreateAuthSetupViewModel() =>\n        services.GetRequiredService<AuthSetupViewModel>();\n\n    public DownloadViewModel CreateDownloadViewModel(\n        IVideo video,\n        VideoDownloadOption downloadOption,\n        string filePath\n    )\n    {\n        var viewModel = services.GetRequiredService<DownloadViewModel>();\n\n        viewModel.Video = video;\n        viewModel.DownloadOption = downloadOption;\n        viewModel.FilePath = filePath;\n\n        return viewModel;\n    }\n\n    public DownloadViewModel CreateDownloadViewModel(\n        IVideo video,\n        VideoDownloadPreference downloadPreference,\n        string filePath\n    )\n    {\n        var viewModel = services.GetRequiredService<DownloadViewModel>();\n\n        viewModel.Video = video;\n        viewModel.DownloadPreference = downloadPreference;\n        viewModel.FilePath = filePath;\n\n        return viewModel;\n    }\n\n    public DownloadMultipleSetupViewModel CreateDownloadMultipleSetupViewModel(\n        string title,\n        IReadOnlyList<IVideo> availableVideos,\n        bool preselectVideos = true\n    )\n    {\n        var viewModel = services.GetRequiredService<DownloadMultipleSetupViewModel>();\n\n        viewModel.Title = title;\n        viewModel.AvailableVideos = availableVideos;\n\n        if (preselectVideos)\n            viewModel.SelectedVideos.AddRange(availableVideos);\n\n        return viewModel;\n    }\n\n    public DownloadSingleSetupViewModel CreateDownloadSingleSetupViewModel(\n        IVideo video,\n        IReadOnlyList<VideoDownloadOption> availableDownloadOptions\n    )\n    {\n        var viewModel = services.GetRequiredService<DownloadSingleSetupViewModel>();\n\n        viewModel.Video = video;\n        viewModel.AvailableDownloadOptions = availableDownloadOptions;\n\n        return viewModel;\n    }\n\n    public MessageBoxViewModel CreateMessageBoxViewModel(\n        string title,\n        string message,\n        string? okButtonText,\n        string? cancelButtonText\n    )\n    {\n        var viewModel = services.GetRequiredService<MessageBoxViewModel>();\n\n        viewModel.Title = title;\n        viewModel.Message = message;\n        viewModel.DefaultButtonText = okButtonText;\n        viewModel.CancelButtonText = cancelButtonText;\n\n        return viewModel;\n    }\n\n    public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message)\n    {\n        var viewModel = services.GetRequiredService<MessageBoxViewModel>();\n\n        viewModel.Title = title;\n        viewModel.Message = message;\n\n        return viewModel;\n    }\n\n    public SettingsViewModel CreateSettingsViewModel() =>\n        services.GetRequiredService<SettingsViewModel>();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Framework/Window.cs",
    "content": "﻿using System;\nusing Avalonia.Controls;\n\nnamespace YoutubeDownloader.Framework;\n\npublic class Window<TDataContext> : Window\n{\n    public new TDataContext DataContext\n    {\n        get =>\n            base.DataContext is TDataContext dataContext\n                ? dataContext\n                : throw new InvalidCastException(\n                    $\"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'.\"\n                );\n        set => base.DataContext = value;\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Localization/Language.cs",
    "content": "namespace YoutubeDownloader.Localization;\n\npublic enum Language\n{\n    System,\n    English,\n    Ukrainian,\n    German,\n    French,\n    Spanish,\n}\n"
  },
  {
    "path": "YoutubeDownloader/Localization/LocalizationManager.English.cs",
    "content": "using System.Collections.Generic;\n\nnamespace YoutubeDownloader.Localization;\n\npublic partial class LocalizationManager\n{\n    private static readonly IReadOnlyDictionary<string, string> EnglishLocalization =\n        new Dictionary<string, string>\n        {\n            // Dashboard\n            [nameof(QueryWatermark)] = \"URL or search query\",\n            [nameof(QueryTooltip)] =\n                \"Any valid YouTube URL or ID is accepted. Prepend a question mark (?) to perform search by text.\",\n            [nameof(ProcessQueryTooltip)] = \"Process query (Enter)\",\n            [nameof(AuthTooltip)] = \"Authentication\",\n            [nameof(SettingsTooltip)] = \"Settings\",\n            [nameof(DashboardPlaceholder)] = \"\"\"\n                Copy-paste a **URL** or enter a **search query** to start downloading\n                Press **Shift+Enter** to add multiple items\n                \"\"\",\n            [nameof(DownloadsFileColumnHeader)] = \"File\",\n            [nameof(DownloadsStatusColumnHeader)] = \"Status\",\n            [nameof(ContextMenuRemoveSuccessful)] = \"Remove successful downloads\",\n            [nameof(ContextMenuRemoveInactive)] = \"Remove inactive downloads\",\n            [nameof(ContextMenuRestartFailed)] = \"Restart failed downloads\",\n            [nameof(ContextMenuCancelAll)] = \"Cancel all downloads\",\n            [nameof(DownloadStatusEnqueued)] = \"Pending...\",\n            [nameof(DownloadStatusCompleted)] = \"Done\",\n            [nameof(DownloadStatusCanceled)] = \"Canceled\",\n            [nameof(DownloadStatusFailed)] = \"Failed\",\n            [nameof(ClickToCopyErrorTooltip)] = \"Note: Click to copy this error message\",\n            [nameof(ShowFileTooltip)] = \"Show file\",\n            [nameof(PlayTooltip)] = \"Play\",\n            [nameof(CancelDownloadTooltip)] = \"Cancel download\",\n            [nameof(RestartDownloadTooltip)] = \"Restart download\",\n            // Settings\n            [nameof(SettingsTitle)] = \"Settings\",\n            [nameof(ThemeLabel)] = \"Theme\",\n            [nameof(ThemeTooltip)] = \"Preferred user interface theme\",\n            [nameof(LanguageLabel)] = \"Language\",\n            [nameof(LanguageTooltip)] = \"Preferred display language for the user interface\",\n            [nameof(AutoUpdateLabel)] = \"Auto-update\",\n            [nameof(AutoUpdateTooltip)] = \"\"\"\n                Perform automatic updates on every launch.\n                **Warning:** it's recommended to leave this option enabled to ensure that the app is compatible with the latest version of YouTube.\n                \"\"\",\n            [nameof(PersistAuthLabel)] = \"Persist authentication\",\n            [nameof(PersistAuthTooltip)] = \"\"\"\n                Save authentication cookies to a file so that they can be persisted between sessions.\n                **Warning**: although the cookies are stored with encryption, they may still be recovered by an attacker who has access to your system.\n                \"\"\",\n            [nameof(InjectAltLanguagesLabel)] = \"Inject alternative languages\",\n            [nameof(InjectAltLanguagesTooltip)] =\n                \"Inject audio tracks in alternative languages (if available) into downloaded files\",\n            [nameof(InjectSubtitlesLabel)] = \"Inject subtitles\",\n            [nameof(InjectSubtitlesTooltip)] =\n                \"Inject subtitles (if available) into downloaded files\",\n            [nameof(InjectTagsLabel)] = \"Inject media tags\",\n            [nameof(InjectTagsTooltip)] = \"Inject media tags (if available) into downloaded files\",\n            [nameof(SkipExistingFilesLabel)] = \"Skip existing files\",\n            [nameof(SkipExistingFilesTooltip)] =\n                \"When downloading multiple videos, skip those that already have matching files in the output directory\",\n            [nameof(FileNameTemplateLabel)] = \"File name template\",\n            [nameof(FileNameTemplateTooltip)] = \"\"\"\n                Template used for generating file names for downloaded videos.\n\n                Available tokens:\n                **$num** — video's position in the list (if applicable)\n                **$id** — video ID\n                **$title** — video title\n                **$author** — video author\n                \"\"\",\n            [nameof(ParallelLimitLabel)] = \"Parallel limit\",\n            [nameof(ParallelLimitTooltip)] = \"How many downloads can be active at the same time\",\n            [nameof(FFmpegPathLabel)] = \"FFmpeg path\",\n            [nameof(FFmpegPathTooltip)] =\n                \"Path to the FFmpeg executable. Leave empty to use auto-detection.\",\n            [nameof(FFmpegPathWatermark)] = \"Auto-detect\",\n            [nameof(FFmpegPathResetTooltip)] = \"Reset to auto-detection\",\n            [nameof(FFmpegPathBrowseTooltip)] = \"Browse for FFmpeg executable\",\n            // Auth Setup\n            [nameof(AuthenticationTitle)] = \"Authentication\",\n            [nameof(AuthenticatedText)] = \"You are currently authenticated\",\n            [nameof(LogOutButton)] = \"Log out\",\n            [nameof(LoadingText)] = \"Loading...\",\n            // Download Single Setup\n            [nameof(CopyMenuItem)] = \"Copy\",\n            [nameof(LiveLabel)] = \"Live\",\n            [nameof(AudioLabel)] = \"Audio\",\n            [nameof(FormatLabel)] = \"Format\",\n            // Download Multiple Setup\n            [nameof(ContainerLabel)] = \"Container\",\n            [nameof(VideoQualityLabel)] = \"Video quality\",\n            // Common buttons\n            [nameof(CloseButton)] = \"CLOSE\",\n            [nameof(DownloadButton)] = \"DOWNLOAD\",\n            [nameof(CancelButton)] = \"CANCEL\",\n            [nameof(SettingsButton)] = \"SETTINGS\",\n            // Dialog messages\n            [nameof(UkraineSupportTitle)] = \"Thank you for supporting Ukraine!\",\n            [nameof(UkraineSupportMessage)] = \"\"\"\n                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.\n\n                Click LEARN MORE to find ways that you can help.\n                \"\"\",\n            [nameof(LearnMoreButton)] = \"LEARN MORE\",\n            [nameof(UnstableBuildTitle)] = \"Unstable build warning\",\n            [nameof(UnstableBuildMessage)] = \"\"\"\n                You're using a development build of {0}. These builds are not thoroughly tested and may contain bugs.\n\n                Auto-updates are disabled for development builds.\n\n                Click SEE RELEASES if you want to download a stable release instead.\n                \"\"\",\n            [nameof(SeeReleasesButton)] = \"SEE RELEASES\",\n            [nameof(FFmpegMissingTitle)] = \"FFmpeg is missing\",\n            [nameof(FFmpegMissingMessage)] = \"\"\"\n                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.\n\n                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.\n\n                Click DOWNLOAD to go to the FFmpeg download page.\n                \"\"\",\n            [nameof(FFmpegPathMissingMessage)] = \"\"\"\n                FFmpeg is required for this app to work, but the configured path does not exist:\n                {0}\n\n                Please update the FFmpeg path in settings or clear it to use auto-detection.\n                \"\"\",\n            [nameof(FFmpegMissingSearchedLabel)] =\n                \"Searched for '{0}' in the following directories:\",\n            [nameof(NothingFoundTitle)] = \"Nothing found\",\n            [nameof(NothingFoundMessage)] =\n                \"Couldn't find any videos based on the query or URL you provided\",\n            [nameof(ErrorTitle)] = \"Error\",\n            [nameof(UpdateDownloadingMessage)] = \"Downloading update to {0} v{1}...\",\n            [nameof(UpdateReadyMessage)] =\n                \"Update has been downloaded and will be installed when you exit\",\n            [nameof(UpdateInstallNowButton)] = \"INSTALL NOW\",\n            [nameof(UpdateFailedMessage)] = \"Failed to perform application update\",\n        };\n}\n"
  },
  {
    "path": "YoutubeDownloader/Localization/LocalizationManager.French.cs",
    "content": "using System.Collections.Generic;\n\nnamespace YoutubeDownloader.Localization;\n\npublic partial class LocalizationManager\n{\n    private static readonly IReadOnlyDictionary<string, string> FrenchLocalization = new Dictionary<\n        string,\n        string\n    >\n    {\n        // Dashboard\n        [nameof(QueryWatermark)] = \"URL ou requête de recherche\",\n        [nameof(QueryTooltip)] =\n            \"Toute URL ou ID YouTube valide est acceptée. Ajoutez un point d'interrogation (?) pour rechercher par texte.\",\n        [nameof(ProcessQueryTooltip)] = \"Traiter la requête (Entrée)\",\n        [nameof(AuthTooltip)] = \"Authentification\",\n        [nameof(SettingsTooltip)] = \"Paramètres\",\n        [nameof(DashboardPlaceholder)] = \"\"\"\n            Collez une **URL** ou entrez une **requête de recherche** pour commencer\n            Appuyez sur **Shift+Entrée** pour ajouter plusieurs éléments\n            \"\"\",\n        [nameof(DownloadsFileColumnHeader)] = \"Fichier\",\n        [nameof(DownloadsStatusColumnHeader)] = \"Statut\",\n        [nameof(ContextMenuRemoveSuccessful)] = \"Supprimer les téléchargements réussis\",\n        [nameof(ContextMenuRemoveInactive)] = \"Supprimer les téléchargements inactifs\",\n        [nameof(ContextMenuRestartFailed)] = \"Relancer les téléchargements échoués\",\n        [nameof(ContextMenuCancelAll)] = \"Annuler tous les téléchargements\",\n        [nameof(DownloadStatusEnqueued)] = \"En attente...\",\n        [nameof(DownloadStatusCompleted)] = \"Terminé\",\n        [nameof(DownloadStatusCanceled)] = \"Annulé\",\n        [nameof(DownloadStatusFailed)] = \"Échec\",\n        [nameof(ClickToCopyErrorTooltip)] = \"Note : Cliquez pour copier ce message d'erreur\",\n        [nameof(ShowFileTooltip)] = \"Afficher le fichier\",\n        [nameof(PlayTooltip)] = \"Lire\",\n        [nameof(CancelDownloadTooltip)] = \"Annuler le téléchargement\",\n        [nameof(RestartDownloadTooltip)] = \"Relancer le téléchargement\",\n        // Settings\n        [nameof(SettingsTitle)] = \"Paramètres\",\n        [nameof(ThemeLabel)] = \"Thème\",\n        [nameof(ThemeTooltip)] = \"Thème d'interface préféré\",\n        [nameof(LanguageLabel)] = \"Langue\",\n        [nameof(LanguageTooltip)] = \"Langue d'affichage préférée pour l'interface utilisateur\",\n        [nameof(AutoUpdateLabel)] = \"Mise à jour automatique\",\n        [nameof(AutoUpdateTooltip)] = \"\"\"\n            Effectuer des mises à jour automatiques à chaque démarrage.\n            **Avertissement :** il est recommandé de laisser cette option activée pour assurer la compatibilité avec la dernière version de YouTube.\n            \"\"\",\n        [nameof(PersistAuthLabel)] = \"Conserver l'authentification\",\n        [nameof(PersistAuthTooltip)] = \"\"\"\n            Enregistrer les cookies d'authentification dans un fichier pour les conserver entre les sessions.\n            **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.\n            \"\"\",\n        [nameof(InjectAltLanguagesLabel)] = \"Injecter les langues alternatives\",\n        [nameof(InjectAltLanguagesTooltip)] =\n            \"Injecter des pistes audio en langues alternatives (si disponibles) dans les fichiers téléchargés\",\n        [nameof(InjectSubtitlesLabel)] = \"Injecter les sous-titres\",\n        [nameof(InjectSubtitlesTooltip)] =\n            \"Injecter les sous-titres (si disponibles) dans les fichiers téléchargés\",\n        [nameof(InjectTagsLabel)] = \"Injecter les balises média\",\n        [nameof(InjectTagsTooltip)] =\n            \"Injecter les balises média (si disponibles) dans les fichiers téléchargés\",\n        [nameof(SkipExistingFilesLabel)] = \"Ignorer les fichiers existants\",\n        [nameof(SkipExistingFilesTooltip)] =\n            \"Lors du téléchargement de plusieurs vidéos, ignorer celles qui ont déjà des fichiers correspondants dans le répertoire de sortie\",\n        [nameof(FileNameTemplateLabel)] = \"Modèle de nom de fichier\",\n        [nameof(FileNameTemplateTooltip)] = \"\"\"\n            Modèle utilisé pour générer les noms de fichiers des vidéos téléchargées.\n\n            Jetons disponibles :\n            **$num** — position de la vidéo dans la liste (si applicable)\n            **$id** — ID de la vidéo\n            **$title** — titre de la vidéo\n            **$author** — auteur de la vidéo\n            \"\"\",\n        [nameof(ParallelLimitLabel)] = \"Limite parallèle\",\n        [nameof(ParallelLimitTooltip)] =\n            \"Combien de téléchargements peuvent être actifs en même temps\",\n        [nameof(FFmpegPathLabel)] = \"Chemin FFmpeg\",\n        [nameof(FFmpegPathTooltip)] =\n            \"Chemin vers l'exécutable FFmpeg. Laisser vide pour la détection automatique.\",\n        [nameof(FFmpegPathWatermark)] = \"Auto\",\n        [nameof(FFmpegPathResetTooltip)] = \"Réinitialiser la détection automatique\",\n        [nameof(FFmpegPathBrowseTooltip)] = \"Parcourir l'exécutable FFmpeg\",\n        // Auth Setup\n        [nameof(AuthenticationTitle)] = \"Authentification\",\n        [nameof(AuthenticatedText)] = \"Vous êtes actuellement authentifié\",\n        [nameof(LogOutButton)] = \"Se déconnecter\",\n        [nameof(LoadingText)] = \"Chargement...\",\n        // Download Single Setup\n        [nameof(CopyMenuItem)] = \"Copier\",\n        [nameof(LiveLabel)] = \"En direct\",\n        [nameof(AudioLabel)] = \"Audio\",\n        [nameof(FormatLabel)] = \"Format\",\n        // Download Multiple Setup\n        [nameof(ContainerLabel)] = \"Conteneur\",\n        [nameof(VideoQualityLabel)] = \"Qualité vidéo\",\n        // Common buttons\n        [nameof(CloseButton)] = \"FERMER\",\n        [nameof(DownloadButton)] = \"TÉLÉCHARGER\",\n        [nameof(CancelButton)] = \"ANNULER\",\n        [nameof(SettingsButton)] = \"PARAMÈTRES\",\n        // Dialog messages\n        [nameof(UkraineSupportTitle)] = \"Merci de soutenir l'Ukraine !\",\n        [nameof(UkraineSupportMessage)] = \"\"\"\n            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é.\n\n            Cliquez sur EN SAVOIR PLUS pour trouver des moyens d'aider.\n            \"\"\",\n        [nameof(LearnMoreButton)] = \"EN SAVOIR PLUS\",\n        [nameof(UnstableBuildTitle)] = \"Avertissement : build instable\",\n        [nameof(UnstableBuildMessage)] = \"\"\"\n            Vous utilisez une version de développement de {0}. Ces versions ne sont pas rigoureusement testées et peuvent contenir des bugs.\n\n            Les mises à jour automatiques sont désactivées pour les versions de développement.\n\n            Cliquez sur VOIR LES VERSIONS pour télécharger une version stable.\n            \"\"\",\n        [nameof(SeeReleasesButton)] = \"VOIR LES VERSIONS\",\n        [nameof(FFmpegMissingTitle)] = \"FFmpeg est manquant\",\n        [nameof(FFmpegMissingMessage)] = \"\"\"\n            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.\n\n            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.\n\n            Cliquez sur TÉLÉCHARGER pour accéder à la page de téléchargement de FFmpeg.\n            \"\"\",\n        [nameof(FFmpegPathMissingMessage)] = \"\"\"\n            FFmpeg est requis pour cette application, mais le chemin configuré n'existe pas :\n            {0}\n\n            Veuillez mettre à jour le chemin FFmpeg dans les paramètres ou le vider pour utiliser la détection automatique.\n            \"\"\",\n        [nameof(FFmpegMissingSearchedLabel)] = \"'{0}' recherché dans les répertoires suivants :\",\n        [nameof(NothingFoundTitle)] = \"Rien trouvé\",\n        [nameof(NothingFoundMessage)] =\n            \"Impossible de trouver des vidéos correspondant à la requête ou l'URL fournie\",\n        [nameof(ErrorTitle)] = \"Erreur\",\n        [nameof(UpdateDownloadingMessage)] = \"Téléchargement de la mise à jour {0} v{1}...\",\n        [nameof(UpdateReadyMessage)] =\n            \"La mise à jour a été téléchargée et sera installée à la fermeture\",\n        [nameof(UpdateInstallNowButton)] = \"INSTALLER MAINTENANT\",\n        [nameof(UpdateFailedMessage)] = \"Échec de la mise à jour de l'application\",\n    };\n}\n"
  },
  {
    "path": "YoutubeDownloader/Localization/LocalizationManager.German.cs",
    "content": "using System.Collections.Generic;\n\nnamespace YoutubeDownloader.Localization;\n\npublic partial class LocalizationManager\n{\n    private static readonly IReadOnlyDictionary<string, string> GermanLocalization = new Dictionary<\n        string,\n        string\n    >\n    {\n        // Dashboard\n        [nameof(QueryWatermark)] = \"URL oder Suchanfrage\",\n        [nameof(QueryTooltip)] =\n            \"Jede gültige YouTube-URL oder -ID wird akzeptiert. Stellen Sie ein Fragezeichen (?) voran, um nach Text zu suchen.\",\n        [nameof(ProcessQueryTooltip)] = \"Anfrage verarbeiten (Enter)\",\n        [nameof(AuthTooltip)] = \"Authentifizierung\",\n        [nameof(SettingsTooltip)] = \"Einstellungen\",\n        [nameof(DashboardPlaceholder)] = \"\"\"\n            **URL** einfügen oder **Suchanfrage** eingeben um den Download zu starten\n            Drücken Sie **Shift+Enter** um mehrere Einträge hinzuzufügen\n            \"\"\",\n        [nameof(DownloadsFileColumnHeader)] = \"Datei\",\n        [nameof(DownloadsStatusColumnHeader)] = \"Status\",\n        [nameof(ContextMenuRemoveSuccessful)] = \"Erfolgreiche Downloads entfernen\",\n        [nameof(ContextMenuRemoveInactive)] = \"Inaktive Downloads entfernen\",\n        [nameof(ContextMenuRestartFailed)] = \"Fehlgeschlagene Downloads neu starten\",\n        [nameof(ContextMenuCancelAll)] = \"Alle Downloads abbrechen\",\n        [nameof(DownloadStatusEnqueued)] = \"Ausstehend...\",\n        [nameof(DownloadStatusCompleted)] = \"Fertig\",\n        [nameof(DownloadStatusCanceled)] = \"Abgebrochen\",\n        [nameof(DownloadStatusFailed)] = \"Fehlgeschlagen\",\n        [nameof(ClickToCopyErrorTooltip)] = \"Hinweis: Klicken zum Kopieren der Fehlermeldung\",\n        [nameof(ShowFileTooltip)] = \"Datei anzeigen\",\n        [nameof(PlayTooltip)] = \"Abspielen\",\n        [nameof(CancelDownloadTooltip)] = \"Download abbrechen\",\n        [nameof(RestartDownloadTooltip)] = \"Download neu starten\",\n        // Settings\n        [nameof(SettingsTitle)] = \"Einstellungen\",\n        [nameof(ThemeLabel)] = \"Design\",\n        [nameof(ThemeTooltip)] = \"Bevorzugtes Oberflächendesign\",\n        [nameof(LanguageLabel)] = \"Sprache\",\n        [nameof(LanguageTooltip)] = \"Bevorzugte Anzeigesprache für die Benutzeroberfläche\",\n        [nameof(AutoUpdateLabel)] = \"Automatische Updates\",\n        [nameof(AutoUpdateTooltip)] = \"\"\"\n            Automatische Updates bei jedem Start durchführen.\n            **Hinweis:** Es wird empfohlen, diese Option aktiviert zu lassen, um die Kompatibilität mit der neuesten YouTube-Version zu gewährleisten.\n            \"\"\",\n        [nameof(PersistAuthLabel)] = \"Authentifizierung speichern\",\n        [nameof(PersistAuthTooltip)] = \"\"\"\n            Authentifizierungs-Cookies in einer Datei speichern für sitzungsübergreifende Persistenz.\n            **Warnung**: Die Cookies werden mit Verschlüsselung gespeichert, können aber dennoch von einem Angreifer mit Zugriff auf Ihr System wiederhergestellt werden.\n            \"\"\",\n        [nameof(InjectAltLanguagesLabel)] = \"Alternative Sprachen einbetten\",\n        [nameof(InjectAltLanguagesTooltip)] =\n            \"Audiotracks in alternativen Sprachen (falls verfügbar) in heruntergeladene Dateien einbetten\",\n        [nameof(InjectSubtitlesLabel)] = \"Untertitel einbetten\",\n        [nameof(InjectSubtitlesTooltip)] =\n            \"Untertitel (falls verfügbar) in heruntergeladene Dateien einbetten\",\n        [nameof(InjectTagsLabel)] = \"Medien-Tags einbetten\",\n        [nameof(InjectTagsTooltip)] =\n            \"Medien-Tags (falls verfügbar) in heruntergeladene Dateien einbetten\",\n        [nameof(SkipExistingFilesLabel)] = \"Vorhandene Dateien überspringen\",\n        [nameof(SkipExistingFilesTooltip)] =\n            \"Beim Herunterladen mehrerer Videos solche überspringen, für die bereits passende Dateien im Ausgabeverzeichnis vorhanden sind\",\n        [nameof(FileNameTemplateLabel)] = \"Dateinamen-Vorlage\",\n        [nameof(FileNameTemplateTooltip)] = \"\"\"\n            Vorlage für die Generierung von Dateinamen heruntergeladener Videos.\n\n            Verfügbare Token:\n            **$num** — Position des Videos in der Liste (falls zutreffend)\n            **$id** — Video-ID\n            **$title** — Videotitel\n            **$author** — Videoautor\n            \"\"\",\n        [nameof(ParallelLimitLabel)] = \"Paralleles Limit\",\n        [nameof(ParallelLimitTooltip)] = \"Wie viele Downloads gleichzeitig aktiv sein können\",\n        [nameof(FFmpegPathLabel)] = \"FFmpeg-Pfad\",\n        [nameof(FFmpegPathTooltip)] =\n            \"Pfad zur FFmpeg-Programmdatei. Leer lassen für automatische Erkennung.\",\n        [nameof(FFmpegPathWatermark)] = \"Auto\",\n        [nameof(FFmpegPathResetTooltip)] = \"Zurücksetzen auf automatische Erkennung\",\n        [nameof(FFmpegPathBrowseTooltip)] = \"FFmpeg-Programmdatei suchen\",\n        // Auth Setup\n        [nameof(AuthenticationTitle)] = \"Authentifizierung\",\n        [nameof(AuthenticatedText)] = \"Sie sind derzeit authentifiziert\",\n        [nameof(LogOutButton)] = \"Abmelden\",\n        [nameof(LoadingText)] = \"Laden...\",\n        // Download Single Setup\n        [nameof(CopyMenuItem)] = \"Kopieren\",\n        [nameof(LiveLabel)] = \"Live\",\n        [nameof(AudioLabel)] = \"Audio\",\n        [nameof(FormatLabel)] = \"Format\",\n        // Download Multiple Setup\n        [nameof(ContainerLabel)] = \"Container\",\n        [nameof(VideoQualityLabel)] = \"Videoqualität\",\n        // Common buttons\n        [nameof(CloseButton)] = \"SCHLIESSEN\",\n        [nameof(DownloadButton)] = \"HERUNTERLADEN\",\n        [nameof(CancelButton)] = \"ABBRECHEN\",\n        [nameof(SettingsButton)] = \"EINSTELLUNGEN\",\n        // Dialog messages\n        [nameof(UkraineSupportTitle)] = \"Danke für Ihre Unterstützung der Ukraine!\",\n        [nameof(UkraineSupportMessage)] = \"\"\"\n            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.\n\n            Klicken Sie auf MEHR ERFAHREN um Wege zu finden, wie Sie helfen können.\n            \"\"\",\n        [nameof(LearnMoreButton)] = \"MEHR ERFAHREN\",\n        [nameof(UnstableBuildTitle)] = \"Warnung: Instabiler Build\",\n        [nameof(UnstableBuildMessage)] = \"\"\"\n            Sie verwenden einen Entwicklungs-Build von {0}. Diese Builds wurden nicht gründlich getestet und können Fehler enthalten.\n\n            Automatische Updates sind für Entwicklungs-Builds deaktiviert.\n\n            Klicken Sie auf RELEASES ANZEIGEN um stattdessen einen stabilen Release herunterzuladen.\n            \"\"\",\n        [nameof(SeeReleasesButton)] = \"RELEASES ANZEIGEN\",\n        [nameof(FFmpegMissingTitle)] = \"FFmpeg fehlt\",\n        [nameof(FFmpegMissingMessage)] = \"\"\"\n            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.\n\n            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.\n\n            Klicken Sie auf HERUNTERLADEN um zur FFmpeg-Downloadseite zu gelangen.\n            \"\"\",\n        [nameof(FFmpegPathMissingMessage)] = \"\"\"\n            FFmpeg wird für diese App benötigt, aber der konfigurierte Pfad existiert nicht:\n            {0}\n\n            Bitte aktualisieren Sie den FFmpeg-Pfad in den Einstellungen oder löschen Sie ihn zur automatischen Erkennung.\n            \"\"\",\n        [nameof(FFmpegMissingSearchedLabel)] = \"'{0}' wurde in folgenden Verzeichnissen gesucht:\",\n        [nameof(NothingFoundTitle)] = \"Nichts gefunden\",\n        [nameof(NothingFoundMessage)] =\n            \"Es konnten keine Videos basierend auf der angegebenen Anfrage oder URL gefunden werden\",\n        [nameof(ErrorTitle)] = \"Fehler\",\n        [nameof(UpdateDownloadingMessage)] = \"Update für {0} v{1} wird heruntergeladen...\",\n        [nameof(UpdateReadyMessage)] =\n            \"Update wurde heruntergeladen und wird beim Beenden installiert\",\n        [nameof(UpdateInstallNowButton)] = \"JETZT INSTALLIEREN\",\n        [nameof(UpdateFailedMessage)] = \"Anwendungsupdate konnte nicht durchgeführt werden\",\n    };\n}\n"
  },
  {
    "path": "YoutubeDownloader/Localization/LocalizationManager.Spanish.cs",
    "content": "using System.Collections.Generic;\n\nnamespace YoutubeDownloader.Localization;\n\npublic partial class LocalizationManager\n{\n    private static readonly IReadOnlyDictionary<string, string> SpanishLocalization =\n        new Dictionary<string, string>\n        {\n            // Dashboard\n            [nameof(QueryWatermark)] = \"URL o consulta de búsqueda\",\n            [nameof(QueryTooltip)] =\n                \"Se acepta cualquier URL o ID de YouTube válido. Antepone un signo de interrogación (?) para buscar por texto.\",\n            [nameof(ProcessQueryTooltip)] = \"Procesar consulta (Enter)\",\n            [nameof(AuthTooltip)] = \"Autenticación\",\n            [nameof(SettingsTooltip)] = \"Configuración\",\n            [nameof(DashboardPlaceholder)] = \"\"\"\n                Pega una **URL** o ingresa una **consulta de búsqueda** para comenzar\n                Presiona **Shift+Enter** para agregar múltiples elementos\n                \"\"\",\n            [nameof(DownloadsFileColumnHeader)] = \"Archivo\",\n            [nameof(DownloadsStatusColumnHeader)] = \"Estado\",\n            [nameof(ContextMenuRemoveSuccessful)] = \"Eliminar descargas exitosas\",\n            [nameof(ContextMenuRemoveInactive)] = \"Eliminar descargas inactivas\",\n            [nameof(ContextMenuRestartFailed)] = \"Reiniciar descargas fallidas\",\n            [nameof(ContextMenuCancelAll)] = \"Cancelar todas las descargas\",\n            [nameof(DownloadStatusEnqueued)] = \"Pendiente...\",\n            [nameof(DownloadStatusCompleted)] = \"Listo\",\n            [nameof(DownloadStatusCanceled)] = \"Cancelado\",\n            [nameof(DownloadStatusFailed)] = \"Fallido\",\n            [nameof(ClickToCopyErrorTooltip)] = \"Nota: Haz clic para copiar este mensaje de error\",\n            [nameof(ShowFileTooltip)] = \"Mostrar archivo\",\n            [nameof(PlayTooltip)] = \"Reproducir\",\n            [nameof(CancelDownloadTooltip)] = \"Cancelar descarga\",\n            [nameof(RestartDownloadTooltip)] = \"Reiniciar descarga\",\n            // Settings\n            [nameof(SettingsTitle)] = \"Configuración\",\n            [nameof(ThemeLabel)] = \"Tema\",\n            [nameof(ThemeTooltip)] = \"Tema de interfaz preferido\",\n            [nameof(LanguageLabel)] = \"Idioma\",\n            [nameof(LanguageTooltip)] =\n                \"Idioma de visualización preferido para la interfaz de usuario\",\n            [nameof(AutoUpdateLabel)] = \"Actualización automática\",\n            [nameof(AutoUpdateTooltip)] = \"\"\"\n                Realizar actualizaciones automáticas en cada inicio.\n                **Advertencia:** se recomienda dejar esta opción habilitada para asegurar la compatibilidad con la última versión de YouTube.\n                \"\"\",\n            [nameof(PersistAuthLabel)] = \"Conservar autenticación\",\n            [nameof(PersistAuthTooltip)] = \"\"\"\n                Guardar las cookies de autenticación en un archivo para persistirlas entre sesiones.\n                **Advertencia**: aunque las cookies se almacenan con cifrado, un atacante con acceso a su sistema podría recuperarlas.\n                \"\"\",\n            [nameof(InjectAltLanguagesLabel)] = \"Insertar idiomas alternativos\",\n            [nameof(InjectAltLanguagesTooltip)] =\n                \"Insertar pistas de audio en idiomas alternativos (si están disponibles) en los archivos descargados\",\n            [nameof(InjectSubtitlesLabel)] = \"Insertar subtítulos\",\n            [nameof(InjectSubtitlesTooltip)] =\n                \"Insertar subtítulos (si están disponibles) en los archivos descargados\",\n            [nameof(InjectTagsLabel)] = \"Insertar etiquetas multimedia\",\n            [nameof(InjectTagsTooltip)] =\n                \"Insertar etiquetas multimedia (si están disponibles) en los archivos descargados\",\n            [nameof(SkipExistingFilesLabel)] = \"Omitir archivos existentes\",\n            [nameof(SkipExistingFilesTooltip)] =\n                \"Al descargar múltiples videos, omitir los que ya tengan archivos correspondientes en el directorio de salida\",\n            [nameof(FileNameTemplateLabel)] = \"Plantilla de nombre de archivo\",\n            [nameof(FileNameTemplateTooltip)] = \"\"\"\n                Plantilla para generar nombres de archivo de los videos descargados.\n\n                Tokens disponibles:\n                **$num** — posición del video en la lista (si aplica)\n                **$id** — ID del video\n                **$title** — título del video\n                **$author** — autor del video\n                \"\"\",\n            [nameof(ParallelLimitLabel)] = \"Límite paralelo\",\n            [nameof(ParallelLimitTooltip)] =\n                \"Cuántas descargas pueden estar activas al mismo tiempo\",\n            [nameof(FFmpegPathLabel)] = \"Ruta de FFmpeg\",\n            [nameof(FFmpegPathTooltip)] =\n                \"Ruta al ejecutable de FFmpeg. Dejar vacío para detección automática.\",\n            [nameof(FFmpegPathWatermark)] = \"Auto\",\n            [nameof(FFmpegPathResetTooltip)] = \"Restablecer detección automática\",\n            [nameof(FFmpegPathBrowseTooltip)] = \"Buscar ejecutable de FFmpeg\",\n            // Auth Setup\n            [nameof(AuthenticationTitle)] = \"Autenticación\",\n            [nameof(AuthenticatedText)] = \"Actualmente estás autenticado\",\n            [nameof(LogOutButton)] = \"Cerrar sesión\",\n            [nameof(LoadingText)] = \"Cargando...\",\n            // Download Single Setup\n            [nameof(CopyMenuItem)] = \"Copiar\",\n            [nameof(LiveLabel)] = \"En vivo\",\n            [nameof(AudioLabel)] = \"Audio\",\n            [nameof(FormatLabel)] = \"Formato\",\n            // Download Multiple Setup\n            [nameof(ContainerLabel)] = \"Contenedor\",\n            [nameof(VideoQualityLabel)] = \"Calidad de video\",\n            // Common buttons\n            [nameof(CloseButton)] = \"CERRAR\",\n            [nameof(DownloadButton)] = \"DESCARGAR\",\n            [nameof(CancelButton)] = \"CANCELAR\",\n            [nameof(SettingsButton)] = \"AJUSTES\",\n            // Dialog messages\n            [nameof(UkraineSupportTitle)] = \"¡Gracias por apoyar a Ucrania!\",\n            [nameof(UkraineSupportMessage)] = \"\"\"\n                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.\n\n                Haz clic en MÁS INFORMACIÓN para encontrar formas en que puedes ayudar.\n                \"\"\",\n            [nameof(LearnMoreButton)] = \"MÁS INFORMACIÓN\",\n            [nameof(UnstableBuildTitle)] = \"Advertencia: versión inestable\",\n            [nameof(UnstableBuildMessage)] = \"\"\"\n                Estás usando una versión de desarrollo de {0}. Estas versiones no han sido probadas exhaustivamente y pueden contener errores.\n\n                Las actualizaciones automáticas están desactivadas para versiones de desarrollo.\n\n                Haz clic en VER LANZAMIENTOS para descargar una versión estable.\n                \"\"\",\n            [nameof(SeeReleasesButton)] = \"VER LANZAMIENTOS\",\n            [nameof(FFmpegMissingTitle)] = \"Falta FFmpeg\",\n            [nameof(FFmpegMissingMessage)] = \"\"\"\n                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.\n\n                Alternativamente, puedes descargar una versión de {0} que incluye FFmpeg. Busca los archivos de lanzamiento que NO estén marcados como *.Bare.\n\n                Haz clic en DESCARGAR para ir a la página de descarga de FFmpeg.\n                \"\"\",\n            [nameof(FFmpegPathMissingMessage)] = \"\"\"\n                FFmpeg es necesario para esta aplicación, pero la ruta configurada no existe:\n                {0}\n\n                Por favor, actualiza la ruta de FFmpeg en los ajustes o bórrala para usar la detección automática.\n                \"\"\",\n            [nameof(FFmpegMissingSearchedLabel)] = \"Se buscó '{0}' en los siguientes directorios:\",\n            [nameof(NothingFoundTitle)] = \"Nada encontrado\",\n            [nameof(NothingFoundMessage)] =\n                \"No se encontraron videos basados en la consulta o URL proporcionada\",\n            [nameof(ErrorTitle)] = \"Error\",\n            [nameof(UpdateDownloadingMessage)] = \"Descargando actualización de {0} v{1}...\",\n            [nameof(UpdateReadyMessage)] =\n                \"La actualización se ha descargado y se instalará al salir\",\n            [nameof(UpdateInstallNowButton)] = \"INSTALAR AHORA\",\n            [nameof(UpdateFailedMessage)] = \"Error al realizar la actualización de la aplicación\",\n        };\n}\n"
  },
  {
    "path": "YoutubeDownloader/Localization/LocalizationManager.Ukrainian.cs",
    "content": "using System.Collections.Generic;\n\nnamespace YoutubeDownloader.Localization;\n\npublic partial class LocalizationManager\n{\n    private static readonly IReadOnlyDictionary<string, string> UkrainianLocalization =\n        new Dictionary<string, string>\n        {\n            // Dashboard\n            [nameof(QueryWatermark)] = \"URL або пошуковий запит\",\n            [nameof(QueryTooltip)] =\n                \"Приймається будь-який дійсний URL або ID YouTube. Додайте знак питання (?) для пошуку за текстом.\",\n            [nameof(ProcessQueryTooltip)] = \"Виконати запит (Enter)\",\n            [nameof(AuthTooltip)] = \"Автентифікація\",\n            [nameof(SettingsTooltip)] = \"Налаштування\",\n            [nameof(DashboardPlaceholder)] = \"\"\"\n                Вставте **URL** або введіть **пошуковий запит** для завантаження\n                Натисніть **Shift+Enter**, щоб додати декілька елементів\n                \"\"\",\n            [nameof(DownloadsFileColumnHeader)] = \"Файл\",\n            [nameof(DownloadsStatusColumnHeader)] = \"Статус\",\n            [nameof(ContextMenuRemoveSuccessful)] = \"Видалити успішні завантаження\",\n            [nameof(ContextMenuRemoveInactive)] = \"Видалити неактивні завантаження\",\n            [nameof(ContextMenuRestartFailed)] = \"Перезапустити невдалі завантаження\",\n            [nameof(ContextMenuCancelAll)] = \"Скасувати всі завантаження\",\n            [nameof(DownloadStatusEnqueued)] = \"В черзі...\",\n            [nameof(DownloadStatusCompleted)] = \"Готово\",\n            [nameof(DownloadStatusCanceled)] = \"Скасовано\",\n            [nameof(DownloadStatusFailed)] = \"Помилка\",\n            [nameof(ClickToCopyErrorTooltip)] = \"Примітка: натисніть, щоб скопіювати повідомлення\",\n            [nameof(ShowFileTooltip)] = \"Показати файл\",\n            [nameof(PlayTooltip)] = \"Відтворити\",\n            [nameof(CancelDownloadTooltip)] = \"Скасувати завантаження\",\n            [nameof(RestartDownloadTooltip)] = \"Перезапустити завантаження\",\n            // Settings\n            [nameof(SettingsTitle)] = \"Налаштування\",\n            [nameof(ThemeLabel)] = \"Тема\",\n            [nameof(ThemeTooltip)] = \"Бажана тема інтерфейсу\",\n            [nameof(LanguageLabel)] = \"Мова\",\n            [nameof(LanguageTooltip)] = \"Бажана мова відображення інтерфейсу користувача\",\n            [nameof(AutoUpdateLabel)] = \"Авто-оновлення\",\n            [nameof(AutoUpdateTooltip)] = \"\"\"\n                Виконувати автоматичні оновлення при кожному запуску.\n                **Увага:** рекомендується залишити цю опцію увімкненою для сумісності з останньою версією YouTube.\n                \"\"\",\n            [nameof(PersistAuthLabel)] = \"Зберігати автентифікацію\",\n            [nameof(PersistAuthTooltip)] = \"\"\"\n                Зберігати файли cookie у файлі для збереження між сеансами.\n                **Увага**: хоча cookies зберігаються із шифруванням, зловмисник з доступом до вашої системи може їх відновити.\n                \"\"\",\n            [nameof(InjectAltLanguagesLabel)] = \"Вставляти альтернативні мови\",\n            [nameof(InjectAltLanguagesTooltip)] =\n                \"Вставляти аудіодоріжки альтернативними мовами (якщо доступні) у завантажені файли\",\n            [nameof(InjectSubtitlesLabel)] = \"Вставляти субтитри\",\n            [nameof(InjectSubtitlesTooltip)] =\n                \"Вставляти субтитри (якщо доступні) у завантажені файли\",\n            [nameof(InjectTagsLabel)] = \"Вставляти медіатеги\",\n            [nameof(InjectTagsTooltip)] = \"Вставляти медіатеги (якщо доступні) у завантажені файли\",\n            [nameof(SkipExistingFilesLabel)] = \"Пропускати наявні файли\",\n            [nameof(SkipExistingFilesTooltip)] =\n                \"При завантаженні кількох відео пропускати ті, для яких вже є відповідні файли\",\n            [nameof(FileNameTemplateLabel)] = \"Шаблон імені файлу\",\n            [nameof(FileNameTemplateTooltip)] = \"\"\"\n                Шаблон для генерації імен файлів завантажених відео.\n\n                Доступні токени:\n                **$num** — позиція відео у списку (якщо застосовно)\n                **$id** — ідентифікатор відео\n                **$title** — назва відео\n                **$author** — автор відео\n                \"\"\",\n            [nameof(ParallelLimitLabel)] = \"Ліміт паралелізації\",\n            [nameof(ParallelLimitTooltip)] = \"Скільки завантажень може бути активними одночасно\",\n            [nameof(FFmpegPathLabel)] = \"Шлях FFmpeg\",\n            [nameof(FFmpegPathTooltip)] =\n                \"Шлях до виконуваного файлу FFmpeg. Залиште порожнім для автоматичного визначення.\",\n            [nameof(FFmpegPathWatermark)] = \"Авто\",\n            [nameof(FFmpegPathResetTooltip)] = \"Скинути до автоматичного визначення\",\n            [nameof(FFmpegPathBrowseTooltip)] = \"Вибрати файл FFmpeg\",\n            // Auth Setup\n            [nameof(AuthenticationTitle)] = \"Автентифікація\",\n            [nameof(AuthenticatedText)] = \"Ви автентифіковані\",\n            [nameof(LogOutButton)] = \"Вийти\",\n            [nameof(LoadingText)] = \"Завантаження...\",\n            // Download Single Setup\n            [nameof(CopyMenuItem)] = \"Копіювати\",\n            [nameof(LiveLabel)] = \"Живе\",\n            [nameof(AudioLabel)] = \"Аудіо\",\n            [nameof(FormatLabel)] = \"Формат\",\n            // Download Multiple Setup\n            [nameof(ContainerLabel)] = \"Контейнер\",\n            [nameof(VideoQualityLabel)] = \"Якість відео\",\n            // Common buttons\n            [nameof(CloseButton)] = \"ЗАКРИТИ\",\n            [nameof(DownloadButton)] = \"ЗАВАНТАЖИТИ\",\n            [nameof(CancelButton)] = \"СКАСУВАТИ\",\n            [nameof(SettingsButton)] = \"НАЛАШТУВАННЯ\",\n            // Dialog messages\n            [nameof(UkraineSupportTitle)] = \"Дякуємо за підтримку України!\",\n            [nameof(UkraineSupportMessage)] = \"\"\"\n                Поки Росія веде геноцидну війну проти моєї країни, я вдячний кожному, хто продовжує підтримувати Україну у нашій боротьбі за свободу.\n\n                Натисніть ДІЗНАТИСЬ БІЛЬШЕ, щоб знайти способи допомогти.\n                \"\"\",\n            [nameof(LearnMoreButton)] = \"ДІЗНАТИСЬ БІЛЬШЕ\",\n            [nameof(UnstableBuildTitle)] = \"Попередження про нестабільну збірку\",\n            [nameof(UnstableBuildMessage)] = \"\"\"\n                Ви використовуєте збірку розробки {0}. Ці збірки не пройшли ретельного тестування та можуть містити помилки.\n\n                Авто-оновлення вимкнено для збірок розробки.\n\n                Натисніть ПЕРЕГЛЯНУТИ РЕЛІЗИ, щоб завантажити стабільний реліз.\n                \"\"\",\n            [nameof(SeeReleasesButton)] = \"ПЕРЕГЛЯНУТИ РЕЛІЗИ\",\n            [nameof(FFmpegMissingTitle)] = \"FFmpeg відсутній\",\n            [nameof(FFmpegMissingMessage)] = \"\"\"\n                FFmpeg потрібен для роботи {0}. Завантажте його та зробіть доступним у каталозі програми або у системному PATH, або вкажіть розташування у налаштуваннях.\n\n                Альтернативно, ви можете завантажити версію {0} з вбудованим FFmpeg. Шукайте ресурси релізу, які НЕ позначені як *.Bare.\n\n                Натисніть ЗАВАНТАЖИТИ, щоб перейти на сторінку завантаження FFmpeg.\n                \"\"\",\n            [nameof(FFmpegPathMissingMessage)] = \"\"\"\n                FFmpeg потрібен для роботи програми, але вказаний шлях не існує:\n                {0}\n\n                Будь ласка, оновіть шлях FFmpeg у налаштуваннях або очистіть його для автовизначення.\n                \"\"\",\n            [nameof(FFmpegMissingSearchedLabel)] = \"Шукали '{0}' у таких директоріях:\",\n            [nameof(NothingFoundTitle)] = \"Нічого не знайдено\",\n            [nameof(NothingFoundMessage)] = \"Не вдалося знайти відео за вказаним запитом або URL\",\n            [nameof(ErrorTitle)] = \"Помилка\",\n            [nameof(UpdateDownloadingMessage)] = \"Завантаження оновлення {0} v{1}...\",\n            [nameof(UpdateReadyMessage)] = \"Оновлення завантажено та буде встановлено після виходу\",\n            [nameof(UpdateInstallNowButton)] = \"ВСТАНОВИТИ ЗАРАЗ\",\n            [nameof(UpdateFailedMessage)] = \"Не вдалося виконати оновлення програми\",\n        };\n}\n"
  },
  {
    "path": "YoutubeDownloader/Localization/LocalizationManager.cs",
    "content": "using System;\nusing System.Globalization;\nusing System.Runtime.CompilerServices;\nusing CommunityToolkit.Mvvm.ComponentModel;\nusing YoutubeDownloader.Services;\nusing YoutubeDownloader.Utils;\nusing YoutubeDownloader.Utils.Extensions;\n\nnamespace YoutubeDownloader.Localization;\n\npublic partial class LocalizationManager : ObservableObject, IDisposable\n{\n    private readonly DisposableCollector _eventRoot = new();\n\n    public LocalizationManager(SettingsService settingsService)\n    {\n        _eventRoot.Add(\n            settingsService.WatchProperty(\n                o => o.Language,\n                () => Language = settingsService.Language,\n                true\n            )\n        );\n\n        _eventRoot.Add(\n            this.WatchProperty(\n                o => o.Language,\n                () =>\n                {\n                    foreach (var propertyName in EnglishLocalization.Keys)\n                        OnPropertyChanged(propertyName);\n                }\n            )\n        );\n    }\n\n    [ObservableProperty]\n    public partial Language Language { get; set; } = Language.System;\n\n    private string Get([CallerMemberName] string? key = null)\n    {\n        if (string.IsNullOrWhiteSpace(key))\n            return string.Empty;\n\n        var localization = Language switch\n        {\n            Language.System =>\n                CultureInfo.CurrentUICulture.ThreeLetterISOLanguageName.ToLowerInvariant() switch\n                {\n                    \"ukr\" => UkrainianLocalization,\n                    \"deu\" => GermanLocalization,\n                    \"fra\" => FrenchLocalization,\n                    \"spa\" => SpanishLocalization,\n                    _ => EnglishLocalization,\n                },\n            Language.Ukrainian => UkrainianLocalization,\n            Language.German => GermanLocalization,\n            Language.French => FrenchLocalization,\n            Language.Spanish => SpanishLocalization,\n            _ => EnglishLocalization,\n        };\n\n        if (\n            localization.TryGetValue(key, out var value)\n            // English is used as a fallback\n            || EnglishLocalization.TryGetValue(key, out value)\n        )\n        {\n            return value;\n        }\n\n        return $\"Missing localization for '{key}'\";\n    }\n\n    public void Dispose() => _eventRoot.Dispose();\n}\n\npublic partial class LocalizationManager\n{\n    // ---- Dashboard ----\n\n    public string QueryWatermark => Get();\n    public string QueryTooltip => Get();\n    public string ProcessQueryTooltip => Get();\n    public string AuthTooltip => Get();\n    public string SettingsTooltip => Get();\n    public string DashboardPlaceholder => Get();\n    public string DownloadsFileColumnHeader => Get();\n    public string DownloadsStatusColumnHeader => Get();\n    public string ContextMenuRemoveSuccessful => Get();\n    public string ContextMenuRemoveInactive => Get();\n    public string ContextMenuRestartFailed => Get();\n    public string ContextMenuCancelAll => Get();\n    public string DownloadStatusEnqueued => Get();\n    public string DownloadStatusCompleted => Get();\n    public string DownloadStatusCanceled => Get();\n    public string DownloadStatusFailed => Get();\n    public string ClickToCopyErrorTooltip => Get();\n    public string ShowFileTooltip => Get();\n    public string PlayTooltip => Get();\n    public string CancelDownloadTooltip => Get();\n    public string RestartDownloadTooltip => Get();\n\n    // ---- Settings ----\n\n    public string SettingsTitle => Get();\n    public string ThemeLabel => Get();\n    public string ThemeTooltip => Get();\n    public string LanguageLabel => Get();\n    public string LanguageTooltip => Get();\n    public string AutoUpdateLabel => Get();\n    public string AutoUpdateTooltip => Get();\n    public string PersistAuthLabel => Get();\n    public string PersistAuthTooltip => Get();\n    public string InjectAltLanguagesLabel => Get();\n    public string InjectAltLanguagesTooltip => Get();\n    public string InjectSubtitlesLabel => Get();\n    public string InjectSubtitlesTooltip => Get();\n    public string InjectTagsLabel => Get();\n    public string InjectTagsTooltip => Get();\n    public string SkipExistingFilesLabel => Get();\n    public string SkipExistingFilesTooltip => Get();\n    public string FileNameTemplateLabel => Get();\n    public string FileNameTemplateTooltip => Get();\n    public string ParallelLimitLabel => Get();\n    public string ParallelLimitTooltip => Get();\n    public string FFmpegPathLabel => Get();\n    public string FFmpegPathTooltip => Get();\n    public string FFmpegPathWatermark => Get();\n    public string FFmpegPathResetTooltip => Get();\n    public string FFmpegPathBrowseTooltip => Get();\n\n    // ---- Auth Setup ----\n\n    public string AuthenticationTitle => Get();\n    public string AuthenticatedText => Get();\n    public string LogOutButton => Get();\n    public string LoadingText => Get();\n\n    // ---- Download Single Setup ----\n\n    public string CopyMenuItem => Get();\n    public string LiveLabel => Get();\n    public string AudioLabel => Get();\n    public string FormatLabel => Get();\n\n    // ---- Download Multiple Setup ----\n\n    public string ContainerLabel => Get();\n    public string VideoQualityLabel => Get();\n\n    // ---- Common buttons ----\n\n    public string CloseButton => Get();\n    public string DownloadButton => Get();\n    public string CancelButton => Get();\n    public string SettingsButton => Get();\n\n    // ---- Dialog messages ----\n\n    public string UkraineSupportTitle => Get();\n    public string UkraineSupportMessage => Get();\n    public string LearnMoreButton => Get();\n    public string UnstableBuildTitle => Get();\n    public string UnstableBuildMessage => Get();\n    public string SeeReleasesButton => Get();\n    public string FFmpegMissingTitle => Get();\n    public string FFmpegMissingMessage => Get();\n    public string FFmpegPathMissingMessage => Get();\n    public string FFmpegMissingSearchedLabel => Get();\n    public string NothingFoundTitle => Get();\n    public string NothingFoundMessage => Get();\n    public string ErrorTitle => Get();\n    public string UpdateDownloadingMessage => Get();\n    public string UpdateReadyMessage => Get();\n    public string UpdateInstallNowButton => Get();\n    public string UpdateFailedMessage => Get();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Program.cs",
    "content": "﻿using System;\nusing System.Reflection;\nusing Avalonia;\nusing Avalonia.WebView.Desktop;\nusing YoutubeDownloader.Utils;\n\nnamespace YoutubeDownloader;\n\npublic static class Program\n{\n    private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly();\n\n    public static string Name { get; } = Assembly.GetName().Name ?? \"YoutubeDownloader\";\n\n    public static Version Version { get; } = Assembly.GetName().Version ?? new Version(0, 0, 0);\n\n    public static string VersionString { get; } = Version.ToString(3);\n\n    public static bool IsDevelopmentBuild { get; } = Version.Major is <= 0 or >= 999;\n\n    public static string ProjectUrl { get; } = \"https://github.com/Tyrrrz/YoutubeDownloader\";\n\n    public static string ProjectReleasesUrl { get; } = $\"{ProjectUrl}/releases\";\n\n    public static AppBuilder BuildAvaloniaApp() =>\n        AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace().UseDesktopWebView();\n\n    [STAThread]\n    public static int Main(string[] args)\n    {\n        // Build and run the app\n        var builder = BuildAvaloniaApp();\n\n        try\n        {\n            return builder.StartWithClassicDesktopLifetime(args);\n        }\n        catch (Exception ex)\n        {\n            if (OperatingSystem.IsWindows())\n                _ = NativeMethods.Windows.MessageBox(0, ex.ToString(), \"Fatal Error\", 0x10);\n\n            throw;\n        }\n        finally\n        {\n            // Clean up after application shutdown\n            if (builder.Instance is IDisposable disposableApp)\n                disposableApp.Dispose();\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Publish-MacOSBundle.ps1",
    "content": "param(\n    [Parameter(Mandatory=$true)]\n    [string]$PublishDirPath,\n\n    [Parameter(Mandatory=$true)]\n    [string]$IconsFilePath,\n\n    [Parameter(Mandatory=$true)]\n    [string]$FullVersion,\n\n    [Parameter(Mandatory=$true)]\n    [string]$ShortVersion\n)\n\n$ErrorActionPreference = \"Stop\"\n\n# Setup paths\n$tempDirPath = Join-Path $PublishDirPath \"../publish-macos-app-temp\"\n$bundleName = \"YoutubeDownloader.app\"\n$bundleDirPath = Join-Path $tempDirPath $bundleName\n$contentsDirPath = Join-Path $bundleDirPath \"Contents\"\n$macosDirPath = Join-Path $contentsDirPath \"MacOS\"\n$resourcesDirPath = Join-Path $contentsDirPath \"Resources\"\n\ntry {\n    # Initialize the bundle's directory structure\n    New-Item -Path $bundleDirPath -ItemType Directory -Force\n    New-Item -Path $contentsDirPath -ItemType Directory -Force\n    New-Item -Path $macosDirPath -ItemType Directory -Force\n    New-Item -Path $resourcesDirPath -ItemType Directory -Force\n\n    # Copy icons into the .app's Resources folder\n    Copy-Item -Path $IconsFilePath -Destination (Join-Path $resourcesDirPath \"AppIcon.icns\") -Force\n\n    # Generate the Info.plist metadata file with the app information\n    $plistContent = @\"\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>CFBundleDisplayName</key>\n    <string>YoutubeDownloader</string>\n    <key>CFBundleName</key>\n    <string>YoutubeDownloader</string>\n    <key>CFBundleExecutable</key>\n    <string>YoutubeDownloader</string>\n    <key>NSHumanReadableCopyright</key>\n    <string>© Oleksii Holub</string>\n    <key>CFBundleIdentifier</key>\n    <string>me.Tyrrrz.YoutubeDownloader</string>\n    <key>CFBundleSpokenName</key>\n    <string>YoutubeDownloader</string>\n    <key>CFBundleIconFile</key>\n    <string>AppIcon</string>\n    <key>CFBundleIconName</key>\n    <string>AppIcon</string>\n    <key>CFBundleVersion</key>\n    <string>$FullVersion</string>\n    <key>CFBundleShortVersionString</key>\n    <string>$ShortVersion</string>\n    <key>NSHighResolutionCapable</key>\n    <true />\n    <key>CFBundlePackageType</key>\n    <string>APPL</string>\n  </dict>\n</plist>\n\"@\n\n    Set-Content -Path (Join-Path $contentsDirPath \"Info.plist\") -Value $plistContent\n\n    # Delete the previous bundle if it exists\n    if (Test-Path (Join-Path $PublishDirPath $bundleName)) {\n        Remove-Item -Path (Join-Path $PublishDirPath $bundleName) -Recurse -Force\n    }\n\n    # Move all files from the publish directory into the MacOS directory\n    Get-ChildItem -Path $PublishDirPath | ForEach-Object {\n        Move-Item -Path $_.FullName -Destination $macosDirPath -Force\n    }\n\n    # Move the final bundle into the publish directory for upload\n    Move-Item -Path $bundleDirPath -Destination $PublishDirPath -Force\n}\nfinally {\n    # Clean up the temporary directory\n    Remove-Item -Path $tempDirPath -Recurse -Force\n}"
  },
  {
    "path": "YoutubeDownloader/Services/SettingsService.AuthCookiesEncryptionConverter.cs",
    "content": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Net;\nusing System.Security.Cryptography;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.Json.Serialization;\nusing YoutubeDownloader.Utils.Extensions;\n\nnamespace YoutubeDownloader.Services;\n\npublic partial class SettingsService\n{\n    private class AuthCookiesEncryptionConverter : JsonConverter<IReadOnlyList<Cookie>?>\n    {\n        private static readonly Lazy<byte[]> Key = new(() =>\n            Rfc2898DeriveBytes.Pbkdf2(\n                Encoding.UTF8.GetBytes(Environment.TryGetMachineId() ?? string.Empty),\n                Encoding.UTF8.GetBytes(ThisAssembly.Project.EncryptionSalt),\n                600_000,\n                HashAlgorithmName.SHA256,\n                16\n            )\n        );\n\n        public override IReadOnlyList<Cookie>? Read(\n            ref Utf8JsonReader reader,\n            Type typeToConvert,\n            JsonSerializerOptions options\n        )\n        {\n            if (reader.TokenType != JsonTokenType.String)\n                return null;\n\n            var value = reader.GetString();\n            if (string.IsNullOrWhiteSpace(value))\n                return null;\n\n            try\n            {\n                var encryptedData = Convert.FromHexString(value);\n                var cookieData = new byte[encryptedData.AsSpan(28).Length];\n\n                // Layout: nonce (12 bytes) | tag (16 bytes) | cipher\n                using var aes = new AesGcm(Key.Value, 16);\n                aes.Decrypt(\n                    encryptedData.AsSpan(0, 12),\n                    encryptedData.AsSpan(28),\n                    encryptedData.AsSpan(12, 16),\n                    cookieData\n                );\n\n                return JsonSerializer\n                    .Deserialize<IReadOnlyList<CookieData>>(cookieData)\n                    ?.Select(c => new Cookie(c.Name, c.Value, c.Path, c.Domain))\n                    .ToArray();\n            }\n            catch (Exception ex)\n                when (ex\n                        is FormatException\n                            or CryptographicException\n                            or ArgumentException\n                            or IndexOutOfRangeException\n                            or JsonException\n                )\n            {\n                return null;\n            }\n        }\n\n        public override void Write(\n            Utf8JsonWriter writer,\n            IReadOnlyList<Cookie>? value,\n            JsonSerializerOptions options\n        )\n        {\n            if (value is null || value.Count == 0)\n            {\n                writer.WriteNullValue();\n                return;\n            }\n\n            var cookieData = Encoding.UTF8.GetBytes(\n                JsonSerializer.Serialize(\n                    value.Select(c => new CookieData(c.Name, c.Value, c.Path, c.Domain))\n                )\n            );\n\n            var encryptedData = new byte[28 + cookieData.Length];\n\n            // Nonce\n            RandomNumberGenerator.Fill(encryptedData.AsSpan(0, 12));\n\n            // Layout: nonce (12 bytes) | tag (16 bytes) | cipher\n            using var aes = new AesGcm(Key.Value, 16);\n            aes.Encrypt(\n                encryptedData.AsSpan(0, 12),\n                cookieData,\n                encryptedData.AsSpan(28),\n                encryptedData.AsSpan(12, 16)\n            );\n\n            writer.WriteStringValue(Convert.ToHexStringLower(encryptedData));\n        }\n\n        private record CookieData(string Name, string Value, string Path, string Domain);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Services/SettingsService.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Net;\nusing System.Text.Json;\nusing System.Text.Json.Serialization;\nusing Cogwheel;\nusing CommunityToolkit.Mvvm.ComponentModel;\nusing YoutubeDownloader.Core.Downloading;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing Container = YoutubeExplode.Videos.Streams.Container;\n\nnamespace YoutubeDownloader.Services;\n\n[ObservableObject]\npublic partial class SettingsService()\n    : SettingsBase(StartOptions.Current.SettingsPath, SerializerContext.Default)\n{\n    [ObservableProperty]\n    public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;\n\n    [ObservableProperty]\n    public partial ThemeVariant Theme { get; set; }\n\n    [ObservableProperty]\n    public partial Language Language { get; set; }\n\n    [ObservableProperty]\n    public partial bool IsAutoUpdateEnabled { get; set; } = true;\n\n    [ObservableProperty]\n    public partial bool IsAuthPersisted { get; set; } = true;\n\n    [ObservableProperty]\n    public partial string? FFmpegFilePath { get; set; }\n\n    [ObservableProperty]\n    public partial bool ShouldInjectLanguageSpecificAudioStreams { get; set; } = true;\n\n    [ObservableProperty]\n    public partial bool ShouldInjectSubtitles { get; set; } = true;\n\n    [ObservableProperty]\n    public partial bool ShouldInjectTags { get; set; } = true;\n\n    [ObservableProperty]\n    public partial bool ShouldSkipExistingFiles { get; set; }\n\n    [ObservableProperty]\n    public partial string FileNameTemplate { get; set; } = \"$title\";\n\n    [ObservableProperty]\n    public partial int ParallelLimit { get; set; } = 2;\n\n    [ObservableProperty]\n    [JsonConverter(typeof(AuthCookiesEncryptionConverter))]\n    public partial IReadOnlyList<Cookie>? LastAuthCookies { get; set; }\n\n    [ObservableProperty]\n    [JsonConverter(typeof(ContainerJsonConverter))]\n    public partial Container LastContainer { get; set; } = Container.Mp4;\n\n    [ObservableProperty]\n    public partial VideoQualityPreference LastVideoQualityPreference { get; set; } =\n        VideoQualityPreference.Highest;\n\n    public override void Save()\n    {\n        // Clear the cookies if they are not supposed to be persisted\n        var lastAuthCookies = LastAuthCookies;\n        if (!IsAuthPersisted)\n            LastAuthCookies = null;\n\n        base.Save();\n\n        LastAuthCookies = lastAuthCookies;\n    }\n}\n\npublic partial class SettingsService\n{\n    private class ContainerJsonConverter : JsonConverter<Container>\n    {\n        public override Container Read(\n            ref Utf8JsonReader reader,\n            Type typeToConvert,\n            JsonSerializerOptions options\n        )\n        {\n            Container? result = null;\n\n            if (reader.TokenType == JsonTokenType.StartObject)\n            {\n                while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)\n                {\n                    if (\n                        reader.TokenType == JsonTokenType.PropertyName\n                        && reader.GetString() == \"Name\"\n                        && reader.Read()\n                        && reader.TokenType == JsonTokenType.String\n                    )\n                    {\n                        var name = reader.GetString();\n                        if (!string.IsNullOrWhiteSpace(name))\n                            result = new Container(name);\n                    }\n                }\n            }\n\n            return result\n                ?? throw new InvalidOperationException(\n                    $\"Invalid JSON for type '{typeToConvert.FullName}'.\"\n                );\n        }\n\n        public override void Write(\n            Utf8JsonWriter writer,\n            Container value,\n            JsonSerializerOptions options\n        )\n        {\n            writer.WriteStartObject();\n            writer.WriteString(\"Name\", value.Name);\n            writer.WriteEndObject();\n        }\n    }\n}\n\npublic partial class SettingsService\n{\n    [JsonSerializable(typeof(SettingsService))]\n    private partial class SerializerContext : JsonSerializerContext;\n}\n"
  },
  {
    "path": "YoutubeDownloader/Services/UpdateService.cs",
    "content": "﻿using System;\nusing System.Runtime.InteropServices;\nusing System.Threading.Tasks;\nusing Onova;\nusing Onova.Exceptions;\nusing Onova.Services;\nusing YoutubeDownloader.Core.Downloading;\n\nnamespace YoutubeDownloader.Services;\n\npublic class UpdateService(SettingsService settingsService) : IDisposable\n{\n    private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows()\n        ? new UpdateManager(\n            new GithubPackageResolver(\n                \"Tyrrrz\",\n                \"YoutubeDownloader\",\n                // Examples:\n                // YoutubeDownloader.win-arm64.zip\n                // YoutubeDownloader.win-x64.zip\n                // YoutubeDownloader.linux-x64.zip\n                // YoutubeDownloader.Bare.linux-x64.zip\n                FFmpeg.IsBundled()\n                    ? $\"YoutubeDownloader.{RuntimeInformation.RuntimeIdentifier}.zip\"\n                    : $\"YoutubeDownloader.Bare.{RuntimeInformation.RuntimeIdentifier}.zip\"\n            ),\n            new ZipPackageExtractor()\n        )\n        : null;\n\n    private Version? _updateVersion;\n    private bool _updatePrepared;\n    private bool _updaterLaunched;\n\n    public async Task<Version?> CheckForUpdatesAsync()\n    {\n        if (_updateManager is null)\n            return null;\n\n        if (!settingsService.IsAutoUpdateEnabled)\n            return null;\n\n        var check = await _updateManager.CheckForUpdatesAsync();\n        return check.CanUpdate ? check.LastVersion : null;\n    }\n\n    public async Task PrepareUpdateAsync(Version version)\n    {\n        if (_updateManager is null)\n            return;\n\n        if (!settingsService.IsAutoUpdateEnabled)\n            return;\n\n        try\n        {\n            await _updateManager.PrepareUpdateAsync(_updateVersion = version);\n            _updatePrepared = true;\n        }\n        catch (UpdaterAlreadyLaunchedException)\n        {\n            // Ignore race conditions\n        }\n        catch (LockFileNotAcquiredException)\n        {\n            // Ignore race conditions\n        }\n    }\n\n    public void FinalizeUpdate(bool needRestart)\n    {\n        if (_updateManager is null)\n            return;\n\n        if (!settingsService.IsAutoUpdateEnabled)\n            return;\n\n        if (_updateVersion is null || !_updatePrepared || _updaterLaunched)\n            return;\n\n        try\n        {\n            _updateManager.LaunchUpdater(_updateVersion, needRestart);\n            _updaterLaunched = true;\n        }\n        catch (UpdaterAlreadyLaunchedException)\n        {\n            // Ignore race conditions\n        }\n        catch (LockFileNotAcquiredException)\n        {\n            // Ignore race conditions\n        }\n    }\n\n    public void Dispose() => _updateManager?.Dispose();\n}\n"
  },
  {
    "path": "YoutubeDownloader/StartOptions.cs",
    "content": "using System;\nusing System.IO;\n\nnamespace YoutubeDownloader;\n\npublic partial class StartOptions\n{\n    public required string SettingsPath { get; init; }\n}\n\npublic partial class StartOptions\n{\n    public static StartOptions Current { get; } =\n        new()\n        {\n            SettingsPath =\n                Environment.GetEnvironmentVariable(\"YOUTUBEDOWNLOADER_SETTINGS_PATH\") is { } path\n                && !string.IsNullOrWhiteSpace(path)\n                    ? Path.EndsInDirectorySeparator(path) || Directory.Exists(path)\n                        ? Path.Combine(path, \"Settings.dat\")\n                        : path\n                    : Path.Combine(AppContext.BaseDirectory, \"Settings.dat\"),\n        };\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/Disposable.cs",
    "content": "﻿using System;\n\nnamespace YoutubeDownloader.Utils;\n\ninternal class Disposable(Action dispose) : IDisposable\n{\n    public static IDisposable Create(Action dispose) => new Disposable(dispose);\n\n    public void Dispose() => dispose();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/DisposableCollector.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing YoutubeDownloader.Utils.Extensions;\n\nnamespace YoutubeDownloader.Utils;\n\ninternal class DisposableCollector : IDisposable\n{\n    private readonly object _lock = new();\n    private readonly List<IDisposable> _items = [];\n\n    public void Add(IDisposable item)\n    {\n        lock (_lock)\n        {\n            _items.Add(item);\n        }\n    }\n\n    public void Dispose()\n    {\n        lock (_lock)\n        {\n            _items.DisposeAll();\n            _items.Clear();\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/Extensions/AvaloniaExtensions.cs",
    "content": "﻿using Avalonia.Controls;\nusing Avalonia.Controls.ApplicationLifetimes;\nusing Avalonia.VisualTree;\n\nnamespace YoutubeDownloader.Utils.Extensions;\n\ninternal static class AvaloniaExtensions\n{\n    extension(IApplicationLifetime lifetime)\n    {\n        public Window? TryGetMainWindow() =>\n            lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime\n                ? desktopLifetime.MainWindow\n                : null;\n\n        public TopLevel? TryGetTopLevel() =>\n            lifetime.TryGetMainWindow()\n            ?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;\n\n        public bool TryShutdown(int exitCode = 0)\n        {\n            if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)\n            {\n                return desktopLifetime.TryShutdown(exitCode);\n            }\n\n            if (lifetime is IControlledApplicationLifetime controlledLifetime)\n            {\n                controlledLifetime.Shutdown(exitCode);\n                return true;\n            }\n\n            return false;\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/Extensions/DirectoryExtensions.cs",
    "content": "﻿using System.IO;\n\nnamespace YoutubeDownloader.Utils.Extensions;\n\ninternal static class DirectoryExtensions\n{\n    extension(Directory)\n    {\n        public static void CreateDirectoryForFile(string filePath)\n        {\n            var dirPath = Path.GetDirectoryName(filePath);\n            if (string.IsNullOrWhiteSpace(dirPath))\n                return;\n\n            Directory.CreateDirectory(dirPath);\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/Extensions/DisposableExtensions.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace YoutubeDownloader.Utils.Extensions;\n\ninternal static class DisposableExtensions\n{\n    extension(IEnumerable<IDisposable> disposables)\n    {\n        public void DisposeAll()\n        {\n            var exceptions = default(List<Exception>);\n\n            foreach (var disposable in disposables)\n            {\n                try\n                {\n                    disposable.Dispose();\n                }\n                catch (Exception ex)\n                {\n                    (exceptions ??= []).Add(ex);\n                }\n            }\n\n            if (exceptions?.Any() == true)\n                throw new AggregateException(exceptions);\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/Extensions/EnvironmentExtensions.cs",
    "content": "using System;\nusing System.IO;\n\nnamespace YoutubeDownloader.Utils.Extensions;\n\ninternal static class EnvironmentExtensions\n{\n    extension(Environment)\n    {\n        public static string? TryGetMachineId()\n        {\n            // Windows: stable GUID written during OS installation\n            if (OperatingSystem.IsWindows())\n            {\n                try\n                {\n                    using var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(\n                        @\"SOFTWARE\\Microsoft\\Cryptography\"\n                    );\n                    if (\n                        regKey?.GetValue(\"MachineGuid\") is string guid\n                        && !string.IsNullOrWhiteSpace(guid)\n                    )\n                        return guid;\n                }\n                catch { }\n            }\n            else\n            {\n                // Unix: /etc/machine-id (set once by systemd at first boot)\n                foreach (var path in new[] { \"/etc/machine-id\", \"/var/lib/dbus/machine-id\" })\n                {\n                    try\n                    {\n                        var id = File.ReadAllText(path).Trim();\n                        if (!string.IsNullOrWhiteSpace(id))\n                            return id;\n                    }\n                    catch { }\n                }\n            }\n\n            // Last-resort fallback\n            try\n            {\n                return Environment.MachineName;\n            }\n            catch\n            {\n                return null;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/Extensions/NotifyPropertyChangedExtensions.cs",
    "content": "﻿using System;\nusing System.ComponentModel;\nusing System.Linq.Expressions;\nusing System.Reflection;\n\nnamespace YoutubeDownloader.Utils.Extensions;\n\ninternal static class NotifyPropertyChangedExtensions\n{\n    extension<TOwner>(TOwner owner)\n        where TOwner : INotifyPropertyChanged\n    {\n        public IDisposable WatchProperty<TProperty>(\n            Expression<Func<TOwner, TProperty>> propertyExpression,\n            Action callback,\n            bool watchInitialValue = false\n        )\n        {\n            var memberExpression = propertyExpression.Body as MemberExpression;\n            if (memberExpression?.Member is not PropertyInfo property)\n                throw new ArgumentException(\"Provided expression must reference a property.\");\n\n            void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)\n            {\n                if (\n                    string.IsNullOrWhiteSpace(args.PropertyName)\n                    || string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)\n                )\n                {\n                    callback();\n                }\n            }\n\n            owner.PropertyChanged += OnPropertyChanged;\n\n            if (watchInitialValue)\n                callback();\n\n            return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);\n        }\n\n        public IDisposable WatchAllProperties(Action callback, bool watchInitialValues = false)\n        {\n            void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();\n            owner.PropertyChanged += OnPropertyChanged;\n\n            if (watchInitialValues)\n                callback();\n\n            return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/Extensions/PathExtensions.cs",
    "content": "﻿using System.IO;\n\nnamespace YoutubeDownloader.Utils.Extensions;\n\ninternal static class PathExtensions\n{\n    extension(Path)\n    {\n        public static string EnsureUniqueFilePath(string baseFilePath, int maxRetries = 100)\n        {\n            if (!File.Exists(baseFilePath))\n                return baseFilePath;\n\n            var baseDirPath = Path.GetDirectoryName(baseFilePath);\n            var baseFileNameWithoutExtension = Path.GetFileNameWithoutExtension(baseFilePath);\n            var baseFileExtension = Path.GetExtension(baseFilePath);\n\n            for (var i = 1; i <= maxRetries; i++)\n            {\n                var fileName = $\"{baseFileNameWithoutExtension} ({i}){baseFileExtension}\";\n                var filePath = !string.IsNullOrWhiteSpace(baseDirPath)\n                    ? Path.Combine(baseDirPath, fileName)\n                    : fileName;\n\n                if (!File.Exists(filePath))\n                    return filePath;\n            }\n\n            return baseFilePath;\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/Extensions/ProcessExtensions.cs",
    "content": "﻿using System.Collections.Generic;\nusing System.Diagnostics;\n\nnamespace YoutubeDownloader.Utils.Extensions;\n\ninternal static class ProcessExtensions\n{\n    extension(Process)\n    {\n        public static void Start(string path, IReadOnlyList<string>? arguments = null)\n        {\n            using var process = new Process();\n            process.StartInfo = new ProcessStartInfo(path);\n\n            if (arguments is not null)\n            {\n                foreach (var argument in arguments)\n                    process.StartInfo.ArgumentList.Add(argument);\n            }\n\n            process.Start();\n        }\n\n        public static void StartShellExecute(string path, IReadOnlyList<string>? arguments = null)\n        {\n            using var process = new Process();\n            process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true };\n\n            if (arguments is not null)\n            {\n                foreach (var argument in arguments)\n                    process.StartInfo.ArgumentList.Add(argument);\n            }\n\n            process.Start();\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/NativeMethods.cs",
    "content": "﻿using System.Runtime.InteropServices;\n\nnamespace YoutubeDownloader.Utils;\n\ninternal static class NativeMethods\n{\n    public static class Windows\n    {\n        [DllImport(\"user32.dll\", SetLastError = true)]\n        public static extern int MessageBox(nint hWnd, string text, string caption, uint type);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Utils/ResizableSemaphore.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Threading;\nusing System.Threading.Tasks;\n\nnamespace YoutubeDownloader.Utils;\n\n// Like a regular semaphore, but the max count can be changed at any point\ninternal partial class ResizableSemaphore : IDisposable\n{\n    private readonly object _lock = new();\n    private readonly Queue<TaskCompletionSource> _waiters = new();\n    private readonly CancellationTokenSource _cts = new();\n\n    private bool _isDisposed;\n    private int _maxCount = int.MaxValue;\n    private int _count;\n\n    public int MaxCount\n    {\n        get\n        {\n            lock (_lock)\n            {\n                return _maxCount;\n            }\n        }\n        set\n        {\n            lock (_lock)\n            {\n                _maxCount = value;\n                Refresh();\n            }\n        }\n    }\n\n    private void Refresh()\n    {\n        lock (_lock)\n        {\n            // Provide access to pending waiters, as long as max count allows\n            while (_count < MaxCount && _waiters.TryDequeue(out var waiter))\n            {\n                // Don't increment the count if the waiter has already been\n                // completed before (most likely by getting canceled).\n                if (waiter.TrySetResult())\n                    _count++;\n            }\n        }\n    }\n\n    public async Task<IDisposable> AcquireAsync(CancellationToken cancellationToken = default)\n    {\n        if (_isDisposed)\n            throw new ObjectDisposedException(GetType().Name);\n\n        var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);\n\n        await using (_cts.Token.Register(() => waiter.TrySetCanceled(_cts.Token)))\n        await using (cancellationToken.Register(() => waiter.TrySetCanceled(cancellationToken)))\n        {\n            // Add the waiter to the queue\n            lock (_lock)\n            {\n                _waiters.Enqueue(waiter);\n                Refresh();\n            }\n\n            // Wait until this waiter has been given access\n            await waiter.Task;\n\n            return new AcquiredAccess(this);\n        }\n    }\n\n    private void Release()\n    {\n        lock (_lock)\n        {\n            _count--;\n            Refresh();\n        }\n    }\n\n    public void Dispose()\n    {\n        if (!_isDisposed)\n        {\n            _cts.Cancel();\n            _cts.Dispose();\n        }\n\n        _isDisposed = true;\n    }\n}\n\ninternal partial class ResizableSemaphore\n{\n    private class AcquiredAccess(ResizableSemaphore semaphore) : IDisposable\n    {\n        private bool _isDisposed;\n\n        public void Dispose()\n        {\n            if (!_isDisposed)\n            {\n                semaphore.Release();\n            }\n\n            _isDisposed = true;\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/Components/DashboardViewModel.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Collections.ObjectModel;\nusing System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CommunityToolkit.Mvvm.ComponentModel;\nusing CommunityToolkit.Mvvm.Input;\nusing Gress;\nusing Gress.Completable;\nusing YoutubeDownloader.Core.Downloading;\nusing YoutubeDownloader.Core.Resolving;\nusing YoutubeDownloader.Core.Tagging;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing YoutubeDownloader.Services;\nusing YoutubeDownloader.Utils;\nusing YoutubeDownloader.Utils.Extensions;\nusing YoutubeExplode.Exceptions;\n\nnamespace YoutubeDownloader.ViewModels.Components;\n\npublic partial class DashboardViewModel : ViewModelBase\n{\n    private readonly ViewModelManager _viewModelManager;\n    private readonly SnackbarManager _snackbarManager;\n    private readonly DialogManager _dialogManager;\n    private readonly SettingsService _settingsService;\n\n    private readonly DisposableCollector _eventRoot = new();\n    private readonly ResizableSemaphore _downloadSemaphore = new();\n    private readonly AutoResetProgressMuxer _progressMuxer;\n\n    public DashboardViewModel(\n        ViewModelManager viewModelManager,\n        SnackbarManager snackbarManager,\n        DialogManager dialogManager,\n        LocalizationManager localizationManager,\n        SettingsService settingsService\n    )\n    {\n        _viewModelManager = viewModelManager;\n        _snackbarManager = snackbarManager;\n        _dialogManager = dialogManager;\n        LocalizationManager = localizationManager;\n        _settingsService = settingsService;\n\n        _progressMuxer = Progress.CreateMuxer().WithAutoReset();\n\n        _eventRoot.Add(\n            _settingsService.WatchProperty(\n                o => o.ParallelLimit,\n                () => _downloadSemaphore.MaxCount = _settingsService.ParallelLimit,\n                true\n            )\n        );\n\n        _eventRoot.Add(\n            Progress.WatchProperty(\n                o => o.Current,\n                () => OnPropertyChanged(nameof(IsProgressIndeterminate))\n            )\n        );\n    }\n\n    public LocalizationManager LocalizationManager { get; }\n\n    [ObservableProperty]\n    [NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))]\n    [NotifyCanExecuteChangedFor(nameof(ProcessQueryCommand))]\n    [NotifyCanExecuteChangedFor(nameof(ShowAuthSetupCommand))]\n    [NotifyCanExecuteChangedFor(nameof(ShowSettingsCommand))]\n    public partial bool IsBusy { get; set; }\n\n    public ProgressContainer<Percentage> Progress { get; } = new();\n\n    public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;\n\n    [ObservableProperty]\n    [NotifyCanExecuteChangedFor(nameof(ProcessQueryCommand))]\n    public partial string? Query { get; set; }\n\n    public ObservableCollection<DownloadViewModel> Downloads { get; } = [];\n\n    private bool CanShowAuthSetup() => !IsBusy;\n\n    [RelayCommand(CanExecute = nameof(CanShowAuthSetup))]\n    private async Task ShowAuthSetupAsync() =>\n        await _dialogManager.ShowDialogAsync(_viewModelManager.CreateAuthSetupViewModel());\n\n    private bool CanShowSettings() => !IsBusy;\n\n    [RelayCommand(CanExecute = nameof(CanShowSettings))]\n    private async Task ShowSettingsAsync() =>\n        await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());\n\n    private async void EnqueueDownload(DownloadViewModel download, int position = 0)\n    {\n        Downloads.Insert(position, download);\n        var progress = _progressMuxer.CreateInput();\n\n        try\n        {\n            using var downloader = new VideoDownloader(_settingsService.LastAuthCookies);\n            var tagInjector = new MediaTagInjector();\n\n            using var access = await _downloadSemaphore.AcquireAsync(download.CancellationToken);\n\n            download.Status = DownloadStatus.Started;\n\n            var downloadOption =\n                download.DownloadOption\n                ?? await downloader.GetBestDownloadOptionAsync(\n                    download.Video!.Id,\n                    download.DownloadPreference!,\n                    _settingsService.ShouldInjectLanguageSpecificAudioStreams,\n                    download.CancellationToken\n                );\n\n            await downloader.DownloadVideoAsync(\n                download.FilePath!,\n                download.Video!,\n                downloadOption,\n                _settingsService.ShouldInjectSubtitles,\n                _settingsService.FFmpegFilePath,\n                download.Progress.Merge(progress),\n                download.CancellationToken\n            );\n\n            if (_settingsService.ShouldInjectTags)\n            {\n                try\n                {\n                    await tagInjector.InjectTagsAsync(\n                        download.FilePath!,\n                        download.Video!,\n                        download.CancellationToken\n                    );\n                }\n                catch\n                {\n                    // Media tagging is not critical\n                }\n            }\n\n            download.Status = DownloadStatus.Completed;\n        }\n        catch (Exception ex)\n        {\n            try\n            {\n                // Delete the incompletely downloaded file\n                if (!string.IsNullOrWhiteSpace(download.FilePath))\n                    File.Delete(download.FilePath);\n            }\n            catch\n            {\n                // Ignore\n            }\n\n            download.Status =\n                ex is OperationCanceledException ? DownloadStatus.Canceled : DownloadStatus.Failed;\n\n            // Short error message for YouTube-related errors, full for others\n            download.ErrorMessage = ex is YoutubeExplodeException ? ex.Message : ex.ToString();\n        }\n        finally\n        {\n            progress.ReportCompletion();\n            download.Dispose();\n        }\n    }\n\n    private bool CanProcessQuery() => !IsBusy && !string.IsNullOrWhiteSpace(Query);\n\n    [RelayCommand(CanExecute = nameof(CanProcessQuery))]\n    private async Task ProcessQueryAsync()\n    {\n        if (string.IsNullOrWhiteSpace(Query))\n            return;\n\n        IsBusy = true;\n\n        // Small weight so as to not offset any existing download operations\n        var progress = _progressMuxer.CreateInput(0.01);\n\n        try\n        {\n            using var resolver = new QueryResolver(_settingsService.LastAuthCookies);\n\n            // Split queries by newlines\n            var queries = Query.Split(\n                '\\n',\n                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries\n            );\n\n            // Process individual queries\n            var queryResults = new List<QueryResult>();\n            foreach (var (i, query) in queries.Index())\n            {\n                try\n                {\n                    queryResults.Add(await resolver.ResolveAsync(query));\n                }\n                // If it's not the only query in the list, don't interrupt the process\n                // and report the error via an async notification instead of a sync dialog.\n                // https://github.com/Tyrrrz/YoutubeDownloader/issues/563\n                catch (YoutubeExplodeException ex)\n                    when (ex is VideoUnavailableException or PlaylistUnavailableException\n                        && queries.Length > 1\n                    )\n                {\n                    _snackbarManager.Notify(ex.Message);\n                }\n\n                progress.Report(Percentage.FromFraction((i + 1.0) / queries.Length));\n            }\n\n            // Aggregate results\n            var queryResult = QueryResult.Aggregate(queryResults);\n\n            // Single video result\n            if (queryResult.Videos.Count == 1)\n            {\n                var video = queryResult.Videos.Single();\n\n                using var downloader = new VideoDownloader(_settingsService.LastAuthCookies);\n\n                var downloadOptions = await downloader.GetDownloadOptionsAsync(\n                    video.Id,\n                    _settingsService.ShouldInjectLanguageSpecificAudioStreams\n                );\n\n                var download = await _dialogManager.ShowDialogAsync(\n                    _viewModelManager.CreateDownloadSingleSetupViewModel(video, downloadOptions)\n                );\n\n                if (download is null)\n                    return;\n\n                EnqueueDownload(download);\n\n                Query = \"\";\n            }\n            // Multiple videos\n            else if (queryResult.Videos.Count > 1)\n            {\n                var downloads = await _dialogManager.ShowDialogAsync(\n                    _viewModelManager.CreateDownloadMultipleSetupViewModel(\n                        queryResult.Title,\n                        queryResult.Videos,\n                        // Pre-select videos if they come from a single query and not from search\n                        queryResult.Kind\n                            is not QueryResultKind.Search\n                                and not QueryResultKind.Aggregate\n                    )\n                );\n\n                if (downloads is null)\n                    return;\n\n                foreach (var download in downloads)\n                    EnqueueDownload(download);\n\n                Query = \"\";\n            }\n            // No videos found\n            else\n            {\n                await _dialogManager.ShowDialogAsync(\n                    _viewModelManager.CreateMessageBoxViewModel(\n                        LocalizationManager.NothingFoundTitle,\n                        LocalizationManager.NothingFoundMessage\n                    )\n                );\n            }\n        }\n        catch (Exception ex)\n        {\n            await _dialogManager.ShowDialogAsync(\n                _viewModelManager.CreateMessageBoxViewModel(\n                    LocalizationManager.ErrorTitle,\n                    // Short error message for YouTube-related errors, full for others\n                    ex is YoutubeExplodeException\n                        ? ex.Message\n                        : ex.ToString()\n                )\n            );\n        }\n        finally\n        {\n            progress.ReportCompletion();\n            IsBusy = false;\n        }\n    }\n\n    private void RemoveDownload(DownloadViewModel download)\n    {\n        Downloads.Remove(download);\n        download.CancelCommand.Execute(null);\n        download.Dispose();\n    }\n\n    [RelayCommand]\n    private void RemoveSuccessfulDownloads()\n    {\n        foreach (var download in Downloads.ToArray())\n        {\n            if (download.Status == DownloadStatus.Completed)\n                RemoveDownload(download);\n        }\n    }\n\n    [RelayCommand]\n    private void RemoveInactiveDownloads()\n    {\n        foreach (var download in Downloads.ToArray())\n        {\n            if (\n                download.Status\n                is DownloadStatus.Completed\n                    or DownloadStatus.Failed\n                    or DownloadStatus.Canceled\n            )\n                RemoveDownload(download);\n        }\n    }\n\n    [RelayCommand]\n    private void RestartDownload(DownloadViewModel download)\n    {\n        var position = Math.Max(0, Downloads.IndexOf(download));\n        RemoveDownload(download);\n\n        var newDownload = download.DownloadOption is not null\n            ? _viewModelManager.CreateDownloadViewModel(\n                download.Video!,\n                download.DownloadOption,\n                download.FilePath!\n            )\n            : _viewModelManager.CreateDownloadViewModel(\n                download.Video!,\n                download.DownloadPreference!,\n                download.FilePath!\n            );\n\n        EnqueueDownload(newDownload, position);\n    }\n\n    [RelayCommand]\n    private void RestartFailedDownloads()\n    {\n        foreach (var download in Downloads.ToArray())\n        {\n            if (download.Status == DownloadStatus.Failed)\n                RestartDownload(download);\n        }\n    }\n\n    [RelayCommand]\n    private void CancelAllDownloads()\n    {\n        foreach (var download in Downloads)\n            download.CancelCommand.Execute(null);\n    }\n\n    protected override void Dispose(bool disposing)\n    {\n        if (disposing)\n        {\n            CancelAllDownloads();\n\n            _eventRoot.Dispose();\n            _downloadSemaphore.Dispose();\n        }\n\n        base.Dispose(disposing);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/Components/DownloadStatus.cs",
    "content": "﻿namespace YoutubeDownloader.ViewModels.Components;\n\npublic enum DownloadStatus\n{\n    Enqueued,\n    Started,\n    Completed,\n    Failed,\n    Canceled,\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/Components/DownloadViewModel.cs",
    "content": "﻿using System;\nusing System.Diagnostics;\nusing System.IO;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Avalonia;\nusing CommunityToolkit.Mvvm.ComponentModel;\nusing CommunityToolkit.Mvvm.Input;\nusing Gress;\nusing YoutubeDownloader.Core.Downloading;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing YoutubeDownloader.Utils;\nusing YoutubeDownloader.Utils.Extensions;\nusing YoutubeExplode.Videos;\n\nnamespace YoutubeDownloader.ViewModels.Components;\n\npublic partial class DownloadViewModel : ViewModelBase\n{\n    private readonly ViewModelManager _viewModelManager;\n    private readonly DialogManager _dialogManager;\n\n    private readonly DisposableCollector _eventRoot = new();\n    private readonly CancellationTokenSource _cancellationTokenSource = new();\n\n    private bool _isDisposed;\n\n    public DownloadViewModel(\n        ViewModelManager viewModelManager,\n        DialogManager dialogManager,\n        LocalizationManager localizationManager\n    )\n    {\n        _viewModelManager = viewModelManager;\n        _dialogManager = dialogManager;\n        LocalizationManager = localizationManager;\n\n        _eventRoot.Add(\n            Progress.WatchProperty(\n                o => o.Current,\n                () => OnPropertyChanged(nameof(IsProgressIndeterminate))\n            )\n        );\n    }\n\n    public LocalizationManager LocalizationManager { get; }\n\n    [ObservableProperty]\n    public partial IVideo? Video { get; set; }\n\n    [ObservableProperty]\n    public partial VideoDownloadOption? DownloadOption { get; set; }\n\n    [ObservableProperty]\n    public partial VideoDownloadPreference? DownloadPreference { get; set; }\n\n    [ObservableProperty]\n    [NotifyPropertyChangedFor(nameof(FileName))]\n    public partial string? FilePath { get; set; }\n\n    [ObservableProperty]\n    [NotifyPropertyChangedFor(nameof(IsCanceledOrFailed))]\n    [NotifyCanExecuteChangedFor(nameof(CancelCommand))]\n    [NotifyCanExecuteChangedFor(nameof(ShowFileCommand))]\n    [NotifyCanExecuteChangedFor(nameof(OpenFileCommand))]\n    public partial DownloadStatus Status { get; set; } = DownloadStatus.Enqueued;\n\n    [ObservableProperty]\n    [NotifyCanExecuteChangedFor(nameof(CopyErrorMessageCommand))]\n    public partial string? ErrorMessage { get; set; }\n\n    public CancellationToken CancellationToken => _cancellationTokenSource.Token;\n\n    public string? FileName => Path.GetFileName(FilePath);\n\n    public ProgressContainer<Percentage> Progress { get; } = new();\n\n    public bool IsProgressIndeterminate => Progress.Current.Fraction is <= 0 or >= 1;\n\n    public bool IsCanceledOrFailed => Status is DownloadStatus.Canceled or DownloadStatus.Failed;\n\n    private bool CanCancel() => Status is DownloadStatus.Enqueued or DownloadStatus.Started;\n\n    [RelayCommand(CanExecute = nameof(CanCancel))]\n    private void Cancel()\n    {\n        if (_isDisposed)\n            return;\n\n        _cancellationTokenSource.Cancel();\n    }\n\n    private bool CanShowFile() =>\n        Status == DownloadStatus.Completed\n        // This only works on Windows currently\n        && OperatingSystem.IsWindows();\n\n    [RelayCommand(CanExecute = nameof(CanShowFile))]\n    private async Task ShowFileAsync()\n    {\n        if (string.IsNullOrWhiteSpace(FilePath))\n            return;\n\n        try\n        {\n            // Navigate to the file in Windows Explorer\n            Process.Start(\"explorer\", [\"/select,\", FilePath]);\n        }\n        catch (Exception ex)\n        {\n            await _dialogManager.ShowDialogAsync(\n                _viewModelManager.CreateMessageBoxViewModel(\n                    LocalizationManager.ErrorTitle,\n                    ex.Message\n                )\n            );\n        }\n    }\n\n    private bool CanOpenFile() => Status == DownloadStatus.Completed;\n\n    [RelayCommand(CanExecute = nameof(CanOpenFile))]\n    private async Task OpenFileAsync()\n    {\n        if (string.IsNullOrWhiteSpace(FilePath))\n            return;\n\n        try\n        {\n            Process.StartShellExecute(FilePath);\n        }\n        catch (Exception ex)\n        {\n            await _dialogManager.ShowDialogAsync(\n                _viewModelManager.CreateMessageBoxViewModel(\n                    LocalizationManager.ErrorTitle,\n                    ex.Message\n                )\n            );\n        }\n    }\n\n    [RelayCommand]\n    private async Task CopyErrorMessageAsync()\n    {\n        if (string.IsNullOrWhiteSpace(ErrorMessage))\n            return;\n\n        if (Application.Current?.ApplicationLifetime?.TryGetTopLevel()?.Clipboard is { } clipboard)\n            await clipboard.SetTextAsync(ErrorMessage);\n    }\n\n    protected override void Dispose(bool disposing)\n    {\n        if (disposing)\n        {\n            _eventRoot.Dispose();\n            _cancellationTokenSource.Dispose();\n\n            _isDisposed = true;\n        }\n\n        base.Dispose(disposing);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/Dialogs/AuthSetupViewModel.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Net;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing YoutubeDownloader.Services;\nusing YoutubeDownloader.Utils;\nusing YoutubeDownloader.Utils.Extensions;\n\nnamespace YoutubeDownloader.ViewModels.Dialogs;\n\npublic class AuthSetupViewModel : DialogViewModelBase\n{\n    private readonly SettingsService _settingsService;\n    private readonly DisposableCollector _eventRoot = new();\n\n    public AuthSetupViewModel(\n        LocalizationManager localizationManager,\n        SettingsService settingsService\n    )\n    {\n        LocalizationManager = localizationManager;\n        _settingsService = settingsService;\n\n        _eventRoot.Add(\n            _settingsService.WatchProperty(\n                o => o.LastAuthCookies,\n                () =>\n                {\n                    OnPropertyChanged(nameof(Cookies));\n                    OnPropertyChanged(nameof(IsAuthenticated));\n                }\n            )\n        );\n    }\n\n    public LocalizationManager LocalizationManager { get; }\n\n    public IReadOnlyList<Cookie>? Cookies\n    {\n        get => _settingsService.LastAuthCookies;\n        set => _settingsService.LastAuthCookies = value;\n    }\n\n    public bool IsAuthenticated =>\n        Cookies?.Any() == true\n        &&\n        // None of the '__SECURE' cookies should be expired\n        Cookies\n            .Where(c => c.Name.StartsWith(\"__SECURE\", StringComparison.OrdinalIgnoreCase))\n            .All(c => !c.Expired && c.Expires.ToUniversalTime() > DateTime.UtcNow);\n\n    protected override void Dispose(bool disposing)\n    {\n        if (disposing)\n        {\n            _eventRoot.Dispose();\n        }\n\n        base.Dispose(disposing);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/Dialogs/DownloadMultipleSetupViewModel.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Collections.ObjectModel;\nusing System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing Avalonia;\nusing CommunityToolkit.Mvvm.ComponentModel;\nusing CommunityToolkit.Mvvm.Input;\nusing YoutubeDownloader.Core.Downloading;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing YoutubeDownloader.Services;\nusing YoutubeDownloader.Utils.Extensions;\nusing YoutubeDownloader.ViewModels.Components;\nusing YoutubeExplode.Videos;\nusing YoutubeExplode.Videos.Streams;\n\nnamespace YoutubeDownloader.ViewModels.Dialogs;\n\npublic partial class DownloadMultipleSetupViewModel(\n    ViewModelManager viewModelManager,\n    DialogManager dialogManager,\n    LocalizationManager localizationManager,\n    SettingsService settingsService\n) : DialogViewModelBase<IReadOnlyList<DownloadViewModel>>\n{\n    public LocalizationManager LocalizationManager { get; } = localizationManager;\n\n    [ObservableProperty]\n    public partial string? Title { get; set; }\n\n    [ObservableProperty]\n    public partial IReadOnlyList<IVideo>? AvailableVideos { get; set; }\n\n    [ObservableProperty]\n    public partial Container SelectedContainer { get; set; } = Container.Mp4;\n\n    [ObservableProperty]\n    public partial VideoQualityPreference SelectedVideoQualityPreference { get; set; } =\n        VideoQualityPreference.Highest;\n\n    public ObservableCollection<IVideo> SelectedVideos { get; } = [];\n\n    public IReadOnlyList<Container> AvailableContainers { get; } =\n    [Container.Mp4, Container.WebM, Container.Mp3, new(\"ogg\")];\n\n    public IReadOnlyList<VideoQualityPreference> AvailableVideoQualityPreferences { get; } =\n        // Without .AsEnumerable(), the below line throws a compile-time error starting with .NET SDK v9.0.200\n        Enum.GetValues<VideoQualityPreference>().AsEnumerable().Reverse().ToArray();\n\n    [RelayCommand]\n    private void Initialize()\n    {\n        SelectedContainer = settingsService.LastContainer;\n        SelectedVideoQualityPreference = settingsService.LastVideoQualityPreference;\n        SelectedVideos.CollectionChanged += (_, _) => ConfirmCommand.NotifyCanExecuteChanged();\n    }\n\n    [RelayCommand]\n    private async Task CopyTitleAsync()\n    {\n        if (Application.Current?.ApplicationLifetime?.TryGetTopLevel()?.Clipboard is { } clipboard)\n            await clipboard.SetTextAsync(Title);\n    }\n\n    private bool CanConfirm() => SelectedVideos.Any();\n\n    [RelayCommand(CanExecute = nameof(CanConfirm))]\n    private async Task ConfirmAsync()\n    {\n        var dirPath = await dialogManager.PromptDirectoryPathAsync();\n        if (string.IsNullOrWhiteSpace(dirPath))\n            return;\n\n        var downloads = new List<DownloadViewModel>();\n        for (var i = 0; i < SelectedVideos.Count; i++)\n        {\n            var video = SelectedVideos[i];\n\n            var baseFilePath = Path.Combine(\n                dirPath,\n                FileNameTemplate.Apply(\n                    settingsService.FileNameTemplate,\n                    video,\n                    SelectedContainer,\n                    (i + 1).ToString().PadLeft(SelectedVideos.Count.ToString().Length, '0')\n                )\n            );\n\n            if (settingsService.ShouldSkipExistingFiles && File.Exists(baseFilePath))\n                continue;\n\n            var filePath = Path.EnsureUniqueFilePath(baseFilePath);\n\n            // Download does not start immediately, so lock in the file path to avoid conflicts\n            Directory.CreateDirectoryForFile(filePath);\n            await File.WriteAllBytesAsync(filePath, []);\n\n            downloads.Add(\n                viewModelManager.CreateDownloadViewModel(\n                    video,\n                    new VideoDownloadPreference(SelectedContainer, SelectedVideoQualityPreference),\n                    filePath\n                )\n            );\n        }\n\n        settingsService.LastContainer = SelectedContainer;\n        settingsService.LastVideoQualityPreference = SelectedVideoQualityPreference;\n\n        Close(downloads);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/Dialogs/DownloadSingleSetupViewModel.cs",
    "content": "﻿using System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing Avalonia;\nusing Avalonia.Platform.Storage;\nusing CommunityToolkit.Mvvm.ComponentModel;\nusing CommunityToolkit.Mvvm.Input;\nusing YoutubeDownloader.Core.Downloading;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing YoutubeDownloader.Services;\nusing YoutubeDownloader.Utils.Extensions;\nusing YoutubeDownloader.ViewModels.Components;\nusing YoutubeExplode.Videos;\n\nnamespace YoutubeDownloader.ViewModels.Dialogs;\n\npublic partial class DownloadSingleSetupViewModel(\n    ViewModelManager viewModelManager,\n    DialogManager dialogManager,\n    LocalizationManager localizationManager,\n    SettingsService settingsService\n) : DialogViewModelBase<DownloadViewModel>\n{\n    public LocalizationManager LocalizationManager { get; } = localizationManager;\n\n    [ObservableProperty]\n    public partial IVideo? Video { get; set; }\n\n    [ObservableProperty]\n    public partial IReadOnlyList<VideoDownloadOption>? AvailableDownloadOptions { get; set; }\n\n    [ObservableProperty]\n    public partial VideoDownloadOption? SelectedDownloadOption { get; set; }\n\n    [RelayCommand]\n    private void Initialize()\n    {\n        SelectedDownloadOption = AvailableDownloadOptions?.FirstOrDefault(o =>\n            o.Container == settingsService.LastContainer\n        );\n    }\n\n    [RelayCommand]\n    private async Task CopyTitleAsync()\n    {\n        if (Application.Current?.ApplicationLifetime?.TryGetTopLevel()?.Clipboard is { } clipboard)\n            await clipboard.SetTextAsync(Video?.Title);\n    }\n\n    [RelayCommand]\n    private async Task ConfirmAsync()\n    {\n        if (Video is null || SelectedDownloadOption is null)\n            return;\n\n        var container = SelectedDownloadOption.Container;\n\n        var filePath = await dialogManager.PromptSaveFilePathAsync(\n            [\n                new FilePickerFileType($\"{container.Name} file\")\n                {\n                    Patterns = [$\"*.{container.Name}\"],\n                },\n            ],\n            FileNameTemplate.Apply(settingsService.FileNameTemplate, Video, container)\n        );\n\n        if (string.IsNullOrWhiteSpace(filePath))\n            return;\n\n        // Download does not start immediately, so lock in the file path to avoid conflicts\n        Directory.CreateDirectoryForFile(filePath);\n        await File.WriteAllBytesAsync(filePath, []);\n\n        settingsService.LastContainer = container;\n\n        Close(viewModelManager.CreateDownloadViewModel(Video, SelectedDownloadOption, filePath));\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/Dialogs/MessageBoxViewModel.cs",
    "content": "﻿using CommunityToolkit.Mvvm.ComponentModel;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\n\nnamespace YoutubeDownloader.ViewModels.Dialogs;\n\npublic partial class MessageBoxViewModel : DialogViewModelBase\n{\n    public MessageBoxViewModel(LocalizationManager localizationManager)\n    {\n        LocalizationManager = localizationManager;\n\n        DefaultButtonText = LocalizationManager.CloseButton;\n        CancelButtonText = LocalizationManager.CancelButton;\n    }\n\n    public LocalizationManager LocalizationManager { get; }\n\n    [ObservableProperty]\n    public partial string? Title { get; set; }\n\n    [ObservableProperty]\n    public partial string? Message { get; set; }\n\n    [ObservableProperty]\n    [NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))]\n    [NotifyPropertyChangedFor(nameof(ButtonsCount))]\n    public partial string? DefaultButtonText { get; set; }\n\n    [ObservableProperty]\n    [NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))]\n    [NotifyPropertyChangedFor(nameof(ButtonsCount))]\n    public partial string? CancelButtonText { get; set; }\n\n    public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText);\n\n    public bool IsCancelButtonVisible => !string.IsNullOrWhiteSpace(CancelButtonText);\n\n    public int ButtonsCount => (IsDefaultButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0);\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/Dialogs/SettingsViewModel.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Threading.Tasks;\nusing Avalonia.Platform.Storage;\nusing CommunityToolkit.Mvvm.Input;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing YoutubeDownloader.Services;\nusing YoutubeDownloader.Utils;\nusing YoutubeDownloader.Utils.Extensions;\n\nnamespace YoutubeDownloader.ViewModels.Dialogs;\n\npublic partial class SettingsViewModel : DialogViewModelBase\n{\n    private readonly DialogManager _dialogManager;\n    private readonly SettingsService _settingsService;\n\n    private readonly DisposableCollector _eventRoot = new();\n\n    public SettingsViewModel(\n        DialogManager dialogManager,\n        LocalizationManager localizationManager,\n        SettingsService settingsService\n    )\n    {\n        _dialogManager = dialogManager;\n        LocalizationManager = localizationManager;\n        _settingsService = settingsService;\n\n        _eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));\n    }\n\n    public LocalizationManager LocalizationManager { get; }\n\n    public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();\n\n    public ThemeVariant Theme\n    {\n        get => _settingsService.Theme;\n        set => _settingsService.Theme = value;\n    }\n\n    public IReadOnlyList<Language> AvailableLanguages { get; } = Enum.GetValues<Language>();\n\n    public Language Language\n    {\n        get => _settingsService.Language;\n        set => _settingsService.Language = value;\n    }\n\n    public bool IsAutoUpdateEnabled\n    {\n        get => _settingsService.IsAutoUpdateEnabled;\n        set => _settingsService.IsAutoUpdateEnabled = value;\n    }\n\n    public bool IsAuthPersisted\n    {\n        get => _settingsService.IsAuthPersisted;\n        set => _settingsService.IsAuthPersisted = value;\n    }\n\n    public string? FFmpegFilePath\n    {\n        get => _settingsService.FFmpegFilePath;\n        set => _settingsService.FFmpegFilePath = !string.IsNullOrWhiteSpace(value) ? value : null;\n    }\n\n    public bool ShouldInjectLanguageSpecificAudioStreams\n    {\n        get => _settingsService.ShouldInjectLanguageSpecificAudioStreams;\n        set => _settingsService.ShouldInjectLanguageSpecificAudioStreams = value;\n    }\n\n    public bool ShouldInjectSubtitles\n    {\n        get => _settingsService.ShouldInjectSubtitles;\n        set => _settingsService.ShouldInjectSubtitles = value;\n    }\n\n    public bool ShouldInjectTags\n    {\n        get => _settingsService.ShouldInjectTags;\n        set => _settingsService.ShouldInjectTags = value;\n    }\n\n    public bool ShouldSkipExistingFiles\n    {\n        get => _settingsService.ShouldSkipExistingFiles;\n        set => _settingsService.ShouldSkipExistingFiles = value;\n    }\n\n    public string FileNameTemplate\n    {\n        get => _settingsService.FileNameTemplate;\n        set => _settingsService.FileNameTemplate = value;\n    }\n\n    public int ParallelLimit\n    {\n        get => _settingsService.ParallelLimit;\n        set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10);\n    }\n\n    [RelayCommand]\n    private async Task BrowseFFmpegFilePathAsync()\n    {\n        var fileTypes = OperatingSystem.IsWindows()\n            ? new[]\n            {\n                new FilePickerFileType(\"FFmpeg executable\") { Patterns = [\"*.exe\"] },\n                FilePickerFileTypes.All,\n            }\n            : null;\n\n        var filePath = await _dialogManager.PromptOpenFilePathAsync(fileTypes);\n\n        if (string.IsNullOrWhiteSpace(filePath))\n            return;\n\n        FFmpegFilePath = filePath;\n    }\n\n    [RelayCommand]\n    private void ResetFFmpegFilePath() => FFmpegFilePath = null;\n\n    protected override void Dispose(bool disposing)\n    {\n        if (disposing)\n        {\n            _eventRoot.Dispose();\n        }\n\n        base.Dispose(disposing);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/ViewModels/MainViewModel.cs",
    "content": "﻿using System;\nusing System.Diagnostics;\nusing System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing Avalonia;\nusing CommunityToolkit.Mvvm.Input;\nusing YoutubeDownloader.Core.Downloading;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.Localization;\nusing YoutubeDownloader.Services;\nusing YoutubeDownloader.Utils.Extensions;\nusing YoutubeDownloader.ViewModels.Components;\n\nnamespace YoutubeDownloader.ViewModels;\n\npublic partial class MainViewModel(\n    ViewModelManager viewModelManager,\n    DialogManager dialogManager,\n    SnackbarManager snackbarManager,\n    LocalizationManager localizationManager,\n    SettingsService settingsService,\n    UpdateService updateService\n) : ViewModelBase\n{\n    public string Title { get; } = $\"{Program.Name} v{Program.VersionString}\";\n\n    public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel();\n\n    private async Task ShowUkraineSupportMessageAsync()\n    {\n        if (!settingsService.IsUkraineSupportMessageEnabled)\n            return;\n\n        var dialog = viewModelManager.CreateMessageBoxViewModel(\n            localizationManager.UkraineSupportTitle,\n            localizationManager.UkraineSupportMessage,\n            localizationManager.LearnMoreButton,\n            localizationManager.CloseButton\n        );\n\n        // Disable this message in the future\n        settingsService.IsUkraineSupportMessageEnabled = false;\n        settingsService.Save();\n\n        if (await dialogManager.ShowDialogAsync(dialog) == true)\n            Process.StartShellExecute(\"https://tyrrrz.me/ukraine?source=youtubedownloader\");\n    }\n\n    private async Task ShowDevelopmentBuildMessageAsync()\n    {\n        if (!Program.IsDevelopmentBuild)\n            return;\n\n        // If debugging, the user is likely a developer\n        if (Debugger.IsAttached)\n            return;\n\n        var dialog = viewModelManager.CreateMessageBoxViewModel(\n            localizationManager.UnstableBuildTitle,\n            string.Format(localizationManager.UnstableBuildMessage, Program.Name),\n            localizationManager.SeeReleasesButton,\n            localizationManager.CloseButton\n        );\n\n        if (await dialogManager.ShowDialogAsync(dialog) == true)\n            Process.StartShellExecute(Program.ProjectReleasesUrl);\n    }\n\n    private async Task ShowFFmpegMissingMessageAsync()\n    {\n        if (settingsService.FFmpegFilePath is { } ffmpegFilePath)\n        {\n            // Explicit path set — only show the dialog if the file is missing\n            if (File.Exists(ffmpegFilePath))\n                return;\n\n            var dialog = viewModelManager.CreateMessageBoxViewModel(\n                localizationManager.FFmpegMissingTitle,\n                string.Format(localizationManager.FFmpegPathMissingMessage, ffmpegFilePath),\n                localizationManager.SettingsButton,\n                localizationManager.CloseButton\n            );\n\n            if (await dialogManager.ShowDialogAsync(dialog) == true)\n                await dialogManager.ShowDialogAsync(viewModelManager.CreateSettingsViewModel());\n        }\n        else\n        {\n            // No explicit path — fall back to auto-detection check\n            if (FFmpeg.TryGetCliFilePath() is not null)\n                return;\n\n            var dialog = viewModelManager.CreateMessageBoxViewModel(\n                localizationManager.FFmpegMissingTitle,\n                $\"\"\"\n                {string.Format(localizationManager.FFmpegMissingMessage, Program.Name)}\n\n                ――――――――――――――――――――――――――――――――――――――――――\n\n                {string.Format(localizationManager.FFmpegMissingSearchedLabel, FFmpeg.CliFileName)}\n                {string.Join(\n                    Environment.NewLine,\n                    FFmpeg.GetProbeDirectoryPaths().Distinct(StringComparer.Ordinal).Select(d =>\n                        $\"- {d}\"\n                    )\n                )}\n                \"\"\",\n                localizationManager.DownloadButton,\n                localizationManager.CloseButton\n            );\n\n            if (await dialogManager.ShowDialogAsync(dialog) == true)\n                Process.StartShellExecute(\"https://ffmpeg.org/download.html\");\n        }\n\n        if (Application.Current?.ApplicationLifetime?.TryShutdown(3) != true)\n            Environment.Exit(3);\n    }\n\n    private async Task CheckForUpdatesAsync()\n    {\n        try\n        {\n            var updateVersion = await updateService.CheckForUpdatesAsync();\n            if (updateVersion is null)\n                return;\n\n            snackbarManager.Notify(\n                string.Format(\n                    localizationManager.UpdateDownloadingMessage,\n                    Program.Name,\n                    updateVersion\n                )\n            );\n            await updateService.PrepareUpdateAsync(updateVersion);\n\n            snackbarManager.Notify(\n                localizationManager.UpdateReadyMessage,\n                localizationManager.UpdateInstallNowButton,\n                () =>\n                {\n                    updateService.FinalizeUpdate(true);\n\n                    if (Application.Current?.ApplicationLifetime?.TryShutdown(2) != true)\n                        Environment.Exit(2);\n                }\n            );\n        }\n        catch\n        {\n            // Failure to update shouldn't crash the application\n            snackbarManager.Notify(localizationManager.UpdateFailedMessage);\n        }\n    }\n\n    [RelayCommand]\n    private async Task InitializeAsync()\n    {\n        await ShowUkraineSupportMessageAsync();\n        await ShowDevelopmentBuildMessageAsync();\n        await ShowFFmpegMissingMessageAsync();\n        await CheckForUpdatesAsync();\n    }\n\n    protected override void Dispose(bool disposing)\n    {\n        if (disposing)\n        {\n            // Save settings\n            settingsService.Save();\n\n            // Finalize pending updates\n            updateService.FinalizeUpdate(false);\n        }\n\n        base.Dispose(disposing);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Views/Components/DashboardView.axaml",
    "content": "<UserControl\n    x:Class=\"YoutubeDownloader.Views.Components.DashboardView\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\"\n    xmlns:asyncImageLoader=\"clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia\"\n    xmlns:components=\"clr-namespace:YoutubeDownloader.ViewModels.Components\"\n    xmlns:converters=\"clr-namespace:YoutubeDownloader.Converters\"\n    xmlns:materialIcons=\"clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia\"\n    xmlns:materialStyles=\"clr-namespace:Material.Styles.Controls;assembly=Material.Styles\"\n    x:Name=\"UserControl\"\n    x:DataType=\"components:DashboardViewModel\"\n    Loaded=\"UserControl_OnLoaded\">\n    <DockPanel>\n        <!--  Header  -->\n        <StackPanel\n            Background=\"{DynamicResource MaterialDarkBackgroundBrush}\"\n            DockPanel.Dock=\"Top\"\n            Orientation=\"Vertical\">\n            <Grid Margin=\"12,12,8,12\" ColumnDefinitions=\"*,Auto,Auto\">\n                <!--  Query  -->\n                <materialStyles:Card Grid.Column=\"0\">\n                    <TextBox\n                        x:Name=\"QueryTextBox\"\n                        AcceptsReturn=\"True\"\n                        FontSize=\"16\"\n                        MaxLines=\"4\"\n                        ScrollViewer.HorizontalScrollBarVisibility=\"Hidden\"\n                        Text=\"{Binding Query}\"\n                        Theme=\"{DynamicResource SoloTextBox}\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.QueryTooltip}\"\n                        Watermark=\"{Binding LocalizationManager.QueryWatermark}\">\n                        <TextBox.InnerLeftContent>\n                            <materialIcons:MaterialIcon\n                                Width=\"24\"\n                                Height=\"24\"\n                                Margin=\"4,0,8,0\"\n                                Kind=\"Search\" />\n                        </TextBox.InnerLeftContent>\n                        <TextBox.InnerRightContent>\n                            <Button\n                                x:Name=\"ProcessQueryButton\"\n                                Margin=\"8,0,0,0\"\n                                Padding=\"4\"\n                                Command=\"{Binding ProcessQueryCommand}\"\n                                IsDefault=\"True\"\n                                Theme=\"{DynamicResource MaterialFlatButton}\"\n                                ToolTip.Tip=\"{Binding LocalizationManager.ProcessQueryTooltip}\">\n                                <materialIcons:MaterialIcon\n                                    Width=\"24\"\n                                    Height=\"24\"\n                                    Kind=\"ArrowRight\" />\n                            </Button>\n                        </TextBox.InnerRightContent>\n                    </TextBox>\n                </materialStyles:Card>\n\n                <!--  Auth button  -->\n                <Button\n                    Grid.Column=\"1\"\n                    Margin=\"8,0,0,0\"\n                    Padding=\"8\"\n                    VerticalAlignment=\"Center\"\n                    Command=\"{Binding ShowAuthSetupCommand}\"\n                    Foreground=\"{DynamicResource MaterialDarkForegroundBrush}\"\n                    IsVisible=\"{OnPlatform False,\n                                           Windows=True}\"\n                    Theme=\"{DynamicResource MaterialFlatButton}\"\n                    ToolTip.Tip=\"{Binding LocalizationManager.AuthTooltip}\">\n                    <materialIcons:MaterialIcon\n                        Width=\"24\"\n                        Height=\"24\"\n                        Kind=\"AccountKey\" />\n                </Button>\n\n                <!--  Settings button  -->\n                <Button\n                    Grid.Column=\"2\"\n                    Margin=\"8,0,0,0\"\n                    Padding=\"8\"\n                    VerticalAlignment=\"Center\"\n                    Command=\"{Binding ShowSettingsCommand}\"\n                    Foreground=\"{DynamicResource MaterialDarkForegroundBrush}\"\n                    Theme=\"{DynamicResource MaterialFlatButton}\"\n                    ToolTip.Tip=\"{Binding LocalizationManager.SettingsTooltip}\">\n                    <materialIcons:MaterialIcon\n                        Width=\"24\"\n                        Height=\"24\"\n                        Kind=\"Settings\" />\n                </Button>\n            </Grid>\n\n            <!--  Progress  -->\n            <ProgressBar\n                Height=\"2\"\n                Background=\"Transparent\"\n                IsIndeterminate=\"{Binding IsProgressIndeterminate}\"\n                Value=\"{Binding Progress.Current.Fraction, Mode=OneWay}\" />\n        </StackPanel>\n\n        <!--  Body  -->\n        <Panel Background=\"{DynamicResource MaterialCardBackgroundBrush}\" DockPanel.Dock=\"Bottom\">\n            <!--  Placeholder  -->\n            <StackPanel\n                Margin=\"8,32,8,8\"\n                HorizontalAlignment=\"Center\"\n                IsVisible=\"{Binding !Downloads.Count}\"\n                Orientation=\"Vertical\">\n                <materialIcons:MaterialIcon\n                    Width=\"256\"\n                    Height=\"256\"\n                    HorizontalAlignment=\"Center\"\n                    Foreground=\"{DynamicResource MaterialDividerBrush}\"\n                    Kind=\"Youtube\" />\n\n                <TextBlock\n                    HorizontalAlignment=\"Center\"\n                    FontSize=\"18\"\n                    FontWeight=\"Light\"\n                    Inlines=\"{Binding LocalizationManager.DashboardPlaceholder, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}\"\n                    LineSpacing=\"8\"\n                    TextAlignment=\"Center\"\n                    TextWrapping=\"Wrap\" />\n            </StackPanel>\n\n            <!--  Downloads  -->\n            <DataGrid\n                ColumnWidth=\"Auto\"\n                HorizontalScrollBarVisibility=\"Disabled\"\n                IsVisible=\"{Binding !!Downloads.Count}\"\n                ItemsSource=\"{Binding Downloads}\"\n                VerticalScrollBarVisibility=\"Visible\">\n                <DataGrid.ContextMenu>\n                    <ContextMenu>\n                        <MenuItem Command=\"{Binding RemoveSuccessfulDownloadsCommand}\" Header=\"{Binding LocalizationManager.ContextMenuRemoveSuccessful}\" />\n                        <MenuItem Command=\"{Binding RemoveInactiveDownloadsCommand}\" Header=\"{Binding LocalizationManager.ContextMenuRemoveInactive}\" />\n                        <Separator />\n                        <MenuItem Command=\"{Binding RestartFailedDownloadsCommand}\" Header=\"{Binding LocalizationManager.ContextMenuRestartFailed}\" />\n                        <Separator />\n                        <MenuItem Command=\"{Binding CancelAllDownloadsCommand}\" Header=\"{Binding LocalizationManager.ContextMenuCancelAll}\" />\n                    </ContextMenu>\n                </DataGrid.ContextMenu>\n                <DataGrid.Columns>\n                    <!--  Thumbnail  -->\n                    <DataGridTemplateColumn>\n                        <DataGridTemplateColumn.CellTemplate>\n                            <DataTemplate>\n                                <Image\n                                    Width=\"48\"\n                                    Height=\"48\"\n                                    asyncImageLoader:ImageLoader.Source=\"{Binding Video, Converter={x:Static converters:VideoToLowestQualityThumbnailUrlStringConverter.Instance}}\" />\n                            </DataTemplate>\n                        </DataGridTemplateColumn.CellTemplate>\n                    </DataGridTemplateColumn>\n\n                    <!--  File name  -->\n                    <DataGridTemplateColumn\n                        Width=\"*\"\n                        Header=\"{Binding #UserControl.DataContext.LocalizationManager.DownloadsFileColumnHeader}\"\n                        SortMemberPath=\"FileName\">\n                        <DataGridTemplateColumn.CellTemplate>\n                            <DataTemplate>\n                                <TextBlock\n                                    FontSize=\"14\"\n                                    Foreground=\"{DynamicResource MaterialBodyBrush}\"\n                                    Text=\"{Binding FileName}\"\n                                    TextTrimming=\"CharacterEllipsis\"\n                                    ToolTip.Tip=\"{Binding FileName}\" />\n                            </DataTemplate>\n                        </DataGridTemplateColumn.CellTemplate>\n                    </DataGridTemplateColumn>\n\n                    <!--  Status  -->\n                    <DataGridTemplateColumn\n                        MinWidth=\"100\"\n                        Header=\"{Binding #UserControl.DataContext.LocalizationManager.DownloadsStatusColumnHeader}\"\n                        SortMemberPath=\"Progress.Current.Fraction\">\n                        <DataGridTemplateColumn.CellTemplate>\n                            <DataTemplate>\n                                <Grid ColumnDefinitions=\"Auto,Auto\">\n                                    <!--  Progress  -->\n                                    <ProgressBar\n                                        Grid.Column=\"0\"\n                                        Margin=\"0,0,6,0\"\n                                        IsIndeterminate=\"{Binding IsProgressIndeterminate}\"\n                                        IsVisible=\"{Binding Status, Converter={x:Static converters:EqualityConverter.IsEqual}, ConverterParameter={x:Static components:DownloadStatus.Started}}\"\n                                        Theme=\"{DynamicResource MaterialCircularProgressBar}\"\n                                        Value=\"{Binding Progress.Current.Fraction, Mode=OneWay}\" />\n\n                                    <!--  Status  -->\n                                    <TextBlock\n                                        x:Name=\"StatusTextBlock\"\n                                        Grid.Column=\"1\"\n                                        Classes.canceled=\"{Binding Status, Converter={x:Static converters:EqualityConverter.IsEqual}, ConverterParameter={x:Static components:DownloadStatus.Canceled}}\"\n                                        Classes.completed=\"{Binding Status, Converter={x:Static converters:EqualityConverter.IsEqual}, ConverterParameter={x:Static components:DownloadStatus.Completed}}\"\n                                        Classes.enqueued=\"{Binding Status, Converter={x:Static converters:EqualityConverter.IsEqual}, ConverterParameter={x:Static components:DownloadStatus.Enqueued}}\"\n                                        Classes.failed=\"{Binding Status, Converter={x:Static converters:EqualityConverter.IsEqual}, ConverterParameter={x:Static components:DownloadStatus.Failed}}\"\n                                        Classes.started=\"{Binding Status, Converter={x:Static converters:EqualityConverter.IsEqual}, ConverterParameter={x:Static components:DownloadStatus.Started}}\"\n                                        PointerReleased=\"StatusTextBlock_OnPointerReleased\"\n                                        TextTrimming=\"CharacterEllipsis\">\n                                        <TextBlock.Resources>\n                                            <ResourceDictionary>\n                                                <ResourceDictionary.ThemeDictionaries>\n                                                    <ResourceDictionary x:Key=\"Default\">\n                                                        <SolidColorBrush x:Key=\"SuccessBrush\" Color=\"DarkGreen\" />\n                                                        <SolidColorBrush x:Key=\"CanceledBrush\" Color=\"DarkOrange\" />\n                                                        <SolidColorBrush x:Key=\"FailedBrush\" Color=\"DarkRed\" />\n                                                    </ResourceDictionary>\n                                                    <ResourceDictionary x:Key=\"Dark\">\n                                                        <SolidColorBrush x:Key=\"SuccessBrush\" Color=\"LightGreen\" />\n                                                        <SolidColorBrush x:Key=\"CanceledBrush\" Color=\"Orange\" />\n                                                        <SolidColorBrush x:Key=\"FailedBrush\" Color=\"OrangeRed\" />\n                                                    </ResourceDictionary>\n                                                </ResourceDictionary.ThemeDictionaries>\n                                            </ResourceDictionary>\n                                        </TextBlock.Resources>\n                                        <TextBlock.Styles>\n                                            <Style Selector=\"TextBlock\">\n                                                <Style Selector=\"^.enqueued\">\n                                                    <Setter Property=\"Opacity\" Value=\"0.7\" />\n                                                    <Setter Property=\"Text\" Value=\"{Binding LocalizationManager.DownloadStatusEnqueued}\" />\n                                                </Style>\n                                                <Style Selector=\"^.started\">\n                                                    <Setter Property=\"Text\" Value=\"{Binding Progress.Current}\" />\n                                                </Style>\n                                                <Style Selector=\"^.completed\">\n                                                    <Setter Property=\"Foreground\" Value=\"{DynamicResource SuccessBrush}\" />\n                                                    <Setter Property=\"Text\" Value=\"{Binding LocalizationManager.DownloadStatusCompleted}\" />\n                                                </Style>\n                                                <Style Selector=\"^.canceled\">\n                                                    <Setter Property=\"Foreground\" Value=\"{DynamicResource CanceledBrush}\" />\n                                                    <Setter Property=\"Text\" Value=\"{Binding LocalizationManager.DownloadStatusCanceled}\" />\n                                                </Style>\n                                                <Style Selector=\"^.failed\">\n                                                    <Setter Property=\"Foreground\" Value=\"{DynamicResource FailedBrush}\" />\n                                                    <Setter Property=\"Text\" Value=\"{Binding LocalizationManager.DownloadStatusFailed}\" />\n                                                    <Setter Property=\"ToolTip.Tip\">\n                                                        <Template>\n                                                            <TextBlock>\n                                                                <Run FontWeight=\"SemiBold\" Text=\"{Binding LocalizationManager.ClickToCopyErrorTooltip}\" />\n                                                                <LineBreak />\n                                                                <LineBreak />\n                                                                <Run Text=\"{Binding ErrorMessage}\" />\n                                                            </TextBlock>\n                                                        </Template>\n                                                    </Setter>\n                                                    <Setter Property=\"Cursor\" Value=\"Hand\" />\n                                                </Style>\n                                            </Style>\n                                        </TextBlock.Styles>\n                                    </TextBlock>\n                                </Grid>\n                            </DataTemplate>\n                        </DataGridTemplateColumn.CellTemplate>\n                    </DataGridTemplateColumn>\n\n                    <!--  Buttons  -->\n                    <DataGridTemplateColumn MinWidth=\"100\">\n                        <DataGridTemplateColumn.CellTemplate>\n                            <DataTemplate>\n                                <StackPanel HorizontalAlignment=\"Right\" Orientation=\"Horizontal\">\n                                    <!--  Show file  -->\n                                    <Button\n                                        Padding=\"4\"\n                                        VerticalAlignment=\"Center\"\n                                        Command=\"{Binding ShowFileCommand}\"\n                                        IsVisible=\"{Binding $self.IsEffectivelyEnabled}\"\n                                        Theme=\"{DynamicResource MaterialFlatButton}\"\n                                        ToolTip.Tip=\"{Binding LocalizationManager.ShowFileTooltip}\">\n                                        <materialIcons:MaterialIcon\n                                            Width=\"24\"\n                                            Height=\"24\"\n                                            Kind=\"FileFindOutline\" />\n                                    </Button>\n\n                                    <!--  Open file  -->\n                                    <Button\n                                        Padding=\"4\"\n                                        VerticalAlignment=\"Center\"\n                                        Command=\"{Binding OpenFileCommand}\"\n                                        IsVisible=\"{Binding $self.IsEffectivelyEnabled}\"\n                                        Theme=\"{DynamicResource MaterialFlatButton}\"\n                                        ToolTip.Tip=\"{Binding LocalizationManager.PlayTooltip}\">\n                                        <materialIcons:MaterialIcon\n                                            Width=\"24\"\n                                            Height=\"24\"\n                                            Kind=\"PlayCircleOutline\" />\n                                    </Button>\n\n                                    <!--  Cancel download  -->\n                                    <Button\n                                        Padding=\"4\"\n                                        VerticalAlignment=\"Center\"\n                                        Command=\"{Binding CancelCommand}\"\n                                        IsVisible=\"{Binding $self.IsEffectivelyEnabled}\"\n                                        Theme=\"{DynamicResource MaterialFlatButton}\"\n                                        ToolTip.Tip=\"{Binding LocalizationManager.CancelDownloadTooltip}\">\n                                        <materialIcons:MaterialIcon\n                                            Width=\"24\"\n                                            Height=\"24\"\n                                            Kind=\"CloseCircleOutline\" />\n                                    </Button>\n\n                                    <!--  Restart download  -->\n                                    <Button\n                                        Padding=\"4\"\n                                        VerticalAlignment=\"Center\"\n                                        Command=\"{Binding $parent[UserControl].((components:DashboardViewModel)DataContext).RestartDownloadCommand}\"\n                                        CommandParameter=\"{Binding}\"\n                                        IsVisible=\"{Binding IsCanceledOrFailed}\"\n                                        Theme=\"{DynamicResource MaterialFlatButton}\"\n                                        ToolTip.Tip=\"{Binding LocalizationManager.RestartDownloadTooltip}\">\n                                        <materialIcons:MaterialIcon\n                                            Width=\"24\"\n                                            Height=\"24\"\n                                            Kind=\"Restart\" />\n                                    </Button>\n                                </StackPanel>\n                            </DataTemplate>\n                        </DataGridTemplateColumn.CellTemplate>\n                    </DataGridTemplateColumn>\n                </DataGrid.Columns>\n            </DataGrid>\n        </Panel>\n    </DockPanel>\n</UserControl>\n"
  },
  {
    "path": "YoutubeDownloader/Views/Components/DashboardView.axaml.cs",
    "content": "using Avalonia;\nusing Avalonia.Input;\nusing Avalonia.Interactivity;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.ViewModels.Components;\n\nnamespace YoutubeDownloader.Views.Components;\n\npublic partial class DashboardView : UserControl<DashboardViewModel>\n{\n    public DashboardView()\n    {\n        InitializeComponent();\n\n        // Bind the event with the tunnel strategy to handle keys that take part in writing text\n        QueryTextBox.AddHandler(KeyDownEvent, QueryTextBox_OnKeyDown, RoutingStrategies.Tunnel);\n    }\n\n    private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) => QueryTextBox.Focus();\n\n    private void QueryTextBox_OnKeyDown(object? sender, KeyEventArgs args)\n    {\n        // When pressing Enter without Shift, execute the default button command\n        // instead of adding a new line.\n        if (args.Key == Key.Enter && args.KeyModifiers != KeyModifiers.Shift)\n        {\n            args.Handled = true;\n            ProcessQueryButton.Command?.Execute(ProcessQueryButton.CommandParameter);\n        }\n    }\n\n    private void StatusTextBlock_OnPointerReleased(object sender, PointerReleasedEventArgs args)\n    {\n        if (sender is IDataContextProvider { DataContext: DownloadViewModel dataContext })\n            dataContext.CopyErrorMessageCommand.Execute(null);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/AuthSetupView.axaml",
    "content": "<UserControl\n    x:Class=\"YoutubeDownloader.Views.Dialogs.AuthSetupView\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\"\n    xmlns:dialogs=\"clr-namespace:YoutubeDownloader.ViewModels.Dialogs\"\n    xmlns:materialIcons=\"clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia\"\n    Height=\"450\"\n    MinWidth=\"450\"\n    x:DataType=\"dialogs:AuthSetupViewModel\">\n    <Grid RowDefinitions=\"Auto,*,Auto\">\n        <!--  Title  -->\n        <TextBlock\n            Grid.Row=\"0\"\n            Margin=\"16\"\n            FontSize=\"19\"\n            FontWeight=\"Light\"\n            Text=\"{Binding LocalizationManager.AuthenticationTitle}\" />\n\n        <!--  Content  -->\n        <Border\n            Grid.Row=\"1\"\n            BorderBrush=\"{DynamicResource MaterialDividerBrush}\"\n            BorderThickness=\"0,1\">\n            <Panel>\n                <!--  Current auth status  -->\n                <StackPanel\n                    Margin=\"16\"\n                    HorizontalAlignment=\"Center\"\n                    VerticalAlignment=\"Center\"\n                    IsVisible=\"{Binding IsAuthenticated}\"\n                    Orientation=\"Vertical\">\n                    <materialIcons:MaterialIcon\n                        Width=\"196\"\n                        Height=\"196\"\n                        HorizontalAlignment=\"Center\"\n                        Foreground=\"{DynamicResource MaterialDividerBrush}\"\n                        Kind=\"AccountCheck\" />\n\n                    <TextBlock\n                        HorizontalAlignment=\"Center\"\n                        FontSize=\"18\"\n                        TextAlignment=\"Center\"\n                        TextWrapping=\"Wrap\">\n                        <Run FontWeight=\"Light\" Text=\"{Binding LocalizationManager.AuthenticatedText}\" />\n                    </TextBlock>\n\n                    <!--  Log out  -->\n                    <Button\n                        x:Name=\"LogOutButton\"\n                        Margin=\"16\"\n                        HorizontalAlignment=\"Center\"\n                        Click=\"LogOutButton_OnClick\"\n                        Content=\"{Binding LocalizationManager.LogOutButton}\"\n                        FontSize=\"18\"\n                        Foreground=\"{DynamicResource MaterialSecondaryMidBrush}\"\n                        Theme=\"{DynamicResource MaterialFlatButton}\" />\n                </StackPanel>\n\n                <!--  Placeholder  -->\n                <TextBlock\n                    Margin=\"16\"\n                    HorizontalAlignment=\"Center\"\n                    VerticalAlignment=\"Center\"\n                    FontSize=\"18\"\n                    IsVisible=\"{Binding !IsAuthenticated}\"\n                    Text=\"{Binding LocalizationManager.LoadingText}\" />\n\n                <!--  Browser  -->\n                <WebView\n                    x:Name=\"WebBrowser\"\n                    IsVisible=\"{Binding !IsAuthenticated}\"\n                    Loaded=\"WebBrowser_OnLoaded\"\n                    NavigationStarting=\"WebBrowser_OnNavigationStarting\"\n                    WebViewCreated=\"WebBrowser_OnWebViewCreated\" />\n            </Panel>\n        </Border>\n\n        <!--  Close button  -->\n        <Button\n            Grid.Row=\"2\"\n            Margin=\"16\"\n            HorizontalAlignment=\"Stretch\"\n            Command=\"{Binding CloseCommand}\"\n            Content=\"{Binding LocalizationManager.CloseButton}\"\n            IsCancel=\"True\"\n            IsDefault=\"True\"\n            Theme=\"{DynamicResource MaterialOutlineButton}\" />\n    </Grid>\n</UserControl>\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/AuthSetupView.axaml.cs",
    "content": "﻿using System;\nusing System.Linq;\nusing Avalonia.Interactivity;\nusing Avalonia.WebView.Windows.Core;\nusing Microsoft.Web.WebView2.Core;\nusing WebViewCore.Events;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.ViewModels.Dialogs;\n\nnamespace YoutubeDownloader.Views.Dialogs;\n\npublic partial class AuthSetupView : UserControl<AuthSetupViewModel>\n{\n    private const string HomePageUrl = \"https://www.youtube.com\";\n    private static readonly string LoginPageUrl =\n        $\"https://accounts.google.com/ServiceLogin?continue={Uri.EscapeDataString(HomePageUrl)}\";\n\n    private CoreWebView2? _coreWebView2;\n\n    public AuthSetupView() => InitializeComponent();\n\n    private void NavigateToLoginPage() => WebBrowser.Url = new Uri(LoginPageUrl);\n\n    private void LogOutButton_OnClick(object sender, RoutedEventArgs args)\n    {\n        DataContext.Cookies = null;\n        NavigateToLoginPage();\n    }\n\n    private void WebBrowser_OnLoaded(object sender, RoutedEventArgs args) => NavigateToLoginPage();\n\n    private void WebBrowser_OnWebViewCreated(object sender, WebViewCreatedEventArgs args)\n    {\n        if (!args.IsSucceed)\n            return;\n\n        var platformWebView = WebBrowser.PlatformWebView as WebView2Core;\n        var coreWebView2 = platformWebView?.CoreWebView2;\n\n        if (coreWebView2 is null)\n            return;\n\n        coreWebView2.Settings.AreDefaultContextMenusEnabled = false;\n        coreWebView2.Settings.AreDevToolsEnabled = false;\n        coreWebView2.Settings.IsGeneralAutofillEnabled = false;\n        coreWebView2.Settings.IsPasswordAutosaveEnabled = false;\n        coreWebView2.Settings.IsStatusBarEnabled = false;\n        coreWebView2.Settings.IsSwipeNavigationEnabled = false;\n\n        _coreWebView2 = coreWebView2;\n    }\n\n    private async void WebBrowser_OnNavigationStarting(\n        object? sender,\n        WebViewUrlLoadingEventArg args\n    )\n    {\n        if (_coreWebView2 is null)\n            return;\n\n        // Reset existing browser cookies if the user is attempting to log in (again)\n        if (string.Equals(args.Url?.AbsoluteUri, LoginPageUrl, StringComparison.OrdinalIgnoreCase))\n            _coreWebView2.CookieManager.DeleteAllCookies();\n\n        // Extract the cookies after being redirected to the home page (i.e. after logging in)\n        if (\n            args.Url?.AbsoluteUri.StartsWith(HomePageUrl, StringComparison.OrdinalIgnoreCase)\n            == true\n        )\n        {\n            var cookies = await _coreWebView2!.CookieManager.GetCookiesAsync(args.Url.AbsoluteUri);\n            DataContext.Cookies = cookies.Select(c => c.ToSystemNetCookie()).ToArray();\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/DownloadMultipleSetupView.axaml",
    "content": "<UserControl\n    x:Class=\"YoutubeDownloader.Views.Dialogs.DownloadMultipleSetupView\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\"\n    xmlns:asyncImageLoader=\"clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia\"\n    xmlns:converters=\"clr-namespace:YoutubeDownloader.Converters\"\n    xmlns:dialogs=\"clr-namespace:YoutubeDownloader.ViewModels.Dialogs\"\n    xmlns:materialAssists=\"clr-namespace:Material.Styles.Assists;assembly=Material.Styles\"\n    xmlns:materialIcons=\"clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia\"\n    x:Name=\"UserControl\"\n    Width=\"500\"\n    x:DataType=\"dialogs:DownloadMultipleSetupViewModel\"\n    Loaded=\"UserControl_OnLoaded\">\n    <Grid RowDefinitions=\"Auto,*,Auto,Auto\">\n        <!--  Title  -->\n        <TextBlock\n            Grid.Row=\"0\"\n            Margin=\"16\"\n            FontSize=\"19\"\n            Text=\"{Binding Title}\"\n            TextTrimming=\"CharacterEllipsis\">\n            <TextBlock.ContextMenu>\n                <ContextMenu>\n                    <MenuItem Command=\"{Binding CopyTitleCommand}\" Header=\"{Binding LocalizationManager.CopyMenuItem}\" />\n                </ContextMenu>\n            </TextBlock.ContextMenu>\n        </TextBlock>\n\n        <!--  Videos  -->\n        <Border\n            Grid.Row=\"1\"\n            BorderBrush=\"{DynamicResource MaterialDividerBrush}\"\n            BorderThickness=\"0,1\">\n            <ListBox\n                ItemsSource=\"{Binding AvailableVideos}\"\n                ScrollViewer.HorizontalScrollBarVisibility=\"Disabled\"\n                SelectedItems=\"{Binding SelectedVideos}\"\n                SelectionMode=\"Multiple,Toggle\">\n                <ListBox.Styles>\n                    <Style Selector=\"ListBox\">\n                        <Style Selector=\"^ ListBoxItem\">\n                            <Setter Property=\"Padding\" Value=\"8\" />\n                        </Style>\n                    </Style>\n                </ListBox.Styles>\n                <ListBox.ItemTemplate>\n                    <DataTemplate>\n                        <Grid Margin=\"0,0,8,0\" ColumnDefinitions=\"Auto,*,Auto\">\n                            <!--  Thumbnail  -->\n                            <Image\n                                Grid.Column=\"0\"\n                                Width=\"48\"\n                                Height=\"48\"\n                                asyncImageLoader:ImageLoader.Source=\"{Binding Converter={x:Static converters:VideoToLowestQualityThumbnailUrlStringConverter.Instance}}\" />\n\n                            <!--  Info  -->\n                            <StackPanel\n                                Grid.Column=\"1\"\n                                Margin=\"8,0,0,0\"\n                                Orientation=\"Vertical\">\n                                <!--  Title  -->\n                                <TextBlock\n                                    FontSize=\"16\"\n                                    Text=\"{Binding Title}\"\n                                    TextTrimming=\"CharacterEllipsis\"\n                                    ToolTip.Tip=\"{Binding Title}\" />\n\n                                <StackPanel Margin=\"0,8,0,0\" Orientation=\"Horizontal\">\n                                    <!--  Author  -->\n                                    <StackPanel Orientation=\"Horizontal\">\n                                        <materialIcons:MaterialIcon\n                                            Width=\"16\"\n                                            Height=\"16\"\n                                            BorderThickness=\"1\"\n                                            Kind=\"UserOutline\" />\n                                        <TextBlock\n                                            Margin=\"3,0,0,0\"\n                                            FontWeight=\"Light\"\n                                            Text=\"{Binding Author.Title}\"\n                                            TextTrimming=\"CharacterEllipsis\" />\n                                    </StackPanel>\n\n                                    <!--  Duration  -->\n                                    <StackPanel Margin=\"8,0,0,0\" Orientation=\"Horizontal\">\n                                        <materialIcons:MaterialIcon\n                                            Width=\"16\"\n                                            Height=\"16\"\n                                            BorderThickness=\"1\"\n                                            Kind=\"ClockOutline\" />\n                                        <TextBlock\n                                            Margin=\"3,0,0,0\"\n                                            FontWeight=\"Light\"\n                                            Text=\"{Binding Duration, TargetNullValue=Live}\"\n                                            TextTrimming=\"CharacterEllipsis\" />\n                                    </StackPanel>\n                                </StackPanel>\n                            </StackPanel>\n\n                            <!--  Checkmark  -->\n                            <materialIcons:MaterialIcon\n                                Grid.Column=\"2\"\n                                Width=\"24\"\n                                Height=\"24\"\n                                Margin=\"8,0,0,0\"\n                                IsVisible=\"{Binding $parent[ListBoxItem].IsSelected, Mode=OneWay}\"\n                                Kind=\"Check\" />\n                        </Grid>\n                    </DataTemplate>\n                </ListBox.ItemTemplate>\n            </ListBox>\n        </Border>\n\n        <!--  Preferences  -->\n        <UniformGrid\n            Grid.Row=\"2\"\n            Margin=\"16\"\n            Columns=\"2\">\n            <!--  Container preference  -->\n            <ComboBox\n                Margin=\"0,0,8,0\"\n                materialAssists:ComboBoxAssist.Label=\"{Binding LocalizationManager.ContainerLabel}\"\n                ItemsSource=\"{Binding AvailableContainers}\"\n                SelectedItem=\"{Binding SelectedContainer}\"\n                Theme=\"{DynamicResource MaterialFilledComboBox}\" />\n\n            <!--  Video quality preference  -->\n            <ComboBox\n                Margin=\"8,0,0,0\"\n                materialAssists:ComboBoxAssist.Label=\"{Binding LocalizationManager.VideoQualityLabel}\"\n                IsEnabled=\"{Binding !SelectedContainer.IsAudioOnly}\"\n                ItemsSource=\"{Binding AvailableVideoQualityPreferences}\"\n                SelectedItem=\"{Binding SelectedVideoQualityPreference}\"\n                Theme=\"{DynamicResource MaterialFilledComboBox}\">\n                <ComboBox.ItemTemplate>\n                    <DataTemplate>\n                        <TextBlock Text=\"{Binding Converter={x:Static converters:VideoQualityPreferenceToStringConverter.Instance}}\" />\n                    </DataTemplate>\n                </ComboBox.ItemTemplate>\n            </ComboBox>\n        </UniformGrid>\n\n        <!--  Buttons  -->\n        <StackPanel\n            Grid.Row=\"3\"\n            Margin=\"16,8,16,16\"\n            HorizontalAlignment=\"Right\"\n            Orientation=\"Horizontal\">\n            <!--  Download  -->\n            <Button\n                Margin=\"0,0,8,0\"\n                Command=\"{Binding ConfirmCommand}\"\n                IsDefault=\"True\"\n                Theme=\"{DynamicResource MaterialOutlineButton}\">\n                <Button.Content>\n                    <TextBlock>\n                        <Run Text=\"{Binding LocalizationManager.DownloadButton}\" />\n                        <Run Text=\"(\" /><Run Text=\"{Binding SelectedVideos.Count, FallbackValue=0, Mode=OneWay}\" /><Run Text=\")\" />\n                    </TextBlock>\n                </Button.Content>\n            </Button>\n\n            <!--  Cancel  -->\n            <Button\n                Margin=\"8,0,0,0\"\n                Command=\"{Binding CloseCommand}\"\n                Content=\"{Binding LocalizationManager.CancelButton}\"\n                IsCancel=\"True\"\n                Theme=\"{DynamicResource MaterialOutlineButton}\" />\n        </StackPanel>\n    </Grid>\n</UserControl>\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/DownloadMultipleSetupView.axaml.cs",
    "content": "using Avalonia.Interactivity;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.ViewModels.Dialogs;\n\nnamespace YoutubeDownloader.Views.Dialogs;\n\npublic partial class DownloadMultipleSetupView : UserControl<DownloadMultipleSetupViewModel>\n{\n    public DownloadMultipleSetupView() => InitializeComponent();\n\n    private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>\n        DataContext.InitializeCommand.Execute(null);\n}\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/DownloadSingleSetupView.axaml",
    "content": "<UserControl\n    x:Class=\"YoutubeDownloader.Views.Dialogs.DownloadSingleSetupView\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\"\n    xmlns:asyncImageLoader=\"clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia\"\n    xmlns:converters=\"clr-namespace:YoutubeDownloader.Converters\"\n    xmlns:dialogs=\"clr-namespace:YoutubeDownloader.ViewModels.Dialogs\"\n    xmlns:materialAssists=\"clr-namespace:Material.Styles.Assists;assembly=Material.Styles\"\n    xmlns:materialIcons=\"clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia\"\n    x:Name=\"UserControl\"\n    Width=\"500\"\n    x:DataType=\"dialogs:DownloadSingleSetupViewModel\"\n    Loaded=\"UserControl_OnLoaded\">\n    <Grid RowDefinitions=\"Auto,*,Auto,Auto\">\n        <!--  Info  -->\n        <StackPanel\n            Grid.Row=\"0\"\n            Margin=\"16\"\n            Orientation=\"Vertical\">\n            <!--  Title  -->\n            <TextBlock\n                FontSize=\"19\"\n                Text=\"{Binding Video.Title}\"\n                TextTrimming=\"CharacterEllipsis\"\n                ToolTip.Tip=\"{Binding Video.Title}\">\n                <TextBlock.ContextMenu>\n                    <ContextMenu>\n                        <MenuItem Command=\"{Binding CopyTitleCommand}\" Header=\"{Binding LocalizationManager.CopyMenuItem}\" />\n                    </ContextMenu>\n                </TextBlock.ContextMenu>\n            </TextBlock>\n\n            <StackPanel Margin=\"0,8,0,0\" Orientation=\"Horizontal\">\n                <!--  Author  -->\n                <StackPanel Orientation=\"Horizontal\">\n                    <materialIcons:MaterialIcon\n                        Width=\"16\"\n                        Height=\"16\"\n                        BorderThickness=\"1\"\n                        Kind=\"UserOutline\" />\n                    <TextBlock\n                        Margin=\"3,0,0,0\"\n                        FontWeight=\"Light\"\n                        Text=\"{Binding Video.Author.Title}\"\n                        TextTrimming=\"CharacterEllipsis\" />\n                </StackPanel>\n\n                <!--  Duration  -->\n                <StackPanel Margin=\"16,0,0,0\" Orientation=\"Horizontal\">\n                    <materialIcons:MaterialIcon\n                        Width=\"16\"\n                        Height=\"16\"\n                        BorderThickness=\"1\"\n                        Kind=\"ClockOutline\" />\n                    <TextBlock\n                        Margin=\"3,0,0,0\"\n                        FontWeight=\"Light\"\n                        Text=\"{Binding Video.Duration, TargetNullValue=Live}\"\n                        TextTrimming=\"CharacterEllipsis\" />\n                </StackPanel>\n            </StackPanel>\n        </StackPanel>\n\n        <!--  Thumbnail  -->\n        <Border\n            Grid.Row=\"1\"\n            BorderBrush=\"{DynamicResource MaterialDividerBrush}\"\n            BorderThickness=\"0,1\">\n            <Image asyncImageLoader:ImageLoader.Source=\"{Binding Video, Converter={x:Static converters:VideoToHighestQualityThumbnailUrlStringConverter.Instance}}\" />\n        </Border>\n\n        <!--  Download options  -->\n        <ComboBox\n            Grid.Row=\"2\"\n            Margin=\"16\"\n            materialAssists:ComboBoxAssist.Label=\"{Binding LocalizationManager.FormatLabel}\"\n            DockPanel.Dock=\"Left\"\n            ItemsSource=\"{Binding AvailableDownloadOptions}\"\n            SelectedItem=\"{Binding SelectedDownloadOption}\"\n            Theme=\"{DynamicResource MaterialFilledComboBox}\">\n            <ComboBox.ItemTemplate>\n                <DataTemplate>\n                    <TextBlock>\n                        <!--  Video quality  -->\n                        <Run Classes.audioOnly=\"{Binding IsAudioOnly}\">\n                            <Run.Styles>\n                                <Style Selector=\"Run\">\n                                    <Setter Property=\"Text\" Value=\"{Binding VideoQuality, Mode=OneWay}\" />\n\n                                    <Style Selector=\"^.audioOnly\">\n                                        <Setter Property=\"Text\" Value=\"Audio\" />\n                                    </Style>\n                                </Style>\n                            </Run.Styles>\n                        </Run>\n\n                        <!--  Separator  -->\n                        <Run Text=\"—\" />\n\n                        <!--  Container  -->\n                        <Run Text=\"{Binding Container, Mode=OneWay}\" />\n                    </TextBlock>\n                </DataTemplate>\n            </ComboBox.ItemTemplate>\n        </ComboBox>\n\n        <!--  Buttons  -->\n        <StackPanel\n            Grid.Row=\"3\"\n            Margin=\"16,8,16,16\"\n            HorizontalAlignment=\"Right\"\n            Orientation=\"Horizontal\">\n            <!--  Download  -->\n            <Button\n                Margin=\"0,0,8,0\"\n                Command=\"{Binding ConfirmCommand}\"\n                Content=\"{Binding LocalizationManager.DownloadButton}\"\n                IsDefault=\"True\"\n                Theme=\"{DynamicResource MaterialOutlineButton}\" />\n\n            <!--  Cancel  -->\n            <Button\n                Margin=\"8,0,0,0\"\n                Command=\"{Binding CloseCommand}\"\n                Content=\"{Binding LocalizationManager.CancelButton}\"\n                IsCancel=\"True\"\n                Theme=\"{DynamicResource MaterialOutlineButton}\" />\n        </StackPanel>\n    </Grid>\n</UserControl>\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/DownloadSingleSetupView.axaml.cs",
    "content": "using Avalonia.Interactivity;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.ViewModels.Dialogs;\n\nnamespace YoutubeDownloader.Views.Dialogs;\n\npublic partial class DownloadSingleSetupView : UserControl<DownloadSingleSetupViewModel>\n{\n    public DownloadSingleSetupView() => InitializeComponent();\n\n    private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>\n        DataContext.InitializeCommand.Execute(null);\n}\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/MessageBoxView.axaml",
    "content": "﻿<UserControl\n    x:Class=\"YoutubeDownloader.Views.Dialogs.MessageBoxView\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\"\n    xmlns:dialogs=\"clr-namespace:YoutubeDownloader.ViewModels.Dialogs\"\n    xmlns:system=\"clr-namespace:System;assembly=System.Runtime\"\n    Width=\"500\"\n    x:DataType=\"dialogs:MessageBoxViewModel\">\n    <Grid RowDefinitions=\"Auto,*,Auto\">\n        <!--  Title  -->\n        <TextBlock\n            Grid.Row=\"0\"\n            Margin=\"16\"\n            FontSize=\"19\"\n            FontWeight=\"Light\"\n            Text=\"{Binding Title}\"\n            TextTrimming=\"CharacterEllipsis\"\n            ToolTip.Tip=\"{Binding Title}\" />\n\n        <!--  Message  -->\n        <Border\n            Grid.Row=\"1\"\n            Padding=\"0,8\"\n            BorderBrush=\"{DynamicResource MaterialDividerBrush}\"\n            BorderThickness=\"0,1\">\n            <ScrollViewer HorizontalScrollBarVisibility=\"Disabled\" VerticalScrollBarVisibility=\"Auto\">\n                <TextBlock\n                    Margin=\"16,8\"\n                    Text=\"{Binding Message}\"\n                    TextWrapping=\"Wrap\" />\n            </ScrollViewer>\n        </Border>\n\n        <UniformGrid\n            Grid.Row=\"2\"\n            Margin=\"8\"\n            Columns=\"{Binding ButtonsCount}\">\n            <!--  OK  -->\n            <Button\n                Margin=\"8\"\n                HorizontalContentAlignment=\"Stretch\"\n                Command=\"{Binding CloseCommand}\"\n                IsDefault=\"True\"\n                IsVisible=\"{Binding IsDefaultButtonVisible}\"\n                Theme=\"{DynamicResource MaterialOutlineButton}\"\n                ToolTip.Tip=\"{Binding DefaultButtonText}\">\n                <Button.CommandParameter>\n                    <system:Boolean>True</system:Boolean>\n                </Button.CommandParameter>\n                <TextBlock\n                    Text=\"{Binding DefaultButtonText}\"\n                    TextAlignment=\"Center\"\n                    TextTrimming=\"CharacterEllipsis\" />\n            </Button>\n\n            <!--  Cancel  -->\n            <Button\n                Margin=\"8\"\n                HorizontalContentAlignment=\"Stretch\"\n                Command=\"{Binding CloseCommand}\"\n                IsCancel=\"True\"\n                IsVisible=\"{Binding IsCancelButtonVisible}\"\n                Theme=\"{DynamicResource MaterialOutlineButton}\"\n                ToolTip.Tip=\"{Binding CancelButtonText}\">\n                <TextBlock\n                    Text=\"{Binding CancelButtonText}\"\n                    TextAlignment=\"Center\"\n                    TextTrimming=\"CharacterEllipsis\" />\n            </Button>\n        </UniformGrid>\n    </Grid>\n</UserControl>"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/MessageBoxView.axaml.cs",
    "content": "using YoutubeDownloader.Framework;\nusing YoutubeDownloader.ViewModels.Dialogs;\n\nnamespace YoutubeDownloader.Views.Dialogs;\n\npublic partial class MessageBoxView : UserControl<MessageBoxViewModel>\n{\n    public MessageBoxView() => InitializeComponent();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/SettingsView.axaml",
    "content": "<UserControl\n    x:Class=\"YoutubeDownloader.Views.Dialogs.SettingsView\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\"\n    xmlns:converters=\"clr-namespace:YoutubeDownloader.Converters\"\n    xmlns:dialogs=\"clr-namespace:YoutubeDownloader.ViewModels.Dialogs\"\n    xmlns:materialIcons=\"clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia\"\n    Width=\"380\"\n    x:DataType=\"dialogs:SettingsViewModel\">\n    <Grid RowDefinitions=\"Auto,*,Auto\">\n        <TextBlock\n            Grid.Row=\"0\"\n            Margin=\"16\"\n            FontSize=\"19\"\n            FontWeight=\"Light\"\n            Text=\"{Binding LocalizationManager.SettingsTitle}\" />\n\n        <Border\n            Grid.Row=\"1\"\n            Padding=\"0,8\"\n            BorderBrush=\"{DynamicResource MaterialDividerBrush}\"\n            BorderThickness=\"0,1\">\n            <ScrollViewer HorizontalScrollBarVisibility=\"Disabled\" VerticalScrollBarVisibility=\"Auto\">\n                <StackPanel Orientation=\"Vertical\">\n                    <!--  Theme  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        LastChildFill=\"False\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.ThemeTooltip}\">\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.ThemeLabel}\" />\n                        <ComboBox\n                            Width=\"150\"\n                            DockPanel.Dock=\"Right\"\n                            ItemsSource=\"{Binding AvailableThemes}\"\n                            SelectedItem=\"{Binding Theme}\" />\n                    </DockPanel>\n\n                    <!--  Language  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        LastChildFill=\"False\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.LanguageTooltip}\">\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.LanguageLabel}\" />\n                        <ComboBox\n                            Width=\"150\"\n                            DockPanel.Dock=\"Right\"\n                            ItemsSource=\"{Binding AvailableLanguages}\"\n                            SelectedItem=\"{Binding Language}\" />\n                    </DockPanel>\n\n                    <!--  Auto-updates  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        IsVisible=\"{OnPlatform False,\n                                               Windows=True}\"\n                        LastChildFill=\"False\">\n                        <ToolTip.Tip>\n                            <TextBlock Inlines=\"{Binding LocalizationManager.AutoUpdateTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}\" />\n                        </ToolTip.Tip>\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.AutoUpdateLabel}\" />\n                        <ToggleSwitch DockPanel.Dock=\"Right\" IsChecked=\"{Binding IsAutoUpdateEnabled}\" />\n                    </DockPanel>\n\n                    <!--  Persist authentication  -->\n                    <DockPanel\n                        IsVisible=\"{OnPlatform False,\n                                               Windows=True}\"\n                        LastChildFill=\"False\">\n                        <ToolTip.Tip>\n                            <TextBlock Inlines=\"{Binding LocalizationManager.PersistAuthTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}\" />\n                        </ToolTip.Tip>\n                        <TextBlock\n                            Margin=\"16,8\"\n                            DockPanel.Dock=\"Left\"\n                            Text=\"{Binding LocalizationManager.PersistAuthLabel}\" />\n                        <ToggleSwitch\n                            Margin=\"16,8\"\n                            DockPanel.Dock=\"Right\"\n                            IsChecked=\"{Binding IsAuthPersisted}\" />\n                    </DockPanel>\n\n                    <!--  FFmpeg path  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        LastChildFill=\"False\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.FFmpegPathTooltip}\">\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.FFmpegPathLabel}\" />\n                        <TextBox\n                            Width=\"150\"\n                            Height=\"20\"\n                            DockPanel.Dock=\"Right\"\n                            FontSize=\"13\"\n                            Text=\"{Binding FFmpegFilePath}\"\n                            Theme=\"{DynamicResource CompactTextBox}\"\n                            Watermark=\"{Binding LocalizationManager.FFmpegPathWatermark}\">\n                            <TextBox.InnerRightContent>\n                                <StackPanel Orientation=\"Horizontal\">\n                                    <Button\n                                        Padding=\"4\"\n                                        Command=\"{Binding ResetFFmpegFilePathCommand}\"\n                                        IsEnabled=\"{Binding FFmpegFilePath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}\"\n                                        Theme=\"{DynamicResource MaterialFlatButton}\"\n                                        ToolTip.Tip=\"{Binding LocalizationManager.FFmpegPathResetTooltip}\">\n                                        <materialIcons:MaterialIcon\n                                            Width=\"14\"\n                                            Height=\"14\"\n                                            Kind=\"Close\" />\n                                    </Button>\n                                    <Button\n                                        Padding=\"4\"\n                                        Command=\"{Binding BrowseFFmpegFilePathCommand}\"\n                                        Theme=\"{DynamicResource MaterialFlatButton}\"\n                                        ToolTip.Tip=\"{Binding LocalizationManager.FFmpegPathBrowseTooltip}\">\n                                        <materialIcons:MaterialIcon\n                                            Width=\"14\"\n                                            Height=\"14\"\n                                            Kind=\"FolderOpen\" />\n                                    </Button>\n                                </StackPanel>\n                            </TextBox.InnerRightContent>\n                        </TextBox>\n                    </DockPanel>\n\n                    <!--  Inject language-specific audio streams  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        LastChildFill=\"False\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.InjectAltLanguagesTooltip}\">\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.InjectAltLanguagesLabel}\" />\n                        <ToggleSwitch DockPanel.Dock=\"Right\" IsChecked=\"{Binding ShouldInjectLanguageSpecificAudioStreams}\" />\n                    </DockPanel>\n\n                    <!--  Inject subtitles  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        LastChildFill=\"False\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.InjectSubtitlesTooltip}\">\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.InjectSubtitlesLabel}\" />\n                        <ToggleSwitch DockPanel.Dock=\"Right\" IsChecked=\"{Binding ShouldInjectSubtitles}\" />\n                    </DockPanel>\n\n                    <!--  Inject tags  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        LastChildFill=\"False\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.InjectTagsTooltip}\">\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.InjectTagsLabel}\" />\n                        <ToggleSwitch DockPanel.Dock=\"Right\" IsChecked=\"{Binding ShouldInjectTags}\" />\n                    </DockPanel>\n\n                    <!--  Skip existing files  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        LastChildFill=\"False\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.SkipExistingFilesTooltip}\">\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.SkipExistingFilesLabel}\" />\n                        <ToggleSwitch DockPanel.Dock=\"Right\" IsChecked=\"{Binding ShouldSkipExistingFiles}\" />\n                    </DockPanel>\n\n                    <!--  File name template  -->\n                    <DockPanel Margin=\"16,8\" LastChildFill=\"False\">\n                        <ToolTip.Tip>\n                            <TextBlock Inlines=\"{Binding LocalizationManager.FileNameTemplateTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}\" />\n                        </ToolTip.Tip>\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.FileNameTemplateLabel}\" />\n                        <TextBox\n                            Width=\"150\"\n                            Height=\"20\"\n                            DockPanel.Dock=\"Right\"\n                            FontSize=\"13\"\n                            Text=\"{Binding FileNameTemplate}\"\n                            Theme=\"{DynamicResource CompactTextBox}\" />\n                    </DockPanel>\n\n                    <!--  Parallel limit  -->\n                    <DockPanel\n                        Margin=\"16,8\"\n                        LastChildFill=\"False\"\n                        ToolTip.Tip=\"{Binding LocalizationManager.ParallelLimitTooltip}\">\n                        <TextBlock DockPanel.Dock=\"Left\" Text=\"{Binding LocalizationManager.ParallelLimitLabel}\" />\n                        <StackPanel DockPanel.Dock=\"Right\" Orientation=\"Horizontal\">\n                            <TextBlock Margin=\"10,0\" Text=\"{Binding ParallelLimit}\" />\n                            <Slider\n                                Width=\"150\"\n                                IsSnapToTickEnabled=\"True\"\n                                Maximum=\"10\"\n                                Minimum=\"1\"\n                                TickFrequency=\"1\"\n                                Value=\"{Binding ParallelLimit}\" />\n                        </StackPanel>\n                    </DockPanel>\n                </StackPanel>\n            </ScrollViewer>\n        </Border>\n\n        <!--  Close button  -->\n        <Button\n            Grid.Row=\"2\"\n            Margin=\"16\"\n            HorizontalAlignment=\"Stretch\"\n            Command=\"{Binding CloseCommand}\"\n            Content=\"{Binding LocalizationManager.CloseButton}\"\n            IsCancel=\"True\"\n            IsDefault=\"True\"\n            Theme=\"{DynamicResource MaterialOutlineButton}\" />\n    </Grid>\n</UserControl>\n"
  },
  {
    "path": "YoutubeDownloader/Views/Dialogs/SettingsView.axaml.cs",
    "content": "using YoutubeDownloader.Framework;\nusing YoutubeDownloader.ViewModels.Dialogs;\n\nnamespace YoutubeDownloader.Views.Dialogs;\n\npublic partial class SettingsView : UserControl<SettingsViewModel>\n{\n    public SettingsView() => InitializeComponent();\n}\n"
  },
  {
    "path": "YoutubeDownloader/Views/MainView.axaml",
    "content": "<Window\n    x:Class=\"YoutubeDownloader.Views.MainView\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\"\n    xmlns:dialogHostAvalonia=\"clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia\"\n    xmlns:materialStyles=\"clr-namespace:Material.Styles.Controls;assembly=Material.Styles\"\n    xmlns:viewModels=\"clr-namespace:YoutubeDownloader.ViewModels\"\n    Title=\"{Binding Title}\"\n    Width=\"720\"\n    Height=\"620\"\n    MinWidth=\"600\"\n    MinHeight=\"400\"\n    x:DataType=\"viewModels:MainViewModel\"\n    Icon=\"/favicon.ico\"\n    RenderOptions.BitmapInterpolationMode=\"HighQuality\"\n    WindowStartupLocation=\"CenterScreen\">\n    <!--  Hack: dialog host animations mess up webview positioning, so need to disable them  -->\n    <dialogHostAvalonia:DialogHost\n        x:Name=\"DialogHost\"\n        CloseOnClickAway=\"False\"\n        DisableOpeningAnimation=\"True\"\n        Loaded=\"DialogHost_OnLoaded\">\n        <materialStyles:SnackbarHost HostName=\"Root\" SnackbarMaxCounts=\"3\">\n            <ContentControl Content=\"{Binding Dashboard}\" />\n        </materialStyles:SnackbarHost>\n    </dialogHostAvalonia:DialogHost>\n</Window>\n"
  },
  {
    "path": "YoutubeDownloader/Views/MainView.axaml.cs",
    "content": "using Avalonia.Interactivity;\nusing YoutubeDownloader.Framework;\nusing YoutubeDownloader.ViewModels;\n\nnamespace YoutubeDownloader.Views;\n\npublic partial class MainView : Window<MainViewModel>\n{\n    public MainView() => InitializeComponent();\n\n    private void DialogHost_OnLoaded(object? sender, RoutedEventArgs args) =>\n        DataContext.InitializeCommand.Execute(null);\n}\n"
  },
  {
    "path": "YoutubeDownloader/YoutubeDownloader.csproj",
    "content": "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup>\n    <OutputType>WinExe</OutputType>\n    <ApplicationIcon>..\\favicon.ico</ApplicationIcon>\n    <ApplicationManifest>app.manifest</ApplicationManifest>\n    <!-- Trimmed builds break support for Windows 10 for some reason -->\n    <!-- https://github.com/Tyrrrz/YoutubeDownloader/issues/496 -->\n    <PublishTrimmed>false</PublishTrimmed>\n    <CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>\n    <BuiltInComInteropSupport>true</BuiltInComInteropSupport>\n    <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>\n  </PropertyGroup>\n\n  <PropertyGroup>\n    <DownloadFFmpeg>true</DownloadFFmpeg>\n    <PublishMacOSBundle>false</PublishMacOSBundle>\n  </PropertyGroup>\n\n  <PropertyGroup>\n    <EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>\n  </PropertyGroup>\n\n  <ItemGroup>\n    <!-- Expose this property in code -->\n    <ProjectProperty Include=\"EncryptionSalt\" />\n  </ItemGroup>\n\n  <ItemGroup>\n    <AvaloniaResource Include=\"..\\favicon.ico\" Link=\"favicon.ico\" />\n  </ItemGroup>\n\n  <ItemGroup>\n    <None\n      Include=\"ffmpeg.exe\"\n      CopyToOutputDirectory=\"PreserveNewest\"\n      Condition=\"Exists('ffmpeg.exe')\"\n    />\n    <None Include=\"ffmpeg\" CopyToOutputDirectory=\"PreserveNewest\" Condition=\"Exists('ffmpeg')\" />\n  </ItemGroup>\n\n  <ItemGroup>\n    <PackageReference Include=\"AsyncImageLoader.Avalonia\" />\n    <PackageReference Include=\"Avalonia\" />\n    <PackageReference Include=\"Avalonia.Controls.DataGrid\" />\n    <PackageReference Include=\"Avalonia.Desktop\" />\n    <PackageReference Include=\"Avalonia.Diagnostics\" Condition=\"'$(Configuration)' == 'Debug'\" />\n    <PackageReference Include=\"Cogwheel\" />\n    <PackageReference Include=\"CommunityToolkit.Mvvm\" />\n    <PackageReference Include=\"CSharpier.MsBuild\" PrivateAssets=\"all\" />\n    <PackageReference Include=\"Deorcify\" PrivateAssets=\"all\" />\n    <PackageReference Include=\"DialogHost.Avalonia\" />\n    <PackageReference Include=\"Gress\" />\n    <PackageReference Include=\"Markdig\" />\n    <PackageReference Include=\"Material.Avalonia\" />\n    <PackageReference Include=\"Material.Avalonia.DataGrid\" />\n    <PackageReference Include=\"Material.Icons.Avalonia\" />\n    <PackageReference Include=\"Microsoft.Extensions.DependencyInjection\" />\n    <PackageReference Include=\"Onova\" />\n    <PackageReference Include=\"ThisAssembly.Project\" PrivateAssets=\"all\" />\n    <PackageReference Include=\"WebView.Avalonia\" />\n    <PackageReference Include=\"WebView.Avalonia.Desktop\" />\n  </ItemGroup>\n\n  <ItemGroup>\n    <ProjectReference Include=\"..\\YoutubeDownloader.Core\\YoutubeDownloader.Core.csproj\" />\n  </ItemGroup>\n\n  <!-- Avalonia.WebView is completely incompatible with trimming -->\n  <ItemGroup>\n    <TrimmerRootAssembly Include=\"Avalonia.WebView\" />\n    <TrimmerRootAssembly Include=\"Avalonia.WebView.Desktop\" />\n    <TrimmerRootAssembly Include=\"Avalonia.WebView.Linux\" />\n    <TrimmerRootAssembly Include=\"Avalonia.WebView.MacCatalyst\" />\n    <TrimmerRootAssembly Include=\"Avalonia.WebView.Windows\" />\n    <TrimmerRootAssembly Include=\"AvaloniaWebView.Shared\" />\n    <TrimmerRootAssembly Include=\"Linux.WebView.Core\" />\n    <TrimmerRootAssembly Include=\"Microsoft.Web.WebView2.Core\" />\n    <TrimmerRootAssembly Include=\"WebView.Avalonia\" />\n    <TrimmerRootAssembly Include=\"WebView.Core\" />\n  </ItemGroup>\n\n  <!-- Download FFmpeg -->\n  <Target\n    Name=\"DownloadFFmpeg\"\n    BeforeTargets=\"PreBuildEvent\"\n    Condition=\"$(DownloadFFmpeg) AND !Exists('ffmpeg.exe') AND !Exists('ffmpeg')\"\n  >\n    <Exec\n      Command=\"pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Download-FFmpeg.ps1 -Platform $(RuntimeIdentifier) -OutputPath $(ProjectDir)\"\n      LogStandardErrorAsError=\"true\"\n      Condition=\"'$(RuntimeIdentifier)' != ''\"\n    />\n    <Exec\n      Command=\"pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Download-FFmpeg.ps1 -OutputPath $(ProjectDir)\"\n      LogStandardErrorAsError=\"true\"\n      Condition=\"'$(RuntimeIdentifier)' == ''\"\n    />\n\n    <!-- Update FFmpeg references so that the files get copied to the output within the same build sequence -->\n    <ItemGroup>\n      <None\n        Include=\"ffmpeg.exe\"\n        CopyToOutputDirectory=\"PreserveNewest\"\n        Condition=\"Exists('ffmpeg.exe')\"\n      />\n      <None Include=\"ffmpeg\" CopyToOutputDirectory=\"PreserveNewest\" Condition=\"Exists('ffmpeg')\" />\n    </ItemGroup>\n  </Target>\n\n  <!-- Publish macOS bundle -->\n  <Target Name=\"PublishMacOSBundle\" AfterTargets=\"Publish\" Condition=\"$(PublishMacOSBundle)\">\n    <Exec\n      Command=\"pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)\"\n      LogStandardErrorAsError=\"true\"\n    />\n  </Target>\n</Project>\n"
  },
  {
    "path": "YoutubeDownloader/app.manifest",
    "content": "﻿<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<assembly manifestVersion=\"1.0\" xmlns=\"urn:schemas-microsoft-com:asm.v1\">\n  <assemblyIdentity version=\"1.0.0.0\" name=\"YoutubeDownloader\"/>\n\n  <compatibility xmlns=\"urn:schemas-microsoft-com:compatibility.v1\">\n    <application>\n      <!-- Windows 7 -->\n      <supportedOS Id=\"{35138b9a-5d96-4fbd-8e2d-a2440225f93a}\" />\n\n      <!-- Windows 8 -->\n      <supportedOS Id=\"{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}\" />\n\n      <!-- Windows 8.1 -->\n      <supportedOS Id=\"{1f676c76-80e1-4239-95bb-83d0f6d0da78}\" />\n\n      <!-- Windows 10 -->\n      <supportedOS Id=\"{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}\" />\n    </application>\n  </compatibility>\n</assembly>"
  },
  {
    "path": "YoutubeDownloader.Core/Downloading/FFmpeg.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\n\nnamespace YoutubeDownloader.Core.Downloading;\n\npublic static class FFmpeg\n{\n    public static string CliFileName { get; } =\n        OperatingSystem.IsWindows() ? \"ffmpeg.exe\" : \"ffmpeg\";\n\n    public static IEnumerable<string> GetProbeDirectoryPaths()\n    {\n        yield return AppContext.BaseDirectory;\n        yield return Directory.GetCurrentDirectory();\n\n        // Process PATH\n        if (\n            Environment.GetEnvironmentVariable(\"PATH\")?.Split(Path.PathSeparator) is\n            { } processPaths\n        )\n        {\n            foreach (var path in processPaths)\n                if (!string.IsNullOrWhiteSpace(path))\n                    yield return path;\n        }\n\n        // Registry-based PATH variables\n        if (OperatingSystem.IsWindows())\n        {\n            // User PATH\n            if (\n                Environment\n                    .GetEnvironmentVariable(\"PATH\", EnvironmentVariableTarget.User)\n                    ?.Split(Path.PathSeparator) is\n                { } userPaths\n            )\n            {\n                foreach (var path in userPaths)\n                    if (!string.IsNullOrWhiteSpace(path))\n                        yield return path;\n            }\n\n            // System PATH\n            if (\n                Environment\n                    .GetEnvironmentVariable(\"PATH\", EnvironmentVariableTarget.Machine)\n                    ?.Split(Path.PathSeparator) is\n                { } systemPaths\n            )\n            {\n                foreach (var path in systemPaths)\n                    if (!string.IsNullOrWhiteSpace(path))\n                        yield return path;\n            }\n        }\n    }\n\n    public static string? TryGetCliFilePath() =>\n        GetProbeDirectoryPaths()\n            .Distinct(StringComparer.Ordinal)\n            .Select(dirPath => Path.Combine(dirPath, CliFileName))\n            .FirstOrDefault(File.Exists);\n\n    public static bool IsBundled() =>\n        File.Exists(Path.Combine(AppContext.BaseDirectory, CliFileName));\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Downloading/FileNameTemplate.cs",
    "content": "﻿using System;\nusing System.IO;\nusing YoutubeDownloader.Core.Utils.Extensions;\nusing YoutubeExplode.Videos;\nusing YoutubeExplode.Videos.Streams;\n\nnamespace YoutubeDownloader.Core.Downloading;\n\npublic static class FileNameTemplate\n{\n    public static string Apply(\n        string template,\n        IVideo video,\n        Container container,\n        string? number = null\n    ) =>\n        Path.EscapeFileName(\n            template\n                .Replace(\"$numc\", number ?? \"\", StringComparison.Ordinal)\n                .Replace(\"$num\", number is not null ? $\"[{number}]\" : \"\", StringComparison.Ordinal)\n                .Replace(\"$id\", video.Id, StringComparison.Ordinal)\n                .Replace(\"$title\", video.Title, StringComparison.Ordinal)\n                .Replace(\"$author\", video.Author.ChannelTitle, StringComparison.Ordinal)\n                .Replace(\n                    \"$uploadDate\",\n                    (video as Video)?.UploadDate.ToString(\"yyyy-MM-dd\") ?? \"\",\n                    StringComparison.Ordinal\n                )\n                .Trim()\n                + '.'\n                + container.Name\n        );\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Downloading/VideoDownloadOption.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing YoutubeDownloader.Core.Utils.Extensions;\nusing YoutubeExplode.Videos.Streams;\n\nnamespace YoutubeDownloader.Core.Downloading;\n\npublic partial record VideoDownloadOption(\n    Container Container,\n    bool IsAudioOnly,\n    IReadOnlyList<IStreamInfo> StreamInfos\n)\n{\n    public VideoQuality? VideoQuality { get; } =\n        StreamInfos.OfType<IVideoStreamInfo>().MaxBy(s => s.VideoQuality)?.VideoQuality;\n}\n\npublic partial record VideoDownloadOption\n{\n    internal static IReadOnlyList<VideoDownloadOption> ResolveAll(\n        StreamManifest manifest,\n        bool includeLanguageSpecificAudioStreams = true\n    )\n    {\n        IEnumerable<VideoDownloadOption> GetVideoAndAudioOptions()\n        {\n            var videoStreamInfos = manifest\n                .GetVideoStreams()\n                .OrderByDescending(v => v.VideoQuality);\n\n            foreach (var videoStreamInfo in videoStreamInfos)\n            {\n                // Muxed stream\n                if (videoStreamInfo is MuxedStreamInfo)\n                {\n                    yield return new VideoDownloadOption(\n                        videoStreamInfo.Container,\n                        false,\n                        [videoStreamInfo]\n                    );\n                }\n                // Separate audio + video stream\n                else\n                {\n                    var audioStreamInfos = manifest\n                        .GetAudioStreams()\n                        // Prefer audio streams with the same container\n                        .OrderByDescending(s => s.Container == videoStreamInfo.Container)\n                        .ThenByDescending(s => s is AudioOnlyStreamInfo)\n                        .ThenByDescending(s => s.Bitrate)\n                        .ToArray();\n\n                    // Prefer language-specific audio streams, if available and if allowed\n                    var languageSpecificAudioStreamInfos = includeLanguageSpecificAudioStreams\n                        ? audioStreamInfos\n                            .Where(s => s.AudioLanguage is not null)\n                            .DistinctBy(s => s.AudioLanguage)\n                            // Default language first so it's encoded as the first audio track in the output file\n                            .OrderByDescending(s => s.IsAudioLanguageDefault)\n                            .ToArray()\n                        : [];\n\n                    // If there are language-specific streams, include them all\n                    if (languageSpecificAudioStreamInfos.Any())\n                    {\n                        yield return new VideoDownloadOption(\n                            videoStreamInfo.Container,\n                            false,\n                            [videoStreamInfo, .. languageSpecificAudioStreamInfos]\n                        );\n                    }\n                    // If there are no language-specific streams, download the single best quality audio stream\n                    else\n                    {\n                        var audioStreamInfo = audioStreamInfos\n                            // Prefer audio streams in the default language (or non-language-specific streams)\n                            .OrderByDescending(s => s.IsAudioLanguageDefault ?? true)\n                            .FirstOrDefault();\n\n                        if (audioStreamInfo is not null)\n                        {\n                            yield return new VideoDownloadOption(\n                                videoStreamInfo.Container,\n                                false,\n                                [videoStreamInfo, audioStreamInfo]\n                            );\n                        }\n                    }\n                }\n            }\n        }\n\n        IEnumerable<VideoDownloadOption> GetAudioOnlyOptions()\n        {\n            // WebM-based audio-only containers\n            {\n                var audioStreamInfo = manifest\n                    .GetAudioStreams()\n                    // Prefer audio streams in the default language (or non-language-specific streams)\n                    .OrderByDescending(s => s.IsAudioLanguageDefault ?? true)\n                    // Prefer audio streams with the same container\n                    .ThenByDescending(s => s.Container == Container.WebM)\n                    .ThenByDescending(s => s is AudioOnlyStreamInfo)\n                    .ThenByDescending(s => s.Bitrate)\n                    .FirstOrDefault();\n\n                if (audioStreamInfo is not null)\n                {\n                    yield return new VideoDownloadOption(Container.WebM, true, [audioStreamInfo]);\n\n                    yield return new VideoDownloadOption(Container.Mp3, true, [audioStreamInfo]);\n\n                    yield return new VideoDownloadOption(\n                        new Container(\"ogg\"),\n                        true,\n                        [audioStreamInfo]\n                    );\n                }\n            }\n\n            // Mp4-based audio-only containers\n            {\n                var audioStreamInfo = manifest\n                    .GetAudioStreams()\n                    // Prefer audio streams in the default language (or non-language-specific streams)\n                    .OrderByDescending(s => s.IsAudioLanguageDefault ?? true)\n                    // Prefer audio streams with the same container\n                    .ThenByDescending(s => s.Container == Container.Mp4)\n                    .ThenByDescending(s => s is AudioOnlyStreamInfo)\n                    .ThenByDescending(s => s.Bitrate)\n                    .FirstOrDefault();\n\n                if (audioStreamInfo is not null)\n                {\n                    yield return new VideoDownloadOption(Container.Mp4, true, [audioStreamInfo]);\n                }\n            }\n        }\n\n        // Deduplicate download options by video quality and container\n        var comparer = EqualityComparer<VideoDownloadOption>.Create(\n            (x, y) => x?.VideoQuality == y?.VideoQuality && x?.Container == y?.Container,\n            x => HashCode.Combine(x.VideoQuality, x.Container)\n        );\n\n        var options = new HashSet<VideoDownloadOption>(comparer);\n\n        options.AddRange(GetVideoAndAudioOptions());\n        options.AddRange(GetAudioOnlyOptions());\n\n        return options.ToArray();\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Downloading/VideoDownloadPreference.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing YoutubeExplode.Videos.Streams;\n\nnamespace YoutubeDownloader.Core.Downloading;\n\npublic record VideoDownloadPreference(\n    Container PreferredContainer,\n    VideoQualityPreference PreferredVideoQuality\n)\n{\n    public VideoDownloadOption? TryGetBestOption(IReadOnlyList<VideoDownloadOption> options)\n    {\n        // Short-circuit for audio-only formats\n        if (PreferredContainer.IsAudioOnly)\n            return options.FirstOrDefault(o => o.Container == PreferredContainer);\n\n        var orderedOptions = options.OrderBy(o => o.VideoQuality).ToArray();\n\n        var preferredOption = PreferredVideoQuality switch\n        {\n            VideoQualityPreference.Highest => orderedOptions.LastOrDefault(o =>\n                o.Container == PreferredContainer\n            ),\n\n            VideoQualityPreference.UpTo1080p => orderedOptions\n                .Where(o => o.VideoQuality?.MaxHeight <= 1080)\n                .LastOrDefault(o => o.Container == PreferredContainer),\n\n            VideoQualityPreference.UpTo720p => orderedOptions\n                .Where(o => o.VideoQuality?.MaxHeight <= 720)\n                .LastOrDefault(o => o.Container == PreferredContainer),\n\n            VideoQualityPreference.UpTo480p => orderedOptions\n                .Where(o => o.VideoQuality?.MaxHeight <= 480)\n                .LastOrDefault(o => o.Container == PreferredContainer),\n\n            VideoQualityPreference.UpTo360p => orderedOptions\n                .Where(o => o.VideoQuality?.MaxHeight <= 360)\n                .LastOrDefault(o => o.Container == PreferredContainer),\n\n            VideoQualityPreference.Lowest => orderedOptions.FirstOrDefault(o =>\n                o.Container == PreferredContainer\n            ),\n\n            _ => throw new InvalidOperationException(\n                $\"Unknown video quality preference '{PreferredVideoQuality}'.\"\n            ),\n        };\n\n        return preferredOption\n            ?? orderedOptions.FirstOrDefault(o => o.Container == PreferredContainer);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Downloading/VideoDownloader.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Net;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Gress;\nusing YoutubeDownloader.Core.Utils;\nusing YoutubeExplode;\nusing YoutubeExplode.Converter;\nusing YoutubeExplode.Videos;\nusing YoutubeExplode.Videos.ClosedCaptions;\n\nnamespace YoutubeDownloader.Core.Downloading;\n\npublic class VideoDownloader(IReadOnlyList<Cookie>? initialCookies = null) : IDisposable\n{\n    private readonly YoutubeClient _youtube = new(Http.Client, initialCookies ?? []);\n\n    public async Task<IReadOnlyList<VideoDownloadOption>> GetDownloadOptionsAsync(\n        VideoId videoId,\n        bool includeLanguageSpecificAudioStreams = true,\n        CancellationToken cancellationToken = default\n    )\n    {\n        var manifest = await _youtube.Videos.Streams.GetManifestAsync(videoId, cancellationToken);\n        return VideoDownloadOption.ResolveAll(manifest, includeLanguageSpecificAudioStreams);\n    }\n\n    public async Task<VideoDownloadOption> GetBestDownloadOptionAsync(\n        VideoId videoId,\n        VideoDownloadPreference preference,\n        bool includeLanguageSpecificAudioStreams = true,\n        CancellationToken cancellationToken = default\n    )\n    {\n        var options = await GetDownloadOptionsAsync(\n            videoId,\n            includeLanguageSpecificAudioStreams,\n            cancellationToken\n        );\n\n        return preference.TryGetBestOption(options)\n            ?? throw new InvalidOperationException(\"No suitable download option found.\");\n    }\n\n    public async Task DownloadVideoAsync(\n        string filePath,\n        IVideo video,\n        VideoDownloadOption downloadOption,\n        bool includeSubtitles = true,\n        string? ffmpegPath = null,\n        IProgress<Percentage>? progress = null,\n        CancellationToken cancellationToken = default\n    )\n    {\n        // Include subtitles in the output container\n        var trackInfos = new List<ClosedCaptionTrackInfo>();\n        if (includeSubtitles && !downloadOption.Container.IsAudioOnly)\n        {\n            var manifest = await _youtube.Videos.ClosedCaptions.GetManifestAsync(\n                video.Id,\n                cancellationToken\n            );\n\n            trackInfos.AddRange(manifest.Tracks);\n        }\n\n        var dirPath = Path.GetDirectoryName(filePath);\n        if (!string.IsNullOrWhiteSpace(dirPath))\n            Directory.CreateDirectory(dirPath);\n\n        await _youtube.Videos.DownloadAsync(\n            downloadOption.StreamInfos,\n            trackInfos,\n            new ConversionRequestBuilder(filePath)\n                .SetFFmpegPath(ffmpegPath ?? FFmpeg.TryGetCliFilePath() ?? \"ffmpeg\")\n                .SetContainer(downloadOption.Container)\n                .SetPreset(ConversionPreset.Medium)\n                .Build(),\n            progress?.ToDoubleBased(),\n            cancellationToken\n        );\n    }\n\n    public void Dispose() => _youtube.Dispose();\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Downloading/VideoQualityPreference.cs",
    "content": "﻿using System;\n\nnamespace YoutubeDownloader.Core.Downloading;\n\npublic enum VideoQualityPreference\n{\n    // ReSharper disable InconsistentNaming\n    Lowest,\n    UpTo360p,\n    UpTo480p,\n    UpTo720p,\n    UpTo1080p,\n    Highest,\n    // ReSharper restore InconsistentNaming\n}\n\npublic static class VideoQualityPreferenceExtensions\n{\n    extension(VideoQualityPreference preference)\n    {\n        public string GetDisplayName() =>\n            preference switch\n            {\n                VideoQualityPreference.Lowest => \"Lowest quality\",\n                VideoQualityPreference.UpTo360p => \"≤ 360p\",\n                VideoQualityPreference.UpTo480p => \"≤ 480p\",\n                VideoQualityPreference.UpTo720p => \"≤ 720p\",\n                VideoQualityPreference.UpTo1080p => \"≤ 1080p\",\n                VideoQualityPreference.Highest => \"Highest quality\",\n                _ => throw new ArgumentOutOfRangeException(nameof(preference)),\n            };\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Resolving/QueryResolver.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Net;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing YoutubeDownloader.Core.Utils;\nusing YoutubeExplode;\nusing YoutubeExplode.Channels;\nusing YoutubeExplode.Common;\nusing YoutubeExplode.Playlists;\nusing YoutubeExplode.Videos;\n\nnamespace YoutubeDownloader.Core.Resolving;\n\npublic class QueryResolver(IReadOnlyList<Cookie>? initialCookies = null) : IDisposable\n{\n    private readonly YoutubeClient _youtube = new(Http.Client, initialCookies ?? []);\n    private readonly bool _isAuthenticated = initialCookies?.Any() == true;\n\n    private async Task<QueryResult?> TryResolvePlaylistAsync(\n        string query,\n        CancellationToken cancellationToken = default\n    )\n    {\n        if (PlaylistId.TryParse(query) is not { } playlistId)\n            return null;\n\n        // Skip personal system playlists if the user is not authenticated\n        var isPersonalSystemPlaylist =\n            playlistId == \"WL\" || playlistId == \"LL\" || playlistId == \"LM\";\n\n        if (isPersonalSystemPlaylist && !_isAuthenticated)\n            return null;\n\n        var playlist = await _youtube.Playlists.GetAsync(playlistId, cancellationToken);\n        var videos = await _youtube.Playlists.GetVideosAsync(playlistId, cancellationToken);\n\n        return new QueryResult(QueryResultKind.Playlist, $\"Playlist: {playlist.Title}\", videos);\n    }\n\n    private async Task<QueryResult?> TryResolveVideoAsync(\n        string query,\n        CancellationToken cancellationToken = default\n    )\n    {\n        if (VideoId.TryParse(query) is not { } videoId)\n            return null;\n\n        var video = await _youtube.Videos.GetAsync(videoId, cancellationToken);\n        return new QueryResult(QueryResultKind.Video, video.Title, [video]);\n    }\n\n    private async Task<QueryResult?> TryResolveChannelAsync(\n        string query,\n        CancellationToken cancellationToken = default\n    )\n    {\n        if (ChannelId.TryParse(query) is { } channelId)\n        {\n            var channel = await _youtube.Channels.GetAsync(channelId, cancellationToken);\n            var videos = await _youtube.Channels.GetUploadsAsync(channelId, cancellationToken);\n\n            return new QueryResult(QueryResultKind.Channel, $\"Channel: {channel.Title}\", videos);\n        }\n\n        if (ChannelHandle.TryParse(query) is { } channelHandle)\n        {\n            var channel = await _youtube.Channels.GetByHandleAsync(\n                channelHandle,\n                cancellationToken\n            );\n\n            var videos = await _youtube.Channels.GetUploadsAsync(channel.Id, cancellationToken);\n\n            return new QueryResult(QueryResultKind.Channel, $\"Channel: {channel.Title}\", videos);\n        }\n\n        if (UserName.TryParse(query) is { } userName)\n        {\n            var channel = await _youtube.Channels.GetByUserAsync(userName, cancellationToken);\n            var videos = await _youtube.Channels.GetUploadsAsync(channel.Id, cancellationToken);\n\n            return new QueryResult(QueryResultKind.Channel, $\"Channel: {channel.Title}\", videos);\n        }\n\n        if (ChannelSlug.TryParse(query) is { } channelSlug)\n        {\n            var channel = await _youtube.Channels.GetBySlugAsync(channelSlug, cancellationToken);\n            var videos = await _youtube.Channels.GetUploadsAsync(channel.Id, cancellationToken);\n\n            return new QueryResult(QueryResultKind.Channel, $\"Channel: {channel.Title}\", videos);\n        }\n\n        return null;\n    }\n\n    private async Task<QueryResult> ResolveSearchAsync(\n        string query,\n        CancellationToken cancellationToken = default\n    )\n    {\n        var videos = await _youtube\n            .Search.GetVideosAsync(query, cancellationToken)\n            .CollectAsync(20);\n\n        return new QueryResult(QueryResultKind.Search, $\"Search: {query}\", videos);\n    }\n\n    public async Task<QueryResult> ResolveAsync(\n        string query,\n        CancellationToken cancellationToken = default\n    )\n    {\n        // If the query starts with a question mark, it's always treated as a search query\n        if (query.StartsWith('?'))\n            return await ResolveSearchAsync(query[1..], cancellationToken);\n\n        return await TryResolvePlaylistAsync(query, cancellationToken)\n            ?? await TryResolveVideoAsync(query, cancellationToken)\n            ?? await TryResolveChannelAsync(query, cancellationToken)\n            ?? await ResolveSearchAsync(query, cancellationToken);\n    }\n\n    public void Dispose() => _youtube.Dispose();\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Resolving/QueryResult.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing YoutubeExplode.Videos;\n\nnamespace YoutubeDownloader.Core.Resolving;\n\npublic record QueryResult(QueryResultKind Kind, string Title, IReadOnlyList<IVideo> Videos)\n{\n    public static QueryResult Aggregate(IReadOnlyList<QueryResult> results)\n    {\n        if (!results.Any())\n            throw new ArgumentException(\"Cannot aggregate empty results.\", nameof(results));\n\n        return new QueryResult(\n            // Single query -> inherit kind, multiple queries -> aggregate\n            results.Count == 1\n                ? results.Single().Kind\n                : QueryResultKind.Aggregate,\n            // Single query -> inherit title, multiple queries -> aggregate\n            results.Count == 1\n                ? results.Single().Title\n                : $\"{results.Count} queries\",\n            // Combine all videos, deduplicate by ID\n            results.SelectMany(q => q.Videos).DistinctBy(v => v.Id).ToArray()\n        );\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Resolving/QueryResultKind.cs",
    "content": "﻿namespace YoutubeDownloader.Core.Resolving;\n\npublic enum QueryResultKind\n{\n    Video,\n    Playlist,\n    Channel,\n    Search,\n    Aggregate,\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Tagging/MediaFile.cs",
    "content": "﻿using System;\nusing TagLib;\nusing TagFile = TagLib.File;\n\nnamespace YoutubeDownloader.Core.Tagging;\n\ninternal partial class MediaFile(TagFile file) : IDisposable\n{\n    public void SetThumbnail(byte[] thumbnailData) =>\n        file.Tag.Pictures = [new Picture(thumbnailData)];\n\n    public void SetArtist(string artist) => file.Tag.Performers = [artist];\n\n    public void SetArtistSort(string artistSort) => file.Tag.PerformersSort = [artistSort];\n\n    public void SetTitle(string title) => file.Tag.Title = title;\n\n    public void SetAlbum(string album) => file.Tag.Album = album;\n\n    public void SetDescription(string description) => file.Tag.Description = description;\n\n    public void SetComment(string comment) => file.Tag.Comment = comment;\n\n    public void Save()\n    {\n        file.Tag.DateTagged = DateTime.Now;\n        file.Save();\n    }\n\n    public void Dispose() => file.Dispose();\n}\n\ninternal partial class MediaFile\n{\n    public static MediaFile Open(string filePath) => new(TagFile.Create(filePath));\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Tagging/MediaTagInjector.cs",
    "content": "﻿using System;\nusing System.Linq;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing YoutubeDownloader.Core.Utils;\nusing YoutubeDownloader.Core.Utils.Extensions;\nusing YoutubeExplode.Videos;\n\nnamespace YoutubeDownloader.Core.Tagging;\n\npublic class MediaTagInjector\n{\n    private readonly MusicBrainzClient _musicBrainz = new();\n\n    private void InjectMiscMetadata(MediaFile mediaFile, IVideo video)\n    {\n        var description = (video as Video)?.Description;\n        if (!string.IsNullOrWhiteSpace(description))\n            mediaFile.SetDescription(description);\n\n        mediaFile.SetComment(\n            $\"\"\"\n            Downloaded using YoutubeDownloader (https://github.com/Tyrrrz/YoutubeDownloader)\n            Video: {video.Title}\n            Video URL: {video.Url}\n            Channel: {video.Author.ChannelTitle}\n            Channel URL: {video.Author.ChannelUrl}\n            \"\"\"\n        );\n    }\n\n    private async Task InjectMusicMetadataAsync(\n        MediaFile mediaFile,\n        IVideo video,\n        CancellationToken cancellationToken = default\n    )\n    {\n        var recordings = await _musicBrainz.SearchRecordingsAsync(video.Title, cancellationToken);\n\n        var recording = recordings.FirstOrDefault(r =>\n            // Recording title must be a part of the video title.\n            // Recording artist must be a part of the video title or channel title.\n            video.Title.Contains(r.Title, StringComparison.OrdinalIgnoreCase)\n            && (\n                video.Title.Contains(r.Artist, StringComparison.OrdinalIgnoreCase)\n                || video.Author.ChannelTitle.Contains(r.Artist, StringComparison.OrdinalIgnoreCase)\n            )\n        );\n\n        if (recording is null)\n            return;\n\n        mediaFile.SetArtist(recording.Artist);\n        mediaFile.SetTitle(recording.Title);\n\n        if (!string.IsNullOrWhiteSpace(recording.ArtistSort))\n            mediaFile.SetArtistSort(recording.ArtistSort);\n\n        if (!string.IsNullOrWhiteSpace(recording.Album))\n            mediaFile.SetAlbum(recording.Album);\n    }\n\n    private async Task InjectThumbnailAsync(\n        MediaFile mediaFile,\n        IVideo video,\n        CancellationToken cancellationToken = default\n    )\n    {\n        var thumbnailUrl =\n            video\n                .Thumbnails.Where(t =>\n                    string.Equals(t.TryGetImageFormat(), \"jpg\", StringComparison.OrdinalIgnoreCase)\n                )\n                .OrderByDescending(t => t.Resolution.Area)\n                .Select(t => t.Url)\n                .FirstOrDefault()\n            ?? $\"https://i.ytimg.com/vi/{video.Id}/hqdefault.jpg\";\n\n        mediaFile.SetThumbnail(\n            await Http.Client.GetByteArrayAsync(thumbnailUrl, cancellationToken)\n        );\n    }\n\n    public async Task InjectTagsAsync(\n        string filePath,\n        IVideo video,\n        CancellationToken cancellationToken = default\n    )\n    {\n        using var mediaFile = MediaFile.Open(filePath);\n\n        InjectMiscMetadata(mediaFile, video);\n        await InjectMusicMetadataAsync(mediaFile, video, cancellationToken);\n        await InjectThumbnailAsync(mediaFile, video, cancellationToken);\n\n        mediaFile.Save();\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Tagging/MusicBrainzClient.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Runtime.CompilerServices;\nusing System.Threading;\nusing JsonExtensions.Http;\nusing JsonExtensions.Reading;\nusing YoutubeDownloader.Core.Utils;\n\nnamespace YoutubeDownloader.Core.Tagging;\n\ninternal class MusicBrainzClient\n{\n    // 4 requests per second\n    private readonly ThrottleLock _throttleLock = new(TimeSpan.FromSeconds(1.0 / 4));\n\n    public async IAsyncEnumerable<MusicBrainzRecording> SearchRecordingsAsync(\n        string query,\n        [EnumeratorCancellation] CancellationToken cancellationToken = default\n    )\n    {\n        var url =\n            \"https://musicbrainz.org/ws/2/recording/\"\n            + \"?version=2\"\n            + \"&fmt=json\"\n            + \"&dismax=true\"\n            + \"&limit=100\"\n            + $\"&query={Uri.EscapeDataString(query)}\";\n\n        await _throttleLock.WaitAsync(cancellationToken);\n        var json = await Http.Client.GetJsonAsync(url, cancellationToken);\n\n        var recordingsJson =\n            json.GetPropertyOrNull(\"recordings\")?.EnumerateArrayOrNull() ?? default;\n\n        foreach (var recordingJson in recordingsJson)\n        {\n            var artist = recordingJson\n                .GetPropertyOrNull(\"artist-credit\")\n                ?.EnumerateArrayOrNull()\n                ?.FirstOrDefault()\n                .GetPropertyOrNull(\"name\")\n                ?.GetNonWhiteSpaceStringOrNull();\n\n            if (string.IsNullOrWhiteSpace(artist))\n                continue;\n\n            var artistSort = recordingJson\n                .GetPropertyOrNull(\"artist-credit\")\n                ?.EnumerateArrayOrNull()\n                ?.FirstOrDefault()\n                .GetPropertyOrNull(\"artist\")\n                ?.GetPropertyOrNull(\"sort-name\")\n                ?.GetNonWhiteSpaceStringOrNull();\n\n            var title = recordingJson.GetPropertyOrNull(\"title\")?.GetNonWhiteSpaceStringOrNull();\n\n            if (string.IsNullOrWhiteSpace(title))\n                continue;\n\n            var album = recordingJson\n                .GetPropertyOrNull(\"releases\")\n                ?.EnumerateArrayOrNull()\n                ?.FirstOrDefault()\n                .GetPropertyOrNull(\"title\")\n                ?.GetNonWhiteSpaceStringOrNull();\n\n            yield return new MusicBrainzRecording(artist, artistSort, title, album);\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Tagging/MusicBrainzRecording.cs",
    "content": "﻿namespace YoutubeDownloader.Core.Tagging;\n\ninternal record MusicBrainzRecording(\n    string Artist,\n    string? ArtistSort,\n    string Title,\n    string? Album\n);\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/Extensions/AsyncCollectionExtensions.cs",
    "content": "﻿using System.Collections.Generic;\nusing System.Runtime.CompilerServices;\nusing System.Threading.Tasks;\n\nnamespace YoutubeDownloader.Core.Utils.Extensions;\n\npublic static class AsyncCollectionExtensions\n{\n    extension<T>(IAsyncEnumerable<T> asyncEnumerable)\n    {\n        private async ValueTask<IReadOnlyList<T>> CollectAsync()\n        {\n            var list = new List<T>();\n\n            await foreach (var i in asyncEnumerable)\n                list.Add(i);\n\n            return list;\n        }\n\n        public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>\n            asyncEnumerable.CollectAsync().GetAwaiter();\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/Extensions/CollectionExtensions.cs",
    "content": "﻿using System.Collections.Generic;\n\nnamespace YoutubeDownloader.Core.Utils.Extensions;\n\npublic static class CollectionExtensions\n{\n    extension<T>(ICollection<T> source)\n    {\n        public void AddRange(IEnumerable<T> items)\n        {\n            foreach (var i in items)\n                source.Add(i);\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/Extensions/GenericExtensions.cs",
    "content": "﻿using System;\n\nnamespace YoutubeDownloader.Core.Utils.Extensions;\n\npublic static class GenericExtensions\n{\n    extension<TIn>(TIn input)\n    {\n        public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/Extensions/PathExtensions.cs",
    "content": "﻿using System.IO;\nusing System.Linq;\nusing System.Text;\n\nnamespace YoutubeDownloader.Core.Utils.Extensions;\n\npublic static class PathExtensions\n{\n    extension(Path)\n    {\n        public static string EscapeFileName(string path)\n        {\n            var buffer = new StringBuilder(path.Length);\n\n            foreach (var c in path)\n                buffer.Append(!Path.GetInvalidFileNameChars().Contains(c) ? c : '_');\n\n            return buffer.ToString();\n        }\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/Extensions/StringExtensions.cs",
    "content": "﻿namespace YoutubeDownloader.Core.Utils.Extensions;\n\npublic static class StringExtensions\n{\n    extension(string str)\n    {\n        public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null;\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/Extensions/YoutubeExtensions.cs",
    "content": "﻿using System.IO;\nusing YoutubeExplode.Common;\n\nnamespace YoutubeDownloader.Core.Utils.Extensions;\n\npublic static class YoutubeExtensions\n{\n    extension(Thumbnail thumbnail)\n    {\n        public string? TryGetImageFormat() =>\n            Url.TryExtractFileName(thumbnail.Url)?.Pipe(Path.GetExtension)?.Trim('.');\n    }\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/Http.cs",
    "content": "﻿using System.Net.Http;\nusing System.Net.Http.Headers;\nusing System.Reflection;\n\nnamespace YoutubeDownloader.Core.Utils;\n\npublic static class Http\n{\n    public static HttpClient Client { get; } =\n        new()\n        {\n            DefaultRequestHeaders =\n            {\n                // Required by some of the services we're using\n                UserAgent =\n                {\n                    new ProductInfoHeaderValue(\n                        \"YoutubeDownloader\",\n                        Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)\n                    ),\n                },\n            },\n        };\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/ThrottleLock.cs",
    "content": "﻿using System;\nusing System.Threading;\nusing System.Threading.Tasks;\n\nnamespace YoutubeDownloader.Core.Utils;\n\npublic class ThrottleLock(TimeSpan interval) : IDisposable\n{\n    private readonly SemaphoreSlim _semaphore = new(1, 1);\n    private DateTimeOffset _lastRequestInstant = DateTimeOffset.MinValue;\n\n    public async Task WaitAsync(CancellationToken cancellationToken = default)\n    {\n        await _semaphore.WaitAsync(cancellationToken);\n\n        try\n        {\n            var timePassedSinceLastRequest = DateTimeOffset.Now - _lastRequestInstant;\n\n            var remainingTime = interval - timePassedSinceLastRequest;\n            if (remainingTime > TimeSpan.Zero)\n                await Task.Delay(remainingTime, cancellationToken);\n\n            _lastRequestInstant = DateTimeOffset.Now;\n        }\n        finally\n        {\n            _semaphore.Release();\n        }\n    }\n\n    public void Dispose() => _semaphore.Dispose();\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/Utils/Url.cs",
    "content": "﻿using System.Text.RegularExpressions;\nusing YoutubeDownloader.Core.Utils.Extensions;\n\nnamespace YoutubeDownloader.Core.Utils;\n\npublic static class Url\n{\n    public static string? TryExtractFileName(string url) =>\n        Regex.Match(url, @\".+/([^?]*)\").Groups[1].Value.NullIfWhiteSpace();\n}\n"
  },
  {
    "path": "YoutubeDownloader.Core/YoutubeDownloader.Core.csproj",
    "content": "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <ItemGroup>\n    <PackageReference Include=\"CSharpier.MsBuild\" PrivateAssets=\"all\" />\n    <PackageReference Include=\"Gress\" />\n    <PackageReference Include=\"JsonExtensions\" />\n    <PackageReference Include=\"TagLibSharp\" />\n    <PackageReference Include=\"YoutubeExplode\" />\n    <PackageReference Include=\"YoutubeExplode.Converter\" />\n  </ItemGroup>\n</Project>\n"
  },
  {
    "path": "YoutubeDownloader.sln",
    "content": "﻿\nMicrosoft Visual Studio Solution File, Format Version 12.00\n# Visual Studio Version 17\nVisualStudioVersion = 17.7.33920.267\nMinimumVisualStudioVersion = 10.0.40219.1\nProject(\"{9A19103F-16F7-4668-BE54-9A1E7A4F7556}\") = \"YoutubeDownloader\", \"YoutubeDownloader\\YoutubeDownloader.csproj\", \"{AF6D645E-DDDD-4034-B644-D5328CC893C1}\"\nEndProject\nProject(\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\") = \"Misc\", \"Misc\", \"{131C2561-E5A1-43E8-BF38-40E2E23DB0A4}\"\n\tProjectSection(SolutionItems) = preProject\n\t\tDirectory.Build.props = Directory.Build.props\n\t\tDirectory.Packages.props = Directory.Packages.props\n\t\tLicense.txt = License.txt\n\t\tReadme.md = Readme.md\n\t\tglobal.json = global.json\n\t\tNuGet.config = NuGet.config\n\tEndProjectSection\nEndProject\nProject(\"{9A19103F-16F7-4668-BE54-9A1E7A4F7556}\") = \"YoutubeDownloader.Core\", \"YoutubeDownloader.Core\\YoutubeDownloader.Core.csproj\", \"{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}\"\nEndProject\nGlobal\n\tGlobalSection(SolutionConfigurationPlatforms) = preSolution\n\t\tDebug|Any CPU = Debug|Any CPU\n\t\tRelease|Any CPU = Release|Any CPU\n\tEndGlobalSection\n\tGlobalSection(ProjectConfigurationPlatforms) = postSolution\n\t\t{AF6D645E-DDDD-4034-B644-D5328CC893C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{AF6D645E-DDDD-4034-B644-D5328CC893C1}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{AF6D645E-DDDD-4034-B644-D5328CC893C1}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{AF6D645E-DDDD-4034-B644-D5328CC893C1}.Release|Any CPU.Build.0 = Release|Any CPU\n\t\t{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{5122A9DE-232C-4DA8-AD76-8B72AA377D5E}.Release|Any CPU.Build.0 = Release|Any CPU\n\tEndGlobalSection\n\tGlobalSection(SolutionProperties) = preSolution\n\t\tHideSolutionNode = FALSE\n\tEndGlobalSection\n\tGlobalSection(ExtensibilityGlobals) = postSolution\n\t\tSolutionGuid = {1455235F-4357-4DB9-BCC1-41A5A8B10AC5}\n\tEndGlobalSection\nEndGlobal\n"
  },
  {
    "path": "global.json",
    "content": "{\n  \"sdk\": {\n    \"version\": \"10.0.100\",\n    \"rollForward\": \"latestFeature\"\n  }\n}"
  }
]