Commit graph

90 commits

Author SHA1 Message Date
jlightner
3205c101c3 fix: graceful WAL mode fallback for CIFS/network filesystems
When the data directory is on a CIFS/SMB mount (or other filesystem
lacking mmap shared-memory support), SQLite WAL mode fails with
'locking protocol' or 'readonly database' errors. The init_db function
now detects this and falls back to DELETE journal mode automatically.
2026-04-01 05:04:45 +00:00
jlightner
23143b4e11 Merge fix/archive-org-audio-detection: correct audio-only detection for archive.org 2026-04-01 04:21:27 +00:00
jlightner
d518304331 fix: detect video from URL extension when yt-dlp extract_flat strips codec info
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.
2026-04-01 04:21:19 +00:00
xpltd
44e24e9393 README: add Docker image location + pull/run instructions
- Added ghcr.io/xpltdco/media-rip:latest prominently in Quickstart
- Added curl one-liner to grab docker-compose.yml
- Added docker run alternative for users who don't want compose
- Updated features: 9 built-in themes (was 3)
2026-03-22 17:15:21 -05:00
xpltd
4870157dbd Mobile header full-width + live theme preview on save
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).
2026-03-22 17:10:59 -05:00
xpltd
9cfa9818f9 Fix paste broken by isAnalyzing + UI polish batch
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
2026-03-22 17:01:35 -05:00
xpltd
f72b649acf Mobile queue badge + fix paste-then-download race condition
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
2026-03-22 16:37:03 -05:00
xpltd
1b5f24f796 Fix theme: load config before theme init, prevent flash on navigation
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.
2026-03-22 16:09:43 -05:00
xpltd
02c5e7bc1f Admin-controlled themes with visitor dark/light toggle
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
2026-03-22 15:58:49 -05:00
xpltd
6804301825 Fix CI: test clients need X-Requested-With for API access guard 2026-03-22 01:39:01 -05:00
xpltd
43b5ba3f72 Fix lint: remove unused save_settings import in revoke_api_key 2026-03-22 01:26:18 -05:00
xpltd
b0d2781980 Flip API key logic: no key = browser-only, add confirmation gates
- 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
2026-03-22 01:16:19 -05:00
xpltd
9b4ffbb754 6 new themes + grouped theme picker dropdown
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
2026-03-22 00:51:00 -05:00
xpltd
4b766bb0e7 Security hardening: API key system, container hardening
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
2026-03-22 00:42:10 -05:00
xpltd
82f78e567b Remove dev artifacts from repo: planning docs, egg-info, test prompts
Removed:
- .bg-shell/ (background shell state)
- .planning/ (early planning docs, superseded)
- DEPLOY-TEST-PROMPT.md (internal testing prompt)
- PROJECT.md (root-level duplicate of README)
- backend/media_rip.egg-info/ (Python build artifact)
- Caddyfile.example (redundant — Caddyfile is the template)

