Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ jobs:
- 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 build Ripmedia.sln --configuration Release --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: dotnet publish src/Ripmedia.Launcher/Ripmedia.Launcher.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output artifacts/launcher
- run: artifacts/app/ripmedia.exe version
- run: if (-not (Test-Path artifacts/launcher/ripmedia-launcher.exe)) { throw 'Published launcher is missing.' }
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
- 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
- run: dotnet publish src/Ripmedia.Launcher/Ripmedia.Launcher.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output artifacts/launcher
- run: Copy-Item artifacts/launcher/ripmedia-launcher.exe artifacts/ripmedia/ripmedia-launcher.exe
- name: Add download engines
shell: pwsh
run: |
Expand Down
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>1.0.4</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Expand Down
6 changes: 6 additions & 0 deletions Ripmedia.sln
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ripmedia.Core", "src\Ripmed
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ripmedia.Cli", "src\Ripmedia.Cli\Ripmedia.Cli.csproj", "{8A305F1D-6C91-4EAB-B227-38F55CFC9B02}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ripmedia.Launcher", "src\Ripmedia.Launcher\Ripmedia.Launcher.csproj", "{D1E1B59E-2F8D-4DDE-9FE7-8C0BFD53D9FC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ripmedia.Tests", "tests\Ripmedia.Tests\Ripmedia.Tests.csproj", "{0C4692B5-855E-444C-B42E-270E0ED65A03}"
EndProject
Global
Expand All @@ -22,6 +24,10 @@ Global
{8A305F1D-6C91-4EAB-B227-38F55CFC9B02}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A305F1D-6C91-4EAB-B227-38F55CFC9B02}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A305F1D-6C91-4EAB-B227-38F55CFC9B02}.Release|Any CPU.Build.0 = Release|Any CPU
{D1E1B59E-2F8D-4DDE-9FE7-8C0BFD53D9FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D1E1B59E-2F8D-4DDE-9FE7-8C0BFD53D9FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D1E1B59E-2F8D-4DDE-9FE7-8C0BFD53D9FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D1E1B59E-2F8D-4DDE-9FE7-8C0BFD53D9FC}.Release|Any CPU.Build.0 = Release|Any CPU
{0C4692B5-855E-444C-B42E-270E0ED65A03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0C4692B5-855E-444C-B42E-270E0ED65A03}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0C4692B5-855E-444C-B42E-270E0ED65A03}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down
5 changes: 4 additions & 1 deletion scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ try {
New-Item -ItemType Directory -Path $target -Force | Out-Null
Expand-Archive -LiteralPath $zip -DestinationPath $target -Force
New-Item -ItemType Directory -Path $bin -Force | Out-Null
Set-Content -LiteralPath (Join-Path $bin 'ripmedia.cmd') -Value "@echo off`r`n`"$target\ripmedia.exe`" %*" -NoNewline
$launcher = Join-Path $target 'ripmedia-launcher.exe'
if (-not (Test-Path -LiteralPath $launcher)) { throw 'Release bundle is missing the ripmedia launcher.' }
Copy-Item -LiteralPath $launcher -Destination (Join-Path $bin 'ripmedia.exe') -Force
Set-Content -LiteralPath (Join-Path $bin 'current.txt') -Value (Join-Path $target 'ripmedia.exe') -NoNewline
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (($userPath -split ';') -notcontains $bin) { [Environment]::SetEnvironmentVariable('Path', ($userPath.TrimEnd(';') + ';' + $bin), 'User') }
if (($env:Path -split ';') -notcontains $bin) { $env:Path += ';' + $bin }
Expand Down
8 changes: 8 additions & 0 deletions src/Ripmedia.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,20 @@ await AnsiConsole.Progress()
},
progress =>
{
task.IsIndeterminate(progress.IsProcessing);
if (progress.IsProcessing)
{
speed = null; eta = null;
task.Description(Describe(progress.Activity!, title));
return;
}
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.IsIndeterminate(false);
task.Value(100);
speed = null; eta = null;
task.Description(result.Paths.Count > 0 ? "[#b8ceb8]Download complete[/]" : "[#d69ba6]Download finished with errors[/]");
Expand Down
25 changes: 14 additions & 11 deletions src/Ripmedia.Cli/WebHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,17 @@ private static async Task RunDownloadAsync(string id, DownloadRequest request, R
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,
Progress = stage.Name == "Inspect" ? null : stage.Name == "Convert" ? 100 : download.Progress,
IsProcessing = false,
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
Message = progress.Activity ?? "Downloading…",
Progress = progress.IsProcessing ? null : progress.Percentage ?? download.Progress,
Speed = progress.IsProcessing ? null : progress.Speed ?? download.Speed,
Eta = progress.IsProcessing ? null : progress.Eta ?? download.Eta,
IsProcessing = progress.IsProcessing
}), CancellationToken.None);
var failed = result.Stages.LastOrDefault(stage => !stage.Success);
UpdateDownload(id, download => result.Paths.Count > 0
Expand Down Expand Up @@ -229,6 +231,7 @@ private static object DownloadStatusPayload()
progress = entry.Value.Progress,
speed = entry.Value.Speed,
eta = entry.Value.Eta,
isProcessing = entry.Value.IsProcessing,
paths = entry.Value.Paths
});
return new { downloads };
Expand Down Expand Up @@ -296,7 +299,7 @@ private static string DashboardPage()
input { background: transparent; border: 0; color: #e0c0c6; font: inherit; outline: none; padding: 0; width: 100%; }
.queue { display: grid; gap: 6px; margin-top: 20px; } .download { background: transparent; min-width: 0; padding: 7px 9px 7px 11px; position: relative; } .download.entering { animation: queued .42s cubic-bezier(.2,.8,.2,1) both; } @keyframes queued { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } } .download::before { background: #d6a1ac; content: ""; height: 100%; left: 0; position: absolute; top: 0; width: 2px; } .download.done::before { background: #b8ceb8; } .download.failed::before { background: #d69ba6; }
header { align-items: baseline; display: flex; gap: 12px; min-width: 0; } .title { color: #e0c0c6; flex: 1; font-size: .78rem; margin: 0; overflow: hidden; text-overflow: ellipsis; text-decoration: none; white-space: nowrap; } .done .title { color: #a99ba0; } .title[href] { cursor: pointer; } .title[href]:hover { color: #b8ceb8; text-decoration: underline; } .meta { color: #a99ba0; display: flex; flex-shrink: 0; font-size: .68rem; gap: 8px; margin-left: auto; }
.meter { background: #211a1d; height: 2px; margin-top: 6px; overflow: hidden; } .meter span { background: #d6a1ac; display: block; height: 100%; transition: width .65s cubic-bezier(.2,.8,.2,1); width: var(--progress, 0%); } .done .meter span { background: #b8ceb8; }
.meter { background: #211a1d; height: 2px; margin-top: 6px; overflow: hidden; } .meter span { background: #d6a1ac; display: block; height: 100%; transition: width .65s cubic-bezier(.2,.8,.2,1); width: var(--progress, 0%); } .meter.processing span { animation: processing 1.4s ease-in-out infinite; width: 36%; } .done .meter span { background: #b8ceb8; } @keyframes processing { from { transform: translateX(-110%); } to { transform: translateX(310%); } }
@media (max-width: 440px) { body { padding-top: 28px; } }
</style>
</head>
Expand All @@ -308,8 +311,8 @@ private static string DashboardPage()
const form = document.querySelector('form'); form.addEventListener('submit', event => { event.preventDefault(); fetch('/download', { method: 'POST', body: new URLSearchParams(new FormData(form)) }).then(() => form.reset()).catch(() => form.submit()); });
const queue = document.querySelector('.queue');
const text = value => document.createTextNode(value ?? '');
const meter = (job, existing) => { const node = existing ?? document.createElement('div'); if (node.className !== 'meter') node.className = 'meter'; const fill = node.firstElementChild ?? node.appendChild(document.createElement('span')); fill.style.setProperty('--progress', `${job.progress == null ? 0 : Math.max(0, Math.min(100, Math.round(job.progress)))}%`); return node; };
const update = (card, job) => { const snapshot = JSON.stringify(job); if (card.dataset.snapshot === snapshot) return; card.dataset.snapshot = snapshot; const classes = `download${job.done ? (job.ok ? ' done' : ' failed') : ''}${card.classList.contains('entering') ? ' entering' : ''}`; if (card.className !== classes) card.className = classes; const title = card.querySelector('.title'); title.textContent = job.title ?? ''; if (job.done && job.ok && job.paths?.length) title.href = `/open/${job.id}`; else title.removeAttribute('href'); const meta = card.querySelector('.meta'); meta.replaceChildren(...[job.progress == null ? '0%' : `${Math.max(0, Math.min(100, Math.round(job.progress)))}%`, job.speed ?? '—', job.eta ? `ETA ${job.eta}` : 'ETA —'].map(value => { const span = document.createElement('span'); span.textContent = value; return span; })); meter(job, card.querySelector('.meter')); };
const meter = (job, existing) => { const node = existing ?? document.createElement('div'); node.classList.add('meter'); node.classList.toggle('processing', job.isProcessing === true); const fill = node.firstElementChild ?? node.appendChild(document.createElement('span')); fill.style.setProperty('--progress', `${job.progress == null ? 0 : Math.max(0, Math.min(100, Math.round(job.progress)))}%`); return node; };
const update = (card, job) => { const snapshot = JSON.stringify(job); if (card.dataset.snapshot === snapshot) return; card.dataset.snapshot = snapshot; const classes = `download${job.done ? (job.ok ? ' done' : ' failed') : ''}${card.classList.contains('entering') ? ' entering' : ''}`; if (card.className !== classes) card.className = classes; const title = card.querySelector('.title'); title.textContent = job.title ?? ''; if (job.done && job.ok && job.paths?.length) title.href = `/open/${job.id}`; else title.removeAttribute('href'); const meta = card.querySelector('.meta'); meta.replaceChildren(...[job.isProcessing ? 'Processing' : job.progress == null ? '0%' : `${Math.max(0, Math.min(100, Math.round(job.progress)))}%`, job.speed ?? '—', job.eta ? `ETA ${job.eta}` : 'ETA —'].map(value => { const span = document.createElement('span'); span.textContent = value; return span; })); meter(job, card.querySelector('.meter')); };
const create = job => { const card = document.createElement('article'); const header = document.createElement('header'); const title = document.createElement('a'); const meta = document.createElement('div'); const meterNode = document.createElement('div'); card.dataset.id = job.id; title.className = 'title'; meta.className = 'meta'; meterNode.className = 'meter'; meterNode.append(document.createElement('span')); header.append(title, meta); card.append(header, meterNode); update(card, job); requestAnimationFrame(() => { card.classList.add('entering'); setTimeout(() => card.classList.remove('entering'), 450); }); return card; };
const sync = downloads => { const old = new Map([...queue.children].map(card => [card.dataset.id, card])); for (const job of downloads) { const card = old.get(job.id) ?? create(job); update(card, job); queue.append(card); old.delete(job.id); } for (const card of old.values()) card.remove(); }; const events = new EventSource('/events'); events.addEventListener('downloads', event => sync(JSON.parse(event.data).downloads));
</script></body>
Expand All @@ -322,7 +325,7 @@ 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 = $"<div class=\"meter\"><span style=\"--progress:{percentage ?? 0}%\"></span></div>";
var meter = $"<div class=\"meter{(download.IsProcessing ? " processing" : string.Empty)}\"><span style=\"--progress:{percentage ?? 0}%\"></span></div>";
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;
Expand Down Expand Up @@ -366,7 +369,7 @@ private static string StatusPage(WebDownload download, bool refresh)
$"<li class=\"{(stage.Success ? "" : "failed")}\"><b>{(stage.Success ? "✓" : "×")}</b><span>{WebUtility.HtmlEncode(stage.Name)}</span>{(string.IsNullOrWhiteSpace(stage.Detail) ? string.Empty : $"<small>{WebUtility.HtmlEncode(UiText.Truncate(stage.Detail))}</small>")}</li>");
var paths = download.Paths is { Count: > 0 }
? $"<div class=\"paths\">{string.Join("<br>", download.Paths.Select(WebUtility.HtmlEncode))}</div>" : string.Empty;
var meter = percentage is null
var meter = download.IsProcessing || percentage is null
? "<div class=\"meter waiting\"><span></span></div>"
: $"<div class=\"meter\"><span style=\"width:{percentage}%\"></span></div>";
return $$"""
Expand All @@ -392,5 +395,5 @@ private static string StatusPage(WebDownload download, bool refresh)
}

private sealed record WebDownload(DateTimeOffset StartedAt, bool Done, bool Ok, string? Title, string Message, double? Progress, string? Speed, string? Eta,
IReadOnlyList<StageResult> Stages, IReadOnlyList<string>? Paths = null);
IReadOnlyList<StageResult> Stages, IReadOnlyList<string>? Paths = null, bool IsProcessing = false);
}
7 changes: 6 additions & 1 deletion src/Ripmedia.Core/DownloadPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ public async Task<DownloadResult> RunAsync(DownloadRequest request, RipmediaSett
Directory.CreateDirectory(Path.GetDirectoryName(target)!); File.Move(media, target); allPaths.Add(target); Add("Saved", true, target);
}
catch (Exception exception) { Add("Download", false, exception.Message); }
finally { try { Directory.Delete(temp, true); } catch (IOException) { } }
finally
{
try { Directory.Delete(temp, true); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
}
return new DownloadResult(allPaths, allStages);
Expand Down
3 changes: 1 addition & 2 deletions src/Ripmedia.Core/MediaTagger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ public async Task<bool> TagAsync(string path, MediaItem item, string temporaryDi
file.Save();
return true;
}
catch (CorruptFileException) { return false; }
catch (UnsupportedFormatException) { return false; }
catch (Exception error) when (error is CorruptFileException or UnsupportedFormatException or HttpRequestException or IOException or UnauthorizedAccessException) { return false; }
}
}
10 changes: 9 additions & 1 deletion src/Ripmedia.Core/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ public static async Task<ProcessResult> RunAsync(string executable, IEnumerable<
var output = new List<string>(); var errors = new List<string>();
process.Start();
var outTask = ReadAsync(process.StandardOutput, output, stdout); var errTask = ReadAsync(process.StandardError, errors, stderr);
await process.WaitForExitAsync(cancellationToken); await Task.WhenAll(outTask, errTask);
try { await process.WaitForExitAsync(cancellationToken); }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
try { if (!process.HasExited) process.Kill(true); }
catch (InvalidOperationException) { }
await Task.WhenAll(outTask, errTask);
throw;
}
await Task.WhenAll(outTask, errTask);
return new ProcessResult(process.ExitCode, output, errors);
}

Expand Down
Loading
Loading