summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-05 21:26:46 +0300
committerPaul Buetow <paul@buetow.org>2026-05-05 21:26:46 +0300
commit83ffe8544aa7845810049083d1376f7c7d01cd2c (patch)
tree12010312175f9fca91c9115ca865bb0fb50fd63c /docs
parent7bc3b65b3c66c7744e1c6d4fa69842985d06d9b7 (diff)
Expand README and split documentation into docs/ directory
Diffstat (limited to 'docs')
-rw-r--r--docs/admin.md50
-rw-r--r--docs/api.md84
-rw-r--r--docs/configuration.md19
-rw-r--r--docs/keyboard-shortcuts.md34
-rw-r--r--docs/podcasts.md31
-rw-r--r--docs/quick-start.md56
-rw-r--r--docs/theming.md53
7 files changed, 327 insertions, 0 deletions
diff --git a/docs/admin.md b/docs/admin.md
new file mode 100644
index 0000000..fcc0cdc
--- /dev/null
+++ b/docs/admin.md
@@ -0,0 +1,50 @@
+Admin Guide
+===========
+
+Admin endpoints are gated by `RequireAdmin` middleware (checks `users.is_admin`). The admin panel is opened via the "Admin" button in the SPA header (shown only when the current user is an admin).
+
+### Bootstrap
+
+On first visit (no users exist), you are redirected to `/bootstrap.html` to create the initial admin account.
+
+### Creating Users
+
+1. Open the admin panel.
+2. Enter username, password, and check "Is admin" if desired.
+3. Submit — the frontend calls `POST /api/admin/users`.
+4. Admins cannot delete themselves via `DELETE /api/admin/users/:id`.
+
+### Managing Set Permissions
+
+- `GET /api/admin/permissions` — list permissions matrix
+- `POST /api/admin/permissions` — grant access to a set (`body: { set_id, user_id, role: "owner" | "viewer" }`)
+- `DELETE /api/admin/permissions` — revoke access (`body: { set_id, user_id }`)
+
+Roles:
+- `owner` — can upload to the set, soft-delete / restore media, regenerate thumbnails
+- `viewer` — can browse and play media in the set
+
+Admins implicitly see all sets without explicit permission rows.
+
+### Rescanning the Library
+
+Click **Rescan** in the admin panel, or call:
+
+```bash
+curl -X POST -b session=<cookie> http://<host>/api/admin/rescan
+```
+
+This triggers `FSScanner.Scan()`, which:
+1. Scans immediate subdirectories of `MEDIA_ROOT` as **sets**
+2. Recursively walks each set for supported media files
+3. Probes new files with `ffprobe`
+4. Generates thumbnails for video files
+5. Inserts new records into the `media` table
+
+### Managing Trash
+
+- `GET /api/admin/trash` — list soft-deleted media
+- `DELETE /api/media/{id}` — soft-delete a media item
+- `POST /api/media/{id}/restore` — restore a soft-deleted item
+
+Soft-deleted media remains on disk until garbage collection removes it (see `GC_INTERVAL_MINUTES`). \ No newline at end of file
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..0132300
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,84 @@
+API Reference
+=============
+
+### Public
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `POST` | `/api/bootstrap` | Create the first admin account |
+| `POST` | `/api/login` | Login |
+| `GET` | `/healthz` | Liveness probe (no DB) |
+| `GET` | `/readyz` | Readiness probe (DB ping) |
+| `GET` | `/s/{token}` | View a shared media item |
+| `GET` | `/s/{token}/stream` | Stream shared media |
+| `GET` | `/s/{token}/thumbnail` | Thumbnail for shared media |
+| `GET` | `/s/{token}/download` | Download shared media |
+
+### Session-required
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `POST` | `/api/logout` | Logout |
+| `GET` | `/api/sets` | List sets |
+| `GET` | `/api/sets/{id}/browse` | Browse a set (folder navigation) |
+| `GET` | `/api/sets/{id}/cover` | Get set cover image |
+| `POST` | `/api/sets/{id}/cover` | Update set cover image |
+| `POST` | `/api/sets/{id}/upload` | Upload file to set |
+| `GET` | `/api/media` | List/search media (with filters) |
+| `GET` | `/api/media/{id}` | Get media details |
+| `GET` | `/api/media/{id}/stream` | Stream media (range support) |
+| `GET` | `/api/media/{id}/download` | Download original file |
+| `GET` | `/api/media/{id}/thumbnail` | Get thumbnail |
+| `POST` | `/api/media/{id}/thumbnail` | Regenerate thumbnail |
+| `POST` | `/api/media/{id}/favorite` | Toggle favorite |
+| `POST` | `/api/media/{id}/tags` | Add tag |
+| `DELETE` | `/api/media/{id}/tags/{tag}` | Remove tag |
+| `POST` | `/api/media/{id}/shares` | Create share link |
+| `GET` | `/api/media/{id}/shares` | List shares for a media item |
+| `GET` | `/api/media/{id}/notes` | Get note |
+| `POST` | `/api/media/{id}/notes` | Upsert note |
+| `DELETE` | `/api/media/{id}/notes` | Delete note |
+| `POST` | `/api/progress` | Save playback progress |
+| `DELETE` | `/api/media/{id}` | Soft-delete media |
+| `POST` | `/api/media/{id}/restore` | Restore soft-deleted media |
+| `GET` | `/api/shares` | List my shares |
+| `DELETE` | `/api/shares/{token}` | Revoke share |
+| `GET` | `/api/podcasts` | List podcasts |
+| `GET` | `/api/podcasts/{id}/episodes` | List episodes |
+| `POST` | `/api/podcasts/episodes/{id}/download` | Download episode to server |
+| `POST` | `/api/podcasts/episodes/{id}/complete` | Toggle episode listened |
+
+### Admin-only
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/api/admin/users` | List users |
+| `POST` | `/api/admin/users` | Create user |
+| `DELETE` | `/api/admin/users/{id}` | Delete user |
+| `GET` | `/api/admin/permissions` | List permissions |
+| `POST` | `/api/admin/permissions` | Grant set permission |
+| `DELETE` | `/api/admin/permissions` | Revoke set permission |
+| `POST` | `/api/admin/rescan` | Trigger library rescan |
+| `GET` | `/api/admin/scan-progress` | Get scan progress |
+| `GET` | `/api/admin/trash` | List soft-deleted media |
+| `POST` | `/api/podcasts` | Subscribe to podcast feed |
+
+### Query parameters for `GET /api/media`
+
+| Parameter | Description |
+|-----------|-------------|
+| `search` | Plain text search on file name |
+| `set_id` | Filter by a single set ID |
+| `set_ids` | Filter by comma-separated set IDs |
+| `type` | Filter by media type (e.g. `video`, `audio`, `image`) |
+| `favorites` | `true` or `1` to show favorites only |
+| `tags` | Comma-separated tag names |
+| `min_duration` | Minimum duration in minutes |
+| `max_duration` | Maximum duration in minutes |
+| `filesize_min` | Minimum file size in MB |
+| `filesize_max` | Maximum file size in MB |
+| `sort` | Sort order (e.g. `random`) |
+| `limit` | Page size |
+| `offset` | Page offset |
+| `folder` | Filter to a specific folder path |
+| `parent` | Filter to items within a parent folder | \ No newline at end of file
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..60d6ad6
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,19 @@
+Configuration
+=============
+
+All settings are environment variables. Unset variables use defaults.
+
+| Variable | Default | Validation | Description |
+|----------|---------|------------|-------------|
+| `PORT` | `8080` | 0–65535 | HTTP listen port (0 = ephemeral, used in tests) |
+| `MEDIA_ROOT` | `./media` | — | Root path for media set directories |
+| `DB_PATH` | `data.db` | — | SQLite database file path |
+| `MAX_UPLOAD_SIZE_MB` | `100` | ≥ 1 | Max upload size per file (MB) |
+| `SESSION_TIMEOUT_HOURS` | `24` | ≥ 1 | Cookie / session expiry |
+| `GC_INTERVAL_MINUTES` | `30` | ≥ 1 | Garbage collector tick interval |
+| `SHARE_DEFAULT_EXPIRY_DAYS` | `7` | ≥ 1 | Default share link lifetime |
+| `PODCAST_CHECK_INTERVAL_MINUTES` | `60` | ≥ 1 | Podcast feed refresh interval |
+| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | Log verbosity |
+| `SECURE_COOKIES` | `true` | `true` / `false` | Set `Secure` flag on session cookies; set to `false` for plain-HTTP local deployments |
+
+**Important:** The K8s `Deployment` overrides `DB_PATH` to `/data/media.db` and `MEDIA_ROOT` to `/media` so the PVC mounts are used. Do not rely on the local defaults in a container. \ No newline at end of file
diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md
new file mode 100644
index 0000000..cbf0cca
--- /dev/null
+++ b/docs/keyboard-shortcuts.md
@@ -0,0 +1,34 @@
+Keyboard Shortcuts
+==================
+
+Global shortcuts are registered in `web/js/keyboard.js`. They are **disabled** while the user is focused on an `INPUT`, `TEXTAREA`, or `contentEditable` element (except `Escape` to blur).
+
+| Key | Action |
+|-----|--------|
+| `↑` / `↓` | Navigate media list (up / down) |
+| `k` / `j` | Navigate media list (up / down) |
+| `←` / `→` | Seek or switch pages |
+| `h` / `l` | Seek or switch pages |
+| `Enter` | Open selected media (navigate to detail) |
+| `Space` / `p` | Play / pause / switch to selected item |
+| `N` / `P` | Next / previous track |
+| `f` | Toggle fullscreen on the player wrapper |
+| `Esc` | Exit fullscreen, or deselect current item |
+| `r` | Toggle shuffle on the current filtered result set |
+| `s` | Toggle sets sidebar |
+| `S` | Generate a share link for the selected media |
+| `/` | Focus the quick search bar (debounced) |
+| `n` | Open notes modal for the selected media |
+| `i` | Show / hide media info |
+| `C` | Minimize player |
+| `d` | Detach / reattach player |
+| `D` | Download selected media |
+| `+` / `−` | Zoom in / out (image viewer) |
+| `Shift+S` | Toggle slideshow (images) |
+| `L` | My Shares |
+| `Backspace` | Go up one folder |
+| `?` | Show / hide help |
+
+Search syntax: plain text searches file name. Modifiers: `min:30`, `max:55`, `tag:a,b`, `like:1`, `type:video`, `sort:random`, `minsize:10`, `maxsize:500`.
+
+My Shares modal: `↑` / `↓` or `k` / `j` navigate, `Enter` copies, `Delete` revokes, `Esc` closes. \ No newline at end of file
diff --git a/docs/podcasts.md b/docs/podcasts.md
new file mode 100644
index 0000000..6d03601
--- /dev/null
+++ b/docs/podcasts.md
@@ -0,0 +1,31 @@
+Podcast Support
+===============
+
+Podcasts are **special sets** (`sets.is_podcast = 1`). They reuse set permissions, browsing, and cover images, while adding feed management and episode tracking.
+
+### Subscribing
+
+Admin opens the **Podcasts** button in the admin panel (or calls `POST /api/podcasts`):
+- Submit an RSS/Atom feed URL and optional folder name.
+- Server creates a set folder, parses the feed, downloads the cover image, and inserts episodes into `podcast_episodes`.
+
+### Episode Management
+
+Episodes are stored in `podcast_episodes` and rendered in the browse grid for podcast sets:
+- **Undownloaded** episodes show a **Download to server** button (calls `POST /api/podcasts/episodes/{id}/download`).
+- **Downloaded** episodes become regular `media` rows and appear as normal media cards.
+- Users can mark episodes as listened/unlistened via the checkmark button.
+
+### Background Feed Checker
+
+A background goroutine (`CheckFeeds`) refreshes feeds every hour (configurable via `PODCAST_CHECK_INTERVAL_MINUTES`). It uses conditional GET (`If-None-Match`, `If-Modified-Since`) to avoid re-downloading unchanged feeds.
+
+### API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/api/podcasts` | List podcast sets |
+| `POST` | `/api/podcasts` | Subscribe to a new feed (admin) |
+| `GET` | `/api/podcasts/{id}/episodes` | List episodes with status |
+| `POST` | `/api/podcasts/episodes/{id}/download` | Server-side download |
+| `POST` | `/api/podcasts/episodes/{id}/complete` | Toggle completion | \ No newline at end of file
diff --git a/docs/quick-start.md b/docs/quick-start.md
new file mode 100644
index 0000000..d38304d
--- /dev/null
+++ b/docs/quick-start.md
@@ -0,0 +1,56 @@
+Quick Start
+===========
+
+### Running from source
+
+```bash
+go build -o player ./cmd/player
+./player
+```
+
+Open `http://localhost:8080` — on first visit you'll be redirected to the bootstrap page to create an admin account.
+
+### Running with Docker
+
+```bash
+docker build -t player:latest .
+docker run -p 8080:8080 \
+ -v player-data:/data \
+ -v /path/to/media:/media \
+ -e MEDIA_ROOT=/media \
+ -e DB_PATH=/data/media.db \
+ player:latest
+```
+
+### Deploying to Kubernetes
+
+```bash
+kubectl apply -f k8s/
+```
+
+This creates a Deployment (non-root, probes included), ClusterIP Service, PVCs for `/data` and `/media`, and an optional Secret.
+
+The `Deployment` overrides two settings for K8s:
+- `DB_PATH=/data/media.db`
+- `MEDIA_ROOT=/media`
+
+Probes:
+- **Liveness:** `GET /healthz` (no DB dependency)
+- **Readiness:** `GET /readyz` (DB ping)
+
+Security:
+- `runAsNonRoot: true`
+- `runAsUser: 65534` / `runAsGroup: 65534`
+- `allowPrivilegeEscalation: false`
+- `readOnlyRootFilesystem: true`
+
+### Mage targets
+
+| Target | Description |
+|--------|-------------|
+| `mage build` | Compile the binary |
+| `mage test` | Run `go test ./...` |
+| `mage install` | Build and copy binary to `$GOPATH/bin` |
+| `mage clean` | Remove build artifacts |
+| `mage docker-build` | Build Docker image as `player:latest` |
+| `mage docker-push` | Push `player:latest` to registry | \ No newline at end of file
diff --git a/docs/theming.md b/docs/theming.md
new file mode 100644
index 0000000..245a801
--- /dev/null
+++ b/docs/theming.md
@@ -0,0 +1,53 @@
+Theming
+=======
+
+All colors live in `web/css/theme.css` as CSS Custom Properties on `:root`.
+
+### Current Implementation
+
+`themes.js` swaps the active theme by setting `document.documentElement.setAttribute('data-theme', ...)` and saves the preference to `localStorage`. Override blocks in `theme.css` handle the light variant:
+
+```css
+/* Default (dark) — defined on :root */
+:root {
+ --bg-body: #0f1117;
+ --text-primary: #e6e8ef;
+ --accent: #5e9eff;
+ ...
+}
+
+/* Light theme overrides */
+[data-theme="light"] {
+ --bg-body: #f4f5f8;
+ --text-primary: #12131a;
+ --accent: #2b6cb0;
+ ...
+}
+```
+
+### Adding a New Theme
+
+Option A — inline override (recommended for small additions):
+
+1. Open `web/css/theme.css`.
+2. Append a new attribute selector after the light block, e.g.:
+
+```css
+[data-theme="solarized"] {
+ --bg-body: #002b36;
+ --text-primary: #839496;
+ --accent: #268bd2;
+ ...
+}
+```
+
+3. Wire the toggle in `web/js/themes.js` (or expose a selector UI in `index.html`) to call `apply('solarized')`.
+
+Option B — separate file (if you prefer a stylesheet swap):
+
+1. Create `web/css/themes/<name>.css` containing `:root { ... }` overrides.
+2. Dynamically create or swap a `<link rel="stylesheet">` in `themes.js` instead of using `data-theme`.
+
+**Rules:**
+- No color literals in component styles — everything must go through `var(--*)`.
+- Do not add inline styles in HTML or JS. \ No newline at end of file