diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f01f4ff..47045ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,10 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: { dotnet-version: '8.0.x' } + - uses: actions/setup-node@v4 + with: { node-version: '24.x' } - run: dotnet restore Ripmedia.sln + - run: dotnet build src/Ripmedia.Cli/Ripmedia.Cli.csproj --no-restore - run: dotnet test Ripmedia.sln --configuration Release --no-restore - run: dotnet publish src/Ripmedia.Cli/Ripmedia.Cli.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output artifacts/app - run: artifacts/app/ripmedia.exe version diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..f3f14fb --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,37 @@ +name: Deploy Pages + +on: + push: + branches: [main] + paths: + - site/** + - .github/workflows/pages.yml + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Upload site artifact + uses: actions/upload-pages-artifact@v4 + with: + path: site + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2aa8552..5e62fda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: { dotnet-version: '8.0.x' } + - uses: actions/setup-node@v4 + with: { node-version: '24.x' } - run: dotnet publish src/Ripmedia.Cli/Ripmedia.Cli.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output artifacts/ripmedia - name: Add download engines shell: pwsh @@ -18,10 +20,23 @@ jobs: New-Item -ItemType Directory artifacts/ripmedia/tools | Out-Null Invoke-WebRequest $deps.ytDlp.url -OutFile artifacts/ripmedia/tools/yt-dlp.exe Invoke-WebRequest $deps.ffmpeg.url -OutFile artifacts/ffmpeg.zip + Invoke-WebRequest $deps.youtubePoTokenProvider.pluginUrl -OutFile artifacts/bgutil-ytdlp-pot-provider.zip if ((Get-FileHash artifacts/ripmedia/tools/yt-dlp.exe -Algorithm SHA256).Hash.ToLowerInvariant() -ne $deps.ytDlp.sha256) { throw 'yt-dlp checksum mismatch' } if ((Get-FileHash artifacts/ffmpeg.zip -Algorithm SHA256).Hash.ToLowerInvariant() -ne $deps.ffmpeg.sha256) { throw 'FFmpeg checksum mismatch' } + if ((Get-FileHash artifacts/bgutil-ytdlp-pot-provider.zip -Algorithm SHA256).Hash.ToLowerInvariant() -ne $deps.youtubePoTokenProvider.pluginSha256) { throw 'YouTube PO-token plugin checksum mismatch' } Expand-Archive artifacts/ffmpeg.zip artifacts/ffmpeg Copy-Item (Get-ChildItem artifacts/ffmpeg -Recurse -Filter ffmpeg.exe | Select-Object -First 1).FullName artifacts/ripmedia/tools/ffmpeg.exe + New-Item -ItemType Directory artifacts/ripmedia/tools/yt-dlp-plugins | Out-Null + Copy-Item artifacts/bgutil-ytdlp-pot-provider.zip artifacts/ripmedia/tools/yt-dlp-plugins/bgutil-ytdlp-pot-provider.zip + git clone --no-checkout $deps.youtubePoTokenProvider.repository artifacts/pot-provider + git -C artifacts/pot-provider checkout $deps.youtubePoTokenProvider.commit + Push-Location artifacts/pot-provider/server + npm ci --include=dev + npx --no-install tsc + Pop-Location + New-Item -ItemType Directory artifacts/ripmedia/tools/pot-provider | Out-Null + Copy-Item artifacts/pot-provider/server artifacts/ripmedia/tools/pot-provider/server -Recurse + Copy-Item (Get-Command node.exe).Source artifacts/ripmedia/tools/pot-provider/node.exe - name: Package shell: pwsh run: | diff --git a/.gitignore b/.gitignore index c2149d0..8f97720 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ bin/ obj/ artifacts/ TestResults/ - +src/Ripmedia.Cli/tools/ diff --git a/CutoutLogo.png b/CutoutLogo.png new file mode 100644 index 0000000..4852ee5 Binary files /dev/null and b/CutoutLogo.png differ diff --git a/FullAppIconLogo.png b/FullAppIconLogo.png new file mode 100644 index 0000000..69b4723 Binary files /dev/null and b/FullAppIconLogo.png differ diff --git a/README.md b/README.md index 7f0ae07..cc96679 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,23 @@ The installer downloads a verified GitHub Release bundle. It includes ripmedia, ```powershell ripmedia "https://soundcloud.com/artist/track" ripmedia download "https://example.com/video" --format mp4 +ripmedia "https://example.com/video" --mp4 ripmedia "https://example.com/audio" --mp3 ripmedia info "https://example.com/video" --formats ripmedia cookies ``` -By default ripmedia saves the best source-provided combined file to Downloads without transcoding. `--audio` selects source audio; `--mp3` and `--format` explicitly convert using FFmpeg. Supported conversion targets are `mp3`, `m4a`, `aac`, `flac`, `ogg`, `opus`, `wav`, `mp4`, `mkv`, and `webm`. +By default ripmedia saves the highest-quality source video and audio streams available to Downloads, merging them with FFmpeg when needed, without transcoding. When you select a browser profile with `ripmedia cookies`, YouTube downloads use that profile's authorized session and the bundled local token provider to access high-quality streams. `--audio` selects the best source audio; `--mp3`, `--mp4`, and `--format` explicitly convert using FFmpeg. Supported conversion targets are `mp3`, `m4a`, `aac`, `flac`, `ogg`, `opus`, `wav`, `mp4`, `mkv`, and `webm`. + +Run `ripmedia config` to open `settings.json` in your default JSON or text editor. Leave `DefaultAudioFormat` and `DefaultVideoFormat` blank to keep source formats; set them to a supported audio or video format to convert downloads of that media type. Per-download `--format`, `--mp3`, and `--mp4` options take precedence. SoundCloud playlists are saved as numbered collection folders and tagged with available metadata and artwork. TikTok downloads the best stream made available by the extractor; ripmedia does not remove an embedded watermark or bypass DRM, paywalls, logins, or access controls. ## Commands -`download` (default), `info`, `cookies`, `config`, `update`, `version`, and `help`. +`download` (default), `info`, `cookies`, `config`, `webhost`, `update`, `version`, and `help`. + +`ripmedia webhost` opens a minimal local downloader at `http://127.0.0.1:4747`. Paste a URL followed by any download flags and press Enter to queue it (for example, `https://example.com/video --mp3`); further URLs run in parallel. Quote flag values containing spaces, such as `--output-dir "C:\Media Files"`. Keep the terminal open while downloads run; press Ctrl+C to stop the web host. It skips browser-profile cookies because it opens a browser itself; use a configured cookies file if an authorized URL requires authentication. `ripmedia cookies` shows an interactive browser-profile menu. It stores a profile reference only; it does not export browser cookies. You may also pass `--cookies ` or `--cookies-from-browser ` per command. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index bebed63..0277d7c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -3,6 +3,8 @@ Ripmedia bundles or invokes these separately licensed components: - [yt-dlp](https://github.com/yt-dlp/yt-dlp) (Unlicense) +- [BgUtils POT Provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider) (GPL-3.0) +- [Node.js](https://nodejs.org/) (MIT) - [FFmpeg](https://ffmpeg.org/legal.html) (LGPL/GPL, depending on the selected build) - [Spectre.Console](https://spectreconsole.net/) (MIT) - [TagLibSharp](https://github.com/mono/taglib-sharp) (LGPL-2.1-or-later) diff --git a/eng/dependencies.json b/eng/dependencies.json index 470e4c7..d743373 100644 --- a/eng/dependencies.json +++ b/eng/dependencies.json @@ -8,5 +8,12 @@ "version": "9.0.1", "url": "https://github.com/GyanD/codexffmpeg/releases/download/9.0.1/ffmpeg-9.0.1-essentials_build.zip", "sha256": "fec81ae03971d9dd4be3ebe02e263bd2ec1d789483f931bdba5f5715e65da2e9" + }, + "youtubePoTokenProvider": { + "version": "1.3.1", + "repository": "https://github.com/Brainicism/bgutil-ytdlp-pot-provider.git", + "commit": "7608dd51ee813b48cf9a6d68c6e42cb197ce10e0", + "pluginUrl": "https://github.com/Brainicism/bgutil-ytdlp-pot-provider/releases/download/1.3.1/bgutil-ytdlp-pot-provider.zip", + "pluginSha256": "b8ceec7f76143da172aaf5ebeec0c2d218e5680c063b931586bca48567069b38" } } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d58cfb4..14429d4 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -14,9 +14,12 @@ New-Item -ItemType Directory -Path $temp | Out-Null try { $zip = Join-Path $temp 'ripmedia-win-x64.zip' Invoke-WebRequest $zipAsset.browser_download_url -OutFile $zip - $sums = (Invoke-WebRequest $sumAsset.browser_download_url).Content + # Windows PowerShell 5.1 exposes Invoke-WebRequest .Content as a byte array. + # Invoke-RestMethod decodes this text asset consistently in both Windows + # PowerShell and PowerShell 7+, so the checksum regex sees the actual file. + $sums = Invoke-RestMethod $sumAsset.browser_download_url -Headers @{ 'User-Agent' = 'ripmedia-installer' } $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() - if ($sums -notmatch "(?im)^$hash\s+\*?ripmedia-win-x64\.zip$") { throw 'Release checksum verification failed.' } + if ($sums -notmatch "(?im)^$hash\s+\*?ripmedia-win-x64\.zip\r?$") { throw 'Release checksum verification failed.' } $target = Join-Path $versions $release.tag_name if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Force -Recurse } New-Item -ItemType Directory -Path $target -Force | Out-Null diff --git a/scripts/restore-tools.ps1 b/scripts/restore-tools.ps1 new file mode 100644 index 0000000..39834ae --- /dev/null +++ b/scripts/restore-tools.ps1 @@ -0,0 +1,78 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$Destination +) + +$ErrorActionPreference = 'Stop' + +$manifestPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'eng/dependencies.json' +$dependencies = Get-Content -Raw $manifestPath | ConvertFrom-Json + +New-Item -ItemType Directory -Path $Destination -Force | Out-Null + +function Restore-File { + param( + [Parameter(Mandatory)]$Dependency, + [Parameter(Mandatory)][string]$Path + ) + + if (Test-Path -LiteralPath $Path) { + $actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -eq $Dependency.sha256) { return } + Remove-Item -LiteralPath $Path -Force + } + + Invoke-WebRequest -Uri $Dependency.url -OutFile $Path + $actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $Dependency.sha256) { + Remove-Item -LiteralPath $Path -Force + throw "Checksum verification failed for $(Split-Path $Path -Leaf)." + } +} + +Restore-File -Dependency $dependencies.ytDlp -Path (Join-Path $Destination 'yt-dlp.exe') + +$ffmpegZip = Join-Path ([IO.Path]::GetTempPath()) ("ripmedia-ffmpeg-" + [guid]::NewGuid() + '.zip') +$ffmpegDirectory = Join-Path ([IO.Path]::GetTempPath()) ("ripmedia-ffmpeg-" + [guid]::NewGuid()) +try { + Restore-File -Dependency $dependencies.ffmpeg -Path $ffmpegZip + Expand-Archive -LiteralPath $ffmpegZip -DestinationPath $ffmpegDirectory + $ffmpeg = Get-ChildItem -LiteralPath $ffmpegDirectory -Recurse -Filter ffmpeg.exe | Select-Object -First 1 + if (-not $ffmpeg) { throw 'The FFmpeg archive did not contain ffmpeg.exe.' } + Copy-Item -LiteralPath $ffmpeg.FullName -Destination (Join-Path $Destination 'ffmpeg.exe') -Force +} finally { + if (Test-Path -LiteralPath $ffmpegZip) { Remove-Item -LiteralPath $ffmpegZip -Force } + if (Test-Path -LiteralPath $ffmpegDirectory) { Remove-Item -LiteralPath $ffmpegDirectory -Force -Recurse } +} + +$node = Get-Command node.exe -ErrorAction SilentlyContinue +$npm = Get-Command npm -ErrorAction SilentlyContinue +$git = Get-Command git -ErrorAction SilentlyContinue +if (-not $node -or -not $npm -or -not $git) { throw 'Restoring YouTube high-quality support requires Node.js, npm, and Git.' } + +$provider = $dependencies.youtubePoTokenProvider +$providerSource = Join-Path ([IO.Path]::GetTempPath()) ("ripmedia-pot-provider-" + [guid]::NewGuid()) +$pluginDirectory = Join-Path $Destination 'yt-dlp-plugins' +$plugin = Join-Path $pluginDirectory 'bgutil-ytdlp-pot-provider.zip' +$providerDirectory = Join-Path $Destination 'pot-provider' +try { + New-Item -ItemType Directory -Path $pluginDirectory -Force | Out-Null + Restore-File -Dependency ([pscustomobject]@{ url = $provider.pluginUrl; sha256 = $provider.pluginSha256 }) -Path $plugin + & $git.Source clone --no-checkout $provider.repository $providerSource + if ($LASTEXITCODE -ne 0) { throw 'Could not download the YouTube PO-token provider source.' } + & $git.Source -C $providerSource checkout $provider.commit + if ($LASTEXITCODE -ne 0) { throw 'Could not verify the YouTube PO-token provider source.' } + Push-Location (Join-Path $providerSource 'server') + try { + & $npm.Source ci --include=dev + if ($LASTEXITCODE -ne 0) { throw 'Could not install the YouTube PO-token provider dependencies.' } + & (Join-Path (Get-Location) 'node_modules\.bin\tsc.cmd') + if ($LASTEXITCODE -ne 0) { throw 'Could not build the YouTube PO-token provider.' } + } finally { Pop-Location } + New-Item -ItemType Directory -Path $providerDirectory -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $providerSource 'server') -Destination (Join-Path $providerDirectory 'server') -Recurse -Force + Copy-Item -LiteralPath $node.Source -Destination (Join-Path $providerDirectory 'node.exe') -Force +} finally { + if (Test-Path -LiteralPath $providerSource) { Remove-Item -LiteralPath $providerSource -Force -Recurse } +} diff --git a/site/assets/CutoutLogo.png b/site/assets/CutoutLogo.png new file mode 100644 index 0000000..4852ee5 Binary files /dev/null and b/site/assets/CutoutLogo.png differ diff --git a/site/assets/FullAppIconLogo.png b/site/assets/FullAppIconLogo.png new file mode 100644 index 0000000..69b4723 Binary files /dev/null and b/site/assets/FullAppIconLogo.png differ diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..57de2f0 --- /dev/null +++ b/site/index.html @@ -0,0 +1,259 @@ + + + + + + + + ripmedia + + + + + +
+
+
+
ripmedia
+

An intuitive CLI tool for downloading media.

+
+ +
+
+ install +
+ irm https://raw.githubusercontent.com/mqt464/ripmedia/main/scripts/install.ps1 | iex +
+
+
+ + +
+
+ + + diff --git a/src/Ripmedia.Cli/Program.cs b/src/Ripmedia.Cli/Program.cs index 160b5c5..23be4a2 100644 --- a/src/Ripmedia.Cli/Program.cs +++ b/src/Ripmedia.Cli/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Diagnostics; using Ripmedia.Core; using Spectre.Console; using Spectre.Console.Rendering; @@ -13,15 +14,16 @@ public static async Task RunAsync(string[] args) var store = new SettingsStore(); var settings = await store.LoadAsync(); if (args.Length == 0 || args[0] is "help" or "--help" or "-h") { Help(); return 0; } var command = args[0].ToLowerInvariant(); - if (command is not ("download" or "info" or "cookies" or "config" or "update" or "version")) return await DownloadAsync(args, settings, store); + if (command is not ("download" or "info" or "cookies" or "config" or "update" or "version" or "webhost")) return await DownloadAsync(args, settings, store); return command switch { "download" => await DownloadAsync(args[1..], settings, store), "info" => await InfoAsync(args[1..], settings), "cookies" => await CookiesAsync(args[1..], settings, store), - "config" => await ConfigAsync(args[1..], settings, store), + "config" => Config(args[1..], store), "update" => await UpdateAsync(), "version" => Version(), + "webhost" => await WebHost.RunAsync(args[1..], settings), _ => 2 }; } @@ -46,22 +48,47 @@ private static async Task DownloadAsync(string[] args, RipmediaSettings set private static async Task RunWithLiveUiAsync(DownloadPipeline pipeline, DownloadRequest request, RipmediaSettings settings) { - var rows = new List(); var percent = 0d; + var title = "media"; var speed = (string?)null; var eta = (string?)null; + var stages = new List(); DownloadResult? result = null; - await AnsiConsole.Live(Render()).AutoClear(false).StartAsync(async context => + await AnsiConsole.Progress() + .AutoClear(false) + .Columns( + new SpinnerColumn(Spinner.Known.Ascii) + .Style(Style.Parse("#d6a1ac")) + .CompletedStyle(Style.Parse("#b8ceb8")), + new TaskDescriptionColumn(), + new ProgressBarColumn() + .CompletedStyle(Style.Parse("#d6a1ac")) + .FinishedStyle(Style.Parse("#b8ceb8")) + .RemainingStyle(Style.Parse("#5e555a")), + new PercentageColumn { Style = Style.Parse("#e0c0c6"), CompletedStyle = Style.Parse("#b8ceb8") }, + new DownloadStatsColumn(() => FormatDownloadStats(speed, eta))) + .UseRenderHook((progress, _) => RenderStages(progress, stages)) + .StartAsync(async context => { + var task = context.AddTask("[#d6a1ac]Preparing[/] media", maxValue: 100); result = await pipeline.RunAsync(request, settings, - stage => { rows.Add(stage); context.UpdateTarget(Render()); }, - progress => { if (progress.Percentage is not null) percent = progress.Percentage.Value; context.UpdateTarget(Render()); }, CancellationToken.None); - context.UpdateTarget(Render()); + stage => + { + stages.Add(stage); + if (!string.IsNullOrWhiteSpace(stage.Detail)) title = UiText.Truncate(stage.Detail, 48); + task.Description(Describe(stage.Success ? stage.Name : $"{stage.Name} failed", title)); + }, + progress => + { + if (progress.Percentage is not null) task.Value(Math.Clamp(progress.Percentage.Value, 0, 100)); + if (!string.IsNullOrWhiteSpace(progress.Speed)) speed = progress.Speed; + if (!string.IsNullOrWhiteSpace(progress.Eta)) eta = progress.Eta; + if (progress.Percentage is not null || progress.Speed is not null || progress.Eta is not null) + task.Description(Describe("Downloading", title)); + }, CancellationToken.None); + task.Value(100); + speed = null; eta = null; + task.Description(result.Paths.Count > 0 ? "[#b8ceb8]Download complete[/]" : "[#d69ba6]Download finished with errors[/]"); + task.StopTask(); }); return result!; - IRenderable Render() - { - var chart = new BarChart().Width(60).AddItem("Downloading", Math.Clamp(percent, 0, 100), Color.Cyan1); - IRenderable text = rows.Count == 0 ? new Markup("[grey]Preparing…[/]") : new Rows(rows.Select(row => (IRenderable)new Markup($"[{(row.Success ? "green" : "red")}]{(row.Success ? "✓" : "×")}[/] {Markup.Escape(row.Name)}" + (string.IsNullOrWhiteSpace(row.Detail) ? string.Empty : $" [grey]{Markup.Escape(Shorten(row.Detail))}[/]")))); - return new Rows(chart, text); - } } private static async Task InfoAsync(string[] args, RipmediaSettings settings) @@ -106,24 +133,23 @@ private static async Task CookiesAsync(string[] args, RipmediaSettings sett settings.BrowserCookieProfile = selected; await store.SaveAsync(settings); AnsiConsole.MarkupLine($"[green]Using[/] {Markup.Escape(selected.ToString())}"); return 0; } - private static async Task ConfigAsync(string[] args, RipmediaSettings settings, SettingsStore store) + private static int Config(string[] args, SettingsStore store) { - if (args.Length == 0) { Console.WriteLine(JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true })); return 0; } - if (args[0].Equals("path", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine(store.SettingsPath); return 0; } - var offset = args[0].Equals("set", StringComparison.OrdinalIgnoreCase) ? 1 : 0; - var expression = args.Skip(offset).FirstOrDefault(); - if (expression is null) { Error("Use config set key=value."); return 2; } - var pair = expression.Split('=', 2); if (pair.Length != 2) { Error("Use config set key=value."); return 2; } - switch (pair[0].Trim().Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant()) + if (args.Length != 0) + { + Error("The config command does not accept arguments."); + return 2; + } + try + { + Process.Start(new ProcessStartInfo { FileName = store.SettingsPath, UseShellExecute = true }); + return 0; + } + catch (Exception error) { - case "outputdir": settings.OutputDirectory = pair[1].Trim(); break; - case "speedunit": settings.SpeedUnit = pair[1].Trim(); break; - case "showfilesize": settings.ShowFileSize = ParseBool(pair[1]); break; - case "nocolor": settings.NoColor = ParseBool(pair[1]); break; - case "cookies": settings.CookieFile = string.Equals(pair[1].Trim(), "none", StringComparison.OrdinalIgnoreCase) ? null : pair[1].Trim(); break; - default: Error("Supported settings: output_dir, speed_unit, show_file_size, no_color, cookies."); return 2; + Error($"Could not open the configuration file: {error.Message}"); + return 1; } - await store.SaveAsync(settings); AnsiConsole.MarkupLine("[green]Configuration saved.[/]"); return 0; } private static async Task UpdateAsync() @@ -133,7 +159,7 @@ private static async Task UpdateAsync() } private static int Version() { Console.WriteLine(typeof(Application).Assembly.GetName().Version?.ToString(3) ?? "0.1.0"); return 0; } - private static DownloadRequest ParseDownload(string[] args) + internal static DownloadRequest ParseDownload(string[] args) { var inputs = new List(); string? output = null; string? format = null; string? cookies = null; string? browser = null; var audio = false; var noPlaylist = false; var quiet = false; var verbose = false; var debug = false; var printPath = false; var noColor = false; string? speed = null; for (var i = 0; i < args.Length; i++) @@ -141,15 +167,47 @@ private static DownloadRequest ParseDownload(string[] args) var arg = args[i]; string Value() => ++i < args.Length ? args[i] : throw new ArgumentException($"{arg} requires a value."); switch (arg) { - case "--output-dir": output = Value(); break; case "--format": format = Value(); break; case "--cookies": cookies = Value(); break; case "--cookies-from-browser": browser = Value(); break; case "--speed-unit": speed = Value(); break; - case "--audio": audio = true; break; case "--mp3": format = "mp3"; break; case "--no-playlist": noPlaylist = true; break; case "--quiet": quiet = true; break; case "--verbose": case "-v": verbose = true; break; case "--debug": debug = true; break; case "--print-path": printPath = true; break; case "--no-color": noColor = true; break; + case "--output-dir": output = Value(); break; + case "--format": format = Value(); break; + case "--cookies": cookies = Value(); break; + case "--cookies-from-browser": browser = Value(); break; + case "--speed-unit": speed = Value(); break; + case "--audio": audio = true; break; + case "--mp3": format = "mp3"; break; + case "--mp4": format = "mp4"; break; + case "--no-playlist": noPlaylist = true; break; + case "--quiet": quiet = true; break; + case "--verbose": + case "-v": verbose = true; break; + case "--debug": debug = true; break; + case "--print-path": printPath = true; break; + case "--no-color": noColor = true; break; default: if (arg.StartsWith('-')) throw new ArgumentException($"Unknown option: {arg}"); else inputs.Add(arg); break; } } return new DownloadRequest(inputs, output, audio, FormatRules.Normalize(format), noPlaylist, cookies, browser, quiet, verbose, debug, printPath, noColor, speed); } - private static bool ParseBool(string value) => value.Trim().ToLowerInvariant() is "1" or "true" or "yes" or "on"; - private static string Shorten(string text) => text.Length <= 72 ? text : text[..69] + "..."; + private static IRenderable RenderStages(IRenderable progress, IReadOnlyList stages) + { + var rows = new List { progress }; + rows.AddRange(stages.Select(stage => (IRenderable)new Markup($"[{(stage.Success ? "#b8ceb8" : "#d69ba6")}]{(stage.Success ? "✓" : "×")}[/] {Markup.Escape(stage.Name)}" + (string.IsNullOrWhiteSpace(stage.Detail) ? string.Empty : $" [#a99ba0]{Markup.Escape(UiText.Truncate(stage.Detail))}[/]")))); + return new Rows(rows); + } + private static string Describe(string action, string title) => $"[#d6a1ac]{Markup.Escape(action)}[/] {Markup.Escape(title)}"; + private static string FormatDownloadStats(string? speed, string? eta) + { + var transfer = new List(); + if (!string.IsNullOrWhiteSpace(speed)) transfer.Add(speed); + if (!string.IsNullOrWhiteSpace(eta)) transfer.Add($"ETA {eta}"); + return string.Join(" · ", transfer); + } private static void Error(string message) => AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}"); - private static void Help() => AnsiConsole.WriteLine("ripmedia — source-format-preserving media downloader\n\nUsage: ripmedia [flags]\n ripmedia download [flags]\n ripmedia info [--json] [--formats]\n ripmedia cookies [list|clear]\n ripmedia config [path|set key=value]\n ripmedia update\n\nFlags: --audio --mp3 --format --output-dir --no-playlist --cookies --cookies-from-browser "); + private static void Help() => AnsiConsole.WriteLine("ripmedia — source-format-preserving media downloader\n\nUsage: ripmedia [flags]\n ripmedia download [flags]\n ripmedia info [--json] [--formats]\n ripmedia cookies [list|clear]\n ripmedia config\n ripmedia webhost\n ripmedia update\n\nFlags: --audio --mp3 --mp4 --format --output-dir --no-playlist --cookies --cookies-from-browser "); +} + +internal sealed class DownloadStatsColumn(Func getText) : ProgressColumn +{ + protected override bool NoWrap => true; + public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan delta) => new Markup($"[#a99ba0]{Markup.Escape(getText())}[/]"); + public override int? GetColumnWidth(RenderOptions options) => 24; } diff --git a/src/Ripmedia.Cli/Ripmedia.Cli.csproj b/src/Ripmedia.Cli/Ripmedia.Cli.csproj index 086a0b8..e463be7 100644 --- a/src/Ripmedia.Cli/Ripmedia.Cli.csproj +++ b/src/Ripmedia.Cli/Ripmedia.Cli.csproj @@ -8,4 +8,17 @@ + + + + + + + + + + + + + diff --git a/src/Ripmedia.Cli/UiText.cs b/src/Ripmedia.Cli/UiText.cs new file mode 100644 index 0000000..26d8ec4 --- /dev/null +++ b/src/Ripmedia.Cli/UiText.cs @@ -0,0 +1,5 @@ +internal static class UiText +{ + public static string Truncate(string text, int maximumLength = 72) => + text.Length <= maximumLength ? text : text[..(maximumLength - 3)] + "..."; +} diff --git a/src/Ripmedia.Cli/WebHost.cs b/src/Ripmedia.Cli/WebHost.cs new file mode 100644 index 0000000..d7a8b47 --- /dev/null +++ b/src/Ripmedia.Cli/WebHost.cs @@ -0,0 +1,396 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using Ripmedia.Core; +using Spectre.Console; + +internal static class WebHost +{ + private const string Address = "http://127.0.0.1:4747/"; + private static readonly ConcurrentDictionary Downloads = new(); + private static readonly ConcurrentDictionary EventClients = new(); + private static readonly SemaphoreSlim EventWriteLock = new(1, 1); + private static readonly HttpClient Http = new(); + + public static async Task RunAsync(string[] args, RipmediaSettings settings) + { + if (args.Length != 0) + { + AnsiConsole.MarkupLine("[red]Error:[/] The webhost command does not accept arguments."); + return 2; + } + + using var listener = new HttpListener(); + listener.Prefixes.Add(Address); + try { listener.Start(); } + catch (HttpListenerException error) + { + AnsiConsole.MarkupLine($"[red]Error:[/] Could not start the web interface: {Markup.Escape(error.Message)}"); + return 1; + } + + try + { + Process.Start(new ProcessStartInfo { FileName = Address, UseShellExecute = true }); + AnsiConsole.MarkupLine($"[#b8ceb8]Web interface running[/] at [#d6a1ac]{Address}[/]. Press Ctrl+C to stop."); + while (listener.IsListening) + { + var context = await listener.GetContextAsync(); + _ = HandleAsync(context, settings); + } + } + catch (HttpListenerException) when (!listener.IsListening) { } + catch (Exception error) + { + AnsiConsole.MarkupLine($"[red]Error:[/] Web interface stopped: {Markup.Escape(error.Message)}"); + return 1; + } + + return 0; + } + + private static async Task HandleAsync(HttpListenerContext context, RipmediaSettings settings) + { + try + { + if (context.Request.HttpMethod == "GET" && context.Request.Url?.AbsolutePath == "/") + await SendAsync(context.Response, "text/html; charset=utf-8", DashboardPage()); + else if (context.Request.HttpMethod == "POST" && context.Request.Url?.AbsolutePath == "/download") + await QueueDownloadAsync(context, settings); + else if (context.Request.HttpMethod == "GET" && context.Request.Url?.AbsolutePath.StartsWith("/download/", StringComparison.Ordinal) == true) + await SendStatusPageAsync(context); + else if (context.Request.HttpMethod == "GET" && context.Request.Url?.AbsolutePath == "/api/downloads") + await SendDownloadStatusAsync(context); + else if (context.Request.HttpMethod == "GET" && context.Request.Url?.AbsolutePath == "/events") + await ListenForEventsAsync(context); + else if (context.Request.HttpMethod == "GET" && context.Request.Url?.AbsolutePath.StartsWith("/open/", StringComparison.Ordinal) == true) + await OpenDownloadAsync(context); + else + await SendAsync(context.Response, "text/plain; charset=utf-8", "Not found.", HttpStatusCode.NotFound); + } + catch (Exception error) + { + try { await SendAsync(context.Response, "text/html; charset=utf-8", StatusPage(new(DateTimeOffset.UtcNow, true, false, null, error.Message, null, null, null, []), false), HttpStatusCode.InternalServerError); } + catch (HttpListenerException) { } + } + finally { context.Response.Close(); } + } + + private static async Task QueueDownloadAsync(HttpListenerContext context, RipmediaSettings settings) + { + using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding ?? Encoding.UTF8); + var commands = ParseFormValues(await reader.ReadToEndAsync(), "url") + .SelectMany(value => value.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + var requests = new List(); + try + { + foreach (var command in commands) + { + var request = Application.ParseDownload(SplitCommandLine(command)); + if (request.Inputs.Count == 0) throw new ArgumentException("Paste a URL followed by any download flags."); + requests.Add(request); + } + } + catch (ArgumentException error) + { + await SendAsync(context.Response, "text/html; charset=utf-8", StatusPage(new(DateTimeOffset.UtcNow, true, false, null, error.Message, null, null, null, []), false), HttpStatusCode.BadRequest); + return; + } + if (requests.Count == 0) + { + await SendAsync(context.Response, "text/html; charset=utf-8", StatusPage(new(DateTimeOffset.UtcNow, true, false, null, "Paste a URL followed by any download flags.", null, null, null, []), false), HttpStatusCode.BadRequest); + return; + } + var queue = new List<(DownloadRequest Request, Uri Url)>(); + foreach (var request in requests) + { + foreach (var input in request.Inputs) + { + if (!Uri.TryCreate(input, UriKind.Absolute, out var url) || url.Scheme is not ("http" or "https")) + { + await SendAsync(context.Response, "text/html; charset=utf-8", StatusPage(new(DateTimeOffset.UtcNow, true, false, null, "Enter valid http(s) URLs followed by download flags.", null, null, null, []), false), HttpStatusCode.BadRequest); + return; + } + queue.Add((request, url)); + } + } + foreach (var item in queue) + { + var id = Guid.NewGuid().ToString("N"); + Downloads[id] = new(DateTimeOffset.UtcNow, false, false, item.Url.Host, "Preparing download…", null, null, null, []); + _ = RunDownloadAsync(id, item.Request with { Inputs = [item.Url.AbsoluteUri], Quiet = true }, settings); + } + _ = BroadcastDownloadsAsync(); + context.Response.StatusCode = (int)HttpStatusCode.SeeOther; + context.Response.RedirectLocation = "/"; + await context.Response.OutputStream.FlushAsync(); + } + + private static async Task RunDownloadAsync(string id, DownloadRequest request, RipmediaSettings settings) + { + try + { + var pipeline = new DownloadPipeline(new YtDlpClient(), new MediaTagger(Http)); + var result = await pipeline.RunAsync(request, settings.WithoutBrowserCookies(), + stage => UpdateDownload(id, download => download with + { + Message = stage.Success ? stage.Name : $"{stage.Name} failed", + Title = !string.IsNullOrWhiteSpace(stage.Detail) && (stage.Name is "Inspect" or "Download") ? stage.Detail + : !string.IsNullOrWhiteSpace(stage.Detail) && stage.Name == "Saved" ? Path.GetFileName(stage.Detail) : download.Title, + Progress = stage.Name == "Download" ? download.Progress : null, + Stages = [.. download.Stages, stage] + }), + progress => UpdateDownload(id, download => download with + { + Message = "Downloading…", + Progress = progress.Percentage ?? download.Progress, + Speed = progress.Speed ?? download.Speed, + Eta = progress.Eta ?? download.Eta + }), CancellationToken.None); + var failed = result.Stages.LastOrDefault(stage => !stage.Success); + UpdateDownload(id, download => result.Paths.Count > 0 + ? download with { Done = true, Ok = true, Message = "Download complete.", Progress = 100, Eta = null, Paths = result.Paths } + : download with { Done = true, Ok = false, Message = failed?.Detail ?? "Download failed.", Eta = null }); + } + catch (Exception error) { UpdateDownload(id, download => download with { Done = true, Ok = false, Message = error.Message, Eta = null }); } + } + + private static Task SendStatusPageAsync(HttpListenerContext context) + { + var id = context.Request.Url!.AbsolutePath["/download/".Length..]; + return Downloads.TryGetValue(id, out var download) + ? SendAsync(context.Response, "text/html; charset=utf-8", StatusPage(download, !download.Done)) + : SendAsync(context.Response, "text/html; charset=utf-8", StatusPage(new(DateTimeOffset.UtcNow, true, false, null, "Download not found.", null, null, null, []), false), HttpStatusCode.NotFound); + } + + private static Task SendDownloadStatusAsync(HttpListenerContext context) + { + return SendJsonAsync(context.Response, DownloadStatusPayload()); + } + + private static async Task OpenDownloadAsync(HttpListenerContext context) + { + var id = context.Request.Url!.AbsolutePath["/open/".Length..]; + if (!Downloads.TryGetValue(id, out var download) || !download.Done || !download.Ok || download.Paths is not { Count: > 0 }) + { + await SendAsync(context.Response, "text/plain; charset=utf-8", "Downloaded file not found.", HttpStatusCode.NotFound); + return; + } + + var path = download.Paths[0]; + if (!File.Exists(path)) + { + await SendAsync(context.Response, "text/plain; charset=utf-8", "Downloaded file no longer exists.", HttpStatusCode.NotFound); + return; + } + + Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"/select,\"{path}\"", UseShellExecute = true }); + context.Response.StatusCode = (int)HttpStatusCode.SeeOther; + context.Response.RedirectLocation = "/"; + await context.Response.OutputStream.FlushAsync(); + } + + private static async Task ListenForEventsAsync(HttpListenerContext context) + { + var response = context.Response; + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = "text/event-stream"; + response.SendChunked = true; + response.KeepAlive = true; + response.Headers[HttpResponseHeader.CacheControl] = "no-store"; + var id = Guid.NewGuid().ToString("N"); + await using var writer = new StreamWriter(response.OutputStream, new UTF8Encoding(false), 1024, true); + EventClients[id] = writer; + try + { + await WriteEventAsync(writer, $"retry: 1000\nevent: downloads\ndata: {JsonSerializer.Serialize(DownloadStatusPayload())}\n\n"); + while (true) + { + await Task.Delay(TimeSpan.FromSeconds(15)); + await WriteEventAsync(writer, ": keepalive\n\n"); + } + } + catch (HttpListenerException) { } + catch (IOException) { } + finally { EventClients.TryRemove(id, out _); } + } + + private static object DownloadStatusPayload() + { + var downloads = Downloads.OrderByDescending(entry => entry.Value.StartedAt).Take(12).Select(entry => new + { + id = entry.Key, + done = entry.Value.Done, + ok = entry.Value.Ok, + title = entry.Value.Title, + message = entry.Value.Message, + progress = entry.Value.Progress, + speed = entry.Value.Speed, + eta = entry.Value.Eta, + paths = entry.Value.Paths + }); + return new { downloads }; + } + + private static IEnumerable ParseFormValues(string form, string name) => form.Split('&') + .Select(part => part.Split('=', 2)) + .Where(part => part.Length == 2 && string.Equals(WebUtility.UrlDecode(part[0]), name, StringComparison.Ordinal)) + .Select(part => WebUtility.UrlDecode(part[1].Replace('+', ' ')) ?? string.Empty); + + private static string[] SplitCommandLine(string command) + { + var arguments = new List(); + var current = new StringBuilder(); + char? quote = null; var started = false; + foreach (var character in command) + { + if (quote is not null) + { + if (character == quote) quote = null; + else current.Append(character); + continue; + } + if (character is '\'' or '"') { quote = character; started = true; } + else if (char.IsWhiteSpace(character)) + { + if (!started) continue; + arguments.Add(current.ToString()); current.Clear(); started = false; + } + else { current.Append(character); started = true; } + } + if (quote is not null) throw new ArgumentException("Close the quoted flag value."); + if (started) arguments.Add(current.ToString()); + return [.. arguments]; + } + + private static async Task SendAsync(HttpListenerResponse response, string contentType, string body, HttpStatusCode status = HttpStatusCode.OK) + { + var bytes = Encoding.UTF8.GetBytes(body); + response.StatusCode = (int)status; + response.ContentType = contentType; + response.Headers[HttpResponseHeader.CacheControl] = "no-store"; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes); + } + + private static Task SendJsonAsync(HttpListenerResponse response, object body) => + SendAsync(response, "application/json; charset=utf-8", JsonSerializer.Serialize(body)); + + private static string DashboardPage() + { + var downloads = Downloads.OrderByDescending(entry => entry.Value.StartedAt).Take(12).ToArray(); + var cards = string.Join("", downloads.Select(entry => DownloadCard(entry.Key, entry.Value))); + return $$""" + + + + + + ripmedia + + +
ripmedia
{{cards}}
+ + """; + } + + private static string DownloadCard(string id, WebDownload download) + { + var percentage = download.Progress is double value ? Math.Clamp((int)Math.Round(value), 0, 100) : (int?)null; + var transfer = new[] { $"{percentage ?? 0}%", string.IsNullOrWhiteSpace(download.Speed) ? "—" : download.Speed, string.IsNullOrWhiteSpace(download.Eta) ? "ETA —" : $"ETA {download.Eta}" } + .Select(WebUtility.HtmlEncode); + var meter = $"
"; + var classes = download.Done ? (download.Ok ? "download done" : "download failed") : "download"; + var title = download.Title ?? string.Empty; + var link = download.Done && download.Ok && download.Paths is { Count: > 0 } ? $" href=\"/open/{WebUtility.HtmlEncode(id)}\"" : string.Empty; + return $""; + } + + private static void UpdateDownload(string id, Func update) + { + while (Downloads.TryGetValue(id, out var current)) + { + if (!Downloads.TryUpdate(id, update(current), current)) continue; + _ = BroadcastDownloadsAsync(); + return; + } + } + + private static async Task BroadcastDownloadsAsync() + { + var message = $"event: downloads\ndata: {JsonSerializer.Serialize(DownloadStatusPayload())}\n\n"; + foreach (var client in EventClients) + { + try { await WriteEventAsync(client.Value, message); } + catch (HttpListenerException) { EventClients.TryRemove(client.Key, out _); } + catch (IOException) { EventClients.TryRemove(client.Key, out _); } + } + } + + private static async Task WriteEventAsync(StreamWriter writer, string message) + { + await EventWriteLock.WaitAsync(); + try { await writer.WriteAsync(message); await writer.FlushAsync(); } + finally { EventWriteLock.Release(); } + } + + private static string StatusPage(WebDownload download, bool refresh) + { + var percentage = download.Progress is double value ? Math.Clamp((int)Math.Round(value), 0, 100) : (int?)null; + var transfer = new[] { percentage is null ? null : $"{percentage}%", download.Speed, string.IsNullOrWhiteSpace(download.Eta) ? null : $"ETA {download.Eta}" } + .Where(value => !string.IsNullOrWhiteSpace(value)).Select(WebUtility.HtmlEncode); + var stages = download.Stages.TakeLast(6).Select(stage => + $"
  • {(stage.Success ? "✓" : "×")}{WebUtility.HtmlEncode(stage.Name)}{(string.IsNullOrWhiteSpace(stage.Detail) ? string.Empty : $"{WebUtility.HtmlEncode(UiText.Truncate(stage.Detail))}")}
  • "); + var paths = download.Paths is { Count: > 0 } + ? $"
    {string.Join("
    ", download.Paths.Select(WebUtility.HtmlEncode))}
    " : string.Empty; + var meter = percentage is null + ? "
    " + : $"
    "; + return $$""" + + + + + + {{(refresh ? "" : string.Empty)}} + ripmedia + + +

    ripmedia

    {{WebUtility.HtmlEncode(download.Message)}}

    {{string.Join("", transfer.Select(item => $"{item}"))}}
    {{meter}}
      {{string.Join("", stages)}}
    {{paths}}download another
    + + """; + } + + private sealed record WebDownload(DateTimeOffset StartedAt, bool Done, bool Ok, string? Title, string Message, double? Progress, string? Speed, string? Eta, + IReadOnlyList Stages, IReadOnlyList? Paths = null); +} diff --git a/src/Ripmedia.Core/CookieProfiles.cs b/src/Ripmedia.Core/CookieProfiles.cs index f13b560..8eaac86 100644 --- a/src/Ripmedia.Core/CookieProfiles.cs +++ b/src/Ripmedia.Core/CookieProfiles.cs @@ -8,20 +8,26 @@ public static IReadOnlyList Discover() var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); var profiles = new List(); + // yt-dlp recognises only a fixed set of browser identifiers. Chromium + // forks such as Thorium and Helium use Chrome's cookie backend, but + // retain their own display name in the picker. var chromium = new[] { ("chrome", "Chrome", Path.Combine(local, "Google", "Chrome", "User Data")), ("edge", "Edge", Path.Combine(local, "Microsoft", "Edge", "User Data")), ("brave", "Brave", Path.Combine(local, "BraveSoftware", "Brave-Browser", "User Data")), - ("chromium", "Chromium", Path.Combine(local, "Chromium", "User Data")) }; + ("chromium", "Chromium", Path.Combine(local, "Chromium", "User Data")), + ("chrome", "Thorium", Path.Combine(local, "Thorium", "User Data")), + ("chrome", "Helium", Path.Combine(local, "imput", "Helium", "User Data")) }; foreach (var (id, name, root) in chromium) { if (!Directory.Exists(root)) continue; foreach (var path in Directory.EnumerateDirectories(root).Where(path => Path.GetFileName(path) == "Default" || Path.GetFileName(path).StartsWith("Profile ", StringComparison.OrdinalIgnoreCase))) - if (File.Exists(Path.Combine(path, "Network", "Cookies")) || File.Exists(Path.Combine(path, "Cookies"))) profiles.Add(new BrowserCookieProfile(id, $"{name} · {Path.GetFileName(path)}", path)); + if (File.Exists(Path.Combine(path, "Network", "Cookies")) || File.Exists(Path.Combine(path, "Cookies"))) profiles.Add(new BrowserCookieProfile(id, Path.GetFileName(path), path, name)); } var firefoxRoot = Path.Combine(roaming, "Mozilla", "Firefox", "Profiles"); - if (Directory.Exists(firefoxRoot)) foreach (var path in Directory.EnumerateDirectories(firefoxRoot)) - if (File.Exists(Path.Combine(path, "cookies.sqlite"))) profiles.Add(new BrowserCookieProfile("firefox", $"Firefox · {Path.GetFileName(path)}", path)); + if (Directory.Exists(firefoxRoot)) + foreach (var path in Directory.EnumerateDirectories(firefoxRoot)) + if (File.Exists(Path.Combine(path, "cookies.sqlite"))) profiles.Add(new BrowserCookieProfile("firefox", Path.GetFileName(path), path, "Firefox")); return profiles.OrderBy(profile => profile.Browser).ThenBy(profile => profile.Profile).ToArray(); } } diff --git a/src/Ripmedia.Core/DownloadPipeline.cs b/src/Ripmedia.Core/DownloadPipeline.cs index cfd2f9d..44feab9 100644 --- a/src/Ripmedia.Core/DownloadPipeline.cs +++ b/src/Ripmedia.Core/DownloadPipeline.cs @@ -8,23 +8,33 @@ public sealed class DownloadPipeline(YtDlpClient ytDlp, MediaTagger tagger) public async Task RunAsync(DownloadRequest request, RipmediaSettings settings, Action? stage, Action? progress, CancellationToken cancellationToken) { var allPaths = new List(); var allStages = new List(); - var format = FormatRules.Normalize(request.Format); - if (format is not null && !FormatRules.IsSupported(format)) throw new ArgumentException($"Unsupported format '{format}'."); + var effectiveRequest = request with + { + CookieFile = request.CookieFile ?? settings.CookieFile, + BrowserCookies = request.BrowserCookies ?? settings.BrowserCookieProfile?.YtDlpValue + }; + var requestedFormat = FormatRules.Normalize(request.Format); + ValidateFormat(requestedFormat, "Requested"); + var defaultAudioFormat = FormatRules.Normalize(settings.DefaultAudioFormat); + var defaultVideoFormat = FormatRules.Normalize(settings.DefaultVideoFormat); var urls = UrlInput.Expand(request.Inputs); if (urls.Count == 0) throw new ArgumentException("Supply at least one URL or URL file."); foreach (var url in urls) { MediaItem root; - try { root = await ytDlp.InspectAsync(url, request.CookieFile ?? settings.CookieFile, request.BrowserCookies ?? settings.BrowserCookieProfile?.YtDlpValue, request.NoPlaylist, cancellationToken); } + try { root = await ytDlp.InspectAsync(url, effectiveRequest.CookieFile, effectiveRequest.BrowserCookies, effectiveRequest.NoPlaylist, cancellationToken); } catch (Exception exception) { Add("Inspect", false, exception.Message); continue; } Add("Inspect", true, root.Title ?? root.Url); var entries = root.Kind == MediaKind.Playlist && !request.NoPlaylist && root.Entries is { Count: > 0 } ? root.Entries : [root]; foreach (var item in entries) { + var useAudioFormat = request.AudioOnly || item.Kind == MediaKind.Track; + var format = requestedFormat ?? (useAudioFormat ? defaultAudioFormat : defaultVideoFormat); + if (requestedFormat is null) ValidateDefaultFormat(format, useAudioFormat ? "DefaultAudioFormat" : "DefaultVideoFormat", useAudioFormat ? "audio" : "video", useAudioFormat ? FormatRules.IsAudio : FormatRules.IsVideo); var temp = Path.Combine(Path.GetTempPath(), "ripmedia", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); try { Add("Download", true, item.Title ?? item.Url); - var media = await ytDlp.DownloadAsync(item, temp, request with { Format = format, CookieFile = request.CookieFile ?? settings.CookieFile, BrowserCookies = request.BrowserCookies ?? settings.BrowserCookieProfile?.YtDlpValue }, progress, cancellationToken); + var media = await ytDlp.DownloadAsync(item, temp, effectiveRequest with { Format = format }, progress, cancellationToken); if (format is not null) Add("Convert", true, format); var tagged = await tagger.TagAsync(media, item, temp, cancellationToken); Add("Tag", true, tagged ? null : "skipped"); var output = request.OutputDirectory ?? settings.OutputDirectory; @@ -38,4 +48,15 @@ public async Task RunAsync(DownloadRequest request, RipmediaSett return new DownloadResult(allPaths, allStages); void Add(string name, bool success, string? detail = null) { var result = new StageResult(name, success, detail); allStages.Add(result); stage?.Invoke(result); } } + + private static void ValidateFormat(string? format, string description) + { + if (format is not null && !FormatRules.IsSupported(format)) throw new ArgumentException($"{description} format '{format}' is unsupported."); + } + + private static void ValidateDefaultFormat(string? format, string setting, string expectedType, Func isExpectedType) + { + ValidateFormat(format, setting); + if (format is not null && !isExpectedType(format)) throw new ArgumentException($"{setting} must be a {expectedType} format."); + } } diff --git a/src/Ripmedia.Core/Models.cs b/src/Ripmedia.Core/Models.cs index 80e3592..2d602d5 100644 --- a/src/Ripmedia.Core/Models.cs +++ b/src/Ripmedia.Core/Models.cs @@ -47,14 +47,14 @@ private static MediaItem FromEntry(JsonElement node, string sourceUrl, Provider private static MediaKind GuessKind(JsonElement node) => string.Equals(GetString(node, "vcodec"), "none", StringComparison.OrdinalIgnoreCase) ? MediaKind.Track : MediaKind.Video; private static string? GetString(JsonElement node, string name) => node.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.String ? value.GetString() : null; - private static uint? GetUInt(JsonElement node, string name) => node.TryGetProperty(name, out var value) && value.TryGetUInt32(out var result) ? result : null; - private static int? GetInt(JsonElement node, string name) => node.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : null; + private static uint? GetUInt(JsonElement node, string name) => node.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetUInt32(out var result) ? result : null; + private static int? GetInt(JsonElement node, string name) => node.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var result) ? result : null; } -public sealed record BrowserCookieProfile(string Browser, string Profile, string Path) +public sealed record BrowserCookieProfile(string Browser, string Profile, string Path, string? DisplayName = null) { public string YtDlpValue => $"{Browser}:{Path}"; - public override string ToString() => $"{Browser} · {Profile}"; + public override string ToString() => $"{DisplayName ?? Browser} · {Profile}"; } public sealed class RipmediaSettings @@ -65,7 +65,19 @@ public sealed class RipmediaSettings public bool NoColor { get; set; } public string? CookieFile { get; set; } public BrowserCookieProfile? BrowserCookieProfile { get; set; } - public bool MigratedLegacySettings { get; set; } + public string DefaultAudioFormat { get; set; } = string.Empty; + public string DefaultVideoFormat { get; set; } = string.Empty; + + public RipmediaSettings WithoutBrowserCookies() => new() + { + OutputDirectory = OutputDirectory, + SpeedUnit = SpeedUnit, + ShowFileSize = ShowFileSize, + NoColor = NoColor, + CookieFile = CookieFile, + DefaultAudioFormat = DefaultAudioFormat, + DefaultVideoFormat = DefaultVideoFormat + }; } public sealed record DownloadRequest(IReadOnlyList Inputs, string? OutputDirectory, bool AudioOnly, string? Format, diff --git a/src/Ripmedia.Core/SettingsStore.cs b/src/Ripmedia.Core/SettingsStore.cs index 61fc59d..d714209 100644 --- a/src/Ripmedia.Core/SettingsStore.cs +++ b/src/Ripmedia.Core/SettingsStore.cs @@ -18,7 +18,7 @@ public async Task LoadAsync(CancellationToken cancellationToke await using var stream = File.OpenRead(SettingsPath); return await JsonSerializer.DeserializeAsync(stream, Options, cancellationToken) ?? new RipmediaSettings(); } - var settings = MigrateLegacy(); + var settings = new RipmediaSettings(); await SaveAsync(settings, cancellationToken); return settings; } @@ -31,21 +31,4 @@ public async Task SaveAsync(RipmediaSettings settings, CancellationToken cancell File.Move(temporary, SettingsPath, true); } - private static RipmediaSettings MigrateLegacy() - { - var settings = new RipmediaSettings(); - var oldPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ripmedia", "config.ini"); - if (!File.Exists(oldPath)) return settings; - var values = File.ReadLines(oldPath).Select(line => line.Trim()).Where(line => line.Contains('=') && !line.StartsWith('#') && !line.StartsWith(';')) - .Select(line => line.Split('=', 2)).ToDictionary(parts => parts[0].Trim().Replace('-', '_'), parts => parts[1].Trim(), StringComparer.OrdinalIgnoreCase); - if (values.TryGetValue("output_dir", out var output) && !string.IsNullOrWhiteSpace(output)) settings.OutputDirectory = output; - if (values.TryGetValue("cookies", out var cookies) && !string.Equals(cookies, "none", StringComparison.OrdinalIgnoreCase)) settings.CookieFile = cookies; - if (values.TryGetValue("no_color", out var noColor)) settings.NoColor = IsTrue(noColor); - if (values.TryGetValue("show_file_size", out var showSize)) settings.ShowFileSize = IsTrue(showSize); - if (values.TryGetValue("speed_unit", out var speed)) settings.SpeedUnit = speed; - settings.MigratedLegacySettings = true; - return settings; - } - - private static bool IsTrue(string value) => value.Equals("true", StringComparison.OrdinalIgnoreCase) || value is "1" or "yes" or "on"; } diff --git a/src/Ripmedia.Core/YoutubePoTokenProvider.cs b/src/Ripmedia.Core/YoutubePoTokenProvider.cs new file mode 100644 index 0000000..60e32bb --- /dev/null +++ b/src/Ripmedia.Core/YoutubePoTokenProvider.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Ripmedia.Core; + +public static class YoutubePoTokenProvider +{ + private static readonly SemaphoreSlim Gate = new(1, 1); + private static Process? _process; + private static int _port; + + static YoutubePoTokenProvider() => AppDomain.CurrentDomain.ProcessExit += (_, _) => Stop(); + + public static async Task> ArgumentsAsync(string? toolDirectory, string? cookieFile, string? browserCookies, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(cookieFile) && string.IsNullOrWhiteSpace(browserCookies)) return []; + if (!TryGetResources(toolDirectory, out var resources)) throw new InvalidOperationException("YouTube high-quality support is unavailable because the bundled PO-token provider is missing. Install a current ripmedia release."); + + var port = await EnsureRunningAsync(resources, cancellationToken); + return [ + "--plugin-dirs", resources.PluginDirectory, + "--js-runtimes", $"node:{resources.Node}", + "--extractor-args", "youtube:player_client=mweb", + "--extractor-args", $"youtubepot-bgutilhttp:base_url=http://127.0.0.1:{port}" + ]; + } + + public static bool IsAvailable(string? toolDirectory) => TryGetResources(toolDirectory, out _); + + private static bool TryGetResources(string? toolDirectory, out Resources resources) + { + resources = default; + if (string.IsNullOrWhiteSpace(toolDirectory)) return false; + var providerDirectory = Path.Combine(toolDirectory, "pot-provider"); + var node = Path.Combine(providerDirectory, "node.exe"); + var entryPoint = Path.Combine(providerDirectory, "server", "build", "main.js"); + var pluginDirectory = Path.Combine(toolDirectory, "yt-dlp-plugins"); + var plugin = Path.Combine(pluginDirectory, "bgutil-ytdlp-pot-provider.zip"); + if (!File.Exists(node) || !File.Exists(entryPoint) || !File.Exists(plugin)) return false; + resources = new(node, entryPoint, pluginDirectory); + return true; + } + + private static async Task EnsureRunningAsync(Resources resources, CancellationToken cancellationToken) + { + await Gate.WaitAsync(cancellationToken); + try + { + if (_process is { HasExited: false }) return _port; + Stop(); + _port = ReservePort(); + var start = new ProcessStartInfo(resources.Node) { UseShellExecute = false, CreateNoWindow = true }; + start.ArgumentList.Add(resources.EntryPoint); + start.ArgumentList.Add("--port"); + start.ArgumentList.Add(_port.ToString(System.Globalization.CultureInfo.InvariantCulture)); + _process = Process.Start(start) ?? throw new InvalidOperationException("Could not start the bundled YouTube PO-token provider."); + try + { + await WaitForPortAsync(_port, _process, cancellationToken); + return _port; + } + catch { Stop(); throw; } + } + finally { Gate.Release(); } + } + + private static int ReservePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task WaitForPortAsync(int port, Process process, CancellationToken cancellationToken) + { + var deadline = DateTime.UtcNow.AddSeconds(20); + while (DateTime.UtcNow < deadline) + { + if (process.HasExited) throw new InvalidOperationException("The bundled YouTube PO-token provider stopped before it was ready."); + try + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, cancellationToken); + return; + } + catch (SocketException) { await Task.Delay(100, cancellationToken); } + } + throw new TimeoutException("The bundled YouTube PO-token provider did not start in time."); + } + + private static void Stop() + { + try + { + if (_process is { HasExited: false }) _process.Kill(true); + _process?.Dispose(); + } + catch (InvalidOperationException) { } + finally + { + _process = null; + _port = 0; + } + } + + private readonly record struct Resources(string Node, string EntryPoint, string PluginDirectory); +} diff --git a/src/Ripmedia.Core/YtDlpClient.cs b/src/Ripmedia.Core/YtDlpClient.cs index f6e8ffa..eb29dc9 100644 --- a/src/Ripmedia.Core/YtDlpClient.cs +++ b/src/Ripmedia.Core/YtDlpClient.cs @@ -27,20 +27,26 @@ public Task ListFormatsAsync(string url, string? cookieFile, stri public async Task DownloadAsync(MediaItem item, string temporaryDirectory, DownloadRequest request, Action? progress, CancellationToken cancellationToken) { - var args = new List { "--newline", "--no-warnings", "--write-thumbnail", "--output", Path.Combine(temporaryDirectory, "%(id)s.%(ext)s") }; - var toolDirectory = Path.GetDirectoryName(_executable); - if (!string.IsNullOrWhiteSpace(toolDirectory) && File.Exists(Path.Combine(toolDirectory, "ffmpeg.exe"))) args.AddRange(["--ffmpeg-location", toolDirectory]); - AddCookies(args, request.CookieFile, request.BrowserCookies); - if (request.AudioOnly && string.IsNullOrWhiteSpace(request.Format)) args.AddRange(["--format", "bestaudio"]); - else if (string.IsNullOrWhiteSpace(request.Format)) args.AddRange(["--format", "best"]); - else if (FormatRules.IsAudio(request.Format)) args.AddRange(["--extract-audio", "--audio-format", request.Format]); - else args.AddRange(["--recode-video", request.Format]); - args.Add(item.Url); - var result = await ProcessRunner.RunAsync(_executable, args, line => progress?.Invoke(DownloadProgress.Parse(line)), cancellationToken: cancellationToken); + var result = await DownloadWithFormatAsync(FormatRules.SelectSourceFormat(request.AudioOnly, request.Format)); + if (result.ExitCode != 0) throw new InvalidOperationException(CleanError(result)); var media = Directory.EnumerateFiles(temporaryDirectory).Where(path => !path.EndsWith(".part", StringComparison.OrdinalIgnoreCase) && !IsImage(path)) .OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault(); return media ?? throw new InvalidOperationException("yt-dlp finished without producing media."); + + async Task DownloadWithFormatAsync(string format) + { + var args = new List { "--newline", "--no-warnings", "--write-thumbnail", "--output", Path.Combine(temporaryDirectory, "%(id)s.%(ext)s") }; + var toolDirectory = Path.GetDirectoryName(_executable); + if (!string.IsNullOrWhiteSpace(toolDirectory) && File.Exists(Path.Combine(toolDirectory, "ffmpeg.exe"))) args.AddRange(["--ffmpeg-location", toolDirectory]); + AddCookies(args, request.CookieFile, request.BrowserCookies); + if (item.Provider == Provider.YouTube) args.AddRange(await YoutubePoTokenProvider.ArgumentsAsync(toolDirectory, request.CookieFile, request.BrowserCookies, cancellationToken)); + args.AddRange(["--format", format]); + if (!string.IsNullOrWhiteSpace(request.Format) && FormatRules.IsAudio(request.Format)) args.AddRange(["--extract-audio", "--audio-format", request.Format]); + else if (!string.IsNullOrWhiteSpace(request.Format)) args.AddRange(["--recode-video", request.Format]); + args.Add(item.Url); + return await ProcessRunner.RunAsync(_executable, args, line => progress?.Invoke(DownloadProgress.Parse(line)), cancellationToken: cancellationToken); + } } private static void AddCookies(List args, string? cookieFile, string? browserCookies) @@ -53,21 +59,38 @@ private static void AddCookies(List args, string? cookieFile, string? br private static string FindExecutable(string name) { var local = Path.Combine(AppContext.BaseDirectory, "tools", name); if (File.Exists(local)) return local; - return name; + var pathEntry = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator).FirstOrDefault(directory => File.Exists(Path.Combine(directory, name))); + if (!string.IsNullOrWhiteSpace(pathEntry)) return Path.Combine(pathEntry, name); + throw new FileNotFoundException($"Could not find {name}. Run 'dotnet run --project src/Ripmedia.Cli' once while connected to the internet to restore the bundled tools, or install ripmedia from its release bundle.", name); } } public sealed record DownloadProgress(double? Percentage, string? Speed, string? Eta) { - private static readonly Regex Pattern = new(@"\[download\]\s+(?[\d.]+)%.*?(?:at\s+(?.+?)\s+ETA\s+(?\S+))?$", RegexOptions.Compiled); - public static DownloadProgress Parse(string line) { var match = Pattern.Match(line); return !match.Success ? new(null, null, null) : new(double.Parse(match.Groups["percent"].Value, System.Globalization.CultureInfo.InvariantCulture), match.Groups["speed"].Success ? match.Groups["speed"].Value : null, match.Groups["eta"].Success ? match.Groups["eta"].Value : null); } + private static readonly Regex PercentagePattern = new(@"\[download\]\s+(?[\d.]+)%", RegexOptions.Compiled); + private static readonly Regex SpeedPattern = new(@"\bat\s+(?\S+)", RegexOptions.Compiled); + private static readonly Regex EtaPattern = new(@"\bETA\s+(?\S+)", RegexOptions.Compiled); + + public static DownloadProgress Parse(string line) + { + var percentage = PercentagePattern.Match(line); + var speed = SpeedPattern.Match(line); + var eta = EtaPattern.Match(line); + return new(percentage.Success ? double.Parse(percentage.Groups["percent"].Value, System.Globalization.CultureInfo.InvariantCulture) : null, + speed.Success ? speed.Groups["speed"].Value : null, eta.Success ? eta.Groups["eta"].Value : null); + } } public static class FormatRules { + public const string BestVideoAndAudio = "bestvideo*+bestaudio/best"; private static readonly HashSet Audio = new(StringComparer.OrdinalIgnoreCase) { "mp3", "m4a", "aac", "flac", "ogg", "opus", "wav" }; private static readonly HashSet Video = new(StringComparer.OrdinalIgnoreCase) { "mp4", "mkv", "webm" }; public static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().TrimStart('.').ToLowerInvariant(); public static bool IsSupported(string value) => Audio.Contains(value) || Video.Contains(value); public static bool IsAudio(string value) => Audio.Contains(value); + public static bool IsVideo(string value) => Video.Contains(value); + public static string SelectSourceFormat(bool audioOnly, string? outputFormat) => audioOnly || (outputFormat is not null && IsAudio(outputFormat)) + ? "bestaudio" + : BestVideoAndAudio; } diff --git a/tests/Ripmedia.Tests/SettingsStoreTests.cs b/tests/Ripmedia.Tests/SettingsStoreTests.cs index 16d6ba3..60cd2ff 100644 --- a/tests/Ripmedia.Tests/SettingsStoreTests.cs +++ b/tests/Ripmedia.Tests/SettingsStoreTests.cs @@ -11,5 +11,20 @@ public async Task PersistsSettingsAtomically() var store = new SettingsStore(_path); var settings = await store.LoadAsync(); settings.OutputDirectory = "C:\\Media"; await store.SaveAsync(settings); Assert.Equal("C:\\Media", (await store.LoadAsync()).OutputDirectory); } + [Fact] + public async Task CreatesBlankDefaultConversionFormats() + { + var settings = await new SettingsStore(_path).LoadAsync(); + Assert.Equal(string.Empty, settings.DefaultAudioFormat); + Assert.Equal(string.Empty, settings.DefaultVideoFormat); + } + [Fact] + public void DisplaysForkNameButUsesChromeForYtDlp() + { + var profile = new BrowserCookieProfile("chrome", "Default", "C:\\Users\\Test\\AppData\\Local\\Thorium\\User Data\\Default", "Thorium"); + + Assert.Equal("Thorium · Default", profile.ToString()); + Assert.StartsWith("chrome:", profile.YtDlpValue); + } public void Dispose() { if (Directory.Exists(_path)) Directory.Delete(_path, true); } } diff --git a/tests/Ripmedia.Tests/UrlInputTests.cs b/tests/Ripmedia.Tests/UrlInputTests.cs index fc37536..f0ea78d 100644 --- a/tests/Ripmedia.Tests/UrlInputTests.cs +++ b/tests/Ripmedia.Tests/UrlInputTests.cs @@ -1,4 +1,5 @@ using Ripmedia.Core; +using System.Text.Json; namespace Ripmedia.Tests; @@ -17,4 +18,57 @@ public void RecognizesSupportedFormats() Assert.True(FormatRules.IsSupported("mkv")); Assert.False(FormatRules.IsSupported("exe")); } + + [Theory] + [InlineData(false, null, "bestvideo*+bestaudio/best")] + [InlineData(false, "mkv", "bestvideo*+bestaudio/best")] + [InlineData(true, null, "bestaudio")] + [InlineData(false, "mp3", "bestaudio")] + public void SelectsTheHighestQualitySourceForTheOutputType(bool audioOnly, string? outputFormat, string expected) + => Assert.Equal(expected, FormatRules.SelectSourceFormat(audioOnly, outputFormat)); + + [Fact] + public async Task StartsTheBundledYouTubeTokenProviderForASelectedBrowserProfile() + { + var workspace = new DirectoryInfo(AppContext.BaseDirectory); + while (workspace is not null && !File.Exists(Path.Combine(workspace.FullName, "Ripmedia.sln"))) workspace = workspace.Parent; + Assert.NotNull(workspace); + var toolDirectory = Path.Combine(workspace!.FullName, "src", "Ripmedia.Cli", "tools"); + Assert.True(YoutubePoTokenProvider.IsAvailable(toolDirectory)); + + var arguments = await YoutubePoTokenProvider.ArgumentsAsync(toolDirectory, null, "chrome:C:\\Users\\Test\\Chrome\\Default", CancellationToken.None); + + Assert.Contains("youtube:player_client=mweb", arguments); + Assert.Contains(arguments, argument => argument.StartsWith("youtubepot-bgutilhttp:base_url=http://127.0.0.1:", StringComparison.Ordinal)); + } + + [Fact] + public void AllowsNullNumericMetadata() + { + using var document = JsonDocument.Parse("{\"id\":\"track\",\"title\":\"Example\",\"release_year\":null,\"playlist_index\":null}"); + + var item = MediaItem.FromJson(document.RootElement, "https://example.com/track"); + + Assert.Null(item.Year); + Assert.Null(item.PlaylistIndex); + } + + [Fact] + public void ParsesDownloadPercentageAndTransferStats() + { + var progress = DownloadProgress.Parse("[download] 42.7% of 10.00MiB at 2.50MiB/s ETA 00:03"); + + Assert.Equal(42.7, progress.Percentage); + Assert.Equal("2.50MiB/s", progress.Speed); + Assert.Equal("00:03", progress.Eta); + } + + [Fact] + public void IgnoresFragmentDetailsInTransferStats() + { + var progress = DownloadProgress.Parse("[download] 42.7% of 10.00MiB at 2.50MiB/s (frag 3/12)"); + + Assert.Equal("2.50MiB/s", progress.Speed); + Assert.Null(progress.Eta); + } }