Updated .gitignore to prevent re-addition.
2026-03-22 00:29:55 -05:00
xpltd
c8d5283926 Remove .gsd/ and .claude/ from repo, add to gitignore
AI tooling artifacts (project planning, milestones, decisions, settings)
are local development state — not part of the distributed project.
2026-03-22 00:27:55 -05:00
xpltd
ae1711ada4 Fix TS: add filesize to ProgressEvent interface 2026-03-21 23:50:47 -05:00
xpltd
b834f63e80 CI: pass APP_VERSION build arg to Docker build from git tag 2026-03-21 23:47:04 -05:00
xpltd
04f7fd09f3 Dynamic app version from git tag + file size display in queue
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
2026-03-21 23:45:48 -05:00
xpltd
6f20d29151 Log URL extraction failures to error_log for admin visibility
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.
2026-03-21 23:39:00 -05:00
xpltd
723e7f4248 Fix purge: use full relative path for file deletion, clean empty dirs
- 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
2026-03-21 23:34:50 -05:00
xpltd
2e87da297f Better UX for auth-required sites + playlist title fallback
- 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
2026-03-21 23:32:56 -05:00
xpltd
cd883205c6 Enable yt-dlp remote JS challenge solver, consolidate base opts
- 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
2026-03-21 22:46:21 -05:00
xpltd
de09e51b11 Remove PASSWORD_HASH from user-facing config — plaintext PASSWORD only
password_hash remains as an internal field (used by auth system and DB),
but is no longer documented or advertised as a config option.
2026-03-21 22:42:05 -05:00
xpltd
2bb97a0b30 Accept plaintext admin password — hash on startup, clear from memory
- 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
2026-03-21 22:40:34 -05:00
xpltd
bfc7eba03f Demote extractor_args to troubleshooting — yt-dlp defaults work out of the box
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.
2026-03-21 22:36:18 -05:00
xpltd
f5b7a8b9ff README: recommended settings table, collapsible full reference, zero-config compose
- 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
2026-03-21 22:34:13 -05:00
xpltd
61ee8d4eff Clean up docs: fix config defaults, remove redundant vars, add missing ones
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
2026-03-21 22:28:31 -05:00
xpltd
f3d1f29ca1 Fix YouTube 403: cookie injection, configurable extractor_args, better errors
- 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
2026-03-21 22:13:25 -05:00
xpltd
245ec0e567 Dockerfile: use MEDIARIP__SERVER__PORT in healthcheck
Healthcheck now respects the PORT env var instead of hardcoding 8000,
so containers running on non-default ports get proper health status.
2026-03-21 21:13:38 -05:00
xpltd
6cb3828b92 Fix SSE keepalive: yield explicit ping event, enforce test timeout
- 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.
2026-03-21 20:57:50 -05:00
xpltd
43ddf43951 Purge intervals: hours→minutes, default ON at 1440min (24h)
- PurgeConfig: max_age_hours→max_age_minutes (default 1440)
- PurgeConfig: privacy_retention_hours→privacy_retention_minutes (default 1440)
- PurgeConfig: enabled default False→True
- PurgeConfig: cron default every minute (was daily 3am)
- Purge scheduler runs every minute for minute-granularity testing
- All API fields renamed: purge_max_age_minutes, privacy_retention_minutes
- Frontend admin panel inputs show minutes with updated labels
- Updated test assertions for new defaults
2026-03-21 20:33:13 -05:00
xpltd
0a67cb45bc Fix session UNIQUE constraint race, fix table horizontal scrollbar
- 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
2026-03-21 20:23:07 -05:00
xpltd
1592407658 First-run admin setup wizard, password persistence, forced setup gate
- 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
2026-03-21 20:01:13 -05:00
xpltd
b86366116a Fix SSE busy-loop (ping=0), keep curl in image, recover zombie jobs on startup
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.
2026-03-21 17:59:24 -05:00
xpltd
182104e57f Persistent admin settings + new server config fields
Settings are now persisted to SQLite (config table) and survive restarts.

New admin-configurable settings (migrated from env-var-only):
- Max concurrent downloads (1-10, default 3)
- Session mode (isolated/shared/open)
- Session timeout hours (1-8760, default 72)
- Admin username
- Auto-purge enabled (bool)
- Purge max age hours (1-87600, default 168)

Existing admin settings now also persist:
- Welcome message
- Default video/audio formats
- Privacy mode + retention hours

Architecture:
- New settings service (services/settings.py) handles DB read/write
- Startup loads persisted settings and applies to AppConfig
- Admin PUT /settings validates, updates live config, and persists
- GET /admin/settings returns all configurable fields
- DownloadService.update_max_concurrent() hot-swaps the thread pool

Also:
- Fix footer GitHub URL (jlightner → xpltdco)
- Add DEPLOY-TEST-PROMPT.md for deployment testing
2026-03-19 12:11:53 -05:00
xpltd
5a6eb00906 Docker self-hosting: fix persistence, add data_dir config
Critical fix:
- Dockerfile env var was MEDIARIP__DATABASE__PATH (ignored) — now MEDIARIP__SERVER__DB_PATH
  DB was landing at /app/mediarip.db (lost on restart) instead of /data/mediarip.db

Persistence model:
- /downloads → media files (bind mount recommended)
- /data → SQLite DB, session cookies, error logs (named volume)
- /themes → custom CSS themes (read-only bind mount)
- /app/config.yaml → optional YAML config (read-only bind mount)

