The tree already links a working x86_64-pc-windows-gnu exe; turn that into
a shipped artifact:
- scripts/package.sh gains a `win` target (and `all` picks it up when the
mingw toolchain is present) that cross-compiles and zips the exe +
LICENSE + a README listing the yt-dlp/ffmpeg/mpv PATH deps.
- Forgejo release workflow installs mingw-w64 + zip + the rust target, so
a tag push produces yt-offline-<ver>-x86_64-windows.zip from the same
Linux container — no Windows runner needed.
- main.rs: attach_windows_console() (cfg(windows), windows-sys
AttachConsole) reattaches a release build to the launching terminal so
--web/CLI output is visible, while a double-click stays windowless
(release sets windows_subsystem = "windows").
- ROADMAP 3.1 updated: Windows ships; macOS deferred (needs a Mac runner).
Native Linux build + 128 unit / 11 integration tests still green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Configure peers as [[remote]] entries (name, url, optional password) and
browse their libraries from this instance, read-only.
- remote.rs: RemoteClient (blocking reqwest + rustls + cookie jar). Fetches
the peer's /api/library, logging in first if the peer has a password and
retrying on a 401. Media is not proxied: the library JSON's media URLs
(/files/, /music-files/, /api/transcode/, /api/sub-vtt/) are rewritten to
absolute peer URLs with the peer's read-only feed token appended, so video
streams straight from the peer to the browser/mpv while only the small JSON
passes through us.
- web.rs: auth_middleware now also accepts the feed token for GET
/api/transcode/ and /api/sub-vtt/ (read-only media, same class as /files/),
so a transcoded peer streams remotely. New GET /api/remotes and
/api/remotes/:id/library (the RemoteClient runs under spawn_blocking).
- web UI: a 🌐 Remotes sidebar switcher; remote mode is read-only (hides the
download bar + per-card mutating actions, keeps Play).
- desktop: a 🌐 Remotes screen that fetches a peer's library off-thread and
plays via mpv on the tokenized URL.
- config.rs: [[remote]] section list; reqwest dep added (rustls/blocking/
cookies, no system OpenSSL).
Verified end-to-end against a second local instance, including a transcoded
stream returning HTTP 200 via the token. ROADMAP 3.5 + CLAUDE.md updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`cargo check --target x86_64-pc-windows-gnu` is now green (only the
upstream egui f32:From<f64> warnings). The Linux-only desktop deps are
target-gated so the rest of the stack — which already cross-compiles —
can build off Linux:
- Cargo.toml: ksni and rfd's xdg-portal backend move to
[target.'cfg(target_os = "linux")'.dependencies]; non-Linux gets rfd
with its native Win32/AppKit backend (default features).
- tray.rs: cfg-split. The ksni/SNI implementation stays Linux-only; other
OSes get a no-op `start() -> None`, i.e. windowed-only (identical to the
Linux no-SNI-host path). TrayEvent/TrayHandle stay cross-platform.
- plex.rs: add a Windows `symlink_file` arm to make_symlink (also fixes an
unused-variable warning on non-unix).
The rest was already portable (disk_space/statvfs, mpv IPC UnixStream,
chmod/PermissionsExt guards all cfg(unix)). macOS is Unix so those apply
there unchanged. Still not a *shipped* binary: needs a real per-OS tray
backend and a linking CI matrix. ROADMAP 3.1 + CLAUDE.md updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop forces the wgpu/Vulkan renderer because glow/OpenGL crashes
on NVIDIA + Wayland maximize. But on some systems wgpu creates the window
and never presents a frame, so nothing appears. Keep wgpu the default but
compile glow back in and let it be selected via `--renderer glow` or
`YT_OFFLINE_RENDERER=glow` for those machines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Maximizing the window crashed the GUI immediately with:
Error: Glutin(Error { raw_code: Some(12291), kind: OutOfMemory })
12291 is EGL_BAD_ALLOC. The default eframe `glow` (OpenGL) renderer
fails to reallocate its surface when the window resizes to maximized
dimensions on NVIDIA + Wayland — a well-known fragility of NVIDIA's
GL-on-Wayland path. It surfaces as a clean Err from run_native (not a
panic), which is why nothing landed in yt-offline.crash.log.
Switch to the wgpu (Vulkan) renderer, which reconfigures the swapchain
cleanly on resize:
- Cargo.toml: drop eframe's default glow feature, enable wgpu, keep
accesskit / default_fonts / wayland / x11.
- main.rs: NativeOptions.renderer = Renderer::Wgpu.
Verified on the NVIDIA GTX 1650 SUPER + Wayland session that triggered
the crash: app launches and maximizes cleanly, no EGL error.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ships beyond the Arch PKGBUILD so non-Arch users can install without a
Rust toolchain.
## Package metadata (Cargo.toml)
- [package.metadata.deb] for cargo-deb: declares the runtime subprocess
deps (yt-dlp, ffmpeg, mpv, xdg-utils) that auto-detection can't find,
plus libxcb1/libc6 link deps and libnotify4 as recommended.
- [package.metadata.generate-rpm] for cargo-generate-rpm with the
RPM-distro dep names.
- Filled in license / repository / authors / readme package fields.
## scripts/package.sh
One entry point: `scripts/package.sh [deb|rpm|appimage|all]`. Builds the
release binary once and reuses it. Installs cargo-deb / cargo-generate-rpm
on demand; downloads appimagetool to dist/tools/ on first AppImage build.
Per-format failures are isolated and summarized at the end rather than
aborting the run. Output to dist/ (gitignored).
AppImage is hand-rolled (AppDir + AppRun + root desktop/icon) and bundles
only the GUI binary's shared-lib closure — yt-dlp/ffmpeg/mpv stay host
PATH deps, same as the package declarations.
## CI
.forgejo/workflows/release.yml builds all formats on a v* tag push and
attaches them to the Codeberg release via actions/forgejo-release. Manual
workflow_dispatch builds without uploading for smoke testing. Sets
APPIMAGE_EXTRACT_AND_RUN since CI containers lack FUSE.
## docs/PACKAGING.md
Per-format build + install instructions, the Arch PKGBUILD pointer, and
an honest Windows/macOS section: both are blocked on the Linux-only tray
(ksni) + file-picker (rfd xdg-portal) deps needing per-OS abstraction.
The rest of the stack already cross-compiles.
Verified .deb (dpkg control + payload correct) and .rpm (payload correct)
build cleanly through the script on this host.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four changes that compound — most users will notice these on first
launch of a large library.
## Release profile
- opt-level: 2 → 3 (more inlining + vectorization, ~5-15% on hot paths)
- lto = "thin" (cross-crate inlining; thin avoids the rust-lld + bundled
sqlite link breakage that full LTO previously caused, which is what
the !lto note was about)
- codegen-units = 1 (whole-crate inlining, ~5% runtime, +30s build)
- strip = "debuginfo" (drops ~30 MB from the binary)
## Thumbnail decode pool
The single decoder thread was the bottleneck on grid fill for libraries
with hundreds of channels. Replace with a worker pool sized at
clamp(2, cores/2, 8). Workers share the request channel behind a Mutex;
lock contention is negligible because each worker spends ~all its time
in image::open + resize.
## info.json parse cache
Library scans re-parse every video's info.json sidecar on every rescan —
serde_json::from_str is the dominant cost on a warm-filesystem scan
(file reads themselves are OS-cached). Add a `info_cache` SQLite table
keyed by (path, mtime); enrich_with_cache hits it first and only parses
on miss. mtime-keyed invalidation means yt-dlp re-writing an info.json
auto-invalidates without explicit eviction.
Each scan worker checks out its own r2d2 connection so lookups don't
serialize. Database is now Clone (the inner Pool is Arc, so cloning is
cheap) so we can hand handles to parallel workers.
## /api/library body cache
The ETag short-circuit catches clients with a matching cached version;
this catches clients without one (curl, freshly-opened tabs, mobile
WebViews that don't store ETags reliably). When the version matches
the cached entry's, we hand back the previously-serialized bytes
without re-walking the channel tree or re-running serde_json::to_string.
A post-build version check prevents poisoning the cache with stale
bytes when the library changes during serialization.
74 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two independent improvements from Phase 2.5.
## Panic hook → crash.log
GUI users launched from a .desktop file have no stderr — today panics
vanish without trace. Install a panic::set_hook early in main() that
appends a structured entry to <library_root>/yt-offline.crash.log for
every panic from every thread (UI, axum workers, downloader spawns,
tray bg thread, thumbnail decode workers).
Each entry: ISO-8601 timestamp, thread name, file:line, panic message,
and a backtrace when RUST_BACKTRACE is set. The default panic hook
still runs after ours, so dev workflows keep getting the stderr trace.
Bounded: when the log passes 256 KB the next write rotates it to .1
(one previous generation kept). No external date library — small
civil-time formula in-tree.
## Disk-full preflight
Before yt-dlp ever launches, statvfs the target filesystem. If free
space is under 500 MB, push a synthetic Failed job classified as
DiskFull instead of starting yt-dlp. That way:
- Users see the actual problem ("only 230 MB free on /mnt/…") in the
Downloads modal with the same "free up space and retry" hint the
error-classifier emits for mid-download ENOSPC.
- No half-written file lands on disk.
- No 30-second yt-dlp warmup wasted on a doomed run.
statvfs returning None (non-Unix, missing path) skips the check —
better to let yt-dlp run than to refuse on no data.
5 new tests: 3 in crash (civil-time, log shape, real-panic capture),
2 in disk_space (statvfs plumbing + missing-path None). 74 pass total.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Uses ksni (StatusNotifierItem via zbus) so we keep our pure-Rust /
no-GTK stack — zbus is already pulled in by notify-rust and rfd.
Behavior:
- Tray icon shows the bundled icon.png; left-click brings the window
back to focus
- Right-click menu: Show / Hide / Quit
- Clicking the window's X button minimizes to tray instead of exiting
- Ctrl+Q (or the tray's Quit item) actually exits
Failure modes are silent and non-blocking: no DBus → app behaves as if
tray flag was off. No StatusNotifier host (e.g. GNOME without the
AppIndicator extension) → icon invisible but app still runs normally,
and the X-button close cancellation only fires when the tray spawn
returned a handle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WebSocket progress
- New `/ws/progress` route using axum 0.7's `ws` feature. Upgrades to
a WebSocket that streams the same `ProgressResponse` payload as the
HTTP polling endpoint, pushed instantly whenever a job moves.
- `WebState.progress_tx` is a `tokio::sync::broadcast::Sender<String>`
(capacity 16, lossy — Lagged subscribers resync on the next tick).
- A background tokio task ticks every 500 ms while any download is
running or queued, every 5 s when idle. Skips the JSON encode when
no subscribers are listening, so a single-user install pays zero
CPU for the broadcast when the browser tab is closed.
- JS replaces the per-interval HTTP poll with a WebSocket connection.
The polling path stays as fallback for mobile carriers / reverse
proxies that strip WS — when the socket closes or fails to open,
schedulePoll() resumes immediately and a reconnect attempt fires
after 5 s.
- Initial snapshot is sent right after subscribe so the UI is live
before the next tick.
Mobile-responsive web UI
- Existing `@media(max-width:640px)` block tightened:
- Hide the H1, AGPL footer notice, sort dropdown, and library-size
counter — they don't fit and aren't actionable.
- Header buttons get min 34×34 hit targets (Apple HIG / Material).
- `.card-foot` wraps and grows to 34×34 buttons so the five action
icons (play / watched / favourite / bookmark / waiting) stay
tappable instead of cramming into thumb-unfriendly 22 px chips.
- Modals go full-screen (no 10 px page-margin) so the iPhone safe
area doesn't crop content.
- Footer (URL bar + download button) wraps onto two rows so neither
squeezes the other off-screen.
- Bulk-actions toolbar wraps and gets bigger buttons.
- Per-job stdout preview hides on small screens.
- New `@media(max-width:380px)` collapses the grid to single column
for very narrow phones.
55 unit tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four nice-to-have items, plus surfacing the upload date in both UIs
since the user explicitly asked.
Upload date display
- Desktop: card meta row now includes a `YYYY-MM-DD` cell between the
ID and the duration; detail panel header shows it as `📅 YYYY-MM-DD`.
- Web: video player modal title gains a date+duration subline so the
viewer has context without opening the metadata pane (card meta
already shows the date).
- New `format_upload_date()` helper on the desktop side; the web side
reuses the existing `fmtDate()` JS.
Extract HTML_UI to include_str!
- `LOGIN_HTML` and `HTML_UI` moved out of `src/web.rs` into
`src/web_ui/login.html` and `src/web_ui/index.html`. The Rust file
drops from 2915 lines to 1806; editors now syntax-highlight the
HTML/CSS/JS, and UI diffs no longer overwhelm the Rust diff view.
- `include_str!` bakes them into the binary at compile time, so there
is no runtime fs lookup and no extra deploy artifacts.
Twitch clips-only mode
- `classify_twitch` now recognises `twitch.tv/<user>/clips` as a
Channel URL (was Unknown), so yt-dlp's TwitchClips extractor is
reachable from our normal download pipeline.
- Both UIs gain a "Clips only" toggle visible only when the URL is
a Twitch channel (not a specific VOD/clip). On submit, the URL is
rewritten to `twitch.tv/<user>/clips` before dispatch.
- 1 new platform test covers the clips-listing classification.
DB connection pool (r2d2_sqlite)
- `Database` now wraps an `r2d2`-managed SQLite pool. File-backed
databases get up to 4 connections; the in-memory fallback is capped
at 1 (since each in-memory connection is a fresh empty DB by default
in SQLite).
- `WebState.db` drops its outer `Mutex` — concurrent handlers each
check out their own connection from the pool. Static-file fetches
through `/files/` no longer serialize on the watched/positions/
password lookups that the auth middleware performs.
- Pool init failures are converted to `rusqlite::Error` so the
existing `Result<T>` return types still work.
47 unit tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four items knocked off the unscoped list:
Auto-chmod 0600 cookies.txt
- web::write_cookies now sets mode 0600 on the file after writing, on Unix.
Mirrors the same guard we put on yt-offline.db. cookies.txt carries
live session credentials; no reason for it to stay umask-default.
Redact cookies path from job log
- Downloader::spawn_job now runs each stdout/stderr line through a small
redactor that replaces the absolute cookies.txt path with the bare
filename before forwarding to the Job log. yt-dlp can echo the path
in error strings, which leaks $HOME into the UI and /api/progress
responses.
- 2 new tests cover the strip + pass-through cases.
Bandcamp full-discography mode
- Bandcamp at the bare artist URL is already a Channel in our classifier
and yt-dlp's BandcampUserIE returns the full discography. The output
template for Bandcamp's channel case now organizes tracks into
per-album subfolders via `%(album|Unknown)s` so a discography pull
stays coherent instead of one flat directory.
- New `Downloader::apply_platform_extras` adds `--embed-thumbnail` for
audio-first platforms (Bandcamp, SoundCloud) so music players see the
cover art via embedded tags rather than scanning for a sidecar JPEG.
Library ETag + gzip
- WebState gains `library_version: AtomicU64`. `bump_library_version()`
is called from post_rescan, post_watched, post_maintenance_remove, and
post_resume (only when crossing the "Continue watching" >3.0 boundary
so playback-time updates don't constantly invalidate the cache).
- get_library is now Response-returning; it consults `If-None-Match` and
short-circuits with 304 Not Modified when the client's ETag matches.
Otherwise it sends `ETag: "<n>"` alongside the JSON.
- JS `loadLibrary()` caches the ETag and sends it on subsequent calls.
Returns early on 304 keeping the existing in-memory library array.
- Adds `tower_http::compression::CompressionLayer` (gzip). Already-
compressed media served from ServeDir is auto-skipped by the layer;
JSON responses get ~10× smaller. New `compression-gzip` feature on
the tower-http dep.
46 unit tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The README still described yt-offline as a YouTube-only desktop app.
Rewrite it to reflect what actually exists today:
- Multi-platform support (YouTube, TikTok, Twitch, Vimeo, Bandcamp,
SoundCloud, Odysee, and yt-dlp's full fallback list) with a table
of what each platform supports.
- Library, playback, downloads, web server, Plex integration, themes,
and misc sections — each grouping the relevant features so a reader
can spot what matters to them.
- Bundled-yt-dlp install walkthrough (venv + curl_cffi + deno).
- Updated config.toml example with every field the app reads.
- Library layout diagram showing the new platform sibling folders
and the `.source-url` sidecar.
- Usage section split between desktop GUI and web UI.
- Troubleshooting now lists the impersonation error path, missing
python3-venv, the transcoding-seek caveat, and rate-limit recovery.
- Pointer to SECURITY_AUDIT.md.
- AGPL §13 obligations spelled out.
Also expand the Cargo.toml description so `cargo metadata` and crates.io
listings convey the multi-platform / web UI scope at a glance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Full-scan download toggle: omits --break-on-existing so all unarchived
videos are fetched regardless of order, filling gaps in partial archives
- Filter "Aborting remaining downloads" from job logs (break-on-existing noise)
- Password hash moved from config.toml to SQLite settings table; survives
git checkouts and rebuilds. config.toml and yt-offline.db added to .gitignore;
config auto-generated on first run if absent
- Channel re-check URLs derived from folder name (/@handle or /channel/UCxxx)
instead of info.json's canonical channel_url, preventing duplicate UCxxx folders
- SRT subtitles detected by library scanner and served via /api/sub-vtt/* endpoint
that converts to WebVTT on the fly; browser <track> element can now play them
- Cookie clear button in both desktop and web UI (DELETE /api/cookies)
- Per-job X close button on finished download jobs in desktop GUI
- Fix widget ID clash when multiple download jobs are shown (push_id per job)
- Watched videos excluded from Continue Watching list and badge count
- settings table added to SQLite schema (key/value store)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Features:
- Port all 7 desktop themes (Dracula, Trans, Emo variants, etc.) to the web UI
- Configurable bind interface (localhost/Tailscale/LAN/all) with detected IPs,
settable from both the web UI and desktop GUI
- Optional password that gates the whole UI and all API access via session
cookies (Argon2), with a login page, logout, and middleware; settable from
both UIs
- Library maintenance: scan for duplicate video IDs and missing assets
(thumbnail/info.json/description), remove duplicates (scan-and-confirm, with
out-of-library path guard), and re-fetch missing sidecars via yt-dlp.
Surfaced in both the web UI and a desktop GUI window.
Optimizations / cleanup:
- Cache has_chapters at scan time (library.rs) instead of re-reading and
parsing every info.json on each /api/library request
- Use the cached channel size in get_library
- Pool a single SQLite connection in WebState instead of opening one per request
- Remove a 1.2 GB stray nested src/yt-offline duplicate tree
Fixes:
- Rescan no longer clears all thumbnail textures (caused thumbnails to unload);
prune only removed entries and bump library_generation so the card grid
refreshes correctly
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Web UI:
- Replace SSE with simple 600ms polling (GET /api/progress) — SSE added
complexity with no real benefit for a local tool and was the source of
connection failures
- Poll task moved into request handler (dl.poll() on each /api/progress call)
so no background goroutine needed; simplifies state greatly
- Remove tokio-stream dependency (no longer needed)
- Fix: create channels dir before opening DB in web serve()
- Rescan also refreshes watched set from DB
- Bulk watched, sort, channel download button all working in web UI
Sort labels:
- Rename "Dur ↑" / "Dur ↓" → "Shortest" / "Longest"
- Rename "Size ↑" / "Size ↓" → "Smallest" / "Largest"
Channel right-click context menu:
- Right-click any channel in sidebar → "Check for new videos" starts a
yt-dlp job on the stored channel_url from its info.json
- Also shows "Open folder" shortcut
- Grayed out with tooltip if no URL is stored yet (channel has no info.json)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
License:
- GPL3 → AGPL3
New features:
- Web interface (--web [port]): axum/tokio server with SSE progress, library browser,
download trigger, watched toggle; embedded single-page HTML/JS UI
- Desktop notifications via notify-rust when downloads complete or fail
- Continue Watching sidebar entry showing videos with saved resume positions
- Bulk select mode: select multiple videos, mark all watched/unwatched at once
- Storage stats: channel disk usage shown in sidebar next to video count
- Scheduled channel checks: configurable interval (hours), auto re-downloads all
tracked channels using channel_url from info.json; enabled in Settings
Theme fixes:
- Scene Queen: remove override_text_color so active button text uses fg_stroke
(dark navy on neon green) instead of near-white — fixes unreadable contrast
- Trans: override_text_color → hot pink #cc0066 for pink-tinted text throughout
Settings additions:
- Auto-check channels toggle + interval_hours DragValue
- Web UI port setting
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- SQLite DB for watched/position persistence (database.rs)
- Mark watched toggle on cards and detail panel
- mpv IPC socket integration for resume position tracking (unix)
- Sort by title, duration, file size
- Thumbnail density slider in top bar
- Channel metadata banner (subscriber count, uploader, channel URL)
- Cookie browser selector dropdown in settings
- Auto-rescan library when a download job finishes
- Fix yt-dlp --no-progress-bar flag (does not exist; removed)
- Browser field wired through config → downloader
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- library.rs: scans channels/<name>/ into channels + videos by filename stem
- downloader.rs: runs yt-dlp in a background thread, streams progress to the UI
- app.rs / main.rs: channel sidebar, searchable thumbnail list, detail/description
panel, downloads panel; plays videos via mpv (falls back to xdg-open)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>