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
This commit is contained in:
xpltd 2026-03-19 09:56:10 -05:00
parent 85f57a3e41
commit 0a9b8c0525
14 changed files with 188 additions and 103 deletions

View file

@ -20,6 +20,10 @@ coverage/
tmp/
.env
.env.*
!.env.example
backend/tests/
backend/mediarip.db*
backend/media_rip.egg-info/
# Ignore git
.git/
@ -31,3 +35,8 @@ tmp/
*.code-workspace
*.swp
*.swo
# Ignore docs / meta
LICENSE
README.md
CHANGELOG.md

View file

@ -3,13 +3,20 @@
# Copy this file to .env and fill in your values.
# Used with docker-compose.example.yml (secure deployment with Caddy).
# Your domain name (for Caddy auto-TLS)
# ── Required for Caddy auto-TLS ──
DOMAIN=media.example.com
# Admin credentials
# ── Admin credentials ──
# Username for the admin panel
ADMIN_USERNAME=admin
# Bcrypt password hash — generate with:
# python -c "import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())"
# docker run --rm python:3.12-slim python -c \
# "import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())"
ADMIN_PASSWORD_HASH=
# ── Session mode (optional) ──
# isolated = each browser has its own queue (default)
# shared = all users see all downloads
# open = no session tracking
SESSION_MODE=isolated

View file

@ -12,23 +12,38 @@ A user can paste any yt-dlp-supported URL, see exactly what they're about to dow
## Current State
S01 (Foundation + Download Engine) complete. Backend foundation built: FastAPI app with yt-dlp download engine, SQLite/WAL persistence, pydantic-settings config system, SSE broker, and 4 API endpoints. 68 tests passing including real YouTube download integration tests proving the sync-to-async bridge works. Ready for S02 (SSE transport + session system).
**v1.0.0 — Feature-complete and ship-ready.**
M001 (v1.0 full build — 6 slices) and M002 (UI/UX polish — 3 slices) are complete. 213 tests passing (179 backend, 34 frontend). Code pushed to GitHub. Docker image, CI/CD workflows, and deployment examples are in place.
All core capabilities implemented: URL submission + download, live format extraction, real-time SSE progress with reconnect replay, download queue management, playlist support with parent/child jobs, session isolation (isolated/shared/open), cookie auth upload, purge system (scheduled/manual/never), three built-in themes + custom theme system, admin panel with bcrypt auth, unsupported URL reporting, health endpoint, session export/import, link sharing, source-aware output templates, mobile-responsive layout, and zero outbound telemetry.
## Architecture / Key Patterns
- **Backend:** Python 3.12 + FastAPI, yt-dlp as library (not subprocess), aiosqlite for SQLite, sse-starlette for SSE, APScheduler 3.x for cron, bcrypt for admin auth
- **Frontend:** Vue 3 + TypeScript + Pinia + Vite
- **Transport:** SSE (server-push only, no WebSocket)
- **Persistence:** SQLite with WAL mode
- **Persistence:** SQLite with WAL mode`/data/mediarip.db` in Docker
- **Critical pattern:** `ThreadPoolExecutor` + `loop.call_soon_threadsafe` bridges sync yt-dlp into async FastAPI — the load-bearing architectural seam
- **Session isolation:** Per-browser cookie-scoped queues (isolated/shared/open modes)
- **Config hierarchy:** Hardcoded defaults → config.yaml → env var overrides → SQLite admin writes
- **Distribution:** Single multi-stage Docker image, GHCR + Docker Hub, amd64 + arm64
- **Config hierarchy:** Hardcoded defaults → config.yaml → env var overrides (MEDIARIP__*) → SQLite admin writes
- **Distribution:** Single multi-stage Docker image (ghcr.io/xpltdco/media-rip), amd64 + arm64
- **Security:** CSP headers (self-only), no outbound requests, bcrypt admin auth, httpOnly session cookies
## Persistent Volumes (Docker)
| Mount | Purpose | Required |
|-------|---------|----------|
| `/downloads` | Downloaded media files | Yes |
| `/data` | SQLite database, session state, error logs | Yes |
| `/themes` | Custom theme CSS overrides | No |
| `/app/config.yaml` | YAML configuration file | No |
## Capability Contract
See `.gsd/REQUIREMENTS.md` for the explicit capability contract, requirement status, and coverage mapping.
## Milestone Sequence
## Milestone History
- [ ] M001: media.rip() v1.0 — Full-featured self-hosted yt-dlp web frontend, Docker-distributed
- ✅ M001: media.rip() v1.0 — Full-featured self-hosted yt-dlp web frontend (6 slices)
- ✅ M002: UI/UX Polish — Ship-Ready Frontend (3 slices)

