summaryrefslogtreecommitdiff
path: root/PLAN.md
diff options
context:
space:
mode:
Diffstat (limited to 'PLAN.md')
-rw-r--r--PLAN.md455
1 files changed, 455 insertions, 0 deletions
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..b405015
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,455 @@
+# KISS Go Media Player — Implementation Plan
+
+## Overview
+A self-hosted, Kubernetes-deployable web media player written in Go (stdlib `net/http`) with a SQLite backend, vanilla JavaScript PWA frontend, and progressive offline capability. Designed for **KISS** (Keep It Stupidly Simple): minimal dependencies, zero frontend frameworks, fully interface-driven for testability.
+
+**Target test coverage:** >80% using dependency injection, hand-written mocks, and SQLite `:memory:` instances.
+
+---
+
+## Architecture
+
+- **Backend:** Go 1.23, `net/http`, stdlib + `modernc.org/sqlite`
+- **Frontend:** Vanilla JS, CSS Custom Properties, HTML5 `<video>`/`<audio>` custom overlay controls
+- **Build:** Mage (`Magefile.go`) with `Build`, `Test`, `Install` targets
+- **Deploy:** Multi-stage `Dockerfile`; K8s `Deployment` + `Service` + two PVCs (`/data`, `/media`)
+- **Storage:** SQLite on `/data`; media library on `/media`; `ffmpeg`/`ffprobe` installed in runtime image
+
+---
+
+## Project Structure
+
+```
+./
+├── cmd/mediaplayer/
+│ └── main.go # Entrypoint: flags, config, wire dependencies, start server
+├── internal/
+│ ├── version.go # const Version; printed via -version flag
+│ ├── config.go # Env-based config with validation
+│ ├── clock/
+│ │ ├── clock.go # Clock interface (Now() time.Time)
+│ │ └── real.go # RealClock implementation
+│ ├── model/
+│ │ └── media.go # Domain structs (zero external deps)
+│ ├── repository/
+│ │ ├── repository.go # All repository interfaces
+│ │ ├── sqlite.go # Concrete SQLite implementations
+│ │ ├── sqlite_test.go # :memory: table-driven tests
+│ │ └── mock.go # Hand-written fakes for service/handler tests
+│ ├── scanner/
+│ │ ├── scanner.go # Recursive set + media scan
+│ │ ├── scanner_test.go
+│ │ └── fs.go # FS interface + OS implementation
+│ ├── probe/
+│ │ ├── probe.go # ffprobe wrapper interface
+│ │ ├── probe_test.go # Golden JSON fixtures
+│ │ └── testdata/
+│ ├── thumb/
+│ │ ├── thumb.go # Thumbnail generator interface
+│ │ └── thumb_test.go
+│ ├── auth/
+│ │ ├── auth.go # Hasher interface, session manager
+│ │ └── auth_test.go
+│ ├── service/
+│ │ ├── media.go # MediaService (business layer)
+│ │ ├── admin.go # AdminService (user/set CRUD)
+│ │ ├── progress.go # Playback counter + resume logic
+│ │ ├── progress_test.go # Heavy tests for 60s rule
+│ │ ├── gc.go # Garbage collector for soft-deleted items
+│ │ └── gc_test.go # Mock clock + repo tests
+│ ├── api/
+│ │ ├── server.go # Route table, middleware wiring
+│ │ ├── handlers.go # Thin HTTP adapters
+│ │ ├── handlers_test.go # httptest + mocked services
+│ │ └── middleware.go # Session auth, admin gate, bootstrap bypass
+│ └── setassign/
+│ ├── assign.go # Permission helper logic
+│ └── assign_test.go
+├── web/
+│ ├── index.html # SPA shell (auth-gated)
+│ ├── login.html # Login form
+│ ├── bootstrap.html # One-time admin bootstrap form
+│ ├── css/
+│ │ ├── base.css # Reset, layout utilities
+│ │ ├── theme.css # Default dark palette (ALL var(--*) definitions)
+│ │ ├── components.css # Buttons, cards, forms, modals
+│ │ ├── player.css # Custom overlay, fullscreen progress-bar-visible
+│ │ ├── layout.css # Responsive grid/flex
+│ │ ├── login.css # Login page layout
+│ │ └── themes/
+│ │ └── light.css # Future swap
+│ └── js/
+│ ├── app.js # Router, auth bootstrap, orchestration
+│ ├── api.js # Fetch wrapper (credentials: include)
+│ ├── keyboard.js # Global keydown listener (hjkl/arrows/p/f/Esc/r/s/3/Enter/Space)//
+│ ├── selection.js # `.selected` index management (mouse + keyboard sync)
+│ ├── player.js # Play/resume/switch/fullscreen logic
+│ ├── favorites.js # Heart toggle
+│ ├── progress.js # Periodic progress POST every 3s while playing
+│ ├── shuffle.js # Toggle random ordering of current filtered view
+│ ├── themes.js # Swap `<link>` href for dark/light mode toggle
+│ ├── search.js # `/` key quick search bar (debounced)
+│ ├── notes.js # Per-media notes modal (CRUD)
+│ ├── admin.js # User CRUD, permission matrix, rescan trigger
+│ └── pwa.js # Service Worker registration
+├── Dockerfile # Multi-stage (builder + alpine w/ ffmpeg)
+├── k8s/
+│ ├── deployment.yaml
+│ ├── service.yaml
+│ ├── pvc-db.yaml # /data
+│ └── pvc-media.yaml # /media
+├── Magefile.go
+├── go.mod
+├── go.sum # Generated
+└── PLAN.md # This file
+```
+
+---
+
+## Database Schema (SQLite)
+
+```sql
+PRAGMA foreign_keys = ON;
+
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE sets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ root_path TEXT UNIQUE NOT NULL,
+ cover_thumbnail_path TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE set_permissions (
+ set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role TEXT CHECK(role IN ('owner','viewer')) NOT NULL DEFAULT 'viewer',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (set_id, user_id)
+);
+
+CREATE TABLE media (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE,
+ rel_path TEXT NOT NULL,
+ file_name TEXT NOT NULL,
+ abs_path TEXT NOT NULL,
+ type TEXT CHECK(type IN ('video','audio')) NOT NULL,
+ duration REAL,
+ codec TEXT,
+ resolution TEXT,
+ bitrate INTEGER,
+ file_size_bytes INTEGER,
+ thumbnail_path TEXT,
+ play_count INTEGER NOT NULL DEFAULT 0,
+ deleted_at DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(set_id, rel_path)
+);
+
+CREATE TABLE tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE media_tags (
+ media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
+ tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ PRIMARY KEY (media_id, tag_id)
+);
+
+CREATE TABLE favorites (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, media_id)
+);
+
+CREATE TABLE playback_progress (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
+ position_seconds REAL NOT NULL,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, media_id)
+);
+
+CREATE TABLE playback_accumulator (
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
+ last_position REAL NOT NULL DEFAULT 0,
+ accumulated_seconds REAL NOT NULL DEFAULT 0,
+ counted INTEGER NOT NULL DEFAULT 0,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (session_id, media_id)
+);
+
+CREATE TABLE sessions (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at DATETIME NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE shares (
+ token TEXT PRIMARY KEY,
+ media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ expires_at DATETIME NOT NULL,
+ max_uses INTEGER,
+ used_count INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE media_notes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ content TEXT NOT NULL DEFAULT '',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(media_id, user_id)
+);
+
+-- Indexes
+CREATE INDEX idx_media_set_id ON media(set_id);
+CREATE INDEX idx_media_rel_path ON media(set_id, rel_path);
+CREATE INDEX idx_media_deleted_at ON media(deleted_at);
+CREATE INDEX idx_media_type ON media(type);
+CREATE INDEX idx_media_filename ON media(file_name);
+CREATE INDEX idx_permissions_user ON set_permissions(user_id);
+CREATE INDEX idx_permissions_set ON set_permissions(set_id);
+CREATE INDEX idx_shares_expires ON shares(expires_at);
+```
+
+---
+
+## API Routes
+
+| Method | Route | Auth | Description |
+|--------|-------|------|-------------|
+| `GET` | `/bootstrap.html` | public | One-time admin creation if `users` table empty |
+| `POST` | `/api/bootstrap` | public | Submit first admin user (blocked if users exist) |
+| `GET` | `/login.html` | public | Login form |
+| `POST` | `/api/login` | public | Create session cookie |
+| `POST` | `/api/logout` | session | Invalidate cookie + DB row |
+| `GET` | `/api/sets` | session | List visible sets (admin = all) |
+| `POST` | `/api/sets/:id/cover` | session (owner/admin) | Regenerate set cover thumbnail |
+| `GET` | `/api/media` | session | Query: `set_id`, `type`, `search`, `tags`, `favorites`, `min_duration`, `max_duration`, `sort`, `limit`, `offset` |
+| `GET` | `/api/media/:id` | session | Detail + metadata + favorite status + user's note |
+| `GET` | `/api/media/:id/stream` | session | Serve original file with Range support |
+| `GET` | `/api/media/:id/download` | session | `Content-Disposition: attachment` |
+| `GET` | `/api/media/:id/thumbnail` | session | Serve thumbnail file |
+| `POST` | `/api/media/:id/thumbnail` | session | Regenerate thumbnail at random offset |
+| `POST` | `/api/media/:id/favorite` | session | Toggle favorite |
+| `POST` | `/api/media/:id/tags` | session | Assign tag |
+| `DELETE` | `/api/media/:id/tags/:tag` | session | Remove tag |
+| `POST` | `/api/progress` | session | Update position + accumulate counter |
+| `DELETE` | `/api/media/:id` | session (owner/admin) | Soft delete |
+| `POST` | `/api/media/:id/restore` | session (owner/admin) | Restore from trash |
+| `GET` | `/api/admin/trash` | admin | List soft-deleted items |
+| `POST` | `/api/admin/rescan` | admin | Trigger filesystem rescan |
+| `GET` | `/api/admin/users` | admin | List users |
+| `POST` | `/api/admin/users` | admin | Create user |
+| `DELETE` | `/api/admin/users/:id` | admin | Remove user (not self) |
+| `GET` | `/api/admin/permissions` | admin | Set permissions matrix |
+| `POST` | `/api/admin/permissions` | admin | Grant set access |
+| `DELETE` | `/api/admin/permissions` | admin | Revoke set access |
+| `POST` | `/api/sets/:id/upload` | session (owner/admin) | Upload file (multipart, max 500MB) |
+| `POST` | `/api/media/:id/shares` | session | Create new share link (expires 14d default) |
+| `GET` | `/api/media/:id/shares` | session | List active shares for media |
+| `DELETE` | `/api/shares/:token` | session | Revoke share |
+| `GET` | `/s/:token` | public | Share landing page with inline player |
+| `GET` | `/s/:token/stream` | public | Stream shared file |
+| `GET` | `/` | session | SPA shell `index.html` (or redirect) |
+| `GET` | `/healthz` | public | Liveness probe (no DB) |
+| `GET` | `/readyz` | public | Readiness probe (DB ping) |
+| `GET` | `/api/media/:id/notes` | session | Get current user's note |
+| `POST` | `/api/media/:id/notes` | session | Create or update note |
+| `DELETE` | `/api/media/:id/notes` | session | Delete user's note |
+
+---
+
+## Feature Details
+
+### 1. Sets and Scanner
+- **Set** = immediate child directory of `MEDIA_ROOT`.
+- Scanner walks each set **recursively**.
+- `ffprobe` extracts duration, codec, resolution, bitrate.
+- `os.Stat` gets `file_size_bytes`.
+- `ffmpeg` generates thumbnail at ~10s (input-side seek `-ss`).
+- Audio files render as **compact text rows** in UI (no thumbnail for single audio items).
+- Sets display a **cover thumbnail** (`.cover.jpg` in set root) derived from a random video frame inside the set.
+
+### 2. Auth
+- `golang.org/x/crypto/bcrypt` for password hashing.
+- HTTP-only cookie sessions stored in `sessions` table.
+- **Bootstrap**: If `users` is empty, all routes redirect to `/bootstrap.html` until first admin is created.
+
+### 3. Roles
+- `set_permissions.role` = `owner` or `viewer`.
+- **Owner/admin** can upload to that set.
+- Admin sees all sets implicitly.
+
+### 4. Playback
+- Custom `<video>`/`<audio>` overlay with play/pause, seek bar, volume, mute, time, fullscreen toggle.
+- **Fullscreen** (`f` key) uses Fullscreen API on the **wrapper container** so overlay controls (including progress bar) remain visible.
+- **Auto-next**: On playback end, play next item from the current filtered/sorted/shuffled result set.
+
+### 5. Playback Counter
+- Client POSTs `position_seconds` every 3 seconds while playing.
+- Server tracks `delta = new_pos - last_position`, clamped to `[0, 12]`.
+- After cumulative `>= 60` seconds and `counted = false`, increments `media.play_count`.
+
+### 6. Keyboard Navigation
+- `↑↓` or `kj` — navigate media list.
+- `←→` or `hl` — switch sets/pages.
+- `Enter` — open selected media.
+- `p` or `Space` — play/pause/switch to selected.
+- `f` — toggle fullscreen.
+- `Esc` — exit fullscreen or deselect.
+- `r` — toggle shuffle on current filtered result set (lost on refresh).
+- `s` — generate share link for selected media.
+- `/` — trigger universal search input.
+
+### 7. Selection
+- `.selected` class highlights the focused media card.
+- Keyboard focus and mouse click both update selection.
+- Visually distinct from `.playing` (which shows a play indicator).
+
+### 8. Filtering
+- **Query params**: `set_id`, `type`, `search` (filename/substring), `tags` (AND logic, comma-separated), `favorites=true`, `min_duration`, `max_duration` (seconds).
+- **Sort**: `name`, `date`, `duration`, `play_count`, `random`.
+- **Search (`/`)**: Debounced 300ms. Searches filename, rel_path, codec, resolution, tags, notes, sets.
+
+### 9. Upload
+- `POST /api/sets/:id/upload` with `multipart/form-data`.
+- Max file size: **500MB** (`MAX_UPLOAD_SIZE_MB=500`).
+- If filename exists, append `(1)`, `(2)`, etc.
+- After save, immediate `ffprobe` + `ffmpeg` thumbnail + insert into `media`.
+
+### 10. Share Links
+- `POST /api/media/:id/shares` generates a new random token.
+- Default expiration: **14 days** from now.
+- Public routes `/s/:token` and `/s/:token/stream` bypass auth.
+- Each time `s` is pressed, a **new share** is created (old ones remain valid until expiry).
+
+### 11. Soft Delete + GC
+- `DELETE /api/media/:id` sets `deleted_at = NOW()`.
+- Media hidden from normal views; shown in admin trash view.
+- Admin/owner can restore before 7 days.
+- Background goroutine (`time.Ticker` hourly) selects items where `deleted_at < NOW() - 7 days`.
+- Physical file deleted via `os.Remove()`, then **hard DELETE** from DB row.
+
+### 12. Thumbnail Regeneration
+- `POST /api/media/:id/thumbnail` or set cover regeneration.
+- Random offset `rand.Float64() * duration`; `ffmpeg -ss` input-side seek.
+- Overwrites existing file.
+
+### 13. Per-Media Notes
+- Private **per-user** notes via `media_notes` table.
+- UI: notes icon on media detail opens textarea modal.
+- Auto-save or explicit save button.
+
+### 14. Theming + Dark Mode
+- All colors defined in `css/theme.css` using `var(--*)`.
+- No inline styles anywhere.
+- Sun/moon toggle in header swaps `<link>` tag to `light.css`.
+- Zero color literals in component styles.
+
+### 15. PWA
+- `manifest.json` for installable app.
+- Service Worker caches static assets for offline.
+- App works as standalone install on mobile/desktop.
+
+---
+
+## Testability Requirements
+
+- **Dependencies injected via constructors** (no package-level vars).
+- **Interfaces at all boundaries**: DB, FS, `ffprobe`, `ffmpeg`, bcrypt, clock, sessions.
+- **Heavy unit tests** with `:memory:` SQLite for repository layer.
+- **Mock-based tests** for service layer using hand-written fakes.
+- **HTTP tests** with `httptest` + mocked services for handlers.
+- **Target:** >80% coverage via `go test ./... -race -coverprofile=cov.out`.
+
+---
+
+## Kubernetes Deployment
+
+- **Image**: Multi-stage Dockerfile; final stage based on `alpine` with `apk add ffmpeg`.
+- **Security**: non-root user (`65534:65534`), `runAsNonRoot: true`, `fsGroup: 65534`.
+- **PVCs**: Separate volumes for `/data` (SQLite) and `/media` (library).
+- **Probes**: `/healthz` (liveness, no DB), `/readyz` (readiness, DB ping).
+- **Graceful shutdown**: Handle `SIGTERM`, close DB, finish active requests.
+
+---
+
+## Environment Configuration
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `PORT` | `8080` | HTTP listen port |
+| `MEDIA_ROOT` | `/media` | Root path for set directories |
+| `DB_PATH` | `/data/media.db` | SQLite database file |
+| `MAX_UPLOAD_SIZE_MB` | `500` | Max upload size per file |
+| `SESSION_TIMEOUT_HOURS` | `24` | Cookie expiry |
+| `GC_INTERVAL_MINUTES` | `60` | Garbage collector tick |
+| `SHARE_DEFAULT_EXPIRY_DAYS` | `14` | Default share link lifetime |
+| `LOG_LEVEL` | `info` | Log verbosity |
+
+---
+
+## Magefile Targets
+
+- `mage` (default) → `mage build`
+- `mage build`
+- `mage test`
+- `mage install`
+- `mage clean`
+- `mage docker-build`
+- `mage docker-push`
+
+---
+
+## Completion Criteria
+
+All 31 features implemented, tested, and deployable:
+1. Sets & Scanner
+2. SQLite DB
+3. Auth & Sessions
+4. Admin Panel
+5. Playback (with fullscreen overlay)
+6. Download
+7. Tagging
+8. Playback Counter (>60s)
+9. Favorites
+10. Progress Tracking
+11. Soft Delete
+12. GC Worker
+13. Thumbnail Regeneration
+14. Filtering (set/type/filename/tags/favorites/duration/filesize)
+15. File Size Display
+16. PWA
+17. Theming (CSS variables)
+18. Dark Mode Toggle
+19. Upload (500MB, owner/admin)
+20. Share Links (s key, 14 days)
+21. Shuffle (r key, filtered scope)
+22. Keyboard Navigation
+23. Selection Highlight
+24. Auto-Next Playback
+25. Admin Bootstrap (`bootstrap.html`)
+26. Set Cover Thumbnail
+27. Audio/Podcast/Audiobook Text Rows
+28. Audiobooks (audio tag)
+29. Per-Media Notes (private per-user)
+30. Quick Search ( `/` key)
+31. Local Mode & K8s Deployability
+