Other changes:
- Add server.data_dir config field (default: /data) for explicit session storage
- Cookie storage uses data_dir instead of fragile path math from output_dir parent
- Lifespan creates data_dir on startup
- .dockerignore excludes tests, dev DB, egg-info
- docker-compose.yml: inline admin/purge config examples
- docker-compose.example.yml: parameterized with env vars
- .env.example: session mode, clearer docs
- README: Docker volumes table, admin setup docs, full config reference
- PROJECT.md: reflects completed v1.0 state
- REQUIREMENTS.md: all 26 requirements validated
2026-03-19 09:56:10 -05:00
xpltd
85f57a3e41 Mark test_get_formats as integration (requires network + YouTube auth) 2026-03-19 07:30:56 -05:00
xpltd
aeb3238b84 Fix ruff lint errors: unused imports, E402 import ordering 2026-03-19 07:27:38 -05:00
xpltd
8eaeef6fcf v1.0.0: Fix Docker refs, Caddyfile, dedupe CI, add LICENSE
- Fix docker-compose.yml: image ref xpltdco (was jlightner), healthcheck uses python (no curl in image)
- Fix Caddyfile: reverse_proxy target matches docker-compose.example.yml service name (media-rip)
- Remove duplicate release.yml workflow (publish.yml handles tag-triggered builds)
- Fix publish.yml: use github.repository for portability, add contents:write for release creation
- Add MIT LICENSE file
2026-03-19 07:26:11 -05:00
xpltd
bef5ebf350 Fix org name: xpltd → xpltdco in image refs 2026-03-19 07:02:27 -05:00
xpltd
c9ad4fc5d0 R021/R022/R026: Docker, CI/CD, deployment example
Dockerfile (multi-stage):
- Stage 1: Node 22 builds frontend (npm ci + npm run build)
- Stage 2: Python 3.12 installs backend deps
- Stage 3: Slim runtime with ffmpeg + deno (yt-dlp needs both)
- Non-root user (mediarip), healthcheck, PYTHONUNBUFFERED
- Volumes: /downloads (media), /data (SQLite DB)

docker-compose.example.yml:
- Caddy reverse proxy with automatic TLS via Let's Encrypt
- Separate Caddyfile.example for domain configuration
- Health-dependent startup ordering
- Environment variables for admin setup

CI/CD (.github/workflows/):
- ci.yml: backend lint+test, frontend typecheck+test, Docker smoke
  build. Runs on PRs and pushes to main.
- publish.yml: multi-platform build (amd64+arm64), pushes to
  ghcr.io/xpltd/media-rip on v*.*.* tags. Semantic version tags
  (v1.0.0 → latest + 1.0.0 + 1.0 + 1). Auto GitHub Release.

.dockerignore: excludes dev artifacts, .gsd/, node_modules/, .venv/
2026-03-19 06:57:25 -05:00
xpltd
cbaec9ad36 R020: Zero outbound telemetry — CSP + security headers
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.
2026-03-19 06:53:08 -05:00
xpltd
8ac0e05b15 Fix table overflow, clickable source URLs, duplicate preview
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.
2026-03-19 06:47:55 -05:00
xpltd
1e9014f569 Error log: failed download diagnostics for admin
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.
2026-03-19 06:34:08 -05:00
xpltd
0df9573caa Settings page: single Save, clean flow
One Save Settings button covers all configuration:
- Welcome message
- Default output formats (video/audio)
- Privacy mode toggle + retention hours

Below the save area, separated by dividers:
- Manual Purge (immediate action, Sure? gate)
- Change Password (immediate action, own button)

Settings fields have subtle bottom borders for visual rhythm.
No section headings — the flow reads naturally top-to-bottom.
Removed redundant privacySaved ref and savePrivacy function.
2026-03-19 06:19:46 -05:00
xpltd
fe45fdce50 Settings flow rework, purge sessions, confirmation gate
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)
2026-03-19 06:16:43 -05:00
xpltd
dd60505f5a Settings layout rework, purge fix, SSE broadcast
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
2026-03-19 06:04:59 -05:00
xpltd
c3278fcac2 Privacy Mode: consolidated purge + auto-cleanup
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
2026-03-19 05:55:08 -05:00
xpltd
74ff9d3c08 Invalid URL display, password mismatch hint
- 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.
2026-03-19 05:42:53 -05:00