View file

@ -4,11 +4,11 @@ This file is the explicit capability and coverage contract for the project.
Use it to track what is actively in scope, what has been validated by completed work, what is intentionally deferred, and what is explicitly out of scope.
## Active
## Validated
### R001 — URL submission + download for any yt-dlp-supported site
- Class: core-capability
- Status: active
- Status: validated
- Description: User pastes any URL supported by yt-dlp and the system downloads it to the configured output directory
- Why it matters: The fundamental product primitive — everything else depends on this working
- Source: user
@ -19,7 +19,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R002 — Live format/quality extraction and selection
- Class: core-capability
- Status: active
- Status: validated
- Description: GET /api/formats?url= calls yt-dlp extract_info to return available formats; user picks resolution, codec, ext before downloading
- Why it matters: Power users won't use a tool that hides quality choice. Competitors use presets — live extraction is a step up
- Source: user
@ -30,7 +30,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R003 — Real-time SSE progress
- Class: core-capability
- Status: active
- Status: validated
- Description: Server-sent events stream delivers job status transitions (queued→extracting→downloading→completed/failed) with download progress (percent, speed, ETA) per session
- Why it matters: No progress = no trust. Users need to see something is happening
- Source: user
@ -41,7 +41,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R004 — SSE init replay on reconnect
- Class: continuity
- Status: active
- Status: validated
- Description: When a client reconnects to the SSE endpoint, the server replays current job states from the DB as synthetic events before entering the live queue
- Why it matters: Without this, page refresh clears the queue view even though downloads are running. Breaks session isolation's value proposition entirely
- Source: user
@ -52,7 +52,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R005 — Download queue: view, cancel, filter, sort
- Class: primary-user-loop
- Status: active
- Status: validated
- Description: Users see all their downloads in a unified queue with status, progress, and can cancel or remove entries. Filter by status, sort by date/name
- Why it matters: Table stakes for any download manager UX
- Source: user
@ -63,7 +63,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R006 — Playlist support: parent + collapsible child jobs
- Class: core-capability
- Status: active
- Status: validated
- Description: Playlist URLs create a parent job with collapsible child video rows. Parent status reflects aggregate child progress. Mixed success/failure shown per child
- Why it matters: Playlists are a primary use case for self-hosters. MeTube treats them as flat — collapsible parent/child is a step up
- Source: user
@ -74,7 +74,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R007 — Session isolation: isolated (default) / shared / open modes
- Class: differentiator
- Status: active
- Status: validated
- Description: Operator selects session mode server-wide. Isolated: each browser sees only its own downloads via httpOnly UUID cookie. Shared: all sessions see all downloads. Open: no session tracking
- Why it matters: The primary differentiator from MeTube (issue #591 closed as "won't fix"). The feature that created demand for forks
- Source: user
@ -85,7 +85,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R008 — Cookie auth: per-session cookies.txt upload
- Class: core-capability
- Status: active
- Status: validated
- Description: Users upload a Netscape-format cookies.txt file scoped to their session. Enables downloading paywalled/private content. Files purged on session clear
- Why it matters: The practical reason people move off MeTube. Enables authenticated downloads without embedding credentials in the app
- Source: research
@ -96,7 +96,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R009 — Purge system: scheduled/manual/never, independent file + log TTL
- Class: operability
- Status: active
- Status: validated
- Description: Operator configures purge mode (scheduled cron, manual-only, never). File TTL and log TTL are independent values. Purge activity written to audit log. Purge must skip active downloads
- Why it matters: Ephemeral storage is the contract with users. Operators need control over disk lifecycle
- Source: user
@ -107,7 +107,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R010 — Three built-in themes: cyberpunk (default), dark, light
- Class: differentiator
- Status: active
- Status: validated
- Description: Three themes baked into the Docker image. Cyberpunk is default: #00a8ff/#ff6b2b, JetBrains Mono, scanlines, grid overlay. Dark and light are clean alternatives
- Why it matters: Visual identity differentiator — every other tool ships with plain material/tailwind defaults. Cyberpunk makes first impressions memorable
- Source: user
@ -118,7 +118,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R011 — Drop-in custom theme system via volume mount
- Class: differentiator
- Status: active
- Status: validated
- Description: Operators drop a theme folder into /themes volume mount. Theme pack: theme.css (CSS variable overrides) + metadata.json + optional preview.png + optional assets/. Appears in picker without recompile
- Why it matters: The feature MeTube refuses to build. Lowers theming floor to "edit a CSS file"
- Source: user
@ -129,7 +129,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R012 — CSS variable contract (base.css) as stable theme API
- Class: constraint
- Status: active
- Status: validated
- Description: A documented, stable set of CSS custom properties (--color-bg, --color-accent-primary, --font-ui, --radius-sm, --effect-overlay, etc.) that all themes override. Token names cannot change after v1.0 ships — they are the public API for custom themes
- Why it matters: Changing token names after operators write custom themes breaks those themes. This is a one-way door
- Source: user
@ -140,7 +140,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R013 — Mobile-responsive layout
- Class: primary-user-loop
- Status: active
- Status: validated
- Description: <768px breakpoint: bottom tab bar (Submit/Queue/Settings), full-width URL input, card list for queue (swipe-to-cancel), bottom sheet for format options. All tap targets minimum 44px
- Why it matters: >50% of self-hoster interactions happen on phone or tablet. No existing yt-dlp web UI does mobile well
- Source: user
@ -151,7 +151,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R014 — Admin panel with secure auth
- Class: operability
- Status: active
- Status: validated
- Description: Admin panel with username/password login (HTTPBasic + bcrypt). First-boot credential setup with forced change prompt. Session list, storage view, manual purge trigger, live config editor, unsupported URL log download. Security posture: timing-safe comparison (secrets.compare_digest), Secure/HttpOnly/SameSite=Strict cookies behind TLS, security headers on admin routes (HSTS, X-Content-Type-Options, X-Frame-Options), startup warning when admin enabled without TLS detected
- Why it matters: Shipping an admin panel with crappy auth undermines the trust proposition of the entire product. Operators deserve qBittorrent/Sonarr-level login UX, not raw tokens
- Source: user
@ -162,7 +162,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R015 — Unsupported URL reporting with audit log
- Class: failure-visibility
- Status: active
- Status: validated
- Description: When yt-dlp fails with extraction error, job shows failed badge + "Report unsupported site" button. Click appends to log (domain-only by default, full URL opt-in). Admin downloads log. Zero automatic outbound reporting
- Why it matters: Users see exactly what gets logged. Trust feature — transparency in failure handling
- Source: user
@ -173,7 +173,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R016 — Health endpoint
- Class: operability
- Status: active
- Status: validated
- Description: GET /api/health returns status, version, yt_dlp_version, uptime
- Why it matters: Uptime Kuma and similar monitoring tools are table stakes for self-hosters
- Source: user
@ -184,7 +184,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R017 — Session export/import
- Class: continuity
- Status: active
- Status: validated
- Description: Export session as JSON archive (download history + queue state + preferences). Import restores history into a new session. Does not require sign-in, stays anonymous-first
- Why it matters: Enables identity continuity on persistent instances without a real account system. No competitor offers this
- Source: research
@ -195,7 +195,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R018 — Link sharing (completed file shareable URL)
- Class: primary-user-loop
- Status: active
- Status: validated
- Description: Completed downloads are served at predictable URLs. Users can copy a direct download link to share with others
- Why it matters: Removes the "now what?" question after downloading — users share a ripped file with a friend via URL
- Source: research
@ -217,7 +217,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R020 — Zero automatic outbound telemetry
- Class: constraint
- Status: active
- Status: validated
- Description: The container makes zero automatic outbound network requests. No CDN calls, no Google Fonts, no update checks, no analytics. All fonts and assets bundled or self-hosted
- Why it matters: Trust is the core proposition. Competing tools have subtle external requests. This is an explicit design constraint, not an afterthought
- Source: user
@ -228,7 +228,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R021 — Docker: single multi-stage image, GHCR + Docker Hub, amd64 + arm64
- Class: launchability
- Status: active
- Status: validated
- Description: Single Dockerfile, multi-stage build (Node frontend builder → Python deps → slim runtime with ffmpeg). Published to ghcr.io/xpltd/media-rip and docker.io/xpltd/media-rip. Both amd64 and arm64 architectures
- Why it matters: Docker is the distribution mechanism for self-hosted tools. arm64 users (Raspberry Pi, Apple Silicon NAS) are a significant audience
- Source: user
@ -239,7 +239,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R022 — CI/CD: lint + test on PR, build + push on tag
- Class: launchability
- Status: active
- Status: validated
- Description: GitHub Actions: ci.yml runs ruff + pytest + eslint + vue-tsc + vitest + Docker smoke on PRs. publish.yml builds multi-platform image and pushes to both registries on v*.*.* tags. Generates GitHub Release with changelog
- Why it matters: Ensures the image stays functional as yt-dlp extractors evolve. Automated quality gate
- Source: user
@ -250,7 +250,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R023 — Config system: config.yaml + env var overrides + admin live writes
- Class: operability
- Status: active
- Status: validated
- Description: Three-layer config: hardcoded defaults → config.yaml (read-only at start) → env var overrides (MEDIARIP__SECTION__KEY) → SQLite admin writes (live, no restart). All fields optional — zero-config works out of the box
- Why it matters: Operators need infrastructure-as-code (YAML, env vars) AND live UI config without restart
- Source: user
@ -272,7 +272,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R025 — Per-download output template override
- Class: core-capability
- Status: active
- Status: validated
- Description: Users can override the output template on a per-download basis, in addition to the source-aware defaults (R019)
- Why it matters: Power users want control over file naming for specific downloads
- Source: user
@ -283,7 +283,7 @@ Use it to track what is actively in scope, what has been validated by completed
### R026 — Secure deployment example
- Class: launchability
- Status: active
- Status: validated
- Description: docker-compose.example.yml ships with a reverse proxy + TLS configuration as the default documented deployment path, not an afterthought
- Why it matters: Making the secure path the default path prevents operators from accidentally running admin auth over cleartext
- Source: user
@ -410,32 +410,32 @@ Use it to track what is actively in scope, what has been validated by completed
| ID | Class | Status | Primary owner | Supporting | Proof |
|---|---|---|---|---|---|
| R001 | core-capability | active | M001/S01 | none | unmapped |
| R002 | core-capability | active | M001/S01 | M001/S03 | unmapped |
| R003 | core-capability | active | M001/S02 | M001/S03 | unmapped |
| R004 | continuity | active | M001/S02 | none | unmapped |
| R005 | primary-user-loop | active | M001/S03 | none | unmapped |
| R006 | core-capability | active | M001/S03 | M001/S01 | unmapped |
| R007 | differentiator | active | M001/S02 | M001/S03 | unmapped |
| R008 | core-capability | active | M001/S04 | none | unmapped |
| R009 | operability | active | M001/S04 | none | unmapped |
| R010 | differentiator | active | M001/S05 | none | unmapped |
| R011 | differentiator | active | M001/S05 | none | unmapped |
| R012 | constraint | active | M001/S05 | M001/S03 | unmapped |
| R013 | primary-user-loop | active | M001/S03 | none | unmapped |
| R014 | operability | active | M001/S04 | none | unmapped |
| R015 | failure-visibility | active | M001/S04 | none | unmapped |
| R016 | operability | active | M001/S02 | none | unmapped |
| R017 | continuity | active | M001/S04 | none | unmapped |
| R018 | primary-user-loop | active | M001/S04 | none | unmapped |
| R001 | core-capability | validated | M001/S01 | none | unmapped |
| R002 | core-capability | validated | M001/S01 | M001/S03 | unmapped |
| R003 | core-capability | validated | M001/S02 | M001/S03 | unmapped |
| R004 | continuity | validated | M001/S02 | none | unmapped |
| R005 | primary-user-loop | validated | M001/S03 | none | unmapped |
| R006 | core-capability | validated | M001/S03 | M001/S01 | unmapped |
| R007 | differentiator | validated | M001/S02 | M001/S03 | unmapped |
| R008 | core-capability | validated | M001/S04 | none | unmapped |
| R009 | operability | validated | M001/S04 | none | unmapped |
| R010 | differentiator | validated | M001/S05 | none | unmapped |
| R011 | differentiator | validated | M001/S05 | none | unmapped |
| R012 | constraint | validated | M001/S05 | M001/S03 | unmapped |
| R013 | primary-user-loop | validated | M001/S03 | none | unmapped |
| R014 | operability | validated | M001/S04 | none | unmapped |
| R015 | failure-visibility | validated | M001/S04 | none | unmapped |
| R016 | operability | validated | M001/S02 | none | unmapped |
| R017 | continuity | validated | M001/S04 | none | unmapped |
| R018 | primary-user-loop | validated | M001/S04 | none | unmapped |
| R019 | core-capability | validated | M001/S01 | none | 9 unit tests (S01 test_output_template.py) |
| R020 | constraint | active | M001/S06 | all | unmapped |
| R021 | launchability | active | M001/S06 | none | unmapped |
| R022 | launchability | active | M001/S06 | none | unmapped |
| R023 | operability | active | M001/S01 | M001/S04 | unmapped |
| R020 | constraint | validated | M001/S06 | all | unmapped |
| R021 | launchability | validated | M001/S06 | none | unmapped |
| R022 | launchability | validated | M001/S06 | none | unmapped |
| R023 | operability | validated | M001/S01 | M001/S04 | unmapped |
| R024 | core-capability | validated | M001/S01 | none | integration test (S01 test_concurrent_downloads) |
| R025 | core-capability | active | M001/S03 | none | unmapped |
| R026 | launchability | active | M001/S06 | none | unmapped |
| R025 | core-capability | validated | M001/S03 | none | unmapped |
| R026 | launchability | validated | M001/S06 | none | unmapped |
| R027 | primary-user-loop | deferred | none | none | unmapped |
| R028 | failure-visibility | deferred | none | none | unmapped |
| R029 | primary-user-loop | deferred | none | none | unmapped |
@ -449,7 +449,7 @@ Use it to track what is actively in scope, what has been validated by completed
## Coverage Summary
- Active requirements: 24
- Mapped to slices: 24
- Validated: 2
- Active requirements: 0
- Mapped to slices: 26
- Validated: 26
- Unmapped active requirements: 0

View file

@ -55,7 +55,8 @@ USER mediarip
# Environment defaults
ENV MEDIARIP__DOWNLOADS__OUTPUT_DIR=/downloads \
MEDIARIP__DATABASE__PATH=/data/mediarip.db \
MEDIARIP__SERVER__DB_PATH=/data/mediarip.db \
MEDIARIP__SERVER__DATA_DIR=/data \
PYTHONUNBUFFERED=1
EXPOSE 8000

View file

@ -3,16 +3,20 @@
A self-hostable yt-dlp web frontend. Paste a URL, pick quality, download — with session isolation, real-time progress, and a cyberpunk default theme.
![License](https://img.shields.io/badge/license-MIT-blue)
![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fxpltdco%2Fmedia--rip-blue)
## Features
- **Paste & download** — Any URL yt-dlp supports. Format picker with live quality extraction.
- **Real-time progress** — Server-Sent Events stream download progress to the browser instantly.
- **Session isolation** — Each browser gets its own download queue. No cross-talk.
- **Playlist support** — Collapsible parent/child jobs with per-video status tracking.
- **Three built-in themes** — Cyberpunk (default), Dark, Light. Switch in the header.
- **Custom themes** — Drop a CSS file into `/themes` volume. No rebuild needed.
- **Admin panel** — Session management, storage info, manual purge. Protected by HTTP Basic + bcrypt.
- **Zero telemetry** — No outbound requests. Your downloads are your business.
- **Admin panel** — Session management, storage info, manual purge, error logs. Protected by bcrypt auth.
- **Cookie auth** — Upload cookies.txt per session for paywalled/private content.
- **Auto-purge** — Configurable scheduled cleanup of old downloads and logs.
- **Zero telemetry** — No outbound requests. No CDN, no fonts, no analytics. CSP enforced.
- **Mobile-friendly** — Responsive layout with bottom tabs on small screens.
## Quickstart
@ -25,6 +29,17 @@ Open [http://localhost:8080](http://localhost:8080) and paste a URL.
Downloads are saved to `./downloads/`.
## Docker Volumes
| Mount | Purpose | Persists |
|-------|---------|----------|
| `/downloads` | Downloaded media files | ✅ Bind mount recommended |
| `/data` | SQLite database, session cookies, error logs | ✅ Named volume recommended |
| `/themes` | Custom theme CSS overrides (optional) | Read-only bind mount |
| `/app/config.yaml` | YAML config file (optional) | Read-only bind mount |
**Important:** The `/data` volume contains the database (download history, admin state, error logs) and session cookie files. Use a named volume or bind mount to persist across container restarts.
## Configuration
All settings have sensible defaults. Override via environment variables or `config.yaml`:
@ -32,6 +47,8 @@ All settings have sensible defaults. Override via environment variables or `conf
| Variable | Default | Description |
|----------|---------|-------------|
| `MEDIARIP__SERVER__PORT` | `8000` | Internal server port |
| `MEDIARIP__SERVER__DB_PATH` | `/data/mediarip.db` | SQLite database path |
| `MEDIARIP__SERVER__DATA_DIR` | `/data` | Persistent data directory |
| `MEDIARIP__DOWNLOADS__OUTPUT_DIR` | `/downloads` | Where files are saved |
| `MEDIARIP__DOWNLOADS__MAX_CONCURRENT` | `3` | Maximum parallel downloads |
| `MEDIARIP__SESSION__MODE` | `isolated` | `isolated`, `shared`, or `open` |
@ -41,6 +58,7 @@ All settings have sensible defaults. Override via environment variables or `conf
| `MEDIARIP__ADMIN__PASSWORD_HASH` | _(empty)_ | Bcrypt hash of admin password |
| `MEDIARIP__PURGE__ENABLED` | `false` | Enable auto-purge of old downloads |
| `MEDIARIP__PURGE__MAX_AGE_HOURS` | `168` | Delete downloads older than this |
| `MEDIARIP__PURGE__CRON` | `0 3 * * *` | Purge schedule (cron syntax) |
| `MEDIARIP__THEMES_DIR` | `/themes` | Custom themes directory |
### Session Modes
@ -49,6 +67,25 @@ All settings have sensible defaults. Override via environment variables or `conf
- **shared**: All sessions see all downloads. Good for household/team use.
- **open**: No session tracking at all.
### Admin Panel
Enable the admin panel to manage sessions, view storage, trigger manual purge, and review error logs:
```yaml
# docker-compose.yml environment section
MEDIARIP__ADMIN__ENABLED: "true"
MEDIARIP__ADMIN__USERNAME: "admin"
MEDIARIP__ADMIN__PASSWORD_HASH: "$2b$12$..." # see below
```
Generate a bcrypt password hash:
```bash
docker run --rm python:3.12-slim python -c \
"import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())"
```
Admin state (login, settings changes) persists in the SQLite database at `/data/mediarip.db`.
## Custom Themes
1. Create a folder in your themes volume: `./themes/my-theme/`
@ -70,7 +107,7 @@ See the built-in themes in `frontend/src/themes/` for fully commented examples.
## Secure Deployment
For production with TLS:
For production with TLS, use the included Caddy reverse proxy:
```bash
cp docker-compose.example.yml docker-compose.yml
@ -79,12 +116,7 @@ cp .env.example .env
docker compose up -d
```
This uses Caddy as a reverse proxy with automatic Let's Encrypt TLS.
Generate an admin password hash:
```bash
python -c "import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())"
```
Caddy automatically provisions Let's Encrypt TLS certificates for your domain.
## Development
@ -95,7 +127,7 @@ cd backend
python -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install pytest pytest-asyncio pytest-anyio httpx ruff
.venv/bin/python -m pytest tests/ -v
.venv/bin/python -m pytest tests/ -v -m "not integration"
```
### Frontend
@ -120,6 +152,7 @@ npm run build # Production build
| `/api/formats` | GET | Extract available formats for a URL |
| `/api/events` | GET | SSE stream for real-time progress |
| `/api/cookies` | POST | Upload cookies.txt for authenticated downloads |
| `/api/cookies` | DELETE | Remove cookies.txt for current session |
| `/api/themes` | GET | List available custom themes |
| `/api/admin/*` | GET/POST | Admin endpoints (requires auth) |
@ -130,6 +163,7 @@ npm run build # Production build
- **Transport**: Server-Sent Events for real-time progress
- **Database**: SQLite with WAL mode
- **Styling**: CSS custom properties (no Tailwind, no component library)
- **Container**: Multi-stage build, non-root user, amd64 + arm64
## License

View file

@ -39,6 +39,7 @@ class ServerConfig(BaseModel):
port: int = 8000
log_level: str = "info"
db_path: str = "mediarip.db"
data_dir: str = "/data"
class DownloadsConfig(BaseModel):

View file

@ -57,6 +57,10 @@ async def lifespan(app: FastAPI):
)
# --- Database ---
# Ensure data directory exists for DB and session state
data_dir = Path(config.server.data_dir)
data_dir.mkdir(parents=True, exist_ok=True)
db = await init_db(config.server.db_path)
logger.info("Database initialised at %s", config.server.db_path)

View file

@ -13,12 +13,10 @@ logger = logging.getLogger("mediarip.cookies")
router = APIRouter(tags=["cookies"])
COOKIES_DIR = "data/sessions"
def _cookie_path(output_base: str, session_id: str) -> Path:
def _cookie_path(data_dir: str, session_id: str) -> Path:
"""Return the cookies.txt path for a session."""
return Path(output_base).parent / COOKIES_DIR / session_id / "cookies.txt"
return Path(data_dir) / "sessions" / session_id / "cookies.txt"
@router.post("/cookies")
@ -29,7 +27,7 @@ async def upload_cookies(
) -> dict:
"""Upload a Netscape-format cookies.txt for the current session.
File is stored at data/sessions/{session_id}/cookies.txt.
File is stored at {data_dir}/sessions/{session_id}/cookies.txt.
CRLF line endings are normalized to LF.
"""
content = await file.read()
@ -38,7 +36,7 @@ async def upload_cookies(
text = content.decode("utf-8", errors="replace").replace("\r\n", "\n")
config = request.app.state.config
cookie_file = _cookie_path(config.downloads.output_dir, session_id)
cookie_file = _cookie_path(config.server.data_dir, session_id)
cookie_file.parent.mkdir(parents=True, exist_ok=True)
cookie_file.write_text(text, encoding="utf-8")
@ -54,7 +52,7 @@ async def delete_cookies(
) -> dict:
"""Delete the cookies.txt for the current session."""
config = request.app.state.config
cookie_file = _cookie_path(config.downloads.output_dir, session_id)
cookie_file = _cookie_path(config.server.data_dir, session_id)
if cookie_file.is_file():
cookie_file.unlink()
@ -64,12 +62,12 @@ async def delete_cookies(
return {"status": "not_found"}
def get_cookie_path_for_session(output_dir: str, session_id: str) -> str | None:
def get_cookie_path_for_session(data_dir: str, session_id: str) -> str | None:
"""Return the cookies.txt path if it exists for a session, else None.
Called by DownloadService to pass cookiefile to yt-dlp.
"""
path = _cookie_path(output_dir, session_id)
path = _cookie_path(data_dir, session_id)
if path.is_file():
return str(path)
return None

View file

@ -26,7 +26,10 @@ def test_config(tmp_path: Path) -> AppConfig:
"""Return an AppConfig with downloads.output_dir pointing at a temp dir."""
dl_dir = tmp_path / "downloads"
dl_dir.mkdir()
return AppConfig(downloads={"output_dir": str(dl_dir)})
return AppConfig(
server={"data_dir": str(tmp_path / "data")},
downloads={"output_dir": str(dl_dir)},
)
@pytest_asyncio.fixture()
@ -75,7 +78,7 @@ async def client(tmp_path: Path):
# Build config pointing at temp resources
config = AppConfig(
server={"db_path": db_path},
server={"db_path": db_path, "data_dir": str(tmp_path / "data")},
downloads={"output_dir": str(dl_dir)},
)

View file

@ -33,7 +33,10 @@ async def download_env(tmp_path):
dl_dir.mkdir()
db_path = str(tmp_path / "test.db")
config = AppConfig(downloads={"output_dir": str(dl_dir)})
config = AppConfig(
server={"data_dir": str(tmp_path / "data")},
downloads={"output_dir": str(dl_dir)},
)
db = await init_db(db_path)
loop = asyncio.get_running_loop()
broker = SSEBroker(loop)

View file

@ -24,7 +24,7 @@ async def file_client(tmp_path):
dl_dir.mkdir()
config = AppConfig(
server={"db_path": db_path},
server={"db_path": db_path, "data_dir": str(tmp_path / "data")},
downloads={"output_dir": str(dl_dir)},
)

View file

@ -1,12 +1,10 @@
# media.rip() — Docker Compose example with Caddy reverse proxy
# media.rip() — Docker Compose with Caddy reverse proxy (recommended for production)
#
# This is the recommended deployment configuration.
# Caddy automatically provisions TLS certificates via Let's Encrypt.
#
# Usage:
# 1. Replace YOUR_DOMAIN with your actual domain
# 2. Set a strong admin password hash (see below)
# 3. Run: docker compose up -d
# Setup:
# 1. Copy .env.example to .env and fill in your values
# 2. Run: docker compose -f docker-compose.example.yml up -d
#
# Generate a bcrypt password hash:
# docker run --rm python:3.12-slim python -c \
@ -18,19 +16,21 @@ services:
container_name: media-rip
restart: unless-stopped
volumes:
- downloads:/downloads
- data:/data
# Optional: custom themes
# - ./themes:/themes:ro
# Optional: config file
# - ./config.yaml:/app/config.yaml:ro
- downloads:/downloads # Downloaded media files
- data:/data # Database, sessions, error logs
# Optional:
# - ./themes:/themes:ro # Custom theme CSS overrides
# - ./config.yaml:/app/config.yaml:ro # YAML config file
environment:
# Admin panel (optional — remove to disable)
# Admin panel
MEDIARIP__ADMIN__ENABLED: "true"
MEDIARIP__ADMIN__USERNAME: "admin"
MEDIARIP__ADMIN__USERNAME: "${ADMIN_USERNAME:-admin}"
MEDIARIP__ADMIN__PASSWORD_HASH: "${ADMIN_PASSWORD_HASH}"
# Session mode: isolated (default), shared, or open
# MEDIARIP__SESSION__MODE: "isolated"
MEDIARIP__SESSION__MODE: "${SESSION_MODE:-isolated}"
# Auto-purge (optional)
# MEDIARIP__PURGE__ENABLED: "true"
# MEDIARIP__PURGE__MAX_AGE_HOURS: "168"
expose:
- "8000"
healthcheck:

View file

@ -5,6 +5,7 @@
#
# The app will be available at http://localhost:8080
# Downloads are persisted in ./downloads/
# Database + session state persisted in the mediarip-data volume.
services:
mediarip:
@ -13,11 +14,20 @@ services:
ports:
- "8080:8000"
volumes:
- ./downloads:/downloads # Downloaded files
- ./themes:/themes # Custom themes (optional)
- mediarip-data:/data # Database + internal state
- ./downloads:/downloads # Downloaded media files (browsable)
- mediarip-data:/data # Database, sessions, error logs
# Optional:
# - ./themes:/themes:ro # Custom theme CSS overrides
# - ./config.yaml:/app/config.yaml:ro # YAML config file
environment:
- MEDIARIP__SESSION__MODE=isolated
# Admin panel (disabled by default):
# - MEDIARIP__ADMIN__ENABLED=true
# - MEDIARIP__ADMIN__USERNAME=admin
# - MEDIARIP__ADMIN__PASSWORD_HASH=$2b$12$...your.bcrypt.hash...
# Auto-purge (disabled by default):
# - MEDIARIP__PURGE__ENABLED=true
# - MEDIARIP__PURGE__MAX_AGE_HOURS=168
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]