summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-28 22:57:46 +0300
committerPaul Buetow <paul@buetow.org>2026-04-28 22:57:46 +0300
commitd0d2b9f47b371afc94c2cdc58977e388ed30e224 (patch)
treefec26b51552ccc0a2c104dbeaf4d6a028916ae49
h9: scaffold go project structure
- Initialize go.mod (github.com/paul/kiss-media-player) - Add internal/version.go with const Version - Add internal/config.go with env-based Config struct and validation (PORT, MEDIA_ROOT, DB_PATH, MAX_UPLOAD_SIZE_MB, SESSION_TIMEOUT_HOURS, GC_INTERVAL_MINUTES, SHARE_DEFAULT_EXPIRY_DAYS, LOG_LEVEL) - Add table-driven config validation tests (defaults, overrides, invalid values) - Add cmd/mediaplayer/main.go with -version flag - Create directory scaffold: internal/{model,repository,scanner,probe,thumb, clock,auth,service,api,setassign}, web/{css,js}, k8s - Add .gitignore for binaries and data.db - Refactor env parsing into envInt/envString helpers per go-best-practices
-rw-r--r--.gitignore18
-rw-r--r--PLAN.md455
-rw-r--r--go.mod3
-rw-r--r--internal/api/api.go2
-rw-r--r--internal/auth/auth.go2
-rw-r--r--internal/clock/clock.go2
-rw-r--r--internal/config.go137
-rw-r--r--internal/config_test.go184
-rw-r--r--internal/model/model.go2
-rw-r--r--internal/probe/probe.go2
-rw-r--r--internal/repository/repository.go2
-rw-r--r--internal/scanner/scanner.go2
-rw-r--r--internal/service/service.go2
-rw-r--r--internal/setassign/setassign.go2
-rw-r--r--internal/thumb/thumb.go2
-rw-r--r--internal/version.go4
16 files changed, 821 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..08b8dee
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# Binaries
+mediaplayer
+*.exe
+*.dll
+*.so
+*.dylib
+
+# Test binaries
+*.test
+
+# Output of the go coverage tool
+*.out
+
+# Go workspace file
+go.work
+
+# Database file
+data.db
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
+
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..931c9a4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/paul/kiss-media-player
+
+go 1.24
diff --git a/internal/api/api.go b/internal/api/api.go
new file mode 100644
index 0000000..e9a49ea
--- /dev/null
+++ b/internal/api/api.go
@@ -0,0 +1,2 @@
+// Package api implements HTTP handlers.
+package api
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..6f65cd0
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,2 @@
+// Package auth handles authentication and authorization.
+package auth
diff --git a/internal/clock/clock.go b/internal/clock/clock.go
new file mode 100644
index 0000000..def1ebb
--- /dev/null
+++ b/internal/clock/clock.go
@@ -0,0 +1,2 @@
+// Package clock abstracts time for testability.
+package clock
diff --git a/internal/config.go b/internal/config.go
new file mode 100644
index 0000000..952bbfd
--- /dev/null
+++ b/internal/config.go
@@ -0,0 +1,137 @@
+package internal
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// Default configuration values.
+const (
+ DefaultPort = 8080
+ DefaultMediaRoot = "./media"
+ DefaultDBPath = "data.db"
+ DefaultMaxUploadSizeMB = 100
+ DefaultSessionTimeoutHours = 24
+ DefaultGCIntervalMinutes = 30
+ DefaultShareDefaultExpiryDays = 7
+ DefaultLogLevel = "info"
+)
+
+// Config holds all application configuration loaded from environment variables.
+type Config struct {
+ Port int
+ MediaRoot string
+ DBPath string
+ MaxUploadSizeMB int
+ SessionTimeoutHours int
+ GCIntervalMinutes int
+ ShareDefaultExpiryDays int
+ LogLevel string
+}
+
+// envInt reads an integer environment variable, validates it with the given check,
+// and sets the field via the provided setter. If the variable is unset, the setter
+// is not called and the default remains.
+func envInt(name string, check func(int) error, set func(int)) error {
+ if v := os.Getenv(name); v != "" {
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ return fmt.Errorf("invalid %s: %w", name, err)
+ }
+ if err := check(n); err != nil {
+ return fmt.Errorf("invalid %s: %w", name, err)
+ }
+ set(n)
+ }
+ return nil
+}
+
+// envString reads a string environment variable, trims space, and sets the field
+// via the provided setter if the variable is non-empty.
+func envString(name string, set func(string)) {
+ if v := os.Getenv(name); v != "" {
+ set(strings.TrimSpace(v))
+ }
+}
+
+// LoadConfig reads configuration from environment variables and returns
+// a populated Config. Unset variables use the package defaults.
+func LoadConfig() (*Config, error) {
+ cfg := &Config{
+ Port: DefaultPort,
+ MediaRoot: DefaultMediaRoot,
+ DBPath: DefaultDBPath,
+ MaxUploadSizeMB: DefaultMaxUploadSizeMB,
+ SessionTimeoutHours: DefaultSessionTimeoutHours,
+ GCIntervalMinutes: DefaultGCIntervalMinutes,
+ ShareDefaultExpiryDays: DefaultShareDefaultExpiryDays,
+ LogLevel: DefaultLogLevel,
+ }
+
+ validLevels := map[string]struct{}{
+ "debug": {},
+ "info": {},
+ "warn": {},
+ "error": {},
+ }
+
+ if err := envInt("PORT", func(n int) error {
+ if n < 1 || n > 65535 {
+ return fmt.Errorf("must be between 1 and 65535, got %d", n)
+ }
+ return nil
+ }, func(n int) { cfg.Port = n }); err != nil {
+ return nil, err
+ }
+
+ envString("MEDIA_ROOT", func(s string) { cfg.MediaRoot = s })
+ envString("DB_PATH", func(s string) { cfg.DBPath = s })
+
+ if err := envInt("MAX_UPLOAD_SIZE_MB", func(n int) error {
+ if n < 1 {
+ return fmt.Errorf("must be >= 1, got %d", n)
+ }
+ return nil
+ }, func(n int) { cfg.MaxUploadSizeMB = n }); err != nil {
+ return nil, err
+ }
+
+ if err := envInt("SESSION_TIMEOUT_HOURS", func(n int) error {
+ if n < 1 {
+ return fmt.Errorf("must be >= 1, got %d", n)
+ }
+ return nil
+ }, func(n int) { cfg.SessionTimeoutHours = n }); err != nil {
+ return nil, err
+ }
+
+ if err := envInt("GC_INTERVAL_MINUTES", func(n int) error {
+ if n < 1 {
+ return fmt.Errorf("must be >= 1, got %d", n)
+ }
+ return nil
+ }, func(n int) { cfg.GCIntervalMinutes = n }); err != nil {
+ return nil, err
+ }
+
+ if err := envInt("SHARE_DEFAULT_EXPIRY_DAYS", func(n int) error {
+ if n < 1 {
+ return fmt.Errorf("must be >= 1, got %d", n)
+ }
+ return nil
+ }, func(n int) { cfg.ShareDefaultExpiryDays = n }); err != nil {
+ return nil, err
+ }
+
+ if v := os.Getenv("LOG_LEVEL"); v != "" {
+ level := strings.ToLower(strings.TrimSpace(v))
+ if _, ok := validLevels[level]; !ok {
+ return nil, fmt.Errorf("invalid LOG_LEVEL: must be one of debug, info, warn, error, got %q", level)
+ }
+ cfg.LogLevel = level
+ }
+
+ return cfg, nil
+}
diff --git a/internal/config_test.go b/internal/config_test.go
new file mode 100644
index 0000000..f92cd58
--- /dev/null
+++ b/internal/config_test.go
@@ -0,0 +1,184 @@
+package internal
+
+import (
+ "os"
+ "testing"
+)
+
+func TestLoadConfig_Defaults(t *testing.T) {
+ // Ensure env vars are not set.
+ clearEnv()
+
+ cfg, err := LoadConfig()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Port != DefaultPort {
+ t.Errorf("Port: expected %d, got %d", DefaultPort, cfg.Port)
+ }
+ if cfg.MediaRoot != DefaultMediaRoot {
+ t.Errorf("MediaRoot: expected %q, got %q", DefaultMediaRoot, cfg.MediaRoot)
+ }
+ if cfg.DBPath != DefaultDBPath {
+ t.Errorf("DBPath: expected %q, got %q", DefaultDBPath, cfg.DBPath)
+ }
+ if cfg.MaxUploadSizeMB != DefaultMaxUploadSizeMB {
+ t.Errorf("MaxUploadSizeMB: expected %d, got %d", DefaultMaxUploadSizeMB, cfg.MaxUploadSizeMB)
+ }
+ if cfg.SessionTimeoutHours != DefaultSessionTimeoutHours {
+ t.Errorf("SessionTimeoutHours: expected %d, got %d", DefaultSessionTimeoutHours, cfg.SessionTimeoutHours)
+ }
+ if cfg.GCIntervalMinutes != DefaultGCIntervalMinutes {
+ t.Errorf("GCIntervalMinutes: expected %d, got %d", DefaultGCIntervalMinutes, cfg.GCIntervalMinutes)
+ }
+ if cfg.ShareDefaultExpiryDays != DefaultShareDefaultExpiryDays {
+ t.Errorf("ShareDefaultExpiryDays: expected %d, got %d", DefaultShareDefaultExpiryDays, cfg.ShareDefaultExpiryDays)
+ }
+ if cfg.LogLevel != DefaultLogLevel {
+ t.Errorf("LogLevel: expected %q, got %q", DefaultLogLevel, cfg.LogLevel)
+ }
+}
+
+func TestLoadConfig_EnvOverrides(t *testing.T) {
+ clearEnv()
+ setEnvPairs(t, []envPair{
+ {"PORT", "9090"},
+ {"MEDIA_ROOT", "/tmp/media"},
+ {"DB_PATH", "/tmp/db.sqlite"},
+ {"MAX_UPLOAD_SIZE_MB", "500"},
+ {"SESSION_TIMEOUT_HOURS", "48"},
+ {"GC_INTERVAL_MINUTES", "60"},
+ {"SHARE_DEFAULT_EXPIRY_DAYS", "14"},
+ {"LOG_LEVEL", "debug"},
+ })
+
+ cfg, err := LoadConfig()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Port != 9090 {
+ t.Errorf("Port: expected 9090, got %d", cfg.Port)
+ }
+ if cfg.MediaRoot != "/tmp/media" {
+ t.Errorf("MediaRoot: expected %q, got %q", "/tmp/media", cfg.MediaRoot)
+ }
+ if cfg.DBPath != "/tmp/db.sqlite" {
+ t.Errorf("DBPath: expected %q, got %q", "/tmp/db.sqlite", cfg.DBPath)
+ }
+ if cfg.MaxUploadSizeMB != 500 {
+ t.Errorf("MaxUploadSizeMB: expected 500, got %d", cfg.MaxUploadSizeMB)
+ }
+ if cfg.SessionTimeoutHours != 48 {
+ t.Errorf("SessionTimeoutHours: expected 48, got %d", cfg.SessionTimeoutHours)
+ }
+ if cfg.GCIntervalMinutes != 60 {
+ t.Errorf("GCIntervalMinutes: expected 60, got %d", cfg.GCIntervalMinutes)
+ }
+ if cfg.ShareDefaultExpiryDays != 14 {
+ t.Errorf("ShareDefaultExpiryDays: expected 14, got %d", cfg.ShareDefaultExpiryDays)
+ }
+ if cfg.LogLevel != "debug" {
+ t.Errorf("LogLevel: expected %q, got %q", "debug", cfg.LogLevel)
+ }
+}
+
+func TestLoadConfig_InvalidValues(t *testing.T) {
+ cases := []struct {
+ name string
+ env []envPair
+ wantErr string
+ }{
+ {
+ name: "invalid PORT",
+ env: []envPair{{"PORT", "abc"}},
+ wantErr: "invalid PORT",
+ },
+ {
+ name: "PORT out of range (0)",
+ env: []envPair{{"PORT", "0"}},
+ wantErr: "invalid PORT",
+ },
+ {
+ name: "PORT out of range (70000)",
+ env: []envPair{{"PORT", "70000"}},
+ wantErr: "invalid PORT",
+ },
+ {
+ name: "invalid MAX_UPLOAD_SIZE_MB",
+ env: []envPair{{"MAX_UPLOAD_SIZE_MB", "-1"}},
+ wantErr: "invalid MAX_UPLOAD_SIZE_MB",
+ },
+ {
+ name: "invalid SESSION_TIMEOUT_HOURS",
+ env: []envPair{{"SESSION_TIMEOUT_HOURS", "0"}},
+ wantErr: "invalid SESSION_TIMEOUT_HOURS",
+ },
+ {
+ name: "invalid GC_INTERVAL_MINUTES",
+ env: []envPair{{"GC_INTERVAL_MINUTES", "-5"}},
+ wantErr: "invalid GC_INTERVAL_MINUTES",
+ },
+ {
+ name: "invalid SHARE_DEFAULT_EXPIRY_DAYS",
+ env: []envPair{{"SHARE_DEFAULT_EXPIRY_DAYS", "0"}},
+ wantErr: "invalid SHARE_DEFAULT_EXPIRY_DAYS",
+ },
+ {
+ name: "invalid LOG_LEVEL",
+ env: []envPair{{"LOG_LEVEL", "trace"}},
+ wantErr: "invalid LOG_LEVEL",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ clearEnv()
+ setEnvPairs(t, tc.env)
+
+ _, err := LoadConfig()
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", tc.wantErr)
+ }
+ if !contains(err.Error(), tc.wantErr) {
+ t.Fatalf("expected error containing %q, got %q", tc.wantErr, err.Error())
+ }
+ })
+ }
+}
+
+type envPair struct {
+ key string
+ value string
+}
+
+func clearEnv() {
+ for _, k := range []string{
+ "PORT", "MEDIA_ROOT", "DB_PATH",
+ "MAX_UPLOAD_SIZE_MB", "SESSION_TIMEOUT_HOURS",
+ "GC_INTERVAL_MINUTES", "SHARE_DEFAULT_EXPIRY_DAYS",
+ "LOG_LEVEL",
+ } {
+ os.Unsetenv(k)
+ }
+}
+
+func setEnvPairs(t *testing.T, pairs []envPair) {
+ for _, p := range pairs {
+ if err := os.Setenv(p.key, p.value); err != nil {
+ t.Fatalf("setenv %s: %v", p.key, err)
+ }
+ }
+}
+
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSearch(s, substr))
+}
+
+func containsSearch(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/model/model.go b/internal/model/model.go
new file mode 100644
index 0000000..8061bf6
--- /dev/null
+++ b/internal/model/model.go
@@ -0,0 +1,2 @@
+// Package model defines the core domain entities.
+package model
diff --git a/internal/probe/probe.go b/internal/probe/probe.go
new file mode 100644
index 0000000..2cc7dc8
--- /dev/null
+++ b/internal/probe/probe.go
@@ -0,0 +1,2 @@
+// Package probe implements media metadata probing.
+package probe
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
new file mode 100644
index 0000000..6271437
--- /dev/null
+++ b/internal/repository/repository.go
@@ -0,0 +1,2 @@
+// Package repository provides data access abstractions.
+package repository
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
new file mode 100644
index 0000000..86129a7
--- /dev/null
+++ b/internal/scanner/scanner.go
@@ -0,0 +1,2 @@
+// Package scanner implements media library scanning logic.
+package scanner
diff --git a/internal/service/service.go b/internal/service/service.go
new file mode 100644
index 0000000..69f7e70
--- /dev/null
+++ b/internal/service/service.go
@@ -0,0 +1,2 @@
+// Package service contains application business logic.
+package service
diff --git a/internal/setassign/setassign.go b/internal/setassign/setassign.go
new file mode 100644
index 0000000..59ea61a
--- /dev/null
+++ b/internal/setassign/setassign.go
@@ -0,0 +1,2 @@
+// Package setassign manages smart set assignment logic.
+package setassign
diff --git a/internal/thumb/thumb.go b/internal/thumb/thumb.go
new file mode 100644
index 0000000..5311f56
--- /dev/null
+++ b/internal/thumb/thumb.go
@@ -0,0 +1,2 @@
+// Package thumb generates thumbnail images.
+package thumb
diff --git a/internal/version.go b/internal/version.go
new file mode 100644
index 0000000..f611205
--- /dev/null
+++ b/internal/version.go
@@ -0,0 +1,4 @@
+package internal
+
+// Version is the current version of the application.
+const Version = "0.0.1"