archive.org and other direct-file hosts return metadata without vcodec
when using extract_flat mode. The UI was incorrectly labeling these as
'Audio Only'. Now we check the URL path extension and yt-dlp's reported
ext against known video containers as a fallback before marking a source
as audio-only.
Fixes incorrect audio-only detection for archive.org video URLs.
Header: remove max-width constraint on mobile so header background
spans the full viewport width.
Theme: updateAdminConfig now applies the new theme immediately if
the user's current mode matches the changed side (e.g. changing
dark theme while in dark mode updates live, without page reload
or dark→light→dark toggle).
Critical fix:
- Input field no longer disabled during URL analysis — the race condition
fix (isAnalyzing=true on paste) was disabling the input mid-paste,
causing the browser to drop the pasted text. Input now only disabled
during submission.
UI polish:
- All action row elements standardized to 42px height
- Mobile toggle pills wider (min-width: 42px, matches gear icon)
- URL clear button (floating X) in the input field
- Footer visible in mobile view (padding above bottom nav)
- FormatPicker mobile: ellipsis on codec text, wrapped layout at narrow widths
Mobile:
- Queue tab shows badge with active job count (queued/downloading)
- Badge hidden when user is already on Queue tab
- Styled as accent-colored pill with count (caps at 9+)
Paste race fix:
- Set isAnalyzing=true immediately on paste event, not after 50ms timeout
- Prevents Download button from being briefly clickable between paste
and analysis start
- Handles edge case where URL is cleared before timeout fires
Theme init was running before config loaded, so admin theme settings
were ignored (config was null). Now: init once immediately (from cookie
or fallback), load config, init again with admin defaults. Theme
persists correctly across all routes including /admin.
Admin Settings:
- Theme section: pick Dark Theme, Light Theme, and Default Mode
- 5 dark options (Cyberpunk/Dark/Midnight/Hacker/Neon)
- 4 light options (Light/Paper/Arctic/Solarized)
- Persisted in SQLite — survives container rebuilds
- Served via /api/config/public so frontend loads admin defaults
Visitor behavior:
- Page loads with admin's chosen default (dark or light theme)
- Sun/moon icon toggles between admin's dark and light pair
- Preference stored in cookie — persists within browser session
- No theme dropdown for visitors — admin controls the pair
Header icon simplified back to clean dark/light toggle
- No API key configured: external API access blocked, browser-only
- API key generated: external access enabled with that key
- Added 'Sure?' confirmation on Regenerate and Revoke buttons (3s timeout)
- Updated hint text to reflect security-first default
Dark themes:
- Midnight: ultra-minimal, near-black, zero effects
- Hacker: green-on-black terminal, monospace, CRT scanlines
- Neon: hot pink + cyan on purple-black, synthwave, heavy glow
Light themes:
- Paper: warm cream/sepia, serif fonts, book-like
- Arctic: cool whites and icy blues, crisp and modern
- Solarized: Ethan Schoonover's solarized-light palette
Theme picker:
- Replaced simple dark/light toggle with grouped dropdown
- Themes organized by Dark / Light sections with active checkmark
- Remembers last dark and light theme separately for quick toggle
- Theme metadata now includes variant field for proper grouping
- Custom themes default to dark variant
API Key (Sonarr/Radarr style):
- Admin panel → Settings: Generate / Show / Copy / Regenerate / Revoke
- Persisted in SQLite via settings system
- When set, POST /api/downloads requires X-API-Key header or browser origin
- Browser users unaffected (X-Requested-With: XMLHttpRequest auto-sent)
- No key configured = open access (backward compatible)
Container hardening:
- Strip SUID/SGID bits from all binaries in image
- Make /app source directory read-only (only /downloads and /data writable)
Download endpoint:
- New _check_api_access guard on POST /api/downloads
- Timing-safe key comparison via secrets.compare_digest
Version:
- New app/__version__.py with 'dev' fallback for local dev
- Dockerfile injects APP_VERSION build arg from CI tag
- Health endpoint and footer now show actual release version
- Test updated to accept 'dev' in non-Docker environments
File size:
- Capture filesize/filesize_approx from yt-dlp extract_info
- Write to DB via update_job_progress and broadcast via SSE
- New 'Size' column in download table (hidden on mobile)
- formatSize helper: bytes → human-readable (KB/MB/GB)
- Frontend store picks up filesize from SSE events
When a user pastes a URL and extraction fails (type=unknown), the
failure is now recorded in the error_log table with the actual yt-dlp
error message. Admins can see these in the Errors tab alongside
download failures — gives visibility into which sites/URLs users
are trying that don't work.
- Bug: purge used Path(filename).name which stripped subdirectories,
so files like 'jawed/Me at the zoo.webm' were never found/deleted
- Fix: use output_dir / filename (preserves relative path)
- Also: clean up empty parent directories after file deletion so
uploader folders like 'jawed/' don't linger as empty dirs
- url-info returns site-specific hints for Instagram, Twitter/X, TikTok,
Facebook when extraction fails (e.g. 'Instagram requires login. Upload
a cookies.txt from a logged-in browser session.')
- Frontend shows the hint instead of generic 'No downloadable media found'
- Playlist entry titles fall back to URL slug (human-readable) instead of
numeric IDs when extract_flat mode doesn't return titles
- Add remote_components={'ejs:github'} to all yt-dlp invocations, fixing
YouTube signature/n-parameter challenge failures that caused missing formats
- Extract _base_opts() method to consolidate quiet, no_warnings,
remote_components, and extractor_args across all three call sites
- Removes PASSWORD_HASH from user-facing config comment
- New MEDIARIP__ADMIN__PASSWORD env var accepts plaintext password
- Hashed via bcrypt on startup, plaintext cleared from memory immediately
- PASSWORD_HASH still works for backward compatibility (takes precedence)
- Removes the 'docker run python bcrypt' ceremony from setup flow
- Updated README, docker-compose, .env.example to use plaintext
The 403 was a VPN IP issue, not a client selection issue. yt-dlp already
picks the right player clients (android_vr + web_safari). Moved
YTDLP__EXTRACTOR_ARGS out of 'Most Useful Settings' into a
'Troubleshooting: YouTube 403 Errors' section that explains the actual
common causes (VPN IPs, private content) before the escape hatch.
- Quickstart emphasizes zero-config: defaults are production-ready
- 'Most Useful Settings' table with 5 knobs operators actually touch,
each with 'when to change' context instead of just descriptions
- Full settings reference in collapsible <details> block
- Consolidated duplicate Admin Panel / Session Modes sections
- docker-compose.yml: environment section fully commented out,
organized with inline explanations for each setting
README Configuration section:
- Organized into logical groups (Core, Admin, Purge, UI, yt-dlp)
- Fixed wrong defaults: admin.enabled (true not false), purge.enabled
(true not false), purge field is max_age_minutes not max_age_hours,
purge.cron is '* * * * *' not '0 3 * * *'
- Removed redundant internal-path vars (DB_PATH, DATA_DIR, OUTPUT_DIR)
that are pre-set in the Dockerfile and shouldn't be changed
- Added missing vars: LOG_LEVEL, UI settings, PRIVACY_MODE, YTDLP
- Added extractor_args usage examples (YAML and env var)
docker-compose files:
- Switched healthcheck from python urllib to curl (already in image)
- Removed stale PURGE__MAX_AGE_HOURS references
- Added commented yt-dlp extractor_args example
- Simplified comments to reflect actual defaults
- Wire up get_cookie_path_for_session() in download opts — session
cookies.txt is now passed to yt-dlp as cookiefile when present
- Add YtdlpConfig with extractor_args field, configurable via
config.yaml or MEDIARIP__YTDLP__EXTRACTOR_ARGS env var
(e.g. {"youtube": {"player_client": ["web_safari"]}})
- Inject extractor_args into all three yt-dlp call sites:
_enqueue_single, _extract_info, _extract_url_info
- Enhance 403 error messages with actionable guidance directing
users to upload cookies.txt
- event_generator now yields {event: 'ping', data: ''} on KEEPALIVE_TIMEOUT
instead of silently looping. Gives SSE clients stream-level liveness signal.
- _collect_events helper now enforces its timeout parameter via asyncio.wait_for,
preventing tests from hanging indefinitely if generator never yields.
- create_session uses INSERT OR IGNORE to handle concurrent requests
with same session cookie (race when multiple requests arrive before
the first INSERT commits)
- Widen actions column 110px→130px to fit 3 action buttons without
overflowing (was causing 4px horizontal scrollbar)
- Widen status column 100px→120px for DOWNLOADING badge breathing room
- Admin enabled by default (was opt-in via env var)
- New /admin/status (public) and /admin/setup (first-run only) endpoints
- Setup endpoint locked after first use (returns 403)
- Admin password persisted to SQLite config table (survives restarts)
- Change password now persists to DB (was in-memory only)
- Frontend router guard forces /admin redirect until setup is complete
- AdminSetup.vue wizard: username + password + confirm
- Public config exposes admin_enabled/admin_setup_complete for frontend
- TLS warning only fires when password is actually configured
Three bugs causing 100% CPU and container crash-looping in production:
1. sse-starlette ping=0 causes await anyio.sleep(0) busy loop in _ping task.
Each SSE connection spins a ping task at 100% CPU. Changed to ping=15
(built-in keepalive). Removed our manual ping yield in favor of continue.
2. Dockerfile purged curl after installing deno, but Docker healthcheck
(and compose override) uses curl. Healthcheck always failed -> autoheal
restarted the container every ~2 minutes. Keep curl in the image.
3. Downloads that fail during server shutdown leave zombie jobs stuck in
queued/downloading status (event loop closes before error handler can
update DB). Added startup recovery that marks these as failed.
Verified: no external URLs in frontend (no CDN fonts, no analytics,
no Google Fonts, no external scripts). All fonts use system fallback
chains (JetBrains Mono → Cascadia Code → Fira Code → monospace).
No outbound HTTP calls in backend code.
Added SecurityHeadersMiddleware enforcing:
- Content-Security-Policy: default-src 'self', script/font/connect
restricted to 'self', style allows 'unsafe-inline' for Vue scoped
styles, img allows data: URIs, object-src 'none', frame-ancestors
'none'
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- Referrer-Policy: no-referrer
These headers prevent any accidental introduction of external
resources in future development — CSP violations will block them.
Table overflow fix:
- table-layout: fixed prevents columns expanding beyond max-width
- min-width: 0 on flex children ensures text-overflow works
- Long filenames now properly truncate with ellipsis
Clickable source URLs:
- Name column is now an <a> link to the original source URL
- Opens in new tab (target=_blank, rel=noopener)
- Styled to inherit text color, underline + accent on hover
- @click.stop prevents row selection interference
Duplicate preview box fix:
- When urlInfo.type === 'unknown', urlInfo is now set to null
- Previously both the preview box ('VIDEO' badge) and error message
showed simultaneously. Now only the error message appears.
Backend:
- New error_log table: url, domain, error, format_id, media_type,
session_id, created_at
- log_download_error() called when yt-dlp throws during download
- GET /admin/errors returns recent entries (limit 200)
- DELETE /admin/errors clears all entries
- Manual purge also clears error log
- Domain extracted from URL via urlparse for grouping
Frontend:
- New 'Errors' tab in admin panel (Sessions, Storage, Errors, Settings)
- Each error entry shows: domain, timestamp, full URL, error message,
format/media type metadata
- Red left border + error-colored message for visual scanning
- Clear Log button to wipe entries
- Empty state: 'No errors logged.'
Error entries contain enough context (full URL, error message, domain,
format, media type) to paste into an LLM for domain-specific debugging.
Settings flow:
- Each section has its own Save button — no ambiguous shared button
- Appearance & Defaults: Save covers welcome message + output formats
- Privacy & Data: Save covers privacy toggle + retention hours
- Security: Change Password button is self-contained
- Bottom note clarifies all settings reset on server restart
Purge improvements:
- Now clears orphaned sessions (sessions with no remaining jobs)
- 'Sure?' confirmation gate on manual purge (3s timeout revert)
- Purge result shows sessions_deleted count
- Cleaner result display: 'X jobs removed' instead of 'Rows deleted: X'
SSE broker:
- publish_all() broadcasts to all sessions (used for purge)
Settings tab reorganized into 3 sections:
- Appearance & Defaults: welcome message + output formats + Save
- Privacy & Data: privacy mode toggle + manual purge
- Security: change password
Manual purge fix:
- purge_all=True clears ALL completed/failed jobs regardless of age
- Previously only cleared jobs older than max_age_hours (7 days),
so recent downloads were never purged on manual trigger
SSE broadcast for purge:
- Added SSEBroker.publish_all() for cross-session broadcasts
- Purge endpoint sends job_removed events for each deleted job
- Frontend queue clears in real-time when admin purges
Privacy Mode feature:
- Toggle in Admin > Settings enables automatic purge of download
history, session logs, and files after configurable retention period
- Default retention: 24 hours when privacy mode is on
- Configurable 1-8760 hours via number input
- When enabled, starts purge scheduler (every 30 min) if not running
- When disabled, data persists indefinitely
Admin panel consolidation:
- Removed separate 'Purge' tab — manual purge moved to Settings
- Settings tab order: Privacy Mode > Manual Purge > Welcome Message >
Output Formats > Change Password
- Toggle switch UI with accent color and smooth animation
- Retention input with left accent border and unit label
Backend:
- PurgeConfig: added privacy_mode (bool) and privacy_retention_hours
- Purge service: uses privacy_retention_hours when privacy mode active
- PUT /admin/settings: accepts privacy_mode + privacy_retention_hours
- GET /config/public: exposes privacy settings to frontend
- Runtime overrides passed to purge service via config._runtime_overrides
- Invalid URL error shows in preview-styled box instead of alongside
format picker. Options panel hidden when URL is invalid.
- Password mismatch warning ('Passwords don't match') shown live
below confirm field when values differ.
Best quality format:
- Synthetic 'bestvideo+bestaudio/best' entry added at top of format
list when the best separate video stream exceeds the best pre-muxed
format. Shows as 'Best quality (1920x1080)' in Video+Audio group.
- YouTube typically only has 360p pre-muxed but 1080p+ as separate
streams — users can now select full quality with auto-merge.
- Only appears when there's actually a quality advantage vs pre-muxed.
Password change UX:
- Enter key on confirm password field submits the change
- Auto-logout 1.5s after successful password change
- User sees '✓ Password changed' before being redirected home
Mobile table:
- Status column hidden on mobile (<640px) alongside Progress
- Only Name + Actions columns shown — clean two-column layout
- Removed mobile-specific status badge font tweaks (column gone)
Admin:
- Username field autofocused on login page
- Change password section in Settings tab — current password
verification, new password + confirm, min 4 chars, updates
bcrypt hash at runtime via PUT /admin/password
- Password change updates stored credentials in admin store
Loading messages:
- Replaced 'Peeking at the URL' with: Scanning the airwaves,
Negotiating with the server, Cracking the codec, Reading
the fine print, Locking on target
Mobile responsive:
- Progress column hidden on mobile (<640px) — table fits viewport
- Action buttons compact (28px) on mobile with 2px gap
- Status badges smaller on mobile (0.625rem)
- Filter tabs scroll horizontally, Download All + Clear go
full-width below as equal-sized buttons
- min-width:0 on section containers prevents flex overflow
- download-table-wrap constrained with max-width:100%