commit c085b12fd17f41b9c9d89628f6b4a9b0f6f8f461 Author: Silo Server Migration Date: Fri May 22 20:26:11 2026 -0400 Initial Silo migration diff --git a/.air.toml b/.air.toml new file mode 100644 index 00000000..e04813f0 --- /dev/null +++ b/.air.toml @@ -0,0 +1,29 @@ +root = "." +tmp_dir = "tmp" + +[build] + # Build frontend then Go binary + cmd = "cd web && pnpm run build && cd .. && go build -o ./tmp/silo ./cmd/silo/" + bin = "./tmp/silo" + # Pass no extra args — config comes from .env via godotenv + args_bin = [] + # Watch these extensions + include_ext = ["go", "ts", "tsx", "css", "html", "sql", "json"] + # Watch these directories + include_dir = ["cmd", "internal", "migrations", "web/src", "web/public"] + # Ignore these directories + exclude_dir = ["tmp", "web/node_modules", "web/dist", "vendor", "third_party", "docs", "scripts"] + # Ignore test files for faster builds (tests run separately) + exclude_regex = ["_test\\.go$"] + # Wait 500ms after last change before rebuilding (lets rsync finish) + delay = 500 + # Send SIGTERM for graceful shutdown + stop_on_error = true + kill_delay = 500 + send_interrupt = true + +[log] + time = true + +[misc] + clean_on_exit = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d106200d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.env +.env.* +docs/ +node_modules +web/node_modules +web/dist +silo +streamapp +*.md +.playwright-cli/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..e62ea990 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# Silo Docker deployment +# For the default docker compose setup, MEDIA_ROOT is the main value you need to set. + +# Required for docker compose: host path to your media library. +# Example: MEDIA_ROOT=/srv/media +MEDIA_ROOT=/srv/media + +# Container path where Silo sees MEDIA_ROOT. New installs can keep /mnt/media. +# Continuum migrations should set this to the old in-container library path if +# existing library records use that path. +MEDIA_CONTAINER_ROOT=/mnt/media + +# Recommended server data root for bind mounts. +SILO_DATA_ROOT=/opt/silo + +# Published application image. Override to pin a specific tag if needed. +SILO_IMAGE=ghcr.io/silo-server/silo-server:latest + +# Quick-start bundled PostgreSQL settings used by docker-compose.yml. +POSTGRES_USER=silo +POSTGRES_PASSWORD=silo +POSTGRES_DB=silo + +# Optional host port overrides for docker-compose.yml. +# PORT=8090 +# JF_PORT=8096 +# PROXY_PORT=8083 +# TRANSCODE_PORT=8082 + +# Run from source / advanced overrides +# Only DATABASE_URL is required when running Silo outside the default docker compose stack. +# DATABASE_URL=postgres://silo:password@localhost:5432/silo + +# Optional Redis override when running from source or pointing workers at an external Redis. +# REDIS_URL=redis://localhost:6379 + +# Optional standalone worker identity settings for docker-compose.remote-transcode.yml. +# NODE_NAME=transcode-01 +# NODE_URL=http://transcode-01.example.com:8082 + +# Optional server mode override for source runs or standalone worker nodes. +# MODE=integrated diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..3ea89644 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,57 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && + contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && + contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association) && + contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association) && + (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/discord-commits.yml b/.github/workflows/discord-commits.yml new file mode 100644 index 00000000..3972ab21 --- /dev/null +++ b/.github/workflows/discord-commits.yml @@ -0,0 +1,67 @@ +name: Discord Commit Feed + +on: + push: + branches: + - main + +jobs: + notify: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Send commit summary to Discord + uses: actions/github-script@v7 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + with: + script: | + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) { + core.setFailed("DISCORD_WEBHOOK_URL is not configured."); + return; + } + + const commits = context.payload.commits || []; + const displayedCommits = commits.slice(0, 10); + const repo = `${context.repo.owner}/${context.repo.repo}`; + const branch = context.ref.replace("refs/heads/", ""); + + const lines = displayedCommits.map((commit) => { + const sha = commit.id.slice(0, 7); + const title = commit.message.split("\n")[0].slice(0, 120); + const author = commit.author?.name || "unknown"; + return `- \`${sha}\` ${title} - **${author}**`; + }); + + if (commits.length > displayedCommits.length) { + lines.push(`...and ${commits.length - displayedCommits.length} more commit(s)`); + } + + let content = + `**${repo}**\n` + + `${commits.length} commit(s) pushed to \`${branch}\` by **${context.actor}**\n\n` + + `${lines.join("\n")}`; + + if (context.payload.compare) { + content += `\n\n<${context.payload.compare}>`; + } + + if (content.length > 1900) { + content = `${content.slice(0, 1897)}...`; + } + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + const body = await response.text(); + core.setFailed(`Discord webhook failed with ${response.status}: ${body}`); + } diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..4b2fc132 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,111 @@ +name: Nightly Docker Image + +on: + schedule: + # 03:00 America/New_York during daylight saving time. + - cron: "0 7 * * *" + workflow_dispatch: + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + GOPROXY: https://proxy.golang.org,direct + GOPRIVATE: github.com/Silo-Server/* + GONOSUMDB: github.com/Silo-Server/* + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "pnpm" + cache-dependency-path: web/pnpm-lock.yaml + + - name: Install frontend dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Build frontend assets + working-directory: web + run: pnpm run build + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Reject committed local SDK replaces + run: | + if grep -Eq '^replace github.com/Silo-Server/silo-plugin-sdk => /' go.mod; then + echo "go.mod contains a machine-local silo-plugin-sdk replace." + exit 1 + fi + + - name: Validate SDK resolves from the module graph + env: + GOWORK: off + run: | + sdk_module_json="$(mktemp)" + go list -m -json github.com/Silo-Server/silo-plugin-sdk > "$sdk_module_json" + if grep -q '"Main": true' "$sdk_module_json"; then + echo "silo-plugin-sdk resolved from the local workspace instead of go.mod." + cat "$sdk_module_json" + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + name: silo-builder + keep-state: true + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=nightly,enable={{is_default_branch}} + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-contexts: | + frontend_dist=./web/dist + build-args: | + BUILD_REVISION=${{ github.sha }} + BUILD_DIRTY=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..efa9b62b --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Binary +/silo +*.exe + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test +*.test +coverage.out +coverage.html + +# Temp +/tmp/ +*.tmp + +# Worktrees +.worktrees/ + +# Config with secrets +silo.yaml +.env +.env.* +!.env.example + +# Developer-specific make targets +Makefile.local +go.work +go.work.sum + +# Local agent instructions (not synced) +*.local.md + +# Frontend +web/node_modules/ +web/dist/ +plugins/metadb/dist/ +web/*.tsbuildinfo + +docs/inspiration + + +.agents/ +/.claude/ +.cursor/ +.superpowers/ +.codex/ +.worktrees/ +node_modules/ +logs/ +.playwright-mcp/ +demo/ +docker-compose.dev.yml +.playwright-cli/ +output/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..9bc83c86 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,62 @@ +version: "2" + +run: + timeout: 5m + +formatters: + enable: + - gofmt + - goimports + + settings: + goimports: + local-prefixes: + - github.com/streamapp/streamapp + +linters: + enable: + - bodyclose + - errcheck + - errorlint + - goconst + - govet + - ineffassign + - misspell + - nilerr + - staticcheck + - unconvert + - unused + - wastedassign + + settings: + errcheck: + check-type-assertions: true + + errorlint: + errorf: true + asserts: true + comparison: true + + goconst: + min-len: 3 + min-occurrences: 3 + + misspell: + locale: US + +issues: + exclude-dirs: + - web + - migrations + + exclude-rules: + # Allow repeated strings in test files + - path: _test\.go + linters: + - goconst + + # Allow unchecked errors in test cleanup/defer + - path: _test\.go + text: "Error return value is not checked" + linters: + - errcheck diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7cb79882 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# Repository Guidelines + +## Project Structure & Module Organization +`cmd/silo` contains the main server entrypoint. Backend code lives in `internal/`, organized by domain (`api`, `catalog`, `metadata`, `playback`, `scanner`, `jellycompat`, etc.); keep new code in the package that owns the behavior instead of creating catch-all helpers. Database changes belong in `migrations/` as paired numbered `.up.sql` and `.down.sql` files. The React frontend lives in `web/src/`, with feature code split across `components/`, `pages/`, `hooks/`, `player/`, and `lib/`. Reference material belongs in `docs/architecture/` or `docs/superpowers/{specs,plans}/`; ad hoc SQL helpers live in `scripts/`. + +This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. + + +## Core Priorities + +1. Performance first. +2. Reliability first. +3. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams). + +If a tradeoff is required, choose correctness and robustness over short-term convenience. + +## Maintainability + +Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem. + +This repository is part of a broader multi-repo Silo workspace. Do not assume all plugin-related code lives in this repo. + +- `silo-plugin-sdk` owns the public plugin SDK, protobuf contracts, generated plugin API code, manifest helpers, and runtime bootstrap. +- `silo-plugins` owns the central plugin catalog / repository manifest. +- First-party plugins such as `silo-plugin-tmdb`, and `silo-plugin-tvdb` live in their own repositories. +- `Silo` owns host-side plugin installation, runtime management, API handlers, and integration logic. + +When a task mentions plugins, first determine whether the change belongs in this repo, the SDK repo, the catalog repo, or a specific plugin repo. Prefer coordinated multi-repo changes over forcing plugin work into `Silo`. + +## Build, and Development Commands +Use the checked-in `Makefile` for the common paths: + +- `make build`: install frontend deps, build `web/dist`, compile `./silo` +- `make dev-backend`: run the Go server in integrated mode +- `make dev-frontend`: start the Vite dev server with HMR +- `make dev-proxy` / `make dev-transcode`: run standalone worker modes +- `make lint`: run `golangci-lint` and frontend ESLint + +Run before opening a merge request: + +- `cd web && pnpm run lint` +- `cd web && pnpm run format:check` + +For local services, start PostgreSQL and Redis with `docker compose up -d postgres redis`. + +## Coding Style & Naming Conventions +Go code must stay `gofmt`/`goimports` clean and pass `golangci-lint`. Keep package names lowercase and focused; Frontend code is TypeScript with 2-space indentation, semicolons, double quotes, trailing commas, and a 100-character line width (`web/.prettierrc`). Use `PascalCase.tsx` for components/pages, `useThing.ts` for hooks, and keep shared utilities in `web/src/lib` or `web/src/utils`. + + +## Deployment Debugging + +When troubleshooting a Silo deployment (container health, playback failures, +database state, log analysis, deploys), follow the runbook at +`.claude/skills/deployment-debugging/SKILL.md`. It includes step-by-step +procedures and a first-run setup that configures SSH, database, and Redis +connection details for your environment. + +## Commit & Merge Request Guidelines +Recent history follows Conventional Commit-style subjects such as `feat(playback): add realtime session hub`, `fix(playback): ...`, and `docs: ...`. Keep commits scoped to one concern. For non-trivial work, open an issue or discussion first; this codebase moves quickly. Merge requests should explain the problem, why this approach was chosen, linked issue/spec/plan, risks or follow-up work, and AI-use disclosure. Include screenshots or recordings for UI changes. + +## Learned User Preferences + +- When implementing from an attached plan, do not edit the plan file itself. +- After feature work, user may invoke the `/simplify` skill to improve readability without changing behavior. + +## Learned Workspace Facts + +- Silo separates login accounts (`users`) from household profiles; multiple profiles on one account share the same `user_id`. +- Profile `is_primary` marks the household parent for that account; it is not the same as server-wide `admin` role on the user account. +- Point the Vite dev frontend at a remote backend with `VITE_API_PROXY_TARGET` in `web/.env.local` (or inline) when running `make dev-frontend`; the frontend calls relative `/api` URLs proxied by Vite. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..8d06a1c7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to Silo + +Hey, thanks for wanting to contribute! + +Let's be real: this project is built almost entirely with AI assistance. Claude, Codex, whatever you've got — we're not pretending otherwise. But there's a big difference between *using AI well* and *submitting AI slop*. We care a lot about the first one, and we'll push back hard on the second. + +- AI-generated code is fine. AI slop is not. +- You are responsible for everything you submit, even if an AI wrote it. +- Actually read the code. Actually run the tests. Actually understand what it does. +- Don't send big changes without talking about them first. + +## Things Are Moving Fast + +Silo is in heavy active development. Features get rewritten, APIs shift, whole sections get reworked — sometimes day to day. **If you want to work on something, reach out first.** Open an issue or drop a message so: + +- You don't build on something that's already been rewritten locally but not pushed yet. +- I can avoid breaking something you're actively working on. +- We can check the area is stable enough to be worth building on right now. + +## Before You Start + +Small stuff (typo fixes, minor bugs) — just open a merge request. No ceremony. + +For anything bigger, start with an issue first. New features, API changes, schema migrations, large refactors, behavior changes — talk about it before writing code. Design docs live under `docs/superpowers/specs/` and `docs/superpowers/plans/` if you need examples. + +## Don't Submit AI Slop + +AI writes most of the code here. That's fine. What's not fine is copy-pasting output without understanding it. + +1. **Read every line of your diff.** If you can't explain it, don't submit it. +2. **Run the tests** — but don't blindly trust them. The tests were also AI-written and they have blind spots. +3. **Test the app yourself locally.** Spin it up, click around, try the thing you changed. There is no substitute for this. +4. **Run code-review** on your own work before submitting. `superpowers:requesting-code-review` or whatever tooling you have. +5. **Watch for AI-introduced bugs.** Silent behavior changes, dead code, subtle regressions — look for them. +6. **Understand the bigger picture.** A change that looks fine in isolation can break something three layers away. + +"The AI suggested it" is not an acceptable answer in review. You should be able to explain the reasoning and tradeoffs. + +## Merge Requests + +A good MR answers: what problem does this solve, why this approach, how was it tested, anything to watch out for. For non-trivial changes, link the issue and note any migration/compatibility concerns. Screenshots for UI changes. + +Small, well-explained MRs get reviewed fast. Big unexplained ones sit. + +If AI meaningfully shaped the implementation, say so in the description. Not gatekeeping — just helps reviewers understand intent. + +## Development Setup + +See the README for full setup. Common checks: + +```sh +go test ./... # Go tests +golangci-lint run # Go lint +cd web && bun test # Frontend tests +cd web && bun run lint # Frontend lint +cd web && bun run format:check # Frontend formatting +``` + +If your change spans `Silo` and `silo-plugin-sdk`, local iteration through [`go.work`](go.work) is expected. Do not rely on that workspace in repo-tracked config or release pipelines. CI validates this repo with `GOWORK=off`, and any new SDK package or symbol must come from a pushed, tagged `github.com/Silo-Server/silo-plugin-sdk` release before the change is ready to merge. + +## Style + +- One thing per MR. Don't mix unrelated changes. +- Follow existing patterns. +- Comments for non-obvious things only. + +## For AI Agents + +If you're an LLM working on this codebase: read `CLAUDE.md` (or `AGENTS.md` when available) for project-specific instructions, architecture reference, and verification requirements. The rules in this file apply to you too — especially the parts about not submitting slop and running verification before declaring work complete. + +## Be Realistic + +Opening a merge request doesn't create an obligation on my side. I might close it, ignore it, ask you to shrink it, or reimplement the idea myself later. The codebase is moving fast and sometimes the best response to a good PR is "thanks, but I already went a different direction." + +If you're fine with that, welcome aboard. + +If you're not sure whether something is in scope, open an issue and ask. Always better than building something that needs to be reshaped. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ff82578e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Stage 1: Build frontend +FROM node:22-slim AS frontend +RUN corepack enable && corepack prepare pnpm@10.32.1 --activate +WORKDIR /app/web +COPY web/package.json web/pnpm-lock.yaml ./ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile +COPY web/ . +RUN pnpm run build + +# Allow CI to inject prebuilt frontend assets via a named `frontend_dist` +# context while local builds keep using the in-Docker frontend stage. +FROM scratch AS frontend_dist +COPY --from=frontend /app/web/dist/. / + +# Stage 2: Build Go binary +FROM golang:1.26 AS build +ENV CGO_ENABLED=1 +ENV GOPROXY=https://proxy.golang.org,direct +ENV GOPRIVATE=github.com/Silo-Server/* +ENV GONOSUMDB=github.com/Silo-Server/* +RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go mod download +COPY web/embed.go web/embed.go +COPY --from=frontend_dist / web/dist +COPY cmd/ cmd/ +COPY internal/ internal/ +COPY migrations/ migrations/ +ARG BUILD_REVISION +ARG BUILD_DIRTY=false +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go build \ + -ldflags "-X github.com/Silo-Server/silo-server/internal/buildinfo.revisionOverride=${BUILD_REVISION} -X github.com/Silo-Server/silo-server/internal/buildinfo.dirtyOverride=${BUILD_DIRTY}" \ + -o /silo ./cmd/silo/ + +# Stage 3: Runtime +FROM debian:bookworm-slim +ARG TARGETARCH +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl gnupg && \ + curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \ + > /etc/apt/sources.list.d/jellyfin.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 && \ + apt-get purge -y gnupg && apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* +RUN mkdir -p /tmp/silo-transcode +COPY --from=build /silo /usr/local/bin/silo +COPY third_party/jellyfin-web/ /srv/jellyfin-web/ +EXPOSE 8080 8096 +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1 +ENTRYPOINT ["silo"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..dd02ce3b --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1.7 + +# Stage 1: Build frontend +FROM node:22-slim AS frontend +RUN corepack enable && corepack prepare pnpm@10.32.1 --activate +WORKDIR /app/web +COPY web/package.json web/pnpm-lock.yaml ./ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile +COPY web/ . +RUN pnpm run build + +# Stage 2: Prepare Go build workspace +FROM golang:1.26 AS build-base +ENV CGO_ENABLED=1 +ENV GOPROXY=direct +ENV GOPRIVATE=github.com/Silo-Server/* +ENV GONOSUMDB=github.com/Silo-Server/* +RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go mod download +COPY web/embed.go web/embed.go +COPY --from=frontend /app/web/dist web/dist +COPY cmd/ cmd/ +COPY internal/ internal/ +COPY migrations/ migrations/ + +# Stage 3: Build Go binary for dev using a local plugin SDK checkout passed via +# BuildKit named context `silo_plugin_sdk`. +FROM build-base AS build +ARG BUILD_REVISION +ARG BUILD_DIRTY=false +COPY --from=silo_plugin_sdk . /tmp/silo-plugin-sdk +RUN test -f /tmp/silo-plugin-sdk/go.mod +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go mod edit -replace=github.com/Silo-Server/silo-plugin-sdk=/tmp/silo-plugin-sdk && \ + go mod download && \ + go build \ + -ldflags "-X github.com/Silo-Server/silo-server/internal/buildinfo.revisionOverride=${BUILD_REVISION} -X github.com/Silo-Server/silo-server/internal/buildinfo.dirtyOverride=${BUILD_DIRTY}" \ + -o /silo ./cmd/silo/ + +# Stage 4: Runtime +FROM debian:bookworm-slim +ARG TARGETARCH +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl gnupg && \ + curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \ + > /etc/apt/sources.list.d/jellyfin.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 && \ + apt-get purge -y gnupg && apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* +RUN mkdir -p /tmp/silo-transcode +COPY --from=build /silo /usr/local/bin/silo +COPY third_party/jellyfin-web/ /srv/jellyfin-web/ +EXPOSE 8080 8080 8096 +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1 +ENTRYPOINT ["silo"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..2b20c86d --- /dev/null +++ b/LICENSE @@ -0,0 +1,619 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..4deea2ce --- /dev/null +++ b/Makefile @@ -0,0 +1,65 @@ +.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check + +GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null)) +MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..)) +SHARED_MAKEFILE_LOCAL := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/../Makefile.local)) +DEFAULT_PLUGIN_SDK_DIR := $(abspath ../silo-plugin-sdk) +SHARED_PLUGIN_SDK_DIR := $(if $(MAIN_CHECKOUT_ROOT),$(abspath $(MAIN_CHECKOUT_ROOT)/../silo-plugin-sdk)) + +ifneq ($(wildcard $(DEFAULT_PLUGIN_SDK_DIR)),) +DEV_PLUGIN_SDK_DIR ?= $(DEFAULT_PLUGIN_SDK_DIR) +else ifneq ($(wildcard $(SHARED_PLUGIN_SDK_DIR)),) +DEV_PLUGIN_SDK_DIR ?= $(SHARED_PLUGIN_SDK_DIR) +endif + +JELLYFIN_WEB_OUTPUT_DIR ?= third_party/jellyfin-web + +# Build the frontend (requires pnpm) +frontend: + cd web && pnpm install --frozen-lockfile && pnpm run build + +# Build the Go binary (depends on frontend) +build: frontend + go build -o silo ./cmd/silo/ + +# Run frontend dev server (proxies API to localhost:8080) +dev-frontend: + cd web && pnpm run dev + +# Run the Go backend (integrated mode) +dev-backend: + go run ./cmd/silo/ + +# Run a proxy node (stateless stream proxy, no DB required) +dev-proxy: + go run ./cmd/silo/ --mode=proxy + +# Run a transcode node (HLS transcode worker, no DB required) +dev-transcode: + go run ./cmd/silo/ --mode=transcode + +# Lint Go and frontend code +lint: + golangci-lint run + cd web && pnpm run lint + +# Fetch and build the pinned Jellyfin Web bundle +jellyfin-web-bundle: + JELLYFIN_WEB_OUTPUT_DIR=$(JELLYFIN_WEB_OUTPUT_DIR) scripts/fetch-jellyfin-web.sh + +# Read-only preflight for Continuum Docker installs moving to Silo. +migrate-continuum-check: + scripts/migrate-continuum-docker.sh check + +# Clean build artifacts +clean: + rm -rf web/dist web/node_modules silo + +# Include developer-specific targets (gitignored, optional). +# In Git worktrees, fall back to the main checkout's Makefile.local so custom +# targets like dev-deploy work without per-worktree symlinks or copies. +ifneq ($(wildcard Makefile.local),) +include Makefile.local +else ifneq ($(wildcard $(SHARED_MAKEFILE_LOCAL)),) +include $(SHARED_MAKEFILE_LOCAL) +endif diff --git a/Makefile.local.example b/Makefile.local.example new file mode 100644 index 00000000..c29b9d74 --- /dev/null +++ b/Makefile.local.example @@ -0,0 +1,109 @@ +.PHONY: deploy-dev dev-deploy deploy-dev-image dev-remote-setup dev-remote-stop deploy-dev-transcode dev-deploy-transcode deploy-dev-import dev-deploy-import deploy-dev-import-reset dev-deploy-import-reset + +# Copy this file to Makefile.local and adjust the host/path values for your +# private development environment. Makefile.local is intentionally gitignored. +DEV_DEPLOY_HOST ?= root@silo-dev.example.invalid +DEV_DEPLOY_DIR ?= /opt/git/silo-dev +DEV_TRANSCODE_DEPLOY_HOST ?= +DEV_TRANSCODE_DEPLOY_DIR ?= /opt/git/silo +DEV_IMPORT_MEDIA_ROOT ?= /srv/media +DEV_MEDIA_ROOT ?= /srv/media +DEV_IMPORT_CATALOG_SEED_PATH ?= /tmp/silo-catalog-seed +DEV_PLUGIN_SDK_DIR ?= ../silo-plugin-sdk +DEV_LOCAL_IMAGE ?= silo:dev-local +DEV_JELLYCOMPAT_DEBUG_LOG ?= /opt/silo/jellycompat-debug.log +RSYNC ?= $(shell command -v rsync || echo /usr/bin/rsync) +SILO_IMAGE ?= ghcr.io/silo-server/silo-server:latest + +RSYNC_EXCLUDE := --exclude='.git' --exclude='node_modules' --exclude='web/dist' \ + --exclude='.env' --exclude='.env.*' --exclude='*.md' --exclude='.agents' \ + --exclude='.claude' --exclude='.codex' --exclude='.cursor' \ + --exclude='.worktrees' --exclude='.playwright-mcp' --exclude='demo' \ + --exclude='output' + +dev-remote-setup: + DEV_HOST=$(DEV_DEPLOY_HOST) DEV_DIR=$(DEV_DEPLOY_DIR) ./scripts/dev-remote-setup.sh + +deploy-dev: + @echo "==> Syncing source to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/..." + $(RSYNC) -az --delete $(RSYNC_EXCLUDE) ./ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ + @if [ -f .env ]; then \ + $(RSYNC) -az .env $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/; \ + fi + @echo "==> Syncing plugin SDK to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/silo-plugin-sdk/..." + $(RSYNC) -az --delete --exclude='.git' $(DEV_PLUGIN_SDK_DIR)/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/silo-plugin-sdk/ + @echo "==> Stopping air tmux session if running..." + ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' + @echo "==> Normalizing jellycompat debug log path..." + ssh $(DEV_DEPLOY_HOST) 'set -e; docker rm -f silo >/dev/null 2>&1 || true; mkdir -p $$(dirname "$(DEV_JELLYCOMPAT_DEBUG_LOG)"); if [ -d "$(DEV_JELLYCOMPAT_DEBUG_LOG)" ]; then if ! rmdir "$(DEV_JELLYCOMPAT_DEBUG_LOG)" 2>/dev/null; then mv "$(DEV_JELLYCOMPAT_DEBUG_LOG)" "$(DEV_JELLYCOMPAT_DEBUG_LOG).dir.$$(date +%s)"; fi; fi; touch "$(DEV_JELLYCOMPAT_DEBUG_LOG)"' + @echo "==> Building $(DEV_LOCAL_IMAGE) on remote..." + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker buildx build \ + -f Dockerfile.dev \ + --build-context silo_plugin_sdk=$(DEV_DEPLOY_DIR)/silo-plugin-sdk \ + -t $(DEV_LOCAL_IMAGE) \ + --load \ + .' + @echo "==> Starting services via Docker Compose..." + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && SILO_IMAGE=$(DEV_LOCAL_IMAGE) MEDIA_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f docker-compose.dev.yml --profile integrated up -d --force-recreate silo' + @echo "==> Pruning old images..." + ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' + @echo "==> Verifying deployment..." + @sleep 5 + ssh $(DEV_DEPLOY_HOST) 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' + @echo "==> Deploy complete (local build)." + +dev-deploy: deploy-dev + +deploy-dev-image: + @echo "==> Syncing compose config to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/..." + $(RSYNC) -az docker-compose.dev.yml $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ + $(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/ + @if [ -f .env ]; then \ + $(RSYNC) -az .env $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/; \ + fi + @echo "==> Stopping air tmux session if running..." + ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' + @echo "==> Normalizing jellycompat debug log path..." + ssh $(DEV_DEPLOY_HOST) 'set -e; docker rm -f silo >/dev/null 2>&1 || true; mkdir -p $$(dirname "$(DEV_JELLYCOMPAT_DEBUG_LOG)"); if [ -d "$(DEV_JELLYCOMPAT_DEBUG_LOG)" ]; then if ! rmdir "$(DEV_JELLYCOMPAT_DEBUG_LOG)" 2>/dev/null; then mv "$(DEV_JELLYCOMPAT_DEBUG_LOG)" "$(DEV_JELLYCOMPAT_DEBUG_LOG).dir.$$(date +%s)"; fi; fi; touch "$(DEV_JELLYCOMPAT_DEBUG_LOG)"' + @echo "==> Pulling $(SILO_IMAGE) on remote..." + ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' + @echo "==> Starting services via Docker Compose..." + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f docker-compose.dev.yml --profile integrated up -d --force-recreate silo' + @echo "==> Verifying deployment..." + @sleep 5 + ssh $(DEV_DEPLOY_HOST) 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' + @echo "==> Deploy complete (image pull)." + +dev-remote-stop: + @echo "==> Stopping air tmux session if running..." + ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' + @echo "==> Stopping dev stack..." + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f docker-compose.dev.yml --profile integrated down' + @echo "==> Done." + +deploy-dev-transcode: + @if [ -z "$(strip $(DEV_TRANSCODE_DEPLOY_HOST))" ]; then \ + echo "DEV_TRANSCODE_DEPLOY_HOST is not set; skipping remote transcode deploy."; \ + exit 0; \ + fi + ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' + $(RSYNC) -az docker-compose.remote-transcode.yml $(DEV_TRANSCODE_DEPLOY_HOST):$(DEV_TRANSCODE_DEPLOY_DIR)/ + ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'set -e; cd $(DEV_TRANSCODE_DEPLOY_DIR) && docker compose -f docker-compose.remote-transcode.yml up -d --force-recreate silo-transcode && sleep 5 && docker compose -f docker-compose.remote-transcode.yml ps && docker inspect -f "{{.State.Status}}" silo-transcode | grep -qx running || { echo; echo "silo-transcode container is not running; recent logs:"; docker logs silo-transcode --tail 120; exit 1; }' + ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' + +dev-deploy-transcode: deploy-dev-transcode + +deploy-dev-import: + $(RSYNC) -az docker-compose.dev.yml $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ + $(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/ + ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" CATALOG_SEED_PATH="$(DEV_IMPORT_CATALOG_SEED_PATH)" docker compose -p silo-import -f docker-compose.dev.yml --profile integrated up -d --force-recreate' + ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' + +dev-deploy-import: deploy-dev-import + +deploy-dev-import-reset: + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -p silo-import -f docker-compose.dev.yml down -v --remove-orphans || true' + $(MAKE) deploy-dev-import + +dev-deploy-import-reset: deploy-dev-import-reset diff --git a/README.md b/README.md new file mode 100644 index 00000000..6b1bf41a --- /dev/null +++ b/README.md @@ -0,0 +1,323 @@ +# Silo + +A self-hosted media streaming server with a React frontend and Go backend. Supports direct play, remuxing, and hardware-accelerated transcoding. Compatible with Jellyfin/Emby clients (VidHub, Findroid) via a built-in compatibility layer. + +## Deploy with Docker (recommended) + +The easiest way to run Silo is with Docker Compose. The default stack assumes you do not already have PostgreSQL and Redis available, so it bundles PostgreSQL, Redis, FFmpeg, and the application for a one-command start. + +1. **Create a `.env` file** + + ```sh + cp .env.example .env + ``` + +2. **Set your media path** + + Edit `.env` and set: + + ```dotenv + MEDIA_ROOT=/path/to/your/media + ``` + + `MEDIA_ROOT` is the one value most users need to change. You can also override `SILO_DATA_ROOT` if you do not want bind mounts under `/opt/silo`, and change ports if the defaults conflict with something else on the host. + +3. **Start the default integrated stack** + + ```sh + docker compose up -d + ``` + + This starts PostgreSQL, Redis, and the integrated Silo server. The app is available at `http://localhost:8090` and the Jellyfin-compatible endpoint at `http://localhost:8096`. + + If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead. + +4. **Configure through the admin UI** + + Add libraries, users, metadata providers, and playback settings from the web interface. + +### Bind Mount Layout + +The deploy-oriented compose files use host folder mappings rather than Docker-managed volumes. + +By default, data is stored under `/opt/silo`: + +- `/opt/silo/postgres` +- `/opt/silo/redis` +- `/opt/silo/transcode` +- `/opt/silo/catalog-seeds` + +Media is mounted into the container at `/mnt/media` from the host path you set in `MEDIA_ROOT`. + +### Optional Profiles + +The main compose file is integrated-first. These profiles exist for operators testing distributed mode or mirroring a split deployment shape. Most single-host installs should stay on the default integrated service, because it already includes proxying and transcoding. + +| Profile | Command | Description | +|---|---|---| +| default | `docker compose up -d` | Integrated server plus bundled PostgreSQL and Redis | +| `proxy` | `docker compose --profile proxy up -d` | Start a standalone proxy service for distributed-mode testing | +| `transcode` | `docker compose --profile transcode up -d` | Start a standalone transcode service for distributed-mode testing | + +You can enable both optional examples together: + +```sh +docker compose --profile proxy --profile transcode up -d +``` + +If you are splitting workers across multiple hosts, use the separate remote worker example instead of trying to stretch the main compose file across machines. + +### Advanced Remote Node Example + +For a dedicated remote transcode worker, use [docker-compose.remote-transcode.yml](docker-compose.remote-transcode.yml). That file is intended for a separate worker host that connects back to an existing Silo deployment using shared PostgreSQL and Redis. + +### Deployment Notes + +The default compose stack intentionally bundles PostgreSQL and Redis for ease of setup and assumes a fresh install without those services already available. If you already operate PostgreSQL and Redis, omit those examples from compose and point Silo at your existing infrastructure instead. For serious installs, PostgreSQL is better on a separate VM or a managed service so upgrades, tuning, and backups are isolated from the app host. Redis can stay local for many installs, but externalizing it is also reasonable if you already operate shared infrastructure. + +Silo is externally stateful by default rather than fully stateless. Durable application state lives in PostgreSQL. Redis only stores coordination and cache-style data. Silo still writes transient transcode output locally under `/tmp/silo-transcode`. If you switch `userdb.backend=sqlite`, Silo also becomes locally stateful at `/var/lib/silo/userdb`. + +Migrating an existing Continuum Docker install should be done with the preflight +helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/continuum-to-silo-docker-migration.md). + +## Build from Source + +### Prerequisites + +- **Go** 1.24+ +- **Bun** 1.0+ +- **PostgreSQL** 18+ +- **FFmpeg** (for transcoding support) + +### Quick Start + +1. **Start PostgreSQL** (skip if you already have one running) + + ```sh + docker compose up -d postgres redis + ``` + + The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first. + +2. **Configure the database connection** + + ```sh + cp .env.example .env + ``` + + Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance. + +3. **Build and run** + + ```sh + make build + ./silo + ``` + + The server starts at `http://localhost:8080` by default. All other settings are configured through the admin UI. + +## Development + +Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.dev.yml](docker-compose.dev.yml) and the existing source-build workflow for local development. + +```sh +# Run the frontend dev server (hot reload, proxies API to :8090) +make dev-frontend + +# Run the Go backend +make dev-backend +``` + +If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions. + +Plugin authors should start with [docs/architecture/plugin-development.md](docs/architecture/plugin-development.md), which covers the RPC plugin package format, generated proto workflow, SDK import paths, route and asset exposure, and auth or user-config integration points. + +## Reporting Issues + +If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis. + +Please include: + +- What you were trying to do +- Exact steps you took +- What you expected to happen +- What actually happened +- What exact action is slow or broken (`save`, `scan`, `browse`, `import`, `playback`, etc.) +- Whether it happens every time or only sometimes +- The library, media type, filter, setting, or value involved +- Version, branch, commit, and deployment details if you know them +- Screenshots, recordings, or log snippets if relevant + +If you used Claude/Codex for debugging, put that under `Technical notes` at the end. Suspected files, SQL output, stack traces, and root-cause theories can be helpful, but only after the workflow and repro steps are clear. + +Use this template: + +```text +Goal: +Steps: +Expected: +Actual: +What is slow/broken: +Scope: +Version/branch: +Deployment: +Technical notes: +``` + +### Make Targets + +| Target | Description | +|---|---| +| `make build` | Build frontend + Go binary | +| `make frontend` | Build frontend only | +| `make dev-frontend` | Vite dev server with HMR | +| `make dev-backend` | Run Go backend (integrated mode) | +| `make dev-proxy` | Run a standalone proxy node | +| `make dev-transcode` | Run a standalone transcode node | +| `make clean` | Remove build artifacts | + +### Running Tests + +```sh +# Go tests (uses testcontainers — Docker must be running) +go test ./... + +# Frontend tests +cd web && bun test +``` + +### Linting + +```sh +# Go +golangci-lint run + +# Frontend +cd web && bun run lint +cd web && bun run format:check +``` + +## License + +Silo is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE). + +## Configuration + +Silo requires only a `DATABASE_URL` when running from source or against external infrastructure. In the default Docker Compose path, the stack wires the database and Redis URLs for you. All other settings — libraries, metadata providers, transcoding, users — are managed through the admin UI after first launch. + +### Server Modes + +| Mode | Description | +|---|---| +| `integrated` | Full server: API + frontend + scanner + transcode (default) | +| `api` | API server only, no local transcoding | +| `proxy` | Stream proxy node that connects to the shared deployment database and Redis | +| `transcode` | HLS transcode worker node that connects to the shared deployment database and Redis | + +## PostgreSQL Performance Tuning + +The default PostgreSQL configuration is conservative and not optimized for any particular workload. For better performance, use [PGTune](https://pgtune.leopard.in.ua/) to generate settings tailored to your hardware. + +**Recommended PGTune settings:** + +| Setting | Value | +|---|---| +| DB Type | **Web application** | +| Total Memory | Your server's RAM | +| Number of CPUs | Your server's CPU count | +| Number of Connections | 20 (Silo's default pool size) | +| Data Storage | SSD or HDD depending on your setup | + +Silo's workload is predominantly read-heavy API queries (browse, search, metadata lookups) with the database small enough to fit in RAM for most libraries. The **Web application** profile optimizes for this: CPU-bound simple queries served from cached pages, high concurrent reads, and low write volume. + +If you have a very large library (50,000+ items) or heavy activity logging, consider switching to the **OLTP** profile instead. + +### Recommended Defaults for Modern Hardware + +Assuming you're using modern hardware with fast NVMe storage this is what I'm currently running. + +```ini +# Connections +max_connections = 100 + +# Memory (tuned to keep PG under ~8GB total) +shared_buffers = 4GB # holds the entire DB in cache (typical DB is 1-4GB) +effective_cache_size = 6GB # planner hint only, not an allocation +work_mem = 32MB # per-operation sort/hash memory +maintenance_work_mem = 512MB # VACUUM, CREATE INDEX, catalog imports + +# WAL / Write Performance +wal_buffers = 64MB +min_wal_size = 512MB +max_wal_size = 2GB +checkpoint_completion_target = 0.9 + +# Storage (NVMe/Optane) +random_page_cost = 1.1 # default 4.0; NVMe random ≈ sequential +effective_io_concurrency = 200 # NVMe handles many concurrent requests + +# Parallelism +max_worker_processes = 8 +max_parallel_workers_per_gather = 4 +max_parallel_workers = 8 +max_parallel_maintenance_workers = 4 + +# Huge Pages +huge_pages = try +``` + +These settings keep PostgreSQL under ~8GB of RAM while still caching the entire database. For HDD storage, change `random_page_cost` to `4.0` and `effective_io_concurrency` to `2`. + +### Applying Settings + +**Docker (recommended):** Save the settings above to a `postgresql.conf` file and mount it into your container: + +```yaml +# docker-compose.yml +postgres: + image: pgvector/pgvector:pg18 + volumes: + - ./postgresql.conf:/etc/postgresql/postgresql.conf:ro + command: postgres -c config_file=/etc/postgresql/postgresql.conf +``` + +Then restart: `docker compose restart postgres` + +**Existing PostgreSQL:** You can apply settings without editing config files using `ALTER SYSTEM`, which writes to `postgresql.auto.conf` inside the data directory: + +```sql +ALTER SYSTEM SET work_mem = '32MB'; +ALTER SYSTEM SET effective_cache_size = '6GB'; +ALTER SYSTEM SET random_page_cost = 1.1; +ALTER SYSTEM SET effective_io_concurrency = 200; +-- Apply without restart: +SELECT pg_reload_conf(); + +-- These require a full restart: +ALTER SYSTEM SET shared_buffers = '4GB'; +ALTER SYSTEM SET max_connections = 100; +ALTER SYSTEM SET max_worker_processes = 8; +ALTER SYSTEM SET huge_pages = 'try'; +``` + +Settings like `shared_buffers`, `max_connections`, `max_worker_processes`, and `huge_pages` require a PostgreSQL restart. Memory/planner settings (`work_mem`, `effective_cache_size`, `random_page_cost`) can be reloaded live. + +## Project Structure + +``` +cmd/silo/ Entry point +internal/ + api/ HTTP router, handlers, middleware + auth/ JWT authentication and sessions + catalog/ Media item, episode, season repositories + config/ YAML + env var configuration + jellycompat/ Jellyfin/Emby protocol compatibility + metadata/ Plugin-driven metadata matching and enrichment + playback/ Direct play, remux, transcode session management + scanner/ Media file discovery and FFProbe + worker/ Background jobs (scan, match, reconcile) +web/ React + TypeScript frontend (Vite, Tailwind, shadcn/ui) +migrations/ PostgreSQL schema migrations +``` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..ea3d2dda --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,20 @@ +# Third-Party Notices + +This repository vendors a small amount of third-party and generated asset +material needed for Silo server builds. + +## Jellyfin Web + +`third_party/jellyfin-web/10.11.6/` contains a vendored Jellyfin Web build used +for Jellyfin compatibility surfaces. Upstream package notices generated by that +build are preserved next to the corresponding bundles as `*.LICENSE.txt` files. +Keep those notices with the vendored files when updating or redistributing this +directory. + +## Collection Template Posters + +`web/public/images/collection-templates/` contains Silo-generated collection +poster artwork. The artwork is intended to use generic original scenes and +avoid copyrighted movie/show posters, recognizable actors, franchise +characters, provider logos, and readable in-image third-party branding. + diff --git a/cmd/silo/main.go b/cmd/silo/main.go new file mode 100644 index 00000000..120f212e --- /dev/null +++ b/cmd/silo/main.go @@ -0,0 +1,1767 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io/fs" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/go-hclog" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus/promhttp" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + + "github.com/Silo-Server/silo-server/internal/activitylog" + "github.com/Silo-Server/silo-server/internal/adminjob" + "github.com/Silo-Server/silo-server/internal/api" + "github.com/Silo-Server/silo-server/internal/api/handlers" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/catalogseed" + "github.com/Silo-Server/silo-server/internal/chapterthumbs" + "github.com/Silo-Server/silo-server/internal/clientip" + "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/database" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/historyimport" + "github.com/Silo-Server/silo-server/internal/imagecache" + "github.com/Silo-Server/silo-server/internal/intromarkers" + "github.com/Silo-Server/silo-server/internal/jellycompat" + "github.com/Silo-Server/silo-server/internal/libraryingest" + "github.com/Silo-Server/silo-server/internal/logfilter" + "github.com/Silo-Server/silo-server/internal/logstream" + "github.com/Silo-Server/silo-server/internal/mdblist" + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodeconfig" + "github.com/Silo-Server/silo-server/internal/nodepool" + "github.com/Silo-Server/silo-server/internal/nodesessions" + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/Silo-Server/silo-server/internal/opslog" + "github.com/Silo-Server/silo-server/internal/partman" + "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/pluginhost" + "github.com/Silo-Server/silo-server/internal/plugins" + "github.com/Silo-Server/silo-server/internal/proxy" + "github.com/Silo-Server/silo-server/internal/ratelimit" + "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/s3client" + "github.com/Silo-Server/silo-server/internal/scanner" + "github.com/Silo-Server/silo-server/internal/scanqueue" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/server" + "github.com/Silo-Server/silo-server/internal/subtitles" + "github.com/Silo-Server/silo-server/internal/taskmanager" + taskrepository "github.com/Silo-Server/silo-server/internal/taskmanager/repository" + "github.com/Silo-Server/silo-server/internal/taskmanager/tasks" + "github.com/Silo-Server/silo-server/internal/taskmanager/triggers" + "github.com/Silo-Server/silo-server/internal/transcodenode" + "github.com/Silo-Server/silo-server/internal/usercollections" + "github.com/Silo-Server/silo-server/internal/userdb" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" + "github.com/Silo-Server/silo-server/internal/watchstate" + "github.com/Silo-Server/silo-server/internal/watchsync" + watchmdblist "github.com/Silo-Server/silo-server/internal/watchsync/providers/mdblist" + "github.com/Silo-Server/silo-server/internal/watchsync/providers/simkl" + "github.com/Silo-Server/silo-server/internal/watchsync/providers/trakt" + "github.com/Silo-Server/silo-server/internal/worker" + "github.com/Silo-Server/silo-server/migrations" + siloweb "github.com/Silo-Server/silo-server/web" +) + +// resolveNodeIdentity returns a stable node identifier used by the +// heartbeat writer, reconciler, and shutdown cleanup. Resolution order: +// SILO_NODE_NAME > NODE_NAME > os.Hostname(). +func resolveNodeIdentity() string { + if v := os.Getenv("SILO_NODE_NAME"); v != "" { + return v + } + if v := os.Getenv("NODE_NAME"); v != "" { + return v + } + h, _ := os.Hostname() + return h +} + +func resolvePluginCacheDir() string { + if v := strings.TrimSpace(os.Getenv("SILO_PLUGIN_CACHE_DIR")); v != "" { + return v + } + return filepath.Join(os.TempDir(), "silo-plugins") +} + +func buildBaseHandler(format string, level slog.Level) slog.Handler { + opts := &slog.HandlerOptions{Level: level} + if strings.EqualFold(format, "json") { + return slog.NewJSONHandler(os.Stderr, opts) + } + return slog.NewTextHandler(os.Stderr, opts) +} + +func mustGetSetting(store interface { + Get(context.Context, string) (string, error) +}, ctx context.Context, key, fallback string) string { + value, err := store.Get(ctx, key) + if err != nil || strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func configureOperationalLogging( + ctx context.Context, + pool *pgxpool.Pool, + settingsRepo *catalog.ServerSettingsRepo, + redisCfg config.RedisConfig, + logStreamHub *logstream.Hub, + baseHandler slog.Handler, + logQuiet string, + nodeID string, +) (opslog.Writer, *opslog.Repo, *partman.Manager) { + if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil { + log.Fatalf("seed opslog defaults: %v", err) + } + opsPM := partman.NewManager(pool, "operational_logs", partman.Daily, 3) + if err := opsPM.EnsureFuturePartitions(ctx); err != nil { + log.Fatalf("ensure operational log partitions: %v", err) + } + + var operationalWriter opslog.Writer + operationalConsumer := opslog.NewConsumer(pool, nil, logStreamHub) + if redisCfg.URL != "" { + redisClient, redisErr := cache.NewRedisClient(redisCfg) + if redisErr == nil && redisClient != nil { + operationalWriter = opslog.NewRedisWriter(redisClient) + operationalConsumer = opslog.NewConsumer(pool, redisClient, logStreamHub) + go operationalConsumer.RunRedis(ctx) + } + } + if operationalWriter == nil { + memWriter := opslog.NewMemoryWriter(10000) + operationalWriter = memWriter + go operationalConsumer.RunMemory(ctx, memWriter.Chan()) + } + + opsCaptureLevel := slog.LevelInfo + switch strings.ToLower(strings.TrimSpace(mustGetSetting(settingsRepo, ctx, "opslog.capture_level", "info"))) { + case "debug": + opsCaptureLevel = slog.LevelDebug + case "warn", "warning": + opsCaptureLevel = slog.LevelWarn + case "error": + opsCaptureLevel = slog.LevelError + } + + filteredHandler := logfilter.New(baseHandler, logQuiet) + slog.SetDefault(slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID))) + + return operationalWriter, opslog.NewRepo(pool), opsPM +} + +func main() { + envFile := flag.String("env", ".env", "path to .env bootstrap file") + flag.Parse() + + ctx := context.Background() + + // Step 1: Bootstrap from .env + bc, err := config.LoadBootstrap(*envFile) + if err != nil { + log.Fatalf("bootstrap: %v", err) + } + + // Step 2: Connect to PostgreSQL (bootstrap pool with default max connections) + bootstrapDBCfg := config.DatabaseConfig{URL: bc.DatabaseURL, MaxConnections: 20} + pool, err := database.NewPool(ctx, bootstrapDBCfg) + if err != nil { + log.Fatalf("database pool: %v", err) + } + defer pool.Close() + slog.Info("connected to PostgreSQL") + + // Run migrations only for integrated/api modes. Proxy and transcode nodes + // should never alter the schema — they may scale independently and would + // race or apply migrations before the primary node is deliberately upgraded. + if bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == "" { + migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute) + if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "."); migErr != nil { + migCancel() + log.Fatalf("failed to run migrations: %v", migErr) + } + migCancel() + slog.Info("database migrations applied") + } + + // Step 3: Load settings from DB + settingsRepo := catalog.NewServerSettingsRepo(pool) + settings, err := settingsRepo.GetAll(ctx) + if err != nil { + log.Fatalf("loading settings: %v", err) + } + + // Step 4: YAML import (one-time) + yamlPath := "silo.yaml" + if _, yamlErr := os.Stat(yamlPath); yamlErr == nil { + if settings["_yaml_imported"] == "" { + yamlSettings, importErr := config.YAMLToSettingsMap(yamlPath) + if importErr != nil { + log.Printf("WARN: could not import YAML config: %v", importErr) + } else { + for k, v := range yamlSettings { + if err := settingsRepo.Set(ctx, k, v); err != nil { + log.Printf("WARN: failed to import setting %s: %v", k, err) + } + } + if err := settingsRepo.Set(ctx, "_yaml_imported", "true"); err != nil { + slog.Warn("failed to set yaml import flag", "error", err) + } + log.Println("Imported config from silo.yaml — this file is no longer used") + settings, _ = settingsRepo.GetAll(ctx) + } + } + } + + // Step 5: Auto-generate secrets + if settings["auth.jwt_secret"] == "" { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + log.Fatalf("generating jwt secret: %v", err) + } + encoded := base64.StdEncoding.EncodeToString(secret) + if err := settingsRepo.Set(ctx, "auth.jwt_secret", encoded); err != nil { + slog.Warn("failed to persist generated JWT secret", "error", err) + } + settings["auth.jwt_secret"] = encoded + } + if settings["jellyfin_compat.server_id"] == "" { + serverID := uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://silo.local/jellycompat")).String() + if err := settingsRepo.Set(ctx, "jellyfin_compat.server_id", serverID); err != nil { + slog.Warn("failed to persist generated server ID", "error", err) + } + settings["jellyfin_compat.server_id"] = serverID + } + + // Step 6: Build config from DB + cfg, err := config.LoadFromDB(settings) + if err != nil { + log.Fatalf("building config: %v", err) + } + + // Step 7: Apply bootstrap overrides + cfg.Server.Listen = bc.Listen + cfg.Server.Mode = bc.Mode + cfg.Database.URL = bc.DatabaseURL + cfg.JellyfinCompat.Listen = bc.JFListen + if bc.RedisURL != "" { + cfg.Redis.URL = bc.RedisURL + } + + // Step 8: Recreate pool if max_connections differs from bootstrap default + if cfg.Database.MaxConnections != bootstrapDBCfg.MaxConnections { + pool.Close() + pool, err = database.NewPool(ctx, cfg.Database) + if err != nil { + log.Fatalf("recreating pool with configured max_connections: %v", err) + } + } + settingsRepo = catalog.NewServerSettingsRepo(pool) + nodeID := resolveNodeIdentity() + + // Step 9: Validate + if err := cfg.Validate(); err != nil { + log.Fatalf("config validation: %v", err) + } + + // Step 10: Configure log level + var slogLevel slog.Level + switch strings.ToLower(cfg.Server.LogLevel) { + case "debug": + slogLevel = slog.LevelDebug + case "warn", "warning": + slogLevel = slog.LevelWarn + case "error": + slogLevel = slog.LevelError + default: + slogLevel = slog.LevelInfo + } + baseHandler := buildBaseHandler(cfg.Server.LogFormat, slogLevel) + slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet))) + + mode := cfg.Server.Mode + slog.Info("silo starting", "mode", mode, "listen", cfg.Server.Listen, "log_level", cfg.Server.LogLevel, "node_id", nodeID) + + appCtx, appCancel := context.WithCancel(ctx) + defer appCancel() + + eventBus := cache.NewEventBus(cfg.Redis.URL) + logStreamHub := logstream.NewHub(nodeID, eventBus) + if err := logStreamHub.Start(appCtx); err != nil { + log.Fatalf("log stream hub start: %v", err) + } + realtimeHub := notifications.NewHub(nodeID, eventBus) + if err := realtimeHub.Start(appCtx); err != nil { + log.Fatalf("realtime hub start: %v", err) + } + eventsHub := realtimeHub.EventsHub() + scanRegistry := evt.NewScanRegistry() + operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, baseHandler, cfg.Server.LogQuiet, nodeID) + defer func() { + if err := eventBus.Close(); err != nil { + slog.Warn("event bus close error", "error", err) + } + }() + + // Proxy and transcode modes run with DB + Redis for hot-reload. + if mode == "proxy" || mode == "transcode" { + redisClient, err := cache.NewRedisClient(cfg.Redis) + if err != nil || redisClient == nil { + slog.Error("redis is required for "+mode+" mode", "error", err) + os.Exit(1) + } + + bootstrap := nodeconfig.BootstrapOverrides{ + Listen: cfg.Server.Listen, + Mode: cfg.Server.Mode, + DatabaseURL: cfg.Database.URL, + JFListen: cfg.JellyfinCompat.Listen, + } + watcher := nodeconfig.NewWatcher(pool, eventBus, bootstrap) + if err := watcher.Start(appCtx); err != nil { + slog.Error("config watcher start failed", "error", err) + os.Exit(1) + } + + nodeURL := os.Getenv("NODE_URL") + nodeName := os.Getenv("NODE_NAME") + if nodeURL == "" { + nodeURL = "http://localhost" + cfg.Server.Listen + slog.Warn("NODE_URL not set, using listen address — session keys may collide across nodes") + } + if nodeName == "" { + nodeName = mode + } + + tracker := nodesessions.NewTracker(redisClient, nodeURL, nodeName, mode) + tracker.StartRefresh(appCtx) + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cleanupCancel() + tracker.Cleanup(cleanupCtx) + }() + + var handler http.Handler + if mode == "proxy" { + srv := proxy.NewServer(watcher, tracker) + handler = srv.Handler() + } else { + srv := transcodenode.NewServer(watcher, tracker) + srv.SetFFmpegLogSink(playback.NewSlogFFmpegLogSink(slog.Default(), nodeID)) + handler = srv.Handler() + } + + _ = operationalWriter + _ = opsRepo + startStandaloneServer(cfg.Server.Listen, handler) + return + } + + // Determine which components to initialize based on mode. + needsS3 := mode == "integrated" || mode == "api" + needsScanner := mode == "integrated" || mode == "api" + needsUserDB := mode == "integrated" || mode == "api" + needsWorkers := mode == "integrated" || mode == "api" + + bootstrapSensitiveConfigured := map[string]bool{} + bootstrapSensitiveValues := map[string]string{} + if bc.RedisURL != "" { + bootstrapSensitiveConfigured["redis.url"] = true + bootstrapSensitiveValues["redis.url"] = bc.RedisURL + } + + deps := api.Dependencies{ + Config: cfg, + BootstrapSensitiveConfigured: bootstrapSensitiveConfigured, + BootstrapSensitiveValues: bootstrapSensitiveValues, + AppContext: appCtx, + DB: pool, + EventBus: eventBus, + LogStreamHub: logStreamHub, + RealtimeHub: realtimeHub, + EventsHub: eventsHub, + ScanRegistry: scanRegistry, + OpsLogRepo: opsRepo, + FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID), + PublicURL: os.Getenv("SILO_PUBLIC_URL"), + } + if needsWorkers && deps.DB != nil { + deps.IntroRepository = intromarkers.NewRepository(deps.DB) + deps.IntroAnalyzer = intromarkers.NewAnalyzer( + deps.IntroRepository, + intromarkers.DefaultConfig(cfg.Playback.FFmpegPath), + slog.Default(), + ) + } + var watchProviderService *watchsync.Service + if deps.DB != nil { + watchProviderRegistry := watchsync.NewRegistry() + if err := watchProviderRegistry.Register(trakt.NewProvider(nil, "")); err != nil { + log.Fatalf("register watch provider: %v", err) + } + if err := watchProviderRegistry.Register(simkl.NewProvider(nil, "")); err != nil { + log.Fatalf("register watch provider: %v", err) + } + if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil { + log.Fatalf("register watch provider: %v", err) + } + watchProviderService = watchsync.NewService( + watchsync.NewPostgresRepository(deps.DB), + watchProviderRegistry, + ) + deps.WatchProviderService = watchProviderService + } + + // Initialize node pools for integrated/api modes. + if mode == "integrated" || mode == "api" { + nodeRepo := nodepool.NewRepository(pool) + deps.NodeRepo = nodeRepo + + proxyPool := nodepool.NewProxyPool() + transcodePool := nodepool.NewTranscodePool() + + proxyNodes, _ := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeProxy) + transcodeNodes, _ := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeTranscode) + proxyPool.SetNodes(proxyNodes) + transcodePool.SetNodes(transcodeNodes) + + deps.ProxyPool = proxyPool + deps.TranscodePool = transcodePool + + healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo) + healthChecker.Start(appCtx) + slog.Info("node pools initialized", "proxy_nodes", len(proxyNodes), "transcode_nodes", len(transcodeNodes)) + + // Subscribe to node pool change events for multi-instance reload. + _ = eventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) { + if event.Type == cache.EventNodePoolChanged { + pNodes, pErr := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeProxy) + tNodes, tErr := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeTranscode) + if pErr != nil || tErr != nil { + slog.Warn("node pool reload from event failed, keeping current pools", + "proxy_err", pErr, "transcode_err", tErr) + return + } + proxyPool.SetNodes(pNodes) + transcodePool.SetNodes(tNodes) + slog.Info("node pools reloaded from event", "proxy", len(pNodes), "transcode", len(tNodes)) + } + }) + } + + // Step 3: Create S3 clients (if needed). + if needsS3 { + configureS3Clients(cfg, &deps) + } + + // Step 4: Create scanner (if needed). + if needsScanner && deps.DB != nil { + folderRepo := catalog.NewFolderRepository(deps.DB) + fileRepo := scanner.NewFileRepository(deps.DB) + deps.FolderRepo = folderRepo + deps.FileRepo = fileRepo + + ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath) + s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan) + deps.Scanner = s + deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second) + slog.Info("scanner initialized") + } + + var chapterThumbService *chapterthumbs.Service + if deps.FileRepo != nil && deps.FolderRepo != nil && deps.S3Public != nil { + chapterThumbService = chapterthumbs.NewService( + deps.FileRepo, + deps.FolderRepo, + deps.ProbeEnsurer, + settingsRepo, + deps.S3Public, + nil, + deps.TranscodePool, + cfg.Playback.FFmpegPath, + cfg.Playback.HWAccel, + cfg.Playback.HWDevice, + cfg.Playback.ChapterThumbnailWorkers, + ) + if chapterThumbService != nil { + chapterThumbService.Start(appCtx) + deps.ChapterThumbnailQueuer = chapterThumbService + } + } + + var pluginHost *pluginhost.Host + var pluginService *plugins.Service + var pluginInstallationStore *plugins.InstallationStore + var pluginRuntimeConfigStore *plugins.RuntimeConfigStore + var pluginHTTPProxy *plugins.HTTPProxy + pluginAutoUpdateDone := make(chan struct{}) + var pluginAutoUpdater *plugins.AutoUpdateService + if deps.DB != nil { + pluginCacheDir := resolvePluginCacheDir() + repositoryStore := plugins.NewRepositoryStore(deps.DB) + installationStore := plugins.NewInstallationStore(deps.DB) + runtimeConfigStore := plugins.NewRuntimeConfigStore(deps.DB) + catalogService := plugins.NewCatalogService(repositoryStore, plugins.CatalogServiceOptions{ + SiloAPIVersion: plugins.DefaultSiloAPIVersion, + }) + installer := plugins.NewInstaller(installationStore, plugins.InstallerOptions{ + BaseDir: pluginCacheDir, + }) + + libDataSource := pluginhost.LibraryDataSourceFunc( + func(ctx context.Context, _ string) ([]pluginhost.LibraryRecord, error) { + // TODO: scope by userID when the requests plugin needs it (Plan B). + // For now, all callers see admin-scope. + if deps.FolderRepo == nil { + return nil, nil + } + folders, err := deps.FolderRepo.List(ctx) + if err != nil { + return nil, err + } + out := make([]pluginhost.LibraryRecord, 0, len(folders)) + for _, f := range folders { + out = append(out, pluginhost.LibraryRecord{ + ID: strconv.Itoa(f.ID), + Name: f.Name, + MediaType: mapFolderTypeToMediaType(f.Type), + }) + } + return out, nil + }, + ) + presenceItemRepo := catalog.NewItemRepository(deps.DB) + catalogPresence := pluginhost.NewCatalogPresence( + func(ctx context.Context, mediaType string, tmdbIDs []string) ([]pluginhost.LibraryPresenceRecord, error) { + rows, err := presenceItemRepo.LookupTMDBIDs(ctx, mediaType, tmdbIDs) + if err != nil { + return nil, err + } + out := make([]pluginhost.LibraryPresenceRecord, 0, len(rows)) + for _, r := range rows { + out = append(out, pluginhost.LibraryPresenceRecord{ + ExternalID: r.TMDBID, + MediaID: r.MediaID, + LibraryID: r.LibraryID, + Title: r.Title, + }) + } + return out, nil + }, + ) + pluginHost = pluginhost.NewHost(pluginhost.Config{ + EventPublisher: eventsHub, + LibraryLister: pluginhost.NewLibraryLister(libDataSource), + CatalogPresence: catalogPresence, + InstalledPlugins: pluginhost.InstalledPluginListerFunc( + func(ctx context.Context) ([]pluginhost.InstalledPluginRecord, error) { + installations, err := installationStore.List(ctx) + if err != nil { + return nil, err + } + out := make([]pluginhost.InstalledPluginRecord, 0, len(installations)) + for _, installation := range installations { + capabilities, err := installationStore.ListCapabilities(ctx, installation.ID) + if err != nil { + return nil, err + } + descriptors := make([]*pluginv1.CapabilityDescriptor, 0, len(capabilities)) + for _, capability := range capabilities { + descriptor, err := plugins.DecodeCapability(capability) + if err != nil { + return nil, err + } + descriptors = append(descriptors, descriptor) + } + out = append(out, pluginhost.InstalledPluginRecord{ + InstallationID: installation.ID, + PluginID: installation.PluginID, + Version: installation.Version, + Enabled: installation.Enabled, + Capabilities: descriptors, + }) + } + return out, nil + }, + ), + GlobalConfigSetter: pluginhost.GlobalConfigSetterFunc( + func(ctx context.Context, installationID int, key string, value map[string]any) error { + return runtimeConfigStore.PutGlobalConfig(ctx, installationID, key, value) + }, + ), + Logger: hclog.New(&hclog.LoggerOptions{ + Name: "plugin-host", + Level: hclog.Info, + Output: os.Stderr, + }), + }) + pluginService = plugins.NewService( + repositoryStore, + installationStore, + runtimeConfigStore, + catalogService, + installer, + plugins.NewHostAdapter(pluginHost), + ) + if err := pluginService.PreloadEnabled(appCtx); err != nil { + log.Fatalf("preload enabled plugins: %v", err) + } + slog.Info("plugin cache initialized", "base_dir", pluginCacheDir) + + pluginAutoUpdater = plugins.NewAutoUpdateService( + repositoryStore, + installationStore, + catalogService, + installer, + pluginHost, + slog.Default(), + ) + go func() { + defer close(pluginAutoUpdateDone) + if err := pluginAutoUpdater.Run(appCtx); err != nil { + slog.Error("plugin auto-update failed", "error", err) + } + }() + pluginInstallationStore = installationStore + pluginRuntimeConfigStore = runtimeConfigStore + pluginHTTPProxy = plugins.NewHTTPProxyWithTypedResolver(pluginService, pluginInstallationStore) + if deps.DB != nil { + pluginHTTPProxy = pluginHTTPProxy.WithUserThemeLookup(plugins.NewPgUserThemeLookup(deps.DB)) + pluginHTTPProxy = pluginHTTPProxy.WithUserIdentityLookup(plugins.NewPgUserIdentityLookup(deps.DB)) + } + deps.PluginService = pluginService + deps.PluginHTTPProxy = pluginHTTPProxy + defer func() { + if pluginHost == nil { + return + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := pluginHost.Shutdown(shutdownCtx); err != nil { + slog.Warn("failed to shut down plugin host", "error", err) + } + }() + } else { + close(pluginAutoUpdateDone) + } + if pluginService != nil && pluginInstallationStore != nil { + dispatcher := plugins.NewEventDispatcherWithTypedResolver(deps.EventBus, deps.EventsHub, pluginInstallationStore, pluginService, 4) + pluginService.SetEventDispatcher(dispatcher) + if err := dispatcher.Start(appCtx); err != nil { + log.Fatalf("plugin event dispatcher: %v", err) + } + defer dispatcher.Stop() + // Backfill the capability-subscriber index from the already-preloaded + // installations. PreloadEnabled ran earlier (before the dispatcher + // existed), so its rebuildDispatcherIndex was a no-op. Without this + // call, capability-scoped subscriptions never fire until the next + // lifecycle mutation. + pluginService.OnLifecycleChange(appCtx) + } + + // Step 4b: Create metadata service and match worker (if needed). + var metadataService *metadata.MetadataService + var personRefreshService *metadata.PersonRefreshService + var matchWorker *metadata.MatchWorker + var libraryIngestExecutor *libraryingest.Executor + var libraryScanQueue *scanqueue.Service + var itemRefreshExecutor *adminjob.ItemRefreshExecutor + var libraryRefreshExecutor *adminjob.LibraryRefreshExecutor + var itemRepo *catalog.ItemRepository + var skippedRootRepo *metadata.SkippedRootRepository + var movieQueueRepo *metadata.MovieMatchQueueRepository + var seriesQueueRepo *metadata.SeriesRootMatchQueueRepository + var matchQueueCoordinator *metadata.MatchQueueCoordinator + var rootClaimRepo *catalog.RootClaimRepository + var groupClaimRepo *catalog.GroupClaimRepository + var seasonRepo *catalog.SeasonRepository + var episodeRepo *catalog.EpisodeRepository + if needsWorkers && deps.DB != nil && deps.FileRepo != nil { + chainRepo := metadata.NewChainRepository(deps.DB) + skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB) + itemRepo = catalog.NewItemRepository(deps.DB) + episodeRepo = catalog.NewEpisodeRepository(deps.DB) + seasonRepo = catalog.NewSeasonRepository(deps.DB) + personRepo := catalog.NewPersonRepository(deps.DB) + libraryRepo := catalog.NewLibraryItemRepository(deps.DB) + + // Wait for plugin auto-update to finish before registering image resolvers. + <-pluginAutoUpdateDone + + imageResolver := metadata.NewPluginImageResolver() + if pluginService != nil { + // Register image resolver sources for all enabled plugin metadata providers. + rows, err := deps.DB.Query(appCtx, + `SELECT pc.plugin_installation_id, pc.capability_id + FROM plugin_capabilities pc + JOIN plugin_installations pi ON pi.id = pc.plugin_installation_id + WHERE pc.capability_type = 'metadata_provider.v1' AND pi.enabled = true`) + if err != nil { + slog.Warn("failed to list capabilities for image resolver registration", "error", err) + } else { + defer rows.Close() + for rows.Next() { + var instID int + var capID string + if err := rows.Scan(&instID, &capID); err != nil { + slog.Warn("failed to scan capability for image resolver", "error", err) + continue + } + source := metadata.NewPluginClientSource(instID, capID, func( + ctx context.Context, installationID int, capabilityID string, + ) (metadata.PluginMetadataClient, error) { + return pluginService.MetadataProviderClient(ctx, installationID, capabilityID) + }) + imageResolver.RegisterSource(capID, source) + slog.Info("registered plugin image resolver", "capability_id", capID, "installation_id", instID) + } + } + } + if deps.S3Public != nil { + presignTTL := cfg.S3.MetadataPresignExpiry + if presignTTL <= 0 { + presignTTL = 4 * time.Hour + } + imageResolver.SetS3Presigner(deps.S3Public, deps.S3Public.EffectivePresignTTL(presignTTL)) + } + deps.ImageResolver = imageResolver + deps.PluginImageResolver = imageResolver + + staleIDRepo := metadata.NewStaleMediaIDRepository(deps.DB) + providerIDRepo := catalog.NewProviderIDRepository(deps.DB) + movieQueueRepo = metadata.NewMovieMatchQueueRepository(deps.DB, deps.FileRepo) + seriesQueueRepo = metadata.NewSeriesRootMatchQueueRepository(deps.DB) + matchQueueCoordinator = metadata.NewMatchQueueCoordinator(movieQueueRepo, seriesQueueRepo) + rootClaimRepo = catalog.NewRootClaimRepository(deps.DB) + groupClaimRepo = catalog.NewGroupClaimRepository(deps.DB) + pluginResolver := metadata.NewPluginResolverAdapter(pluginService) + metadataService = metadata.NewMetadataService( + chainRepo, pluginResolver, + itemRepo, providerIDRepo, episodeRepo, seasonRepo, libraryRepo, deps.FolderRepo, + personRepo, + deps.FileRepo, skippedRootRepo, staleIDRepo, rootClaimRepo, + ) + personRefreshService = metadata.NewPersonRefreshService(deps.DB, pluginResolver, personRepo) + personRefreshService.SetImageResolver(imageResolver) + + // Always wire the image resolver so plugin-prefixed URLs (e.g. + // metadb://) can be resolved to presigned HTTP URLs in API responses. + metadataService.SetImageResolver(imageResolver) + + // Wire the image cacher whenever object storage is available so explicit + // admin image applies can succeed even if automatic metadata caching is off. + if deps.S3Public != nil { + imageCacher := imagecache.New(deps.S3Public) + metadataService.SetImageCacher(imageCacher) + metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages) + if cfg.Metadata.CacheImages { + personRefreshService.SetImageCacher(imageCacher) + slog.Info("metadata image caching enabled") + } + } + + matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second) + if movieQueueRepo != nil { + matchWorker.SetMovieFileClaimer(movieQueueRepo) + } + if seriesQueueRepo != nil { + if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(appCtx); err != nil { + slog.Warn("failed to clean legacy series group queue rows", "error", err) + } else if cleaned > 0 { + slog.Info("cleaned legacy series group queue rows", "count", cleaned) + } + matchWorker.SetSeriesRootClaimer(seriesQueueRepo, cfg.Matcher.TVSeriesRootQueueEnabled()) + } + if deps.FolderRepo != nil { + enabledFolders, err := deps.FolderRepo.GetEnabled(appCtx) + if err != nil { + slog.Warn("failed to seed metadata queues", "error", err) + } else { + for _, folder := range enabledFolders { + if folder == nil { + continue + } + switch strings.ToLower(strings.TrimSpace(folder.Type)) { + case "movie", "movies": + if movieQueueRepo != nil { + if err := movieQueueRepo.SyncForFolder(appCtx, folder.ID); err != nil { + slog.Warn("failed to seed movie match queue", "folder_id", folder.ID, "error", err) + } + } + case "series", "tv", "show", "tvshows": + if seriesQueueRepo != nil { + if err := seriesQueueRepo.SyncForFolder(appCtx, folder.ID); err != nil { + slog.Warn("failed to seed series root queue", "folder_id", folder.ID, "error", err) + } + } + } + } + } + } + + deps.SkippedRootRepo = skippedRootRepo + deps.StaleIDRepo = staleIDRepo + deps.PersonRepo = personRepo + deps.PersonRefreshQueue = worker.NewPersonRefreshWorker( + personRefreshService, + worker.DefaultPersonRefreshWorkerConfig(), + ) + deps.PersonRefresher = personRefreshService + deps.Refresher = metadataService + deps.MetadataService = metadataService + slog.Info("metadata service initialized and running") + + } + if deps.Scanner != nil { + if matchQueueCoordinator != nil { + deps.Scanner.SetMetadataQueueProducer(matchQueueCoordinator) + } + if movieQueueRepo != nil { + deps.Scanner.SetMovieQueueSyncer(movieQueueRepo) + } + if seriesQueueRepo != nil { + deps.Scanner.SetSeriesQueueSyncer(seriesQueueRepo) + } + } + if deps.Scanner != nil && matchWorker != nil && deps.FolderRepo != nil && skippedRootRepo != nil { + libraryIngestExecutor = libraryingest.NewExecutor( + deps.Scanner, + matchWorker, + deps.FolderRepo, + skippedRootRepo, + deps.EventBus, + deps.RealtimeHub, + ) + deps.LibraryIngester = libraryIngestExecutor + if deps.DB != nil { + libraryScanQueue = scanqueue.NewService( + scanqueue.NewRepository(deps.DB), + deps.FolderRepo, + libraryIngestExecutor, + deps.EventsHub, + appCtx, + cfg.Scanner.MaxConcurrentLibraries, + cfg.Scanner.MaxConcurrentScoped, + ) + libraryScanQueue.Start() + defer libraryScanQueue.Stop() + deps.LibraryScanQueue = libraryScanQueue + } + if deps.DB != nil && deps.FileRepo != nil && metadataService != nil { + itemRefreshResolver := adminjob.NewItemRefreshResolver( + itemRepo, + seasonRepo, + episodeRepo, + deps.FolderRepo, + deps.FileRepo, + ) + libraryRefreshExecutor = adminjob.NewLibraryRefreshExecutor( + adminjob.NewPGLibraryRefreshItemLister(deps.DB), + deps.FolderRepo, + itemRefreshResolver, + libraryIngestExecutor, + metadataService, + deps.EventBus, + deps.RealtimeHub, + ) + } + if metadataService != nil && deps.FileRepo != nil { + itemRefreshExecutor = adminjob.NewItemRefreshExecutor( + deps.FolderRepo, + deps.FileRepo, + rootClaimRepo, + groupClaimRepo, + skippedRootRepo, + seasonRepo, + episodeRepo, + libraryIngestExecutor, + metadataService, + deps.EventBus, + deps.RealtimeHub, + ) + } + } + + // Ensure PersonRepo is available for the router's DetailService. + if deps.DB != nil && deps.PersonRepo == nil { + deps.PersonRepo = catalog.NewPersonRepository(deps.DB) + } + + // Step 5: Create user store provider (if needed). + var userStoreProvider userstore.UserStoreProvider + if needsUserDB { + switch cfg.UserDB.Backend { + case "sqlite": + poolConfig := userdb.PoolConfig{ + MaxOpen: cfg.UserDB.PoolMaxOpen, + IdleTimeout: cfg.UserDB.IdleTimeout, + DataDir: "/var/lib/silo/userdb", + } + pool := userdb.NewUserDBPool(poolConfig) + userStoreProvider = userdb.NewSQLiteProvider(pool) + slog.Info("user store initialized", "backend", "sqlite", "max_open", poolConfig.MaxOpen) + default: // "postgres" + userStoreProvider = pgstore.NewPostgresProvider(deps.DB) + slog.Info("user store initialized", "backend", "postgres") + } + defer userStoreProvider.Close() + } + if userStoreProvider != nil && pluginService != nil { + deps.PluginUserConfig = plugins.NewUserConfigStore(userStoreProvider, pluginService) + } + + // Step 6: Create playback session manager and wire into dependencies. + sessionMgr := playback.NewSessionManager(6, 2) // defaults from plan: max_streams=6, max_transcodes=2 + if deps.DB != nil { + userRepo := auth.NewUserRepository(deps.DB) + sessionMgr.SetLimitProvider(func(ctx context.Context, userID int) (playback.SessionLimits, error) { + user, err := userRepo.GetByID(ctx, userID) + if err != nil { + return playback.SessionLimits{}, err + } + return playback.SessionLimits{ + MaxStreams: user.MaxStreams, + MaxTranscodes: user.MaxTranscodes, + }, nil + }) + } + if userStoreProvider != nil { + deps.UserStoreProvider = userStoreProvider + } + if watchProviderService != nil { + historyRepo := historyimport.NewRepository(deps.DB) + historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, catalog.NewProviderIDRepository(deps.DB)) + watchProviderService. + WithMatcher(historyimport.NewMatcher(historyRepo)). + WithWatchState(watchstate.NewService(userStoreProvider).WithStableIdentityResolver(historyIdentity)). + WithUserStoreProvider(userStoreProvider) + if err := watchProviderService.SweepOpenScrobbles(appCtx); err != nil { + slog.Warn("failed to sweep open watch provider scrobbles", "error", err) + } + } + deps.SessionMgr = sessionMgr + deps.PlaybackRealtimeHub = playback.NewRealtimeHub() + if chapterThumbService != nil && deps.S3Public != nil { + chapterThumbService.SetNotifier( + playback.NewChapterThumbnailNotifier(sessionMgr, deps.PlaybackRealtimeHub, deps.S3Public, 0), + ) + } + + // Build the reconciler early enough that playback handlers can trigger + // immediate session syncs after start/stop events. + nodeIdentity := resolveNodeIdentity() + + var reconciler *worker.Reconciler + var heartbeatWriter *worker.HeartbeatWriter + if needsWorkers && deps.DB != nil { + sessionProvider := func() []worker.SessionSync { + sessions := sessionMgr.AllSessions() + syncs := make([]worker.SessionSync, len(sessions)) + for i, s := range sessions { + syncs[i] = buildLiveSessionSync(s, nodeIdentity) + } + return syncs + } + reconciler = worker.NewReconciler(deps.DB, nodeIdentity, sessionProvider) + reconciler.EventBus = deps.EventBus + reconciler.EventsHub = deps.EventsHub + reconciler.PreSync = func() { + // Retire sessions that have not shown real playback activity + // recently enough to count as live. This keeps the in-memory + // limiter, transcode teardown, and synced admin view aligned. + if expired := sessionMgr.CleanStale(); len(expired) > 0 { + slog.Info("expired idle sessions", "count", len(expired)) + } + } + deps.SessionSyncer = reconciler + + nodeURL := fmt.Sprintf("http://%s%s", nodeIdentity, cfg.Server.Listen) + heartbeatWriter = worker.NewHeartbeatWriter(deps.DB, nodeIdentity, mode, nodeURL) + } + + if deps.DB != nil { + adminStatsProvider, statsErr := handlers.NewAdminStatsProvider(appCtx, deps.DB, deps.EventBus) + if statsErr != nil { + log.Fatalf("failed to create admin stats provider: %v", statsErr) + } + defer adminStatsProvider.Close() + deps.AdminStatsProvider = adminStatsProvider + } + + // Wire recommendations engine, worker, and ratings repo if enabled. + var recEngine *recommendations.Engine + var recWorker *recommendations.Worker + if cfg.Recommendations.Enabled && deps.DB != nil { + deps.RatingsRepo = catalog.NewRatingsRepo(deps.DB) + recEngine = recommendations.NewEngine( + deps.DB, + deps.RatingsRepo, + catalog.NewItemRepository(deps.DB), + catalog.NewPersonRepository(deps.DB), + userStoreProvider, + cfg.Recommendations, + ) + deps.Recommender = recEngine + + var err error + recWorker, err = recommendations.NewWorker( + recEngine, + cfg.Recommendations.EmbeddingsCron, + cfg.Recommendations.TasteProfilesCron, + cfg.Recommendations.CowatchCron, + cfg.Recommendations.RecommendationsCron, + ) + if err != nil { + slog.Error("failed to create recommendation worker", "error", err) + } else { + deps.RecWorker = recWorker + } + } + + // Client IP resolver with trusted proxy config. + if err := clientip.SeedDefaults(ctx, settingsRepo); err != nil { + log.Fatalf("seed clientip defaults: %v", err) + } + trustedCIDRs, err := clientip.LoadTrustedCIDRs(ctx, settingsRepo) + if err != nil { + log.Fatalf("load trusted CIDRs: %v", err) + } + ipResolver := clientip.NewResolver(trustedCIDRs) + deps.ClientIPResolver = ipResolver + + // Step 6b: Create rate limiter. + if cfg.RateLimit.Enabled && deps.DB != nil { + var perKeyLimiter, globalLimiter ratelimit.RateLimiter + isMemory := true + + if cfg.RateLimit.Backend == "redis" { + redisClient, redisErr := cache.NewRedisClient(cfg.Redis) + if redisErr != nil { + log.Fatalf("failed to create Redis client for rate limiting: %v", redisErr) + } + if redisClient != nil { + perKeyLimiter = ratelimit.NewRedisLimiter(redisClient) + globalLimiter = ratelimit.NewRedisLimiter(redisClient) + isMemory = false + defer redisClient.Close() + } + } + + if isMemory { + perKeyLimiter = ratelimit.NewMemoryLimiter() + globalLimiter = ratelimit.NewMemoryLimiter() + } + defer perKeyLimiter.Close() + defer globalLimiter.Close() + + rateLimitMW := ratelimit.NewMiddleware(perKeyLimiter, globalLimiter, settingsRepo, isMemory) + if err := rateLimitMW.Init(context.Background()); err != nil { + log.Fatalf("failed to init rate limiter: %v", err) + } + + // Subscribe for multi-instance reload (only fires if EventBus is Redis-backed) + _ = eventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) { + if event.Type == cache.EventSettingsChanged { + if reloadErr := rateLimitMW.Reload(context.Background()); reloadErr != nil { + slog.Warn("rate limit config reload from event failed", "error", reloadErr) + } + // Reload trusted proxies + cidrs, loadErr := clientip.LoadTrustedCIDRs(context.Background(), settingsRepo) + if loadErr != nil { + slog.Warn("clientip config reload failed", "error", loadErr) + } else { + ipResolver.UpdateTrustedCIDRs(cidrs) + } + } + }) + + deps.RateLimitMW = rateLimitMW + } + + // Activity log writer + consumer. + if err := activitylog.SeedDefaults(ctx, settingsRepo); err != nil { + log.Fatalf("seed activitylog defaults: %v", err) + } + + // Seed default page sections for home and existing libraries. + sectionRepo := sections.NewRepository(pool) + var folders []*models.MediaFolder + if deps.FolderRepo != nil { + var listErr error + folders, listErr = deps.FolderRepo.List(ctx) + if listErr != nil { + log.Fatalf("list libraries for section defaults: %v", listErr) + } + } + if err := sectionRepo.SeedDefaults(ctx, "home", nil, sections.DefaultHomeSections(folders)); err != nil { + log.Fatalf("seed home section defaults: %v", err) + } + if deps.FolderRepo != nil { + for _, f := range folders { + id := f.ID + if seedErr := sectionRepo.SeedDefaults(ctx, "library", &id, sections.DefaultLibrarySectionsForType(&id, f.Type)); seedErr != nil { + slog.Warn("seed library section defaults", "library_id", id, "error", seedErr) + } + } + } + activityPM := partman.NewManager(pool, "activity_log", partman.Weekly, 2) + if err := activityPM.EnsureFuturePartitions(appCtx); err != nil { + log.Fatalf("ensure activity log partitions: %v", err) + } + var activityWriter activitylog.Writer + activityConsumer := activitylog.NewConsumer(pool, nil, logStreamHub) + + if cfg.Redis.URL != "" { + actRedisClient, actRedisErr := cache.NewRedisClient(cfg.Redis) + if actRedisErr == nil && actRedisClient != nil { + activityWriter = activitylog.NewRedisWriter(actRedisClient) + activityConsumer = activitylog.NewConsumer(pool, actRedisClient, logStreamHub) + go activityConsumer.RunRedis(appCtx) + defer actRedisClient.Close() + } + } + + if activityWriter == nil { + memWriter := activitylog.NewMemoryWriter(10000) + activityWriter = memWriter + go activityConsumer.RunMemory(appCtx, memWriter.Chan()) + } + deps.ActivityLogWriter = activityWriter + deps.ActivityLogRepo = activitylog.NewRepo(pool) + deps.NodeID = nodeID + + // Create refresh worker early so the task manager can use it for FindCandidates. + var refreshWorker *worker.RefreshWorker + var personRefreshWorker *worker.PersonRefreshWorker + if needsWorkers && deps.DB != nil { + refreshWorker = worker.NewRefreshWorker(deps.DB) + if deps.PersonRefreshQueue != nil { + personRefreshWorker, _ = deps.PersonRefreshQueue.(*worker.PersonRefreshWorker) + } + } + + // Construct collection service for both the router and the collection sync scheduler. + var collectionSyncScheduler *catalog.CollectionSyncScheduler + var userCollectionScheduler *usercollections.Scheduler + if needsWorkers && deps.DB != nil { + collectionRepo := catalog.NewLibraryCollectionRepository(deps.DB) + collItemRepo := catalog.NewItemRepository(deps.DB) + libraryItemRepo := catalog.NewLibraryItemRepository(deps.DB) + collectionService := catalog.NewLibraryCollectionService(collectionRepo, collItemRepo, libraryItemRepo, nil) + collectionService.TMDBCollections = api.NewTMDBCollectionFetcher(cfg.TMDBAPIKey) + deps.CollectionService = collectionService + collectionSyncScheduler = catalog.NewCollectionSyncScheduler(collectionRepo, collectionService, slog.Default()) + + if deps.UserStoreProvider != nil { + userSync := usercollections.NewService(deps.UserStoreProvider, collItemRepo, libraryItemRepo, nil, slog.Default()) + userSync.TMDBCollections = collectionService.TMDBCollections + // Trakt fetchers are wired in router.go (they need settingsRepo); + // router.go propagates them onto userSync once configured. + userCollectionScheduler = usercollections.NewScheduler(deps.DB, userSync, slog.Default()) + deps.UserCollectionSync = userSync + deps.UserCollectionScheduler = userCollectionScheduler + deps.MDBListClient = mdblist.NewClient(cfg.MDBListAPIKey, nil) + } + } + + // Wire up task manager for admin task API. + if needsWorkers && deps.DB != nil { + triggerRepo := taskrepository.NewPgTriggerRepository(deps.DB) + historyRepo := taskrepository.NewPgExecutionRepository(deps.DB) + taskMgr := taskmanager.New(triggerRepo, historyRepo, triggers.New, slog.Default()) + if deps.EventsHub != nil { + taskMgr.AddObserver(evt.NewTaskObserver(deps.EventsHub)) + } + + if deps.FolderRepo != nil && deps.LibraryScanQueue != nil { + taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus)) + } + if deps.IntroAnalyzer != nil { + taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo)) + } + if chapterBackfiller, ok := deps.ChapterThumbnailQueuer.(*chapterthumbs.Service); ok { + taskMgr.Register(tasks.NewChapterThumbnailBackfillTask(chapterBackfiller, 25)) + } + taskMgr.Register(tasks.NewActivityLogCleanupTask(deps.DB, settingsRepo, activityPM)) + taskMgr.Register(tasks.NewOperationalLogCleanupTask(deps.DB, settingsRepo, opsPM)) + if matchWorker != nil { + taskMgr.Register(tasks.NewMatchMediaTask(matchWorker)) + } + if refreshWorker != nil && metadataService != nil { + taskMgr.Register(tasks.NewRefreshMetadataTask(refreshWorker, metadataService)) + } + if pluginAutoUpdater != nil { + taskMgr.Register(tasks.NewCheckPluginUpdatesTask(pluginAutoUpdater)) + } + if collectionSyncScheduler != nil { + taskMgr.Register(tasks.NewSyncCollectionsTask(collectionSyncScheduler)) + } + if userCollectionScheduler != nil { + taskMgr.Register(tasks.NewSyncUserCollectionsTask(userCollectionScheduler)) + } + if watchProviderService != nil { + taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService)) + } + reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB) + reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB) + historyResolver := watchstate.NewStableIdentityResolver(nil, reconcileEpisodeRepo, reconcileProviderIDRepo) + historyReconciler := watchstate.NewHistoryReconciler(deps.DB, historyResolver) + taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler)) + taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler)) + if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil { + pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx) + if err != nil { + log.Fatalf("plugin task registry: %v", err) + } + for _, pluginTask := range pluginTasks { + taskMgr.Register(pluginTask) + } + } + + taskMgr.Start(appCtx) + defer taskMgr.Stop() + deps.TaskManager = taskMgr + slog.Info("task manager started") + } + + if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil { + userRepo := auth.NewUserRepository(deps.DB) + sessionRepo := auth.NewSessionRepository(deps.DB) + authBindings, err := pluginRuntimeConfigStore.ListAuthBindings(appCtx) + if err != nil { + log.Fatalf("list plugin auth bindings: %v", err) + } + for _, binding := range authBindings { + if binding == nil || !binding.Enabled { + continue + } + installation, err := pluginInstallationStore.GetByID(appCtx, binding.InstallationID) + if err != nil { + log.Fatalf("load plugin auth installation %d: %v", binding.InstallationID, err) + } + if !installation.Enabled { + continue + } + displayName := binding.CapabilityID + mode := "credentials" + iconURL := "" + capabilities, err := pluginInstallationStore.ListCapabilities(appCtx, binding.InstallationID) + if err == nil { + for _, capability := range capabilities { + if capability != nil && capability.Type == "auth_provider.v1" && capability.ID == binding.CapabilityID { + if name, ok := capability.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" { + displayName = name + } + // auth_modes ["oauth2"] flips the login button into + // an OAuth-style "Sign in with X" path. Mode is "oauth" + // when oauth2 is the only declared mode; "credentials" + // when password is supported alongside or alone. + if rawModes, ok := capability.Metadata["auth_modes"].([]any); ok { + hasPassword := false + hasOAuth := false + for _, m := range rawModes { + switch m { + case "password": + hasPassword = true + case "oauth2": + hasOAuth = true + } + } + if hasOAuth && !hasPassword { + mode = "oauth" + } + } + if url, ok := capability.Metadata["icon_url"].(string); ok { + iconURL = url + } + break + } + } + } + + // Generic OIDC and similar multi-instance plugins ship one binary + // but install once per IdP. Their admin SPA writes display_name + // + icon_url_path to runtime config so each install renders its + // own brand on the login page. Manifest values are the fallback. + if runtimeConfigs, err := pluginRuntimeConfigStore.ListGlobalConfigs(appCtx, binding.InstallationID); err == nil { + for _, rc := range runtimeConfigs { + switch rc.Key { + case "display_name": + if v, ok := rc.Value["value"].(string); ok && strings.TrimSpace(v) != "" { + displayName = v + } + case "icon_url_path": + if v, ok := rc.Value["value"].(string); ok && strings.TrimSpace(v) != "" { + iconURL = fmt.Sprintf("/api/v1/plugins/%d/assets/%s", binding.InstallationID, strings.TrimLeft(v, "/")) + } + } + } + } + + deps.AuthProviders = append(deps.AuthProviders, auth.RegisteredProvider{ + Info: auth.LoginProviderInfo{ + ID: fmt.Sprintf("plugin:%d:%s", binding.InstallationID, binding.CapabilityID), + DisplayName: displayName, + Mode: mode, + Default: binding.DefaultLogin, + IconURL: iconURL, + InstallationID: binding.InstallationID, + }, + Provider: auth.NewPluginProvider( + auth.PluginProviderConfig{ + InstallationID: binding.InstallationID, + CapabilityID: binding.CapabilityID, + DisplayName: displayName, + AutoProvision: binding.AutoProvision, + }, + sessionRepo, + userRepo, + deps.DB, + deps.PluginService, + ), + }) + } + } + + // Step 7: Build HTTP router with all dependencies. + // compatServer is populated after the compat server is constructed below; + // the closure captures the pointer so revocation calls reach the live instance. + var compatServer *jellycompat.Server + deps.OnUserSessionsRevoked = func(ctx context.Context, userID int) { + if compatServer != nil { + compatServer.SessionStore().DeleteByUserID(userID) + } + } + router := api.NewRouter(deps) + + // Step 8: Expose Prometheus metrics endpoint (not behind auth). + distFS, fsErr := fs.Sub(siloweb.DistFS, "dist") + if fsErr != nil { + log.Fatalf("failed to create frontend FS: %v", fsErr) + } + server.WebDistFS = distFS + + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", promhttp.Handler()) + metricsMux.Handle("/api/", router) + metricsMux.Handle("/", server.FrontendHandler()) + + // Step 9: Start background workers (if needed). + var sessionCleaner *worker.SessionCleaner + var adminJobRunner *adminjob.Runner + + if needsWorkers && deps.DB != nil { + if reconciler == nil { + log.Fatal("reconciler must be initialized before starting workers") + } + reconciler.Start() + defer reconciler.Stop() + + if heartbeatWriter != nil { + heartbeatWriter.Start() + defer heartbeatWriter.Stop() + } + + // RefreshWorker is kept as a RefreshCandidateFinder for the task manager's + // RefreshMetadataTask but no longer runs its own background loop. + // Scanning is handled exclusively by the task manager's ScanLibrariesTask. + if personRefreshWorker != nil { + personRefreshWorker.Start() + defer personRefreshWorker.Stop() + } + + sessionCleaner = worker.NewSessionCleaner(deps.DB, cfg.UserDB.StaleGraceSeconds) + sessionCleaner.EventBus = deps.EventBus + sessionCleaner.EventsHub = deps.EventsHub + sessionCleaner.Start() + defer sessionCleaner.Stop() + + adminJobRunner = adminjob.NewRunner( + adminjob.NewRepository(deps.DB), + catalogseed.NewService(deps.DB, catalog.NewPersonRepository(deps.DB), recommendations.NewRepo(deps.DB)), + deps.S3Private, + itemRefreshExecutor, + libraryRefreshExecutor, + adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo), + adminjob.NewImageCacheCleanupExecutor(deps.S3Public), + deps.RealtimeHub, + ) + adminJobRunner.Start() + defer adminJobRunner.Stop() + + // Start recommendation worker if enabled (reuse worker created above). + if recWorker != nil { + recWorker.Start() + defer recWorker.Stop() + + // Check if this is first run (no embeddings yet). + embCount, _ := recommendations.NewRepo(deps.DB).EmbeddingCount(appCtx) + if embCount == 0 { + slog.Info("first run detected, triggering initial embedding") + recWorker.RunEmbeddingsNow() + } + } + + slog.Info("background workers started") + } + + // Step 10: Create and start the HTTP server. + srv := &http.Server{ + Addr: cfg.Server.Listen, + Handler: metricsMux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + } + + var compatSrv *http.Server + if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" { + compatDeps := jellycompat.Dependencies{ + Config: cfg, + DB: deps.DB, + ClientIPResolver: ipResolver, + ProxyPool: deps.ProxyPool, + TranscodePool: deps.TranscodePool, + JWTSecret: cfg.Auth.JWTSecret, + RecWorker: recWorker, + } + + // Wire direct dependencies when DB is available. + if deps.DB != nil { + browseRepo := catalog.NewBrowseRepository(deps.DB) + itemRepo := catalog.NewItemRepository(deps.DB) + seasonRepo := catalog.NewSeasonRepository(deps.DB) + episodeRepo := catalog.NewEpisodeRepository(deps.DB) + providerIDRepo := catalog.NewProviderIDRepository(deps.DB) + personRepo := catalog.NewPersonRepository(deps.DB) + folderRepo := deps.FolderRepo + + var fileFetcher catalog.FileVersionFetcher + if deps.FileRepo != nil { + fileFetcher = deps.FileRepo + } + + detailSvc := catalog.NewDetailService(itemRepo, episodeRepo, seasonRepo, personRepo, fileFetcher) + detailSvc.SetFolderRepository(folderRepo) + detailSvc.SetGroupClaimRepository(catalog.NewGroupClaimRepository(deps.DB)) + detailSvc.SetProbeEnsurer(deps.ProbeEnsurer) + detailSvc.SetChapterThumbnailQueuer(deps.ChapterThumbnailQueuer) + if deps.ImageResolver != nil { + detailSvc.SetImageResolver(deps.ImageResolver) + } + + compatDeps.BrowseRepo = browseRepo + compatDeps.ItemRepo = itemRepo + compatDeps.SeasonRepo = seasonRepo + compatDeps.EpisodeRepo = episodeRepo + compatDeps.ProviderIDRepo = providerIDRepo + compatDeps.DetailSvc = detailSvc + compatDeps.FolderRepo = folderRepo + compatDeps.SessionMgr = sessionMgr + compatDeps.UserStoreProvider = userStoreProvider + compatDeps.SettingsRepo = settingsRepo + compatDeps.PersonRepo = personRepo + + if deps.S3Public != nil { + compatDeps.PosterPresigner = deps.S3Public + compatDeps.S3Client = deps.S3Public + compatDeps.S3Bucket = deps.S3Public.Bucket() + } + + if deps.FileRepo != nil { + compatDeps.FileResolver = deps.FileRepo + } + + compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB) + + // Construct auth service for jellycompat login. + userRepo := auth.NewUserRepository(deps.DB) + sessionRepo := auth.NewSessionRepository(deps.DB) + jwtService := auth.NewJWTService( + cfg.Auth.JWTSecret, + cfg.Auth.AccessTokenExpiry, + cfg.Auth.RefreshTokenExpiry, + ) + provider := auth.NewLocalProvider(userRepo, sessionRepo) + compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil) + + // Access filter resolver for profile-scoped library access. + compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter { + if userStoreProvider == nil { + return catalog.AccessFilter{} + } + store, err := userStoreProvider.ForUser(ctx, userID) + if err != nil { + return catalog.AccessFilter{} + } + profile, err := store.GetProfile(ctx, profileID) + if err != nil || profile == nil { + return catalog.AccessFilter{} + } + filter := catalog.AccessFilter{ + MaxContentRating: profile.MaxContentRating, + } + if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 { + filter.AllowedLibraryIDs = profile.AllowedLibraryIDs + } + // Apply user-disabled library IDs. + if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" { + var disabled []int + if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 { + if filter.AllowedLibraryIDs != nil { + filtered := make([]int, 0, len(filter.AllowedLibraryIDs)) + disSet := make(map[int]struct{}, len(disabled)) + for _, id := range disabled { + disSet[id] = struct{}{} + } + for _, id := range filter.AllowedLibraryIDs { + if _, ok := disSet[id]; !ok { + filtered = append(filtered, id) + } + } + filter.AllowedLibraryIDs = filtered + } else { + filter.DisabledLibraryIDs = disabled + } + } + } + return filter + } + } + + compat := jellycompat.NewServerWithDependencies(compatDeps) + compatServer = compat + compat.StartBackgroundTasks(context.Background()) + compatSrv = compat.HTTPServer() + compatSrv.ReadTimeout = 30 * time.Second + compatSrv.WriteTimeout = 0 + compatSrv.IdleTimeout = 120 * time.Second + } + + errCh := make(chan error, 2) + go func() { + slog.Info("HTTP server listening", "addr", cfg.Server.Listen) + if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed { + errCh <- fmt.Errorf("HTTP server error: %w", listenErr) + } + }() + if compatSrv != nil { + go func() { + slog.Info("Jellyfin compat server listening", "addr", compatSrv.Addr) + if listenErr := compatSrv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed { + errCh <- fmt.Errorf("jellyfin compat server error: %w", listenErr) + } + }() + } + + // Step 11: Wait for termination signal. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + defer signal.Stop(sigCh) + + select { + case sig := <-sigCh: + appCancel() + slog.Info("received signal, shutting down", "signal", sig) + case serverErr := <-errCh: + appCancel() + slog.Error("server error, shutting down", "error", serverErr) + } + + // Step 12: Graceful shutdown sequence. + slog.Info("beginning graceful shutdown") + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + // 1. Stop accepting new requests. + if shutdownErr := srv.Shutdown(shutdownCtx); shutdownErr != nil { + slog.Error("HTTP shutdown error", "error", shutdownErr) + } + if compatSrv != nil { + if shutdownErr := compatSrv.Shutdown(shutdownCtx); shutdownErr != nil { + slog.Error("jellyfin compat shutdown error", "error", shutdownErr) + } + } + + // 2. Clean up stale sessions. + if sessionCleaner != nil { + cleaned, cleanErr := sessionCleaner.CleanStale(shutdownCtx) + if cleanErr != nil { + slog.Error("stale session cleanup error", "error", cleanErr) + } else if cleaned > 0 { + slog.Info("cleaned stale sessions", "count", cleaned) + } + } + + // 2b. Remove this node's heartbeat and sessions from shared state. + if heartbeatWriter != nil { + if err := heartbeatWriter.CleanupSelf(shutdownCtx); err != nil { + slog.Error("heartbeat cleanup error", "error", err) + } + } + + // 3. Close user store provider. + if userStoreProvider != nil { + if closeErr := userStoreProvider.Close(); closeErr != nil { + slog.Error("user store provider close error", "error", closeErr) + } + } + + // 4. (match worker is now managed by the task manager — no separate cancel needed) + + // Suppress unused variable warnings for workers used only in deferred calls. + _ = reconciler + _ = heartbeatWriter + _ = refreshWorker + _ = adminJobRunner + + slog.Info("server stopped") +} + +// startStandaloneServer runs a standalone HTTP server for proxy/transcode modes. +// It listens on the given address, handles graceful shutdown on SIGTERM/SIGINT. +func startStandaloneServer(addr string, handler http.Handler) { + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 30 * time.Second, + WriteTimeout: 0, // no timeout for long streams + IdleTimeout: 120 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + slog.Info("HTTP server listening", "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- fmt.Errorf("HTTP server error: %w", err) + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + + select { + case sig := <-sigCh: + slog.Info("received signal, shutting down", "signal", sig) + case serverErr := <-errCh: + slog.Error("server error, shutting down", "error", serverErr) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("HTTP shutdown error", "error", err) + } + slog.Info("server stopped") +} + +// newS3ClientIfConfigured creates an S3 client only if the bucket name is +// configured. Returns nil if the bucket is empty (not configured). +func newS3ClientIfConfigured(cfg s3client.BucketConfig) *s3client.Client { + if cfg.Bucket == "" { + return nil + } + return s3client.NewClient(cfg) +} + +func configureS3Clients(cfg *config.Config, deps *api.Dependencies) { + if s3Public := newS3ClientIfConfigured(s3client.BucketConfig{ + Endpoint: cfg.S3.Public.Endpoint, + PublicEndpoint: cfg.S3.Public.ReadEndpoint, + Region: cfg.S3.Public.Region, + Bucket: cfg.S3.Public.Bucket, + KeyPrefix: cfg.S3.Public.KeyPrefix, + AccessKey: cfg.S3.Public.AccessKey, + SecretKey: cfg.S3.Public.SecretKey, + PathStyle: cfg.S3.Public.PathStyle, + URLAuth: cfg.S3.Public.URLAuth, + TokenSecret: cfg.S3.Public.TokenSecret, + TokenParam: cfg.S3.Public.TokenParam, + TokenTTL: cfg.S3.Public.TokenTTL, + }); s3Public != nil { + deps.S3Public = s3Public + slog.Info("S3 public assets client configured", "bucket", s3Public.Bucket()) + + // Allow browsers to fetch presigned client-facing assets directly from S3. + // Skip for public/token auth (e.g. Cloudflare R2) where CORS is managed externally. + if !s3Public.UsesExternalAuth() { + corsCtx, corsCancel := context.WithTimeout(context.Background(), 10*time.Second) + if corsErr := s3Public.SetBucketCORS(corsCtx, s3Public.Bucket(), []string{"*"}); corsErr != nil { + slog.Warn("failed to set CORS on public assets bucket", "error", corsErr) + } + corsCancel() + } + } + + if s3Private := newS3ClientIfConfigured(s3client.BucketConfig{ + Endpoint: cfg.S3.Private.Endpoint, + Region: cfg.S3.Private.Region, + Bucket: cfg.S3.Private.Bucket, + KeyPrefix: cfg.S3.Private.KeyPrefix, + AccessKey: cfg.S3.Private.AccessKey, + SecretKey: cfg.S3.Private.SecretKey, + PathStyle: cfg.S3.Private.PathStyle, + }); s3Private != nil { + deps.S3Private = s3Private + slog.Info("S3 private internal client configured", "bucket", s3Private.Bucket()) + if !s3Private.UsesExternalAuth() { + corsCtx, corsCancel := context.WithTimeout(context.Background(), 10*time.Second) + if corsErr := s3Private.SetBucketCORS(corsCtx, s3Private.Bucket(), []string{"*"}); corsErr != nil { + slog.Warn("failed to set CORS on private assets bucket", "error", corsErr) + } + corsCancel() + } + } + + if s3UserDB := newS3ClientIfConfigured(s3client.BucketConfig{ + Endpoint: cfg.S3.UserDB.Endpoint, + Region: cfg.S3.UserDB.Region, + Bucket: cfg.S3.UserDB.Bucket, + KeyPrefix: cfg.S3.UserDB.KeyPrefix, + AccessKey: cfg.S3.UserDB.AccessKey, + SecretKey: cfg.S3.UserDB.SecretKey, + PathStyle: cfg.S3.UserDB.PathStyle, + }); s3UserDB != nil { + deps.S3UserDB = s3UserDB + slog.Info("S3 user-db client configured", "bucket", s3UserDB.Bucket()) + } +} + +// mapFolderTypeToMediaType maps silo's MediaFolder.Type values +// ("movies", "series", "mixed") to the SDK's MediaType values +// ("movie", "tv", "mixed"). Unknown values map to "mixed". +func mapFolderTypeToMediaType(t string) string { + switch t { + case "movies": + return "movie" + case "series": + return "tv" + default: + return "mixed" + } +} diff --git a/cmd/silo/main_test.go b/cmd/silo/main_test.go new file mode 100644 index 00000000..d5419357 --- /dev/null +++ b/cmd/silo/main_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/Silo-Server/silo-server/internal/api" + "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/playback" +) + +func TestConfigureS3Clients_SetsCORSOnPublicAssetsBucket(t *testing.T) { + publicServer := newS3BucketRecorder(t) + + cfg := &config.Config{ + S3: config.S3Config{ + Public: config.S3PublicAssetsSettings{ + S3BucketSettings: config.S3BucketSettings{ + Endpoint: publicServer.URL(), + Region: "us-east-1", + Bucket: "public-assets", + AccessKey: "test", + SecretKey: "test", + PathStyle: true, + }, + }, + }, + } + + deps := &api.Dependencies{} + configureS3Clients(cfg, deps) + + if deps.S3Public == nil { + t.Fatal("S3Public should be configured") + } + if got := publicServer.CORSRequests(); got != 1 { + t.Fatalf("public assets bucket CORS requests = %d, want 1", got) + } +} + +func TestConfigureS3Clients_PassesPublicKeyPrefix(t *testing.T) { + publicServer := newS3BucketRecorder(t) + + cfg := &config.Config{ + S3: config.S3Config{ + Public: config.S3PublicAssetsSettings{ + S3BucketSettings: config.S3BucketSettings{ + Endpoint: publicServer.URL(), + Region: "us-east-1", + Bucket: "public-assets", + KeyPrefix: "silo/dev", + AccessKey: "test", + SecretKey: "test", + PathStyle: true, + }, + }, + }, + } + + deps := &api.Dependencies{} + configureS3Clients(cfg, deps) + + if deps.S3Public == nil { + t.Fatal("S3Public should be configured") + } + + url, err := deps.S3Public.PublicURL(deps.S3Public.Bucket(), "catalog-seeds/export.json.gz") + if err != nil { + t.Fatalf("PublicURL() returned error: %v", err) + } + if !strings.Contains(url, "/silo/dev/catalog-seeds/export.json.gz") { + t.Fatalf("PublicURL() = %q, want prefixed path", url) + } +} + +type s3BucketRecorder struct { + server *httptest.Server + mu sync.Mutex + corsRequests int +} + +func newS3BucketRecorder(t *testing.T) *s3BucketRecorder { + t.Helper() + + recorder := &s3BucketRecorder{} + recorder.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + if r.Method == http.MethodPut && r.URL.Query().Has("cors") { + recorder.mu.Lock() + recorder.corsRequests++ + recorder.mu.Unlock() + } + + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(recorder.server.Close) + + return recorder +} + +func (r *s3BucketRecorder) URL() string { + return r.server.URL +} + +func (r *s3BucketRecorder) CORSRequests() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.corsRequests +} + +func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + session playback.Session + want string + }{ + { + name: "transcode transport remains transcode when base method is remux", + session: playback.Session{ + ID: "session-1", + UserID: 7, + ProfileID: "profile-1", + MediaFileID: 42, + RequestedMediaFileID: 41, + PlayMethod: playback.PlayTranscode, + BasePlayMethod: playback.PlayRemux, + }, + want: "transcode", + }, + { + name: "remux transport stays remux", + session: playback.Session{ + ID: "session-2", + UserID: 8, + ProfileID: "profile-2", + MediaFileID: 99, + RequestedMediaFileID: 99, + PlayMethod: playback.PlayRemux, + BasePlayMethod: playback.PlayRemux, + }, + want: "remux", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := buildLiveSessionSync(&tc.session, "node-a") + if got.PlayMethod != tc.want { + t.Fatalf("PlayMethod = %q, want %q", got.PlayMethod, tc.want) + } + if got.ReportingNode != "node-a" { + t.Fatalf("ReportingNode = %q, want %q", got.ReportingNode, "node-a") + } + if got.SessionID != tc.session.ID { + t.Fatalf("SessionID = %q, want %q", got.SessionID, tc.session.ID) + } + }) + } +} diff --git a/cmd/silo/session_sync.go b/cmd/silo/session_sync.go new file mode 100644 index 00000000..08d699d8 --- /dev/null +++ b/cmd/silo/session_sync.go @@ -0,0 +1,39 @@ +package main + +import ( + "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/worker" +) + +// buildLiveSessionSync converts an in-memory playback session into the shared +// live admin session snapshot. For live admin views, play_method tracks the +// current transport method rather than the preserved semantic/base method used +// by player-facing responses and playback history. +func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.SessionSync { + if s == nil { + return worker.SessionSync{ReportingNode: reportingNode} + } + + return worker.SessionSync{ + SessionID: s.ID, + UserID: s.UserID, + ProfileID: s.ProfileID, + MediaFileID: s.MediaFileID, + RequestedMediaFileID: s.RequestedMediaFileID, + PlayMethod: string(s.PlayMethod), + ReportingNode: reportingNode, + ClientIP: s.ClientIP, + AudioTrackIndex: s.AudioTrackIndex, + TranscodeAudio: s.TranscodeAudio, + StreamBitrateKbps: s.StreamBitrateKbps, + TranscodeNodeURL: s.TranscodeNodeURL, + TargetResolution: s.TargetResolution, + TargetVideoCodec: s.TargetVideoCodec, + TargetAudioCodec: s.TargetAudioCodec, + TargetBitrateKbps: s.TargetBitrateKbps, + StartedAt: s.StartedAt, + UpdatedAt: s.UpdatedAt, + IsPaused: s.IsPaused, + HasWebSocket: s.HasWebSocket, + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..7bcf5f71 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,109 @@ +services: + postgres: + image: pgvector/pgvector:pg18 + restart: unless-stopped + shm_size: 1gb + environment: + POSTGRES_USER: ${POSTGRES_USER:-silo} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo} + POSTGRES_DB: ${POSTGRES_DB:-silo} + ports: + - "5432:5432" + volumes: + - ${SILO_DATA_ROOT:-/opt/silo}/postgres:/var/lib/postgresql + - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro + command: postgres -c config_file=/etc/postgresql/postgresql.conf + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-silo}"] + interval: 5s + timeout: 3s + retries: 5 + + redis: + image: redis:alpine + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - ${SILO_DATA_ROOT:-/opt/silo}/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + silo: + image: ${SILO_IMAGE:-ghcr.io/silo-server/silo-server:latest} + restart: unless-stopped + environment: + MODE: integrated + DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable + REDIS_URL: redis://redis:6379 + SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins + ports: + - "${PORT:-8090}:8080" + - "${JF_PORT:-8096}:8096" + volumes: + - ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro + - ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins + - ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode + - ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - path: .env + required: false + + # Optional distributed-mode example. Most single-host installs should + # just use the integrated service above. + # silo-proxy: + # image: ${SILO_IMAGE:-ghcr.io/silo-server/silo-server:latest} + # restart: unless-stopped + # profiles: [proxy] + # environment: + # MODE: proxy + # DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable + # REDIS_URL: redis://redis:6379 + # PORT: "8080" + # ports: + # - "${PROXY_PORT:-8083}:8080" + # volumes: + # - ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro + # depends_on: + # postgres: + # condition: service_healthy + # redis: + # condition: service_healthy + # env_file: + # - path: .env + # required: false + + # Optional distributed-mode example. Most single-host installs should + # just use the integrated service above. + # silo-transcode: + # image: ${SILO_IMAGE:-ghcr.io/silo-server/silo-server:latest} + # restart: unless-stopped + # profiles: [transcode] + # environment: + # MODE: transcode + # DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable + # REDIS_URL: redis://redis:6379 + # SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins + # PORT: "8080" + # ports: + # - "${TRANSCODE_PORT:-8082}:8080" + # volumes: + # - ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro + # - ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins + # - ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode + # depends_on: + # postgres: + # condition: service_healthy + # redis: + # condition: service_healthy + # env_file: + # - path: .env + # required: false diff --git a/docs/SECURITY_AND_CODE_REVIEW.md b/docs/SECURITY_AND_CODE_REVIEW.md new file mode 100644 index 00000000..9c669b45 --- /dev/null +++ b/docs/SECURITY_AND_CODE_REVIEW.md @@ -0,0 +1,7 @@ +# Security and Code Review Notes + +This file previously contained an internal security audit with actionable findings and operational details. + +That material was retired before the repo was made public. Use [SECURITY.md](../SECURITY.md) for private vulnerability reporting and the public docs in `README.md`, `CONTRIBUTING.md`, and `SUPPORT.md` for contributor guidance. + +If you need a public launch checklist, start with the current docs and workflows rather than relying on this historical audit. diff --git a/docs/continuum-to-silo-docker-migration.md b/docs/continuum-to-silo-docker-migration.md new file mode 100644 index 00000000..75624ca4 --- /dev/null +++ b/docs/continuum-to-silo-docker-migration.md @@ -0,0 +1,128 @@ +# Continuum to Silo Docker Migration + +This guide is for admins who already run a Docker Compose based Continuum install +and want to switch that host to the Silo repository without rebuilding their +library from scratch. + +## Best Practice + +Treat the rename as a controlled cutover, not an in-place edit while the old app +is running: + +1. Take a host snapshot or a PostgreSQL dump. +2. Run the migration helper in read-only `check` mode. +3. Fix `.env` until the check output matches the old deployment. +4. Run the cutover during a quiet window. +5. Verify health, readiness, media paths, users, and plugins before deleting any + old compatibility symlink or backup. + +The migration helper is intentionally conservative. Its default mode is +read-only, and mutating modes require `--apply`. + +## Prepare `.env` + +From a Silo checkout: + +```sh +cp .env.example .env +``` + +Set these values before migrating: + +```dotenv +MEDIA_ROOT=/host/path/to/media +MEDIA_CONTAINER_ROOT=/old/container/path/to/media +SILO_DATA_ROOT=/opt/silo +POSTGRES_USER=continuum +POSTGRES_PASSWORD=continuum +POSTGRES_DB=continuum +``` + +`MEDIA_CONTAINER_ROOT` matters for existing installs. New Silo installs can use +`/mnt/media`, but migrated libraries may already store absolute paths from the +old container. Preserve the old in-container path here if existing library paths +use it. + +If your old database used different PostgreSQL credentials, keep those exact +values. Do not switch to the Silo defaults unless you are starting with a fresh +database. + +## Run The Preflight + +```sh +scripts/migrate-continuum-docker.sh check +``` + +Common overrides: + +```sh +CONTINUUM_DATA_ROOT=/srv/continuum \ +SILO_DATA_ROOT=/srv/silo \ +scripts/migrate-continuum-docker.sh check +``` + +If the old stack does not use obvious `continuum-*` container names, point the +script at the old compose file: + +```sh +CONTINUUM_COMPOSE_FILE=/path/to/old/docker-compose.yml \ +CONTINUUM_ENV_FILE=/path/to/old/.env \ +scripts/migrate-continuum-docker.sh check +``` + +## Cut Over + +After reviewing the preflight output: + +```sh +sudo scripts/migrate-continuum-docker.sh migrate --apply +``` + +The migration mode: + +- creates a `pg_dump` backup unless `--skip-db-dump` is provided +- stops the old Continuum compose stack or detected `continuum-*` containers +- moves `CONTINUUM_DATA_ROOT` to `SILO_DATA_ROOT` +- leaves a compatibility symlink from the old data root to the new one unless + `--no-compat-symlink` is provided +- starts the Silo compose stack +- updates plugin cache paths from `/var/lib/continuum/plugins` to + `/var/lib/silo/plugins` +- disables old `continuum.*` plugin installations and the old plugin catalog + +Old Continuum plugin binaries are disabled because they may not be compatible +with the Silo plugin runtime. Install Silo-native plugin packages from the admin +UI after the server is healthy. + +## Verify + +```sh +docker compose ps +curl -fsS http://localhost:8090/api/v1/health +curl -fsS http://localhost:8090/api/v1/ready +docker compose logs --tail=200 silo +``` + +In the admin UI, verify: + +- admin login works +- libraries point at paths visible inside the Silo container +- users and profiles are present +- metadata plugins are installed and provider chains are configured +- playback starts for at least one direct-play item and one transcode/remux item + +## DB Fix Only + +If you already moved data and started Silo manually, run just the compatibility +SQL against the current Silo compose stack: + +```sh +scripts/migrate-continuum-docker.sh db-fix --apply +``` + +## Rollback + +Prefer restoring the host snapshot. If no snapshot is available, stop Silo, +restore the PostgreSQL dump created under the migration backup directory, and +move the data root back to the old location. Keep the backup until you have +verified library scans, metadata refreshes, and playback under Silo. diff --git a/docs/design/devices-redesign.html b/docs/design/devices-redesign.html new file mode 100644 index 00000000..afdb578e --- /dev/null +++ b/docs/design/devices-redesign.html @@ -0,0 +1,1265 @@ + + + + + + Devices — Silo admin (redesign) + + + + + + +
+ +
+ + +
+
+

Devices.

+

+ Inspect, tune, and reset per-profile playback overrides across the fleet. + Filter by user, platform, or override pattern. Press ⌘K to jump anywhere. +

+
+
+ +
+
+ + +
+
+
9users
+
28devices
+
33profiles
+
85overrides
+
3anomalies
+
+ +
+ override density · 28 devices +
+
+ +
+
+ view + + + +
+
+ group by + + + +
+
+
+ + +
+ + + + + + + + +
+
+
+
+ +
+
+
Apple TV 4K
+
+ 973df8c5-7ada-4896-8d7b-5120b1fa04e8 + tvOS 17.4 + admin + live · 2h +
+
+
+
+ + +
+
+ + +
+
+
Overrides
+
11 / 24 fields
+
across 3 profiles
+
+
+
Profiles touched
+
admin · Test · aaa
+
7 / 2 / 2 overrides
+
+
+
Anomaly
+
Profile 7 fallback
+
no other tvOS device disables it
+
+
+
Last update
+
2d 04h
+
subtitle_appearance · admin
+
+
+ + +
+ + + +
+ +
+ 8bf465bc — 3a0b — 4cca — 87b9 — 4a1473890be6 · 7 overrides + + + Reset profile overrides + +
+ +
+ +
+
+
Profile 7 HDR10 fallback anomaly
+
player.dv_profile7_hdr10_fallback
+
Play Dolby Vision Profile 7 as the HDR10 base layer on this device.
+
Disabledfleet default Enabledlast 2d ago
+
+
+ + reset +
+
+ + +
+
+
Auto-play next episode
+
playback.auto_play_next
+
Start the next episode automatically on this device after the current one ends.
+
Enabledmatches defaultlast 11d ago
+
+
+ + reset +
+
+ + +
+
+
Subtitle appearance
+
subtitle_appearance
+
Customize subtitles on this device — font, size, edge style, and background. Stored as a JSON document; edit inline for fine-grained control.
+
Custom5 fields differ from defaultlast 2d ago
+
+
+ + reset +
+
+ + +
+
+
Playback speed
+
player.playback_speed
+
Choose the default playback speed for this device.
+
1×matches defaultlast 18d ago
+
+
+ + reset +
+
+ + +
+
+
Video fit
+
player.video_gravity
+
Control how video should fill the screen on this device.
+
Fitmatches defaultlast 14d ago
+
+
+ + reset +
+
+ + +
+
+
Preferred audio language
+
playback.audio_language
+
Choose which spoken language Silo should prefer first on this device.
+
Englishfleet default Autolast 7d ago
+
+
+ + reset +
+
+ + +
+
+
Preferred quality
+
playback.preferred_quality
+
Pick the quality Silo should prefer on this device for the current profile.
+
2160p / 4Kfleet default Autolast 7d ago
+
+
+ + reset +
+
+
+
+
+
+ + + + diff --git a/docs/feature-changelog.md b/docs/feature-changelog.md new file mode 100644 index 00000000..8fd5a0fd --- /dev/null +++ b/docs/feature-changelog.md @@ -0,0 +1,52 @@ +# Feature Changelog + +## 2026-04-09 + +Covers commits from 2026-04-08 22:32 EDT through 2026-04-09 20:02 EDT. + +### Add artwork selection in the metadata editor +Admins can now browse and apply poster, backdrop, and logo images from enabled metadata providers without leaving the edit flow. +- Adds an `Images` tab in the item metadata editor for movies, series, seasons, and episodes. +- Pulls image options from provider plugins, shows provider badges and popularity ordering, and highlights the current selection. +- Applies the chosen image through new admin endpoints, caches it to storage, and locks image fields so future refreshes do not overwrite the manual choice. + +### Add native ASS and SSA subtitle support +The player and backend now preserve styled anime subtitles instead of flattening them into simpler text formats. +- Serves ASS tracks natively across the main playback API, Jellyfin compatibility routes, and the proxy subtitle path. +- Integrates JASSUB in the web player so fonts, positioning, karaoke effects, and other advanced subtitle styling render correctly. +- Keeps existing SRT and VTT handling unchanged while exposing subtitle format badges in the subtitle picker. + +### Show live server activity in the web app +Admins now get a Plex-style live activity indicator directly in the UI. +- Adds a real-time dropdown that summarizes direct play, remux, and transcode sessions alongside task progress and active library scans. +- Keeps the activity affordance visible on admin screens and only exposes it on regular app pages when activity is actually happening. +- Reuses existing realtime channels instead of introducing new backend plumbing. + +### Improve playback reliability and control flow +Recent playback work focused on making copy-mode streaming behave more like direct play while reducing restart-related failures. +- Preserves copy-mode when seeking, enables seek-anywhere manifests when duration is known, and allows supported HDR content to stay in direct play or remux paths instead of forcing full transcodes. +- Retries Firefox copy-mode startup with a compatibility fallback to reduce failed starts on that browser. +- Separates player exit and minimize controls so watch-page navigation is less ambiguous. +- Handles transcode restarts more cleanly during segment waits and fixes copy-mode buffering after a restart so old segments are not served back to the player. + +### Refresh playback surfaces after watch-state changes +A few related UI changes now keep browsing and episode detail views aligned with what the viewer just watched. +- Refreshes home sections after watched-state updates so progress-sensitive shelves stay current without a manual reload. +- Shows sticky version preferences on episode detail pages so preferred cuts or versions remain visible when choosing playback options. + +### Add a cinematic Playing Next experience +Episode-to-episode playback now transitions through a richer post-roll flow. +- Replaces the bare post-roll screen with a `Playing Next` screen that can appear about 30 seconds before the end of an episode. +- Keeps the current episode running in a resizable mini-player while showing the upcoming episode with a more prominent background treatment. +- Adds auto-play-next playback settings and supports cross-season next-episode lookups. + +### Support S3 key prefixes for stored media and assets +Storage configuration can now target a scoped path inside an S3 bucket instead of requiring a bucket root layout. +- Adds S3 key prefix configuration in server config loading, admin storage settings, and setup flows. +- Updates the S3 client so Silo reads and writes through the configured prefix consistently. + +### Add chapter thumbnails and chapter-aware navigation +Playback now has much deeper chapter support, including generated preview images and more resilient thumbnail processing. +- Scans and stores embedded chapter metadata, exposes it through watch detail responses, and adds player chapter menus and seek-bar chapter affordances. +- Introduces library settings and backfill tasks for chapter thumbnail generation, with realtime thumbnail delivery into active playback sessions. +- Tracks thumbnail failure state, retry timing, and HDR thumbnail policy so generation can recover more predictably when extraction fails. diff --git a/docs/s3-storage-setup.md b/docs/s3-storage-setup.md new file mode 100644 index 00000000..8112c613 --- /dev/null +++ b/docs/s3-storage-setup.md @@ -0,0 +1,133 @@ +# S3 Storage Setup + +Silo uses S3-compatible object storage for caching artwork, catalog exports, and other operational data. Any S3-compatible backend works (AWS S3, Ceph RGW, MinIO, Cloudflare R2, etc.). + +## Core Settings + +Configure these in **Admin > Settings > Storage** or via `server_settings`: + +| Setting | Description | +|---------|-------------| +| **Endpoint** | S3 API endpoint (e.g. `https://s3.amazonaws.com`, `https://.r2.cloudflarestorage.com`) | +| **Region** | AWS region (defaults to `us-east-1`) | +| **Bucket** | Bucket name | +| **Path Style** | Use path-style URLs (enable for most non-AWS backends) | +| **Access Key** | S3 access key ID | +| **Secret Key** | S3 secret access key | + +## URL Auth Methods + +Controls how Silo generates read URLs for cached images served to clients. Three modes are available: + +### S3 Presigned URLs (default) + +Standard S3 presigned URLs using the configured endpoint. Works with any S3-compatible backend out of the box — no additional setup required. + +Images are served directly from the S3 endpoint with time-limited signed URLs. + +### Public (no auth) + +Serves images via an unsigned public URL through a custom domain. Use this when your bucket is publicly readable (e.g. Cloudflare R2 with a public custom domain) and you don't need URL-level access control. + +**Additional setting:** +- **Public Endpoint** — The public CDN domain bound to the bucket (e.g. `https://cdn.example.com`) + +### Cloudflare Token Auth + +Generates HMAC-signed URLs validated by a Cloudflare WAF rule. Best for Cloudflare R2 with a custom domain when you want URL-level access control without exposing the R2 API endpoint. + +**Additional settings:** +- **Public Endpoint** — R2 custom domain (e.g. `https://cdn.example.com`) +- **Token Secret** — HMAC-SHA256 shared secret (must match the WAF rule) +- **Token Param** — Query parameter name (default: `verify`) +- **Token TTL** — Token lifetime in seconds (default: `10800` = 3 hours) + +--- + +## Cloudflare R2 Setup Guide + +### Option A: Public bucket (simplest) + +1. **Create an R2 bucket** in the Cloudflare dashboard +2. **Connect a custom domain** under R2 > your bucket > Settings > Custom Domains +3. **Create an R2 API token** with read/write permissions for the bucket +4. **Configure Silo:** + +| Setting | Value | +|---------|-------| +| Endpoint | `https://.r2.cloudflarestorage.com` | +| Bucket | Your bucket name | +| Access Key | R2 API token access key | +| Secret Key | R2 API token secret key | +| Path Style | Enabled | +| URL Auth Method | Public (no auth) | +| Public Endpoint | `https://your-custom-domain.com` | + +### Option B: Token-authenticated (recommended) + +Adds HMAC-based access control so only Silo can generate valid image URLs. + +**Requires Cloudflare Pro plan or higher** for the `is_timed_hmac_valid_v0()` WAF function. + +#### Step 1: Generate a shared secret + +```bash +openssl rand -hex 32 +``` + +Save the output for Steps 2 and 3. + +#### Step 2: Create a Cloudflare WAF rule + +1. **Cloudflare Dashboard** > your zone > **Security** > **WAF** > **Custom rules** +2. Click **Create rule** +3. Name: `Silo CDN Token Auth` +4. Switch to **Edit expression** and enter: + +``` +(http.host eq "your-cdn-domain.com" and not is_timed_hmac_valid_v0("YOUR_SECRET", http.request.uri, 10800, http.request.timestamp.sec, 8)) +``` + +Replace: +- `your-cdn-domain.com` with your R2 custom domain +- `YOUR_SECRET` with the secret from Step 1 +- `10800` with your desired TTL in seconds (must match Silo's Token TTL) +- `8` with the separator length (`len(token_param) + 2`, e.g. `?verify=` = 8) + +5. Set action to **Block** +6. **Deploy** + +#### Step 3: Configure Silo + +| Setting | Value | +|---------|-------| +| Endpoint | `https://.r2.cloudflarestorage.com` | +| Bucket | Your bucket name | +| Access Key | R2 API token access key | +| Secret Key | R2 API token secret key | +| Path Style | Enabled | +| URL Auth Method | Cloudflare Token Auth | +| Public Endpoint | `https://your-cdn-domain.com` | +| Token Secret | Same secret from Step 1 | +| Token Param | `verify` (default) | +| Token TTL | `10800` (default, must match WAF rule) | + +#### Step 4: Verify + +Restart the server, then check that image URLs in API responses look like: + +``` +https://your-cdn-domain.com/tmdb/movies/550/poster/original.jpg?verify=1712150400-abc123... +``` + +**Troubleshooting:** If images don't load, temporarily change the WAF rule action from Block to Log to debug without breaking access. + +--- + +## Traditional S3 Setup (AWS, Ceph, MinIO) + +1. Create a bucket with appropriate IAM permissions (`PutObject`, `GetObject`, `DeleteObject`) +2. Configure Silo with the endpoint, bucket, and credentials +3. Leave URL Auth Method as **S3 Presigned URLs** (default) + +No additional setup needed — presigned URLs work out of the box. diff --git a/docs/scan-api.md b/docs/scan-api.md new file mode 100644 index 00000000..70ebb0d2 --- /dev/null +++ b/docs/scan-api.md @@ -0,0 +1,351 @@ +# Scan API + +Silo exposes a scan API that lets external tools trigger media library scans on demand. This is useful for integrating with download managers like Sonarr, Radarr, or relay tools like Autoscan that notify your server when new media arrives. + +## Prerequisites + +- Silo must be running in **integrated** or **api** mode. In `proxy` and `transcode` modes, the scan endpoints are not registered and will return 404. +- You need an **admin API key**. Create one in the Silo web UI under **Settings > API Keys**. Keys start with the `sa_` prefix. + +## Authentication + +All scan endpoints require admin-level authentication via the `Authorization` header using either a **JWT access token** or an **API key**. + +``` +Authorization: Bearer sa_your_api_key_here +``` + +## Finding Your Library ID + +To list libraries and their IDs: + +```bash +curl -s http://your-server:8090/api/v1/libraries \ + -H "Authorization: Bearer sa_your_api_key" | jq +``` + +You can also find library IDs in the Silo web UI under **Settings > Libraries**. + +## Endpoints + +All endpoints are under `/api/v1` and require `Content-Type: application/json`. + +--- + +### Trigger a Scan + +``` +POST /api/v1/scan +``` + +Accepts a library ID, a filesystem path, or both. The server determines the appropriate scan mode automatically and runs the scan asynchronously in the background. + +#### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `library_id` | integer | no* | ID of the library to scan. | +| `path` | string | no* | Filesystem path to scan. Can be a library root, subdirectory, or single file. | + +\* At least one of `library_id` or `path` must be provided. + +#### Scan Mode Resolution + +The server automatically selects the scan mode based on the input: + +| Input | Path Target | Mode | Behavior | +|-------|-------------|------|----------| +| `library_id` only | — | `library` | Full scan of all paths in the library. | +| `path` equals a library root | directory | `library` | Full scan of that library. | +| `path` is a subdirectory within a library | directory | `subtree` | Scans only that directory and its descendants. | +| `path` is a media file | file | `file` | Scans only that single file. | + +When only `path` is provided, the server automatically resolves which library owns that path. When both are provided, the server verifies the path belongs to the specified library. + +#### Supported Media Extensions + +`.mkv`, `.mp4`, `.avi`, `.m4v`, `.ts`, `.wmv` + +Extension matching is case-insensitive. Single-file scans will be rejected if the file does not have a recognized extension. + +#### Response + +**HTTP 202 Accepted** + +```json +{ + "status": "accepted", + "mode": "subtree", + "library_id": 1 +} +``` + +The `mode` field will be one of `library`, `subtree`, or `file`. + +> **Note:** A 202 response means the request was validated and accepted, not that a scan goroutine was necessarily started. If a conflicting scan is already running for the same library (see [Deduplication](#deduplication) below), the request is silently deduplicated and no additional scan runs. + +#### Error Responses + +| Status | Code | Message | Cause | +|--------|------|---------|-------| +| 400 | `bad_request` | Either library_id or path is required | Missing both fields. | +| 400 | `bad_request` | Path does not belong to the specified library | Path is outside the library's configured roots. | +| 400 | `bad_request` | Path does not exist | Filesystem path not found. | +| 400 | `bad_request` | Permission denied for path | Server lacks read permission. | +| 400 | `bad_request` | Path could not be inspected | Stat failed for another reason. | +| 400 | `bad_request` | Path must be a file or directory | Path is a socket, FIFO, or other special file. | +| 400 | `bad_request` | Path matches multiple libraries | Ambiguous path — provide `library_id` to disambiguate. | +| 400 | `bad_request` | No library matches the given path | Path is not within any configured library. | +| 400 | `bad_request` | Unsupported media file extension | File mode only — extension not recognized. | +| 401 | `unauthorized` | (varies) | Missing, invalid, or expired credentials. | +| 403 | `forbidden` | (varies) | Authenticated user is not an admin. | +| 404 | `not_found` | Library not found | Library ID does not exist. | + +--- + +### Cancel a Scan + +``` +POST /api/v1/scan/cancel +``` + +Cancels all running scans for a given library. + +#### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `library_id` | integer | yes | ID of the library whose scans should be cancelled. Must be > 0. | + +#### Response + +**HTTP 200 OK** + +```json +{ + "cancelled": 2, + "library_id": 1 +} +``` + +The `cancelled` field indicates how many in-progress scans were stopped. + +#### Error Responses + +| Status | Code | Message | Cause | +|--------|------|---------|-------| +| 400 | `bad_request` | library_id is required | Missing or zero/negative `library_id`. | +| 503 | `unavailable` | Scanner not available | Scanner is not initialized on this server instance. | + +--- + +## Examples + +### Scan an entire library + +```bash +curl -X POST http://your-server:8090/api/v1/scan \ + -H "Authorization: Bearer sa_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{"library_id": 1}' +``` + +### Scan a specific show folder (subtree) + +This is the most common integration pattern. When Sonarr downloads a new episode, you scan the show's folder: + +```bash +curl -X POST http://your-server:8090/api/v1/scan \ + -H "Authorization: Bearer sa_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{"path": "/mnt/media/tv/Breaking Bad"}' +``` + +### Scan a single newly downloaded file + +```bash +curl -X POST http://your-server:8090/api/v1/scan \ + -H "Authorization: Bearer sa_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{"path": "/mnt/media/movies/Oppenheimer (2023)/Oppenheimer.2023.2160p.mkv"}' +``` + +### Scan a path within a specific library + +When a path could theoretically belong to multiple libraries, you can disambiguate by providing both: + +```bash +curl -X POST http://your-server:8090/api/v1/scan \ + -H "Authorization: Bearer sa_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{"library_id": 2, "path": "/mnt/media/movies/Oppenheimer (2023)"}' +``` + +--- + +## Integration with Autoscan + +[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and other sources for new downloads, then relays scan requests to media servers. To use Autoscan with Silo, configure a **manual/generic target** using a custom script or webhook that calls the Silo scan API. + +### Autoscan Custom Script Target + +Create a script (e.g., `silo-scan.sh`) that Autoscan calls with the changed path: + +```bash +#!/bin/bash +# silo-scan.sh — called by Autoscan with the path as $1 +SILO_URL="http://your-server:8090" +API_KEY="sa_your_api_key" + +BODY=$(jq -n --arg path "$1" '{"path": $path}') + +curl -s -S --fail -X POST "${SILO_URL}/api/v1/scan" \ + -H "Authorization: Bearer ${API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$BODY" || echo "Silo scan request failed for: $1" >&2 +``` + +--- + +## Integration with Sonarr / Radarr + +Sonarr and Radarr can trigger external scripts or webhooks when a download completes. The simplest approach is to use a **Connect > Custom Script** in Sonarr/Radarr that calls the Silo scan API. + +### Sonarr Custom Script + +Create a script that Sonarr calls on import. Sonarr sets environment variables with episode details: + +```bash +#!/bin/bash +# silo-sonarr.sh — Sonarr Connect > Custom Script +SILO_URL="http://your-server:8090" +API_KEY="sa_your_api_key" + +# Sonarr sets these environment variables on import: +# sonarr_series_path — /mnt/media/tv/Show Name +# sonarr_episodefile_path — /mnt/media/tv/Show Name/Season 01/episode.mkv +# sonarr_eventtype — Download, Rename, Test, etc. +# sonarr_isupgrade — True if this is an upgrade of an existing file + +case "$sonarr_eventtype" in + Download) + # Fires for both new imports and upgrades (check sonarr_isupgrade if needed) + if [ -n "$sonarr_episodefile_path" ]; then + SCAN_PATH="$sonarr_episodefile_path" + else + SCAN_PATH="$sonarr_series_path" + fi + ;; + Rename) + # On rename, rescan the entire series folder + SCAN_PATH="$sonarr_series_path" + ;; + SeriesDelete|EpisodeFileDelete) + # On deletion, rescan to mark files as missing + SCAN_PATH="$sonarr_series_path" + ;; + Test) + # Sonarr sends this when you test the connection — exit successfully + exit 0 + ;; + *) + exit 0 + ;; +esac + +# If Sonarr and Silo see files at different mount points, remap here: +# SCAN_PATH="${SCAN_PATH/#\/tv//mnt/media/tv}" + +BODY=$(jq -n --arg path "$SCAN_PATH" '{"path": $path}') + +curl -s -S --fail -X POST "${SILO_URL}/api/v1/scan" \ + -H "Authorization: Bearer ${API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$BODY" || echo "Silo scan failed for: $SCAN_PATH" >&2 +``` + +Place this script somewhere accessible (e.g., `/opt/scripts/silo-sonarr.sh`), make it executable (`chmod +x`), then in Sonarr go to **Settings > Connect > + > Custom Script** and set the path. Use the **Test** button to verify connectivity. + +### Radarr Custom Script + +Radarr works the same way with different environment variables: + +```bash +#!/bin/bash +# silo-radarr.sh — Radarr Connect > Custom Script +SILO_URL="http://your-server:8090" +API_KEY="sa_your_api_key" + +# Radarr sets these environment variables on import: +# radarr_movie_path — /mnt/media/movies/Movie Name (2024) +# radarr_moviefile_path — /mnt/media/movies/Movie Name (2024)/movie.mkv +# radarr_eventtype — Download, Rename, Test, etc. +# radarr_isupgrade — True if this is an upgrade of an existing file + +case "$radarr_eventtype" in + Download) + # Fires for both new imports and upgrades (check radarr_isupgrade if needed) + if [ -n "$radarr_moviefile_path" ]; then + SCAN_PATH="$radarr_moviefile_path" + else + SCAN_PATH="$radarr_movie_path" + fi + ;; + Rename) + SCAN_PATH="$radarr_movie_path" + ;; + MovieDelete|MovieFileDelete) + # On deletion, rescan to mark files as missing + SCAN_PATH="$radarr_movie_path" + ;; + Test) + exit 0 + ;; + *) + exit 0 + ;; +esac + +# If Radarr and Silo see files at different mount points, remap here: +# SCAN_PATH="${SCAN_PATH/#\/movies//mnt/media/movies}" + +BODY=$(jq -n --arg path "$SCAN_PATH" '{"path": $path}') + +curl -s -S --fail -X POST "${SILO_URL}/api/v1/scan" \ + -H "Authorization: Bearer ${API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$BODY" || echo "Silo scan failed for: $SCAN_PATH" >&2 +``` + +--- + +## How Scanning Works + +Understanding the scan pipeline helps you choose the right scan mode: + +1. **File discovery** — The scanner walks the target path and collects files with recognized extensions. +2. **Technical probing** — Each new or changed file is analyzed with ffprobe to extract codec, resolution, duration, HDR status, and track information. +3. **Metadata matching** — Newly discovered files are matched to library items (movies, series, episodes) using filename parsing and configured metadata providers. +4. **Reconciliation** — Files that were previously in the database but no longer exist on disk are marked as missing. + +Subtree and file scans only reconcile within their scope — they will not mark files outside the scanned path as missing. This makes them safe and efficient for targeted updates. + +### Deduplication + +Scans are deduplicated per library. If a conflicting scan is already running, the new request is silently dropped (not queued). The API still returns 202 in this case. The conflict rules are: + +- Two full library scans on the same library conflict with each other. +- Two subtree/file scans conflict only if their paths overlap. +- A subtree or file scan does **not** conflict with a full library scan. + +In practice this means: if a full library scan is running and Sonarr fires a subtree scan, the subtree scan will still run. But if two full library scans are triggered back-to-back, the second is dropped. + +--- + +## Tips + +- **Prefer subtree scans** for automation. Scanning a show or movie folder is fast and precise — it picks up new files and marks removed ones without touching the rest of the library. +- **Use file scans sparingly.** They're useful when you know the exact file, but a subtree scan of the parent folder is usually just as fast and also catches renames, deletions, and new subtitle files. +- **Full library scans are expensive.** Reserve these for periodic maintenance (Silo runs one daily at 02:00 server-local time by default). Don't trigger full scans from download automation. +- **Paths must be server-side paths.** The path you send must match the filesystem as the Silo server sees it. If Sonarr and Silo see files at different mount points (common with Docker), uncomment and adjust the path remapping line in the scripts above. +- **Scripts require `jq`.** The integration scripts use `jq` to safely construct JSON, which correctly handles paths containing special characters like quotes or backslashes. Install it via your package manager (`apt install jq`, `brew install jq`, etc.). diff --git a/docs/security_best_practices_report.md b/docs/security_best_practices_report.md new file mode 100644 index 00000000..84cff9f2 --- /dev/null +++ b/docs/security_best_practices_report.md @@ -0,0 +1,5 @@ +# Security Best Practices Report + +This report has been retired from the public repository. + +The public launch policy now lives in `SECURITY.md`, `SUPPORT.md`, and the repo's public workflows and onboarding docs. diff --git a/docs/server-query-hardening-and-optimization-audit.md b/docs/server-query-hardening-and-optimization-audit.md new file mode 100644 index 00000000..89f1237b --- /dev/null +++ b/docs/server-query-hardening-and-optimization-audit.md @@ -0,0 +1,389 @@ +# Server Query Hardening And Optimization Audit + +Last updated: 2026-04-19 + +## Purpose + +This document is a living reference for server-side query hardening and performance work. +It captures what the current queries are trying to do, where the highest-leverage issues are, +and what order to tackle them in. + +This pass was a read-only code audit across the main server query surfaces. It was grounded in +the current code paths, but it did not include live `EXPLAIN ANALYZE` sampling, so treat the +items below as implementation-informed priorities rather than measured query plans. + +A second read-only verification pass reviewed this document for accuracy, expected benefit, and +implementation risk. The recommendations below incorporate that review, including places where +the original plan was too broad, slightly stale, or needed behavior-preservation caveats. + +## Scope Covered + +- Shared catalog/query engine +- API handlers and browse/query endpoints +- Auth, session, profile, and user-state storage +- Scanner, metadata, admin jobs, and catalog seed import/export +- Playback, Jelly compat, subtitles, plugins, sections, node pool, and webhook sync + +## Top Priorities + +### P1: Fix live catalog count and pagination correctness + +The highest-priority issue is that some catalog and browse paths count joined rows instead of +deduped items. + +- `internal/catalog/browse.go` +- `internal/api/handlers/catalog.go` + +Why it matters: + +- `total` and `has_more` can be inflated +- items can be effectively overcounted when they belong to multiple libraries or person joins +- fallback query loops can do extra pages based on a bad total + +Recommended fix: + +- Make the count query operate on the same deduped relation as the data query +- Prefer `COUNT(DISTINCT mi.content_id)` or a grouped subquery over raw `COUNT(*)` + +### P1: Stop materializing full candidate sets in query-source fallbacks + +The main query-source fallback path currently does too much work when it cannot stay on the +direct SQL path. + +- `internal/catalog/catalog_resolver.go` +- `internal/catalog/item_repo.go` + +Current pattern: + +- fetch all search candidates +- count inside `Search` +- page through `Search` +- sometimes re-fetch all candidates again for `name_prefix` or secondary sort handling + +Why it matters: + +- scales poorly with library size +- duplicates count work +- turns many searches into full-set materialization problems + +Recommended fix: + +- move resolver call sites onto the existing no-total page paths where only `has_more` is needed +- push `name_prefix` down into SQL only with a semantics-preserving rewrite +- avoid whole-candidate fetches for non-relevance sorts unless absolutely necessary + +### P1: Fix technical filter and sort scoping for disabled libraries + +Technical media-file predicates and joins do not consistently honor disabled-library exclusions. + +- `internal/catalog/query_executor.go` +- `internal/catalog/query_builder.go` + +Why it matters: + +- a disabled-library file can still satisfy `resolution`, `hdr`, `bitrate`, + `audio_language`, or `subtitle_language` +- this is both a correctness issue and a trust-boundary issue for filtered views + +Recommended fix: + +- pass disabled-library scope all the way into media-file `EXISTS` predicates and sort joins +- make technical filter/sort scoping use the same effective library rules as item visibility + +## Cross-Cutting Themes + +### Deduped counts must match data semantics + +Anywhere a query joins `media_item_libraries`, `item_people`, or similar fanout tables, the +count path must match the item-level dedupe semantics of the data query. + +### Avoid repeated full counts when `has_more` is enough + +Several paths still pay for `COUNT(*)` on every page even when the caller only needs to know +whether another page exists. Add explicit no-total execution paths where possible. + +### Batch hydration instead of per-item lookups + +The codebase has multiple season/detail/compat/profile surfaces that still do follow-up queries +per item, per episode, or per installation. Those should move to batched list queries or +per-request caching. + +### Wrap multi-step reconciliation in transactions + +Some write paths are still read-check-write loops or multi-statement reconciliation sequences +without a transaction. Those should be hardened before deeper tuning. + +### Preserve Existing Behavior While Optimizing + +Several of the recommendations below are only safe if they preserve current semantics: + +- `name_prefix` must continue matching the current `title OR sort_title` behavior +- `release_date` must keep its cross-scope movie / series / episode contract +- provider-chain batching must preserve fallback display names, priority defaults, and ordering +- representative-file batching must preserve current first-hit and fallback selection behavior +- compat startup changes must not regress clients that depend on current manifest readiness behavior +- subtitle dedupe must become conflict-aware before it is made more aggressive + +### Indexes should match real read shapes + +A number of hot paths have indexes that are close, but not quite aligned with their current +query predicates or sort order. + +## Detailed Findings By Area + +### Shared Catalog Query Layer + +Files: + +- `internal/catalog/browse.go` +- `internal/catalog/query_definition.go` +- `internal/catalog/query_builder.go` +- `internal/catalog/query_executor.go` +- `internal/catalog/catalog_resolver.go` +- `internal/catalog/air_date_sql.go` + +Key findings: + +- Browse counts do not match deduped item semantics when library or person joins are present. +- `release_date` is modeled as a text expression in the shared query definition even though the + movie-side field is date-typed. That weakens type semantics and index use, but it is also part + of a cross-scope contract for series and episode surfaces, so it cannot be split carelessly. +- `name_prefix` is often filtered in Go after fetching candidates instead of being pushed into SQL, + but any pushdown must preserve current `title OR sort_title` matching semantics. +- `fetchAllBrowseCandidates()` currently pays for a full count on every page. +- Browse `ASC` sorts on nullable fields do not preserve the same null-handling semantics as the + shared query-builder sort path. Fixing that will change visible ordering and should be treated + as a behavior adjustment, not only as an optimization. +- `effectiveLastAirDateExpr()` is centralized and semantically strong, but it can still be reused + more efficiently on the browse side when a sort join has already materialized the same aggregate. + +What is already strong: + +- explicit access scoping +- stable tie-break ordering +- centralized `last_air_date` normalization +- same-file technical rule collapsing for positive technical predicates + +Recommended next steps: + +1. Fix count/data parity in browse and legacy catalog query paths. +2. Push `name_prefix` into SQL with an exact semantics-preserving rewrite and matching index plan. +3. Rework `release_date` handling carefully so planner gains do not break series/episode parity. +4. Move fallback resolver call sites onto the existing no-total browse/query paths. + +### API Handlers And Surface-Level Query Use + +Files: + +- `internal/api/handlers/catalog.go` +- `internal/api/handlers/items.go` +- `internal/api/handlers/user_state.go` +- `internal/api/handlers/profiles.go` +- `internal/api/handlers/admin.go` +- `internal/api/handlers/nodes.go` + +Key findings: + +- The legacy `POST /catalog/query` path has the same join-row count inflation risk as the shared + browse layer. +- Catalog item hydration still does extra follow-up passes for overlay summaries, user state, + and episode metadata. +- Season detail still contains per-episode aggregate user-state work; episode detail is much less + problematic and should not be lumped into the same recommendation. +- Profile creation currently does extra pre-read work and has race potential around `max_profiles` + and first-primary assignment. +- Some profile summary uses only need names and IDs, but the backing repository eagerly loads + additional profile-library state. This is mainly an admin and session-list summary issue, not a + reason to change full profile payloads everywhere. +- Node force-reload loads all nodes and filters in Go instead of using the enabled-node query shape. + +Recommended next steps: + +1. Collapse season aggregate hydration into batched file and progress reads. +2. Make profile creation atomic with locking/transaction semantics that preserve bootstrap and + allowed-library write behavior. +3. Add lightweight profile summary queries for admin and session-list surfaces instead of changing + the semantics of full profile list calls. + +### Auth, Sessions, Profiles, And User State + +Files: + +- `internal/auth/session.go` +- `internal/userstore/pgstore/progress.go` +- `internal/userstore/pgstore/section_overrides.go` +- `internal/userstore/pgstore/collections.go` +- `internal/api/handlers/api_keys.go` + +Key findings: + +- `auth_sessions` already supports user-scoped listing, revocation, and expiry cleanup at the + repository level, but the schema does not appear to have ideal index support for the current + list/revoke shapes. +- Hidden-history suppression already has a PK on `(user_id, profile_id, media_item_id)` and a + profile/time index. A wider composite index may still help the suppression probes, but that + should be validated with `EXPLAIN` before treating it as an obvious win. +- Profile progress listing sorts by `updated_at DESC` without a matching index shape. +- Postgres section overrides are stored as a single JSON blob in `user_settings`, which causes + read amplification and creates lost-update risk. Full normalization is a later structural option, + but smaller compare-and-swap or transactional protections may be the better first step. +- Collection listing still does per-collection profile hydration, but the ROI depends on how large + those lists get in practice. +- The admin API-key create path is safe so long as it remains admin-gated, but it should stay + clearly separated from self-service creation semantics. + +Recommended next steps: + +1. Add session indexes aligned with the real list/revoke paths, and only add expiry-cleanup support + if a cleanup job is actually wired. +2. Add a progress-list index aligned with the real sort shape. +3. Add transactional CAS/versioning protection to section overrides first; treat full normalization + as a later cleanup if the surface keeps growing. +4. Batch collection/profile membership loading where the surface area justifies it. + +### Scanner, Metadata, Admin Jobs, And Catalog Seed + +Files: + +- `internal/scanner/scanner.go` +- `internal/metadata/chain.go` +- `internal/metadata/refresh_debt_repo.go` +- `internal/adminjob/item_refresh.go` +- `internal/adminjob/repository.go` +- `internal/catalogseed/service.go` + +Key findings: + +- `syncPresentLibraryState` performs a multi-step reconciliation without a transaction. +- Metadata provider-chain resolution is still chatty and partially N+1, but any batching must + preserve current fallback display-name, default-priority, and ordering semantics. +- `AppendProviderToAllChains` is a read-check-write loop without a transaction. +- Representative-file resolution for series and seasons is still N+1, but any replacement needs to + preserve the current first-hit and fallback selection behavior. +- Refresh-debt claiming performs broad cleanup work inline with claim flow. If that cleanup moves, + an equivalent prune path must remain in place. +- Admin job list-by-type lacks an ideal supporting index. +- Catalog export still does avoidable read amplification, especially when loading totals, but this + is lower-priority admin progress plumbing rather than a top-tier hot-path issue. + +Recommended next steps: + +1. Wrap the truly non-transactional reconciliation and mutation paths in transactions. +2. Batch provider-chain metadata and priority resolution only if fallback and ordering semantics are + preserved explicitly. +3. Replace representative-file N+1 lookups only with a query that preserves first-hit selection and + fallback behavior. +4. Move broad queue cleanup out of hot claim paths only if an equivalent prune mechanism remains. +5. Treat export total collapsing as a lower-priority admin-path tuning item. + +### Playback, Jelly Compat, Subtitles, Plugins, Sections, Nodes, Webhook Sync + +Files: + +- `internal/jellycompat/streams.go` +- `internal/jellycompat/playback_sessions.go` +- `internal/jellycompat/handlers_items.go` +- `internal/jellycompat/content_direct.go` +- `internal/playback/session.go` +- `internal/subtitles/pgrepo.go` +- `internal/subtitles/manager.go` +- `internal/plugins/task_registry.go` +- `internal/plugins/user_config.go` +- `internal/webhooksync/repo_events.go` +- `internal/sections/fetcher.go` +- `internal/nodepool/repository.go` +- `internal/watchtogether/repository.go` + +Key findings: + +- Compat playback route resolution still scans active sessions linearly. +- Downloaded subtitle rows are repeatedly reloaded on visible compat/playback paths. +- Compat browse and season surfaces still over-fetch and rehydrate too much state. +- Some stream paths re-read in-memory session state immediately after update. +- Manifest waiting uses tight polling instead of an event or gentler backoff, but that behavior + exists partly for client compatibility and should not be changed casually. +- Playback session bookkeeping still does repeated full-map scans by user and media file. +- Subtitle dedupe is based on pre-check plus insert instead of an authoritative uniqueness rule, and + any change here must become conflict-aware to avoid deleting another request's successful insert. +- Plugin task-registry building is still N+1 across installations and capabilities. +- Plugin user config scans all user settings for prefix filtering and does non-transactional replace. +- Webhook event retention trims with a broad self-correlated delete on every insert. The issue is + the delete shape and churn cost, not a missing index in the current schema. +- Random sections use `ORDER BY RANDOM()`. +- Watch-together lookup may want a functional index on `lower(code)` only if case-insensitive room + codes are a real requirement; normalizing input to exact-match semantics may be cheaper. + +Recommended next steps: + +1. Add reverse indexes for compat and playback session lookup. +2. Cache or batch downloaded subtitle hydration. +3. Add a uniqueness-backed, conflict-safe dedupe path for subtitle storage. +4. Flatten plugin registry rebuild queries. +5. Replace `ORDER BY RANDOM()` only if random sections become a notable cost center. + +## Index Candidates + +These are the most obvious index opportunities from this audit: + +- `auth_sessions` indexes aligned with `ListByUser` / `RevokeAllByUser`, plus an `expires_at` + index only if expiry cleanup is actually scheduled +- consider `user_history_hidden_items (user_id, profile_id, media_item_id, hidden_before DESC)` + only if `EXPLAIN` shows it materially improves suppression probes beyond the PK and current + profile/time index +- progress listing index aligned with `updated_at DESC` +- `downloaded_subtitles (media_file_id, created_at DESC)` if subtitle history ordering matters +- webhook event retention delete-shape tuning; the current composite index already exists +- functional index on `lower(code)` for watch-together room lookup only if case-insensitive codes + are a real product requirement +- compat session support indexes on expiry and streamapp user keys + +## Suggested Rollout Order + +### Wave 1: Correctness And Hot Query Shape Fixes + +- Fix deduped count semantics in browse and legacy catalog query paths +- Fix disabled-library scoping in technical filters and sorts +- Remove full-set query fallback behavior where possible +- Make scanner reconciliation transactional + +### Wave 2: Index Pack + +- session indexes aligned with real list/revoke paths, plus optional expiry-cleanup support +- hidden-history index improvement only if supported by `EXPLAIN` +- progress-list index +- webhook retention delete-shape tuning +- watch-together lookup strategy, which may be normalization rather than a new functional index + +### Wave 3: Batch Hydration + +- season aggregate detail and user-state hydration +- compat subtitle loading +- compat season/progress surfaces +- plugin registry loading +- collection/profile membership loading where ROI is clear + +### Wave 4: Structural Cleanups + +- normalize section overrides away from JSON blob storage only if smaller CAS/versioning fixes are + not sufficient +- improve provider-chain resolution and mutation paths without changing current fallback/order + semantics +- replace broad queue cleanup in refresh-debt claiming only with an equivalent prune path +- revisit random-section strategy if still needed + +## How To Update This Document + +When a query issue is fixed or re-scoped: + +1. Keep the section, but mark the item as addressed or reduced in scope. +2. Add the file path or migration that changed the behavior. +3. If a finding was disproven with measurement, note the evidence and why it is no longer a priority. +4. Prefer moving solved items into a short "Resolved" subsection instead of deleting the history. + +## Notes + +- This audit intentionally distinguishes existing good structure from true gaps. The catalog query + layer is already a strong foundation; most of the leverage now is in tightening count semantics, + avoiding full-set fallbacks, batching repeated hydration, and hardening multi-step mutations. +- Reliability can still beat small theoretical query savings on user-facing browse surfaces. Where + exact totals are important, keep exact totals, but make sure they are counting the right thing. diff --git a/docs/wiki/admin/collection-templates.md b/docs/wiki/admin/collection-templates.md new file mode 100644 index 00000000..bc1bf5b1 --- /dev/null +++ b/docs/wiki/admin/collection-templates.md @@ -0,0 +1,177 @@ +--- +title: Collection Templates +description: Curated, one-click starting points for synced library collections. +summary: How the collection template gallery works, what ships built-in, and how to add your own. +tags: + - silo + - docs + - wiki + - collections + - admin +audience: + - operator +last_reviewed: 2026-05-05 +related: + - ../index.md +--- + +# Collection Templates + +Collection templates are pre-configured "blueprints" for a synced library +collection. Picking one in the admin UI seeds a new collection wired to TMDB, +Trakt, or MDBList — including a sensible default sync schedule — without the +operator hand-typing presets, URLs, or cron expressions. + +They are the collection-side analogue of the +[Section Recipe Gallery](../../superpowers/specs/sections-recipes.md): a +catalog of opinionated starting points that live alongside the lower-level +"Add Collection" flow. + +## When to reach for a template + +Use a template when you want a synced shelf that follows a well-known feed +(TMDB Trending, Trakt Popular Shows, your favourite MDBList list). Use the +manual flow ("Add Collection") when you need a smart query, a hand-curated +manual list, or a custom MDBList URL that you do not want to reuse. + +## Where to find them + +Two entry points open the same gallery: + +1. **Admin → Collections → "Browse Templates"** in the page header. +2. **Admin → Collections → "Add Collection" → "Browse Templates"** tile in the + source-type chooser. + +Both flows hand the chosen template to the existing import endpoint +(`/admin/collections/import/{tmdb,trakt,mdblist}`), so the resulting +collection looks identical to one created through the standard import form. + +## What ships built-in + +The default registry covers TMDB and Trakt's public discovery feeds plus a +"bring your own URL" MDBList template: + +| Category | Templates | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | +| Trending | TMDB Trending Today (mixed), Trending Movies (week), Trending TV (week), Trakt Trending Movies/Shows | +| Popular | TMDB Popular Movies/TV, Trakt Popular Movies/Shows | +| Streaming Services | Netflix Movies/Shows, Disney+ Movies/Shows, HBO Shows, Amazon Prime Movies/Shows, Hulu Shows (community MDBList) | +| Top Rated | TMDB Top Rated Movies/TV, IMDb Top 250 Movies, IMDb Top 250 Shows (MDBList) | +| In Theaters | TMDB Now Playing | +| Upcoming | TMDB Upcoming Movies | +| On Air | TMDB Airing Today, TMDB On The Air This Week | +| Editorial | Trakt Recommended Movies/Shows (per profile), Mindfuck Movies, Top Documentary Movies, Top Horror Movies (MDBList) | +| Custom | MDBList — bring your own JSON URL | + +TMDB and Trakt templates default to 20 items; MDBList templates default to 50 +because the underlying lists tend to be longer (IMDb Top 250, full streaming +catalogs, etc.). Every template ships with a conservative sync cadence — every +6 hours for trending, daily for popular and streaming services, weekly for +top-rated and editorial picks — and a "featured" hint where appropriate. All +defaults are editable in the small confirmation drawer before the collection +is created. + +## Poster artwork + +Built-in templates ship with poster artwork under +`web/public/images/collection-templates/`. Poster filenames match template +IDs, for example `tmdb_popular_movies.jpg`; raw generated plates live in +`web/public/images/collection-templates/raw/` so typography can be regenerated +without re-running image generation. + +The poster style is intentionally close to Kometa/Plex collection posters: + +- Use a 2:3 full-bleed cinematic poster composition. +- Use generic, original scenes only. Do not use copyrighted movie/show posters, + recognizable actors, franchise characters, provider logos, or readable + in-image text. +- Make the art context-specific. A horror template should look like horror; a + documentary template should look investigative; a streaming-service template + should have provider-flavoured ambience without provider branding. +- Do not use a generic category poster for many unrelated templates unless the + context is truly identical. +- Keep typography deterministic outside the generated plate: media type in gold + at top-left, collection title at bottom-left, no app branding, and no solid + black lower-third text box. Use a subtle vignette and shadow/stroke for + readability instead. + +### Trakt "Recommended" templates + +The two Trakt Recommended templates require a profile that already has a +Trakt account connected via **Settings → Watch Providers**. The drawer asks +for a profile picker before submitting; the resulting collection is scoped to +that profile's recommendations and re-syncs daily by default. + +## How a template becomes a collection + +Picking a template opens a small confirmation drawer with: + +- **Library** — which library the collection belongs to. +- **Title / Description** — pre-filled from the template, fully editable. +- **Max Items** — pre-filled with the template's default limit. +- **Sync Schedule** — pre-filled with the template's recommended cadence; can + be changed to any preset or a custom cron expression. +- **Featured** — surface the collection in hero shelves on the library tab. +- **MDBList URL** — only shown when the template's source is MDBList. +- **Profile** — only shown when the template requires one (Trakt + Recommended). + +Submitting the form posts to the matching import endpoint. The backend +creates the collection, runs the first sync, and the new shelf shows up in +the collections list with its status set by the initial sync run. + +## Extending the registry + +The catalog lives in +[`internal/collections/templates/builtin.go`](../../../internal/collections/templates/builtin.go), +fronted by a thread-safe registry in +[`internal/collections/templates/registry.go`](../../../internal/collections/templates/registry.go). + +To add a template: + +1. Append a `Template` struct to `builtinTemplates` (or call + `templates.Register(...)` from your own startup code if you maintain a + private fork). +2. Run `go test ./internal/collections/templates/...` — the registry runs + `validate(...)` on every entry at registration time, so invalid presets, + unknown media types, or non-HTTP MDBList URLs panic immediately and the + tests catch them. +3. Restart the server. The `/admin/collections/templates` endpoint will pick + up the new template without a frontend rebuild — the gallery renders + straight from the server-supplied catalog. + +### Source contracts + +| Source | Required fields | Notes | +| ------ | -------------------------------------------------- | --------------------------------------------------------- | +| `tmdb` | `preset`, `media_type`; `time_window` for trending | Same shape the existing TMDB import endpoint accepts. | +| `trakt`| `preset`, `media_type`; profile required if `recommended` | Set `requires_profile: true` for `recommended` presets. | +| `mdblist` | `url` (optional — empty means "ask the operator") | Empty URL renders an MDBList URL field in the drawer. | + +### A note on MDBList catalogs + +The shipped MDBList templates point at top-ranked public lists from +[mdblist.com/toplists](https://mdblist.com/toplists/). They are +community-maintained, so the upstream URLs may eventually be retired. When +that happens the import job logs a warning during sync; the affected +template can then be retired or re-pointed in `builtin.go`. Operators who +maintain their own MDBList lists can register additional templates against +`templates.Default` at startup without forking this repo. + +## API surface + +`GET /admin/collections/templates` returns the catalog as +`{ "categories": [{ "category", "label", "templates": [...] }] }`. The shape +mirrors the Go types in `internal/collections/templates/templates.go`. The +admin role is required (the endpoint sits inside the same `/admin/...` +chain as the rest of the collection admin handlers). + +## Testing + +- `go test ./internal/collections/templates/...` — registry validation, the + built-in catalog, and category/lookup behaviour. +- `go test ./internal/api/handlers/ -run TestCollectionTemplateHandler` — + HTTP handler smoke tests. +- `pnpm vitest run src/components/CollectionTemplateGallery + src/lib/collectionTemplates.test.ts` — frontend filter/search and + source-dispatch tests. diff --git a/docs/wiki/admin/media-folder-and-naming.md b/docs/wiki/admin/media-folder-and-naming.md new file mode 100644 index 00000000..1baf430e --- /dev/null +++ b/docs/wiki/admin/media-folder-and-naming.md @@ -0,0 +1,244 @@ +--- +title: Supported Media Folder Structures and Naming +description: Accurate reference for the folder layouts and filename patterns Silo supports today. +summary: Supported movie and series organization rules, naming conventions, and known ambiguous cases. +tags: + - silo + - docs + - wiki + - libraries + - scanner + - metadata +audience: + - operator + - end-user +last_reviewed: 2026-04-11 +related: + - ../index.md +--- + +# Supported Media Folder Structures and Naming + +This page documents the folder layouts and filename conventions that Silo supports today. +It is based on the current scanner, naming, and metadata-matching code paths, plus a validation pass +against the dev anime library on `2026-04-11`. + +All examples in this page use generic placeholder names rather than real media titles. + +## Core Rules + +- Movies and series do not have the same requirements. +- Series are expected to be contained within a single parent show folder. +- Movies can be stored either in a dedicated movie folder or as loose files. +- Mixed libraries are supported, but they rely on heuristics and are more likely to become ambiguous + when names and folders are inconsistent. +- Provider ID tags such as `{tvdb-12345}`, `{tmdb-12345}`, or `{imdb-tt1234567}` are strongly + recommended because they improve matching and reduce ambiguity. + +## Supported Series Layouts + +### Recommended series layout + +```text +/television/Show Name (2024) {tvdb-12345}/Season 01/Show Name - S01E01 - Pilot.mkv +``` + +This is the clearest and most reliable structure for series. + +### Also supported + +Series content is supported in these layouts: + +- Show folder with `Season XX` directories. +- Show folder with `Season XX - extra text` directories, such as arc names. +- Show folder with numeric season directories like `01` when the surrounding context still looks + like series content. +- Show folder with `Specials` directories. +- Show folder with `Extras` directories. Silo maps these to season `0`. +- Show folder with episode files directly under the show folder when the filenames contain a + supported episodic token such as `S01E03`. + +Examples: + +```text +/tv/Series Name (2008)/Season 01/Series.Name.S01E01.mkv +/tv/Show Name/Specials/Show.Name.S00E01.mkv +/tv/Show Name/Extras/Show.Name.S00E01.mkv +/mixed/Show Name/01/Show Name S01E03.mkv +/mixed/Show Name/Show Name S01E03.mkv +``` + +One narrow edge case is worth calling out separately: + +```text +/television/anime/Series Name/Season 01 - Arc 01 - Arc Name/Series.Name.E01.1080p.mkv +``` + +That kind of path can still help Silo recognize the show root because the folder layout is +strong, but bare `E01` is not a generally supported episode parsing format. + +## Supported Episode Filename Patterns + +Silo currently treats `SxxExx` as the supported episode token. + +Supported examples: + +```text +Show Name - S01E01 - Pilot.mkv +Show Name.S01E03.mkv +Show Name - S00E01 - Special.mkv +Show Name - S01E01.001 - Pilot [Bluray-1080p][x265].mkv +``` + +Important details: + +- The parser only needs the `SxxExx` part. Extra text after it is tolerated. +- Decimal suffixes such as `S01E01.001` are supported because the parser still recognizes the + leading `S01E01`. +- Release metadata and noisy suffixes after the episode token are tolerated as long as the file + still contains a valid `SxxExx` token. +- `Specials` and `Extras` use season `0`, so `S00E01` is supported. + +Representative validated pattern from the dev server: + +```text +Series Name (2017) - S01E01.001 - Episode Title [Bluray-1080p][10bit][x265][AC3 5.1][EN+JA]-GROUP][.mkv +``` + +## Series Patterns That Are Not Reliably Supported + +These patterns should be treated as unsupported or ambiguous: + +- Unrelated episodes from different series thrown into the same folder. +- Episode files that use only absolute numbering, such as `Stone Ocean 24.mkv`, without `SxxExx`. +- Bare `E01` filenames as a general naming convention. + Silo has a narrow test case where `E01` still helps identify a season-folder layout, but it + is not a generally supported episode parsing format. +- Series files with no clear show folder, no supported episode token, and no provider-tagged parent + folder. + +If a series library contains a flat dump of unrelated episodes, Silo can collapse them into +the same inferred root because the current model assumes one parent show folder per series. + +When Silo cannot confidently classify or reconcile a root, it can surface the content as an +explicitly ambiguous item instead of auto-matching it. In practice, unsupported layouts often show +up as ambiguous or pending items rather than as clean matches. + +## Supported Movie Layouts + +Movies are more flexible than series. + +### Recommended movie layout + +```text +/movies/Movie Name (2016)/Movie.Name.2016.1080p.BluRay.mkv +``` + +### Also supported + +- Loose movie files in a movie library. +- Dedicated movie folders with provider tags. +- Release-style filenames inside a trusted movie folder. + +Examples: + +```text +/movies/Movie Name (2016)/Movie.Name.2016.1080p.BluRay.mkv +/movies/Movie Name [imdbid-tt1234567]/Movie Name.mp4 +/movies/Loose Movie.2024.2160p.WEB-DL.mkv +/movies/Movie Name {imdb-tt1234567} {tmdb-12345}/Movie.Name (2023) [Remux-1080p].mkv +``` + +Silo can infer a synthetic root for a truly loose movie file when there is no trusted movie +folder around it. + +## Supported Mixed-Library Behavior + +Mixed libraries are supported, but they are heuristic-driven. + +Silo resolves mixed-library content roughly like this: + +- If the path has season structure, it is treated as series. +- If the enclosing folder looks like a movie folder, it is treated as a movie. +- If the filename contains `SxxExx`, it is treated as series. +- Otherwise it falls back to movie. + +This means mixed libraries can work well, but they are less deterministic than separate movie and +series libraries. + +## Provider Tags + +Provider tags are supported in folder names and are strongly recommended. + +Supported formats include: + +```text +Series Name (2024) {tvdb-12345} +Movie Name (2024) {tmdb-12345} +Movie Name [imdbid-tt1234567] +``` + +Benefits of provider tags: + +- More reliable initial skeleton creation. +- Better deduplication. +- Less ambiguity in mixed libraries. +- Better resilience when release filenames diverge from the human title. + +## Sidecar Files and Supplemental Files + +Silo often sees sidecar files that mirror the media basename: + +- `.nfo` +- `.bif` +- `-thumb.jpg` +- `tvshow.nfo` +- `season.nfo` +- posters, banners, logos, and fanart + +These are common and expected. Episode naming guidance in this page refers to the media files +themselves, but sidecars may legitimately reuse the same stem. + +Some movie supplemental content is intentionally skipped, such as: + +- `Featurettes` +- `Behind the Scenes` +- `Deleted Scenes` +- `Trailers` +- `Sample.mkv` + +## What The Dev Anime Library Validated + +A large anime series library on the dev server was audited to validate that this page reflects +real-world usage and not just unit tests. + +Observed on `2026-04-11`: + +- `4038` top-level show directories. +- `4029` show directories with at least one `Season XX` directory. +- `1184` show directories with a `Specials` directory. +- `4036` show directories with a provider tag in the folder name. +- `474304` files matching `SxxExx.xxx`. +- `87289` `.mkv` files matching `SxxExx.xxx`. + +That makes `SxxExx.xxx` an important supported pattern, not a corner case. + +## Recommended Practices + +- Put every series inside its own parent show folder. +- Use `Season XX` and `Specials` when possible. +- Include the year in the top-level folder when it is known. +- Add provider tags when you can. +- Keep movies and series in separate libraries unless you specifically want mixed-library + heuristics. +- Do not rely on flat folders of unrelated episodes. + +## Source References + +- `internal/naming/filename.go` +- `internal/naming/root_inference.go` +- `internal/scanner/scanner.go` +- `internal/metadata/service.go` +- `internal/naming/filename_test.go` +- `internal/scanner/root_observation_test.go` +- `internal/metadata/service_test.go` diff --git a/docs/wiki/index.md b/docs/wiki/index.md new file mode 100644 index 00000000..92b01ce5 --- /dev/null +++ b/docs/wiki/index.md @@ -0,0 +1,57 @@ +--- +title: Silo Wiki +description: User-facing Silo documentation written in Markdown for in-repo use and Wiki.js sync. +summary: Index of digestible Silo docs by audience and subject. +tags: + - silo + - docs + - wiki +audience: + - end-user + - operator +last_reviewed: 2026-04-11 +related: [] +--- + +# Silo Wiki + +This directory is the repo-local home for user-digestible Silo documentation. Pages here +should stay portable Markdown so they can render cleanly in GitHub and sync into Wiki.js without +rewriting. + +## Sections + +## Getting Started + +- No pages yet. + +## Features + +- No pages yet. + +## Admin + +- [Supported Media Folder Structures and Naming](admin/media-folder-and-naming.md) - Accurate + reference for the folder layouts and filenames Silo can scan and match today. +- [Collection Templates](admin/collection-templates.md) - Curated, one-click starting points for + synced library collections sourced from TMDB, Trakt, and MDBList. + +## Deployment + +- No pages yet. + +## Troubleshooting + +- No pages yet. + +## Editing Rules + +- Keep pages in `docs/wiki/` digestible for the intended audience. +- Prefer updating existing pages over creating duplicates. +- Use YAML frontmatter and portable Markdown. +- Add `## Source References` sections instead of copying code into docs. +- Reserve `docs/wiki/` for end-user and operator docs. Keep architecture, specs, and plans in + `docs/architecture/` or `docs/superpowers/`. +- When a page is added, replace the matching `No pages yet.` line with bullet entries in this form: + `- [Page Title](section/file.md) - one-line summary` +- When a section already has pages, append a new bullet instead of adding prose. diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..962d1bd0 --- /dev/null +++ b/go.mod @@ -0,0 +1,80 @@ +module github.com/Silo-Server/silo-server + +go 1.26.3 + +require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 + github.com/go-chi/chi/v5 v5.2.5 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/mattn/go-sqlite3 v1.14.34 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 // indirect + github.com/redis/go-redis/v9 v9.18.0 + golang.org/x/crypto v0.50.0 + golang.org/x/sys v0.43.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/go-chi/cors v1.2.2 + github.com/gorilla/websocket v1.5.3 + github.com/h2non/bimg v1.1.9 + github.com/hashicorp/go-hclog v1.6.3 + github.com/joho/godotenv v1.5.1 + github.com/oklog/ulid/v2 v2.1.0 + github.com/pgvector/pgvector-go v0.3.0 + go.n16f.net/thumbhash v1.1.0 + golang.org/x/image v0.39.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 +) + +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect + golang.org/x/net v0.53.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect +) + +require ( + github.com/Silo-Server/silo-plugin-sdk v0.4.0 + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/smithy-go v1.24.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/hashicorp/go-plugin v1.7.0 + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/robfig/cron/v3 v3.0.1 + github.com/sony/sonyflake v1.3.0 + go.opentelemetry.io/otel v1.41.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sync v0.20.0 + golang.org/x/text v0.36.0 + golang.org/x/time v0.14.0 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.11 +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..09c130bb --- /dev/null +++ b/go.sum @@ -0,0 +1,233 @@ +entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= +entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= +github.com/Silo-Server/silo-plugin-sdk v0.4.0 h1:DJkRROQfr/kfwnF5dUdkhmbCso1KDLZc7uK7YfJnaO0= +github.com/Silo-Server/silo-plugin-sdk v0.4.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg= +github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc= +github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sony/sonyflake v1.3.0 h1:tiB4Dlp0lnmKp/h6BLXA14P8Qi+LYS9+0QRpcrKHvg4= +github.com/sony/sonyflake v1.3.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk= +go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= +go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= +go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= +go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= diff --git a/internal/access/context.go b/internal/access/context.go new file mode 100644 index 00000000..a464b740 --- /dev/null +++ b/internal/access/context.go @@ -0,0 +1,18 @@ +package access + +import "context" + +type contextKey string + +const scopeKey contextKey = "access_scope" + +// SetScope stores a resolved access scope in context. +func SetScope(ctx context.Context, scope Scope) context.Context { + return context.WithValue(ctx, scopeKey, scope) +} + +// GetScope retrieves the resolved access scope from context. +func GetScope(ctx context.Context) (Scope, bool) { + scope, ok := ctx.Value(scopeKey).(Scope) + return scope, ok +} diff --git a/internal/access/errors.go b/internal/access/errors.go new file mode 100644 index 00000000..2bda5adc --- /dev/null +++ b/internal/access/errors.go @@ -0,0 +1,8 @@ +package access + +import "errors" + +var ( + ErrProfileNotFound = errors.New("profile not found") + ErrProfileUnverified = errors.New("profile unverified") +) diff --git a/internal/access/profile_token.go b/internal/access/profile_token.go new file mode 100644 index 00000000..4e117682 --- /dev/null +++ b/internal/access/profile_token.go @@ -0,0 +1,83 @@ +package access + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ProfileTokenService mints and validates profile verification tokens. +type ProfileTokenService struct { + secret []byte + ttl time.Duration +} + +type profileJWTClaims struct { + UserID int `json:"user_id"` + SessionID string `json:"session_id"` + ProfileID string `json:"profile_id"` + PolicyRevision int64 `json:"policy_revision"` + jwt.RegisteredClaims +} + +// NewProfileTokenService creates a profile token service. +func NewProfileTokenService(secret string, ttl time.Duration) *ProfileTokenService { + return &ProfileTokenService{secret: []byte(secret), ttl: ttl} +} + +// Mint creates a signed profile token. When ttl is zero or negative, the token +// is durable and is invalidated by session/profile/policy checks instead. +func (s *ProfileTokenService) Mint(claims ProfileTokenClaims) (string, time.Time, error) { + now := time.Now().UTC() + var expiresAt time.Time + registeredClaims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + } + if s.ttl > 0 { + expiresAt = now.Add(s.ttl) + registeredClaims.ExpiresAt = jwt.NewNumericDate(expiresAt) + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, profileJWTClaims{ + UserID: claims.UserID, + SessionID: claims.SessionID, + ProfileID: claims.ProfileID, + PolicyRevision: claims.PolicyRevision, + RegisteredClaims: registeredClaims, + }) + signed, err := token.SignedString(s.secret) + if err != nil { + return "", time.Time{}, fmt.Errorf("signing profile token: %w", err) + } + return signed, expiresAt, nil +} + +// Validate parses and validates a profile token. +func (s *ProfileTokenService) Validate(tokenStr string) (*ProfileTokenClaims, error) { + if tokenStr == "" { + return nil, ErrProfileUnverified + } + claims := &profileJWTClaims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return s.secret, nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, fmt.Errorf("%w: expired", ErrProfileUnverified) + } + return nil, fmt.Errorf("%w: %v", ErrProfileUnverified, err) + } + if !token.Valid { + return nil, ErrProfileUnverified + } + return &ProfileTokenClaims{ + UserID: claims.UserID, + SessionID: claims.SessionID, + ProfileID: claims.ProfileID, + PolicyRevision: claims.PolicyRevision, + }, nil +} diff --git a/internal/access/quality.go b/internal/access/quality.go new file mode 100644 index 00000000..419f8da6 --- /dev/null +++ b/internal/access/quality.go @@ -0,0 +1,81 @@ +package access + +import "strings" + +var qualityRank = map[string]int{ + "": 0, + "480P": 1, + "720P": 2, + "1080P": 3, + "2160P": 4, + "4320P": 5, +} + +const ( + PlaybackQualityStandard = "1080p" + PlaybackQuality4K = "2160p" +) + +// ParsePlaybackQualityPreset normalizes user-facing playback quality presets +// into canonical stored values. +func ParsePlaybackQualityPreset(value string) (string, bool) { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "", "ANY": + return "", true + case "STANDARD", "480P", "720P", "1080P": + return PlaybackQualityStandard, true + case "4K", "UHD", "2160P", "4320P": + return PlaybackQuality4K, true + default: + return "", false + } +} + +// NormalizePlaybackQuality collapses legacy and preset values into the +// canonical policy values used throughout the app. +func NormalizePlaybackQuality(value string) string { + if normalized, ok := ParsePlaybackQualityPreset(value); ok { + return normalized + } + return "" +} + +// CompareQuality compares normalized playback qualities. +// It returns -1 when a < b, 0 when equal, and 1 when a > b. +func CompareQuality(a, b string) int { + aRank := qualityRank[strings.ToUpper(strings.TrimSpace(a))] + bRank := qualityRank[strings.ToUpper(strings.TrimSpace(b))] + switch { + case aRank < bRank: + return -1 + case aRank > bRank: + return 1 + default: + return 0 + } +} + +// QualityAllowed reports whether the file quality fits within the ceiling. +func QualityAllowed(fileQuality, ceiling string) bool { + ceiling = NormalizePlaybackQuality(ceiling) + if strings.TrimSpace(ceiling) == "" { + return true + } + return CompareQuality(fileQuality, ceiling) <= 0 +} + +// MinQuality returns the stricter non-empty quality ceiling. +func MinQuality(a, b string) string { + a = NormalizePlaybackQuality(a) + b = NormalizePlaybackQuality(b) + switch { + case strings.TrimSpace(a) == "": + return b + case strings.TrimSpace(b) == "": + return a + case CompareQuality(a, b) <= 0: + return a + default: + return b + } +} diff --git a/internal/access/quality_test.go b/internal/access/quality_test.go new file mode 100644 index 00000000..ed6a664a --- /dev/null +++ b/internal/access/quality_test.go @@ -0,0 +1,56 @@ +package access + +import "testing" + +func TestQuality_Order(t *testing.T) { + if CompareQuality("480p", "720p") >= 0 { + t.Fatal("expected 480p < 720p") + } + if CompareQuality("720p", "1080p") >= 0 { + t.Fatal("expected 720p < 1080p") + } + if CompareQuality("1080p", "2160p") >= 0 { + t.Fatal("expected 1080p < 2160p") + } + if CompareQuality("2160p", "4320p") >= 0 { + t.Fatal("expected 2160p < 4320p") + } +} + +func TestQuality_FitsWithinCeiling(t *testing.T) { + if !QualityAllowed("720p", "1080p") { + t.Fatal("expected 720p under 1080p ceiling") + } + if QualityAllowed("2160p", "1080p") { + t.Fatal("expected 2160p over 1080p ceiling") + } + if QualityAllowed("4320p", "2160p") { + t.Fatal("expected 4320p over 2160p ceiling") + } +} + +func TestParsePlaybackQualityPreset(t *testing.T) { + tests := map[string]string{ + "": "", + "any": "", + "standard": "1080p", + "720p": "1080p", + "1080p": "1080p", + "4k": "2160p", + "2160p": "2160p", + } + + for input, want := range tests { + got, ok := ParsePlaybackQualityPreset(input) + if !ok { + t.Fatalf("ParsePlaybackQualityPreset(%q) returned ok=false", input) + } + if got != want { + t.Fatalf("ParsePlaybackQualityPreset(%q) = %q, want %q", input, got, want) + } + } + + if _, ok := ParsePlaybackQualityPreset("banana"); ok { + t.Fatal("expected invalid preset to return ok=false") + } +} diff --git a/internal/access/rating.go b/internal/access/rating.go new file mode 100644 index 00000000..a2642647 --- /dev/null +++ b/internal/access/rating.go @@ -0,0 +1,75 @@ +package access + +import "strings" + +var ratingRank = map[string]int{ + "G": 0, "TV-Y": 0, "TV-G": 0, + "PG": 1, "TV-Y7": 1, "TV-PG": 1, + "PG-13": 2, "TV-14": 2, + "R": 3, "NC-17": 3, "TV-MA": 3, +} + +type RatingRankEntry struct { + Rating string + Rank int +} + +var ratingRankEntries = []RatingRankEntry{ + {Rating: "G", Rank: 0}, + {Rating: "TV-Y", Rank: 0}, + {Rating: "TV-G", Rank: 0}, + {Rating: "PG", Rank: 1}, + {Rating: "TV-Y7", Rank: 1}, + {Rating: "TV-PG", Rank: 1}, + {Rating: "PG-13", Rank: 2}, + {Rating: "TV-14", Rank: 2}, + {Rating: "R", Rank: 3}, + {Rating: "NC-17", Rank: 3}, + {Rating: "TV-MA", Rank: 3}, +} + +// RatingAllowed reports whether a content rating is visible under the ceiling. +func RatingAllowed(rating, ceiling string) bool { + if strings.TrimSpace(ceiling) == "" { + return true + } + ratingValue, ok := ratingRank[strings.ToUpper(strings.TrimSpace(rating))] + if !ok { + return false + } + ceilingValue, ok := ratingRank[strings.ToUpper(strings.TrimSpace(ceiling))] + if !ok { + return false + } + return ratingValue <= ceilingValue +} + +// AllowedRatingsUpTo returns the normalized ratings permitted by the ceiling. +func AllowedRatingsUpTo(ceiling string) []string { + if strings.TrimSpace(ceiling) == "" { + return nil + } + ceilingValue, ok := ratingRank[strings.ToUpper(strings.TrimSpace(ceiling))] + if !ok { + return []string{} + } + + ratings := make([]string, 0, len(ratingRank)) + for rating, value := range ratingRank { + if value <= ceilingValue { + ratings = append(ratings, rating) + } + } + return ratings +} + +// RatingRank returns the maturity rank for a normalized content rating. +func RatingRank(rating string) (int, bool) { + rank, ok := ratingRank[strings.ToUpper(strings.TrimSpace(rating))] + return rank, ok +} + +// RatingRankEntries exposes the canonical maturity ordering used for access and sorting. +func RatingRankEntries() []RatingRankEntry { + return append([]RatingRankEntry(nil), ratingRankEntries...) +} diff --git a/internal/access/resolver.go b/internal/access/resolver.go new file mode 100644 index 00000000..3da3d22f --- /dev/null +++ b/internal/access/resolver.go @@ -0,0 +1,205 @@ +package access + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// settingKeyDisabledLibraryIDs is the user-settings key that stores a JSON +// array of library IDs the user has chosen to hide. +const settingKeyDisabledLibraryIDs = "disabled_library_ids" + +// UserRepository loads account-level access settings. +type UserRepository interface { + GetByID(ctx context.Context, id int) (*models.User, error) +} + +// ProfileTokenValidator validates short-lived profile verification tokens. +type ProfileTokenValidator interface { + Validate(tokenStr string) (*ProfileTokenClaims, error) +} + +// Resolver resolves a viewer request into an effective access scope. +type Resolver struct { + users UserRepository + storeFactory userstore.UserStoreProvider + tokens ProfileTokenValidator +} + +// NewResolver creates a new scope resolver. +func NewResolver(users UserRepository, storeFactory userstore.UserStoreProvider, tokens ProfileTokenValidator) *Resolver { + return &Resolver{ + users: users, + storeFactory: storeFactory, + tokens: tokens, + } +} + +// Resolve computes the effective viewer scope for the request. +func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, error) { + user, err := r.users.GetByID(ctx, input.UserID) + if err != nil { + return Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err) + } + + scope := Scope{ + UserID: user.ID, + ProfileID: input.ProfileID, + AllowedLibraryIDs: cloneInts(user.LibraryIDs), + LibrariesRestricted: user.LibraryIDs != nil, + MaxPlaybackQuality: NormalizePlaybackQuality(user.MaxPlaybackQuality), + PolicyRevision: user.AccessPolicyRevision, + ProfileVerified: input.ProfileID == "", + } + + store, err := r.storeFactory.ForUser(ctx, input.UserID) + if err != nil { + return Scope{}, fmt.Errorf("opening user store for %d: %w", input.UserID, err) + } + + if input.ProfileID != "" { + profile, err := store.GetProfile(ctx, input.ProfileID) + if err != nil { + return Scope{}, fmt.Errorf("loading profile %s: %w", input.ProfileID, err) + } + if profile == nil { + return Scope{}, ErrProfileNotFound + } + + scope.MaxContentRating = profile.MaxContentRating + scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality)) + scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile) + scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification + + if profile.PINHash != "" && !input.SkipPINVerification { + if r.tokens == nil { + return Scope{}, ErrProfileUnverified + } + claims, err := r.tokens.Validate(input.ProfileToken) + if err != nil { + return Scope{}, err + } + if claims.UserID != user.ID || claims.SessionID != input.SessionID || claims.ProfileID != profile.ID || claims.PolicyRevision != user.AccessPolicyRevision { + return Scope{}, ErrProfileUnverified + } + scope.ProfileVerified = true + } + } + + // Apply user-level disabled library IDs setting. + disabled := loadDisabledLibraryIDs(ctx, store) + if len(disabled) > 0 { + if scope.AllowedLibraryIDs != nil { + // Restricted user: subtract disabled IDs from the allowed set. + scope.AllowedLibraryIDs = subtractInts(scope.AllowedLibraryIDs, disabled) + } else { + // Unrestricted user: pass disabled IDs through so query layer + // can apply a NOT IN filter. + scope.DisabledLibraryIDs = disabled + } + } + + return scope, nil +} + +// loadDisabledLibraryIDs reads and parses the disabled_library_ids user setting. +func loadDisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int { + raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs) + if err != nil || raw == "" { + return nil + } + var ids []int + if err := json.Unmarshal([]byte(raw), &ids); err != nil { + return nil + } + // Filter out invalid values. + n := 0 + for _, id := range ids { + if id > 0 { + ids[n] = id + n++ + } + } + return ids[:n] +} + +// subtractInts removes all values in exclude from src, preserving order. +func subtractInts(src, exclude []int) []int { + if len(exclude) == 0 { + return src + } + set := make(map[int]struct{}, len(exclude)) + for _, id := range exclude { + set[id] = struct{}{} + } + out := make([]int, 0, len(src)) + for _, id := range src { + if _, ok := set[id]; !ok { + out = append(out, id) + } + } + return out +} + +func effectiveLibraries(accountLibraryIDs []int, profile *userstore.Profile) ([]int, bool) { + accountRestricted := accountLibraryIDs != nil + profileRestricted := profile.LibraryRestrictionsEnabled + + switch { + case accountRestricted && profileRestricted: + return intersectInts(accountLibraryIDs, profile.AllowedLibraryIDs), true + case accountRestricted: + return cloneInts(accountLibraryIDs), true + case profileRestricted: + return sortedUniqueInts(profile.AllowedLibraryIDs), true + default: + return nil, false + } +} + +func intersectInts(left, right []int) []int { + if len(left) == 0 || len(right) == 0 { + return []int{} + } + set := make(map[int]struct{}, len(left)) + for _, id := range left { + set[id] = struct{}{} + } + var out []int + for _, id := range right { + if _, ok := set[id]; ok { + out = append(out, id) + } + } + return sortedUniqueInts(out) +} + +func sortedUniqueInts(values []int) []int { + if len(values) == 0 { + return []int{} + } + out := cloneInts(values) + sort.Ints(out) + n := 1 + for i := 1; i < len(out); i++ { + if out[i] != out[i-1] { + out[n] = out[i] + n++ + } + } + return out[:n] +} + +func cloneInts(values []int) []int { + if values == nil { + return nil + } + out := make([]int, len(values)) + copy(out, values) + return out +} diff --git a/internal/access/resolver_test.go b/internal/access/resolver_test.go new file mode 100644 index 00000000..6568ed21 --- /dev/null +++ b/internal/access/resolver_test.go @@ -0,0 +1,410 @@ +package access + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +type stubUserRepo struct { + user *models.User + err error +} + +func (s stubUserRepo) GetByID(_ context.Context, id int) (*models.User, error) { + if s.err != nil { + return nil, s.err + } + if s.user == nil || s.user.ID != id { + return nil, errors.New("user not found") + } + return s.user, nil +} + +type stubStoreProvider struct { + store userstore.UserStore + err error +} + +func (s stubStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) { + return s.store, s.err +} +func (s stubStoreProvider) Close() error { return nil } + +type stubStore struct { + profile *userstore.Profile + err error + settings map[string]string +} + +func (s stubStore) CreateProfile(context.Context, userstore.Profile) error { panic("unused") } +func (s stubStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) { + if s.err != nil { + return nil, s.err + } + if s.profile == nil || s.profile.ID != id { + return nil, nil + } + return s.profile, nil +} +func (s stubStore) ListProfiles(context.Context) ([]userstore.Profile, error) { panic("unused") } +func (s stubStore) UpdateProfile(context.Context, string, userstore.UpdateProfileInput) error { + panic("unused") +} +func (s stubStore) DeleteProfile(context.Context, string) error { panic("unused") } +func (s stubStore) VerifyPIN(context.Context, string, string) (bool, error) { panic("unused") } +func (s stubStore) UpdateProgress(context.Context, string, string, float64, float64, userstore.ProgressThresholds) error { + panic("unused") +} +func (s stubStore) SetProgress(context.Context, string, string, float64, float64, userstore.ProgressThresholds) error { + panic("unused") +} +func (s stubStore) SetProgressAt(context.Context, string, string, float64, float64, bool, time.Time) error { + panic("unused") +} +func (s stubStore) SetProgressIfNewer(context.Context, string, string, float64, float64, bool, time.Time) (bool, error) { + panic("unused") +} +func (s stubStore) UpdateProgressHints(_ context.Context, _, _ string, _ userstore.VersionHints) error { + return nil +} +func (s stubStore) MarkWatched(context.Context, string, string, float64) error { panic("unused") } +func (s stubStore) MarkProgressBatch(context.Context, string, []string, time.Time) error { + panic("unused") +} +func (s stubStore) ClearProgressBatch(context.Context, string, []string, time.Time) error { + panic("unused") +} +func (s stubStore) ClearProgress(context.Context, string, string) error { panic("unused") } +func (s stubStore) GetProgress(context.Context, string, string) (*userstore.WatchProgress, error) { + panic("unused") +} +func (s stubStore) ListProgress(context.Context, string, string, int, int) ([]userstore.WatchProgress, error) { + panic("unused") +} +func (s stubStore) ListProgressByMediaItems(context.Context, string, []string) (map[string]userstore.WatchProgress, error) { + panic("unused") +} +func (s stubStore) AddHistory(context.Context, userstore.WatchHistoryEntry) error { panic("unused") } +func (s stubStore) AddHistoryIfMissing(context.Context, userstore.WatchHistoryEntry) (bool, error) { + panic("unused") +} +func (s stubStore) ListHistory(context.Context, string, int, int) ([]userstore.WatchHistoryEntry, error) { + panic("unused") +} +func (s stubStore) ListCompletedHistory(context.Context, userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) { + panic("unused") +} +func (s stubStore) RemoveHistoryItems(context.Context, string, []string, time.Time) error { + panic("unused") +} +func (s stubStore) DeleteHistoryBySource(context.Context, string, []string, userstore.WatchHistorySource) error { + panic("unused") +} +func (s stubStore) ListHomeDismissals(context.Context, string, string) ([]userstore.HomeItemDismissal, error) { + panic("unused") +} +func (s stubStore) UpsertHomeDismissal(context.Context, userstore.HomeItemDismissal) error { + panic("unused") +} +func (s stubStore) DeleteHomeDismissal(context.Context, string, string, string) error { + panic("unused") +} +func (s stubStore) AddFavorite(context.Context, string, string) error { panic("unused") } +func (s stubStore) AddFavoriteAt(context.Context, string, string, time.Time) error { + panic("unused") +} +func (s stubStore) RemoveFavorite(context.Context, string, string) error { + panic("unused") +} +func (s stubStore) ListFavorites(context.Context, string, int, int) ([]userstore.Favorite, error) { + panic("unused") +} +func (s stubStore) ListFavoritesByMediaItems(context.Context, string, []string) (map[string]bool, error) { + panic("unused") +} +func (s stubStore) IsFavorite(context.Context, string, string) (bool, error) { panic("unused") } +func (s stubStore) AddToWatchlist(context.Context, string, string) error { panic("unused") } +func (s stubStore) RemoveFromWatchlist(context.Context, string, string) error { panic("unused") } +func (s stubStore) ListWatchlist(context.Context, string, int, int) ([]userstore.WatchlistEntry, error) { + panic("unused") +} +func (s stubStore) ListWatchlistByMediaItems(context.Context, string, []string) (map[string]bool, error) { + panic("unused") +} +func (s stubStore) InWatchlist(context.Context, string, string) (bool, error) { panic("unused") } +func (s stubStore) CreateCollection(context.Context, userstore.CreateCollectionInput) (*userstore.Collection, error) { + panic("unused") +} +func (s stubStore) GetCollection(context.Context, string) (*userstore.Collection, error) { + panic("unused") +} +func (s stubStore) ListCollections(context.Context, string) ([]userstore.Collection, error) { + panic("unused") +} +func (s stubStore) UpdateCollection(context.Context, userstore.UpdateCollectionInput) error { + panic("unused") +} +func (s stubStore) DeleteCollection(context.Context, string) error { panic("unused") } +func (s stubStore) AddCollectionItem(context.Context, string, string, int) error { + panic("unused") +} +func (s stubStore) RemoveCollectionItem(context.Context, string, string) error { panic("unused") } +func (s stubStore) ListCollectionItems(context.Context, string) ([]userstore.CollectionItem, error) { + panic("unused") +} +func (s stubStore) ReplaceCollectionItems(context.Context, string, []userstore.CollectionItemReplacement) error { + panic("unused") +} +func (s stubStore) ReorderCollectionItems(context.Context, string, []string) error { + panic("unused") +} +func (s stubStore) ReorderCollections(context.Context, string, *string, []string) error { + panic("unused") +} +func (s stubStore) UpdateCollectionSyncState(context.Context, userstore.UpdateCollectionSyncStateInput) error { + panic("unused") +} +func (s stubStore) ListCollectionGroups(context.Context) ([]userstore.CollectionGroup, error) { + panic("unused") +} +func (s stubStore) EnsureCollectionGroup(context.Context, string) error { panic("unused") } +func (s stubStore) CreateCollectionGroup(context.Context, string, string, userstore.GroupSortMode) (*userstore.CollectionGroup, error) { + panic("unused") +} +func (s stubStore) UpdateCollectionGroup(context.Context, string, *string, *string, *userstore.GroupSortMode) (*userstore.CollectionGroup, error) { + panic("unused") +} +func (s stubStore) DeleteCollectionGroup(context.Context, string) error { panic("unused") } +func (s stubStore) ReorderCollectionGroups(context.Context, []string) error { + panic("unused") +} +func (s stubStore) ListSectionOverrides(context.Context, string, string, string) ([]userstore.SectionOverride, error) { + panic("unused") +} +func (s stubStore) SaveSectionOverrides(context.Context, string, string, string, []userstore.SectionOverride) error { + panic("unused") +} +func (s stubStore) ResetSectionOverrides(context.Context, string, string, string) error { + panic("unused") +} +func (s stubStore) GetSetting(_ context.Context, key string) (string, error) { + if s.settings != nil { + return s.settings[key], nil + } + return "", nil +} +func (s stubStore) SetSetting(context.Context, string, string) error { panic("unused") } +func (s stubStore) DeleteSetting(context.Context, string) error { panic("unused") } +func (s stubStore) ListSettings(context.Context) ([]userstore.SettingEntry, error) { panic("unused") } +func (s stubStore) GetDeviceSetting(context.Context, string, string, string) (*userstore.DeviceSettingEntry, error) { + panic("unused") +} +func (s stubStore) SetDeviceSetting(context.Context, userstore.DeviceSettingEntry) error { + panic("unused") +} +func (s stubStore) DeleteDeviceSetting(context.Context, string, string, string) error { + panic("unused") +} +func (s stubStore) DeleteAllDeviceSettings(context.Context, string, string) error { panic("unused") } +func (s stubStore) DeleteDeviceSettingsByKey(context.Context, string) error { panic("unused") } +func (s stubStore) ListDeviceSettings(context.Context, string) ([]userstore.DeviceSettingEntry, error) { + panic("unused") +} +func (s stubStore) ListAllDeviceSettings(context.Context) ([]userstore.DeviceSettingEntry, error) { + panic("unused") +} +func (s stubStore) SetSubtitlePreference(context.Context, userstore.SubtitlePreference) error { + panic("unused") +} +func (s stubStore) GetSubtitlePreference(context.Context, string, string) (*userstore.SubtitlePreference, error) { + panic("unused") +} +func (s stubStore) DeleteSubtitlePreference(context.Context, string, string) error { panic("unused") } +func (s stubStore) SetAudioPreference(context.Context, userstore.AudioPreference) error { + panic("unused") +} +func (s stubStore) GetAudioPreference(context.Context, string, string) (*userstore.AudioPreference, error) { + panic("unused") +} +func (s stubStore) DeleteAudioPreference(context.Context, string, string) error { panic("unused") } +func (s stubStore) SetSeriesPlaybackPreference(context.Context, userstore.SeriesPlaybackPreference) error { + panic("unused") +} +func (s stubStore) GetSeriesPlaybackPreference(context.Context, string, string) (*userstore.SeriesPlaybackPreference, error) { + panic("unused") +} +func (s stubStore) DeleteSeriesPlaybackPreference(context.Context, string, string) error { + panic("unused") +} +func (s stubStore) GetLibraryPlaybackPreference(context.Context, string, int) (*userstore.LibraryPlaybackPreference, error) { + panic("unused") +} +func (s stubStore) ListLibraryPlaybackPreferences(context.Context, string) ([]userstore.LibraryPlaybackPreference, error) { + panic("unused") +} +func (s stubStore) UpsertLibraryPlaybackPreference(context.Context, userstore.LibraryPlaybackPreference) error { + panic("unused") +} +func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int) error { + panic("unused") +} + +func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) { + resolver := NewResolver( + stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}}, + stubStoreProvider{store: stubStore{profile: &userstore.Profile{ + ID: "prof-1", + LibraryRestrictionsEnabled: true, + AllowedLibraryIDs: []int{2, 4}, + MaxContentRating: "PG-13", + MaxPlaybackQuality: "720p", + }}}, + nil, + ) + + scope, err := resolver.Resolve(context.Background(), ResolveInput{ + UserID: 1, + SessionID: "sess-1", + ProfileID: "prof-1", + }) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if !scope.LibrariesRestricted || len(scope.AllowedLibraryIDs) != 2 || scope.AllowedLibraryIDs[0] != 2 || scope.AllowedLibraryIDs[1] != 4 { + t.Fatalf("unexpected scope libraries: %+v", scope) + } + if scope.MaxContentRating != "PG-13" || scope.MaxPlaybackQuality != "1080p" { + t.Fatalf("unexpected scope ceilings: %+v", scope) + } +} + +func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) { + resolver := NewResolver( + stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}}, + stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}}, + nil, + ) + + scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"}) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if !scope.LibrariesRestricted || len(scope.AllowedLibraryIDs) != 2 || scope.AllowedLibraryIDs[0] != 1 || scope.AllowedLibraryIDs[1] != 3 { + t.Fatalf("unexpected scope libraries: %+v", scope) + } + if scope.MaxPlaybackQuality != "1080p" { + t.Fatalf("MaxPlaybackQuality = %q, want 1080p", scope.MaxPlaybackQuality) + } +} + +func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) { + resolver := NewResolver( + stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}}, + stubStoreProvider{store: stubStore{profile: &userstore.Profile{ + ID: "prof-1", + LibraryRestrictionsEnabled: true, + AllowedLibraryIDs: []int{2, 4}, + }}}, + nil, + ) + + scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"}) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(scope.AllowedLibraryIDs) != 1 || scope.AllowedLibraryIDs[0] != 2 { + t.Fatalf("AllowedLibraryIDs = %v, want [2]", scope.AllowedLibraryIDs) + } +} + +func TestResolver_EmptyEffectiveLibraries(t *testing.T) { + resolver := NewResolver( + stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1}, AccessPolicyRevision: 4}}, + stubStoreProvider{store: stubStore{profile: &userstore.Profile{ + ID: "prof-1", + LibraryRestrictionsEnabled: true, + AllowedLibraryIDs: []int{2}, + }}}, + nil, + ) + + scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"}) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if len(scope.AllowedLibraryIDs) != 0 || !scope.LibrariesRestricted { + t.Fatalf("unexpected scope: %+v", scope) + } +} + +func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) { + resolver := NewResolver( + stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}}, + stubStoreProvider{store: stubStore{ + profile: &userstore.Profile{ID: "prof-1"}, + settings: map[string]string{"disabled_library_ids": "[3,5]"}, + }}, + nil, + ) + + scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"}) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + // Unrestricted user: AllowedLibraryIDs stays nil, DisabledLibraryIDs is populated. + if scope.AllowedLibraryIDs != nil { + t.Fatalf("AllowedLibraryIDs = %v, want nil", scope.AllowedLibraryIDs) + } + if len(scope.DisabledLibraryIDs) != 2 || scope.DisabledLibraryIDs[0] != 3 || scope.DisabledLibraryIDs[1] != 5 { + t.Fatalf("DisabledLibraryIDs = %v, want [3 5]", scope.DisabledLibraryIDs) + } +} + +func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) { + resolver := NewResolver( + stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}}, + stubStoreProvider{store: stubStore{ + profile: &userstore.Profile{ID: "prof-1"}, + settings: map[string]string{"disabled_library_ids": "[2,4]"}, + }}, + nil, + ) + + scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"}) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + // Restricted user: disabled IDs subtracted from AllowedLibraryIDs. + if len(scope.AllowedLibraryIDs) != 2 || scope.AllowedLibraryIDs[0] != 1 || scope.AllowedLibraryIDs[1] != 3 { + t.Fatalf("AllowedLibraryIDs = %v, want [1 3]", scope.AllowedLibraryIDs) + } + if len(scope.DisabledLibraryIDs) != 0 { + t.Fatalf("DisabledLibraryIDs = %v, want empty", scope.DisabledLibraryIDs) + } +} + +func TestResolver_DisabledLibraries_NoProfile(t *testing.T) { + resolver := NewResolver( + stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}}, + stubStoreProvider{store: stubStore{ + settings: map[string]string{"disabled_library_ids": "[7]"}, + }}, + nil, + ) + + scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1}) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if scope.AllowedLibraryIDs != nil { + t.Fatalf("AllowedLibraryIDs = %v, want nil", scope.AllowedLibraryIDs) + } + if len(scope.DisabledLibraryIDs) != 1 || scope.DisabledLibraryIDs[0] != 7 { + t.Fatalf("DisabledLibraryIDs = %v, want [7]", scope.DisabledLibraryIDs) + } +} diff --git a/internal/access/types.go b/internal/access/types.go new file mode 100644 index 00000000..8b25f259 --- /dev/null +++ b/internal/access/types.go @@ -0,0 +1,31 @@ +package access + +// Scope is the resolved effective access policy for a viewer request. +type Scope struct { + UserID int + ProfileID string + AllowedLibraryIDs []int + DisabledLibraryIDs []int // user-disabled libraries (only set when AllowedLibraryIDs is nil) + LibrariesRestricted bool + MaxContentRating string + MaxPlaybackQuality string + PolicyRevision int64 + ProfileVerified bool +} + +// ResolveInput is the request input for resolving a viewer access scope. +type ResolveInput struct { + UserID int + SessionID string + ProfileID string + ProfileToken string + SkipPINVerification bool +} + +// ProfileTokenClaims are the claims embedded in a verified profile token. +type ProfileTokenClaims struct { + UserID int + SessionID string + ProfileID string + PolicyRevision int64 +} diff --git a/internal/activitylog/cleanup.go b/internal/activitylog/cleanup.go new file mode 100644 index 00000000..51f324fc --- /dev/null +++ b/internal/activitylog/cleanup.go @@ -0,0 +1,135 @@ +package activitylog + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + keyRetentionDays = "activitylog.retention_days" + defaultRetentionStr = "90" + defaultRetention = 90 + cleanupBatchSize = 10000 +) + +// SettingsStore is satisfied by *catalog.ServerSettingsRepo. +type SettingsStore interface { + Get(ctx context.Context, key string) (string, error) + Set(ctx context.Context, key, value string) error +} + +type PartitionManager interface { + EnsureFuturePartitions(ctx context.Context) error + DropExpiredPartitions(ctx context.Context, cutoff time.Time) ([]string, error) + DeleteExpiredRowsFromDefault(ctx context.Context, cutoff time.Time) (int64, error) +} + +// SeedDefaults writes default activity log settings if not already set. +func SeedDefaults(ctx context.Context, store SettingsStore) error { + existing, err := store.Get(ctx, keyRetentionDays) + if err != nil { + return fmt.Errorf("seed activitylog defaults: %w", err) + } + if existing != "" { + return nil + } + return store.Set(ctx, keyRetentionDays, defaultRetentionStr) +} + +// RunCleanup starts a background goroutine that runs batched deletes daily. +// Blocks until ctx is cancelled. +func RunCleanup(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, pm PartitionManager) { + // Run once at startup, then every 24 hours + CleanupOnce(ctx, pool, store, pm) + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + CleanupOnce(ctx, pool, store, pm) + } + } +} + +// CleanupOnce runs a single activity log retention pass. +func CleanupOnce(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, pm PartitionManager) int64 { + days := defaultRetention + if raw, err := store.Get(ctx, keyRetentionDays); err == nil && raw != "" { + if d := parseInt(raw); d > 0 { + days = d + } + } + + cutoff := time.Now().UTC().AddDate(0, 0, -days) + if pm != nil { + if err := pm.EnsureFuturePartitions(ctx); err != nil { + slog.Warn("activitylog ensure future partitions error", "error", err) + } + + partitionCleanupFailed := false + totalDeleted := int64(0) + if dropped, err := pm.DropExpiredPartitions(ctx, cutoff); err != nil { + slog.Warn("activitylog partition cleanup error", "error", err) + partitionCleanupFailed = true + } else if len(dropped) > 0 { + slog.Info("activitylog dropped expired partitions", "partitions", dropped) + } + + if deleted, err := pm.DeleteExpiredRowsFromDefault(ctx, cutoff); err != nil { + slog.Warn("activitylog default partition cleanup error", "error", err) + partitionCleanupFailed = true + } else if deleted > 0 { + totalDeleted += deleted + slog.Info("activitylog default partition cleanup completed", "deleted", deleted, "retention_days", days) + } + + if !partitionCleanupFailed { + return totalDeleted + } + slog.Warn("activitylog partition cleanup degraded, falling back to row deletes", "retention_days", days) + } + + total := deleteExpiredRowsBefore(ctx, pool, cutoff) + if total > 0 { + slog.Info("activitylog cleanup completed", "deleted", total, "retention_days", days) + } + return total +} + +func deleteExpiredRowsBefore(ctx context.Context, pool *pgxpool.Pool, cutoff time.Time) int64 { + total := int64(0) + for { + result, err := pool.Exec(ctx, ` + DELETE FROM activity_log + WHERE id IN ( + SELECT id FROM activity_log + WHERE timestamp < $1 + LIMIT $2 + ) + `, cutoff, cleanupBatchSize) + if err != nil { + slog.Warn("activitylog cleanup error", "error", err) + return total + } + deleted := result.RowsAffected() + total += deleted + if deleted < int64(cleanupBatchSize) { + break + } + } + return total +} + +func parseInt(s string) int { + var v int + fmt.Sscanf(s, "%d", &v) + return v +} diff --git a/internal/activitylog/consumer.go b/internal/activitylog/consumer.go new file mode 100644 index 00000000..b0bd3f83 --- /dev/null +++ b/internal/activitylog/consumer.go @@ -0,0 +1,228 @@ +package activitylog + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "github.com/Silo-Server/silo-server/internal/logstream" +) + +// Lua script: atomically LRANGE + LTRIM to pop a batch from the Redis list. +var popBatchScript = redis.NewScript(` +local key = KEYS[1] +local count = tonumber(ARGV[1]) +local items = redis.call('LRANGE', key, 0, count - 1) +if #items > 0 then + redis.call('LTRIM', key, count, -1) +end +return items +`) + +// Consumer reads log entries from Redis (or a memory channel) and batch-inserts +// them into PostgreSQL. +type Consumer struct { + pool *pgxpool.Pool + redis *redis.Client // nil when Redis not configured + batchSize int + interval time.Duration + maxRetries int + streamHub *logstream.Hub +} + +// NewConsumer creates a new activity log consumer. +func NewConsumer(pool *pgxpool.Pool, redisClient *redis.Client, streamHub *logstream.Hub) *Consumer { + return &Consumer{ + pool: pool, + redis: redisClient, + batchSize: 100, + interval: 2 * time.Second, + maxRetries: 3, + streamHub: streamHub, + } +} + +// RunRedis starts the Redis consumer loop. Blocks until ctx is cancelled. +func (c *Consumer) RunRedis(ctx context.Context) { + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + // Final drain + c.drainRedis(context.Background()) + return + case <-ticker.C: + c.drainRedis(ctx) + } + } +} + +func (c *Consumer) drainRedis(ctx context.Context) { + for { + entries, err := c.popRedisBatch(ctx) + if err != nil { + slog.Warn("activitylog: Redis pop error", "error", err) + return + } + if len(entries) == 0 { + return + } + if err := c.insertBatchWithRetry(ctx, entries); err != nil { + slog.Error("activitylog: batch insert failed after retries, dropping batch", + "error", err, "count", len(entries)) + } + } +} + +func (c *Consumer) popRedisBatch(ctx context.Context) ([]LogEntry, error) { + result, err := popBatchScript.Run(ctx, c.redis, []string{redisKey}, c.batchSize).StringSlice() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, fmt.Errorf("pop batch script: %w", err) + } + + entries := make([]LogEntry, 0, len(result)) + for _, raw := range result { + var entry LogEntry + if err := json.Unmarshal([]byte(raw), &entry); err != nil { + slog.Warn("activitylog: skipping malformed entry", "error", err) + continue + } + entries = append(entries, entry) + } + return entries, nil +} + +func (c *Consumer) insertBatchWithRetry(ctx context.Context, entries []LogEntry) error { + var lastErr error + for attempt := 0; attempt < c.maxRetries; attempt++ { + if err := c.insertBatch(ctx, entries); err != nil { + lastErr = err + slog.Warn("activitylog: batch insert attempt failed", + "attempt", attempt+1, "error", err) + time.Sleep(time.Duration(attempt+1) * time.Second) + continue + } + return nil + } + return lastErr +} + +// RunMemory starts the in-memory consumer loop. Blocks until ctx is cancelled +// or the channel is closed. +func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) { + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + var batch []LogEntry + + for { + select { + case <-ctx.Done(): + if len(batch) > 0 { + if err := c.insertBatch(context.Background(), batch); err != nil { + slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err) + } + } + return + case entry, ok := <-ch: + if !ok { + if len(batch) > 0 { + if err := c.insertBatch(context.Background(), batch); err != nil { + slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err) + } + } + return + } + batch = append(batch, entry) + if len(batch) >= c.batchSize { + if err := c.insertBatch(ctx, batch); err != nil { + slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err) + } + batch = batch[:0] + } + case <-ticker.C: + if len(batch) > 0 { + if err := c.insertBatch(ctx, batch); err != nil { + slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err) + } + batch = batch[:0] + } + } + } +} + +// insertBatch performs a bulk INSERT into the activity_log table. +func (c *Consumer) insertBatch(ctx context.Context, entries []LogEntry) error { + if len(entries) == 0 { + return nil + } + + var b strings.Builder + b.WriteString("INSERT INTO activity_log (timestamp, client_ip, user_id, impersonator_user_id, session_id, playback_session_id, request_id, node_id, method, path, path_pattern, status_code, user_agent, duration_ms) VALUES ") + + args := make([]interface{}, 0, len(entries)*14) + for i, e := range entries { + if i > 0 { + b.WriteString(", ") + } + base := i * 14 + fmt.Fprintf(&b, "($%d, $%d::inet, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d)", + base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10, base+11, base+12, base+13, base+14) + args = append(args, e.Timestamp, e.ClientIP, e.UserID, e.ImpersonatorUserID, e.SessionID, e.PlaybackSessionID, + e.RequestID, e.NodeID, e.Method, e.Path, e.PathPattern, e.StatusCode, e.UserAgent, e.DurationMs) + } + b.WriteString(" RETURNING id, timestamp, client_ip::text, user_id, impersonator_user_id, COALESCE(session_id, ''), COALESCE(playback_session_id, ''), COALESCE(request_id, ''), COALESCE(node_id, ''), method, path, COALESCE(path_pattern, ''), COALESCE(status_code, 0), COALESCE(user_agent, ''), COALESCE(duration_ms, 0)") + + rows, err := c.pool.Query(ctx, b.String(), args...) + if err != nil { + return fmt.Errorf("batch insert: %w", err) + } + defer rows.Close() + + inserted := make([]AuditEntry, 0, len(entries)) + for rows.Next() { + var entry AuditEntry + if err := rows.Scan( + &entry.ID, + &entry.Timestamp, + &entry.ClientIP, + &entry.UserID, + &entry.ImpersonatorUserID, + &entry.SessionID, + &entry.PlaybackSessionID, + &entry.RequestID, + &entry.NodeID, + &entry.Method, + &entry.Path, + &entry.PathPattern, + &entry.StatusCode, + &entry.UserAgent, + &entry.DurationMs, + ); err != nil { + return fmt.Errorf("scan inserted activity log row: %w", err) + } + inserted = append(inserted, entry) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate inserted activity log rows: %w", err) + } + + for _, entry := range inserted { + if err := c.streamHub.PublishAppend(ctx, logstream.StreamAudit, entry); err != nil { + slog.Warn("activitylog: failed to publish log stream append", "error", err, "id", entry.ID) + } + } + + return nil +} diff --git a/internal/activitylog/entry.go b/internal/activitylog/entry.go new file mode 100644 index 00000000..c68c5486 --- /dev/null +++ b/internal/activitylog/entry.go @@ -0,0 +1,30 @@ +package activitylog + +import "time" + +// LogEntry represents a single activity log row. +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + ClientIP string `json:"client_ip"` + UserID *int `json:"user_id,omitempty"` + ImpersonatorUserID *int `json:"impersonator_user_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + PlaybackSessionID string `json:"playback_session_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + NodeID string `json:"node_id,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + PathPattern string `json:"path_pattern,omitempty"` + StatusCode int `json:"status_code"` + UserAgent string `json:"user_agent"` + DurationMs int `json:"duration_ms"` +} + +// Writer buffers log entries for asynchronous persistence. +type Writer interface { + // Write enqueues a log entry. Must not block. + Write(entry LogEntry) + + // Close flushes remaining entries and releases resources. + Close() error +} diff --git a/internal/activitylog/middleware.go b/internal/activitylog/middleware.go new file mode 100644 index 00000000..ff6b9fda --- /dev/null +++ b/internal/activitylog/middleware.go @@ -0,0 +1,188 @@ +package activitylog + +import ( + "bufio" + "context" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/Silo-Server/silo-server/internal/clientip" +) + +// excludedPrefixes are paths that should not be logged. +var excludedPrefixes = []string{ + "/api/v1/health", + "/api/v1/ready", + "/api/v1/admin/logs", +} + +// streamPrefixes are paths logged only at session-start (not per-chunk). +var streamPrefixes = []string{ + "/api/v1/stream/", + "/api/v1/playback/transcode/", +} + +// LogContext is a mutable holder stored in context BEFORE auth middleware. +// Auth middleware populates it, and the activity log middleware reads it +// after the handler chain completes. +type LogContext struct { + UserID *int + ImpersonatorUserID *int + SessionID string +} + +type logContextKey struct{} + +// PlaybackLogContext is a mutable holder for playback-specific correlation. +// Handlers update it once they know the playback session ID, and both the +// request logger and activity logger read it after the handler returns. +type PlaybackLogContext struct { + PlaybackSessionID string +} + +type playbackLogContextKey struct{} + +// SetLogContext stores a LogContext pointer in the request context. +func SetLogContext(ctx context.Context, lc *LogContext) context.Context { + return context.WithValue(ctx, logContextKey{}, lc) +} + +// GetLogContext retrieves the LogContext from the request context. +func GetLogContext(ctx context.Context) *LogContext { + lc, _ := ctx.Value(logContextKey{}).(*LogContext) + return lc +} + +func SetPlaybackLogContext(ctx context.Context, lc *PlaybackLogContext) context.Context { + return context.WithValue(ctx, playbackLogContextKey{}, lc) +} + +func GetPlaybackLogContext(ctx context.Context) *PlaybackLogContext { + lc, _ := ctx.Value(playbackLogContextKey{}).(*PlaybackLogContext) + return lc +} + +// NewMiddleware returns chi-compatible middleware that logs every request +// to the given Writer. It wraps ResponseWriter to capture the status code. +// It stores a mutable LogContext in the request context that downstream auth +// middleware can populate with user info. +func NewMiddleware(w Writer, nodeID string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + // Skip excluded endpoints + for _, prefix := range excludedPrefixes { + if strings.HasPrefix(path, prefix) { + next.ServeHTTP(rw, r) + return + } + } + + // Skip per-chunk stream requests (segments, manifests after start) + for _, prefix := range streamPrefixes { + if strings.HasPrefix(path, prefix) && isStreamChunk(path) { + next.ServeHTTP(rw, r) + return + } + } + + start := time.Now() + wrapped := &statusWriter{ResponseWriter: rw, status: http.StatusOK} + + // Reuse any existing LogContext so request logging and activity logging + // observe the same auth-populated values. + lc := GetLogContext(r.Context()) + if lc == nil { + lc = &LogContext{} + ctx := SetLogContext(r.Context(), lc) + r = r.WithContext(ctx) + } + playbackLC := GetPlaybackLogContext(r.Context()) + if playbackLC == nil { + playbackLC = &PlaybackLogContext{} + ctx := SetPlaybackLogContext(r.Context(), playbackLC) + r = r.WithContext(ctx) + } + + next.ServeHTTP(wrapped, r) + + pathPattern := path + if routeCtx := chi.RouteContext(r.Context()); routeCtx != nil { + if route := routeCtx.RoutePattern(); route != "" { + pathPattern = route + } + } + + // After handler chain completes, auth middleware has populated lc + entry := LogEntry{ + Timestamp: start, + ClientIP: clientip.FromContext(r.Context()), + UserID: lc.UserID, + ImpersonatorUserID: lc.ImpersonatorUserID, + SessionID: lc.SessionID, + PlaybackSessionID: playbackLC.PlaybackSessionID, + RequestID: middleware.GetReqID(r.Context()), + NodeID: nodeID, + Method: r.Method, + Path: path, + PathPattern: pathPattern, + StatusCode: wrapped.status, + UserAgent: r.UserAgent(), + DurationMs: int(time.Since(start).Milliseconds()), + } + + w.Write(entry) + }) + } +} + +// isStreamChunk returns true if the path looks like a stream segment/manifest +// chunk rather than a session-start request. +func isStreamChunk(path string) bool { + return strings.Contains(path, "/segment/") || + strings.Contains(path, "/master.m3u8") || + strings.Contains(path, "/subtitles/") +} + +// statusWriter wraps http.ResponseWriter to capture the status code. +type statusWriter struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func (w *statusWriter) WriteHeader(code int) { + if !w.wroteHeader { + w.status = code + w.wroteHeader = true + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.wroteHeader = true + } + return w.ResponseWriter.Write(b) +} + +// Hijack implements http.Hijacker, required for WebSocket upgrades. +func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := w.ResponseWriter.(http.Hijacker); ok { + return hj.Hijack() + } + return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") +} + +// Unwrap returns the underlying ResponseWriter, preserving http.Flusher, +// http.Hijacker, etc. (Go 1.20+ ResponseController pattern). +func (w *statusWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/internal/activitylog/repo.go b/internal/activitylog/repo.go new file mode 100644 index 00000000..0bc3b9af --- /dev/null +++ b/internal/activitylog/repo.go @@ -0,0 +1,276 @@ +package activitylog + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// UserIPEntry is a row in the per-user IP history view. +type UserIPEntry struct { + ClientIP string `json:"client_ip"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + RequestCount int `json:"request_count"` +} + +// IPUserEntry is a row in the per-IP user view. +type IPUserEntry struct { + UserID int `json:"user_id"` + Username string `json:"username"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + RequestCount int `json:"request_count"` +} + +type AuditEntry struct { + ID int64 `json:"id"` + Timestamp time.Time `json:"timestamp"` + ClientIP string `json:"client_ip"` + UserID *int `json:"user_id,omitempty"` + ImpersonatorUserID *int `json:"impersonator_user_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + PlaybackSessionID string `json:"playback_session_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + NodeID string `json:"node_id,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + PathPattern string `json:"path_pattern,omitempty"` + StatusCode int `json:"status_code"` + UserAgent string `json:"user_agent,omitempty"` + DurationMs int `json:"duration_ms"` +} + +type ListOptions struct { + From *time.Time + To *time.Time + Method string + StatusCode *int + PathPrefix string + ClientIP string + RequestID string + UserID *int + SessionID string + PlaybackSessionID string + Limit int + Cursor string +} + +type ListResult struct { + Entries []AuditEntry `json:"entries"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// Repo provides query access to the activity_log table. +type Repo struct { + pool *pgxpool.Pool +} + +// NewRepo creates a new activity log repository. +func NewRepo(pool *pgxpool.Pool) *Repo { + return &Repo{pool: pool} +} + +// UserIPs returns the IP addresses used by a given user within the lookback window. +func (r *Repo) UserIPs(ctx context.Context, userID int, days int) ([]UserIPEntry, error) { + rows, err := r.pool.Query(ctx, ` + SELECT client_ip::text, min(timestamp) AS first_seen, max(timestamp) AS last_seen, count(*) AS request_count + FROM activity_log + WHERE user_id = $1 AND timestamp > now() - make_interval(days => $2) + GROUP BY client_ip + ORDER BY last_seen DESC + `, userID, days) + if err != nil { + return nil, fmt.Errorf("query user IPs: %w", err) + } + defer rows.Close() + + var results []UserIPEntry + for rows.Next() { + var e UserIPEntry + if err := rows.Scan(&e.ClientIP, &e.FirstSeen, &e.LastSeen, &e.RequestCount); err != nil { + return nil, fmt.Errorf("scan user IP row: %w", err) + } + results = append(results, e) + } + return results, rows.Err() +} + +// IPUsers returns the users that have connected from a given IP within the lookback window. +func (r *Repo) IPUsers(ctx context.Context, ip string, days int) ([]IPUserEntry, error) { + rows, err := r.pool.Query(ctx, ` + SELECT a.user_id, COALESCE(u.username, ''), min(a.timestamp) AS first_seen, + max(a.timestamp) AS last_seen, count(*) AS request_count + FROM activity_log a + LEFT JOIN users u ON u.id = a.user_id + WHERE a.client_ip = $1::inet AND a.timestamp > now() - make_interval(days => $2) AND a.user_id IS NOT NULL + GROUP BY a.user_id, u.username + ORDER BY last_seen DESC + `, ip, days) + if err != nil { + return nil, fmt.Errorf("query IP users: %w", err) + } + defer rows.Close() + + var results []IPUserEntry + for rows.Next() { + var e IPUserEntry + if err := rows.Scan(&e.UserID, &e.Username, &e.FirstSeen, &e.LastSeen, &e.RequestCount); err != nil { + return nil, fmt.Errorf("scan IP user row: %w", err) + } + results = append(results, e) + } + return results, rows.Err() +} + +func (r *Repo) List(ctx context.Context, opts ListOptions) (ListResult, error) { + limit := opts.Limit + if limit <= 0 || limit > 200 { + limit = 100 + } + + conditions := []string{"1=1"} + args := make([]any, 0, 12) + argIdx := 1 + + if opts.From != nil { + conditions = append(conditions, fmt.Sprintf("timestamp >= $%d", argIdx)) + args = append(args, *opts.From) + argIdx++ + } + if opts.To != nil { + conditions = append(conditions, fmt.Sprintf("timestamp <= $%d", argIdx)) + args = append(args, *opts.To) + argIdx++ + } + if opts.Method != "" { + conditions = append(conditions, fmt.Sprintf("method = $%d", argIdx)) + args = append(args, strings.ToUpper(opts.Method)) + argIdx++ + } + if opts.StatusCode != nil { + conditions = append(conditions, fmt.Sprintf("status_code = $%d", argIdx)) + args = append(args, *opts.StatusCode) + argIdx++ + } + if opts.PathPrefix != "" { + conditions = append(conditions, fmt.Sprintf("path LIKE $%d", argIdx)) + args = append(args, opts.PathPrefix+"%") + argIdx++ + } + if opts.ClientIP != "" { + conditions = append(conditions, fmt.Sprintf("client_ip = $%d::inet", argIdx)) + args = append(args, opts.ClientIP) + argIdx++ + } + if opts.RequestID != "" { + conditions = append(conditions, fmt.Sprintf("request_id = $%d", argIdx)) + args = append(args, opts.RequestID) + argIdx++ + } + if opts.UserID != nil { + conditions = append(conditions, fmt.Sprintf("user_id = $%d", argIdx)) + args = append(args, *opts.UserID) + argIdx++ + } + if opts.SessionID != "" { + conditions = append(conditions, fmt.Sprintf("session_id = $%d", argIdx)) + args = append(args, opts.SessionID) + argIdx++ + } + if opts.PlaybackSessionID != "" { + conditions = append(conditions, fmt.Sprintf("playback_session_id = $%d", argIdx)) + args = append(args, opts.PlaybackSessionID) + argIdx++ + } + if opts.Cursor != "" { + cursorTs, cursorID, err := decodeCursor(opts.Cursor) + if err != nil { + return ListResult{}, err + } + conditions = append(conditions, fmt.Sprintf("(timestamp, id) < ($%d, $%d)", argIdx, argIdx+1)) + args = append(args, cursorTs, cursorID) + argIdx += 2 + } + + query := fmt.Sprintf(` + SELECT id, timestamp, client_ip::text, user_id, impersonator_user_id, COALESCE(session_id, ''), COALESCE(playback_session_id, ''), COALESCE(request_id, ''), COALESCE(node_id, ''), + method, path, COALESCE(path_pattern, ''), COALESCE(status_code, 0), COALESCE(user_agent, ''), COALESCE(duration_ms, 0) + FROM activity_log + WHERE %s + ORDER BY timestamp DESC, id DESC + LIMIT $%d + `, strings.Join(conditions, " AND "), argIdx) + args = append(args, limit+1) + + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return ListResult{}, fmt.Errorf("list activity logs: %w", err) + } + defer rows.Close() + + entries := make([]AuditEntry, 0, limit+1) + for rows.Next() { + var entry AuditEntry + if err := rows.Scan( + &entry.ID, + &entry.Timestamp, + &entry.ClientIP, + &entry.UserID, + &entry.ImpersonatorUserID, + &entry.SessionID, + &entry.PlaybackSessionID, + &entry.RequestID, + &entry.NodeID, + &entry.Method, + &entry.Path, + &entry.PathPattern, + &entry.StatusCode, + &entry.UserAgent, + &entry.DurationMs, + ); err != nil { + return ListResult{}, fmt.Errorf("scan activity log row: %w", err) + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + return ListResult{}, fmt.Errorf("iterate activity logs: %w", err) + } + + result := ListResult{} + if len(entries) > limit { + last := entries[limit-1] + result.NextCursor = encodeCursor(last.Timestamp, last.ID) + entries = entries[:limit] + } + result.Entries = entries + return result, nil +} + +func encodeCursor(ts time.Time, id int64) string { + return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d|%d", ts.UnixNano(), id))) +} + +func decodeCursor(cursor string) (time.Time, int64, error) { + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return time.Time{}, 0, fmt.Errorf("decode cursor: %w", err) + } + parts := strings.Split(string(raw), "|") + if len(parts) != 2 { + return time.Time{}, 0, fmt.Errorf("invalid cursor") + } + var nanos int64 + var id int64 + if _, err := fmt.Sscanf(parts[0], "%d", &nanos); err != nil { + return time.Time{}, 0, fmt.Errorf("parse cursor timestamp: %w", err) + } + if _, err := fmt.Sscanf(parts[1], "%d", &id); err != nil { + return time.Time{}, 0, fmt.Errorf("parse cursor id: %w", err) + } + return time.Unix(0, nanos).UTC(), id, nil +} diff --git a/internal/activitylog/writer_memory.go b/internal/activitylog/writer_memory.go new file mode 100644 index 00000000..accd0978 --- /dev/null +++ b/internal/activitylog/writer_memory.go @@ -0,0 +1,30 @@ +package activitylog + +// MemoryWriter buffers entries in a Go channel for the consumer goroutine. +type MemoryWriter struct { + ch chan LogEntry +} + +// NewMemoryWriter creates a Writer backed by an in-memory channel. +// bufSize controls the channel buffer capacity. +func NewMemoryWriter(bufSize int) *MemoryWriter { + return &MemoryWriter{ch: make(chan LogEntry, bufSize)} +} + +func (w *MemoryWriter) Write(entry LogEntry) { + select { + case w.ch <- entry: + default: + // Drop entry if buffer is full — acceptable trade-off for memory-only mode + } +} + +func (w *MemoryWriter) Close() error { + close(w.ch) + return nil +} + +// Chan returns the underlying channel for the consumer to read from. +func (w *MemoryWriter) Chan() <-chan LogEntry { + return w.ch +} diff --git a/internal/activitylog/writer_redis.go b/internal/activitylog/writer_redis.go new file mode 100644 index 00000000..a7bcb56e --- /dev/null +++ b/internal/activitylog/writer_redis.go @@ -0,0 +1,37 @@ +package activitylog + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/redis/go-redis/v9" +) + +const redisKey = "activity_log:buffer" + +// RedisWriter pushes log entries to a Redis list for async consumption. +type RedisWriter struct { + client *redis.Client +} + +// NewRedisWriter creates a Writer backed by Redis RPUSH. +func NewRedisWriter(client *redis.Client) *RedisWriter { + return &RedisWriter{client: client} +} + +func (w *RedisWriter) Write(entry LogEntry) { + data, err := json.Marshal(entry) + if err != nil { + slog.Warn("activitylog: failed to marshal entry", "error", err) + return + } + if err := w.client.RPush(context.Background(), redisKey, data).Err(); err != nil { + slog.Warn("activitylog: failed to push to Redis", "error", err) + } +} + +func (w *RedisWriter) Close() error { + // Redis client lifecycle managed externally + return nil +} diff --git a/internal/adminjob/catalog_import.go b/internal/adminjob/catalog_import.go new file mode 100644 index 00000000..9d83353b --- /dev/null +++ b/internal/adminjob/catalog_import.go @@ -0,0 +1,13 @@ +package adminjob + +import "github.com/Silo-Server/silo-server/internal/catalogseed" + +type CatalogImportRequest struct { + SourceBucket string `json:"source_bucket"` + SourceKey string `json:"source_key"` + SourceLabel string `json:"source_label,omitempty"` + CleanupSource bool `json:"cleanup_source,omitempty"` + LocalPath string `json:"local_path,omitempty"` + RemoteURL string `json:"remote_url,omitempty"` + Options catalogseed.ImportOptions `json:"options"` +} diff --git a/internal/adminjob/image_cache_cleanup.go b/internal/adminjob/image_cache_cleanup.go new file mode 100644 index 00000000..3e881ff2 --- /dev/null +++ b/internal/adminjob/image_cache_cleanup.go @@ -0,0 +1,96 @@ +package adminjob + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" +) + +const JobTypeImageCacheCleanup = "image_cache_cleanup" + +type ImageCacheCleanupRequest struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + Prefixes []string `json:"prefixes"` +} + +type ImageCacheCleanupResult struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + DeletedPrefixes int `json:"deleted_prefixes"` + DeletedS3Objects int `json:"deleted_s3_objects"` +} + +type imageCacheCleanupExecutor interface { + Execute(ctx context.Context, req ImageCacheCleanupRequest, progress func(current, total int, message string)) (*ImageCacheCleanupResult, error) +} + +type ImageCacheCleanupExecutor struct { + s3 S3PrefixDeleter +} + +func NewImageCacheCleanupExecutor(s3 S3PrefixDeleter) *ImageCacheCleanupExecutor { + if s3 == nil { + return nil + } + return &ImageCacheCleanupExecutor{s3: s3} +} + +func (e *ImageCacheCleanupExecutor) Execute( + ctx context.Context, + req ImageCacheCleanupRequest, + progress func(current, total int, message string), +) (*ImageCacheCleanupResult, error) { + if e == nil || e.s3 == nil { + return nil, fmt.Errorf("image cache cleanup executor is not configured") + } + if len(req.Prefixes) == 0 { + return &ImageCacheCleanupResult{ + LibraryID: req.LibraryID, + LibraryName: req.LibraryName, + DeletedPrefixes: 0, + DeletedS3Objects: 0, + }, nil + } + + total := len(req.Prefixes) + deletedPrefixes := 0 + deletedObjects := 0 + bucket := e.s3.Bucket() + + for index, prefix := range req.Prefixes { + if progress != nil { + progress(index, total, fmt.Sprintf("Cleaning cached images %d/%d", index+1, total)) + } + n, err := e.s3.DeletePrefix(ctx, bucket, prefix) + if err != nil { + slog.Warn("image cache cleanup: s3 delete failed", "prefix", prefix, "error", err) + continue + } + deletedPrefixes++ + deletedObjects += n + } + + if progress != nil { + progress(total, total, "Cached image cleanup completed") + } + + return &ImageCacheCleanupResult{ + LibraryID: req.LibraryID, + LibraryName: req.LibraryName, + DeletedPrefixes: deletedPrefixes, + DeletedS3Objects: deletedObjects, + }, nil +} + +func decodeImageCacheCleanupRequest(data json.RawMessage) (ImageCacheCleanupRequest, error) { + var req ImageCacheCleanupRequest + if len(data) == 0 { + return req, fmt.Errorf("missing image cache cleanup payload") + } + if err := json.Unmarshal(data, &req); err != nil { + return req, fmt.Errorf("invalid image cache cleanup request payload: %w", err) + } + return req, nil +} diff --git a/internal/adminjob/item_refresh.go b/internal/adminjob/item_refresh.go new file mode 100644 index 00000000..d27321ea --- /dev/null +++ b/internal/adminjob/item_refresh.go @@ -0,0 +1,653 @@ +package adminjob + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/libraryingest" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/naming" + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/Silo-Server/silo-server/internal/scanner" +) + +const ( + JobTypeItemRefresh = "item_refresh" + ScanScopeMovieParent = "movie_parent" + ScanScopeSeriesRoot = "series_root" + ScanScopeSeasonFolder = "season_folder" +) + +type ItemRefreshMode string + +const ( + ItemRefreshModeQuick ItemRefreshMode = "quick" + ItemRefreshModeComplete ItemRefreshMode = "complete" +) + +var ErrScopeHasNoFiles = errors.New("cannot determine scan scope because this item has no indexed files") + +type ScopeResolutionError struct { + StatusCode int + Message string +} + +func (e *ScopeResolutionError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +type ItemRefreshRequest struct { + RequestedContentID string `json:"requested_content_id"` + RequestedType string `json:"requested_type"` + RequestedSeriesID string `json:"requested_series_id,omitempty"` + RequestedSeasonNumber int `json:"requested_season_number,omitempty"` + RequestedEpisodeNumber int `json:"requested_episode_number,omitempty"` + RefreshTargetType string `json:"refresh_target_type"` + RefreshContentID string `json:"refresh_content_id"` + ScanFolderID int `json:"scan_folder_id"` + ScanPath string `json:"scan_path"` + ScanScope string `json:"scan_scope"` + Mode ItemRefreshMode `json:"mode,omitempty"` + CanonicalRootPath string `json:"canonical_root_path,omitempty"` +} + +type ItemRefreshResult struct { + RequestedContentID string `json:"requested_content_id"` + RefreshContentID string `json:"refresh_content_id"` + DetailContentID string `json:"detail_content_id,omitempty"` + ScanPath string `json:"scan_path"` + ScanResult *scanner.ScanResult `json:"scan_result"` + MatchedFiles int `json:"matched_files"` +} + +type itemRefreshItemRepo interface { + GetByID(ctx context.Context, contentID string) (*models.MediaItem, error) +} + +type itemRefreshSeasonRepo interface { + GetByID(ctx context.Context, contentID string) (*models.Season, error) + GetBySeriesAndNumber(ctx context.Context, seriesID string, seasonNum int) (*models.Season, error) +} + +type itemRefreshEpisodeRepo interface { + GetByID(ctx context.Context, contentID string) (*models.Episode, error) + GetBySeriesAndNumber(ctx context.Context, seriesID string, seasonNum int, episodeNum int) (*models.Episode, error) + ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error) + ListBySeason(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) + ListBySeasonID(ctx context.Context, seasonID string) ([]*models.Episode, error) +} + +type itemRefreshFolderRepo interface { + GetByID(ctx context.Context, id int) (*models.MediaFolder, error) +} + +type itemRefreshFileRepo interface { + GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error) + GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error) + GetByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string) ([]*models.MediaFile, error) + ClearContentLinksByPathPrefix(ctx context.Context, folderID int, pathPrefix string) (int, error) +} + +type itemRefreshRootClaimRepo interface { + DeleteByFolderAndRoot(ctx context.Context, folderID int, rootPath string) error +} + +type itemRefreshGroupClaimRepo interface { + DeleteByFolderAndObservedPathPrefix(ctx context.Context, folderID int, pathPrefix string) error +} + +type itemRefreshSkippedRootRepo interface { + Upsert(ctx context.Context, root models.SkippedMediaRoot) error + Delete(ctx context.Context, folderID int, rootPath string) error + DeleteMissingInScope(ctx context.Context, folderID int, scopePath string, seenRoots []string) error +} + +type ItemRefreshResolver struct { + itemRepo itemRefreshItemRepo + seasonRepo itemRefreshSeasonRepo + episodeRepo itemRefreshEpisodeRepo + folderRepo itemRefreshFolderRepo + fileRepo itemRefreshFileRepo +} + +func NewItemRefreshResolver( + itemRepo itemRefreshItemRepo, + seasonRepo itemRefreshSeasonRepo, + episodeRepo itemRefreshEpisodeRepo, + folderRepo itemRefreshFolderRepo, + fileRepo itemRefreshFileRepo, +) *ItemRefreshResolver { + return &ItemRefreshResolver{ + itemRepo: itemRepo, + seasonRepo: seasonRepo, + episodeRepo: episodeRepo, + folderRepo: folderRepo, + fileRepo: fileRepo, + } +} + +func (r *ItemRefreshResolver) Resolve(ctx context.Context, contentID string) (*ItemRefreshRequest, error) { + return r.resolve(ctx, contentID, 0, ItemRefreshModeQuick) +} + +func (r *ItemRefreshResolver) ResolveWithMode(ctx context.Context, contentID string, mode ItemRefreshMode) (*ItemRefreshRequest, error) { + return r.resolve(ctx, contentID, 0, normalizeItemRefreshMode(mode)) +} + +func (r *ItemRefreshResolver) ResolveForLibrary(ctx context.Context, contentID string, libraryID int) (*ItemRefreshRequest, error) { + if libraryID <= 0 { + return nil, fmt.Errorf("library_id is required") + } + return r.resolve(ctx, contentID, libraryID, ItemRefreshModeQuick) +} + +func (r *ItemRefreshResolver) resolve(ctx context.Context, contentID string, libraryID int, mode ItemRefreshMode) (*ItemRefreshRequest, error) { + if item, err := r.itemRepo.GetByID(ctx, contentID); err == nil { + if item.Type == "series" { + return r.resolveSeries(ctx, item, libraryID, mode) + } + return r.resolveMovie(ctx, item, libraryID, mode) + } else if !errors.Is(err, catalog.ErrItemNotFound) { + return nil, err + } + + if season, err := r.seasonRepo.GetByID(ctx, contentID); err == nil { + return r.resolveSeason(ctx, season, libraryID, mode) + } else if !errors.Is(err, catalog.ErrSeasonNotFound) { + return nil, err + } + + episode, err := r.episodeRepo.GetByID(ctx, contentID) + if err != nil { + if errors.Is(err, catalog.ErrEpisodeNotFound) { + return nil, &ScopeResolutionError{ + StatusCode: 404, + Message: "Item not found", + } + } + return nil, err + } + return r.resolveEpisode(ctx, episode, libraryID, mode) +} + +func (r *ItemRefreshResolver) resolveMovie(ctx context.Context, item *models.MediaItem, libraryID int, mode ItemRefreshMode) (*ItemRefreshRequest, error) { + files, err := r.fileRepo.GetByContentID(ctx, item.ContentID) + if err != nil { + return nil, err + } + file, err := firstPresentFileInLibrary(files, libraryID) + if err != nil { + return nil, err + } + req := &ItemRefreshRequest{ + RequestedContentID: item.ContentID, + RequestedType: item.Type, + RefreshTargetType: "item", + RefreshContentID: item.ContentID, + ScanScope: ScanScopeMovieParent, + Mode: mode, + } + if mode == ItemRefreshModeComplete { + return r.buildCompleteRequest(ctx, file, req) + } + return r.buildRequest(ctx, file.MediaFolderID, filepath.Dir(file.FilePath), req) +} + +func (r *ItemRefreshResolver) resolveSeries(ctx context.Context, item *models.MediaItem, libraryID int, mode ItemRefreshMode) (*ItemRefreshRequest, error) { + file, err := r.findSeriesRepresentativeFile(ctx, item.ContentID, libraryID) + if err != nil { + return nil, err + } + req := &ItemRefreshRequest{ + RequestedContentID: item.ContentID, + RequestedType: item.Type, + RefreshTargetType: "item", + RefreshContentID: item.ContentID, + ScanScope: ScanScopeSeriesRoot, + Mode: mode, + } + if mode == ItemRefreshModeComplete { + return r.buildCompleteRequest(ctx, file, req) + } + scanPath := filepath.Dir(file.FilePath) + if root, ok := naming.DetectSeriesRoot(file.FilePath, "series"); ok && root != nil && root.RootPath != "" { + scanPath = filepath.Clean(root.RootPath) + } + return r.buildRequest(ctx, file.MediaFolderID, scanPath, req) +} + +func (r *ItemRefreshResolver) resolveSeason(ctx context.Context, season *models.Season, libraryID int, mode ItemRefreshMode) (*ItemRefreshRequest, error) { + file, err := r.findSeasonRepresentativeFile(ctx, season.SeriesID, season.SeasonNumber, season.ContentID, libraryID) + if err != nil { + return nil, err + } + req := &ItemRefreshRequest{ + RequestedContentID: season.ContentID, + RequestedType: "season", + RequestedSeriesID: season.SeriesID, + RequestedSeasonNumber: season.SeasonNumber, + RefreshTargetType: "season", + RefreshContentID: season.ContentID, + ScanScope: ScanScopeSeasonFolder, + Mode: mode, + } + if mode == ItemRefreshModeComplete { + return r.buildCompleteRequest(ctx, file, req) + } + return r.buildRequest(ctx, file.MediaFolderID, filepath.Dir(file.FilePath), req) +} + +func (r *ItemRefreshResolver) resolveEpisode(ctx context.Context, episode *models.Episode, libraryID int, mode ItemRefreshMode) (*ItemRefreshRequest, error) { + files, err := r.fileRepo.GetByEpisodeID(ctx, episode.ContentID) + if err != nil { + return nil, err + } + file, err := firstPresentFileInLibrary(files, libraryID) + if err != nil { + file, err = r.findSeasonRepresentativeFile(ctx, episode.SeriesID, episode.SeasonNumber, episode.SeasonID, libraryID) + if err != nil { + return nil, err + } + } + req := &ItemRefreshRequest{ + RequestedContentID: episode.ContentID, + RequestedType: "episode", + RequestedSeriesID: episode.SeriesID, + RequestedSeasonNumber: episode.SeasonNumber, + RequestedEpisodeNumber: episode.EpisodeNumber, + RefreshTargetType: "episode", + RefreshContentID: episode.ContentID, + ScanScope: ScanScopeSeasonFolder, + Mode: mode, + } + if mode == ItemRefreshModeComplete { + return r.buildCompleteRequest(ctx, file, req) + } + return r.buildRequest(ctx, file.MediaFolderID, filepath.Dir(file.FilePath), req) +} + +func (r *ItemRefreshResolver) buildRequest(ctx context.Context, folderID int, scanPath string, req *ItemRefreshRequest) (*ItemRefreshRequest, error) { + if _, err := r.folderRepo.GetByID(ctx, folderID); err != nil { + return nil, err + } + // The scanner persists canonical file paths after following directory + // symlinks, so the derived scan path may not share a literal prefix with the + // configured library root. The folder ownership is established by the linked + // media file we selected above, not by a raw string-prefix match here. + cleanPath := filepath.Clean(scanPath) + req.ScanFolderID = folderID + req.ScanPath = cleanPath + return req, nil +} + +func (r *ItemRefreshResolver) buildCompleteRequest(ctx context.Context, file *models.MediaFile, req *ItemRefreshRequest) (*ItemRefreshRequest, error) { + scanPath, canonicalRootPath, err := r.deriveCompleteRefreshScope(ctx, file) + if err != nil { + return nil, err + } + req.CanonicalRootPath = canonicalRootPath + return r.buildRequest(ctx, file.MediaFolderID, scanPath, req) +} + +func (r *ItemRefreshResolver) deriveCompleteRefreshScope(ctx context.Context, file *models.MediaFile) (string, string, error) { + folder, err := r.folderRepo.GetByID(ctx, file.MediaFolderID) + if err != nil { + return "", "", err + } + + cleanFilePath := filepath.Clean(file.FilePath) + parentDir := filepath.Clean(filepath.Dir(cleanFilePath)) + if file.CanonicalRootPath != "" { + canonicalRootPath := filepath.Clean(file.CanonicalRootPath) + switch { + case shouldRefreshSeriesCanonicalRoot(folder, file): + return canonicalRootPath, canonicalRootPath, nil + case file.ContentID != "" && canonicalRootPath == parentDir: + return canonicalRootPath, canonicalRootPath, nil + case file.ContentID != "": + return cleanFilePath, canonicalRootPath, nil + default: + return cleanFilePath, canonicalRootPath, nil + } + } + if file.EpisodeID != "" || file.SeasonNumber != 0 || file.EpisodeNumber != 0 { + return parentDir, "", nil + } + return cleanFilePath, "", nil +} + +func shouldRefreshSeriesCanonicalRoot(folder *models.MediaFolder, file *models.MediaFile) bool { + if file == nil { + return false + } + if file.EpisodeID != "" || file.SeasonNumber != 0 || file.EpisodeNumber != 0 { + return true + } + if strings.EqualFold(strings.TrimSpace(file.BaseType), "series") { + return true + } + if folder == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(folder.Type)) { + case "series", "tv", "show", "tvshows": + return true + default: + return false + } +} + +func (r *ItemRefreshResolver) findSeriesRepresentativeFile(ctx context.Context, seriesID string, libraryID int) (*models.MediaFile, error) { + episodes, err := r.episodeRepo.ListBySeries(ctx, seriesID) + if err != nil { + return nil, err + } + for _, episode := range episodes { + files, fileErr := r.fileRepo.GetByEpisodeID(ctx, episode.ContentID) + if fileErr != nil { + return nil, fileErr + } + if file, pickErr := firstPresentFileInLibrary(files, libraryID); pickErr == nil { + return file, nil + } + } + + files, err := r.fileRepo.GetByContentID(ctx, seriesID) + if err != nil { + return nil, err + } + return firstPresentFileInLibrary(files, libraryID) +} + +func (r *ItemRefreshResolver) findSeasonRepresentativeFile(ctx context.Context, seriesID string, seasonNumber int, seasonID string, libraryID int) (*models.MediaFile, error) { + var ( + episodes []*models.Episode + err error + ) + if seasonID != "" { + episodes, err = r.episodeRepo.ListBySeasonID(ctx, seasonID) + } else { + episodes, err = r.episodeRepo.ListBySeason(ctx, seriesID, seasonNumber) + } + if err != nil { + return nil, err + } + for _, episode := range episodes { + files, fileErr := r.fileRepo.GetByEpisodeID(ctx, episode.ContentID) + if fileErr != nil { + return nil, fileErr + } + if file, pickErr := firstPresentFileInLibrary(files, libraryID); pickErr == nil { + return file, nil + } + } + return nil, &ScopeResolutionError{ + StatusCode: 409, + Message: ErrScopeHasNoFiles.Error(), + } +} + +func firstPresentFileInLibrary(files []*models.MediaFile, libraryID int) (*models.MediaFile, error) { + for _, file := range files { + if file == nil || file.MissingSince != nil { + continue + } + if libraryID > 0 && file.MediaFolderID != libraryID { + continue + } + return file, nil + } + return nil, &ScopeResolutionError{ + StatusCode: 409, + Message: ErrScopeHasNoFiles.Error(), + } +} + +type ItemRefreshIngester interface { + IngestSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*libraryingest.Result, error) +} + +type ItemRefreshExecutor struct { + folderRepo itemRefreshFolderRepo + fileRepo itemRefreshFileRepo + rootClaimRepo itemRefreshRootClaimRepo + groupClaimRepo itemRefreshGroupClaimRepo + skippedRootRepo itemRefreshSkippedRootRepo + seasonRepo itemRefreshSeasonRepo + episodeRepo itemRefreshEpisodeRepo + ingester ItemRefreshIngester + refresher interface { + RefreshItem(ctx context.Context, contentID string) error + RefreshItemForLibrary(ctx context.Context, contentID string, folderID int) error + RefreshTargetForLibrary(ctx context.Context, targetType, contentID string, folderID int) error + } + eventBus cache.EventBus + realtimeHub *notifications.Hub +} + +func NewItemRefreshExecutor( + folderRepo itemRefreshFolderRepo, + fileRepo itemRefreshFileRepo, + rootClaimRepo itemRefreshRootClaimRepo, + groupClaimRepo itemRefreshGroupClaimRepo, + skippedRootRepo itemRefreshSkippedRootRepo, + seasonRepo itemRefreshSeasonRepo, + episodeRepo itemRefreshEpisodeRepo, + ingester ItemRefreshIngester, + refresher interface { + RefreshItem(ctx context.Context, contentID string) error + RefreshItemForLibrary(ctx context.Context, contentID string, folderID int) error + RefreshTargetForLibrary(ctx context.Context, targetType, contentID string, folderID int) error + }, + eventBus cache.EventBus, + realtimeHub *notifications.Hub, +) *ItemRefreshExecutor { + return &ItemRefreshExecutor{ + folderRepo: folderRepo, + fileRepo: fileRepo, + rootClaimRepo: rootClaimRepo, + groupClaimRepo: groupClaimRepo, + skippedRootRepo: skippedRootRepo, + seasonRepo: seasonRepo, + episodeRepo: episodeRepo, + ingester: ingester, + refresher: refresher, + eventBus: eventBus, + realtimeHub: realtimeHub, + } +} + +func (e *ItemRefreshExecutor) Execute(ctx context.Context, req ItemRefreshRequest, progress func(current, total int, message string)) (*ItemRefreshResult, error) { + if e.folderRepo == nil || e.ingester == nil || e.refresher == nil { + return nil, fmt.Errorf("resolve scan scope: item refresh executor is not fully configured") + } + req.Mode = normalizeItemRefreshMode(req.Mode) + folder, err := e.folderRepo.GetByID(ctx, req.ScanFolderID) + if err != nil { + return nil, fmt.Errorf("resolve scan scope: loading folder: %w", err) + } + if !folder.Enabled { + return nil, fmt.Errorf("resolve scan scope: library is disabled") + } + + refreshContentID := req.RefreshContentID + detailContentID := req.RequestedContentID + + if req.Mode == ItemRefreshModeComplete { + if e.fileRepo == nil || e.rootClaimRepo == nil || e.groupClaimRepo == nil { + return nil, fmt.Errorf("resolve scan scope: complete item refresh requires file, root claim, and group claim repositories") + } + if err := e.prepareCompleteRefresh(ctx, req); err != nil { + return nil, fmt.Errorf("prepare complete refresh: %w", err) + } + } + + if progress != nil { + progress(1, 3, "Scanning scope") + } + ingestResult, err := e.ingester.IngestSubtree(ctx, folder, req.ScanPath) + if err != nil { + return nil, fmt.Errorf("scan scope: %w", err) + } + scanResult := ingestResult.ScanResult + + if progress != nil { + progress(2, 3, "Matching discovered files") + } + if refreshedFolder, reloadErr := e.folderRepo.GetByID(ctx, req.ScanFolderID); reloadErr == nil && !refreshedFolder.Enabled { + return nil, fmt.Errorf("match discovered files: library is disabled") + } + matched := ingestResult.MatchedFiles + + if req.Mode == ItemRefreshModeComplete { + resolvedRefreshContentID, resolvedDetailContentID, err := e.resolveCompleteRefreshTargets(ctx, req) + if err != nil { + return nil, fmt.Errorf("resolve rebuilt item: %w", err) + } + refreshContentID = resolvedRefreshContentID + if resolvedDetailContentID != "" { + detailContentID = resolvedDetailContentID + } + } + + if progress != nil { + progress(3, 3, "Refreshing metadata") + } + if refreshedFolder, reloadErr := e.folderRepo.GetByID(ctx, req.ScanFolderID); reloadErr == nil && !refreshedFolder.Enabled { + return nil, fmt.Errorf("refresh metadata: library is disabled") + } + refreshTargetType := strings.TrimSpace(req.RefreshTargetType) + if refreshTargetType == "" { + switch req.RequestedType { + case "season", "episode": + refreshTargetType = req.RequestedType + default: + refreshTargetType = "item" + } + } + if err := e.refresher.RefreshTargetForLibrary(ctx, refreshTargetType, refreshContentID, req.ScanFolderID); err != nil { + return nil, fmt.Errorf("refresh metadata: %w", err) + } + + e.publish(cache.EventScanComplete, strconv.Itoa(req.ScanFolderID)) + e.publish(cache.EventMetadataUpdated, refreshContentID) + if e.realtimeHub != nil { + if scanResult != nil && (scanResult.New > 0 || scanResult.Updated > 0 || scanResult.Missing > 0 || matched > 0) { + _ = e.realtimeHub.PublishLibraryChanged(ctx, notifications.LibraryChangeEvent{ + LibraryID: req.ScanFolderID, + New: scanResult.New, + Updated: scanResult.Updated, + Missing: scanResult.Missing, + MatchedFiles: matched, + }) + } + _ = e.realtimeHub.PublishMetadataUpdated(ctx, notifications.MetadataUpdateEvent{ + LibraryID: req.ScanFolderID, + ContentID: refreshContentID, + }) + } + + return &ItemRefreshResult{ + RequestedContentID: req.RequestedContentID, + RefreshContentID: refreshContentID, + DetailContentID: detailContentID, + ScanPath: req.ScanPath, + ScanResult: scanResult, + MatchedFiles: matched, + }, nil +} + +func (e *ItemRefreshExecutor) prepareCompleteRefresh(ctx context.Context, req ItemRefreshRequest) error { + if req.CanonicalRootPath != "" { + if err := e.rootClaimRepo.DeleteByFolderAndRoot(ctx, req.ScanFolderID, req.CanonicalRootPath); err != nil { + return fmt.Errorf("deleting root claim: %w", err) + } + } + if e.groupClaimRepo != nil { + if err := e.groupClaimRepo.DeleteByFolderAndObservedPathPrefix(ctx, req.ScanFolderID, req.ScanPath); err != nil { + return fmt.Errorf("deleting group claims: %w", err) + } + } + if _, err := e.fileRepo.ClearContentLinksByPathPrefix(ctx, req.ScanFolderID, req.ScanPath); err != nil { + return fmt.Errorf("clearing content links: %w", err) + } + return nil +} + +func (e *ItemRefreshExecutor) resolveCompleteRefreshTargets(ctx context.Context, req ItemRefreshRequest) (string, string, error) { + files, err := e.fileRepo.GetByFolderAndPathPrefix(ctx, req.ScanFolderID, req.ScanPath) + if err != nil { + return "", "", fmt.Errorf("loading rebuilt files: %w", err) + } + + distinctContentIDs := make(map[string]struct{}) + for _, file := range files { + if file == nil || file.MissingSince != nil || file.ContentID == "" { + continue + } + distinctContentIDs[file.ContentID] = struct{}{} + } + + switch len(distinctContentIDs) { + case 0: + return "", "", fmt.Errorf("no rebuilt content found in refresh scope") + case 1: + default: + return "", "", fmt.Errorf("multiple rebuilt content IDs found in refresh scope") + } + + refreshContentID := "" + for contentID := range distinctContentIDs { + refreshContentID = contentID + } + + detailContentID := refreshContentID + switch req.RequestedType { + case "season": + if e.seasonRepo != nil { + if season, lookupErr := e.seasonRepo.GetBySeriesAndNumber(ctx, refreshContentID, req.RequestedSeasonNumber); lookupErr == nil && season != nil { + detailContentID = season.ContentID + refreshContentID = season.ContentID + } else { + return "", "", fmt.Errorf("rebuilt season target not found") + } + } + case "episode": + if e.episodeRepo != nil { + if episode, lookupErr := e.episodeRepo.GetBySeriesAndNumber(ctx, refreshContentID, req.RequestedSeasonNumber, req.RequestedEpisodeNumber); lookupErr == nil && episode != nil { + detailContentID = episode.ContentID + refreshContentID = episode.ContentID + } else { + return "", "", fmt.Errorf("rebuilt episode target not found") + } + } + } + + return refreshContentID, detailContentID, nil +} + +func normalizeItemRefreshMode(mode ItemRefreshMode) ItemRefreshMode { + if mode == ItemRefreshModeComplete { + return ItemRefreshModeComplete + } + return ItemRefreshModeQuick +} + +func (e *ItemRefreshExecutor) publish(eventType, payload string) { + if e.eventBus == nil { + return + } + _ = e.eventBus.Publish(context.Background(), cache.ChannelCatalog, cache.Event{ + Type: eventType, + Payload: payload, + }) +} diff --git a/internal/adminjob/item_refresh_test.go b/internal/adminjob/item_refresh_test.go new file mode 100644 index 00000000..a84f0a7f --- /dev/null +++ b/internal/adminjob/item_refresh_test.go @@ -0,0 +1,406 @@ +package adminjob + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/libraryingest" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scanner" +) + +type itemRefreshTestFolderRepo struct { + folder *models.MediaFolder + err error +} + +func (r *itemRefreshTestFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { + if r.err != nil { + return nil, r.err + } + if r.folder == nil || r.folder.ID != id { + return nil, errors.New("folder not found") + } + return r.folder, nil +} + +func TestItemRefreshResolverBuildRequestAcceptsPathOutsideLibraryRoots(t *testing.T) { + t.Parallel() + + resolver := &ItemRefreshResolver{ + folderRepo: &itemRefreshTestFolderRepo{ + folder: &models.MediaFolder{ + ID: 3, + Paths: []string{"/LibraryManager2/movies/popular_trending"}, + }, + }, + } + + req, err := resolver.buildRequest( + context.Background(), + 3, + "/srv/media/movies/4k/Example Movie (2026)", + &ItemRefreshRequest{}, + ) + if err != nil { + t.Fatalf("buildRequest() error = %v, want nil", err) + } + if req == nil { + t.Fatal("buildRequest() request = nil, want non-nil") + } + if got, want := req.ScanPath, "/srv/media/movies/4k/Example Movie (2026)"; got != want { + t.Fatalf("buildRequest() scan path = %q, want %q", got, want) + } +} + +func TestItemRefreshResolverBuildRequestAcceptsPathWithinLibraryRoots(t *testing.T) { + t.Parallel() + + resolver := &ItemRefreshResolver{ + folderRepo: &itemRefreshTestFolderRepo{ + folder: &models.MediaFolder{ + ID: 3, + Paths: []string{"/srv/media/movies"}, + }, + }, + } + + req, err := resolver.buildRequest( + context.Background(), + 3, + "/srv/media/movies/4k/Example Movie (2026)", + &ItemRefreshRequest{RequestedContentID: "119730834381996036"}, + ) + if err != nil { + t.Fatalf("buildRequest() error = %v, want nil", err) + } + if req == nil { + t.Fatal("buildRequest() request = nil, want non-nil") + } + if got, want := req.ScanFolderID, 3; got != want { + t.Fatalf("buildRequest() folder = %d, want %d", got, want) + } + if got, want := req.ScanPath, "/srv/media/movies/4k/Example Movie (2026)"; got != want { + t.Fatalf("buildRequest() scan path = %q, want %q", got, want) + } +} + +func TestItemRefreshResolverDeriveCompleteRefreshScope_UsesCanonicalRootForSeriesWithoutEpisodes(t *testing.T) { + t.Parallel() + + resolver := &ItemRefreshResolver{ + folderRepo: &itemRefreshTestFolderRepo{ + folder: &models.MediaFolder{ + ID: 7, + Type: "series", + }, + }, + } + + file := &models.MediaFile{ + MediaFolderID: 7, + FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv", + CanonicalRootPath: "/media/shows/Example Show", + ContentID: "pending-series", + BaseType: "series", + } + + scanPath, canonicalRootPath, err := resolver.deriveCompleteRefreshScope(context.Background(), file) + if err != nil { + t.Fatalf("deriveCompleteRefreshScope() error = %v", err) + } + if got, want := scanPath, "/media/shows/Example Show"; got != want { + t.Fatalf("scanPath = %q, want %q", got, want) + } + if got, want := canonicalRootPath, "/media/shows/Example Show"; got != want { + t.Fatalf("canonicalRootPath = %q, want %q", got, want) + } +} + +type itemRefreshTestIngester struct { + scanPath string + result *libraryingest.Result +} + +func (s *itemRefreshTestIngester) IngestSubtree(_ context.Context, _ *models.MediaFolder, subtreePath string) (*libraryingest.Result, error) { + s.scanPath = subtreePath + if s.result != nil { + return s.result, nil + } + return &libraryingest.Result{ + ScanResult: &scanner.ScanResult{}, + MatchedFiles: 2, + }, nil +} + +type itemRefreshTestRefresher struct { + targetType string + contentID string + folderID int +} + +func (r *itemRefreshTestRefresher) RefreshItem(_ context.Context, contentID string) error { + r.contentID = contentID + return nil +} + +func (r *itemRefreshTestRefresher) RefreshItemForLibrary(_ context.Context, contentID string, folderID int) error { + return r.RefreshTargetForLibrary(context.Background(), "item", contentID, folderID) +} + +func (r *itemRefreshTestRefresher) RefreshTargetForLibrary(_ context.Context, targetType, contentID string, folderID int) error { + r.targetType = targetType + r.contentID = contentID + r.folderID = folderID + return nil +} + +type itemRefreshTestFileRepo struct { + filesByPath []*models.MediaFile + clearedPath string +} + +func (r *itemRefreshTestFileRepo) GetByContentID(_ context.Context, _ string) ([]*models.MediaFile, error) { + return nil, nil +} + +func (r *itemRefreshTestFileRepo) GetByEpisodeID(_ context.Context, _ string) ([]*models.MediaFile, error) { + return nil, nil +} + +func (r *itemRefreshTestFileRepo) GetByFolderAndPathPrefix(_ context.Context, _ int, _ string) ([]*models.MediaFile, error) { + return r.filesByPath, nil +} + +func (r *itemRefreshTestFileRepo) ClearContentLinksByPathPrefix(_ context.Context, _ int, pathPrefix string) (int, error) { + r.clearedPath = pathPrefix + return 1, nil +} + +type itemRefreshTestRootClaimRepo struct { + deletedRoot string +} + +func (r *itemRefreshTestRootClaimRepo) DeleteByFolderAndRoot(_ context.Context, _ int, rootPath string) error { + r.deletedRoot = rootPath + return nil +} + +type itemRefreshTestGroupClaimRepo struct { + deletedPath string +} + +func (r *itemRefreshTestGroupClaimRepo) DeleteByFolderAndObservedPathPrefix(_ context.Context, _ int, pathPrefix string) error { + r.deletedPath = pathPrefix + return nil +} + +type itemRefreshTestSkippedRootRepo struct { + skipped map[string]models.SkippedMediaRoot +} + +func newItemRefreshTestSkippedRootRepo() *itemRefreshTestSkippedRootRepo { + return &itemRefreshTestSkippedRootRepo{skipped: make(map[string]models.SkippedMediaRoot)} +} + +func (r *itemRefreshTestSkippedRootRepo) Upsert(_ context.Context, root models.SkippedMediaRoot) error { + r.skipped[fmt.Sprintf("%d:%s", root.MediaFolderID, root.RootPath)] = root + return nil +} + +func (r *itemRefreshTestSkippedRootRepo) Delete(_ context.Context, folderID int, rootPath string) error { + delete(r.skipped, fmt.Sprintf("%d:%s", folderID, rootPath)) + return nil +} + +func (r *itemRefreshTestSkippedRootRepo) DeleteMissingInScope(_ context.Context, folderID int, scopePath string, seenRoots []string) error { + scopePath = filepath.Clean(scopePath) + prefix := scopePath + string(filepath.Separator) + seen := make(map[string]struct{}, len(seenRoots)) + for _, root := range seenRoots { + seen[root] = struct{}{} + } + for key, root := range r.skipped { + if root.MediaFolderID != folderID { + continue + } + if root.RootPath != scopePath && !strings.HasPrefix(root.RootPath, prefix) { + continue + } + if _, ok := seen[root.RootPath]; ok { + continue + } + delete(r.skipped, key) + } + return nil +} + +type itemRefreshTestSeasonRepo struct { + season *models.Season +} + +func (r *itemRefreshTestSeasonRepo) GetByID(_ context.Context, _ string) (*models.Season, error) { + return nil, errors.New("not implemented") +} + +func (r *itemRefreshTestSeasonRepo) GetBySeriesAndNumber(_ context.Context, seriesID string, seasonNum int) (*models.Season, error) { + if r.season != nil && r.season.SeriesID == seriesID && r.season.SeasonNumber == seasonNum { + return r.season, nil + } + return nil, errors.New("season not found") +} + +type itemRefreshTestEpisodeRepo struct { + episode *models.Episode +} + +func (r *itemRefreshTestEpisodeRepo) GetByID(_ context.Context, _ string) (*models.Episode, error) { + return nil, errors.New("not implemented") +} + +func (r *itemRefreshTestEpisodeRepo) GetBySeriesAndNumber(_ context.Context, seriesID string, seasonNum int, episodeNum int) (*models.Episode, error) { + if r.episode != nil && r.episode.SeriesID == seriesID && r.episode.SeasonNumber == seasonNum && r.episode.EpisodeNumber == episodeNum { + return r.episode, nil + } + return nil, errors.New("episode not found") +} + +func (r *itemRefreshTestEpisodeRepo) ListBySeries(_ context.Context, _ string) ([]*models.Episode, error) { + return nil, errors.New("not implemented") +} + +func (r *itemRefreshTestEpisodeRepo) ListBySeason(_ context.Context, _ string, _ int) ([]*models.Episode, error) { + return nil, errors.New("not implemented") +} + +func (r *itemRefreshTestEpisodeRepo) ListBySeasonID(_ context.Context, _ string) ([]*models.Episode, error) { + return nil, errors.New("not implemented") +} + +func TestItemRefreshExecutorAllowsScanPathOutsideLibraryRoots(t *testing.T) { + t.Parallel() + + folderRepo := &itemRefreshTestFolderRepo{ + folder: &models.MediaFolder{ + ID: 3, + Enabled: true, + Paths: []string{"/LibraryManager2/movies/popular_trending"}, + }, + } + ingester := &itemRefreshTestIngester{} + refresher := &itemRefreshTestRefresher{} + fileRepo := &itemRefreshTestFileRepo{} + rootClaimRepo := &itemRefreshTestRootClaimRepo{} + groupClaimRepo := &itemRefreshTestGroupClaimRepo{} + skippedRootRepo := newItemRefreshTestSkippedRootRepo() + + executor := NewItemRefreshExecutor(folderRepo, fileRepo, rootClaimRepo, groupClaimRepo, skippedRootRepo, nil, nil, ingester, refresher, nil, nil) + + result, err := executor.Execute(context.Background(), ItemRefreshRequest{ + RequestedContentID: "119730834381996036", + RefreshContentID: "119730834381996036", + ScanFolderID: 3, + ScanPath: "/srv/media/movies/4k/Example Movie (2026)", + }, nil) + if err != nil { + t.Fatalf("Execute() error = %v, want nil", err) + } + if result == nil { + t.Fatal("Execute() result = nil, want non-nil") + } + if got, want := ingester.scanPath, "/srv/media/movies/4k/Example Movie (2026)"; got != want { + t.Fatalf("IngestSubtree() path = %q, want %q", got, want) + } + if got, want := refresher.contentID, "119730834381996036"; got != want { + t.Fatalf("RefreshItem() content_id = %q, want %q", got, want) + } + if got, want := refresher.folderID, 3; got != want { + t.Fatalf("RefreshItemForLibrary() folder_id = %d, want %d", got, want) + } + if got, want := result.MatchedFiles, 2; got != want { + t.Fatalf("Execute() matched files = %d, want %d", got, want) + } +} + +func TestItemRefreshExecutorCompleteRefreshRebuildsAndMapsEpisodeTarget(t *testing.T) { + t.Parallel() + + folderRepo := &itemRefreshTestFolderRepo{ + folder: &models.MediaFolder{ + ID: 7, + Enabled: true, + Paths: []string{"/media/mixed"}, + }, + } + ingester := &itemRefreshTestIngester{} + refresher := &itemRefreshTestRefresher{} + fileRepo := &itemRefreshTestFileRepo{ + filesByPath: []*models.MediaFile{ + {ContentID: "new-series-id", FilePath: "/media/mixed/Show/Season 01/Show S01E03.mkv"}, + }, + } + rootClaimRepo := &itemRefreshTestRootClaimRepo{} + groupClaimRepo := &itemRefreshTestGroupClaimRepo{} + episodeRepo := &itemRefreshTestEpisodeRepo{ + episode: &models.Episode{ + ContentID: "new-episode-id", + SeriesID: "new-series-id", + SeasonNumber: 1, + EpisodeNumber: 3, + }, + } + + executor := NewItemRefreshExecutor( + folderRepo, + fileRepo, + rootClaimRepo, + groupClaimRepo, + newItemRefreshTestSkippedRootRepo(), + nil, + episodeRepo, + ingester, + refresher, + nil, + nil, + ) + + result, err := executor.Execute(context.Background(), ItemRefreshRequest{ + RequestedContentID: "old-episode-id", + RequestedType: "episode", + RequestedSeasonNumber: 1, + RequestedEpisodeNumber: 3, + RefreshContentID: "old-series-id", + ScanFolderID: 7, + ScanPath: "/media/mixed/Show/Season 01", + Mode: ItemRefreshModeComplete, + CanonicalRootPath: "/media/mixed/Show", + }, nil) + if err != nil { + t.Fatalf("Execute() error = %v, want nil", err) + } + if result == nil { + t.Fatal("Execute() result = nil, want non-nil") + } + if got, want := fileRepo.clearedPath, "/media/mixed/Show/Season 01"; got != want { + t.Fatalf("ClearContentLinksByPathPrefix() path = %q, want %q", got, want) + } + if got, want := rootClaimRepo.deletedRoot, "/media/mixed/Show"; got != want { + t.Fatalf("DeleteByFolderAndRoot() root = %q, want %q", got, want) + } + if got, want := refresher.targetType, "episode"; got != want { + t.Fatalf("RefreshTargetForLibrary() target_type = %q, want %q", got, want) + } + if got, want := refresher.contentID, "new-episode-id"; got != want { + t.Fatalf("RefreshItem() content_id = %q, want %q", got, want) + } + if got, want := result.RefreshContentID, "new-episode-id"; got != want { + t.Fatalf("result refresh_content_id = %q, want %q", got, want) + } + if got, want := result.DetailContentID, "new-episode-id"; got != want { + t.Fatalf("result detail_content_id = %q, want %q", got, want) + } +} diff --git a/internal/adminjob/library_delete.go b/internal/adminjob/library_delete.go new file mode 100644 index 00000000..6aa37cf7 --- /dev/null +++ b/internal/adminjob/library_delete.go @@ -0,0 +1,115 @@ +package adminjob + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/sections" +) + +const JobTypeDeleteLibrary = "delete_library" + +type DeleteLibraryRequest struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` +} + +type DeleteLibraryResult struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + DeletedMediaFiles int `json:"deleted_media_files"` + DeletedItemLinks int `json:"deleted_item_links"` + DeletedOrphanedItems int `json:"deleted_orphaned_items"` + DeletedS3Objects int `json:"deleted_s3_objects"` + ImageCleanupQueued bool `json:"image_cleanup_queued"` + ImageCleanupJobID string `json:"image_cleanup_job_id,omitempty"` + ImageCleanupDirs int `json:"image_cleanup_dirs"` + + orphanedImageDirs []string +} + +// S3PrefixDeleter can delete all objects under a prefix. +type S3PrefixDeleter interface { + DeletePrefix(ctx context.Context, bucket, prefix string) (int, error) + Bucket() string +} + +type deleteLibraryExecutor interface { + Execute(ctx context.Context, req DeleteLibraryRequest, progress func(current, total int, message string)) (*DeleteLibraryResult, error) +} + +type LibraryDeleteExecutor struct { + folderRepo *catalog.FolderRepository + sectionRepo *sections.Repository +} + +func NewLibraryDeleteExecutor(folderRepo *catalog.FolderRepository, sectionRepo *sections.Repository) *LibraryDeleteExecutor { + return &LibraryDeleteExecutor{folderRepo: folderRepo, sectionRepo: sectionRepo} +} + +func (e *LibraryDeleteExecutor) Execute( + ctx context.Context, + req DeleteLibraryRequest, + progress func(current, total int, message string), +) (*DeleteLibraryResult, error) { + if e == nil || e.folderRepo == nil { + return nil, fmt.Errorf("library delete executor is not configured") + } + if req.LibraryID <= 0 { + return nil, fmt.Errorf("library_id is required") + } + + if progress != nil { + progress(0, 5, "Counting library contents") + } + + stats, err := e.folderRepo.DeleteWithStats(ctx, req.LibraryID, func(current, total int, message string) { + if progress != nil { + progress(current, total+2, message) + } + }) + if err != nil { + return nil, err + } + + if e.sectionRepo != nil { + if progress != nil { + progress(4, 5, "Cleaning up generated home sections") + } + if err := e.sectionRepo.DeleteGeneratedHomeLibraryRecentSections(ctx, req.LibraryID); err != nil { + return nil, fmt.Errorf("deleting generated home sections: %w", err) + } + } + if progress != nil { + progress(5, 5, "Library deletion completed") + } + + name := req.LibraryName + if name == "" { + name = stats.LibraryName + } + + return &DeleteLibraryResult{ + LibraryID: req.LibraryID, + LibraryName: name, + DeletedMediaFiles: stats.MediaFiles, + DeletedItemLinks: stats.MediaItemLinks, + DeletedOrphanedItems: stats.OrphanedItems, + DeletedS3Objects: 0, + ImageCleanupDirs: len(stats.OrphanedImageDirs), + orphanedImageDirs: append([]string(nil), stats.OrphanedImageDirs...), + }, nil +} + +func decodeDeleteLibraryRequest(data json.RawMessage) (DeleteLibraryRequest, error) { + var req DeleteLibraryRequest + if len(data) == 0 { + return req, fmt.Errorf("missing delete library payload") + } + if err := json.Unmarshal(data, &req); err != nil { + return req, fmt.Errorf("invalid delete library request payload: %w", err) + } + return req, nil +} diff --git a/internal/adminjob/library_refresh.go b/internal/adminjob/library_refresh.go new file mode 100644 index 00000000..5e2bc259 --- /dev/null +++ b/internal/adminjob/library_refresh.go @@ -0,0 +1,437 @@ +package adminjob + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/libraryingest" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/notifications" +) + +const ( + JobTypeLibraryRefresh = "library_refresh" + + defaultUnmatchedRefreshDelay = 2 * time.Second + libraryRefreshWorkerCount = 4 +) + +type LibraryRefreshMode string + +const ( + LibraryRefreshModeQuick LibraryRefreshMode = "quick" + LibraryRefreshModeFull LibraryRefreshMode = "full" +) + +type LibraryRefreshRequest struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + Mode LibraryRefreshMode `json:"mode,omitempty"` +} + +type LibraryRefreshResult struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + Mode LibraryRefreshMode `json:"mode"` + TotalItems int `json:"total_items"` + ItemsWithIDs int `json:"items_with_ids"` + ItemsWithoutIDs int `json:"items_without_ids"` + RefreshedOK int `json:"refreshed_ok"` + RefreshedFailed int `json:"refreshed_failed"` + PipelineOK int `json:"pipeline_ok"` + PipelineFailed int `json:"pipeline_failed"` +} + +type LibraryRefreshItem struct { + ContentID string + TmdbID string + TvdbID string + ImdbID string +} + +type libraryRefreshItemLister interface { + ListLibraryItems(ctx context.Context, libraryID int, mode LibraryRefreshMode) ([]LibraryRefreshItem, error) +} + +type libraryRefreshFolderRepo interface { + GetByID(ctx context.Context, id int) (*models.MediaFolder, error) +} + +type libraryRefreshScopeResolver interface { + ResolveForLibrary(ctx context.Context, contentID string, libraryID int) (*ItemRefreshRequest, error) +} + +type libraryRefreshIngester interface { + IngestSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*libraryingest.Result, error) +} + +type libraryRefreshRefresher interface { + RefreshItem(ctx context.Context, contentID string) error + RefreshItemForLibrary(ctx context.Context, contentID string, folderID int) error +} + +type PGLibraryRefreshItemLister struct { + pool *pgxpool.Pool +} + +func NewPGLibraryRefreshItemLister(pool *pgxpool.Pool) *PGLibraryRefreshItemLister { + return &PGLibraryRefreshItemLister{pool: pool} +} + +func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libraryID int, mode LibraryRefreshMode) ([]LibraryRefreshItem, error) { + query := ` + SELECT mi.content_id, COALESCE(mi.tmdb_id, ''), COALESCE(mi.tvdb_id, ''), COALESCE(mi.imdb_id, '') + FROM media_item_libraries mil + JOIN media_items mi ON mi.content_id = mil.content_id + WHERE mil.media_folder_id = $1` + args := []any{libraryID} + + if normalizeLibraryRefreshMode(mode) == LibraryRefreshModeQuick { + query += ` + AND ( + COALESCE(mi.tmdb_id, '') <> '' + OR COALESCE(mi.tvdb_id, '') <> '' + OR COALESCE(mi.imdb_id, '') <> '' + ) + AND ( + mi.last_refreshed IS NULL + OR COALESCE(mi.overview, '') = '' + OR COALESCE(mi.poster_path, '') = '' + OR COALESCE(mi.backdrop_path, '') = '' + OR COALESCE(mi.poster_path, '') LIKE '%//poster/%' + OR COALESCE(mi.backdrop_path, '') LIKE '%//backdrop/%' + OR COALESCE(mi.logo_path, '') LIKE '%//logo/%' + OR mi.refresh_failures > 0 + OR mi.episode_metadata_incomplete = TRUE + OR EXISTS ( + SELECT 1 + FROM stale_media_ids smi + WHERE smi.content_id = mi.content_id + ) + )` + } + + query += "\n\t\tORDER BY mi.content_id ASC" + + rows, err := l.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query library items: %w", err) + } + defer rows.Close() + + items := make([]LibraryRefreshItem, 0) + for rows.Next() { + var item LibraryRefreshItem + if err := rows.Scan(&item.ContentID, &item.TmdbID, &item.TvdbID, &item.ImdbID); err != nil { + return nil, fmt.Errorf("scan library refresh item: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate library refresh items: %w", err) + } + return items, nil +} + +type LibraryRefreshExecutor struct { + itemLister libraryRefreshItemLister + folderRepo libraryRefreshFolderRepo + resolver libraryRefreshScopeResolver + ingester libraryRefreshIngester + refresher libraryRefreshRefresher + eventBus cache.EventBus + realtimeHub *notifications.Hub + unmatchedDelay time.Duration + wait func(ctx context.Context, delay time.Duration) error +} + +func NewLibraryRefreshExecutor( + itemLister libraryRefreshItemLister, + folderRepo libraryRefreshFolderRepo, + resolver libraryRefreshScopeResolver, + ingester libraryRefreshIngester, + refresher libraryRefreshRefresher, + eventBus cache.EventBus, + realtimeHub *notifications.Hub, +) *LibraryRefreshExecutor { + return &LibraryRefreshExecutor{ + itemLister: itemLister, + folderRepo: folderRepo, + resolver: resolver, + ingester: ingester, + refresher: refresher, + eventBus: eventBus, + realtimeHub: realtimeHub, + unmatchedDelay: defaultUnmatchedRefreshDelay, + wait: waitWithContext, + } +} + +func (e *LibraryRefreshExecutor) Execute( + ctx context.Context, + req LibraryRefreshRequest, + progress func(current, total int, message string), +) (*LibraryRefreshResult, error) { + if e == nil || e.itemLister == nil || e.folderRepo == nil || e.refresher == nil { + return nil, fmt.Errorf("library refresh executor is not fully configured") + } + req.Mode = normalizeLibraryRefreshMode(req.Mode) + if req.LibraryID <= 0 { + return nil, fmt.Errorf("library_id is required") + } + if err := e.ensureLibraryEnabled(ctx, req.LibraryID); err != nil { + return nil, err + } + + items, err := e.itemLister.ListLibraryItems(ctx, req.LibraryID, req.Mode) + if err != nil { + return nil, fmt.Errorf("load library items: %w", err) + } + + result := &LibraryRefreshResult{ + LibraryID: req.LibraryID, + LibraryName: req.LibraryName, + Mode: req.Mode, + TotalItems: len(items), + } + if result.TotalItems == 0 { + if progress != nil { + progress(0, 0, "No library items need refresh") + } + return result, nil + } + + withIDs := make([]string, 0, len(items)) + withoutIDs := make([]string, 0, len(items)) + for _, item := range items { + if item.TmdbID == "" && item.TvdbID == "" && item.ImdbID == "" { + withoutIDs = append(withoutIDs, item.ContentID) + } else { + withIDs = append(withIDs, item.ContentID) + } + } + result.ItemsWithIDs = len(withIDs) + result.ItemsWithoutIDs = len(withoutIDs) + + current := 0 + advance := func(message string) { + current++ + if progress != nil { + progress(current, result.TotalItems, message) + } + } + + if len(withIDs) > 0 { + if err := e.refreshItemsWithIDs(ctx, withIDs, result, advance); err != nil { + return nil, err + } + } + + if req.Mode == LibraryRefreshModeFull && len(withoutIDs) > 0 { + if err := e.refreshItemsWithoutIDs(ctx, req.LibraryID, withoutIDs, result, advance); err != nil { + return nil, err + } + } + + if result.RefreshedOK > 0 || result.PipelineOK > 0 { + e.publish(cache.EventMetadataUpdated, strconv.Itoa(req.LibraryID)) + if e.realtimeHub != nil { + _ = e.realtimeHub.PublishMetadataUpdated(ctx, notifications.MetadataUpdateEvent{ + LibraryID: req.LibraryID, + }) + } + } + + return result, nil +} + +func (e *LibraryRefreshExecutor) refreshItemsWithIDs( + ctx context.Context, + contentIDs []string, + result *LibraryRefreshResult, + advance func(message string), +) error { + workerCount := libraryRefreshWorkerCount + if len(contentIDs) < workerCount { + workerCount = len(contentIDs) + } + + type refreshResult struct { + contentID string + err error + } + + jobs := make(chan string, len(contentIDs)) + results := make(chan refreshResult, len(contentIDs)) + + var wg sync.WaitGroup + for i := 0; i < workerCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for contentID := range jobs { + results <- refreshResult{ + contentID: contentID, + err: e.refresher.RefreshItemForLibrary(ctx, contentID, result.LibraryID), + } + } + }() + } + + scheduled := 0 + for _, contentID := range contentIDs { + if err := e.ensureLibraryEnabled(ctx, result.LibraryID); err != nil { + close(jobs) + wg.Wait() + return err + } + select { + case <-ctx.Done(): + close(jobs) + wg.Wait() + return ctx.Err() + case jobs <- contentID: + scheduled++ + } + } + close(jobs) + + for i := 0; i < scheduled; i++ { + select { + case <-ctx.Done(): + wg.Wait() + return ctx.Err() + case itemResult := <-results: + if itemResult.err != nil { + result.RefreshedFailed++ + } else { + result.RefreshedOK++ + } + advance("Refreshing items with external IDs") + } + } + + wg.Wait() + return nil +} + +func (e *LibraryRefreshExecutor) refreshItemsWithoutIDs( + ctx context.Context, + libraryID int, + contentIDs []string, + result *LibraryRefreshResult, + advance func(message string), +) error { + fullPipelineAvailable := e.resolver != nil && e.ingester != nil + for i, contentID := range contentIDs { + if err := e.ensureLibraryEnabled(ctx, libraryID); err != nil { + return err + } + if i > 0 && e.unmatchedDelay > 0 { + if err := e.wait(ctx, e.unmatchedDelay); err != nil { + return err + } + } + + if !fullPipelineAvailable { + result.PipelineFailed++ + advance("Full refresh unavailable for unmatched items") + continue + } + + if err := e.refreshUnmatchedItem(ctx, libraryID, contentID); err != nil { + result.PipelineFailed++ + } else { + result.PipelineOK++ + } + advance("Refreshing unmatched items") + } + return nil +} + +func (e *LibraryRefreshExecutor) refreshUnmatchedItem(ctx context.Context, libraryID int, contentID string) error { + req, err := e.resolver.ResolveForLibrary(ctx, contentID, libraryID) + if err != nil { + return fmt.Errorf("resolve scope: %w", err) + } + if req.ScanFolderID != libraryID { + return fmt.Errorf("resolved scan folder %d does not match requested library %d", req.ScanFolderID, libraryID) + } + + folder, err := e.folderRepo.GetByID(ctx, req.ScanFolderID) + if err != nil { + return fmt.Errorf("load folder %d: %w", req.ScanFolderID, err) + } + if !folder.Enabled { + return fmt.Errorf("load folder %d: library is disabled", req.ScanFolderID) + } + if _, err := e.ingester.IngestSubtree(ctx, folder, req.ScanPath); err != nil { + return fmt.Errorf("ingest subtree: %w", err) + } + if err := e.refresher.RefreshItemForLibrary(ctx, req.RefreshContentID, req.ScanFolderID); err != nil { + return fmt.Errorf("refresh metadata: %w", err) + } + return nil +} + +func (e *LibraryRefreshExecutor) publish(eventType, payload string) { + if e.eventBus == nil { + return + } + _ = e.eventBus.Publish(context.Background(), cache.ChannelCatalog, cache.Event{ + Type: eventType, + Payload: payload, + }) +} + +func decodeLibraryRefreshRequest(data json.RawMessage) (LibraryRefreshRequest, error) { + var req LibraryRefreshRequest + if len(data) == 0 { + return req, fmt.Errorf("missing library refresh payload") + } + if err := json.Unmarshal(data, &req); err != nil { + return req, fmt.Errorf("invalid library refresh payload: %w", err) + } + req.Mode = normalizeLibraryRefreshMode(req.Mode) + return req, nil +} + +func normalizeLibraryRefreshMode(mode LibraryRefreshMode) LibraryRefreshMode { + if mode == LibraryRefreshModeFull { + return LibraryRefreshModeFull + } + return LibraryRefreshModeQuick +} + +func waitWithContext(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (e *LibraryRefreshExecutor) ensureLibraryEnabled(ctx context.Context, libraryID int) error { + if e == nil || e.folderRepo == nil { + return nil + } + folder, err := e.folderRepo.GetByID(ctx, libraryID) + if err != nil { + return fmt.Errorf("load library %d: %w", libraryID, err) + } + if !folder.Enabled { + return fmt.Errorf("library %d is disabled", libraryID) + } + return nil +} diff --git a/internal/adminjob/repository.go b/internal/adminjob/repository.go new file mode 100644 index 00000000..a91f3ce9 --- /dev/null +++ b/internal/adminjob/repository.go @@ -0,0 +1,536 @@ +package adminjob + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/idgen" + "github.com/Silo-Server/silo-server/internal/models" +) + +const ( + JobTypeCatalogExport = "catalog_export" + JobTypeCatalogImport = "catalog_import" + + StatusQueued = "queued" + StatusRunning = "running" + StatusCompleted = "completed" + StatusFailed = "failed" +) + +var ( + ErrJobNotFound = errors.New("admin job not found") + ErrActiveJobConflict = errors.New("admin job already active for type") +) + +type ActiveJobConflictError struct { + Job *models.AdminJob +} + +func (e *ActiveJobConflictError) Error() string { + if e.Job == nil { + return ErrActiveJobConflict.Error() + } + return fmt.Sprintf("%s: %s", ErrActiveJobConflict.Error(), e.Job.ID) +} + +func (e *ActiveJobConflictError) Unwrap() error { + return ErrActiveJobConflict +} + +type CreateJobInput struct { + JobType string + CreatedByUserID int + RequestPayload any + Message string +} + +type CompleteJobInput struct { + ResultPayload any + Message string + ProgressCurrent int + ProgressTotal int + ArtifactBucket string + ArtifactKey string + ArtifactSizeBytes int64 + ExpiresAt time.Time +} + +type FailJobInput struct { + Message string + ErrorMessage string + ProgressCurrent int + ProgressTotal int + ExpiresAt time.Time +} + +type ListJobsOptions struct { + JobType string + Limit int +} + +type Repository struct { + pool *pgxpool.Pool +} + +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +const adminJobColumns = `id, job_type, status, created_by_user_id, request_payload, + result_payload, message, error_message, progress_current, progress_total, + artifact_bucket, artifact_key, artifact_size_bytes, + public_url, requested_at, started_at, completed_at, heartbeat_at, expires_at, + published_at, updated_at` + +func scanAdminJob(row pgx.Row) (*models.AdminJob, error) { + var job models.AdminJob + err := row.Scan( + &job.ID, + &job.JobType, + &job.Status, + &job.CreatedByUserID, + &job.RequestPayload, + &job.ResultPayload, + &job.Message, + &job.ErrorMessage, + &job.ProgressCurrent, + &job.ProgressTotal, + &job.ArtifactBucket, + &job.ArtifactKey, + &job.ArtifactSizeBytes, + &job.PublicURL, + &job.RequestedAt, + &job.StartedAt, + &job.CompletedAt, + &job.HeartbeatAt, + &job.ExpiresAt, + &job.PublishedAt, + &job.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrJobNotFound + } + return nil, fmt.Errorf("scanning admin job: %w", err) + } + return &job, nil +} + +func scanAdminJobs(rows pgx.Rows) ([]*models.AdminJob, error) { + var jobs []*models.AdminJob + for rows.Next() { + job, err := scanAdminJob(rows) + if err != nil { + return nil, err + } + jobs = append(jobs, job) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating admin jobs: %w", err) + } + return jobs, nil +} + +func (r *Repository) Create(ctx context.Context, input CreateJobInput) (*models.AdminJob, error) { + payload, err := marshalPayload(input.RequestPayload) + if err != nil { + return nil, fmt.Errorf("marshaling admin job request payload: %w", err) + } + + id, err := idgen.NextID() + if err != nil { + return nil, fmt.Errorf("generate job id: %w", err) + } + job, err := scanAdminJob(r.pool.QueryRow(ctx, ` + INSERT INTO admin_jobs ( + id, job_type, status, created_by_user_id, request_payload, message + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING `+adminJobColumns, + id, + input.JobType, + StatusQueued, + input.CreatedByUserID, + payload, + input.Message, + )) + if err == nil { + return job, nil + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + activeJob, lookupErr := r.GetActiveByType(ctx, input.JobType) + if lookupErr != nil && !errors.Is(lookupErr, ErrJobNotFound) { + return nil, lookupErr + } + return nil, &ActiveJobConflictError{Job: activeJob} + } + + return nil, fmt.Errorf("creating admin job: %w", err) +} + +func (r *Repository) CreateLibraryRefresh( + ctx context.Context, + createdByUserID int, + req LibraryRefreshRequest, + message string, +) (*models.AdminJob, error) { + payload, err := marshalPayload(req) + if err != nil { + return nil, fmt.Errorf("marshaling library refresh request payload: %w", err) + } + + id, err := idgen.NextID() + if err != nil { + return nil, fmt.Errorf("generate job id: %w", err) + } + job, err := scanAdminJob(r.pool.QueryRow(ctx, ` + INSERT INTO admin_jobs ( + id, job_type, status, created_by_user_id, request_payload, message + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING `+adminJobColumns, + id, + JobTypeLibraryRefresh, + StatusQueued, + createdByUserID, + payload, + message, + )) + if err == nil { + return job, nil + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + activeJob, lookupErr := r.GetActiveLibraryRefreshByLibraryID(ctx, req.LibraryID) + if lookupErr != nil && !errors.Is(lookupErr, ErrJobNotFound) { + return nil, lookupErr + } + return nil, &ActiveJobConflictError{Job: activeJob} + } + + return nil, fmt.Errorf("creating library refresh job: %w", err) +} + +func (r *Repository) GetByID(ctx context.Context, id string) (*models.AdminJob, error) { + return scanAdminJob(r.pool.QueryRow(ctx, + `SELECT `+adminJobColumns+` FROM admin_jobs WHERE id = $1`, + id, + )) +} + +func (r *Repository) GetActiveByType(ctx context.Context, jobType string) (*models.AdminJob, error) { + return scanAdminJob(r.pool.QueryRow(ctx, ` + SELECT `+adminJobColumns+` + FROM admin_jobs + WHERE job_type = $1 AND status IN ($2, $3) + ORDER BY requested_at ASC + LIMIT 1`, + jobType, StatusQueued, StatusRunning, + )) +} + +func (r *Repository) GetActiveLibraryRefreshByLibraryID(ctx context.Context, libraryID int) (*models.AdminJob, error) { + return scanAdminJob(r.pool.QueryRow(ctx, ` + SELECT `+adminJobColumns+` + FROM admin_jobs + WHERE job_type = $1 + AND status IN ($2, $3) + AND request_payload->>'library_id' = $4 + ORDER BY requested_at ASC + LIMIT 1`, + JobTypeLibraryRefresh, + StatusQueued, + StatusRunning, + strconv.Itoa(libraryID), + )) +} + +func (r *Repository) List(ctx context.Context, opts ListJobsOptions) ([]*models.AdminJob, error) { + if opts.Limit <= 0 { + opts.Limit = 20 + } + + args := []any{opts.Limit} + query := `SELECT ` + adminJobColumns + ` FROM admin_jobs` + if opts.JobType != "" { + query += ` WHERE job_type = $2` + args = append(args, opts.JobType) + query += ` ORDER BY requested_at DESC LIMIT $1` + } else { + query += ` ORDER BY requested_at DESC LIMIT $1` + } + + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("listing admin jobs: %w", err) + } + defer rows.Close() + + return scanAdminJobs(rows) +} + +func (r *Repository) ClaimNextQueued(ctx context.Context, jobType string) (*models.AdminJob, error) { + return r.claimNextQueued(ctx, jobType) +} + +func (r *Repository) ClaimNextQueuedByTypes(ctx context.Context, jobTypes []string) (*models.AdminJob, error) { + if len(jobTypes) == 0 { + return nil, nil + } + return r.claimNextQueued(ctx, jobTypes) +} + +func (r *Repository) claimNextQueued(ctx context.Context, jobTypeFilter any) (*models.AdminJob, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, fmt.Errorf("beginning admin job claim transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var id string + if err := tx.QueryRow(ctx, ` + SELECT id + FROM admin_jobs + WHERE job_type = ANY($1) AND status = $2 + ORDER BY requested_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 1`, + normalizeJobTypeFilter(jobTypeFilter), StatusQueued, + ).Scan(&id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, tx.Commit(ctx) + } + return nil, fmt.Errorf("claiming admin job: %w", err) + } + + job, err := scanAdminJob(tx.QueryRow(ctx, ` + UPDATE admin_jobs + SET status = $2, + started_at = NOW(), + heartbeat_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING `+adminJobColumns, + id, StatusRunning, + )) + if err != nil { + return nil, fmt.Errorf("marking admin job running: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("committing admin job claim: %w", err) + } + return job, nil +} + +func normalizeJobTypeFilter(jobTypeFilter any) []string { + switch value := jobTypeFilter.(type) { + case string: + return []string{value} + case []string: + return value + default: + return nil + } +} + +func (r *Repository) UpdateProgress(ctx context.Context, id string, current, total int, message string) error { + tag, err := r.pool.Exec(ctx, ` + UPDATE admin_jobs + SET progress_current = $2, + progress_total = $3, + message = $4, + heartbeat_at = NOW(), + updated_at = NOW() + WHERE id = $1`, + id, current, total, message, + ) + if err != nil { + return fmt.Errorf("updating admin job progress: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrJobNotFound + } + return nil +} + +func (r *Repository) TouchHeartbeat(ctx context.Context, id string) error { + tag, err := r.pool.Exec(ctx, ` + UPDATE admin_jobs + SET heartbeat_at = NOW(), + updated_at = NOW() + WHERE id = $1`, + id, + ) + if err != nil { + return fmt.Errorf("touching admin job heartbeat: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrJobNotFound + } + return nil +} + +func (r *Repository) Complete(ctx context.Context, id string, input CompleteJobInput) error { + resultPayload, err := marshalPayload(input.ResultPayload) + if err != nil { + return fmt.Errorf("marshaling admin job result payload: %w", err) + } + + tag, err := r.pool.Exec(ctx, ` + UPDATE admin_jobs + SET status = $2, + result_payload = $3, + message = $4, + error_message = '', + progress_current = $5, + progress_total = $6, + artifact_bucket = $7, + artifact_key = $8, + artifact_size_bytes = $9, + completed_at = NOW(), + heartbeat_at = NOW(), + expires_at = $10, + updated_at = NOW() + WHERE id = $1`, + id, + StatusCompleted, + resultPayload, + input.Message, + input.ProgressCurrent, + input.ProgressTotal, + input.ArtifactBucket, + input.ArtifactKey, + input.ArtifactSizeBytes, + input.ExpiresAt, + ) + if err != nil { + return fmt.Errorf("completing admin job: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrJobNotFound + } + return nil +} + +func (r *Repository) MarkPublic(ctx context.Context, id, publicURL string, publishedAt time.Time) error { + tag, err := r.pool.Exec(ctx, ` + UPDATE admin_jobs + SET public_url = $2, + published_at = $3, + updated_at = NOW() + WHERE id = $1`, + id, publicURL, publishedAt, + ) + if err != nil { + return fmt.Errorf("marking admin job public: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrJobNotFound + } + return nil +} + +func (r *Repository) Fail(ctx context.Context, id string, input FailJobInput) error { + tag, err := r.pool.Exec(ctx, ` + UPDATE admin_jobs + SET status = $2, + message = $3, + error_message = $4, + progress_current = $5, + progress_total = $6, + completed_at = NOW(), + heartbeat_at = NOW(), + expires_at = $7, + updated_at = NOW() + WHERE id = $1`, + id, + StatusFailed, + input.Message, + input.ErrorMessage, + input.ProgressCurrent, + input.ProgressTotal, + input.ExpiresAt, + ) + if err != nil { + return fmt.Errorf("failing admin job: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrJobNotFound + } + return nil +} + +func (r *Repository) RequeueStaleRunning(ctx context.Context, before time.Time) (int, error) { + tag, err := r.pool.Exec(ctx, ` + UPDATE admin_jobs + SET status = $2, + message = 'Requeued after stale worker heartbeat', + error_message = '', + started_at = NULL, + completed_at = NULL, + heartbeat_at = NULL, + updated_at = NOW() + WHERE status = $1 + AND COALESCE(heartbeat_at, started_at, requested_at) < $3`, + StatusRunning, StatusQueued, before, + ) + if err != nil { + return 0, fmt.Errorf("requeueing stale admin jobs: %w", err) + } + return int(tag.RowsAffected()), nil +} + +func (r *Repository) ListExpired(ctx context.Context, now time.Time, limit int) ([]*models.AdminJob, error) { + if limit <= 0 { + limit = 50 + } + rows, err := r.pool.Query(ctx, ` + SELECT `+adminJobColumns+` + FROM admin_jobs + WHERE expires_at IS NOT NULL AND expires_at < $1 + ORDER BY expires_at ASC + LIMIT $2`, + now, limit, + ) + if err != nil { + return nil, fmt.Errorf("listing expired admin jobs: %w", err) + } + defer rows.Close() + return scanAdminJobs(rows) +} + +func (r *Repository) DeleteByID(ctx context.Context, id string) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM admin_jobs WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("deleting admin job: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrJobNotFound + } + return nil +} + +func marshalPayload(v any) ([]byte, error) { + if v == nil { + return []byte(`{}`), nil + } + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + if len(data) == 0 || string(data) == "null" { + return []byte(`{}`), nil + } + return data, nil +} diff --git a/internal/adminjob/runner.go b/internal/adminjob/runner.go new file mode 100644 index 00000000..a52074b2 --- /dev/null +++ b/internal/adminjob/runner.go @@ -0,0 +1,774 @@ +package adminjob + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/Silo-Server/silo-server/internal/catalogseed" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/notifications" +) + +type ArtifactStore interface { + Bucket() string + GetObject(ctx context.Context, bucket, key string) ([]byte, error) + UploadFile(ctx context.Context, bucket, key, path, contentType string) (int64, error) + DeleteObject(ctx context.Context, bucket, key string) error +} + +const remoteCatalogImportTimeout = 10 * time.Minute + +// Maximum wall-clock time a single admin job execution may run before its +// context is cancelled. This is the safety net that prevents a hung +// operation (e.g. an unreachable S3 endpoint) from blocking the job queue +// indefinitely, while still giving large jobs a budget that matches their +// actual scope. +const ( + deleteLibraryTimeout = 2 * time.Hour + imageCacheCleanupTimeout = 2 * time.Hour + libraryRefreshTimeout = 6 * time.Hour + jobTimeoutLong = 2 * time.Hour // catalog_export, catalog_import +) + +type Runner struct { + repo *Repository + exporter *catalogseed.Service + store ArtifactStore + itemRefresh itemRefreshExecutor + libraryRefresh libraryRefreshExecutor + libraryDelete deleteLibraryExecutor + imageCacheCleanup imageCacheCleanupExecutor + realtimeHub *notifications.Hub + pollInterval time.Duration + cleanupInterval time.Duration + heartbeatInterval time.Duration + staleAfter time.Duration + retention time.Duration + stop chan struct{} + stopOnce sync.Once +} + +type itemRefreshExecutor interface { + Execute(ctx context.Context, req ItemRefreshRequest, progress func(current, total int, message string)) (*ItemRefreshResult, error) +} + +type libraryRefreshExecutor interface { + Execute(ctx context.Context, req LibraryRefreshRequest, progress func(current, total int, message string)) (*LibraryRefreshResult, error) +} + +func NewRunner( + repo *Repository, + exporter *catalogseed.Service, + store ArtifactStore, + itemRefresh itemRefreshExecutor, + libraryRefresh libraryRefreshExecutor, + libraryDelete deleteLibraryExecutor, + imageCacheCleanup imageCacheCleanupExecutor, + realtimeHub *notifications.Hub, +) *Runner { + return &Runner{ + repo: repo, + exporter: exporter, + store: store, + itemRefresh: itemRefresh, + libraryRefresh: libraryRefresh, + libraryDelete: libraryDelete, + imageCacheCleanup: imageCacheCleanup, + realtimeHub: realtimeHub, + pollInterval: 5 * time.Second, + cleanupInterval: time.Hour, + heartbeatInterval: 10 * time.Second, + staleAfter: 2 * time.Minute, + retention: 7 * 24 * time.Hour, + stop: make(chan struct{}), + } +} + +func (r *Runner) Start() { + go func() { + r.requeueStaleJobs() + + pollTicker := time.NewTicker(r.pollInterval) + cleanupTicker := time.NewTicker(r.cleanupInterval) + defer pollTicker.Stop() + defer cleanupTicker.Stop() + + for { + select { + case <-r.stop: + return + case <-pollTicker.C: + r.runNext() + case <-cleanupTicker.C: + r.cleanupExpired() + } + } + }() +} + +func (r *Runner) Stop() { + r.stopOnce.Do(func() { + close(r.stop) + }) +} + +func (r *Runner) runNext() { + r.requeueStaleJobs() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + job, err := r.repo.ClaimNextQueuedByTypes(ctx, []string{ + JobTypeCatalogImport, + JobTypeCatalogExport, + JobTypeItemRefresh, + JobTypeLibraryRefresh, + JobTypeDeleteLibrary, + }) + cancel() + if err != nil { + slog.Warn("admin jobs: failed to claim next catalog job", "error", err) + return + } + if job == nil { + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + job, err = r.repo.ClaimNextQueued(ctx, JobTypeImageCacheCleanup) + cancel() + if err != nil { + slog.Warn("admin jobs: failed to claim next image cache cleanup job", "error", err) + return + } + } + if job == nil { + return + } + r.publishJob(context.Background(), notifications.TypeJobProgress, job) + + switch job.JobType { + case JobTypeCatalogImport: + r.executeCatalogImport(job) + case JobTypeCatalogExport: + r.executeCatalogExport(job) + case JobTypeItemRefresh: + r.executeItemRefresh(job) + case JobTypeLibraryRefresh: + r.executeLibraryRefresh(job) + case JobTypeDeleteLibrary: + r.executeDeleteLibrary(job) + case JobTypeImageCacheCleanup: + r.executeImageCacheCleanup(job) + default: + r.failJob(job.ID, 0, 0, "Admin job failed", "unsupported admin job type") + } +} + +func (r *Runner) executeDeleteLibrary(job *models.AdminJob) { + if r.libraryDelete == nil { + r.failJob(job.ID, 0, 0, "Library deletion failed", "library delete executor is not configured") + return + } + + req, err := decodeDeleteLibraryRequest(job.RequestPayload) + if err != nil { + r.failJob(job.ID, 0, 0, "Library deletion failed", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), deleteLibraryTimeout) + defer cancel() + + heartbeatStop := make(chan struct{}) + go r.heartbeatLoop(ctx, job.ID, heartbeatStop) + defer close(heartbeatStop) + + progress := func(current, total int, message string) { + if err := r.repo.UpdateProgress(ctx, job.ID, current, total, message); err != nil { + slog.Warn("admin jobs: failed to update delete progress", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + } + + result, err := r.libraryDelete.Execute(ctx, req, progress) + if err != nil { + msg := err.Error() + if ctx.Err() != nil { + msg = fmt.Sprintf("timed out after %s: %s", deleteLibraryTimeout, msg) + } + r.failJob(job.ID, 0, 5, "Library deletion failed", msg) + return + } + + if cleanupJob := r.queueImageCacheCleanup(context.Background(), job.CreatedByUserID, result); cleanupJob != nil { + result.ImageCleanupQueued = true + result.ImageCleanupJobID = cleanupJob.ID + } + + if err := r.repo.Complete(ctx, job.ID, CompleteJobInput{ + ResultPayload: result, + Message: "Library deletion completed", + ProgressCurrent: 5, + ProgressTotal: 5, + ExpiresAt: time.Now().UTC().Add(r.retention), + }); err != nil { + slog.Warn("admin jobs: failed to mark library deletion complete", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobCompleted, job.ID) +} + +func (r *Runner) queueImageCacheCleanup(ctx context.Context, createdByUserID int, result *DeleteLibraryResult) *models.AdminJob { + if r == nil || r.repo == nil || r.imageCacheCleanup == nil || result == nil || len(result.orphanedImageDirs) == 0 { + return nil + } + + cleanupJob, err := r.repo.Create(ctx, CreateJobInput{ + JobType: JobTypeImageCacheCleanup, + CreatedByUserID: createdByUserID, + RequestPayload: ImageCacheCleanupRequest{ + LibraryID: result.LibraryID, + LibraryName: result.LibraryName, + Prefixes: append([]string(nil), result.orphanedImageDirs...), + }, + Message: "Queued cached image cleanup", + }) + if err != nil { + slog.Warn("admin jobs: failed to queue image cache cleanup", + "library_id", result.LibraryID, + "library_name", result.LibraryName, + "error", err, + ) + return nil + } + + r.publishJob(ctx, notifications.TypeJobCreated, cleanupJob) + return cleanupJob +} + +func (r *Runner) executeImageCacheCleanup(job *models.AdminJob) { + if r.imageCacheCleanup == nil { + r.failJob(job.ID, 0, 0, "Image cache cleanup failed", "image cache cleanup executor is not configured") + return + } + + req, err := decodeImageCacheCleanupRequest(job.RequestPayload) + if err != nil { + r.failJob(job.ID, 0, 0, "Image cache cleanup failed", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), imageCacheCleanupTimeout) + defer cancel() + + heartbeatStop := make(chan struct{}) + go r.heartbeatLoop(ctx, job.ID, heartbeatStop) + defer close(heartbeatStop) + + progress := func(current, total int, message string) { + if err := r.repo.UpdateProgress(ctx, job.ID, current, total, message); err != nil { + slog.Warn("admin jobs: failed to update image cache cleanup progress", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + } + + result, err := r.imageCacheCleanup.Execute(ctx, req, progress) + if err != nil { + msg := err.Error() + if ctx.Err() != nil { + msg = fmt.Sprintf("timed out after %s: %s", imageCacheCleanupTimeout, msg) + } + r.failJob(job.ID, 0, len(req.Prefixes), "Image cache cleanup failed", msg) + return + } + + if err := r.repo.Complete(ctx, job.ID, CompleteJobInput{ + ResultPayload: result, + Message: "Cached image cleanup completed", + ProgressCurrent: len(req.Prefixes), + ProgressTotal: len(req.Prefixes), + ExpiresAt: time.Now().UTC().Add(r.retention), + }); err != nil { + slog.Warn("admin jobs: failed to mark image cache cleanup complete", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobCompleted, job.ID) +} + +func (r *Runner) executeLibraryRefresh(job *models.AdminJob) { + if r.libraryRefresh == nil { + r.failJob(job.ID, 0, 0, "Library metadata refresh failed", "library refresh executor is not configured") + return + } + + req, err := decodeLibraryRefreshRequest(job.RequestPayload) + if err != nil { + r.failJob(job.ID, 0, 0, "Library metadata refresh failed", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), libraryRefreshTimeout) + defer cancel() + + heartbeatStop := make(chan struct{}) + go r.heartbeatLoop(ctx, job.ID, heartbeatStop) + defer close(heartbeatStop) + + if err := r.repo.UpdateProgress(ctx, job.ID, 0, 0, "Preparing library metadata refresh"); err != nil { + slog.Warn("admin jobs: failed to set initial library refresh progress", "job_id", job.ID, "error", err) + } else { + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + } + + current := 0 + total := 0 + result, err := r.libraryRefresh.Execute(ctx, req, func(nextCurrent, nextTotal int, message string) { + current = nextCurrent + total = nextTotal + if updateErr := r.repo.UpdateProgress(ctx, job.ID, nextCurrent, nextTotal, message); updateErr != nil { + slog.Warn("admin jobs: failed to update library refresh progress", + "job_id", job.ID, + "current", nextCurrent, + "total", nextTotal, + "error", updateErr, + ) + return + } + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + }) + if err != nil { + msg := err.Error() + if ctx.Err() != nil { + msg = fmt.Sprintf("timed out after %s: %s", libraryRefreshTimeout, msg) + } + r.failJob(job.ID, current, total, "Library metadata refresh failed", msg) + return + } + + if err := r.repo.Complete(ctx, job.ID, CompleteJobInput{ + ResultPayload: result, + Message: "Library metadata refresh completed", + ProgressCurrent: current, + ProgressTotal: total, + ExpiresAt: time.Now().UTC().Add(r.retention), + }); err != nil { + slog.Warn("admin jobs: failed to complete library refresh", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobCompleted, job.ID) +} + +func (r *Runner) requeueStaleJobs() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if requeued, err := r.repo.RequeueStaleRunning(ctx, time.Now().UTC().Add(-r.staleAfter)); err != nil { + slog.Warn("admin jobs: failed to requeue stale jobs", "error", err) + } else if requeued > 0 { + slog.Info("admin jobs: requeued stale jobs", "count", requeued) + } +} + +func (r *Runner) executeCatalogExport(job *models.AdminJob) { + if r.store == nil { + r.failJob(job.ID, 0, 0, "Catalog export failed", "private internal S3 is not configured") + return + } + + var opts catalogseed.ExportOptions + if len(job.RequestPayload) > 0 { + if err := json.Unmarshal(job.RequestPayload, &opts); err != nil { + r.failJob(job.ID, 0, 0, "Catalog export failed", fmt.Sprintf("invalid export request payload: %v", err)) + return + } + } + + ctx, cancel := context.WithTimeout(context.Background(), jobTimeoutLong) + defer cancel() + + heartbeatStop := make(chan struct{}) + go r.heartbeatLoop(ctx, job.ID, heartbeatStop) + + if err := r.repo.UpdateProgress(ctx, job.ID, 0, 0, "Exporting catalog"); err != nil { + slog.Warn("admin jobs: failed to update initial export progress", "job_id", job.ID, "error", err) + } else { + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + } + + tempFile, err := os.CreateTemp("", "silo-catalog-seed-*.json.gz") + if err != nil { + r.failJob(job.ID, 0, 0, "Catalog export failed", fmt.Sprintf("creating temp file: %v", err)) + return + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + + var ( + lastProgressUpdate time.Time + lastProgress catalogseed.ExportProgress + ) + summary, exportErr := r.exporter.ExportToWriter(ctx, tempFile, opts, func(progress catalogseed.ExportProgress) { + lastProgress = progress + if time.Since(lastProgressUpdate) < time.Second && progress.Current != progress.Total { + return + } + lastProgressUpdate = time.Now() + if err := r.repo.UpdateProgress(ctx, job.ID, progress.Current, progress.Total, progress.Message); err != nil { + slog.Warn("admin jobs: failed to update export progress", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + }) + close(heartbeatStop) + if err := tempFile.Close(); err != nil && exportErr == nil { + exportErr = fmt.Errorf("closing temp export file: %w", err) + } + if exportErr != nil { + r.failJob(job.ID, lastProgress.Current, lastProgress.Total, "Catalog export failed", exportErr.Error()) + return + } + + key := filepath.ToSlash(filepath.Join( + "catalog-seeds", + time.Now().UTC().Format("2006"), + time.Now().UTC().Format("01"), + time.Now().UTC().Format("02"), + job.ID+".json.gz", + )) + + uploadCtx, uploadCancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer uploadCancel() + if err := r.repo.UpdateProgress(uploadCtx, job.ID, lastProgress.Total, lastProgress.Total, "Uploading catalog export"); err != nil { + slog.Warn("admin jobs: failed to mark upload phase", "job_id", job.ID, "error", err) + } else { + r.publishJobByID(uploadCtx, notifications.TypeJobProgress, job.ID) + } + size, err := r.store.UploadFile(uploadCtx, r.store.Bucket(), key, tempPath, "application/gzip") + if err != nil { + r.failJob(job.ID, lastProgress.Total, lastProgress.Total, "Catalog export failed", err.Error()) + return + } + + if err := r.repo.Complete(uploadCtx, job.ID, CompleteJobInput{ + ResultPayload: summary, + Message: "Catalog export completed", + ProgressCurrent: lastProgress.Total, + ProgressTotal: lastProgress.Total, + ArtifactBucket: r.store.Bucket(), + ArtifactKey: key, + ArtifactSizeBytes: size, + ExpiresAt: time.Now().UTC().Add(r.retention), + }); err != nil { + slog.Warn("admin jobs: failed to mark export complete", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(uploadCtx, notifications.TypeJobCompleted, job.ID) +} + +func (r *Runner) executeCatalogImport(job *models.AdminJob) { + var req CatalogImportRequest + if len(job.RequestPayload) > 0 { + if err := json.Unmarshal(job.RequestPayload, &req); err != nil { + r.failJob(job.ID, 0, 0, "Catalog import failed", fmt.Sprintf("invalid import request payload: %v", err)) + return + } + } + + ctx, cancel := context.WithTimeout(context.Background(), jobTimeoutLong) + defer cancel() + + heartbeatStop := make(chan struct{}) + go r.heartbeatLoop(ctx, job.ID, heartbeatStop) + defer close(heartbeatStop) + + if err := r.repo.UpdateProgress(ctx, job.ID, 0, 0, "Loading catalog import source"); err != nil { + slog.Warn("admin jobs: failed to update initial import progress", "job_id", job.ID, "error", err) + } else { + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + } + + var data []byte + if req.LocalPath != "" { + var err error + data, err = os.ReadFile(req.LocalPath) + if err != nil { + r.failJob(job.ID, 0, 0, "Catalog import failed", fmt.Sprintf("reading local file: %v", err)) + return + } + } else if req.RemoteURL != "" { + if err := r.repo.UpdateProgress(ctx, job.ID, 0, 0, "Downloading catalog import source"); err != nil { + slog.Warn("admin jobs: failed to update remote import progress", "job_id", job.ID, "error", err) + } else { + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + } + var err error + data, err = downloadRemoteCatalogSeed(ctx, req.RemoteURL) + if err != nil { + r.failJob(job.ID, 0, 0, "Catalog import failed", err.Error()) + return + } + } else { + if r.store == nil { + r.failJob(job.ID, 0, 0, "Catalog import failed", "private internal S3 is not configured") + return + } + if req.SourceBucket == "" { + req.SourceBucket = r.store.Bucket() + } + if req.SourceKey == "" { + r.failJob(job.ID, 0, 0, "Catalog import failed", "missing import source object") + return + } + var err error + data, err = r.store.GetObject(ctx, req.SourceBucket, req.SourceKey) + if err != nil { + r.failJob(job.ID, 0, 0, "Catalog import failed", err.Error()) + return + } + if req.CleanupSource { + defer func() { + if err := r.store.DeleteObject(context.Background(), req.SourceBucket, req.SourceKey); err != nil { + slog.Warn("admin jobs: failed to delete staged import object", "job_id", job.ID, "bucket", req.SourceBucket, "key", req.SourceKey, "error", err) + } + }() + } + } + + var ( + lastProgressUpdate time.Time + lastProgress catalogseed.ImportProgress + ) + result, importErr := r.exporter.ImportWithProgress(ctx, data, req.Options, func(progress catalogseed.ImportProgress) { + lastProgress = progress + if time.Since(lastProgressUpdate) < time.Second && progress.Current != progress.Total { + return + } + lastProgressUpdate = time.Now() + if err := r.repo.UpdateProgress(ctx, job.ID, progress.Current, progress.Total, progress.Message); err != nil { + slog.Warn("admin jobs: failed to update import progress", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + }) + if importErr != nil { + r.failJob(job.ID, lastProgress.Current, lastProgress.Total, "Catalog import failed", importErr.Error()) + return + } + + completeCtx, completeCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer completeCancel() + if err := r.repo.Complete(completeCtx, job.ID, CompleteJobInput{ + ResultPayload: result, + Message: "Catalog import completed", + ProgressCurrent: lastProgress.Total, + ProgressTotal: lastProgress.Total, + ExpiresAt: time.Now().UTC().Add(r.retention), + }); err != nil { + slog.Warn("admin jobs: failed to mark import complete", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(completeCtx, notifications.TypeJobCompleted, job.ID) +} + +func downloadRemoteCatalogSeed(ctx context.Context, remoteURL string) ([]byte, error) { + parsed, err := url.Parse(remoteURL) + if err != nil { + return nil, fmt.Errorf("invalid remote URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("invalid remote URL scheme") + } + if !strings.HasSuffix(strings.ToLower(parsed.Path), ".json.gz") { + return nil, fmt.Errorf("remote URL must point to a .json.gz file") + } + + reqCtx, cancel := context.WithTimeout(ctx, remoteCatalogImportTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, remoteURL, nil) + if err != nil { + return nil, fmt.Errorf("building remote import request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading remote catalog seed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading remote catalog seed: unexpected status %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading remote catalog seed: %w", err) + } + + return data, nil +} + +func (r *Runner) executeItemRefresh(job *models.AdminJob) { + if r.itemRefresh == nil { + r.failJob(job.ID, 0, 3, "Item refresh failed", "item refresh executor is not configured") + return + } + + var req ItemRefreshRequest + if len(job.RequestPayload) > 0 { + if err := json.Unmarshal(job.RequestPayload, &req); err != nil { + r.failJob(job.ID, 0, 3, "Item refresh failed", fmt.Sprintf("invalid item refresh payload: %v", err)) + return + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + heartbeatStop := make(chan struct{}) + go r.heartbeatLoop(ctx, job.ID, heartbeatStop) + defer close(heartbeatStop) + + if err := r.repo.UpdateProgress(ctx, job.ID, 0, 3, "Resolving scan scope"); err != nil { + slog.Warn("admin jobs: failed to set initial item refresh progress", "job_id", job.ID, "error", err) + } else { + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + } + + result, err := r.itemRefresh.Execute(ctx, req, func(current, total int, message string) { + if updateErr := r.repo.UpdateProgress(ctx, job.ID, current, total, message); updateErr != nil { + slog.Warn("admin jobs: failed to update item refresh progress", + "job_id", job.ID, + "current", current, + "total", total, + "error", updateErr, + ) + return + } + r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID) + }) + if err != nil { + message := err.Error() + switch { + case containsPhase(message, "scan scope"): + r.failJob(job.ID, 1, 3, "Item refresh failed", message) + case containsPhase(message, "match discovered files"): + r.failJob(job.ID, 2, 3, "Item refresh failed", message) + case containsPhase(message, "refresh metadata"): + r.failJob(job.ID, 3, 3, "Item refresh failed", message) + default: + r.failJob(job.ID, 0, 3, "Item refresh failed", message) + } + return + } + if err := r.repo.Complete(ctx, job.ID, CompleteJobInput{ + ResultPayload: result, + Message: "Metadata refreshed", + ProgressCurrent: 3, + ProgressTotal: 3, + }); err != nil { + slog.Warn("admin jobs: failed to complete item refresh", "job_id", job.ID, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobCompleted, job.ID) +} + +func (r *Runner) heartbeatLoop(ctx context.Context, jobID string, stop <-chan struct{}) { + ticker := time.NewTicker(r.heartbeatInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-stop: + return + case <-ticker.C: + if err := r.repo.TouchHeartbeat(ctx, jobID); err != nil && !errors.Is(err, ErrJobNotFound) { + slog.Warn("admin jobs: failed to touch heartbeat", "job_id", jobID, "error", err) + } + } + } +} + +func containsPhase(value, phase string) bool { + return strings.Contains(value, phase) +} + +func (r *Runner) cleanupExpired() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + jobs, err := r.repo.ListExpired(ctx, time.Now().UTC(), 50) + if err != nil { + slog.Warn("admin jobs: failed to list expired jobs", "error", err) + return + } + + for _, job := range jobs { + if r.store != nil && job.ArtifactBucket != "" && job.ArtifactKey != "" { + if err := r.store.DeleteObject(ctx, job.ArtifactBucket, job.ArtifactKey); err != nil { + slog.Warn("admin jobs: failed to delete expired artifact", "job_id", job.ID, "error", err) + continue + } + } + if err := r.repo.DeleteByID(ctx, job.ID); err != nil && !errors.Is(err, ErrJobNotFound) { + slog.Warn("admin jobs: failed to delete expired job", "job_id", job.ID, "error", err) + } + } +} + +func (r *Runner) publishJobByID(ctx context.Context, eventType notifications.Type, id string) { + if r == nil || r.realtimeHub == nil || r.repo == nil || id == "" { + return + } + + job, err := r.repo.GetByID(ctx, id) + if err != nil { + if !errors.Is(err, ErrJobNotFound) { + slog.Warn("admin jobs: failed to load job for realtime event", "job_id", id, "error", err) + } + return + } + + r.publishJob(ctx, eventType, job) +} + +func (r *Runner) publishJob(ctx context.Context, eventType notifications.Type, job *models.AdminJob) { + if r == nil || r.realtimeHub == nil || job == nil { + return + } + if err := r.realtimeHub.PublishJob(ctx, eventType, job); err != nil { + slog.Warn("admin jobs: failed to publish realtime job event", + "job_id", job.ID, + "type", eventType, + "error", err, + ) + } +} + +func (r *Runner) failJob(id string, current, total int, message, errorMessage string) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := r.repo.Fail(ctx, id, FailJobInput{ + Message: message, + ErrorMessage: errorMessage, + ProgressCurrent: current, + ProgressTotal: total, + ExpiresAt: time.Now().UTC().Add(r.retention), + }); err != nil { + slog.Warn("admin jobs: failed to mark job failed", "job_id", id, "error", err) + return + } + r.publishJobByID(ctx, notifications.TypeJobFailed, id) +} diff --git a/internal/api/handlers/access_filter.go b/internal/api/handlers/access_filter.go new file mode 100644 index 00000000..49649e01 --- /dev/null +++ b/internal/api/handlers/access_filter.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" +) + +func requestAccessFilter(r *http.Request) catalog.AccessFilter { + if scope, ok := access.GetScope(r.Context()); ok { + return catalog.AccessFilter{ + AllowedLibraryIDs: scope.AllowedLibraryIDs, + DisabledLibraryIDs: scope.DisabledLibraryIDs, + MaxContentRating: scope.MaxContentRating, + MaxPlaybackQuality: scope.MaxPlaybackQuality, + UserID: apimw.GetUserID(r.Context()), + ProfileID: apimw.GetProfileID(r.Context()), + } + } + return catalog.AccessFilter{ + UserID: apimw.GetUserID(r.Context()), + ProfileID: apimw.GetProfileID(r.Context()), + } +} diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go new file mode 100644 index 00000000..356c73f3 --- /dev/null +++ b/internal/api/handlers/admin.go @@ -0,0 +1,1901 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/sync/errgroup" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/adminjob" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/clientip" + "github.com/Silo-Server/silo-server/internal/markers" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// AdminMetadataRefresher can refresh metadata for individual items. +type AdminMetadataRefresher interface { + RefreshItem(ctx context.Context, contentID string) error +} + +// UserRepository defines the operations the AdminHandler needs on users. +type UserRepository interface { + List(ctx context.Context) ([]*models.User, error) + Create(ctx context.Context, input models.CreateUserInput) (*models.User, error) + Update(ctx context.Context, id int, input models.UpdateUserInput) error + Delete(ctx context.Context, id int) error + GetByID(ctx context.Context, id int) (*models.User, error) +} + +// ServerSettingsStore provides access to server-wide admin settings. +type ServerSettingsStore interface { + Get(ctx context.Context, key string) (string, error) + Set(ctx context.Context, key, value string) error + GetAll(ctx context.Context) (map[string]string, error) +} + +type AdminJobCreator interface { + Create(ctx context.Context, input adminjob.CreateJobInput) (*models.AdminJob, error) + CreateLibraryRefresh( + ctx context.Context, + createdByUserID int, + req adminjob.LibraryRefreshRequest, + message string, + ) (*models.AdminJob, error) +} + +type ItemRefreshScopeResolver interface { + Resolve(ctx context.Context, contentID string) (*adminjob.ItemRefreshRequest, error) + ResolveWithMode(ctx context.Context, contentID string, mode adminjob.ItemRefreshMode) (*adminjob.ItemRefreshRequest, error) +} + +type ImpersonationService interface { + StartImpersonation(ctx context.Context, adminUserID, targetUserID int, deviceName, ip string) (*auth.TokenPair, *models.User, *models.User, error) +} + +// AdminHandler handles admin-only HTTP endpoints for user management, +// session listing, unmatched files, and system stats. +type AdminHandler struct { + userRepo UserRepository + pool *pgxpool.Pool + SessionsLoader *PlaybackSessionsLoader + storeProv userstore.UserStoreProvider + accountProvisioner *auth.AccountProvisioner + DetailSvc *catalog.DetailService + StatsSource AdminStatsSource + EventBus cache.EventBus + SettingsRepo ServerSettingsStore + JobRepo AdminJobCreator + ItemRefreshResolver ItemRefreshScopeResolver + ImpersonationService ImpersonationService + RealtimeHub *notifications.Hub + BootstrapSensitiveConfigured map[string]bool + BootstrapSensitiveValues map[string]string + OnUserSessionsRevoked func(ctx context.Context, userID int) +} + +// NewAdminHandler creates a new AdminHandler backed by the given +// user repository and database pool. +func NewAdminHandler( + userRepo UserRepository, + pool *pgxpool.Pool, + storeProv userstore.UserStoreProvider, +) *AdminHandler { + return &AdminHandler{ + userRepo: userRepo, + pool: pool, + storeProv: storeProv, + accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv), + } +} + +// --- Request/Response types --- + +// createUserRequest represents the JSON body for POST /admin/users. +type createUserRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + CreateDefaultProfile bool `json:"create_default_profile"` + DefaultProfileName string `json:"default_profile_name,omitempty"` + LibraryIDs []int `json:"library_ids"` + MaxPlaybackQuality string `json:"max_playback_quality"` + MaxStreams *int `json:"max_streams,omitempty"` + MaxTranscodes *int `json:"max_transcodes,omitempty"` + MaxProfiles *int `json:"max_profiles,omitempty"` + DownloadAllowed *bool `json:"download_allowed,omitempty"` + DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"` +} + +type updateLibraryIDsField struct { + Set bool + Value []int +} + +func (f *updateLibraryIDsField) UnmarshalJSON(data []byte) error { + f.Set = true + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + f.Value = nil + return nil + } + return json.Unmarshal(data, &f.Value) +} + +func (f updateLibraryIDsField) Ptr() *[]int { + if !f.Set { + return nil + } + value := append([]int(nil), f.Value...) + return &value +} + +// updateUserRequest represents the JSON body for PUT /admin/users/{id}. +type updateUserRequest struct { + Username *string `json:"username,omitempty"` + Email *string `json:"email,omitempty"` + Password *string `json:"password,omitempty"` + Role *string `json:"role,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"` + MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"` + MaxStreams *int `json:"max_streams,omitempty"` + MaxTranscodes *int `json:"max_transcodes,omitempty"` + MaxProfiles *int `json:"max_profiles,omitempty"` + DownloadAllowed *bool `json:"download_allowed,omitempty"` + DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"` +} + +// adminUserResponse represents a user in admin JSON responses. +type adminUserResponse struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + Enabled bool `json:"enabled"` + LibraryIDs []int `json:"library_ids"` + MaxPlaybackQuality string `json:"max_playback_quality"` + MaxStreams int `json:"max_streams"` + MaxTranscodes int `json:"max_transcodes"` + MaxProfiles int `json:"max_profiles"` + DownloadAllowed bool `json:"download_allowed"` + DownloadTranscodeAllowed bool `json:"download_transcode_allowed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastActiveAt *time.Time `json:"last_active_at,omitempty"` +} + +type adminPlaybackHistoryRow struct { + SessionID string `json:"session_id"` + UserID int `json:"user_id"` + Username string `json:"username"` + ProfileID string `json:"profile_id"` + ProfileName string `json:"profile_name"` + MediaItemID string `json:"media_item_id"` + MediaFileID int `json:"media_file_id"` + MediaTitle string `json:"media_title"` + MediaType string `json:"media_type"` + PlayMethod string `json:"play_method"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + WatchedSeconds float64 `json:"watched_seconds"` + DurationSeconds *float64 `json:"duration_seconds"` + Completed bool `json:"completed"` +} + +type adminUserProfileRow struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// unmatchedFileRow represents a media file with no content_id. +type unmatchedFileRow struct { + ID int `json:"id"` + MediaFolderID int `json:"media_folder_id"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + Container string `json:"container"` +} + +// --- Helper --- + +// presignPosterURL generates a presigned poster URL for admin sessions. +// Returns empty string if no detail service is configured or the path is empty. +func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string { + if h.DetailSvc != nil { + return h.DetailSvc.PresignURL(r.Context(), cardThumbnailPath(path), "card") + } + return "" +} + +// toAdminUserResponse converts a User model to an admin API response. +func toAdminUserResponse(u *models.User) adminUserResponse { + return adminUserResponse{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + Role: u.Role, + Enabled: u.Enabled, + LibraryIDs: append([]int(nil), u.LibraryIDs...), + MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality), + MaxStreams: u.MaxStreams, + MaxTranscodes: u.MaxTranscodes, + MaxProfiles: u.MaxProfiles, + DownloadAllowed: u.DownloadAllowed, + DownloadTranscodeAllowed: u.DownloadTranscodeAllowed, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +func (h *AdminHandler) loadUserLastActiveAt(ctx context.Context, userIDs []int) (map[int]time.Time, error) { + lastActive := make(map[int]time.Time) + if h == nil || h.pool == nil || len(userIDs) == 0 { + return lastActive, nil + } + + rows, err := h.pool.Query(ctx, ` + SELECT user_id, MAX("timestamp") AS last_active_at + FROM activity_log + WHERE user_id = ANY($1::int[]) + GROUP BY user_id`, userIDs) + if err != nil { + return lastActive, fmt.Errorf("loading user last activity: %w", err) + } + defer rows.Close() + + for rows.Next() { + var userID int + var timestamp time.Time + if err := rows.Scan(&userID, ×tamp); err != nil { + return lastActive, fmt.Errorf("scanning user last activity: %w", err) + } + lastActive[userID] = timestamp + } + if err := rows.Err(); err != nil { + return lastActive, fmt.Errorf("iterating user last activity: %w", err) + } + + return lastActive, nil +} + +func applyLastActiveAt(resp *adminUserResponse, lastActive map[int]time.Time) { + if resp == nil { + return + } + if timestamp, ok := lastActive[resp.ID]; ok { + resp.LastActiveAt = ×tamp + } +} + +// --- Handler methods --- + +// HandleListUsers handles GET /admin/users. +func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.userRepo.List(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list users") + return + } + + resp := make([]adminUserResponse, 0, len(users)) + userIDs := make([]int, 0, len(users)) + for _, u := range users { + userIDs = append(userIDs, u.ID) + resp = append(resp, toAdminUserResponse(u)) + } + lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs) + if err != nil { + slog.Warn("failed to load admin user last activity", "error", err) + } + for i := range resp { + applyLastActiveAt(&resp[i], lastActive) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetUser handles GET /admin/users/{id}. +func (h *AdminHandler) HandleGetUser(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID") + return + } + + user, err := h.userRepo.GetByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "User not found") + return + } + + resp := toAdminUserResponse(user) + lastActive, err := h.loadUserLastActiveAt(r.Context(), []int{user.ID}) + if err != nil { + slog.Warn("failed to load admin user last activity", "user_id", user.ID, "error", err) + } + applyLastActiveAt(&resp, lastActive) + + writeJSON(w, http.StatusOK, resp) +} + +// HandleCreateUser handles POST /admin/users. +func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request) { + var req createUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required") + return + } + + maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality) + if !ok { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality") + return + } + if req.MaxProfiles != nil && *req.MaxProfiles < 1 { + writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1") + return + } + + user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{ + User: models.CreateUserInput{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + Role: req.Role, + LibraryIDs: req.LibraryIDs, + MaxPlaybackQuality: maxPlaybackQuality, + MaxStreams: req.MaxStreams, + MaxTranscodes: req.MaxTranscodes, + MaxProfiles: req.MaxProfiles, + DownloadAllowed: req.DownloadAllowed, + DownloadTranscodeAllowed: req.DownloadTranscodeAllowed, + }, + DefaultProfile: auth.DefaultProfileOptions{ + Enabled: req.CreateDefaultProfile, + Name: req.DefaultProfileName, + }, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user") + return + } + h.invalidateStats(r.Context(), cache.ChannelAdmin, cache.EventAdminStatsInvalidated, strconv.Itoa(user.ID)) + + writeJSON(w, http.StatusCreated, toAdminUserResponse(user)) +} + +// HandleUpdateUser handles PUT /admin/users/{id}. +func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID") + return + } + + var req updateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + var maxPlaybackQuality *string + if req.MaxPlaybackQuality != nil { + normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality) + if !ok { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality") + return + } + maxPlaybackQuality = &normalized + } + if req.MaxProfiles != nil && *req.MaxProfiles < 1 { + writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1") + return + } + + err = h.userRepo.Update(r.Context(), id, models.UpdateUserInput{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + Role: req.Role, + Enabled: req.Enabled, + LibraryIDs: req.LibraryIDs.Ptr(), + MaxPlaybackQuality: maxPlaybackQuality, + MaxStreams: req.MaxStreams, + MaxTranscodes: req.MaxTranscodes, + MaxProfiles: req.MaxProfiles, + DownloadAllowed: req.DownloadAllowed, + DownloadTranscodeAllowed: req.DownloadTranscodeAllowed, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user") + return + } + if updateRequiresSessionRevocation(req) { + if err := h.revokeUserSessions(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke updated user sessions") + return + } + } + + user, err := h.userRepo.GetByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated user") + return + } + + writeJSON(w, http.StatusOK, toAdminUserResponse(user)) +} + +// HandleDeleteUser handles DELETE /admin/users/{id}. +func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID") + return + } + + err = h.userRepo.Delete(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete user") + return + } + if err := h.revokeUserSessions(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke deleted user sessions") + return + } + h.invalidateStats(r.Context(), cache.ChannelAdmin, cache.EventAdminStatsInvalidated, strconv.Itoa(id)) + + w.WriteHeader(http.StatusNoContent) +} + +// HandleImpersonateUser handles POST /admin/users/{id}/impersonate. +func (h *AdminHandler) HandleImpersonateUser(w http.ResponseWriter, r *http.Request) { + if h.ImpersonationService == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Impersonation service unavailable") + return + } + + claims := apimw.GetClaims(r.Context()) + if claims == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + if claims.TokenType == auth.TokenTypeAPIKey || claims.SessionID == "" { + writeError(w, http.StatusForbidden, "impersonation_not_allowed", "Impersonation is not allowed") + return + } + + targetID, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID") + return + } + + pair, impersonator, effectiveUser, err := h.ImpersonationService.StartImpersonation( + auth.WithClaims(r.Context(), claims), + claims.UserID, + targetID, + r.UserAgent(), + clientip.FromContext(r.Context()), + ) + if err != nil { + if auth.IsNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "User not found") + return + } + if errors.Is(err, auth.ErrAlreadyImpersonating) { + writeError(w, http.StatusConflict, "already_impersonating", "An impersonation session is already active") + return + } + if errors.Is(err, auth.ErrImpersonationNotAllowed) { + writeError(w, http.StatusForbidden, "impersonation_not_allowed", "Impersonation is not allowed") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start impersonation") + return + } + + writeJSON(w, http.StatusOK, buildLoginResponse(pair, effectiveUser, impersonator)) +} + +// HandleListSessions handles GET /admin/sessions. +// Lists active playback sessions enriched with user and media information. +func (h *AdminHandler) HandleListSessions(w http.ResponseWriter, r *http.Request) { + sessions, err := h.loadPlaybackSessions(r.Context(), r) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list sessions") + return + } + + writeJSON(w, http.StatusOK, sessions) +} + +func (h *AdminHandler) loadPlaybackSessions(ctx context.Context, r *http.Request) ([]playbackSessionRow, error) { + loader, err := resolvePlaybackSessionsLoader(h.SessionsLoader, h.pool, h.storeProv, h.DetailSvc) + if err != nil { + return nil, err + } + return loader.Load(ctx, r, PlaybackSessionsQuery{}) +} + +// HandleListPlaybackHistory handles GET /admin/playback-history. +func (h *AdminHandler) HandleListPlaybackHistory(w http.ResponseWriter, r *http.Request) { + if h.pool == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured") + return + } + + limit, offset := parsePagination(r) + q := r.URL.Query() + + var ( + args []any + conditions []string + argIndex = 1 + ) + + if userIDStr := strings.TrimSpace(q.Get("user_id")); userIDStr != "" { + userID, err := strconv.Atoi(userIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user_id") + return + } + conditions = append(conditions, "h.user_id = $"+strconv.Itoa(argIndex)) + args = append(args, userID) + argIndex++ + } + + if profileID := strings.TrimSpace(q.Get("profile_id")); profileID != "" { + conditions = append(conditions, "h.profile_id = $"+strconv.Itoa(argIndex)) + args = append(args, profileID) + argIndex++ + } + + if mediaItemID := strings.TrimSpace(q.Get("media_item_id")); mediaItemID != "" { + conditions = append(conditions, "h.media_item_id = $"+strconv.Itoa(argIndex)) + args = append(args, mediaItemID) + argIndex++ + } + + switch strings.TrimSpace(q.Get("completed")) { + case "", "all": + case "true": + conditions = append(conditions, "h.completed = TRUE") + case "false": + conditions = append(conditions, "h.completed = FALSE") + default: + writeError(w, http.StatusBadRequest, "bad_request", "Invalid completed filter") + return + } + + query := ` + SELECT + h.session_id, + h.user_id, + COALESCE(u.username, ''), + h.profile_id, + COALESCE(NULLIF(h.profile_name, ''), h.profile_id), + h.media_item_id, + h.media_file_id, + COALESCE(ep.title, mi.title, ''), + COALESCE(CASE WHEN ep.content_id IS NOT NULL THEN 'episode' ELSE mi.type END, ''), + h.play_method, + h.started_at, + h.ended_at, + h.watched_seconds, + h.duration_seconds, + h.completed + FROM playback_history_admin h + LEFT JOIN users u ON u.id = h.user_id + LEFT JOIN media_items mi ON mi.content_id = h.media_item_id + LEFT JOIN episodes ep ON ep.content_id = h.media_item_id + ` + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + query += " ORDER BY h.ended_at DESC" + query += " LIMIT $" + strconv.Itoa(argIndex) + args = append(args, limit) + argIndex++ + query += " OFFSET $" + strconv.Itoa(argIndex) + args = append(args, offset) + + rows, err := h.pool.Query(r.Context(), query, args...) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list playback history") + return + } + defer rows.Close() + + history := make([]adminPlaybackHistoryRow, 0) + for rows.Next() { + var row adminPlaybackHistoryRow + if err := rows.Scan( + &row.SessionID, + &row.UserID, + &row.Username, + &row.ProfileID, + &row.ProfileName, + &row.MediaItemID, + &row.MediaFileID, + &row.MediaTitle, + &row.MediaType, + &row.PlayMethod, + &row.StartedAt, + &row.EndedAt, + &row.WatchedSeconds, + &row.DurationSeconds, + &row.Completed, + ); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan playback history row") + return + } + history = append(history, row) + } + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate playback history") + return + } + + writeJSON(w, http.StatusOK, history) +} + +// HandleListUserProfiles handles GET /admin/users/{id}/profiles. +func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Request) { + if h.storeProv == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "User store not configured") + return + } + + idStr := chi.URLParam(r, "id") + userID, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID") + return + } + + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + if store == nil { + writeError(w, http.StatusNotFound, "not_found", "User store not found") + return + } + + profiles, err := store.ListProfiles(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles") + return + } + + resp := make([]adminUserProfileRow, 0, len(profiles)) + for _, profile := range profiles { + resp = append(resp, adminUserProfileRow{ID: profile.ID, Name: profile.Name}) + } + + writeJSON(w, http.StatusOK, resp) +} + +func updateRequiresSessionRevocation(req updateUserRequest) bool { + return req.Password != nil || + req.Role != nil || + req.Enabled != nil || + req.LibraryIDs.Set || + req.MaxPlaybackQuality != nil +} + +func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error { + if h.pool == nil { + return nil + } + sessionRepo := auth.NewSessionRepository(h.pool) + if err := sessionRepo.RevokeAllByUser(ctx, userID); err != nil { + return err + } + if err := sessionRepo.RevokeAllByImpersonator(ctx, userID); err != nil { + return err + } + if h.OnUserSessionsRevoked != nil { + h.OnUserSessionsRevoked(ctx, userID) + } + return nil +} + +// HandleListUnmatched handles GET /admin/unmatched. +// Lists media files that have not been matched to content (content_id IS NULL). +func (h *AdminHandler) HandleListUnmatched(w http.ResponseWriter, r *http.Request) { + if h.pool == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured") + return + } + + q := r.URL.Query() + limit := 100 + if v := q.Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { + limit = n + } + } + offset := 0 + if v := q.Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + rows, err := h.pool.Query(r.Context(), + `SELECT id, media_folder_id, file_path, file_size, container + FROM media_files + WHERE content_id IS NULL + ORDER BY id ASC + LIMIT $1 OFFSET $2`, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched files") + return + } + defer rows.Close() + + files := make([]unmatchedFileRow, 0) + for rows.Next() { + var f unmatchedFileRow + if err := rows.Scan(&f.ID, &f.MediaFolderID, &f.FilePath, &f.FileSize, &f.Container); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan file") + return + } + files = append(files, f) + } + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate files") + return + } + + writeJSON(w, http.StatusOK, files) +} + +// HandleGetStats handles GET /admin/stats. +// Returns system statistics for the admin dashboard. +func (h *AdminHandler) HandleGetStats(w http.ResponseWriter, r *http.Request) { + var resp AdminStats + + if h.StatsSource != nil { + stats, err := h.StatsSource.Get(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get stats") + return + } + resp = stats + } else if h.pool != nil { + stats, err := queryAdminStats(r.Context(), h.pool) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get stats") + return + } + resp = stats + } else { + // Fallback: use the user repository when PG pool is not available. + users, err := h.userRepo.List(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count users") + return + } + resp.TotalUsers = len(users) + } + + writeJSON(w, http.StatusOK, resp) +} + +func (h *AdminHandler) invalidateStats(ctx context.Context, channel, eventType, payload string) { + if h.StatsSource != nil { + h.StatsSource.Invalidate() + } + h.publishStatsEvent(ctx, channel, eventType, payload) +} + +func (h *AdminHandler) publishStatsEvent(ctx context.Context, channel, eventType, payload string) { + if h.EventBus == nil { + return + } + if err := h.EventBus.Publish(ctx, channel, cache.Event{Type: eventType, Payload: payload}); err != nil { + slog.Warn("admin: failed to publish stats invalidation event", + "channel", channel, + "type", eventType, + "error", err, + ) + } +} + +type refreshItemMetadataRequest struct { + Mode string `json:"mode"` +} + +// HandleRefreshItemMetadata handles POST /admin/items/{id}/refresh-metadata. +func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.Request) { + if h.JobRepo == nil || h.ItemRefreshResolver == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Item refresh jobs are not configured") + return + } + + contentID := chi.URLParam(r, "id") + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + mode := adminjob.ItemRefreshModeQuick + if r.Body != nil { + var req refreshItemMetadataRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.Mode != "" { + switch adminjob.ItemRefreshMode(req.Mode) { + case adminjob.ItemRefreshModeQuick, adminjob.ItemRefreshModeComplete: + mode = adminjob.ItemRefreshMode(req.Mode) + default: + writeError(w, http.StatusBadRequest, "bad_request", "Invalid refresh mode") + return + } + } + } + + payload, err := h.ItemRefreshResolver.ResolveWithMode(r.Context(), contentID, mode) + if err != nil { + var scopeErr *adminjob.ScopeResolutionError + if errors.As(err, &scopeErr) { + code := "bad_request" + if scopeErr.StatusCode == http.StatusNotFound { + code = "not_found" + } else if scopeErr.StatusCode >= http.StatusConflict { + code = "conflict" + } + writeError(w, scopeErr.StatusCode, code, scopeErr.Message) + return + } + slog.Error("admin: resolve item refresh scope failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item refresh scope") + return + } + + job, err := h.JobRepo.Create(r.Context(), adminjob.CreateJobInput{ + JobType: adminjob.JobTypeItemRefresh, + CreatedByUserID: currentAdminUserID(r), + RequestPayload: payload, + Message: "Queued item metadata refresh", + }) + if err != nil { + slog.Error("admin: create item refresh job failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue item metadata refresh") + return + } + if h.RealtimeHub != nil { + publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job) + } + + writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil)) +} + +// UpdateItemMetadataRequest contains the fields that can be updated via +// PATCH /admin/items/{id}/metadata. +type UpdateItemMetadataRequest struct { + Title *string `json:"title"` + SortTitle *string `json:"sort_title"` + OriginalTitle *string `json:"original_title"` + Overview *string `json:"overview"` + Tagline *string `json:"tagline"` + ContentRating *string `json:"content_rating"` + Year *int `json:"year"` + Runtime *int `json:"runtime"` + Genres *[]string `json:"genres"` + Studios *[]string `json:"studios"` + Networks *[]string `json:"networks"` + Countries *[]string `json:"countries"` + ReleaseDate *string `json:"release_date"` + FirstAirDate *string `json:"first_air_date"` + LastAirDate *string `json:"last_air_date"` + AirTime *string `json:"air_time"` + AirDate *string `json:"air_date"` + Status *string `json:"status"` + RatingIMDB *float64 `json:"rating_imdb"` + RatingTMDB *float64 `json:"rating_tmdb"` + RatingRTCritic *int `json:"rating_rt_critic"` + RatingRTAudience *int `json:"rating_rt_audience"` + ImdbID *string `json:"imdb_id"` + TmdbID *string `json:"tmdb_id"` + TvdbID *string `json:"tvdb_id"` + SeasonNumber *int `json:"season_number"` + EpisodeNumber *int `json:"episode_number"` + LockedFields *[]int `json:"locked_fields"` +} + +// HandleUpdateItemMetadata handles PATCH /admin/items/{id}/metadata. +func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + var req UpdateItemMetadataRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + upd := catalog.MetadataUpdate{ + Title: req.Title, SortTitle: req.SortTitle, OriginalTitle: req.OriginalTitle, + Overview: req.Overview, Tagline: req.Tagline, ContentRating: req.ContentRating, + Year: req.Year, Runtime: req.Runtime, + Genres: req.Genres, Studios: req.Studios, Networks: req.Networks, Countries: req.Countries, + ReleaseDate: req.ReleaseDate, FirstAirDate: req.FirstAirDate, LastAirDate: req.LastAirDate, + AirTime: req.AirTime, + AirDate: req.AirDate, Status: req.Status, + RatingIMDB: req.RatingIMDB, RatingTMDB: req.RatingTMDB, + RatingRTCritic: req.RatingRTCritic, RatingRTAudience: req.RatingRTAudience, + ImdbID: req.ImdbID, TmdbID: req.TmdbID, TvdbID: req.TvdbID, + SeasonNumber: req.SeasonNumber, EpisodeNumber: req.EpisodeNumber, + LockedFields: req.LockedFields, + } + + // Try media_items first, then seasons, then episodes. + if err := h.DetailSvc.UpdateMediaItemMetadata(r.Context(), contentID, &upd); err != nil { + if !errors.Is(err, catalog.ErrItemNotFound) { + slog.Error("admin: update item metadata failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata") + return + } + if err := h.DetailSvc.UpdateSeasonMetadata(r.Context(), contentID, &upd); err != nil { + if !errors.Is(err, catalog.ErrSeasonNotFound) { + slog.Error("admin: update season metadata failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata") + return + } + if err := h.DetailSvc.UpdateEpisodeMetadata(r.Context(), contentID, &upd); err != nil { + if errors.Is(err, catalog.ErrEpisodeNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + slog.Error("admin: update episode metadata failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata") + return + } + } + } + + if h.EventBus != nil { + _ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin, + cache.Event{Type: "item:updated", Payload: contentID}) + } + if h.RealtimeHub != nil { + publishEventMetadataUpdate(r.Context(), h.RealtimeHub.EventsHub(), 0, contentID) + } + + detail, err := h.DetailSvc.GetItemDetail(r.Context(), contentID, catalog.AccessFilter{}) + if err != nil { + slog.Error("admin: fetch updated detail failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Updated but failed to fetch result") + return + } + writeJSON(w, http.StatusOK, detail) +} + +// --- Server Settings endpoints --- + +var sensitiveSettingKeys = map[string]bool{ + "auth.jwt_secret": true, + "s3.public_access_key": true, + "s3.public_secret_key": true, + "s3.public_token_secret": true, + "s3.private_access_key": true, + "s3.private_secret_key": true, + "s3.user_db_access_key": true, + "s3.user_db_secret_key": true, + "redis.url": true, + "recommendations.openai_api_key": true, + "recommendations.embedding_auth_token": true, + "tmdb.api_key": true, + "mdblist.api_key": true, + "watchsync.trakt.client_id": true, + "watchsync.trakt.client_secret": true, + "watchsync.simkl.client_id": true, + "watchsync.simkl.client_secret": true, +} + +// HandleGetSettings handles GET /admin/settings. +func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) { + if h.SettingsRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured") + return + } + all, err := h.SettingsRepo.GetAll(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings") + return + } + for key := range sensitiveSettingKeys { + delete(all, key) + } + writeJSON(w, http.StatusOK, all) +} + +type sensitiveStatusResponse struct { + Configured []string `json:"configured"` + ManagedByEnv []string `json:"managed_by_env,omitempty"` +} + +// HandleGetSensitiveStatus handles GET /admin/settings/sensitive-status. +// Returns which sensitive keys are configured and which are managed by env. +func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.Request) { + if h.SettingsRepo == nil { + writeError(w, http.StatusInternalServerError, "settings_error", "settings not configured") + return + } + all, err := h.SettingsRepo.GetAll(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_error", err.Error()) + return + } + configuredSet := make(map[string]struct{}) + for key := range sensitiveSettingKeys { + if v, ok := all[key]; ok && v != "" { + configuredSet[key] = struct{}{} + } + } + for key, configured := range h.BootstrapSensitiveConfigured { + if configured && sensitiveSettingKeys[key] { + configuredSet[key] = struct{}{} + } + } + for key, value := range h.BootstrapSensitiveValues { + if value != "" && sensitiveSettingKeys[key] { + configuredSet[key] = struct{}{} + } + } + configured := make([]string, 0, len(configuredSet)) + for key := range configuredSet { + configured = append(configured, key) + } + sort.Strings(configured) + + managedByEnv := make([]string, 0, len(h.BootstrapSensitiveConfigured)) + for key, configured := range h.BootstrapSensitiveConfigured { + if configured && sensitiveSettingKeys[key] { + managedByEnv = append(managedByEnv, key) + } + } + sort.Strings(managedByEnv) + + writeJSON(w, http.StatusOK, sensitiveStatusResponse{ + Configured: configured, + ManagedByEnv: managedByEnv, + }) +} + +type adminSettingResponse struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type adminSettingsListResponse struct { + Settings []adminSettingResponse `json:"settings"` +} + +type adminDeviceSettingResponse struct { + UserID int `json:"user_id"` + ProfileID string `json:"profile_id"` + ProfileName string `json:"profile_name,omitempty"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + DevicePlatform string `json:"device_platform"` + Key string `json:"key"` + Value string `json:"value"` + UpdatedAt string `json:"updated_at"` +} + +type adminDeviceSettingsListResponse struct { + Settings []adminDeviceSettingResponse `json:"settings"` +} + +type adminDeviceProfileSummary struct { + ProfileID string `json:"profile_id"` + ProfileName string `json:"profile_name"` + OverrideCount int `json:"override_count"` + LastUpdated string `json:"last_updated"` +} + +type adminDeviceSummaryResponse struct { + UserID int `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + DevicePlatform string `json:"device_platform"` + OverrideCount int `json:"override_count"` + ProfileCount int `json:"profile_count"` + Profiles []adminDeviceProfileSummary `json:"profiles"` + LastUpdated string `json:"last_updated"` +} + +type adminDevicesListResponse struct { + Devices []adminDeviceSummaryResponse `json:"devices"` +} + +type adminDeviceDetailResponse struct { + UserID int `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + DevicePlatform string `json:"device_platform"` + LastUpdated string `json:"last_updated"` + Settings []adminDeviceSettingResponse `json:"settings"` +} + +// HandleListUserSettings handles GET /admin/users/{id}/settings. +func (h *AdminHandler) HandleListUserSettings(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + entries, err := store.ListSettings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings") + return + } + resp := adminSettingsListResponse{ + Settings: make([]adminSettingResponse, 0, len(entries)), + } + for _, entry := range entries { + if !keyUsesUserScope(entry.Key) { + continue + } + resp.Settings = append(resp.Settings, adminSettingResponse{ + Key: entry.Key, + Value: entry.Value, + }) + } + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetUserSetting handles GET /admin/users/{id}/settings/{key}. +func (h *AdminHandler) HandleGetUserSetting(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesUserScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser)) + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + value, err := store.GetSetting(r.Context(), key) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting") + return + } + if value == "" { + entries, err := store.ListSettings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting") + return + } + found := false + for _, entry := range entries { + if entry.Key == key { + found = true + break + } + } + if !found { + writeError(w, http.StatusNotFound, "not_found", "Setting not found") + return + } + } + writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value}) +} + +// HandleUpdateUserSetting handles PUT /admin/users/{id}/settings/{key}. +func (h *AdminHandler) HandleUpdateUserSetting(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesUserScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser)) + return + } + var req updateSettingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if err := validateRegisteredSetting(key, req.Value, scopeUser); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + if err := store.SetSetting(r.Context(), key, req.Value); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting") + return + } + writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value}) +} + +// HandleDeleteUserSetting handles DELETE /admin/users/{id}/settings/{key}. +func (h *AdminHandler) HandleDeleteUserSetting(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesUserScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser)) + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + if err := store.DeleteSetting(r.Context(), key); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete setting") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleListUserDeviceSettings handles GET /admin/users/{id}/device-settings. +func (h *AdminHandler) HandleListUserDeviceSettings(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + entries, err := store.ListAllDeviceSettings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings") + return + } + profileNames, err := listProfileNamesByID(r.Context(), store) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles") + return + } + writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries)) +} + +// HandleListUserDeviceSettingsByKey handles GET /admin/users/{id}/device-settings/{key}. +func (h *AdminHandler) HandleListUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + entries, err := store.ListDeviceSettings(r.Context(), key) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings") + return + } + profileNames, err := listProfileNamesByID(r.Context(), store) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles") + return + } + writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries)) +} + +// HandleUpdateUserDeviceSetting handles PUT /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}. +func (h *AdminHandler) HandleUpdateUserDeviceSetting(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + profileID := strings.TrimSpace(chi.URLParam(r, "profile_id")) + key := strings.TrimSpace(chi.URLParam(r, "key")) + deviceID := strings.TrimSpace(chi.URLParam(r, "device_id")) + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required") + return + } + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if deviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Device id is required") + return + } + var req updateSettingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if err := validateRegisteredSetting(key, req.Value, scopeDevice); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + if !adminProfileExists(w, r, store, profileID) { + return + } + existing, err := store.GetDeviceSetting(r.Context(), profileID, deviceID, key) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device setting") + return + } + entry := userstore.DeviceSettingEntry{ + ProfileID: profileID, + DeviceID: deviceID, + Key: key, + Value: req.Value, + } + if existing != nil { + entry.DeviceName = existing.DeviceName + entry.DevicePlatform = existing.DevicePlatform + } + if err := store.SetDeviceSetting(r.Context(), entry); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device setting") + return + } + writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value}) +} + +// HandleDeleteUserDeviceSetting handles DELETE /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}. +func (h *AdminHandler) HandleDeleteUserDeviceSetting(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + profileID := strings.TrimSpace(chi.URLParam(r, "profile_id")) + key := strings.TrimSpace(chi.URLParam(r, "key")) + deviceID := strings.TrimSpace(chi.URLParam(r, "device_id")) + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required") + return + } + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if deviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Device id is required") + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + if !adminProfileExists(w, r, store, profileID) { + return + } + if err := store.DeleteDeviceSetting(r.Context(), profileID, deviceID, key); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleDeleteAllUserDeviceSettings handles DELETE /admin/users/{id}/profiles/{profile_id}/devices/{device_id}/settings. +func (h *AdminHandler) HandleDeleteAllUserDeviceSettings(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + profileID := strings.TrimSpace(chi.URLParam(r, "profile_id")) + deviceID := strings.TrimSpace(chi.URLParam(r, "device_id")) + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required") + return + } + if deviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Device id is required") + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + if !adminProfileExists(w, r, store, profileID) { + return + } + if err := store.DeleteAllDeviceSettings(r.Context(), profileID, deviceID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleDeleteUserDeviceSettingsByKey handles DELETE /admin/users/{id}/device-settings/{key}. +func (h *AdminHandler) HandleDeleteUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) { + userID, ok := parseAdminUserIDParam(w, r) + if !ok { + return + } + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + if err := store.DeleteDeviceSettingsByKey(r.Context(), key); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleListDevices handles GET /admin/devices. +func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) { + if h.userRepo == nil || h.storeProv == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Device settings not configured") + return + } + + users, err := h.userRepo.List(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices") + return + } + + perUser := make([][]adminDeviceSummaryResponse, len(users)) + g, gctx := errgroup.WithContext(r.Context()) + g.SetLimit(8) + for i, user := range users { + i, user := i, user + g.Go(func() error { + store, err := h.storeProv.ForUser(gctx, user.ID) + if err != nil { + return fmt.Errorf("user store: %w", err) + } + entries, err := store.ListAllDeviceSettings(gctx) + if err != nil { + return fmt.Errorf("list device settings: %w", err) + } + profileNames, err := listProfileNamesByID(gctx, store) + if err != nil { + slog.Warn("admin list devices profile lookup failed", + "user_id", user.ID, + "error", err, + ) + profileNames = map[string]string{} + } + perUser[i] = buildAdminDeviceSummaries(user.ID, user.Username, user.Email, entries, profileNames) + return nil + }) + } + if err := g.Wait(); err != nil { + slog.Error("admin list devices failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices") + return + } + + devices := make([]adminDeviceSummaryResponse, 0) + for _, batch := range perUser { + devices = append(devices, batch...) + } + + sort.Slice(devices, func(i, j int) bool { + if devices[i].LastUpdated != devices[j].LastUpdated { + return devices[i].LastUpdated > devices[j].LastUpdated + } + if devices[i].Username != devices[j].Username { + return devices[i].Username < devices[j].Username + } + if devices[i].DeviceName != devices[j].DeviceName { + return devices[i].DeviceName < devices[j].DeviceName + } + return devices[i].DeviceID < devices[j].DeviceID + }) + + writeJSON(w, http.StatusOK, adminDevicesListResponse{Devices: devices}) +} + +// HandleGetDevice handles GET /admin/devices/{user_id}/{device_id}. +func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) { + if h.userRepo == nil || h.storeProv == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Device settings not configured") + return + } + + userIDRaw := strings.TrimSpace(chi.URLParam(r, "user_id")) + userID, err := strconv.Atoi(userIDRaw) + if err != nil || userID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user id") + return + } + deviceID := strings.TrimSpace(chi.URLParam(r, "device_id")) + if deviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Device id is required") + return + } + + user, err := h.userRepo.GetByID(r.Context(), userID) + if err != nil || user == nil { + writeError(w, http.StatusNotFound, "not_found", "User not found") + return + } + store, ok := h.adminUserStore(w, r, userID) + if !ok { + return + } + entries, err := store.ListAllDeviceSettings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device") + return + } + profileNames, err := listProfileNamesByID(r.Context(), store) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles") + return + } + + deviceEntries := make([]userstore.DeviceSettingEntry, 0) + for _, entry := range entries { + if entry.DeviceID == deviceID { + deviceEntries = append(deviceEntries, entry) + } + } + if len(deviceEntries) == 0 { + writeError(w, http.StatusNotFound, "not_found", "Device not found") + return + } + + summary := buildAdminDeviceSummaries(user.ID, user.Username, user.Email, deviceEntries, profileNames)[0] + writeJSON(w, http.StatusOK, adminDeviceDetailResponse{ + UserID: user.ID, + Username: user.Username, + Email: user.Email, + DeviceID: summary.DeviceID, + DeviceName: summary.DeviceName, + DevicePlatform: summary.DevicePlatform, + LastUpdated: summary.LastUpdated, + Settings: buildAdminDeviceSettingsResponse(user.ID, profileNames, deviceEntries).Settings, + }) +} + +func buildAdminDeviceSettingsResponse(userID int, profileNames map[string]string, entries []userstore.DeviceSettingEntry) adminDeviceSettingsListResponse { + resp := adminDeviceSettingsListResponse{ + Settings: make([]adminDeviceSettingResponse, 0, len(entries)), + } + for _, entry := range entries { + resp.Settings = append(resp.Settings, adminDeviceSettingResponse{ + UserID: userID, + ProfileID: entry.ProfileID, + ProfileName: profileNames[entry.ProfileID], + DeviceID: entry.DeviceID, + DeviceName: entry.DeviceName, + DevicePlatform: entry.DevicePlatform, + Key: entry.Key, + Value: entry.Value, + UpdatedAt: entry.UpdatedAt, + }) + } + return resp +} + +func buildAdminDeviceSummaries( + userID int, + username string, + email string, + entries []userstore.DeviceSettingEntry, + profileNames map[string]string, +) []adminDeviceSummaryResponse { + type profileAccumulator struct { + summary adminDeviceProfileSummary + keys map[string]struct{} + } + type summary struct { + device adminDeviceSummaryResponse + keys map[string]struct{} + profiles map[string]*profileAccumulator + } + + byDevice := make(map[string]*summary) + for _, entry := range entries { + deviceID := strings.TrimSpace(entry.DeviceID) + if deviceID == "" { + continue + } + current, ok := byDevice[deviceID] + if !ok { + current = &summary{ + device: adminDeviceSummaryResponse{ + UserID: userID, + Username: username, + Email: email, + DeviceID: deviceID, + DeviceName: entry.DeviceName, + DevicePlatform: entry.DevicePlatform, + LastUpdated: entry.UpdatedAt, + }, + keys: make(map[string]struct{}), + profiles: make(map[string]*profileAccumulator), + } + byDevice[deviceID] = current + } + current.keys[entry.ProfileID+":"+entry.Key] = struct{}{} + profileID := strings.TrimSpace(entry.ProfileID) + if profileID != "" { + profile, exists := current.profiles[profileID] + if !exists { + profile = &profileAccumulator{ + summary: adminDeviceProfileSummary{ + ProfileID: profileID, + ProfileName: profileNames[profileID], + LastUpdated: entry.UpdatedAt, + }, + keys: make(map[string]struct{}), + } + current.profiles[profileID] = profile + } + profile.keys[entry.Key] = struct{}{} + if entry.UpdatedAt > profile.summary.LastUpdated { + profile.summary.LastUpdated = entry.UpdatedAt + } + } + if current.device.DeviceName == "" && entry.DeviceName != "" { + current.device.DeviceName = entry.DeviceName + } + if current.device.DevicePlatform == "" && entry.DevicePlatform != "" { + current.device.DevicePlatform = entry.DevicePlatform + } + if entry.UpdatedAt > current.device.LastUpdated { + current.device.LastUpdated = entry.UpdatedAt + if entry.DeviceName != "" { + current.device.DeviceName = entry.DeviceName + } + if entry.DevicePlatform != "" { + current.device.DevicePlatform = entry.DevicePlatform + } + } + } + + devices := make([]adminDeviceSummaryResponse, 0, len(byDevice)) + for _, current := range byDevice { + current.device.OverrideCount = len(current.keys) + current.device.ProfileCount = len(current.profiles) + profiles := make([]adminDeviceProfileSummary, 0, len(current.profiles)) + for _, profile := range current.profiles { + profile.summary.OverrideCount = len(profile.keys) + profiles = append(profiles, profile.summary) + } + sort.Slice(profiles, func(i, j int) bool { + if profiles[i].LastUpdated != profiles[j].LastUpdated { + return profiles[i].LastUpdated > profiles[j].LastUpdated + } + a := profiles[i].ProfileName + b := profiles[j].ProfileName + if a == "" { + a = profiles[i].ProfileID + } + if b == "" { + b = profiles[j].ProfileID + } + return a < b + }) + current.device.Profiles = profiles + devices = append(devices, current.device) + } + return devices +} + +func listProfileNamesByID(ctx context.Context, store userstore.UserStore) (map[string]string, error) { + profiles, err := store.ListProfiles(ctx) + if err != nil { + return nil, err + } + profileNames := make(map[string]string, len(profiles)) + for _, profile := range profiles { + profileNames[profile.ID] = strings.TrimSpace(profile.Name) + } + return profileNames, nil +} + +func adminProfileExists(w http.ResponseWriter, r *http.Request, store userstore.UserStore, profileID string) bool { + profile, err := store.GetProfile(r.Context(), profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load profile") + return false + } + if profile == nil { + writeError(w, http.StatusNotFound, "not_found", "Profile not found") + return false + } + return true +} + +func (h *AdminHandler) adminUserStore(w http.ResponseWriter, r *http.Request, userID int) (userstore.UserStore, bool) { + if h.storeProv == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "User store not configured") + return nil, false + } + store, err := h.storeProv.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return nil, false + } + if store == nil { + writeError(w, http.StatusNotFound, "not_found", "User store not found") + return nil, false + } + return store, true +} + +func parseAdminUserIDParam(w http.ResponseWriter, r *http.Request) (int, bool) { + raw := chi.URLParam(r, "id") + userID, err := strconv.Atoi(raw) + if err != nil || userID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user id") + return 0, false + } + return userID, true +} + +// HandleGetSetting handles GET /admin/settings/{key}. +func (h *AdminHandler) HandleGetSetting(w http.ResponseWriter, r *http.Request) { + if h.SettingsRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured") + return + } + + key := chi.URLParam(r, "key") + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + + if sensitiveSettingKeys[key] { + writeError(w, http.StatusNotFound, "not_found", "Setting not found") + return + } + + if value, ok := h.BootstrapSensitiveValues[key]; ok && value != "" { + writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value}) + return + } + + value, err := h.SettingsRepo.Get(r.Context(), key) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting") + return + } + if value == "" { + writeError(w, http.StatusNotFound, "not_found", "Setting not found") + return + } + + writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value}) +} + +type updateSettingRequest struct { + Value string `json:"value"` +} + +// HandleUpdateSetting handles PUT /admin/settings/{key}. +func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Request) { + if h.SettingsRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured") + return + } + + key := chi.URLParam(r, "key") + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + + var req updateSettingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if key == "defaults.max_playback_quality" { + normalized, ok := access.ParsePlaybackQualityPreset(req.Value) + if !ok { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality") + return + } + req.Value = normalized + } + if key == "defaults.max_profiles" { + n, err := strconv.Atoi(req.Value) + if err != nil || n < 1 { + writeError(w, http.StatusBadRequest, "bad_request", "defaults.max_profiles must be an integer >= 1") + return + } + } + switch key { + case markers.SettingMode, markers.SettingLazyPlayback: + if normalized, err := markers.NormalizeSetting(key, req.Value); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } else { + req.Value = normalized + } + } + + if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting") + return + } + + if h.EventBus != nil { + _ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin, + cache.Event{Type: cache.EventSettingsChanged}) + } + + if sensitiveSettingKeys[key] { + writeJSON(w, http.StatusOK, adminSettingResponse{Key: key}) + return + } + writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value}) +} diff --git a/internal/api/handlers/admin_images.go b/internal/api/handlers/admin_images.go new file mode 100644 index 00000000..4d0f101e --- /dev/null +++ b/internal/api/handlers/admin_images.go @@ -0,0 +1,482 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" +) + +// ImageService defines the image-related methods on MetadataService +// needed by the admin image handler. +type ImageService interface { + FetchItemImages(ctx context.Context, providerIDs map[string]string, contentType string, language string, folderID int) ([]metadata.RemoteImage, map[string]string, error) + ApplyItemImage(ctx context.Context, req metadata.ApplyItemImageRequest) (*metadata.ApplyItemImageResult, error) +} + +// ImageItemLookup loads media items, seasons, and episodes by content ID. +type ImageItemLookup interface { + GetByID(ctx context.Context, contentID string) (*models.MediaItem, error) +} + +// ImageSeasonLookup loads seasons by content ID. +type ImageSeasonLookup interface { + GetByID(ctx context.Context, contentID string) (*models.Season, error) +} + +// ImageEpisodeLookup loads episodes by content ID. +type ImageEpisodeLookup interface { + GetByID(ctx context.Context, contentID string) (*models.Episode, error) +} + +// ImageURLResolver resolves image URLs for display (plugin-prefixed → HTTP). +type ImageURLResolver interface { + ResolveImageURL(ctx context.Context, path string, variant string) string + ResolveImageURLs(ctx context.Context, paths []string, variant string) map[string]string +} + +// AdminImageHandler handles endpoints for browsing and selecting item images. +type AdminImageHandler struct { + items ImageItemLookup + seasons ImageSeasonLookup + episodes ImageEpisodeLookup + folders MatchFolderLookup + imageSvc ImageService + imageResolver ImageURLResolver + detailSvc *catalog.DetailService +} + +// NewAdminImageHandler creates a handler for admin image selection endpoints. +func NewAdminImageHandler( + items ImageItemLookup, + seasons ImageSeasonLookup, + episodes ImageEpisodeLookup, + folders MatchFolderLookup, + imageSvc ImageService, + imageResolver ImageURLResolver, + detailSvc *catalog.DetailService, +) *AdminImageHandler { + return &AdminImageHandler{ + items: items, + seasons: seasons, + episodes: episodes, + folders: folders, + imageSvc: imageSvc, + imageResolver: imageResolver, + detailSvc: detailSvc, + } +} + +// --- Request/Response types --- + +type itemImageEntry struct { + ProviderID string `json:"provider_id"` + URL string `json:"url"` + OriginalURL string `json:"original_url"` + Type string `json:"type"` + Language string `json:"language"` + Width int `json:"width"` + Height int `json:"height"` + Rating float64 `json:"rating"` +} + +type getItemImagesResponse struct { + Images []itemImageEntry `json:"images"` + Current currentImages `json:"current"` + ProviderErrors map[string]string `json:"provider_errors,omitempty"` +} + +type currentImages struct { + PosterURL string `json:"poster_url,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty"` + LogoURL string `json:"logo_url,omitempty"` +} + +type applyItemImageRequest struct { + OriginalURL string `json:"original_url"` + Type string `json:"type"` + ProviderID string `json:"provider_id"` +} + +type applyItemImageResponse struct { + ContentID string `json:"content_id"` + StoredPath string `json:"stored_path"` + Thumbhash string `json:"thumbhash"` +} + +// resolvedItem holds the result of looking up a content ID across all three tables. +type resolvedItem struct { + contentType string // "movie", "series", "season", "episode" + // The parent MediaItem (for movies/series it's the item itself; + // for seasons/episodes it's the owning series). + parentItem *models.MediaItem + // The season/episode model, if applicable. + season *models.Season + episode *models.Episode +} + +// resolveContentID looks up a content ID in media_items, then seasons, then episodes. +// Returns the resolved item info including the parent MediaItem (for provider IDs). +func (h *AdminImageHandler) resolveContentID(ctx context.Context, contentID string) (*resolvedItem, error) { + // Try media_items first. + item, err := h.items.GetByID(ctx, contentID) + if err == nil { + return &resolvedItem{ + contentType: item.Type, + parentItem: item, + }, nil + } + if !errors.Is(err, catalog.ErrItemNotFound) { + return nil, err + } + + // Try seasons. + season, err := h.seasons.GetByID(ctx, contentID) + if err == nil { + // Load the parent series for provider IDs. + parentItem, err := h.items.GetByID(ctx, season.SeriesID) + if err != nil { + return nil, err + } + return &resolvedItem{ + contentType: "season", + parentItem: parentItem, + season: season, + }, nil + } + if !errors.Is(err, catalog.ErrSeasonNotFound) { + return nil, err + } + + // Try episodes. + ep, err := h.episodes.GetByID(ctx, contentID) + if err == nil { + parentItem, err := h.items.GetByID(ctx, ep.SeriesID) + if err != nil { + return nil, err + } + return &resolvedItem{ + contentType: "episode", + parentItem: parentItem, + episode: ep, + }, nil + } + if !errors.Is(err, catalog.ErrEpisodeNotFound) { + return nil, err + } + + return nil, catalog.ErrItemNotFound +} + +// HandleGetItemImages handles GET /admin/items/{id}/images. +// It fetches available images from all enabled metadata providers. +func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + resolved, err := h.resolveContentID(r.Context(), contentID) + if err != nil { + if errors.Is(err, catalog.ErrItemNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + slog.Error("admin images: resolve content ID failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item") + return + } + + // Determine the folder ID for chain resolution. + // For seasons/episodes, use the parent series content ID. + lookupContentID := resolved.parentItem.ContentID + folderID, err := h.resolveImageFolderID(r.Context(), lookupContentID) + if err != nil { + slog.Error("admin images: resolve folder failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Could not determine library for item") + return + } + + // Build provider IDs map from the parent item. + providerIDs := buildProviderIDs(resolved.parentItem) + + // Determine the language preference. Use the parent item's default + // metadata language, or fall back to "en". + language := resolved.parentItem.DefaultMetadataLanguage + if language == "" { + language = "en" + } + + // Use the parent item's type for the plugin call (always "movie" or "series"). + images, providerErrors, err := h.imageSvc.FetchItemImages( + r.Context(), providerIDs, resolved.parentItem.Type, language, folderID, + ) + if err != nil { + slog.Error("admin images: fetch failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch images") + return + } + + // Batch-resolve plugin-prefixed URLs for display. + rawPaths := make([]string, len(images)) + for i, img := range images { + rawPaths[i] = img.URL + } + var resolvedURLs map[string]string + if h.imageResolver != nil && len(rawPaths) > 0 { + resolvedURLs = h.imageResolver.ResolveImageURLs(r.Context(), rawPaths, "card") + } + + // Build the response entries. + entries := make([]itemImageEntry, 0, len(images)) + for _, img := range images { + displayURL := img.URL + if resolved, ok := resolvedURLs[img.URL]; ok && resolved != "" { + displayURL = resolved + } + entries = append(entries, itemImageEntry{ + ProviderID: img.ProviderID, + URL: displayURL, + OriginalURL: img.URL, + Type: metadata.ImageTypeToString(img.Type), + Language: img.Language, + Width: img.Width, + Height: img.Height, + Rating: img.Rating, + }) + } + + // Include current image paths so the frontend can highlight them. + current := currentImages{ + PosterURL: resolved.parentItem.PosterPath, + BackdropURL: resolved.parentItem.BackdropPath, + LogoURL: resolved.parentItem.LogoPath, + } + // For seasons, use the season's poster if it has one. + if resolved.season != nil && resolved.season.PosterPath != "" { + current.PosterURL = resolved.season.PosterPath + } + + writeJSON(w, http.StatusOK, getItemImagesResponse{ + Images: entries, + Current: current, + ProviderErrors: providerErrors, + }) +} + +// HandleApplyItemImage handles POST /admin/items/{id}/images/apply. +// It downloads the selected image, caches it to S3, and updates the item. +func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + var req applyItemImageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.OriginalURL == "" || req.Type == "" { + writeError(w, http.StatusBadRequest, "bad_request", "original_url and type are required") + return + } + + resolved, err := h.resolveContentID(r.Context(), contentID) + if err != nil { + if errors.Is(err, catalog.ErrItemNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + slog.Error("admin images: resolve content ID failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item") + return + } + + imageType := metadata.ImageTypeFromString(req.Type) + providerID := req.ProviderID + if providerID == "" { + providerID = primaryProvider(resolved.parentItem) + } + + // Use the parent item's ContentID for S3 key construction. Season / + // episode numbers (when present) scope the S3 key beneath the series + // prefix so siblings do not collide. + cacheContentID := findBestContentID(resolved.parentItem, providerID) + var seasonNumber, episodeNumber *int + switch resolved.contentType { + case "season": + if resolved.season != nil { + n := resolved.season.SeasonNumber + seasonNumber = &n + } + case "episode": + if resolved.episode != nil { + s := resolved.episode.SeasonNumber + e := resolved.episode.EpisodeNumber + seasonNumber = &s + episodeNumber = &e + } + } + + result, err := h.imageSvc.ApplyItemImage(r.Context(), metadata.ApplyItemImageRequest{ + OriginalURL: req.OriginalURL, + ProviderID: providerID, + ContentType: resolved.parentItem.Type, + ContentID: cacheContentID, + ImageType: imageType, + SeasonNumber: seasonNumber, + EpisodeNumber: episodeNumber, + }) + if err != nil { + slog.Error("admin images: apply failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply image") + return + } + + // Build the MetadataUpdate targeting only the relevant image field. + upd := buildImageUpdate(imageType, result.StoredPath, result.Thumbhash) + + // Lock FieldImages on the parent media_items row to prevent + // automatic refreshes from overwriting the manual selection. + if resolved.contentType == "movie" || resolved.contentType == "series" { + locked := mergeLockedField(resolved.parentItem.LockedFields, int(metadata.FieldImages)) + upd.LockedFields = &locked + } + + // Persist to the correct table. + if err := h.persistImageUpdate(r.Context(), contentID, resolved, &upd); err != nil { + slog.Error("admin images: persist failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Image cached but failed to update item") + return + } + + // Also lock FieldImages on the parent series for seasons/episodes. + if resolved.contentType == "season" || resolved.contentType == "episode" { + parentLocked := mergeLockedField(resolved.parentItem.LockedFields, int(metadata.FieldImages)) + parentUpd := catalog.MetadataUpdate{LockedFields: &parentLocked} + if lockErr := h.detailSvc.UpdateMediaItemMetadata(r.Context(), resolved.parentItem.ContentID, &parentUpd); lockErr != nil { + slog.Error("admin images: failed to lock FieldImages on parent series", + "content_id", contentID, "parent_id", resolved.parentItem.ContentID, "error", lockErr) + } + } + + writeJSON(w, http.StatusOK, applyItemImageResponse{ + ContentID: contentID, + StoredPath: result.StoredPath, + Thumbhash: result.Thumbhash, + }) +} + +// persistImageUpdate writes the image path update to the correct table. +func (h *AdminImageHandler) persistImageUpdate(ctx context.Context, contentID string, resolved *resolvedItem, upd *catalog.MetadataUpdate) error { + switch resolved.contentType { + case "season": + return h.detailSvc.UpdateSeasonMetadata(ctx, contentID, upd) + case "episode": + return h.detailSvc.UpdateEpisodeMetadata(ctx, contentID, upd) + default: + return h.detailSvc.UpdateMediaItemMetadata(ctx, contentID, upd) + } +} + +// resolveImageFolderID finds the primary library folder for a content ID. +func (h *AdminImageHandler) resolveImageFolderID(ctx context.Context, contentID string) (int, error) { + if h.folders == nil { + return 0, nil + } + folderID, err := h.folders.GetFolderIDForItem(ctx, contentID) + if err != nil { + return 0, fmt.Errorf("resolving folder for %s: %w", contentID, err) + } + return folderID, nil +} + +// buildProviderIDs extracts the provider IDs map from a MediaItem. +func buildProviderIDs(item *models.MediaItem) map[string]string { + ids := make(map[string]string) + if item.TmdbID != "" { + ids["tmdb"] = item.TmdbID + } + if item.TvdbID != "" { + ids["tvdb"] = item.TvdbID + } + if item.ImdbID != "" { + ids["imdb"] = item.ImdbID + } + return ids +} + +// buildImageUpdate constructs a MetadataUpdate targeting a single image field. +func buildImageUpdate(imageType metadata.ImageType, storedPath, thumbhash string) catalog.MetadataUpdate { + var upd catalog.MetadataUpdate + switch imageType { + case metadata.ImageBackdrop: + upd.BackdropPath = &storedPath + upd.BackdropThumbhash = &thumbhash + case metadata.ImageLogo: + upd.LogoPath = &storedPath + case metadata.ImageStill: + upd.StillPath = &storedPath + upd.StillThumbhash = &thumbhash + default: // poster + upd.PosterPath = &storedPath + upd.PosterThumbhash = &thumbhash + } + return upd +} + +// mergeLockedField adds a field value to the locked fields slice if not already present. +func mergeLockedField(existing []int, field int) []int { + for _, f := range existing { + if f == field { + return existing + } + } + result := make([]int, len(existing)+1) + copy(result, existing) + result[len(existing)] = field + return result +} + +// primaryProvider returns the primary provider slug for a media item. +func primaryProvider(item *models.MediaItem) string { + if item.TmdbID != "" { + return "tmdb" + } + if item.TvdbID != "" { + return "tvdb" + } + return "" +} + +// findBestContentID returns the best provider-specific ID for S3 key construction. +func findBestContentID(item *models.MediaItem, providerID string) string { + switch providerID { + case "tmdb": + if item.TmdbID != "" { + return item.TmdbID + } + case "tvdb": + if item.TvdbID != "" { + return item.TvdbID + } + } + if item.TmdbID != "" { + return item.TmdbID + } + if item.TvdbID != "" { + return item.TvdbID + } + return item.ContentID +} diff --git a/internal/api/handlers/admin_intro.go b/internal/api/handlers/admin_intro.go new file mode 100644 index 00000000..28496358 --- /dev/null +++ b/internal/api/handlers/admin_intro.go @@ -0,0 +1,193 @@ +package handlers + +import ( + "context" + "errors" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/intromarkers" + "github.com/Silo-Server/silo-server/internal/markers" + "github.com/Silo-Server/silo-server/internal/models" +) + +type IntroEpisodeAnalyzer interface { + AnalyzeEpisode(ctx context.Context, episodeID string) (intromarkers.RunSummary, error) +} + +type IntroEpisodeEligibilityChecker interface { + EpisodeIntroEligibility(ctx context.Context, episodeID string) (*intromarkers.EpisodeIntroEligibility, error) +} + +type MarkerSettingsReader interface { + Get(ctx context.Context, key string) (string, error) +} + +type AdminIntroFileResolver interface { + GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error) +} + +type AdminIntroHandler struct { + analyzer IntroEpisodeAnalyzer + eligibility IntroEpisodeEligibilityChecker + Settings MarkerSettingsReader + FileResolver AdminIntroFileResolver + MarkerUpdateNotifier PlaybackMarkerUpdateNotifier + baseContext context.Context + inFlight sync.Map + logger *slog.Logger +} + +func NewAdminIntroHandler( + analyzer IntroEpisodeAnalyzer, + eligibility IntroEpisodeEligibilityChecker, + baseContext context.Context, + logger *slog.Logger, +) *AdminIntroHandler { + if baseContext == nil { + baseContext = context.Background() + } + if logger == nil { + logger = slog.Default() + } + return &AdminIntroHandler{ + analyzer: analyzer, + eligibility: eligibility, + baseContext: baseContext, + logger: logger, + } +} + +type redetectIntroResponse struct { + Status string `json:"status"` +} + +func (h *AdminIntroHandler) HandleRefreshEpisodeMarkers(w http.ResponseWriter, r *http.Request) { + h.handleEpisodeMarkers(w, r, "refresh") +} + +func (h *AdminIntroHandler) HandleRedetectEpisodeIntro(w http.ResponseWriter, r *http.Request) { + h.handleEpisodeMarkers(w, r, "redetect") +} + +func (h *AdminIntroHandler) handleEpisodeMarkers(w http.ResponseWriter, r *http.Request, action string) { + if h == nil || h.analyzer == nil || h.eligibility == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Intro detection is not configured") + return + } + + episodeID := chi.URLParam(r, "id") + if episodeID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + eligibility, err := h.eligibility.EpisodeIntroEligibility(r.Context(), episodeID) + if err != nil { + if errors.Is(err, intromarkers.ErrEpisodeNotFound) { + writeError(w, http.StatusBadRequest, "bad_request", "Item must be an episode") + return + } + h.logger.Error("admin intro: resolve episode failed", "episode_id", episodeID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve episode") + return + } + if !eligibility.HasMediaFiles { + writeError(w, http.StatusConflict, "conflict", "Episode has no media files to analyze") + return + } + if !eligibility.IntroDetectionEnabled { + writeError(w, http.StatusConflict, "conflict", "Intro detection is disabled for this episode's library") + return + } + if h.Settings == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker settings are not configured") + return + } + raw, err := h.Settings.Get(r.Context(), markers.SettingMode) + if err != nil { + h.logger.Error("admin markers: load mode failed", "episode_id", episodeID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker settings") + return + } + mode := markers.NormalizeMode(raw) + if !markers.ShouldRunLocal(mode) { + switch mode { + case markers.ModeOff: + writeError(w, http.StatusConflict, "conflict", "Marker detection is disabled") + case markers.ModeOnline: + writeError(w, http.StatusConflict, "conflict", "Online-only marker refresh is not available for this endpoint") + default: + writeError(w, http.StatusConflict, "conflict", "Local intro detection is disabled") + } + return + } + + if _, loaded := h.inFlight.LoadOrStore(episodeID, struct{}{}); loaded { + writeJSON(w, http.StatusAccepted, redetectIntroResponse{Status: "already_running"}) + return + } + + go func() { + defer h.inFlight.Delete(episodeID) + start := time.Now() + h.logger.Info("admin markers: episode refresh started", "episode_id", episodeID, "action", action) + summary, err := h.analyzer.AnalyzeEpisode(h.baseContext, episodeID) + if err != nil { + h.logger.Error("admin markers: episode refresh failed", + "episode_id", episodeID, + "action", action, + "duration", time.Since(start), + "error", err) + return + } + h.logger.Info("admin markers: episode refresh finished", + "episode_id", episodeID, + "action", action, + "duration", time.Since(start), + "files_considered", summary.FilesConsidered, + "season_groups_considered", summary.SeasonGroupsConsidered, + "chapter_markers_written", summary.ChapterMarkersWritten, + "chromaprint_markers_written", summary.ChromaprintMarkersWritten, + "fingerprint_cache_hits", summary.FingerprintCacheHits, + "fingerprints_computed", summary.FingerprintsComputed, + "errors", len(summary.Errors)) + h.notifyEpisodeMarkerUpdates(h.baseContext, episodeID, action) + }() + + writeJSON(w, http.StatusAccepted, redetectIntroResponse{Status: "queued"}) +} + +func (h *AdminIntroHandler) notifyEpisodeMarkerUpdates(ctx context.Context, episodeID, action string) { + if h == nil || h.FileResolver == nil || h.MarkerUpdateNotifier == nil { + return + } + files, err := h.FileResolver.GetByEpisodeID(ctx, episodeID) + if err != nil { + h.logger.Warn("admin markers: reload episode files for marker update failed", + "episode_id", episodeID, + "action", action, + "error", err) + return + } + for _, file := range files { + if !hasAnyPlaybackMarker(file) { + continue + } + h.MarkerUpdateNotifier.MarkersUpdated(ctx, file) + h.logger.Info("admin markers: emitted marker update", + "episode_id", episodeID, + "action", action, + "file_id", file.ID) + } +} + +func hasAnyPlaybackMarker(file *models.MediaFile) bool { + return file != nil && + ((file.IntroStart != nil && file.IntroEnd != nil) || + (file.CreditsStart != nil && file.CreditsEnd != nil)) +} diff --git a/internal/api/handlers/admin_intro_test.go b/internal/api/handlers/admin_intro_test.go new file mode 100644 index 00000000..48db869b --- /dev/null +++ b/internal/api/handlers/admin_intro_test.go @@ -0,0 +1,410 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/intromarkers" + "github.com/Silo-Server/silo-server/internal/markers" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeIntroAnalyzer struct { + started chan string + release chan struct{} + summary intromarkers.RunSummary + err error +} + +func (f *fakeIntroAnalyzer) AnalyzeEpisode(ctx context.Context, episodeID string) (intromarkers.RunSummary, error) { + if f.started != nil { + f.started <- episodeID + } + if f.release != nil { + select { + case <-f.release: + case <-ctx.Done(): + return intromarkers.RunSummary{}, ctx.Err() + } + } + if f.err != nil { + return intromarkers.RunSummary{}, f.err + } + if f.summary.FilesConsidered != 0 { + return f.summary, nil + } + return intromarkers.RunSummary{FilesConsidered: 1}, nil +} + +type fakeIntroEligibility struct { + result *intromarkers.EpisodeIntroEligibility + err error +} + +func (f fakeIntroEligibility) EpisodeIntroEligibility(context.Context, string) (*intromarkers.EpisodeIntroEligibility, error) { + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +type fakeMarkerSettings struct { + values map[string]string + err error +} + +func (f fakeMarkerSettings) Get(_ context.Context, key string) (string, error) { + if f.err != nil { + return "", f.err + } + return f.values[key], nil +} + +type fakeAdminIntroFileResolver struct { + files []*models.MediaFile + err error + called chan string +} + +func (f fakeAdminIntroFileResolver) GetByEpisodeID(_ context.Context, episodeID string) ([]*models.MediaFile, error) { + if f.called != nil { + f.called <- episodeID + } + if f.err != nil { + return nil, f.err + } + return f.files, nil +} + +type fakeAdminIntroMarkerNotifier struct { + ch chan *models.MediaFile +} + +func (n fakeAdminIntroMarkerNotifier) MarkersUpdated(_ context.Context, file *models.MediaFile) { + if n.ch == nil { + return + } + n.ch <- file +} + +func TestAdminIntroRedetectQueuesAndDedupsInFlightEpisode(t *testing.T) { + analyzer := &fakeIntroAnalyzer{ + started: make(chan string, 1), + release: make(chan struct{}), + } + handler := NewAdminIntroHandler( + analyzer, + fakeIntroEligibility{result: &intromarkers.EpisodeIntroEligibility{ + EpisodeID: "ep1", + HasMediaFiles: true, + IntroDetectionEnabled: true, + }}, + context.Background(), + nil, + ) + handler.Settings = fakeMarkerSettings{values: map[string]string{markers.SettingMode: string(markers.ModeLocal)}} + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + first := httptest.NewRecorder() + router.ServeHTTP(first, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if first.Code != http.StatusAccepted { + t.Fatalf("expected first request 202, got %d: %s", first.Code, first.Body.String()) + } + if status := decodeRedetectStatus(t, first); status != "queued" { + t.Fatalf("expected queued, got %q", status) + } + + select { + case id := <-analyzer.started: + if id != "ep1" { + t.Fatalf("expected analyzer to start ep1, got %q", id) + } + case <-time.After(time.Second): + t.Fatal("analyzer did not start") + } + + second := httptest.NewRecorder() + router.ServeHTTP(second, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if second.Code != http.StatusAccepted { + t.Fatalf("expected second request 202, got %d: %s", second.Code, second.Body.String()) + } + if status := decodeRedetectStatus(t, second); status != "already_running" { + t.Fatalf("expected already_running, got %q", status) + } + + close(analyzer.release) +} + +func TestAdminIntroRedetectRejectsModesWithoutLocalAnalysis(t *testing.T) { + for _, tt := range []struct { + name string + mode string + }{ + {name: "off", mode: string(markers.ModeOff)}, + {name: "online", mode: string(markers.ModeOnline)}, + } { + t.Run(tt.name, func(t *testing.T) { + analyzer := &fakeIntroAnalyzer{started: make(chan string, 1), release: make(chan struct{})} + handler := NewAdminIntroHandler( + analyzer, + fakeIntroEligibility{result: &intromarkers.EpisodeIntroEligibility{ + EpisodeID: "ep1", + HasMediaFiles: true, + IntroDetectionEnabled: true, + }}, + context.Background(), + nil, + ) + handler.Settings = fakeMarkerSettings{values: map[string]string{markers.SettingMode: tt.mode}} + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if rec.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String()) + } + + select { + case id := <-analyzer.started: + t.Fatalf("expected analyzer not to start, got %q", id) + case <-time.After(50 * time.Millisecond): + } + }) + } +} + +func TestAdminIntroRedetectRejectsNonEpisode(t *testing.T) { + handler := NewAdminIntroHandler( + &fakeIntroAnalyzer{started: make(chan string, 1), release: make(chan struct{})}, + fakeIntroEligibility{err: intromarkers.ErrEpisodeNotFound}, + context.Background(), + nil, + ) + handler.Settings = fakeMarkerSettings{values: map[string]string{markers.SettingMode: string(markers.ModeLocal)}} + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/items/movie1/redetect-intro", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAdminIntroRedetectRejectsIntroDisabledLibrary(t *testing.T) { + handler := NewAdminIntroHandler( + &fakeIntroAnalyzer{started: make(chan string, 1), release: make(chan struct{})}, + fakeIntroEligibility{result: &intromarkers.EpisodeIntroEligibility{ + EpisodeID: "ep1", + HasMediaFiles: true, + IntroDetectionEnabled: false, + }}, + context.Background(), + nil, + ) + handler.Settings = fakeMarkerSettings{values: map[string]string{markers.SettingMode: string(markers.ModeLocal)}} + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if rec.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAdminIntroRedetectRejectsEpisodeWithoutMediaFiles(t *testing.T) { + analyzer := &fakeIntroAnalyzer{started: make(chan string, 1), release: make(chan struct{})} + handler := NewAdminIntroHandler( + analyzer, + fakeIntroEligibility{result: &intromarkers.EpisodeIntroEligibility{ + EpisodeID: "ep1", + HasMediaFiles: false, + IntroDetectionEnabled: false, + }}, + context.Background(), + nil, + ) + handler.Settings = fakeMarkerSettings{values: map[string]string{markers.SettingMode: string(markers.ModeLocal)}} + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if rec.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String()) + } + + select { + case id := <-analyzer.started: + t.Fatalf("expected analyzer not to start, got %q", id) + case <-time.After(50 * time.Millisecond): + } +} + +func TestAdminIntroRedetectRejectsMissingSettings(t *testing.T) { + handler := NewAdminIntroHandler( + &fakeIntroAnalyzer{started: make(chan string, 1), release: make(chan struct{})}, + fakeIntroEligibility{result: &intromarkers.EpisodeIntroEligibility{ + EpisodeID: "ep1", + HasMediaFiles: true, + IntroDetectionEnabled: true, + }}, + context.Background(), + nil, + ) + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAdminIntroRedetectNotifiesMarkedFilesAfterAnalyzerSuccess(t *testing.T) { + start := 12.0 + end := 75.0 + analyzer := &fakeIntroAnalyzer{started: make(chan string, 1)} + handler := NewAdminIntroHandler( + analyzer, + fakeIntroEligibility{result: &intromarkers.EpisodeIntroEligibility{ + EpisodeID: "ep1", + HasMediaFiles: true, + IntroDetectionEnabled: true, + }}, + context.Background(), + nil, + ) + handler.Settings = fakeMarkerSettings{values: map[string]string{markers.SettingMode: string(markers.ModeLocal)}} + handler.FileResolver = fakeAdminIntroFileResolver{files: []*models.MediaFile{ + {ID: 1, EpisodeID: "ep1", IntroStart: &start, IntroEnd: &end}, + {ID: 2, EpisodeID: "ep1"}, + }} + notifier := fakeAdminIntroMarkerNotifier{ch: make(chan *models.MediaFile, 1)} + handler.MarkerUpdateNotifier = notifier + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if rec.Code != http.StatusAccepted { + t.Fatalf("expected request 202, got %d: %s", rec.Code, rec.Body.String()) + } + + select { + case id := <-analyzer.started: + if id != "ep1" { + t.Fatalf("expected analyzer to start ep1, got %q", id) + } + case <-time.After(time.Second): + t.Fatal("analyzer did not start") + } + + select { + case notified := <-notifier.ch: + if notified.ID != 1 { + t.Fatalf("notified file ID = %d, want 1", notified.ID) + } + case <-time.After(time.Second): + t.Fatal("expected marker update notification") + } + select { + case notified := <-notifier.ch: + t.Fatalf("unexpected second notification: %#v", notified) + case <-time.After(25 * time.Millisecond): + } +} + +func TestAdminIntroRedetectDoesNotNotifyFilesStillMissingMarkers(t *testing.T) { + analyzer := &fakeIntroAnalyzer{started: make(chan string, 1)} + resolverCalled := make(chan string, 1) + handler := NewAdminIntroHandler( + analyzer, + fakeIntroEligibility{result: &intromarkers.EpisodeIntroEligibility{ + EpisodeID: "ep1", + HasMediaFiles: true, + IntroDetectionEnabled: true, + }}, + context.Background(), + nil, + ) + handler.Settings = fakeMarkerSettings{values: map[string]string{markers.SettingMode: string(markers.ModeLocal)}} + handler.FileResolver = fakeAdminIntroFileResolver{ + files: []*models.MediaFile{{ID: 1, EpisodeID: "ep1"}}, + called: resolverCalled, + } + notifier := fakeAdminIntroMarkerNotifier{ch: make(chan *models.MediaFile, 1)} + handler.MarkerUpdateNotifier = notifier + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if rec.Code != http.StatusAccepted { + t.Fatalf("expected request 202, got %d: %s", rec.Code, rec.Body.String()) + } + select { + case <-resolverCalled: + case <-time.After(time.Second): + t.Fatal("file resolver was not called") + } + select { + case notified := <-notifier.ch: + t.Fatalf("unexpected marker update: %#v", notified) + case <-time.After(25 * time.Millisecond): + } +} + +func TestAdminIntroRedetectNotificationReloadFailureDoesNotFailRequest(t *testing.T) { + analyzer := &fakeIntroAnalyzer{started: make(chan string, 1)} + resolverCalled := make(chan string, 1) + handler := NewAdminIntroHandler( + analyzer, + fakeIntroEligibility{result: &intromarkers.EpisodeIntroEligibility{ + EpisodeID: "ep1", + HasMediaFiles: true, + IntroDetectionEnabled: true, + }}, + context.Background(), + nil, + ) + handler.Settings = fakeMarkerSettings{values: map[string]string{markers.SettingMode: string(markers.ModeLocal)}} + handler.FileResolver = fakeAdminIntroFileResolver{ + err: errors.New("reload failed"), + called: resolverCalled, + } + handler.MarkerUpdateNotifier = fakeAdminIntroMarkerNotifier{ch: make(chan *models.MediaFile, 1)} + router := chi.NewRouter() + router.Post("/admin/items/{id}/redetect-intro", handler.HandleRedetectEpisodeIntro) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/items/ep1/redetect-intro", nil)) + if rec.Code != http.StatusAccepted { + t.Fatalf("expected request 202, got %d: %s", rec.Code, rec.Body.String()) + } + select { + case <-resolverCalled: + case <-time.After(time.Second): + t.Fatal("file resolver was not called") + } +} + +func decodeRedetectStatus(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var response redetectIntroResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decoding response: %v", err) + } + return response.Status +} diff --git a/internal/api/handlers/admin_invite_codes.go b/internal/api/handlers/admin_invite_codes.go new file mode 100644 index 00000000..084c079a --- /dev/null +++ b/internal/api/handlers/admin_invite_codes.go @@ -0,0 +1,199 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +// InviteCodeHandler handles admin endpoints for invite code management. +type InviteCodeHandler struct { + repo *auth.InviteCodeRepository +} + +// NewInviteCodeHandler creates a new InviteCodeHandler. +func NewInviteCodeHandler(repo *auth.InviteCodeRepository) *InviteCodeHandler { + return &InviteCodeHandler{repo: repo} +} + +// --- Request/Response types --- + +type createInviteCodeRequest struct { + Code string `json:"code"` + Label string `json:"label"` + MaxUses int `json:"max_uses"` +} + +type updateInviteCodeRequest struct { + Label *string `json:"label"` + MaxUses *int `json:"max_uses"` + Enabled *bool `json:"enabled"` +} + +type topUpInviteCodeRequest struct { + AdditionalUses int `json:"additional_uses"` +} + +type inviteCodeResponse struct { + ID int `json:"id"` + Code string `json:"code"` + Label string `json:"label"` + MaxUses int `json:"max_uses"` + UseCount int `json:"use_count"` + CreatedBy int `json:"created_by"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toInviteCodeResponse(ic *models.InviteCode) inviteCodeResponse { + return inviteCodeResponse{ + ID: ic.ID, + Code: ic.Code, + Label: ic.Label, + MaxUses: ic.MaxUses, + UseCount: ic.UseCount, + CreatedBy: ic.CreatedBy, + Enabled: ic.Enabled, + CreatedAt: ic.CreatedAt, + UpdatedAt: ic.UpdatedAt, + } +} + +// HandleListInviteCodes handles GET /admin/invite-codes. +func (h *InviteCodeHandler) HandleListInviteCodes(w http.ResponseWriter, r *http.Request) { + codes, err := h.repo.List(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list invite codes") + return + } + + resp := make([]inviteCodeResponse, 0, len(codes)) + for _, ic := range codes { + resp = append(resp, toInviteCodeResponse(ic)) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleCreateInviteCode handles POST /admin/invite-codes. +func (h *InviteCodeHandler) HandleCreateInviteCode(w http.ResponseWriter, r *http.Request) { + var req createInviteCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.MaxUses <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "max_uses must be greater than 0") + return + } + + claims := apimw.GetClaims(r.Context()) + if claims == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + ic, err := h.repo.Create(r.Context(), models.CreateInviteCodeInput{ + Code: req.Code, + Label: req.Label, + MaxUses: req.MaxUses, + CreatedBy: claims.UserID, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create invite code") + return + } + + writeJSON(w, http.StatusCreated, toInviteCodeResponse(ic)) +} + +// HandleUpdateInviteCode handles PUT /admin/invite-codes/{id}. +func (h *InviteCodeHandler) HandleUpdateInviteCode(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid invite code ID") + return + } + + var req updateInviteCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if err := h.repo.Update(r.Context(), id, models.UpdateInviteCodeInput{ + Label: req.Label, + MaxUses: req.MaxUses, + Enabled: req.Enabled, + }); err != nil { + if errors.Is(err, auth.ErrInviteCodeNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Invite code not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update invite code") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleTopUpInviteCode handles POST /admin/invite-codes/{id}/top-up. +func (h *InviteCodeHandler) HandleTopUpInviteCode(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid invite code ID") + return + } + + var req topUpInviteCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + ic, err := h.repo.TopUp(r.Context(), id, req.AdditionalUses) + if err != nil { + if errors.Is(err, auth.ErrInviteCodeInvalid) { + writeError(w, http.StatusBadRequest, "bad_request", "additional_uses must be greater than 0") + return + } + if errors.Is(err, auth.ErrInviteCodeNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Invite code not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to top up invite code") + return + } + + writeJSON(w, http.StatusOK, toInviteCodeResponse(ic)) +} + +// HandleDeleteInviteCode handles DELETE /admin/invite-codes/{id}. +func (h *InviteCodeHandler) HandleDeleteInviteCode(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid invite code ID") + return + } + + if err := h.repo.Delete(r.Context(), id); err != nil { + if errors.Is(err, auth.ErrInviteCodeNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Invite code not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete invite code") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers/admin_ip.go b/internal/api/handlers/admin_ip.go new file mode 100644 index 00000000..3cb047e4 --- /dev/null +++ b/internal/api/handlers/admin_ip.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/activitylog" +) + +// AdminIPHandler serves admin endpoints for IP/user visibility. +type AdminIPHandler struct { + repo *activitylog.Repo +} + +// NewAdminIPHandler creates a new AdminIPHandler. +func NewAdminIPHandler(repo *activitylog.Repo) *AdminIPHandler { + return &AdminIPHandler{repo: repo} +} + +// HandleGetUserIPs handles GET /admin/users/{id}/ips. +func (h *AdminIPHandler) HandleGetUserIPs(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + userID, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID") + return + } + + days := 30 + if d := r.URL.Query().Get("days"); d != "" { + if parsed, parseErr := strconv.Atoi(d); parseErr == nil && parsed > 0 { + days = parsed + } + } + + entries, err := h.repo.UserIPs(r.Context(), userID, days) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to query user IPs") + return + } + + writeJSON(w, http.StatusOK, entries) +} + +// HandleGetIPUsers handles GET /admin/ips?ip=... +func (h *AdminIPHandler) HandleGetIPUsers(w http.ResponseWriter, r *http.Request) { + ip := r.URL.Query().Get("ip") + if ip == "" { + writeError(w, http.StatusBadRequest, "bad_request", "ip query parameter is required") + return + } + + days := 30 + if d := r.URL.Query().Get("days"); d != "" { + if parsed, parseErr := strconv.Atoi(d); parseErr == nil && parsed > 0 { + days = parsed + } + } + + entries, err := h.repo.IPUsers(r.Context(), ip, days) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to query IP users") + return + } + + writeJSON(w, http.StatusOK, entries) +} diff --git a/internal/api/handlers/admin_jobs.go b/internal/api/handlers/admin_jobs.go new file mode 100644 index 00000000..4e4d000e --- /dev/null +++ b/internal/api/handlers/admin_jobs.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/adminjob" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/models" +) + +const adminJobDownloadExpiry = 15 * time.Minute + +type AdminJobArtifactStore interface { + Bucket() string + PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) +} + +type AdminJobsHandler struct { + repo *adminjob.Repository + store AdminJobArtifactStore +} + +func NewAdminJobsHandler(repo *adminjob.Repository, store AdminJobArtifactStore) *AdminJobsHandler { + return &AdminJobsHandler{repo: repo, store: store} +} + +type adminJobResponse struct { + ID string `json:"id"` + JobType string `json:"job_type"` + Status string `json:"status"` + CreatedByUserID int `json:"created_by_user_id"` + RequestPayload json.RawMessage `json:"request_payload"` + ResultPayload json.RawMessage `json:"result_payload"` + Message string `json:"message"` + ErrorMessage string `json:"error_message,omitempty"` + ProgressCurrent int `json:"progress_current"` + ProgressTotal int `json:"progress_total"` + ArtifactSizeBytes int64 `json:"artifact_size_bytes"` + PublicURL string `json:"public_url,omitempty"` + RequestedAt time.Time `json:"requested_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + HeartbeatAt *time.Time `json:"heartbeat_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + DownloadExpiresAt *time.Time `json:"download_expires_at,omitempty"` +} + +type listAdminJobsResponse struct { + Jobs []adminJobResponse `json:"jobs"` +} + +type adminJobConflictResponse struct { + Error string `json:"error"` + Message string `json:"message"` + ActiveJobID string `json:"active_job_id,omitempty"` + ActiveJob *adminJobResponse `json:"active_job,omitempty"` +} + +func (h *AdminJobsHandler) HandleList(w http.ResponseWriter, r *http.Request) { + limit := 20 + if raw := r.URL.Query().Get("limit"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid limit") + return + } + limit = value + } + + jobs, err := h.repo.List(r.Context(), adminjob.ListJobsOptions{ + JobType: r.URL.Query().Get("job_type"), + Limit: limit, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list admin jobs") + return + } + + respJobs := make([]adminJobResponse, 0, len(jobs)) + for _, job := range jobs { + respJobs = append(respJobs, adminJobToResponse(r, job, h.store)) + } + + writeJSON(w, http.StatusOK, listAdminJobsResponse{Jobs: respJobs}) +} + +func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Job ID is required") + return + } + + job, err := h.repo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, adminjob.ErrJobNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Job not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job") + return + } + + writeJSON(w, http.StatusOK, adminJobToResponse(r, job, h.store)) +} + +func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArtifactStore) adminJobResponse { + resp := adminJobResponse{ + ID: job.ID, + JobType: job.JobType, + Status: job.Status, + CreatedByUserID: job.CreatedByUserID, + RequestPayload: ensureJSONPayload(job.RequestPayload), + ResultPayload: ensureJSONPayload(job.ResultPayload), + Message: job.Message, + ErrorMessage: job.ErrorMessage, + ProgressCurrent: job.ProgressCurrent, + ProgressTotal: job.ProgressTotal, + ArtifactSizeBytes: job.ArtifactSizeBytes, + PublicURL: job.PublicURL, + RequestedAt: job.RequestedAt, + StartedAt: job.StartedAt, + CompletedAt: job.CompletedAt, + HeartbeatAt: job.HeartbeatAt, + ExpiresAt: job.ExpiresAt, + PublishedAt: job.PublishedAt, + } + + if store != nil && job.Status == adminjob.StatusCompleted && job.ArtifactBucket != "" && job.ArtifactKey != "" { + expiresAt := time.Now().UTC().Add(adminJobDownloadExpiry) + url, err := store.PresignGetURL(r.Context(), job.ArtifactBucket, job.ArtifactKey, adminJobDownloadExpiry) + if err == nil { + resp.DownloadURL = url + resp.DownloadExpiresAt = &expiresAt + } + } + + return resp +} + +func writeAdminJobConflict(w http.ResponseWriter, message string, job *models.AdminJob, handler *AdminJobsHandler, r *http.Request) { + resp := adminJobConflictResponse{ + Error: "conflict", + Message: message, + } + if job != nil { + resp.ActiveJobID = job.ID + response := adminJobToResponse(r, job, handler.store) + resp.ActiveJob = &response + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(resp) +} + +func ensureJSONPayload(data json.RawMessage) json.RawMessage { + if len(data) == 0 { + return json.RawMessage(`{}`) + } + return data +} + +func currentAdminUserID(r *http.Request) int { + claims := apimw.GetClaims(r.Context()) + if claims == nil { + return 0 + } + return claims.UserID +} diff --git a/internal/api/handlers/admin_logs.go b/internal/api/handlers/admin_logs.go new file mode 100644 index 00000000..b3519ecb --- /dev/null +++ b/internal/api/handlers/admin_logs.go @@ -0,0 +1,494 @@ +package handlers + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gorilla/websocket" + + "github.com/Silo-Server/silo-server/internal/activitylog" + "github.com/Silo-Server/silo-server/internal/logstream" + "github.com/Silo-Server/silo-server/internal/opslog" +) + +type AdminLogsHandler struct { + opsRepo *opslog.Repo + auditRepo *activitylog.Repo + streamHub *logstream.Hub +} + +func NewAdminLogsHandler(opsRepo *opslog.Repo, auditRepo *activitylog.Repo, streamHub *logstream.Hub) *AdminLogsHandler { + return &AdminLogsHandler{opsRepo: opsRepo, auditRepo: auditRepo, streamHub: streamHub} +} + +func (h *AdminLogsHandler) HandleListOperationalLogs(w http.ResponseWriter, r *http.Request) { + opts, err := parseOperationalLogOptionsFromRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + result, err := h.opsRepo.List(r.Context(), opts) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to query operational logs") + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *AdminLogsHandler) HandleListAuditLogs(w http.ResponseWriter, r *http.Request) { + opts, err := parseAuditLogOptionsFromRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + result, err := h.auditRepo.List(r.Context(), opts) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to query audit logs") + return + } + writeJSON(w, http.StatusOK, result) +} + +func parseOperationalLogOptionsFromRequest(r *http.Request) (opslog.ListOptions, error) { + opts := opslog.ListOptions{ + Level: strings.TrimSpace(r.URL.Query().Get("level")), + Component: strings.TrimSpace(r.URL.Query().Get("component")), + NodeID: strings.TrimSpace(r.URL.Query().Get("node_id")), + RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")), + SessionID: strings.TrimSpace(r.URL.Query().Get("session_id")), + PlaybackSessionID: strings.TrimSpace(r.URL.Query().Get("playback_session_id")), + Query: strings.TrimSpace(r.URL.Query().Get("q")), + Cursor: strings.TrimSpace(r.URL.Query().Get("cursor")), + Limit: parseLimit(r, 100), + } + + userID, err := parseOptionalIntQuery(r, "user_id") + if err != nil { + return opslog.ListOptions{}, err + } + opts.UserID = userID + + if from, err := parseTimeQuery(r, "from"); err != nil { + return opslog.ListOptions{}, err + } else { + opts.From = from + } + if to, err := parseTimeQuery(r, "to"); err != nil { + return opslog.ListOptions{}, err + } else { + opts.To = to + } + + return opts, nil +} + +func parseAuditLogOptionsFromRequest(r *http.Request) (activitylog.ListOptions, error) { + opts := activitylog.ListOptions{ + Method: strings.TrimSpace(r.URL.Query().Get("method")), + PathPrefix: strings.TrimSpace(r.URL.Query().Get("path_prefix")), + ClientIP: strings.TrimSpace(r.URL.Query().Get("client_ip")), + RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")), + SessionID: strings.TrimSpace(r.URL.Query().Get("session_id")), + PlaybackSessionID: strings.TrimSpace(r.URL.Query().Get("playback_session_id")), + Cursor: strings.TrimSpace(r.URL.Query().Get("cursor")), + Limit: parseLimit(r, 100), + } + + statusCode, err := parseOptionalIntQuery(r, "status_code") + if err != nil { + return activitylog.ListOptions{}, err + } + opts.StatusCode = statusCode + + userID, err := parseOptionalIntQuery(r, "user_id") + if err != nil { + return activitylog.ListOptions{}, err + } + opts.UserID = userID + + if from, err := parseTimeQuery(r, "from"); err != nil { + return activitylog.ListOptions{}, err + } else { + opts.From = from + } + if to, err := parseTimeQuery(r, "to"); err != nil { + return activitylog.ListOptions{}, err + } else { + opts.To = to + } + + return opts, nil +} + +func parseTimeQuery(r *http.Request, key string) (*time.Time, error) { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return nil, nil + } + ts, err := time.Parse(time.RFC3339, raw) + if err != nil { + return nil, invalidQueryError(key) + } + value := ts.UTC() + return &value, nil +} + +func parseOptionalIntQuery(r *http.Request, key string) (*int, error) { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return nil, nil + } + value, err := strconv.Atoi(raw) + if err != nil { + return nil, invalidQueryError(key) + } + return &value, nil +} + +func invalidQueryError(key string) error { + return &requestParseError{message: "Invalid " + key} +} + +type requestParseError struct { + message string +} + +func (e *requestParseError) Error() string { + return e.message +} + +func parseLimit(r *http.Request, fallback int) int { + raw := strings.TrimSpace(r.URL.Query().Get("limit")) + if raw == "" { + return fallback + } + limit, err := strconv.Atoi(raw) + if err != nil || limit <= 0 { + return fallback + } + if limit > 200 { + return 200 + } + return limit +} + +func (h *AdminLogsHandler) HandleLogStreamWebSocket(w http.ResponseWriter, r *http.Request) { + if h.streamHub == nil { + http.Error(w, "log stream unavailable", http.StatusServiceUnavailable) + return + } + + stream := logstream.Stream(strings.TrimSpace(r.URL.Query().Get("stream"))) + if stream != logstream.StreamApp && stream != logstream.StreamAudit { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid stream") + return + } + + var ( + appOpts opslog.ListOptions + auditOpts activitylog.ListOptions + err error + ) + switch stream { + case logstream.StreamApp: + appOpts, err = parseOperationalLogOptionsFromRequest(r) + case logstream.StreamAudit: + auditOpts, err = parseAuditLogOptionsFromRequest(r) + } + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + events, unsubscribe := h.streamHub.Subscribe(func(msg logstream.Message) bool { + return msg.Type == logstream.MessageTypeAppend && msg.Stream == stream + }) + defer unsubscribe() + + conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout)) + }) + + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + + buffered := drainBufferedMessages(events) + seenIDs := make(map[int64]struct{}) + newestSnapshotID := int64(0) + + switch stream { + case logstream.StreamApp: + if h.opsRepo == nil { + h.writeStreamError(conn, stream, "internal_error", "Operational log stream unavailable") + return + } + result, err := h.opsRepo.List(context.Background(), appOpts) + if err != nil { + slog.Error("admin log stream operational snapshot failed", "error", err) + h.writeStreamError(conn, stream, "internal_error", "Failed to query operational logs") + return + } + if len(result.Entries) > 0 { + newestSnapshotID = result.Entries[0].ID + } + for _, entry := range result.Entries { + seenIDs[entry.ID] = struct{}{} + } + if err := writeSnapshotMessage(conn, stream, result.Entries, result.NextCursor); err != nil { + return + } + buffered = append(buffered, drainBufferedMessages(events)...) + for _, msg := range buffered { + entry, ok := decodeAppEntry(msg) + if !ok { + continue + } + if newestSnapshotID > 0 && entry.ID <= newestSnapshotID { + continue + } + if !matchesOperationalLog(appOpts, entry) { + continue + } + if _, ok := seenIDs[entry.ID]; ok { + continue + } + seenIDs[entry.ID] = struct{}{} + if err := conn.WriteJSON(msg); err != nil { + return + } + } + case logstream.StreamAudit: + if h.auditRepo == nil { + h.writeStreamError(conn, stream, "internal_error", "Audit log stream unavailable") + return + } + result, err := h.auditRepo.List(context.Background(), auditOpts) + if err != nil { + slog.Error("admin log stream audit snapshot failed", "error", err) + h.writeStreamError(conn, stream, "internal_error", "Failed to query audit logs") + return + } + if len(result.Entries) > 0 { + newestSnapshotID = result.Entries[0].ID + } + for _, entry := range result.Entries { + seenIDs[entry.ID] = struct{}{} + } + if err := writeSnapshotMessage(conn, stream, result.Entries, result.NextCursor); err != nil { + return + } + buffered = append(buffered, drainBufferedMessages(events)...) + for _, msg := range buffered { + entry, ok := decodeAuditEntry(msg) + if !ok { + continue + } + if newestSnapshotID > 0 && entry.ID <= newestSnapshotID { + continue + } + if !matchesAuditLog(auditOpts, entry) { + continue + } + if _, ok := seenIDs[entry.ID]; ok { + continue + } + seenIDs[entry.ID] = struct{}{} + if err := conn.WriteJSON(msg); err != nil { + return + } + } + } + + ticker := time.NewTicker(wsPingInterval) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-done: + return + case msg, ok := <-events: + if !ok { + return + } + switch stream { + case logstream.StreamApp: + entry, ok := decodeAppEntry(msg) + if !ok || !matchesOperationalLog(appOpts, entry) { + continue + } + if _, ok := seenIDs[entry.ID]; ok { + continue + } + seenIDs[entry.ID] = struct{}{} + case logstream.StreamAudit: + entry, ok := decodeAuditEntry(msg) + if !ok || !matchesAuditLog(auditOpts, entry) { + continue + } + if _, ok := seenIDs[entry.ID]; ok { + continue + } + seenIDs[entry.ID] = struct{}{} + } + if err := conn.WriteJSON(msg); err != nil { + if websocket.IsCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + return + } + return + } + case <-ticker.C: + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil { + return + } + } + } +} + +func writeSnapshotMessage(conn *websocket.Conn, stream logstream.Stream, entries any, nextCursor string) error { + raw, err := json.Marshal(entries) + if err != nil { + return err + } + return conn.WriteJSON(logstream.Message{ + Type: logstream.MessageTypeSnapshot, + Stream: stream, + Entries: raw, + NextCursor: nextCursor, + }) +} + +func (h *AdminLogsHandler) writeStreamError(conn *websocket.Conn, stream logstream.Stream, code, message string) { + _ = conn.WriteJSON(logstream.Message{ + Type: logstream.MessageTypeError, + Stream: stream, + Code: code, + Message: message, + }) + _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, message), time.Now().Add(5*time.Second)) +} + +func drainBufferedMessages(events <-chan logstream.Message) []logstream.Message { + buffered := make([]logstream.Message, 0, 8) + for { + select { + case msg, ok := <-events: + if !ok { + return buffered + } + buffered = append(buffered, msg) + default: + return buffered + } + } +} + +func decodeAppEntry(msg logstream.Message) (opslog.EntryRow, bool) { + var entry opslog.EntryRow + if err := json.Unmarshal(msg.Entry, &entry); err != nil { + return opslog.EntryRow{}, false + } + return entry, true +} + +func decodeAuditEntry(msg logstream.Message) (activitylog.AuditEntry, bool) { + var entry activitylog.AuditEntry + if err := json.Unmarshal(msg.Entry, &entry); err != nil { + return activitylog.AuditEntry{}, false + } + return entry, true +} + +func matchesOperationalLog(opts opslog.ListOptions, entry opslog.EntryRow) bool { + if opts.From != nil && entry.Timestamp.Before(*opts.From) { + return false + } + if opts.To != nil && entry.Timestamp.After(*opts.To) { + return false + } + if opts.Level != "" && entry.Level != strings.ToLower(opts.Level) { + return false + } + if opts.Component != "" && entry.Component != opts.Component { + return false + } + if opts.NodeID != "" && entry.NodeID != opts.NodeID { + return false + } + if opts.RequestID != "" && entry.RequestID != opts.RequestID { + return false + } + if opts.UserID != nil { + if entry.UserID == nil || *entry.UserID != *opts.UserID { + return false + } + } + if opts.SessionID != "" && entry.SessionID != opts.SessionID { + return false + } + if opts.PlaybackSessionID != "" && entry.PlaybackSessionID != opts.PlaybackSessionID { + return false + } + if opts.Query != "" && !strings.Contains(strings.ToLower(entry.Message), strings.ToLower(opts.Query)) { + return false + } + return true +} + +func matchesAuditLog(opts activitylog.ListOptions, entry activitylog.AuditEntry) bool { + if opts.From != nil && entry.Timestamp.Before(*opts.From) { + return false + } + if opts.To != nil && entry.Timestamp.After(*opts.To) { + return false + } + if opts.Method != "" && entry.Method != strings.ToUpper(opts.Method) { + return false + } + if opts.StatusCode != nil && entry.StatusCode != *opts.StatusCode { + return false + } + if opts.PathPrefix != "" && !strings.HasPrefix(entry.Path, opts.PathPrefix) { + return false + } + if opts.ClientIP != "" && entry.ClientIP != opts.ClientIP { + return false + } + if opts.RequestID != "" && entry.RequestID != opts.RequestID { + return false + } + if opts.UserID != nil { + if entry.UserID == nil || *entry.UserID != *opts.UserID { + return false + } + } + if opts.SessionID != "" && entry.SessionID != opts.SessionID { + return false + } + if opts.PlaybackSessionID != "" && entry.PlaybackSessionID != opts.PlaybackSessionID { + return false + } + return true +} diff --git a/internal/api/handlers/admin_match.go b/internal/api/handlers/admin_match.go new file mode 100644 index 00000000..2eca8a73 --- /dev/null +++ b/internal/api/handlers/admin_match.go @@ -0,0 +1,289 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" +) + +// MatchItemLookup loads a media item by content ID. +type MatchItemLookup interface { + GetByID(ctx context.Context, contentID string) (*models.MediaItem, error) +} + +// MatchFolderLookup resolves the primary library folder for a content ID. +type MatchFolderLookup interface { + GetFolderIDForItem(ctx context.Context, contentID string) (int, error) + GetFolderIDsForItem(ctx context.Context, contentID string) ([]int, error) +} + +// MatchMetadataService exposes search and process operations needed by the +// admin match endpoints. +type MatchMetadataService interface { + SearchAndNormalize(ctx context.Context, query metadata.SearchQuery, folderID int) ([]metadata.MatchCandidate, error) + Process(ctx context.Context, req metadata.ProcessRequest) (*metadata.ProcessResult, error) +} + +// AdminMatchHandler handles the explicit match search and apply endpoints +// used by the admin match-repair UI. +type AdminMatchHandler struct { + items MatchItemLookup + folders MatchFolderLookup + metadata MatchMetadataService +} + +// NewAdminMatchHandler creates a handler for admin match search/apply endpoints. +func NewAdminMatchHandler( + items MatchItemLookup, + folders MatchFolderLookup, + metadataSvc MatchMetadataService, +) *AdminMatchHandler { + return &AdminMatchHandler{ + items: items, + folders: folders, + metadata: metadataSvc, + } +} + +// --- Request/Response types --- + +type matchSearchRequest struct { + Title string `json:"title"` + Year int `json:"year"` + ImdbID string `json:"imdb_id"` + TmdbID string `json:"tmdb_id"` + TvdbID string `json:"tvdb_id"` + LibraryID *int `json:"library_id,omitempty"` +} + +type matchSearchResponse struct { + Candidates []metadata.MatchCandidate `json:"candidates"` +} + +type matchApplyRequest struct { + ProviderIDs map[string]string `json:"provider_ids"` + LibraryID *int `json:"library_id,omitempty"` +} + +type matchApplyResponse struct { + ContentID string `json:"content_id"` + Updated bool `json:"updated"` +} + +// HandleSearchItemMatchCandidates handles POST /admin/items/{id}/match/search. +// It searches metadata providers for candidate matches based on the given +// query parameters (title, year, external IDs) and returns normalized, +// deduplicated candidates. +func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + var req matchSearchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + // Load the existing item to infer content type. + item, err := h.items.GetByID(r.Context(), contentID) + if err != nil { + slog.Warn("admin match: item not found", "content_id", contentID, "error", err) + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + folderID, err := h.resolveMatchFolderID(r.Context(), contentID, req.LibraryID) + if err != nil { + if err.Error() == "ambiguous_library" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id is required for items in multiple libraries") + return + } + slog.Warn("admin match: resolve folder failed", "content_id", contentID, "error", err) + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id") + return + } + + // Build the search query from the request, falling back to item metadata. + query := metadata.SearchQuery{ + Title: req.Title, + Year: req.Year, + ContentType: item.Type, + ProviderIDs: make(map[string]string), + } + if query.Title == "" { + query.Title = item.Title + } + if query.Year == 0 { + query.Year = item.Year + } + + // Inject any provider IDs from the request. + if req.ImdbID != "" { + query.ProviderIDs["imdb"] = req.ImdbID + } + if req.TmdbID != "" { + query.ProviderIDs["tmdb"] = req.TmdbID + } + if req.TvdbID != "" { + query.ProviderIDs["tvdb"] = req.TvdbID + } + + candidates, err := h.metadata.SearchAndNormalize(r.Context(), query, folderID) + if err != nil { + slog.Error("admin match: search failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Metadata search failed") + return + } + + writeJSON(w, http.StatusOK, matchSearchResponse{Candidates: candidates}) +} + +// HandleApplyItemMatch handles POST /admin/items/{id}/match/apply. +// It applies the user-selected provider IDs to the item via ModeIdentify, +// preserving the original content_id. +func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + var req matchApplyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if len(req.ProviderIDs) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "At least one provider ID is required") + return + } + + // Verify the item exists. + _, err := h.items.GetByID(r.Context(), contentID) + if err != nil { + slog.Warn("admin match: item not found for apply", "content_id", contentID, "error", err) + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + folderID, err := h.resolveMatchFolderID(r.Context(), contentID, req.LibraryID) + if err != nil { + if err.Error() == "ambiguous_library" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id is required for items in multiple libraries") + return + } + slog.Warn("admin match: resolve folder failed for apply", "content_id", contentID, "error", err) + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id") + return + } + folderIDStr := "" + if folderID > 0 { + folderIDStr = fmt.Sprintf("%d", folderID) + } + + result, err := h.metadata.Process(r.Context(), metadata.ProcessRequest{ + ContentID: contentID, + ProviderIDs: req.ProviderIDs, + FolderID: folderIDStr, + Mode: metadata.ModeIdentify, + }) + if err != nil { + slog.Error("admin match: apply failed", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply match") + return + } + + writeJSON(w, http.StatusOK, matchApplyResponse{ + ContentID: result.ContentID, + Updated: result.Updated, + }) +} + +// PoolFolderLookup implements MatchFolderLookup using a direct pgx pool query +// against the media_item_libraries table. +type PoolFolderLookup struct { + Pool *pgxpool.Pool +} + +// GetFolderIDForItem returns the first library folder ID associated with the +// given content ID. +func (l *PoolFolderLookup) GetFolderIDForItem(ctx context.Context, contentID string) (int, error) { + var folderID int + err := l.Pool.QueryRow(ctx, + `SELECT media_folder_id FROM media_item_libraries WHERE content_id = $1 ORDER BY media_folder_id ASC LIMIT 1`, + contentID, + ).Scan(&folderID) + if err != nil { + return 0, fmt.Errorf("looking up folder for item %s: %w", contentID, err) + } + return folderID, nil +} + +func (l *PoolFolderLookup) GetFolderIDsForItem(ctx context.Context, contentID string) ([]int, error) { + rows, err := l.Pool.Query(ctx, + `SELECT media_folder_id FROM media_item_libraries WHERE content_id = $1 ORDER BY media_folder_id ASC`, + contentID, + ) + if err != nil { + return nil, fmt.Errorf("looking up folders for item %s: %w", contentID, err) + } + defer rows.Close() + + var folderIDs []int + for rows.Next() { + var folderID int + if scanErr := rows.Scan(&folderID); scanErr != nil { + return nil, fmt.Errorf("scanning folder for item %s: %w", contentID, scanErr) + } + folderIDs = append(folderIDs, folderID) + } + return folderIDs, rows.Err() +} + +func (h *AdminMatchHandler) resolveMatchFolderID(ctx context.Context, contentID string, requestedLibraryID *int) (int, error) { + if h.folders == nil { + if requestedLibraryID != nil && *requestedLibraryID > 0 { + return *requestedLibraryID, nil + } + return 0, nil + } + if requestedLibraryID != nil { + if *requestedLibraryID <= 0 { + return 0, fmt.Errorf("invalid library id") + } + folderIDs, err := h.folders.GetFolderIDsForItem(ctx, contentID) + if err != nil { + return 0, fmt.Errorf("invalid library id") + } + for _, folderID := range folderIDs { + if folderID == *requestedLibraryID { + return folderID, nil + } + } + return 0, fmt.Errorf("invalid library id") + } + folderIDs, err := h.folders.GetFolderIDsForItem(ctx, contentID) + if err != nil { + return 0, fmt.Errorf("folder lookup failed: %w", err) + } + switch len(folderIDs) { + case 0: + return 0, nil + case 1: + return folderIDs[0], nil + default: + return 0, fmt.Errorf("ambiguous_library") + } +} diff --git a/internal/api/handlers/admin_match_test.go b/internal/api/handlers/admin_match_test.go new file mode 100644 index 00000000..8846a376 --- /dev/null +++ b/internal/api/handlers/admin_match_test.go @@ -0,0 +1,307 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" +) + +// --- Fakes --- + +type fakeMatchItemLookup struct { + items map[string]*models.MediaItem +} + +func (f *fakeMatchItemLookup) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) { + if item, ok := f.items[contentID]; ok { + return item, nil + } + return nil, fmt.Errorf("item not found: %s", contentID) +} + +type fakeMatchFolderLookup struct { + folders map[string]int + folderIDs map[string][]int +} + +func (f *fakeMatchFolderLookup) GetFolderIDForItem(_ context.Context, contentID string) (int, error) { + if fid, ok := f.folders[contentID]; ok { + return fid, nil + } + return 0, fmt.Errorf("no folder for %s", contentID) +} + +func (f *fakeMatchFolderLookup) GetFolderIDsForItem(_ context.Context, contentID string) ([]int, error) { + if ids, ok := f.folderIDs[contentID]; ok { + return ids, nil + } + if fid, ok := f.folders[contentID]; ok { + return []int{fid}, nil + } + return nil, fmt.Errorf("no folders for %s", contentID) +} + +type fakeMatchMetadataService struct { + searchResults []metadata.MatchCandidate + searchErr error + processResult *metadata.ProcessResult + processErr error + lastProcess metadata.ProcessRequest +} + +func (f *fakeMatchMetadataService) SearchAndNormalize(_ context.Context, _ metadata.SearchQuery, _ int) ([]metadata.MatchCandidate, error) { + return f.searchResults, f.searchErr +} + +func (f *fakeMatchMetadataService) Process(_ context.Context, req metadata.ProcessRequest) (*metadata.ProcessResult, error) { + f.lastProcess = req + return f.processResult, f.processErr +} + +// --- Helpers --- + +func buildMatchRouter(h *AdminMatchHandler) *chi.Mux { + r := chi.NewRouter() + r.Post("/admin/items/{id}/match/search", h.HandleSearchItemMatchCandidates) + r.Post("/admin/items/{id}/match/apply", h.HandleApplyItemMatch) + return r +} + +// --- Tests --- + +func TestAdminMatchSearch_ReturnsCandidatesArray(t *testing.T) { + items := &fakeMatchItemLookup{ + items: map[string]*models.MediaItem{ + "item-1": {ContentID: "item-1", Title: "The Matrix", Year: 1999, Type: "movie"}, + }, + } + folders := &fakeMatchFolderLookup{ + folders: map[string]int{"item-1": 10}, + } + metaSvc := &fakeMatchMetadataService{ + searchResults: []metadata.MatchCandidate{ + { + Title: "The Matrix", + Year: 1999, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "603", "imdb": "tt0133093"}, + Sources: []string{"tmdb"}, + AgreementHints: []string{"agreed_by_tmdb_and_tvdb"}, + }, + { + Title: "The Matrix Reloaded", + Year: 2003, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "604"}, + Sources: []string{"tmdb"}, + }, + }, + } + + h := NewAdminMatchHandler(items, folders, metaSvc) + router := buildMatchRouter(h) + + body, _ := json.Marshal(matchSearchRequest{Title: "The Matrix"}) + req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/match/search", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp matchSearchResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + + if len(resp.Candidates) != 2 { + t.Fatalf("expected 2 candidates, got %d", len(resp.Candidates)) + } + + // Verify candidate structure. + c0 := resp.Candidates[0] + if c0.Title != "The Matrix" { + t.Errorf("expected title 'The Matrix', got %q", c0.Title) + } + if c0.ProviderIDs["tmdb"] != "603" { + t.Errorf("expected tmdb=603, got %q", c0.ProviderIDs["tmdb"]) + } + if len(c0.AgreementHints) != 1 || c0.AgreementHints[0] != "agreed_by_tmdb_and_tvdb" { + t.Errorf("expected agreement_hints, got %v", c0.AgreementHints) + } +} + +func TestAdminMatchSearch_ItemNotFound(t *testing.T) { + items := &fakeMatchItemLookup{items: map[string]*models.MediaItem{}} + h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{}) + router := buildMatchRouter(h) + + body, _ := json.Marshal(matchSearchRequest{Title: "foo"}) + req := httptest.NewRequest(http.MethodPost, "/admin/items/nonexistent/match/search", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAdminMatchSearch_FallsBackToItemMetadata(t *testing.T) { + items := &fakeMatchItemLookup{ + items: map[string]*models.MediaItem{ + "item-2": {ContentID: "item-2", Title: "Inception", Year: 2010, Type: "movie"}, + }, + } + var capturedQuery metadata.SearchQuery + metaSvc := &fakeMatchMetadataService{ + searchResults: []metadata.MatchCandidate{}, + } + // Wrap to capture the query. + captureSvc := &capturingMetadataService{ + inner: metaSvc, + } + + h := NewAdminMatchHandler(items, nil, captureSvc) + router := buildMatchRouter(h) + + // Send empty title/year -- handler should use item's metadata. + body, _ := json.Marshal(matchSearchRequest{}) + req := httptest.NewRequest(http.MethodPost, "/admin/items/item-2/match/search", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + capturedQuery = captureSvc.lastQuery + if capturedQuery.Title != "Inception" { + t.Errorf("expected title fallback to 'Inception', got %q", capturedQuery.Title) + } + if capturedQuery.Year != 2010 { + t.Errorf("expected year fallback to 2010, got %d", capturedQuery.Year) + } +} + +func TestAdminMatchApply_PreservesContentID(t *testing.T) { + items := &fakeMatchItemLookup{ + items: map[string]*models.MediaItem{ + "item-3": {ContentID: "item-3", Title: "Interstellar", Year: 2014, Type: "movie"}, + }, + } + metaSvc := &fakeMatchMetadataService{ + processResult: &metadata.ProcessResult{ + ContentID: "item-3", + Updated: true, + }, + } + + h := NewAdminMatchHandler(items, nil, metaSvc) + router := buildMatchRouter(h) + + body, _ := json.Marshal(matchApplyRequest{ + ProviderIDs: map[string]string{"tmdb": "157336"}, + }) + req := httptest.NewRequest(http.MethodPost, "/admin/items/item-3/match/apply", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp matchApplyResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + + // The original content_id must be preserved (not replaced with a new ID). + if resp.ContentID != "item-3" { + t.Errorf("expected content_id 'item-3' preserved, got %q", resp.ContentID) + } + if !resp.Updated { + t.Error("expected updated=true") + } + + // Verify the Process call used ModeIdentify with the original content_id. + if metaSvc.lastProcess.ContentID != "item-3" { + t.Errorf("expected Process called with content_id 'item-3', got %q", metaSvc.lastProcess.ContentID) + } + if metaSvc.lastProcess.Mode != metadata.ModeIdentify { + t.Errorf("expected ModeIdentify, got %d", metaSvc.lastProcess.Mode) + } + if metaSvc.lastProcess.ProviderIDs["tmdb"] != "157336" { + t.Errorf("expected provider_ids tmdb=157336, got %v", metaSvc.lastProcess.ProviderIDs) + } +} + +func TestAdminMatchApply_RequiresProviderIDs(t *testing.T) { + items := &fakeMatchItemLookup{ + items: map[string]*models.MediaItem{ + "item-4": {ContentID: "item-4", Title: "Test", Type: "movie"}, + }, + } + h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{}) + router := buildMatchRouter(h) + + body, _ := json.Marshal(matchApplyRequest{ProviderIDs: map[string]string{}}) + req := httptest.NewRequest(http.MethodPost, "/admin/items/item-4/match/apply", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty provider_ids, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAdminMatchApply_ItemNotFound(t *testing.T) { + items := &fakeMatchItemLookup{items: map[string]*models.MediaItem{}} + h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{}) + router := buildMatchRouter(h) + + body, _ := json.Marshal(matchApplyRequest{ProviderIDs: map[string]string{"tmdb": "123"}}) + req := httptest.NewRequest(http.MethodPost, "/admin/items/nonexistent/match/apply", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// capturingMetadataService wraps a fake to capture the query passed to SearchAndNormalize. +type capturingMetadataService struct { + inner *fakeMatchMetadataService + lastQuery metadata.SearchQuery +} + +func (c *capturingMetadataService) SearchAndNormalize(ctx context.Context, query metadata.SearchQuery, folderID int) ([]metadata.MatchCandidate, error) { + c.lastQuery = query + return c.inner.SearchAndNormalize(ctx, query, folderID) +} + +func (c *capturingMetadataService) Process(ctx context.Context, req metadata.ProcessRequest) (*metadata.ProcessResult, error) { + return c.inner.Process(ctx, req) +} diff --git a/internal/api/handlers/admin_playback_control.go b/internal/api/handlers/admin_playback_control.go new file mode 100644 index 00000000..69ba6f06 --- /dev/null +++ b/internal/api/handlers/admin_playback_control.go @@ -0,0 +1,235 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Silo-Server/silo-server/internal/playback" +) + +const ( + defaultPlaybackControlDeadline = 3 * time.Second + maxPlaybackControlDeadline = 10 * time.Second +) + +type AdminPlaybackControlHandler struct { + playback *PlaybackHandler +} + +type playbackControlRequest struct { + Reason string `json:"reason"` + Title string `json:"title"` + Message string `json:"message"` + DeadlineMS int `json:"deadline_ms"` +} + +type playbackControlResponse struct { + CommandID string `json:"command_id"` + Status string `json:"status"` +} + +func requiresLivePlaybackControl(name playback.CommandName) bool { + switch name { + case playback.CommandPause, playback.CommandUnpause: + return true + default: + return false + } +} + +func NewAdminPlaybackControlHandler(playbackHandler *PlaybackHandler) *AdminPlaybackControlHandler { + return &AdminPlaybackControlHandler{playback: playbackHandler} +} + +func (h *AdminPlaybackControlHandler) HandleStopSession(w http.ResponseWriter, r *http.Request) { + h.handleSessionCommand(w, r, playback.CommandStop) +} + +func (h *AdminPlaybackControlHandler) HandlePauseSession(w http.ResponseWriter, r *http.Request) { + h.handleSessionCommand(w, r, playback.CommandPause) +} + +func (h *AdminPlaybackControlHandler) HandleResumeSession(w http.ResponseWriter, r *http.Request) { + h.handleSessionCommand(w, r, playback.CommandUnpause) +} + +func (h *AdminPlaybackControlHandler) HandleTerminateSession(w http.ResponseWriter, r *http.Request) { + h.handleSessionCommand(w, r, playback.CommandTerminate) +} + +func (h *AdminPlaybackControlHandler) HandleMessageSession(w http.ResponseWriter, r *http.Request) { + if h == nil || h.playback == nil || h.playback.CommandDispatcher == nil { + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Playback control is unavailable") + return + } + + sessionID := chi.URLParam(r, "session_id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required") + return + } + + if _, err := h.playback.sessionMgr.GetSession(sessionID); err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Playback session not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session") + return + } + + var req playbackControlRequest + if err := decodeOptionalJSONBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.Message == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Message is required") + return + } + + payload, err := json.Marshal(map[string]string{ + "title": req.Title, + "message": req.Message, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build command payload") + return + } + + commandID := uuid.NewString() + command, err := playback.NewCommandEnvelope(sessionID, commandID, playback.CommandDisplayMessage, payload) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build command") + return + } + command.Reason = req.Reason + command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"} + + result := h.playback.CommandDispatcher.DispatchToSession(command, 0, nil) + if result.DispatchErr != nil { + status := http.StatusInternalServerError + code := "internal_error" + message := "Failed to dispatch command" + if errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) { + status = http.StatusConflict + code = "realtime_unavailable" + message = "Realtime connection unavailable for playback session" + } + writeError(w, status, code, message) + return + } + + writeJSON(w, http.StatusAccepted, playbackControlResponse{ + CommandID: commandID, + Status: "dispatched", + }) +} + +func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter, r *http.Request, name playback.CommandName) { + if h == nil || h.playback == nil || h.playback.CommandDispatcher == nil { + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Playback control is unavailable") + return + } + + sessionID := chi.URLParam(r, "session_id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required") + return + } + + session, err := h.playback.sessionMgr.GetSession(sessionID) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Playback session not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session") + return + } + + var req playbackControlRequest + if err := decodeOptionalJSONBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if requiresLivePlaybackControl(name) && (session == nil || !session.HasRealtimeConnection) { + writeError(w, http.StatusConflict, "realtime_unavailable", "Realtime connection unavailable for playback session") + return + } + + commandID := uuid.NewString() + command, err := playback.NewCommandEnvelope(sessionID, commandID, name, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build command") + return + } + command.Reason = req.Reason + command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"} + deadline := boundedPlaybackControlDeadline(req.DeadlineMS) + command.DeadlineMS = int(deadline / time.Millisecond) + + fallback := func() { + h.playback.forgetRealtimeCommand(commandID) + _ = h.playback.stopPlaybackSessionByID(context.Background(), sessionID) + } + + h.playback.rememberRealtimeCommand(commandID, sessionID, name) + result := h.playback.CommandDispatcher.DispatchToSession(command, deadline, fallback) + if result.DispatchErr == nil { + writeJSON(w, http.StatusAccepted, playbackControlResponse{ + CommandID: commandID, + Status: "dispatched", + }) + return + } + + h.playback.forgetRealtimeCommand(commandID) + if errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) { + time.AfterFunc(deadline, fallback) + writeJSON(w, http.StatusAccepted, playbackControlResponse{ + CommandID: commandID, + Status: "fallback_scheduled", + }) + return + } + + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to dispatch command") +} + +func decodeOptionalJSONBody(r *http.Request, target any) error { + if r.Body == nil { + return nil + } + defer r.Body.Close() + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(target); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + if errors.Is(err, http.ErrBodyNotAllowed) { + return nil + } + return err + } + return nil +} + +func boundedPlaybackControlDeadline(deadlineMS int) time.Duration { + if deadlineMS <= 0 { + return defaultPlaybackControlDeadline + } + deadline := time.Duration(deadlineMS) * time.Millisecond + if deadline > maxPlaybackControlDeadline { + return maxPlaybackControlDeadline + } + return deadline +} diff --git a/internal/api/handlers/admin_playback_control_test.go b/internal/api/handlers/admin_playback_control_test.go new file mode 100644 index 00000000..1d047fb9 --- /dev/null +++ b/internal/api/handlers/admin_playback_control_test.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/playback" +) + +type adminPlaybackControlTestConn struct { + messages []any +} + +func (c *adminPlaybackControlTestConn) WriteJSON(v any) error { + c.messages = append(c.messages, v) + return nil +} + +func TestHandlePauseSession_RequiresHelloReadyControlConnection(t *testing.T) { + control, sessionMgr, realtimeHub, session := newAdminPlaybackControlTestHandler(t) + + registration := realtimeHub.Register(session.ID, &adminPlaybackControlTestConn{}) + if registration == nil { + t.Fatal("expected raw realtime registration") + } + defer realtimeHub.Unregister(registration) + + req := httptest.NewRequest(http.MethodPost, "/admin/sessions/"+session.ID+"/pause", strings.NewReader(`{"deadline_ms":10}`)) + req = withPlaybackRouteParam(req, "session_id", session.ID) + + rr := httptest.NewRecorder() + control.HandlePauseSession(rr, req) + + if rr.Code != http.StatusConflict { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp errorResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode error response: %v", err) + } + if resp.Error != "realtime_unavailable" { + t.Fatalf("error code = %q, want realtime_unavailable", resp.Error) + } + + time.Sleep(40 * time.Millisecond) + + if _, err := sessionMgr.GetSession(session.ID); err != nil { + t.Fatalf("pause should not schedule a fallback stop, GetSession: %v", err) + } +} + +func TestHandleStopSession_KeepsFallbackWhenRealtimeUnavailable(t *testing.T) { + control, sessionMgr, _, session := newAdminPlaybackControlTestHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/admin/sessions/"+session.ID+"/stop", strings.NewReader(`{"deadline_ms":10}`)) + req = withPlaybackRouteParam(req, "session_id", session.ID) + + rr := httptest.NewRecorder() + control.HandleStopSession(rr, req) + + if rr.Code != http.StatusAccepted { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp playbackControlResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Status != "fallback_scheduled" { + t.Fatalf("status = %q, want fallback_scheduled", resp.Status) + } + + waitForPlaybackSessionMissing(t, sessionMgr, session.ID) +} + +func TestPlaybackSessionRowJSONIncludesHasPlaybackControl(t *testing.T) { + row := playbackSessionRow{ + SessionID: "session-1", + HasPlaybackControl: true, + } + + data, err := json.Marshal(row) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + value, ok := decoded["has_playback_control"].(bool) + if !ok { + t.Fatalf("has_playback_control missing from JSON: %s", string(data)) + } + if !value { + t.Fatalf("has_playback_control = %v, want true", value) + } +} + +func newAdminPlaybackControlTestHandler(t *testing.T) (*AdminPlaybackControlHandler, *playback.SessionManager, *playback.RealtimeHub, *playback.Session) { + t.Helper() + + sessionMgr := playback.NewSessionManager(0, 0) + session, err := sessionMgr.StartSession(1, "profile-1", 100, playback.PlayDirect, false) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + realtimeHub := playback.NewRealtimeHub() + commandTracker := playback.NewCommandTracker() + t.Cleanup(commandTracker.Close) + + playbackHandler := NewPlaybackHandler(sessionMgr) + playbackHandler.RealtimeHub = realtimeHub + playbackHandler.CommandTracker = commandTracker + playbackHandler.CommandDispatcher = playback.NewCommandDispatcher(sessionMgr, realtimeHub, commandTracker) + + return NewAdminPlaybackControlHandler(playbackHandler), sessionMgr, realtimeHub, session +} + +func waitForPlaybackSessionMissing(t *testing.T, sessionMgr *playback.SessionManager, sessionID string) { + t.Helper() + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + _, err := sessionMgr.GetSession(sessionID) + if errors.Is(err, playback.ErrSessionNotFound) { + return + } + time.Sleep(10 * time.Millisecond) + } + + session, err := sessionMgr.GetSession(sessionID) + if err == nil && session != nil { + t.Fatalf("session %q still exists after fallback deadline", sessionID) + } + t.Fatalf("unexpected GetSession result after fallback deadline: %v", err) +} diff --git a/internal/api/handlers/admin_recommendations.go b/internal/api/handlers/admin_recommendations.go new file mode 100644 index 00000000..a79704b5 --- /dev/null +++ b/internal/api/handlers/admin_recommendations.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "net/http" + + "github.com/Silo-Server/silo-server/internal/recommendations" +) + +// AdminRecommendationsHandler handles admin recommendation status and trigger endpoints. +type AdminRecommendationsHandler struct { + worker *recommendations.Worker +} + +// NewAdminRecommendationsHandler creates a new AdminRecommendationsHandler. +func NewAdminRecommendationsHandler(worker *recommendations.Worker) *AdminRecommendationsHandler { + return &AdminRecommendationsHandler{worker: worker} +} + +// jobStatusResponse is the status for a single job. +type jobStatusResponse struct { + Running bool `json:"running"` + Count int `json:"count"` + Total int `json:"total,omitempty"` +} + +// recommendationsStatusResponse is the full status response. +type recommendationsStatusResponse struct { + Embeddings jobStatusResponse `json:"embeddings"` + TasteProfiles jobStatusResponse `json:"taste_profiles"` + Cowatch jobStatusResponse `json:"cowatch"` + Recommendations jobStatusResponse `json:"recommendations"` +} + +// HandleStatus handles GET /admin/recommendations/status. +func (h *AdminRecommendationsHandler) HandleStatus(w http.ResponseWriter, r *http.Request) { + embedded, totalItems, tasteProfiles, cacheEntries, cowatchPairs, err := h.worker.StatusCounts(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendation status") + return + } + + resp := recommendationsStatusResponse{ + Embeddings: jobStatusResponse{ + Running: h.worker.IsRunning(recommendations.JobEmbeddings), + Count: embedded, + Total: totalItems, + }, + TasteProfiles: jobStatusResponse{ + Running: h.worker.IsRunning(recommendations.JobTasteProfiles), + Count: tasteProfiles, + }, + Cowatch: jobStatusResponse{ + Running: h.worker.IsRunning(recommendations.JobCowatch), + Count: cowatchPairs, + }, + Recommendations: jobStatusResponse{ + Running: h.worker.IsRunning(recommendations.JobRecommendations), + Count: cacheEntries, + }, + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleTriggerEmbeddings handles POST /admin/recommendations/trigger/embeddings. +func (h *AdminRecommendationsHandler) HandleTriggerEmbeddings(w http.ResponseWriter, r *http.Request) { + if err := h.worker.TriggerEmbeddings(); err != nil { + writeError(w, http.StatusConflict, "conflict", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "started"}) +} + +// HandleTriggerTasteProfiles handles POST /admin/recommendations/trigger/taste-profiles. +func (h *AdminRecommendationsHandler) HandleTriggerTasteProfiles(w http.ResponseWriter, r *http.Request) { + if err := h.worker.TriggerTasteProfiles(); err != nil { + writeError(w, http.StatusConflict, "conflict", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "started"}) +} + +// HandleTriggerCowatch handles POST /admin/recommendations/trigger/cowatch. +func (h *AdminRecommendationsHandler) HandleTriggerCowatch(w http.ResponseWriter, r *http.Request) { + if err := h.worker.TriggerCowatch(); err != nil { + writeError(w, http.StatusConflict, "conflict", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "started"}) +} + +// HandleTriggerRecommendations handles POST /admin/recommendations/trigger/recommendations. +func (h *AdminRecommendationsHandler) HandleTriggerRecommendations(w http.ResponseWriter, r *http.Request) { + if err := h.worker.TriggerRecommendations(); err != nil { + writeError(w, http.StatusConflict, "conflict", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "started"}) +} diff --git a/internal/api/handlers/admin_settings_checks.go b/internal/api/handlers/admin_settings_checks.go new file mode 100644 index 00000000..c4be1eff --- /dev/null +++ b/internal/api/handlers/admin_settings_checks.go @@ -0,0 +1,322 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/redis/go-redis/v9" + + "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/recommendations/embeddings" + "github.com/Silo-Server/silo-server/internal/s3client" +) + +type adminSettingsConnectionCheckRequest struct { + Values map[string]string `json:"values"` + DirtyKeys []string `json:"dirty_keys"` +} + +type connectionCheckResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +type s3SettingsCheckClient interface { + HeadBucket(ctx context.Context, bucket string) error +} + +type redisSettingsCheckClient interface { + Ping(ctx context.Context) error + Close() error +} + +type embeddingsSettingsCheckClient interface { + Embed(ctx context.Context, texts []string) ([][]float32, error) +} + +type redisSettingsCheckAdapter struct { + client *redis.Client +} + +func (a *redisSettingsCheckAdapter) Ping(ctx context.Context) error { + return a.client.Ping(ctx).Err() +} + +func (a *redisSettingsCheckAdapter) Close() error { + return a.client.Close() +} + +var newAdminS3SettingsCheckClient = func(cfg s3client.BucketConfig) s3SettingsCheckClient { + return s3client.NewClient(cfg) +} + +var newAdminRedisSettingsCheckClient = func(cfg config.RedisConfig) (redisSettingsCheckClient, error) { + client, err := cache.NewRedisClient(cfg) + if err != nil { + return nil, err + } + if client == nil { + return nil, nil + } + return &redisSettingsCheckAdapter{client: client}, nil +} + +var newAdminEmbeddingsSettingsCheckClient = func( + cfg embeddings.ClientConfig, +) embeddingsSettingsCheckClient { + return embeddings.NewClient(cfg) +} + +func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *http.Request) { + if h.SettingsRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured") + return + } + + kind := chi.URLParam(r, "kind") + if strings.TrimSpace(kind) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Check kind is required") + return + } + + var req adminSettingsConnectionCheckRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.Values == nil { + req.Values = map[string]string{} + } + + effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), req) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings") + return + } + + cfg, err := config.LoadFromDB(effectiveSettings) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + var response connectionCheckResponse + switch kind { + case "s3_public", "s3_operational": + response = checkS3PublicConnection(r.Context(), cfg) + case "s3_private": + response = checkS3PrivateConnection(r.Context(), cfg) + case "redis": + response = checkRedisConnection(r.Context(), cfg) + case "recommendations_embedding": + response = checkRecommendationsEmbeddingConnection(r.Context(), cfg) + default: + writeError(w, http.StatusBadRequest, "bad_request", "Unsupported connection check kind") + return + } + + writeJSON(w, http.StatusOK, response) +} + +func (h *AdminHandler) effectiveSettingsForConnectionCheck( + ctx context.Context, + req adminSettingsConnectionCheckRequest, +) (map[string]string, error) { + settings, err := h.SettingsRepo.GetAll(ctx) + if err != nil { + return nil, err + } + + merged := make(map[string]string, len(settings)+len(h.BootstrapSensitiveValues)+len(req.DirtyKeys)) + for key, value := range settings { + merged[key] = value + } + for key, value := range h.BootstrapSensitiveValues { + if value == "" { + continue + } + merged[key] = value + } + for _, key := range req.DirtyKeys { + merged[key] = req.Values[key] + } + + return merged, nil +} + +func checkS3PublicConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse { + if strings.TrimSpace(cfg.S3.Public.Endpoint) == "" { + return connectionCheckResponse{Success: false, Message: "S3 endpoint is required."} + } + if strings.TrimSpace(cfg.S3.Public.Bucket) == "" { + return connectionCheckResponse{Success: false, Message: "S3 bucket is required."} + } + + urlAuth := strings.TrimSpace(cfg.S3.Public.URLAuth) + switch urlAuth { + case "", s3client.URLAuthPresigned: + case s3client.URLAuthPublic: + if strings.TrimSpace(cfg.S3.Public.ReadEndpoint) == "" { + return connectionCheckResponse{ + Success: false, + Message: "A public endpoint is required when URL auth is set to Public.", + } + } + case s3client.URLAuthCloudflareToken: + if strings.TrimSpace(cfg.S3.Public.ReadEndpoint) == "" { + return connectionCheckResponse{ + Success: false, + Message: "A public endpoint is required when URL auth is set to Cloudflare Token.", + } + } + if strings.TrimSpace(cfg.S3.Public.TokenSecret) == "" { + return connectionCheckResponse{ + Success: false, + Message: "A token secret is required when URL auth is set to Cloudflare Token.", + } + } + default: + return connectionCheckResponse{ + Success: false, + Message: fmt.Sprintf("Unsupported S3 URL auth method %q.", urlAuth), + } + } + + client := newAdminS3SettingsCheckClient(s3client.BucketConfig{ + Endpoint: cfg.S3.Public.Endpoint, + PublicEndpoint: cfg.S3.Public.ReadEndpoint, + Region: cfg.S3.Public.Region, + Bucket: cfg.S3.Public.Bucket, + KeyPrefix: cfg.S3.Public.KeyPrefix, + AccessKey: cfg.S3.Public.AccessKey, + SecretKey: cfg.S3.Public.SecretKey, + PathStyle: cfg.S3.Public.PathStyle, + URLAuth: cfg.S3.Public.URLAuth, + TokenSecret: cfg.S3.Public.TokenSecret, + TokenParam: cfg.S3.Public.TokenParam, + TokenTTL: cfg.S3.Public.TokenTTL, + }) + + checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if err := client.HeadBucket(checkCtx, cfg.S3.Public.Bucket); err != nil { + return connectionCheckResponse{ + Success: false, + Message: fmt.Sprintf("S3 connection check failed: %v", err), + } + } + + return connectionCheckResponse{ + Success: true, + Message: "S3 connection successful.", + } +} + +func checkS3PrivateConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse { + if strings.TrimSpace(cfg.S3.Private.Endpoint) == "" { + return connectionCheckResponse{Success: false, Message: "S3 endpoint is required."} + } + if strings.TrimSpace(cfg.S3.Private.Bucket) == "" { + return connectionCheckResponse{Success: false, Message: "S3 bucket is required."} + } + + client := newAdminS3SettingsCheckClient(s3client.BucketConfig{ + Endpoint: cfg.S3.Private.Endpoint, + Region: cfg.S3.Private.Region, + Bucket: cfg.S3.Private.Bucket, + KeyPrefix: cfg.S3.Private.KeyPrefix, + AccessKey: cfg.S3.Private.AccessKey, + SecretKey: cfg.S3.Private.SecretKey, + PathStyle: cfg.S3.Private.PathStyle, + }) + + checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if err := client.HeadBucket(checkCtx, cfg.S3.Private.Bucket); err != nil { + return connectionCheckResponse{ + Success: false, + Message: fmt.Sprintf("S3 connection check failed: %v", err), + } + } + + return connectionCheckResponse{ + Success: true, + Message: "S3 connection successful.", + } +} + +func checkRedisConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse { + if strings.TrimSpace(cfg.Redis.URL) == "" { + return connectionCheckResponse{Success: false, Message: "Redis URL is required."} + } + + client, err := newAdminRedisSettingsCheckClient(cfg.Redis) + if err != nil { + return connectionCheckResponse{ + Success: false, + Message: fmt.Sprintf("Redis connection check failed: %v", err), + } + } + if client == nil { + return connectionCheckResponse{Success: false, Message: "Redis URL is required."} + } + defer client.Close() + + checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := client.Ping(checkCtx); err != nil { + return connectionCheckResponse{ + Success: false, + Message: fmt.Sprintf("Redis connection check failed: %v", err), + } + } + + return connectionCheckResponse{ + Success: true, + Message: "Redis connection successful.", + } +} + +func checkRecommendationsEmbeddingConnection( + ctx context.Context, + cfg *config.Config, +) connectionCheckResponse { + if strings.TrimSpace(cfg.Recommendations.EmbeddingBaseURL) == "" { + return connectionCheckResponse{ + Success: false, + Message: "Embedding base URL is required.", + } + } + if strings.TrimSpace(cfg.Recommendations.EmbeddingModel) == "" { + return connectionCheckResponse{ + Success: false, + Message: "Embedding model is required.", + } + } + + client := newAdminEmbeddingsSettingsCheckClient(embeddings.ClientConfig{ + BaseURL: cfg.Recommendations.EmbeddingBaseURL, + Model: cfg.Recommendations.EmbeddingModel, + APIKey: cfg.Recommendations.EmbeddingAuthToken, + }) + + checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + if _, err := client.Embed(checkCtx, []string{"silo connection test"}); err != nil { + return connectionCheckResponse{ + Success: false, + Message: fmt.Sprintf("Embedding connection check failed: %v", err), + } + } + + return connectionCheckResponse{ + Success: true, + Message: "Embedding connection successful.", + } +} diff --git a/internal/api/handlers/admin_settings_checks_test.go b/internal/api/handlers/admin_settings_checks_test.go new file mode 100644 index 00000000..171aaa79 --- /dev/null +++ b/internal/api/handlers/admin_settings_checks_test.go @@ -0,0 +1,355 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/s3client" +) + +type fakeServerSettingsStore struct { + values map[string]string +} + +func (f *fakeServerSettingsStore) Get(_ context.Context, key string) (string, error) { + return f.values[key], nil +} + +func (f *fakeServerSettingsStore) Set(_ context.Context, key, value string) error { + if f.values == nil { + f.values = map[string]string{} + } + f.values[key] = value + return nil +} + +func (f *fakeServerSettingsStore) GetAll(context.Context) (map[string]string, error) { + cloned := make(map[string]string, len(f.values)) + for key, value := range f.values { + cloned[key] = value + } + return cloned, nil +} + +type fakeS3SettingsCheckClient struct { + headBucket func(ctx context.Context, bucket string) error +} + +func (f *fakeS3SettingsCheckClient) HeadBucket(ctx context.Context, bucket string) error { + if f.headBucket != nil { + return f.headBucket(ctx, bucket) + } + return nil +} + +type fakeRedisSettingsCheckClient struct { + ping func(ctx context.Context) error +} + +func (f *fakeRedisSettingsCheckClient) Ping(ctx context.Context) error { + if f.ping != nil { + return f.ping(ctx) + } + return nil +} + +func (f *fakeRedisSettingsCheckClient) Close() error { + return nil +} + +type fakeEmbeddingsSettingsCheckClient struct { + embed func(ctx context.Context, texts []string) ([][]float32, error) +} + +func (f *fakeEmbeddingsSettingsCheckClient) Embed( + ctx context.Context, + texts []string, +) ([][]float32, error) { + if f.embed != nil { + return f.embed(ctx, texts) + } + return [][]float32{{0.1, 0.2}}, nil +} + +func TestHandleCheckSettingsConnectionS3UsesPersistedSensitiveValues(t *testing.T) { + originalFactory := newAdminS3SettingsCheckClient + t.Cleanup(func() { + newAdminS3SettingsCheckClient = originalFactory + }) + + var captured s3client.BucketConfig + newAdminS3SettingsCheckClient = func(cfg s3client.BucketConfig) s3SettingsCheckClient { + captured = cfg + return &fakeS3SettingsCheckClient{} + } + + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + "s3.public_endpoint": "https://persisted.example.test", + "s3.public_bucket": "silo", + "s3.public_key_prefix": "persisted/prefix", + "s3.public_access_key": "persisted-access", + "s3.public_secret_key": "persisted-secret", + }, + }, + } + + body := map[string]any{ + "values": map[string]string{ + "s3.public_endpoint": "https://draft.example.test", + "s3.public_access_key": "", + "s3.public_secret_key": "", + }, + "dirty_keys": []string{"s3.public_endpoint"}, + } + + rec := performSettingsCheckRequest( + t, + handler, + "/admin/settings/check/s3_public", + body, + ) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var response connectionCheckResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Decode() returned error: %v", err) + } + if !response.Success { + t.Fatalf("response.Success = false, want true (message=%q)", response.Message) + } + if captured.Endpoint != "https://draft.example.test" { + t.Fatalf("captured endpoint = %q, want draft endpoint", captured.Endpoint) + } + if captured.KeyPrefix != "persisted/prefix" { + t.Fatalf("captured key prefix = %q, want persisted/prefix", captured.KeyPrefix) + } + if captured.AccessKey != "persisted-access" { + t.Fatalf("captured access key = %q, want persisted-access", captured.AccessKey) + } + if captured.SecretKey != "persisted-secret" { + t.Fatalf("captured secret key = %q, want persisted-secret", captured.SecretKey) + } +} + +func TestHandleCheckSettingsConnectionRedisHonorsExplicitClear(t *testing.T) { + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + "redis.url": "redis://persisted:6379", + }, + }, + } + + rec := performSettingsCheckRequest( + t, + handler, + "/admin/settings/check/redis", + map[string]any{ + "values": map[string]string{ + "redis.url": "", + }, + "dirty_keys": []string{"redis.url"}, + }, + ) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var response connectionCheckResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Decode() returned error: %v", err) + } + if response.Success { + t.Fatalf("response.Success = true, want false") + } + if !strings.Contains(response.Message, "Redis URL is required") { + t.Fatalf("message = %q, want Redis URL validation", response.Message) + } +} + +func TestHandleCheckSettingsConnectionRejectsInvalidDraftValues(t *testing.T) { + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + "s3.public_endpoint": "https://persisted.example.test", + "s3.public_bucket": "silo", + }, + }, + } + + rec := performSettingsCheckRequest( + t, + handler, + "/admin/settings/check/s3_public", + map[string]any{ + "values": map[string]string{ + "s3.public_token_ttl": "not-a-number", + }, + "dirty_keys": []string{"s3.public_token_ttl"}, + }, + ) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + + var response map[string]string + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Decode() returned error: %v", err) + } + if !strings.Contains(response["message"], "invalid int for") { + t.Fatalf("message = %q, want parse failure", response["message"]) + } +} + +func TestSettingsCheckRouteIsNotShadowedByKeyRoute(t *testing.T) { + originalFactory := newAdminRedisSettingsCheckClient + t.Cleanup(func() { + newAdminRedisSettingsCheckClient = originalFactory + }) + + newAdminRedisSettingsCheckClient = func(cfg config.RedisConfig) (redisSettingsCheckClient, error) { + return &fakeRedisSettingsCheckClient{}, nil + } + + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + "redis.url": "redis://cache:6379", + }, + }, + } + + router := chi.NewRouter() + router.Post("/admin/settings/check/{kind}", handler.HandleCheckSettingsConnection) + router.Get("/admin/settings/{key}", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + + body, err := json.Marshal(map[string]any{ + "values": map[string]string{ + "redis.url": "redis://cache:6379", + }, + "dirty_keys": []string{"redis.url"}, + }) + if err != nil { + t.Fatalf("Marshal() returned error: %v", err) + } + + req := httptest.NewRequest( + http.MethodPost, + "/admin/settings/check/redis", + bytes.NewReader(body), + ) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestAdminGetSettingRedactsSensitiveSetting(t *testing.T) { + for _, key := range []string{"watchsync.trakt.client_secret", "watchsync.simkl.client_secret"} { + t.Run(key, func(t *testing.T) { + const storedSecret = "stored-watch-provider-secret" + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + key: storedSecret, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/admin/settings/"+key, nil) + req = withChiParam(req, "key", key) + rec := httptest.NewRecorder() + + handler.HandleGetSetting(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), storedSecret) { + t.Fatalf("response leaked sensitive value: %s", rec.Body.String()) + } + }) + } +} + +func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) { + for _, key := range []string{"watchsync.trakt.client_secret", "watchsync.simkl.client_secret"} { + t.Run(key, func(t *testing.T) { + const submittedSecret = "submitted-watch-provider-secret" + settings := &fakeServerSettingsStore{} + handler := &AdminHandler{SettingsRepo: settings} + + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/"+key, + strings.NewReader(`{"value":"`+submittedSecret+`"}`), + ) + req = withChiParam(req, "key", key) + rec := httptest.NewRecorder() + + handler.HandleUpdateSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values[key] != submittedSecret { + t.Fatalf("stored value = %q, want submitted secret", settings.values[key]) + } + if strings.Contains(rec.Body.String(), submittedSecret) { + t.Fatalf("response leaked sensitive value: %s", rec.Body.String()) + } + + var resp adminSettingResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Key != key || resp.Value != "" { + t.Fatalf("response = %+v, want key with empty value", resp) + } + }) + } +} + +func performSettingsCheckRequest( + t *testing.T, + handler *AdminHandler, + path string, + body map[string]any, +) *httptest.ResponseRecorder { + t.Helper() + + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("Marshal() returned error: %v", err) + } + + router := chi.NewRouter() + router.Post("/admin/settings/check/{kind}", handler.HandleCheckSettingsConnection) + + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func withChiParam(r *http.Request, key, value string) *http.Request { + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add(key, value) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)) +} diff --git a/internal/api/handlers/admin_stats.go b/internal/api/handlers/admin_stats.go new file mode 100644 index 00000000..47b7840b --- /dev/null +++ b/internal/api/handlers/admin_stats.go @@ -0,0 +1,340 @@ +package handlers + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/cache" +) + +const ( + adminStatsCacheKey = "global" + adminStatsCacheTTL = 15 * time.Second +) + +// AdminStats represents system statistics for the admin dashboard. +type AdminStats struct { + TotalItems int `json:"total_items"` + TotalFiles int `json:"total_files"` + TotalUsers int `json:"total_users"` + TotalMovies int `json:"total_movies"` + TotalShows int `json:"total_shows"` + ActiveStreams int `json:"active_streams"` + TotalStorageBytes int64 `json:"total_storage_bytes"` + WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"` +} + +type WatchProviderActivity struct { + TraktConnectedProfiles int64 `json:"trakt_connected_profiles"` + TraktEnabledProfiles int64 `json:"trakt_enabled_profiles"` + TraktExportEnabled int64 `json:"trakt_export_enabled"` + TraktScrobbleEnabled int64 `json:"trakt_scrobble_enabled"` + LastSyncCompletedAt *time.Time `json:"last_sync_completed_at,omitempty"` + SyncRuns24h int64 `json:"sync_runs_24h"` + SyncErrors24h int64 `json:"sync_errors_24h"` + ImportedWatched24h int64 `json:"imported_watched_24h"` + ImportedProgress24h int64 `json:"imported_progress_24h"` + ExportedWatched24h int64 `json:"exported_watched_24h"` + PendingExports int64 `json:"pending_exports"` + FailedExports int64 `json:"failed_exports"` + OpenScrobbles int64 `json:"open_scrobbles"` + Scrobbles24h int64 `json:"scrobbles_24h"` +} + +// AdminStatsSource returns cached or freshly queried admin stats. +type AdminStatsSource interface { + Get(ctx context.Context) (AdminStats, error) + Invalidate() +} + +// AdminStatsProvider serves exact admin stats with a short in-process TTL and +// optional cross-node invalidation via the shared event bus. +type AdminStatsProvider struct { + pool *pgxpool.Pool + cache *cache.TTLCache[AdminStats] + ttl time.Duration +} + +var _ AdminStatsSource = (*AdminStatsProvider)(nil) + +// NewAdminStatsProvider creates a cached provider and subscribes it to the +// shared invalidation channels when an event bus is configured. +func NewAdminStatsProvider(ctx context.Context, pool *pgxpool.Pool, bus cache.EventBus) (*AdminStatsProvider, error) { + provider := &AdminStatsProvider{ + pool: pool, + cache: cache.NewTTLCache[AdminStats](), + ttl: adminStatsCacheTTL, + } + + if bus == nil || ctx == nil { + return provider, nil + } + + handler := func(cache.Event) { + provider.Invalidate() + } + for _, channel := range []string{cache.ChannelCatalog, cache.ChannelAdmin, cache.ChannelPlayback} { + if err := bus.Subscribe(ctx, channel, handler); err != nil { + provider.Close() + return nil, fmt.Errorf("subscribing admin stats provider to %s: %w", channel, err) + } + } + + return provider, nil +} + +// Get returns cached stats when available, otherwise it queries Postgres and +// stores the exact result for a short period. +func (p *AdminStatsProvider) Get(ctx context.Context) (AdminStats, error) { + if p == nil || p.pool == nil { + return AdminStats{}, fmt.Errorf("admin stats provider is not configured") + } + if stats, ok := p.cache.Get(adminStatsCacheKey); ok { + return stats, nil + } + + stats, err := queryAdminStats(ctx, p.pool) + if err != nil { + return AdminStats{}, err + } + p.cache.Set(adminStatsCacheKey, stats, p.ttl) + return stats, nil +} + +// Invalidate drops the current cached stats snapshot. +func (p *AdminStatsProvider) Invalidate() { + if p == nil || p.cache == nil { + return + } + p.cache.Invalidate(adminStatsCacheKey) +} + +// Close stops the background TTL sweeper. +func (p *AdminStatsProvider) Close() { + if p == nil || p.cache == nil { + return + } + p.cache.Close() +} + +func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error) { + if pool == nil { + return AdminStats{}, fmt.Errorf("database not configured") + } + + var ( + totalUsers int64 + totalItems int64 + totalFiles int64 + totalMovies int64 + totalShows int64 + activeStreams int64 + totalStorage int64 + ) + + row := pool.QueryRow(ctx, ` + WITH user_stats AS ( + SELECT COUNT(*)::bigint AS total_users + FROM users + ), + item_stats AS ( + SELECT + COUNT(*)::bigint AS total_items, + COUNT(*) FILTER (WHERE type = 'movie')::bigint AS total_movies, + COUNT(*) FILTER (WHERE type = 'series')::bigint AS total_shows + FROM media_items + ), + file_stats AS ( + SELECT + COUNT(*)::bigint AS total_files, + COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes + FROM media_files + ), + session_stats AS ( + SELECT COUNT(*)::bigint AS active_streams + FROM playback_sessions_sync + ) + SELECT + user_stats.total_users, + item_stats.total_items, + file_stats.total_files, + item_stats.total_movies, + item_stats.total_shows, + session_stats.active_streams, + file_stats.total_storage_bytes + FROM user_stats + CROSS JOIN item_stats + CROSS JOIN file_stats + CROSS JOIN session_stats + `) + if err := row.Scan( + &totalUsers, + &totalItems, + &totalFiles, + &totalMovies, + &totalShows, + &activeStreams, + &totalStorage, + ); err != nil { + return AdminStats{}, fmt.Errorf("querying admin stats: %w", err) + } + + activity, err := queryWatchProviderActivity(ctx, pool) + if err != nil { + slog.Warn("failed to query watch provider admin stats", "error", err) + activity = WatchProviderActivity{} + } + + return AdminStats{ + TotalUsers: int(totalUsers), + TotalItems: int(totalItems), + TotalFiles: int(totalFiles), + TotalMovies: int(totalMovies), + TotalShows: int(totalShows), + ActiveStreams: int(activeStreams), + TotalStorageBytes: totalStorage, + WatchProviderActivity: activity, + }, nil +} + +func queryWatchProviderActivity(ctx context.Context, pool *pgxpool.Pool) (WatchProviderActivity, error) { + ready, err := watchProviderStatsTablesReady(ctx, pool) + if err != nil { + return WatchProviderActivity{}, err + } + if !ready { + return WatchProviderActivity{}, nil + } + + var activity WatchProviderActivity + row := pool.QueryRow(ctx, ` + WITH watch_provider_connection_stats AS ( + SELECT + COUNT(*) FILTER (WHERE provider = 'trakt')::bigint AS trakt_connected_profiles, + COUNT(*) FILTER ( + WHERE provider = 'trakt' + AND ( + import_watched_enabled + OR import_progress_enabled + OR export_watched_enabled + OR scrobble_enabled + ) + )::bigint AS trakt_enabled_profiles, + COUNT(*) FILTER (WHERE provider = 'trakt' AND export_watched_enabled)::bigint AS trakt_export_enabled, + COUNT(*) FILTER (WHERE provider = 'trakt' AND scrobble_enabled)::bigint AS trakt_scrobble_enabled + FROM watch_provider_connections + ), + watch_provider_sync_stats AS ( + SELECT + MAX(completed_at) FILTER (WHERE provider = 'trakt') AS last_sync_completed_at, + COUNT(*) FILTER ( + WHERE provider = 'trakt' + AND started_at >= now() - interval '24 hours' + )::bigint AS sync_runs_24h, + COUNT(*) FILTER ( + WHERE provider = 'trakt' + AND status = 'failed' + AND started_at >= now() - interval '24 hours' + )::bigint AS sync_errors_24h, + COALESCE(SUM(inbound_watched_imported) FILTER ( + WHERE provider = 'trakt' + AND started_at >= now() - interval '24 hours' + ), 0)::bigint AS imported_watched_24h, + COALESCE(SUM(inbound_progress_imported) FILTER ( + WHERE provider = 'trakt' + AND started_at >= now() - interval '24 hours' + ), 0)::bigint AS imported_progress_24h, + COALESCE(SUM(outbound_sent) FILTER ( + WHERE provider = 'trakt' + AND started_at >= now() - interval '24 hours' + ), 0)::bigint AS exported_watched_24h + FROM watch_provider_sync_runs + ), + watch_provider_export_stats AS ( + SELECT + COUNT(*) FILTER ( + WHERE c.provider = 'trakt' + AND e.status = 'pending' + )::bigint AS pending_exports, + COUNT(*) FILTER ( + WHERE c.provider = 'trakt' + AND e.status = 'failed' + )::bigint AS failed_exports + FROM watch_provider_history_exports e + JOIN watch_provider_connections c ON c.id = e.connection_id + ), + watch_provider_scrobble_stats AS ( + SELECT + COUNT(*) FILTER ( + WHERE c.provider = 'trakt' + AND s.stop_sent_at IS NULL + )::bigint AS open_scrobbles, + COUNT(*) FILTER ( + WHERE c.provider = 'trakt' + AND s.updated_at >= now() - interval '24 hours' + )::bigint AS scrobbles_24h + FROM watch_provider_scrobble_sessions s + JOIN watch_provider_connections c ON c.id = s.connection_id + ) + SELECT + watch_provider_connection_stats.trakt_connected_profiles, + watch_provider_connection_stats.trakt_enabled_profiles, + watch_provider_connection_stats.trakt_export_enabled, + watch_provider_connection_stats.trakt_scrobble_enabled, + watch_provider_sync_stats.last_sync_completed_at, + watch_provider_sync_stats.sync_runs_24h, + watch_provider_sync_stats.sync_errors_24h, + watch_provider_sync_stats.imported_watched_24h, + watch_provider_sync_stats.imported_progress_24h, + watch_provider_sync_stats.exported_watched_24h, + watch_provider_export_stats.pending_exports, + watch_provider_export_stats.failed_exports, + watch_provider_scrobble_stats.open_scrobbles, + watch_provider_scrobble_stats.scrobbles_24h + FROM watch_provider_connection_stats + CROSS JOIN watch_provider_sync_stats + CROSS JOIN watch_provider_export_stats + CROSS JOIN watch_provider_scrobble_stats + `) + if err := row.Scan( + &activity.TraktConnectedProfiles, + &activity.TraktEnabledProfiles, + &activity.TraktExportEnabled, + &activity.TraktScrobbleEnabled, + &activity.LastSyncCompletedAt, + &activity.SyncRuns24h, + &activity.SyncErrors24h, + &activity.ImportedWatched24h, + &activity.ImportedProgress24h, + &activity.ExportedWatched24h, + &activity.PendingExports, + &activity.FailedExports, + &activity.OpenScrobbles, + &activity.Scrobbles24h, + ); err != nil { + return WatchProviderActivity{}, fmt.Errorf("querying watch provider activity stats: %w", err) + } + + return activity, nil +} + +func watchProviderStatsTablesReady(ctx context.Context, pool *pgxpool.Pool) (bool, error) { + var ready bool + err := pool.QueryRow(ctx, ` + SELECT bool_and(to_regclass(table_name) IS NOT NULL) + FROM unnest($1::text[]) AS table_name + `, []string{ + "public.watch_provider_connections", + "public.watch_provider_sync_runs", + "public.watch_provider_history_exports", + "public.watch_provider_scrobble_sessions", + }).Scan(&ready) + if err != nil { + return false, fmt.Errorf("checking watch provider stats tables: %w", err) + } + return ready, nil +} diff --git a/internal/api/handlers/admin_subtitles.go b/internal/api/handlers/admin_subtitles.go new file mode 100644 index 00000000..2798297f --- /dev/null +++ b/internal/api/handlers/admin_subtitles.go @@ -0,0 +1,191 @@ +// internal/api/handlers/admin_subtitles.go +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/Silo-Server/silo-server/internal/subtitles" + "github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles" + "github.com/Silo-Server/silo-server/internal/subtitles/subdl" + "github.com/Silo-Server/silo-server/internal/subtitles/subsource" + "github.com/go-chi/chi/v5" +) + +// SubtitleProviderFactory creates a Provider from a config. Allows testing without real providers. +type SubtitleProviderFactory func(cfg *subtitles.ProviderConfig) (subtitles.Provider, error) + +// AdminSubtitleHandler handles admin operations for subtitle provider management. +type AdminSubtitleHandler struct { + repo subtitles.Repository + providerFactory SubtitleProviderFactory +} + +// NewAdminSubtitleHandler creates a new AdminSubtitleHandler. +func NewAdminSubtitleHandler(repo subtitles.Repository) *AdminSubtitleHandler { + return &AdminSubtitleHandler{ + repo: repo, + providerFactory: defaultProviderFactory, + } +} + +type updateSubtitleProviderRequest struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + Username string `json:"username"` + Password string `json:"password"` +} + +var builtinSubtitleProviders = []string{"opensubtitles", "subdl", "subsource"} + +// HandleListProviders handles GET /api/v1/admin/subtitle-providers +func (h *AdminSubtitleHandler) HandleListProviders(w http.ResponseWriter, r *http.Request) { + configs, err := h.repo.ListProviderConfigs(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "list_error", "Failed to list providers") + return + } + configs = mergeSubtitleProviderConfigs(configs) + writeJSON(w, http.StatusOK, map[string]interface{}{"providers": configs}) +} + +// HandleUpdateProvider handles PUT /api/v1/admin/subtitle-providers/{provider} +func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *http.Request) { + providerName := chi.URLParam(r, "provider") + + var req updateSubtitleProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + cfg := &subtitles.ProviderConfig{ + ProviderName: providerName, + Enabled: req.Enabled, + APIKey: req.APIKey, + Username: req.Username, + Password: req.Password, + } + + if err := h.repo.UpsertProviderConfig(r.Context(), cfg); err != nil { + writeError(w, http.StatusInternalServerError, "update_error", "Failed to update provider config") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"}) +} + +// HandleTestProvider handles POST /api/v1/admin/subtitle-providers/{provider}/test +func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) { + providerName := chi.URLParam(r, "provider") + + cfg, err := h.repo.GetProviderConfig(r.Context(), providerName) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": false, "error": fmt.Sprintf("Failed to load config: %v", err), + }) + return + } + if cfg == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": false, "error": "Provider not found", + }) + return + } + + provider, err := h.providerFactory(cfg) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": false, "error": err.Error(), + }) + return + } + + // Do a test search with a well-known title. + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + results, err := provider.Search(ctx, subtitles.SearchRequest{ + Title: "The Matrix", + Year: 1999, + Languages: []string{"en"}, + }) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": false, "error": err.Error(), + }) + return + } + + if len(results) == 0 { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": false, "error": "Search returned no results (credentials may be invalid)", + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) +} + +func defaultProviderFactory(cfg *subtitles.ProviderConfig) (subtitles.Provider, error) { + switch cfg.ProviderName { + case "opensubtitles": + if cfg.Username == "" || cfg.Password == "" { + return nil, fmt.Errorf("Username and password are required") + } + return opensubtitles.New(opensubtitles.Config{ + Username: cfg.Username, + Password: cfg.Password, + }), nil + case "subdl": + if cfg.APIKey == "" { + return nil, fmt.Errorf("API key is required") + } + return subdl.New(subdl.Config{APIKey: cfg.APIKey}), nil + case "subsource": + if cfg.APIKey == "" { + return nil, fmt.Errorf("API key is required") + } + return subsource.New(subsource.Config{APIKey: cfg.APIKey}), nil + default: + return nil, fmt.Errorf("Unknown provider: %s", cfg.ProviderName) + } +} + +func mergeSubtitleProviderConfigs(configs []subtitles.ProviderConfig) []subtitles.ProviderConfig { + if len(configs) == 0 { + merged := make([]subtitles.ProviderConfig, 0, len(builtinSubtitleProviders)) + for _, providerName := range builtinSubtitleProviders { + merged = append(merged, subtitles.ProviderConfig{ProviderName: providerName}) + } + return merged + } + + byName := make(map[string]subtitles.ProviderConfig, len(configs)) + for _, cfg := range configs { + byName[cfg.ProviderName] = cfg + } + + merged := make([]subtitles.ProviderConfig, 0, len(builtinSubtitleProviders)) + for _, providerName := range builtinSubtitleProviders { + if cfg, ok := byName[providerName]; ok { + merged = append(merged, cfg) + delete(byName, providerName) + continue + } + merged = append(merged, subtitles.ProviderConfig{ProviderName: providerName}) + } + + for _, cfg := range configs { + if _, ok := byName[cfg.ProviderName]; ok { + merged = append(merged, cfg) + } + } + + return merged +} diff --git a/internal/api/handlers/api_keys.go b/internal/api/handlers/api_keys.go new file mode 100644 index 00000000..f782d317 --- /dev/null +++ b/internal/api/handlers/api_keys.go @@ -0,0 +1,291 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +// APIKeyHandler handles API key management endpoints. +type APIKeyHandler struct { + repo *auth.APIKeyRepository +} + +// NewAPIKeyHandler creates a new APIKeyHandler. +func NewAPIKeyHandler(repo *auth.APIKeyRepository) *APIKeyHandler { + return &APIKeyHandler{repo: repo} +} + +// --- Request/Response types --- + +type createAPIKeyRequest struct { + Label string `json:"label"` +} + +type apiKeyResponse struct { + ID int64 `json:"id"` + UserID int `json:"user_id"` + Label string `json:"label"` + Key string `json:"key"` + RateTier string `json:"rate_tier"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + +func toAPIKeyResponse(k *models.APIKey) apiKeyResponse { + return apiKeyResponse{ + ID: k.ID, + UserID: k.UserID, + Label: k.Label, + Key: k.Key, + RateTier: k.RateTier, + CreatedAt: k.CreatedAt, + LastUsedAt: k.LastUsedAt, + } +} + +type adminApiKeyResponse struct { + ID int64 `json:"id"` + UserID int `json:"user_id"` + Username string `json:"username"` + Label string `json:"label"` + Key string `json:"key"` + RateTier string `json:"rate_tier"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + +type adminCreateAPIKeyRequest struct { + Label string `json:"label"` + UserID *int `json:"user_id,omitempty"` +} + +// requireJWTAuth checks that the request was authenticated with a JWT, not an API key. +// Returns the claims if valid, or writes a 403 and returns nil. +func requireJWTAuth(w http.ResponseWriter, r *http.Request) *auth.Claims { + claims := apimw.GetClaims(r.Context()) + if claims == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return nil + } + if claims.TokenType == auth.TokenTypeAPIKey { + writeError(w, http.StatusForbidden, "forbidden", "API key management is not accessible via API key authentication") + return nil + } + return claims +} + +// HandleCreateAPIKey handles POST /api-keys. +func (h *APIKeyHandler) HandleCreateAPIKey(w http.ResponseWriter, r *http.Request) { + claims := requireJWTAuth(w, r) + if claims == nil { + return + } + + var req createAPIKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Label == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Label is required") + return + } + + key, err := h.repo.Create(r.Context(), claims.UserID, req.Label) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create API key") + return + } + + writeJSON(w, http.StatusCreated, toAPIKeyResponse(key)) +} + +// HandleListAPIKeys handles GET /api-keys. +func (h *APIKeyHandler) HandleListAPIKeys(w http.ResponseWriter, r *http.Request) { + claims := requireJWTAuth(w, r) + if claims == nil { + return + } + + keys, err := h.repo.ListByUser(r.Context(), claims.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list API keys") + return + } + + resp := make([]apiKeyResponse, 0, len(keys)) + for _, k := range keys { + resp = append(resp, toAPIKeyResponse(k)) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleDeleteAPIKey handles DELETE /api-keys/{id}. +func (h *APIKeyHandler) HandleDeleteAPIKey(w http.ResponseWriter, r *http.Request) { + claims := requireJWTAuth(w, r) + if claims == nil { + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid API key ID") + return + } + + if err := h.repo.Delete(r.Context(), id, claims.UserID); err != nil { + if errors.Is(err, auth.ErrAPIKeyNotFound) { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete API key") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleAdminListUserAPIKeys handles GET /admin/users/{userId}/api-keys. +func (h *APIKeyHandler) HandleAdminListUserAPIKeys(w http.ResponseWriter, r *http.Request) { + userID, err := strconv.Atoi(chi.URLParam(r, "userId")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid user ID") + return + } + + keys, err := h.repo.ListByUserAdmin(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list API keys") + return + } + + resp := make([]apiKeyResponse, 0, len(keys)) + for _, k := range keys { + resp = append(resp, toAPIKeyResponse(k)) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleAdminDeleteAPIKey handles DELETE /admin/api-keys/{id}. +func (h *APIKeyHandler) HandleAdminDeleteAPIKey(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid API key ID") + return + } + + if err := h.repo.DeleteByAdmin(r.Context(), id); err != nil { + if errors.Is(err, auth.ErrAPIKeyNotFound) { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete API key") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleAdminListAllAPIKeys handles GET /admin/api-keys. +func (h *APIKeyHandler) HandleAdminListAllAPIKeys(w http.ResponseWriter, r *http.Request) { + keys, err := h.repo.ListAll(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list API keys") + return + } + + resp := make([]adminApiKeyResponse, 0, len(keys)) + for _, k := range keys { + resp = append(resp, adminApiKeyResponse{ + ID: k.ID, + UserID: k.UserID, + Username: k.Username, + Label: k.Label, + Key: k.Key, + RateTier: k.RateTier, + CreatedAt: k.CreatedAt, + LastUsedAt: k.LastUsedAt, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleAdminUpdateTier handles PUT /admin/api-keys/{id}/tier. +func (h *APIKeyHandler) HandleAdminUpdateTier(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid API key ID") + return + } + + var req struct { + Tier string `json:"tier"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Tier != "standard" && req.Tier != "elevated" { + writeError(w, http.StatusBadRequest, "bad_request", "Tier must be 'standard' or 'elevated'") + return + } + + if err := h.repo.UpdateTier(r.Context(), id, req.Tier); err != nil { + if errors.Is(err, auth.ErrAPIKeyNotFound) { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update tier") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// HandleAdminCreateAPIKey handles POST /admin/api-keys. +func (h *APIKeyHandler) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) { + claims := apimw.GetClaims(r.Context()) + if claims == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req adminCreateAPIKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Label == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Label is required") + return + } + + targetUserID := claims.UserID + if req.UserID != nil { + targetUserID = *req.UserID + } + + key, err := h.repo.Create(r.Context(), targetUserID, req.Label) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create API key") + return + } + + writeJSON(w, http.StatusCreated, toAPIKeyResponse(key)) +} diff --git a/internal/api/handlers/audio_prefs.go b/internal/api/handlers/audio_prefs.go new file mode 100644 index 00000000..47e25e11 --- /dev/null +++ b/internal/api/handlers/audio_prefs.go @@ -0,0 +1,148 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// AudioPrefHandler handles per-series audio preference endpoints. +type AudioPrefHandler struct { + storeProvider userstore.UserStoreProvider +} + +// NewAudioPrefHandler creates a new AudioPrefHandler. +func NewAudioPrefHandler(provider userstore.UserStoreProvider) *AudioPrefHandler { + return &AudioPrefHandler{storeProvider: provider} +} + +// --- Request/Response types --- + +type setAudioPrefRequest struct { + AudioTrackIndex int `json:"audio_track_index"` + AudioLanguage string `json:"audio_language"` + TrackSignature *userstore.AudioTrackSignature `json:"track_signature,omitempty"` +} + +type audioPrefResponse struct { + ProfileID string `json:"profile_id"` + SeriesID string `json:"series_id"` + AudioTrackIndex int `json:"audio_track_index"` + AudioLanguage string `json:"audio_language"` + TrackSignature *userstore.AudioTrackSignature `json:"track_signature,omitempty"` + UpdatedAt string `json:"updated_at"` +} + +// --- Handler methods --- + +// HandleGetAudioPref handles GET /audio-prefs/{series_id}. +func (h *AudioPrefHandler) HandleGetAudioPref(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + seriesID := chi.URLParam(r, "series_id") + + if seriesID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + pref, err := store.GetAudioPreference(r.Context(), profileID, seriesID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get audio preference") + return + } + + if pref == nil { + writeError(w, http.StatusNotFound, "not_found", "Audio preference not found") + return + } + + writeJSON(w, http.StatusOK, toAudioPrefResponse(*pref)) +} + +// HandleSetAudioPref handles PUT /audio-prefs/{series_id}. +func (h *AudioPrefHandler) HandleSetAudioPref(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + seriesID := chi.URLParam(r, "series_id") + + if seriesID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID is required") + return + } + + var req setAudioPrefRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + pref := userstore.AudioPreference{ + ProfileID: profileID, + SeriesID: seriesID, + AudioTrackIndex: req.AudioTrackIndex, + AudioLanguage: req.AudioLanguage, + TrackSignature: req.TrackSignature, + } + + if err := store.SetAudioPreference(r.Context(), pref); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set audio preference") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleDeleteAudioPref handles DELETE /audio-prefs/{series_id}. +func (h *AudioPrefHandler) HandleDeleteAudioPref(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + seriesID := chi.URLParam(r, "series_id") + + if seriesID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.DeleteAudioPreference(r.Context(), profileID, seriesID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete audio preference") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// --- Helpers --- + +func toAudioPrefResponse(p userstore.AudioPreference) audioPrefResponse { + return audioPrefResponse{ + ProfileID: p.ProfileID, + SeriesID: p.SeriesID, + AudioTrackIndex: p.AudioTrackIndex, + AudioLanguage: p.AudioLanguage, + TrackSignature: p.TrackSignature, + UpdatedAt: p.UpdatedAt, + } +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go new file mode 100644 index 00000000..21f31404 --- /dev/null +++ b/internal/api/handlers/auth.go @@ -0,0 +1,577 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/clientip" + "github.com/Silo-Server/silo-server/internal/models" +) + +// AuthHandler handles authentication-related HTTP endpoints. +type AuthHandler struct { + service *auth.Service + jwt *auth.JWTService + device *auth.DeviceLoginService +} + +// NewAuthHandler creates a new AuthHandler backed by the given auth, JWT, +// and device login services. +func NewAuthHandler(service *auth.Service, jwt *auth.JWTService, device *auth.DeviceLoginService) *AuthHandler { + return &AuthHandler{ + service: service, + jwt: jwt, + device: device, + } +} + +// --- Request/Response types --- + +// loginRequest represents the JSON body of a POST /auth/login request. +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Provider string `json:"provider,omitempty"` +} + +// loginResponse represents the JSON body of a successful login response. +type loginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + User userResponse `json:"user"` +} + +// setupRequest represents the JSON body of a POST /auth/setup request. +type setupRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + CreateDefaultProfile bool `json:"create_default_profile"` + DefaultProfileName string `json:"default_profile_name,omitempty"` +} + +// setupStatusResponse represents the JSON body of a GET /auth/setup request. +type setupStatusResponse struct { + NeedsSetup bool `json:"needs_setup"` +} + +// signupRequest represents the JSON body of a POST /auth/signup request. +type signupRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + InviteCode string `json:"invite_code"` + CreateDefaultProfile bool `json:"create_default_profile"` + DefaultProfileName string `json:"default_profile_name,omitempty"` +} + +// signupStatusResponse represents the JSON body of a GET /auth/signup request. +type signupStatusResponse struct { + Enabled bool `json:"enabled"` +} + +// refreshRequest represents the JSON body of a POST /auth/refresh request. +type refreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +// refreshResponse represents the JSON body of a successful refresh response. +type refreshResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +type pluginLaunchResponse struct { + ExpiresIn int `json:"expires_in"` +} + +type impersonationResponse struct { + Active bool `json:"active"` + ImpersonatorUserID int `json:"impersonator_user_id"` + ImpersonatorUsername string `json:"impersonator_username"` +} + +// userResponse represents a user in JSON responses. +type userResponse struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + DownloadAllowed bool `json:"download_allowed"` + Impersonation *impersonationResponse `json:"impersonation,omitempty"` +} + +// sessionResponse represents a session in JSON responses. +type sessionResponse struct { + ID string `json:"id"` + DeviceName string `json:"device_name"` + IPAddress string `json:"ip_address"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` +} + +// sessionsListResponse represents the JSON body of a GET /auth/sessions response. +type sessionsListResponse struct { + Sessions []sessionResponse `json:"sessions"` +} + +// errorResponse represents an error in JSON responses. +type errorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} + +type authProviderResponse struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Mode string `json:"mode"` + Default bool `json:"default"` + IconURL string `json:"icon_url,omitempty"` + InstallationID int `json:"installation_id,omitempty"` +} + +// --- Handler methods --- + +// HandleLogin handles POST /auth/login. +func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Username and password are required") + return + } + + // Extract device name from User-Agent header and IP from request. + deviceName := r.UserAgent() + ip := clientip.FromContext(r.Context()) + + pair, user, err := h.service.LoginWithProvider(r.Context(), req.Provider, req.Username, req.Password, deviceName, ip) + if err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + writeError(w, http.StatusUnauthorized, "invalid_credentials", "Invalid username or password") + return + } + if errors.Is(err, auth.ErrUserDisabled) { + writeError(w, http.StatusForbidden, "user_disabled", "User account is disabled") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + writeJSON(w, http.StatusOK, buildLoginResponse(pair, user, nil)) +} + +func (h *AuthHandler) HandleProviders(w http.ResponseWriter, r *http.Request) { + providers := h.service.ListProviders() + response := make([]authProviderResponse, 0, len(providers)) + for _, provider := range providers { + response = append(response, authProviderResponse{ + ID: provider.ID, + DisplayName: provider.DisplayName, + Mode: provider.Mode, + Default: provider.Default, + IconURL: provider.IconURL, + InstallationID: provider.InstallationID, + }) + } + writeJSON(w, http.StatusOK, response) +} + +// HandleSetupStatus handles GET /auth/setup. +func (h *AuthHandler) HandleSetupStatus(w http.ResponseWriter, r *http.Request) { + needsSetup, err := h.service.NeedsSetup(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + writeJSON(w, http.StatusOK, setupStatusResponse{ + NeedsSetup: needsSetup, + }) +} + +// HandleSetup handles POST /auth/setup. +func (h *AuthHandler) HandleSetup(w http.ResponseWriter, r *http.Request) { + var req setupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Username == "" || req.Email == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required") + return + } + + deviceName := r.UserAgent() + ip := clientip.FromContext(r.Context()) + + pair, user, err := h.service.SetupInitialUser( + r.Context(), + req.Username, + req.Email, + req.Password, + req.CreateDefaultProfile, + req.DefaultProfileName, + deviceName, + ip, + ) + if err != nil { + if errors.Is(err, auth.ErrSetupAlreadyComplete) { + writeError(w, http.StatusUnauthorized, "setup_complete", "Initial setup has already been completed") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + writeJSON(w, http.StatusCreated, buildLoginResponse(pair, user, nil)) +} + +// HandleLogout handles POST /auth/logout. Requires authentication. +func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { + claims, err := h.extractClaims(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token") + return + } + + if err := h.service.Logout(r.Context(), claims.SessionID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleEndImpersonation handles POST /auth/impersonation/end. Requires authentication. +func (h *AuthHandler) HandleEndImpersonation(w http.ResponseWriter, r *http.Request) { + claims, err := h.extractClaims(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token") + return + } + if claims.ImpersonatorUserID == nil { + writeError(w, http.StatusBadRequest, "not_impersonating", "No active impersonation session") + return + } + + if err := h.service.EndImpersonation(r.Context(), claims.SessionID, *claims.ImpersonatorUserID); err != nil { + if errors.Is(err, auth.ErrNotImpersonating) { + writeError(w, http.StatusBadRequest, "not_impersonating", "No active impersonation session") + return + } + if errors.Is(err, auth.ErrImpersonationNotAllowed) { + writeError(w, http.StatusForbidden, "impersonation_not_allowed", "Impersonation is not allowed") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleRefresh handles POST /auth/refresh. +func (h *AuthHandler) HandleRefresh(w http.ResponseWriter, r *http.Request) { + var req refreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.RefreshToken == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Refresh token is required") + return + } + + pair, err := h.service.Refresh(r.Context(), req.RefreshToken) + if err != nil { + if errors.Is(err, auth.ErrSessionRevoked) { + writeError(w, http.StatusUnauthorized, "session_revoked", "Session has been revoked") + return + } + if errors.Is(err, auth.ErrInvalidToken) || errors.Is(err, auth.ErrExpiredToken) { + writeError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired refresh token") + return + } + writeError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired refresh token") + return + } + + writeJSON(w, http.StatusOK, refreshResponse{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + ExpiresIn: pair.ExpiresIn, + }) +} + +func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request) { + claims, err := h.extractClaims(r) + if err != nil || claims.SessionID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token") + return + } + + const ttl = 5 * time.Minute + token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.PluginAccessCookieName, + Value: token, + Path: "/api/v1", + MaxAge: int(ttl.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecureRequest(r), + }) + writeJSON(w, http.StatusOK, pluginLaunchResponse{ExpiresIn: int(ttl.Seconds())}) +} + +// HandleMe handles GET /auth/me. Requires authentication. +func (h *AuthHandler) HandleMe(w http.ResponseWriter, r *http.Request) { + claims, err := h.extractClaims(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token") + return + } + + user, err := h.service.GetCurrentUser(r.Context(), claims) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + impersonator, err := h.loadImpersonator(r.Context(), claims) + if err != nil && !auth.IsNotFound(err) { + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + writeJSON(w, http.StatusOK, buildUserResponse(user, claims.ImpersonatorUserID, impersonator)) +} + +// HandleListSessions handles GET /auth/sessions. Requires authentication. +func (h *AuthHandler) HandleListSessions(w http.ResponseWriter, r *http.Request) { + claims, err := h.extractClaims(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token") + return + } + + sessions, err := h.service.GetSessions(r.Context(), claims.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + resp := sessionsListResponse{ + Sessions: make([]sessionResponse, 0, len(sessions)), + } + for _, s := range sessions { + resp.Sessions = append(resp.Sessions, sessionResponse{ + ID: s.ID, + DeviceName: s.DeviceName, + IPAddress: s.IPAddress, + CreatedAt: s.CreatedAt, + ExpiresAt: s.ExpiresAt, + RevokedAt: s.RevokedAt, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleDeleteSession handles DELETE /auth/sessions/{id}. Requires authentication. +func (h *AuthHandler) HandleDeleteSession(w http.ResponseWriter, r *http.Request) { + claims, err := h.extractClaims(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or missing authentication token") + return + } + + sessionID := chi.URLParam(r, "id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required") + return + } + + err = h.service.RevokeSession(r.Context(), sessionID, claims.UserID) + if err != nil { + if auth.IsSessionNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Session not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleSignupStatus handles GET /auth/signup. +func (h *AuthHandler) HandleSignupStatus(w http.ResponseWriter, r *http.Request) { + enabled, err := h.service.IsSignupEnabled(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + writeJSON(w, http.StatusOK, signupStatusResponse{Enabled: enabled}) +} + +// HandleSignup handles POST /auth/signup. +func (h *AuthHandler) HandleSignup(w http.ResponseWriter, r *http.Request) { + var req signupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Username == "" || req.Email == "" || req.Password == "" || req.InviteCode == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and invite code are required") + return + } + + deviceName := r.UserAgent() + ip := clientip.FromContext(r.Context()) + + pair, user, err := h.service.Signup( + r.Context(), + req.Username, + req.Email, + req.Password, + req.InviteCode, + req.CreateDefaultProfile, + req.DefaultProfileName, + deviceName, + ip, + ) + if err != nil { + if errors.Is(err, auth.ErrSignupDisabled) { + writeError(w, http.StatusForbidden, "signup_disabled", "Public signups are not currently enabled") + return + } + if errors.Is(err, auth.ErrInviteCodeNotFound) { + writeError(w, http.StatusBadRequest, "invalid_code", "Invalid invite code") + return + } + if errors.Is(err, auth.ErrInviteCodeExhausted) { + writeError(w, http.StatusBadRequest, "code_exhausted", "This invite code has reached its maximum uses") + return + } + if errors.Is(err, auth.ErrInviteCodeDisabled) { + writeError(w, http.StatusBadRequest, "code_disabled", "This invite code is no longer active") + return + } + if auth.IsDuplicate(err) { + writeError(w, http.StatusBadRequest, "duplicate", "Username or email already taken") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") + return + } + + writeJSON(w, http.StatusCreated, buildLoginResponse(pair, user, nil)) +} + +// --- Helper functions --- + +func buildLoginResponse(pair *auth.TokenPair, user *models.User, impersonator *models.User) loginResponse { + return loginResponse{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + ExpiresIn: pair.ExpiresIn, + User: buildUserResponse(user, impersonatorUserID(impersonator), impersonator), + } +} + +func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator *models.User) userResponse { + resp := userResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Role: user.Role, + DownloadAllowed: user.DownloadAllowed, + } + if impersonatorUserID != nil { + resp.Impersonation = &impersonationResponse{ + Active: true, + ImpersonatorUserID: *impersonatorUserID, + } + if impersonator != nil { + resp.Impersonation.ImpersonatorUsername = impersonator.Username + } + } + return resp +} + +func impersonatorUserID(impersonator *models.User) *int { + if impersonator == nil { + return nil + } + return &impersonator.ID +} + +func isSecureRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func (h *AuthHandler) loadImpersonator(ctx context.Context, claims *auth.Claims) (*models.User, error) { + if claims == nil || claims.ImpersonatorUserID == nil { + return nil, nil + } + return h.service.GetCurrentUser(ctx, &auth.Claims{UserID: *claims.ImpersonatorUserID}) +} + +// extractClaims extracts JWT claims from the Authorization header. +func (h *AuthHandler) extractClaims(r *http.Request) (*auth.Claims, error) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return nil, auth.ErrInvalidToken + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return nil, auth.ErrInvalidToken + } + + return h.jwt.ValidateToken(parts[1]) +} + +// writeJSON marshals the given value as JSON and writes it to the response. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// writeError writes a JSON error response with the given status code, +// error code, and message. +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, errorResponse{ + Error: code, + Message: message, + }) +} diff --git a/internal/api/handlers/auth_device.go b/internal/api/handlers/auth_device.go new file mode 100644 index 00000000..c81dec41 --- /dev/null +++ b/internal/api/handlers/auth_device.go @@ -0,0 +1,247 @@ +package handlers + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "time" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/clientip" +) + +type deviceStartRequest struct { + DeviceName string `json:"device_name"` + DevicePlatform string `json:"device_platform"` +} + +type deviceStartResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + MatchCode string `json:"match_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresAt string `json:"expires_at"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + DeviceName string `json:"device_name"` + DevicePlatform string `json:"device_platform"` +} + +type deviceLookupResponse struct { + Status string `json:"status"` + UserCode string `json:"user_code,omitempty"` + MatchCode string `json:"match_code,omitempty"` + DeviceName string `json:"device_name,omitempty"` + DevicePlatform string `json:"device_platform,omitempty"` + IPAddressHint string `json:"ip_address_hint,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +type devicePollRequest struct { + DeviceCode string `json:"device_code"` +} + +type devicePollResponse struct { + Status string `json:"status"` + PollAfter int `json:"poll_after"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + User *userResponse `json:"user,omitempty"` +} + +type deviceDecisionRequest struct { + Token string `json:"token,omitempty"` + Code string `json:"code,omitempty"` +} + +func (h *AuthHandler) HandleDeviceStart(w http.ResponseWriter, r *http.Request) { + if h.device == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured") + return + } + + var req deviceStartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + result, err := h.device.Start(r.Context(), auth.DeviceLoginStartInput{ + DeviceName: req.DeviceName, + DevicePlatform: req.DevicePlatform, + IPAddress: clientip.FromContext(r.Context()), + UserAgent: r.UserAgent(), + BaseURL: requestBaseURL(r), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start device login") + return + } + + writeJSON(w, http.StatusCreated, deviceStartResponse{ + DeviceCode: result.DeviceCode, + UserCode: result.UserCode, + MatchCode: result.MatchCode, + VerificationURI: result.VerificationURI, + VerificationURIComplete: result.VerificationURIComplete, + ExpiresAt: result.ExpiresAt.UTC().Format(time.RFC3339), + ExpiresIn: result.ExpiresIn, + Interval: result.Interval, + DeviceName: result.DeviceName, + DevicePlatform: result.DevicePlatform, + }) +} + +func (h *AuthHandler) HandleDeviceLookup(w http.ResponseWriter, r *http.Request) { + if h.device == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured") + return + } + + info, err := h.device.Lookup(r.Context(), auth.DeviceLoginLookupInput{ + BrowserCode: r.URL.Query().Get("token"), + UserCode: r.URL.Query().Get("code"), + }) + if err != nil { + if errors.Is(err, auth.ErrDeviceLoginNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Device login request not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device login") + return + } + + response := deviceLookupResponse{ + Status: info.Status, + UserCode: info.UserCode, + MatchCode: info.MatchCode, + DeviceName: info.DeviceName, + DevicePlatform: info.DevicePlatform, + IPAddressHint: info.IPAddressHint, + } + if !info.ExpiresAt.IsZero() { + response.ExpiresAt = info.ExpiresAt.UTC().Format(time.RFC3339) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *AuthHandler) HandleDevicePoll(w http.ResponseWriter, r *http.Request) { + if h.device == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured") + return + } + + var req devicePollRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.DeviceCode == "" { + writeError(w, http.StatusBadRequest, "bad_request", "device_code is required") + return + } + + result, err := h.device.Poll(r.Context(), req.DeviceCode) + if err != nil { + if errors.Is(err, auth.ErrDeviceLoginNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Device login request not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to poll device login") + return + } + + resp := devicePollResponse{ + Status: result.Status, + PollAfter: result.PollAfter, + } + if result.TokenPair != nil && result.User != nil { + resp.AccessToken = result.TokenPair.AccessToken + resp.RefreshToken = result.TokenPair.RefreshToken + resp.ExpiresIn = result.TokenPair.ExpiresIn + user := buildUserResponse(result.User, nil, nil) + resp.User = &user + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *AuthHandler) HandleDeviceApprove(w http.ResponseWriter, r *http.Request) { + if h.device == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured") + return + } + + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req deviceDecisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + err := h.device.Approve(r.Context(), auth.DeviceLoginLookupInput{ + BrowserCode: req.Token, + UserCode: req.Code, + }, userID) + if err != nil { + h.writeDeviceDecisionError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "approved"}) +} + +func (h *AuthHandler) HandleDeviceDeny(w http.ResponseWriter, r *http.Request) { + if h.device == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured") + return + } + + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req deviceDecisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + err := h.device.Deny(r.Context(), auth.DeviceLoginLookupInput{ + BrowserCode: req.Token, + UserCode: req.Code, + }) + if err != nil { + h.writeDeviceDecisionError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "denied"}) +} + +func (h *AuthHandler) writeDeviceDecisionError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, auth.ErrDeviceLoginNotFound): + writeError(w, http.StatusNotFound, "not_found", "Device login request not found") + case errors.Is(err, auth.ErrDeviceLoginExpired): + writeError(w, http.StatusGone, "expired", "Device login request has expired") + case errors.Is(err, auth.ErrDeviceLoginConsumed): + writeError(w, http.StatusConflict, "consumed", "Device login request has already been used") + case errors.Is(err, auth.ErrDeviceLoginDenied): + writeError(w, http.StatusConflict, "denied", "Device login request has already been denied") + case errors.Is(err, auth.ErrUserDisabled): + writeError(w, http.StatusForbidden, "user_disabled", "User account is disabled") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Device login request failed") + } +} diff --git a/internal/api/handlers/calendar.go b/internal/api/handlers/calendar.go new file mode 100644 index 00000000..47419ce7 --- /dev/null +++ b/internal/api/handlers/calendar.go @@ -0,0 +1,201 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type calendarRepository interface { + ListEvents(ctx context.Context, f catalog.CalendarFilter) ([]catalog.CalendarEvent, error) +} + +// CalendarHandler handles the calendar endpoint. +type CalendarHandler struct { + repo calendarRepository + detailSvc *catalog.DetailService +} + +// NewCalendarHandler creates a new CalendarHandler. +func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService) *CalendarHandler { + return &CalendarHandler{repo: repo, detailSvc: detailSvc} +} + +// --- Response types --- + +type calendarEventResponse struct { + ContentID string `json:"content_id"` + Type string `json:"type"` + Title string `json:"title"` + EpisodeTitle *string `json:"episode_title,omitempty"` + SeriesID *string `json:"series_id,omitempty"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` + AirDate string `json:"air_date"` + AirTime *string `json:"air_time,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + Badges []string `json:"badges"` +} + +type calendarDayResponse struct { + Date string `json:"date"` + Items []calendarEventResponse `json:"items"` +} + +type calendarResponse struct { + Events []calendarDayResponse `json:"events"` +} + +// HandleGetCalendar returns calendar events for a date range. +func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + start, err := time.Parse("2006-01-02", q.Get("start")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "start must be a valid date (YYYY-MM-DD)") + return + } + end, err := time.Parse("2006-01-02", q.Get("end")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "end must be a valid date (YYYY-MM-DD)") + return + } + if end.Before(start) { + writeError(w, http.StatusBadRequest, "bad_request", "end must not be before start") + return + } + if end.Sub(start) > 30*24*time.Hour { + writeError(w, http.StatusBadRequest, "bad_request", "date range cannot exceed 31 days") + return + } + + filter := q.Get("filter") + if filter == "" { + filter = "all" + } + switch filter { + case "all", "favorites", "watchlist": + default: + writeError(w, http.StatusBadRequest, "bad_request", "filter must be all, favorites, or watchlist") + return + } + + af := requestAccessFilter(r) + + if (filter == "favorites" || filter == "watchlist") && af.ProfileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "profile required for favorites/watchlist filter") + return + } + + cf := catalog.CalendarFilter{ + Start: start, + End: end, + Filter: filter, + AllowedLibraryIDs: af.AllowedLibraryIDs, + DisabledLibraryIDs: af.DisabledLibraryIDs, + MaxContentRating: af.MaxContentRating, + UserID: af.UserID, + ProfileID: af.ProfileID, + } + + if v := q.Get("library_id"); v != "" { + id, err := strconv.Atoi(v) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer") + return + } + cf.LibraryID = &id + } + + events, err := h.repo.ListEvents(r.Context(), cf) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch calendar events") + return + } + + // Group events by date and build response. + days := groupEventsByDate(events, r, h.detailSvc) + writeJSON(w, http.StatusOK, calendarResponse{Events: days}) +} + +func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService) []calendarDayResponse { + if len(events) == 0 { + return []calendarDayResponse{} + } + + posterURLs := map[string]string{} + if detailSvc != nil { + posterPaths := make([]string, 0, len(events)) + seenPosterPaths := make(map[string]struct{}, len(events)) + for _, ev := range events { + if ev.PosterPath == "" { + continue + } + if _, ok := seenPosterPaths[ev.PosterPath]; ok { + continue + } + seenPosterPaths[ev.PosterPath] = struct{}{} + posterPaths = append(posterPaths, ev.PosterPath) + } + posterURLs = detailSvc.PresignImageURLs(r.Context(), posterPaths, "poster", "small") + } + + var days []calendarDayResponse + var currentDay *calendarDayResponse + + for _, ev := range events { + dateStr := ev.AirDate.Format("2006-01-02") + + if currentDay == nil || currentDay.Date != dateStr { + if currentDay != nil { + days = append(days, *currentDay) + } + currentDay = &calendarDayResponse{Date: dateStr} + } + + badges := buildBadges(ev) + + currentDay.Items = append(currentDay.Items, calendarEventResponse{ + ContentID: ev.ContentID, + Type: ev.Type, + Title: ev.Title, + EpisodeTitle: ev.EpisodeTitle, + SeriesID: ev.SeriesID, + SeasonNumber: ev.SeasonNumber, + EpisodeNumber: ev.EpisodeNumber, + AirDate: dateStr, + AirTime: ev.AirTime, + PosterURL: posterURLs[ev.PosterPath], + PosterThumbhash: ev.PosterThumbhash, + Badges: badges, + }) + } + + if currentDay != nil { + days = append(days, *currentDay) + } + + return days +} + +func buildBadges(ev catalog.CalendarEvent) []string { + var badges []string + if ev.IsPremiere { + if ev.SeasonNumber != nil && *ev.SeasonNumber == 1 { + badges = append(badges, "series_premiere") + } else { + badges = append(badges, "season_premiere") + } + } + if ev.IsFinale { + badges = append(badges, "finale") + } + if badges == nil { + badges = []string{} + } + return badges +} diff --git a/internal/api/handlers/calendar_test.go b/internal/api/handlers/calendar_test.go new file mode 100644 index 00000000..e9813b31 --- /dev/null +++ b/internal/api/handlers/calendar_test.go @@ -0,0 +1,198 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type stubCalendarRepo struct { + calls int + last catalog.CalendarFilter + events []catalog.CalendarEvent + err error +} + +func (r *stubCalendarRepo) ListEvents(_ context.Context, f catalog.CalendarFilter) ([]catalog.CalendarEvent, error) { + r.calls++ + r.last = f + return r.events, r.err +} + +type stubCalendarImageResolver struct { + resolveURLCalls int + resolveURLsCalls int + lastPaths []string + lastVariant string + urls map[string]string +} + +func (r *stubCalendarImageResolver) ResolveImageURL(_ context.Context, path string, _ string) string { + r.resolveURLCalls++ + return r.urls[path] +} + +func (r *stubCalendarImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string { + r.resolveURLsCalls++ + r.lastPaths = append([]string(nil), paths...) + r.lastVariant = variant + + result := make(map[string]string, len(paths)) + for _, path := range paths { + if url, ok := r.urls[path]; ok { + result[path] = url + } + } + return result +} + +func TestHandleGetCalendar_RejectsEndBeforeStart(t *testing.T) { + t.Parallel() + + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-12&end=2026-04-06", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + if repo.calls != 0 { + t.Fatalf("expected repo not to be called, got %d calls", repo.calls) + } +} + +func TestHandleGetCalendar_RejectsRangesLongerThan31Days(t *testing.T) { + t.Parallel() + + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-01&end=2026-05-02", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + if repo.calls != 0 { + t.Fatalf("expected repo not to be called, got %d calls", repo.calls) + } +} + +func TestHandleGetCalendar_ReturnsEmptyEvents(t *testing.T) { + t.Parallel() + + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp calendarResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Events) != 0 { + t.Fatalf("events len = %d, want 0", len(resp.Events)) + } +} + +func TestHandleGetCalendar_GroupsEventsAndBatchResolvesCardPosters(t *testing.T) { + t.Parallel() + + repo := &stubCalendarRepo{ + events: []catalog.CalendarEvent{ + { + ContentID: "episode-1", + Type: "episode", + Title: "Series", + SeriesID: ptrString("series-1"), + SeasonNumber: ptrInt(1), + EpisodeNumber: ptrInt(1), + AirDate: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC), + PosterPath: "images/poster/original.jpg", + PosterThumbhash: "thumb-a", + IsPremiere: true, + }, + { + ContentID: "episode-2", + Type: "episode", + Title: "Series", + SeriesID: ptrString("series-1"), + SeasonNumber: ptrInt(1), + EpisodeNumber: ptrInt(2), + AirDate: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC), + PosterPath: "images/poster/original.jpg", + PosterThumbhash: "thumb-a", + IsFinale: true, + }, + }, + } + resolver := &stubCalendarImageResolver{ + urls: map[string]string{ + "images/poster/w300.jpg": "https://cdn.example/poster-card.jpg", + }, + } + detailSvc := &catalog.DetailService{} + detailSvc.SetImageResolver(resolver) + + handler := &CalendarHandler{repo: repo, detailSvc: detailSvc} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if resolver.resolveURLsCalls != 1 { + t.Fatalf("ResolveImageURLs calls = %d, want 1", resolver.resolveURLsCalls) + } + if resolver.resolveURLCalls != 0 { + t.Fatalf("ResolveImageURL calls = %d, want 0", resolver.resolveURLCalls) + } + if len(resolver.lastPaths) != 1 || resolver.lastPaths[0] != "images/poster/w300.jpg" { + t.Fatalf("lastPaths = %#v, want [images/poster/w300.jpg]", resolver.lastPaths) + } + if resolver.lastVariant != "card" { + t.Fatalf("variant = %q, want card", resolver.lastVariant) + } + + var resp calendarResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Events) != 1 { + t.Fatalf("days len = %d, want 1", len(resp.Events)) + } + if got := len(resp.Events[0].Items); got != 2 { + t.Fatalf("items len = %d, want 2", got) + } + for i, item := range resp.Events[0].Items { + if item.PosterURL != "https://cdn.example/poster-card.jpg" { + t.Fatalf("item %d poster_url = %q, want resolved card URL", i, item.PosterURL) + } + } +} + +func ptrString(value string) *string { + return &value +} + +func ptrInt(value int) *int { + return &value +} diff --git a/internal/api/handlers/catalog.go b/internal/api/handlers/catalog.go new file mode 100644 index 00000000..c4336871 --- /dev/null +++ b/internal/api/handlers/catalog.go @@ -0,0 +1,370 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/sections" +) + +type CatalogHandler struct { + resolver *catalog.CatalogResolver + itemsH *ItemsHandler +} + +func NewCatalogHandler(resolver *catalog.CatalogResolver, itemsH *ItemsHandler) *CatalogHandler { + return &CatalogHandler{ + resolver: resolver, + itemsH: itemsH, + } +} + +type catalogResponse struct { + Total int `json:"total"` + TotalExact bool `json:"total_exact"` + HasMore bool `json:"has_more"` + Items []itemListResponse `json:"items"` + Snapshot string `json:"snapshot,omitempty"` +} + +type catalogFiltersResponse struct { + Genres []string `json:"genres"` + Studios []string `json:"studios"` + Networks []string `json:"networks"` + Countries []string `json:"countries"` + OriginalLanguages []string `json:"original_languages"` + ContentRatings []string `json:"content_ratings"` + Resolutions *[]string `json:"resolutions,omitempty"` + AudioLanguages *[]string `json:"audio_languages,omitempty"` + SubtitleLanguages *[]string `json:"subtitle_languages,omitempty"` +} + +func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request) { + if h == nil || h.resolver == nil || h.itemsH == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") + return + } + + req, err := catalog.ParseCatalogRequest(r.URL.Query()) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + result, err := h.resolver.Resolve(r.Context(), req, h.itemsH.accessFilter(r)) + if err != nil { + if errors.Is(err, catalog.ErrInvalidCatalogRequest) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if errors.Is(err, catalog.ErrCatalogSourceNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Catalog source not found") + return + } + slog.Error("catalog: resolve failed", "err_msg", err.Error()) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog") + return + } + + overlaySummaries := h.itemsH.listOverlaySummaries(r.Context(), result.Items, h.itemsH.accessFilter(r)) + userStates := h.itemsH.listItemUserStates(r, result.Items) + episodeMetadata := h.itemsH.listEpisodeBrowseMetadata(r.Context(), result.Items) + items := make([]itemListResponse, 0, len(result.Items)) + for _, item := range result.Items { + resp := h.itemsH.toItemListResponseWithOverlay( + r, + item, + overlaySummaries[item.ContentID], + userStates[item.ContentID], + ) + if meta, ok := episodeMetadata[item.ContentID]; ok { + resp.SeriesTitle = meta.SeriesTitle + resp.SeasonNumber = meta.SeasonNumber + resp.EpisodeNumber = meta.EpisodeNumber + } + items = append(items, resp) + } + + var snapshot string + if !result.SnapshotAt.IsZero() { + snapshot = result.SnapshotAt.Format(time.RFC3339Nano) + } + + writeJSON(w, http.StatusOK, catalogResponse{ + Total: result.Total, + TotalExact: result.TotalExact, + HasMore: result.HasMore, + Items: items, + Snapshot: snapshot, + }) +} + +func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.Request) { + if h == nil || h.resolver == nil || h.itemsH == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") + return + } + + req, err := catalog.ParseCatalogRequest(r.URL.Query()) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + includeTechnical := parseIncludeTechnical(r.URL.Query().Get("include_technical")) + filters, err := h.resolver.ListFiltersWithOptions( + r.Context(), + req, + h.itemsH.accessFilter(r), + catalog.CatalogFilterOptions{IncludeTechnical: includeTechnical}, + ) + if err != nil { + if errors.Is(err, catalog.ErrInvalidCatalogRequest) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list catalog filters") + return + } + + var resolutions *[]string + var audioLanguages *[]string + var subtitleLanguages *[]string + if includeTechnical { + resolutions = &filters.Resolutions + audioLanguages = &filters.AudioLanguages + subtitleLanguages = &filters.SubtitleLanguages + } + + writeJSON(w, http.StatusOK, catalogFiltersResponse{ + Genres: filters.Genres, + Studios: filters.Studios, + Networks: filters.Networks, + Countries: filters.Countries, + OriginalLanguages: filters.OriginalLanguages, + ContentRatings: filters.ContentRatings, + Resolutions: resolutions, + AudioLanguages: audioLanguages, + SubtitleLanguages: subtitleLanguages, + }) +} + +func parseIncludeTechnical(raw string) bool { + if strings.TrimSpace(raw) == "" { + return true + } + includeTechnical, err := strconv.ParseBool(raw) + if err != nil { + return true + } + return includeTechnical +} + +func (h *CatalogHandler) HandlePostCatalogQuery(w http.ResponseWriter, r *http.Request) { + if h == nil || h.itemsH == nil || h.itemsH.browseRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") + return + } + + var req filterItemsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Limit <= 0 { + req.Limit = 20 + } + if req.Limit > 100 { + req.Limit = 100 + } + + fb := sections.NewFilterBuilder("mi") + filterWhere, filterArgs, err := fb.Build(req.FilterConfig) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid filter: "+err.Error()) + return + } + + accessFilter := h.itemsH.accessFilter(r) + if req.LibraryID > 0 { + accessFilter.PresentationLibraryID = &req.LibraryID + } + libraryIDs := accessFilter.AllowedLibraryIDs + + var conditions []string + args := filterArgs + argIdx := fb.ArgIdx() + + if filterWhere != "" { + conditions = append(conditions, filterWhere) + } + + disabledLibraryIDs := accessFilter.DisabledLibraryIDs + fromClause := "media_items mi" + if libraryIDs != nil || req.LibraryID > 0 || len(disabledLibraryIDs) > 0 { + fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id" + } + + if req.LibraryID > 0 { + conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx)) + args = append(args, req.LibraryID) + argIdx++ + } + + if libraryIDs != nil { + if len(libraryIDs) == 0 { + writeJSON(w, http.StatusOK, browseResponse{Items: []itemListResponse{}, Total: 0}) + return + } + conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = ANY($%d)", argIdx)) + args = append(args, libraryIDs) + argIdx++ + } + if len(disabledLibraryIDs) > 0 { + conditions = append(conditions, fmt.Sprintf("NOT (mil.media_folder_id = ANY($%d))", argIdx)) + args = append(args, disabledLibraryIDs) + argIdx++ + } + catalog.ApplySectionAccessFilter("mi", catalog.AccessFilter{MaxContentRating: accessFilter.MaxContentRating}, &conditions, &args, &argIdx) + + whereClause := "" + if len(conditions) > 0 { + whereClause = "WHERE " + strings.Join(conditions, " AND ") + } + + sortClause := "ORDER BY mi.created_at DESC" + if req.Sort != "" { + sortClause = filterSortClause(req.Sort, req.Order) + } + + // Single-pass query: COUNT(*) OVER () returns the same total on every + // row of the filtered set, so we read it once from the first scanned row + // instead of firing a separate SELECT COUNT(*). + query := fmt.Sprintf( + `SELECT %s, COUNT(*) OVER () AS total_count FROM %s %s %s LIMIT $%d OFFSET $%d`, + filterItemColumns("mi"), fromClause, whereClause, sortClause, argIdx, argIdx+1, + ) + args = append(args, req.Limit, req.Offset) + + rows, err := h.itemsH.browseRepo.Pool().Query(r.Context(), query, args...) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to query items") + return + } + defer rows.Close() + + var total int + modelItems := make([]*models.MediaItem, 0) + for rows.Next() { + var item models.MediaItem + var rowTotal int + scanErr := rows.Scan( + &item.ContentID, &item.Type, &item.Title, &item.SortTitle, &item.OriginalTitle, + &item.Year, &item.Genres, &item.ContentRating, &item.Runtime, &item.Overview, &item.Tagline, + &item.RatingIMDB, &item.RatingTMDB, &item.RatingRTCritic, &item.RatingRTAudience, + &item.ImdbID, &item.TmdbID, &item.TvdbID, + &item.PosterPath, &item.PosterThumbhash, &item.BackdropPath, &item.BackdropThumbhash, &item.LogoPath, + &item.MetadataS3Path, &item.MetadataEtag, &item.SeasonCount, + &item.Studios, &item.Networks, &item.Countries, &item.FirstAirDate, &item.LastAirDate, + &item.MatchedAt, &item.Status, &item.CreatedAt, &item.UpdatedAt, + &rowTotal, + ) + if scanErr != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan item") + return + } + modelItems = append(modelItems, &item) + total = rowTotal + } + + // COUNT(*) OVER () emits no rows when the data SELECT is empty, so total + // stays 0 even when the broader result set has matching rows (e.g. OFFSET + // past the last page). Re-query the count to give callers the real total. + // Skip when offset == 0 because in that case an empty page genuinely means + // total = 0. Mirrors the fallback in browse.go / query_executor.go / + // favorites_browse.go / item_repo.go Search. + if len(modelItems) == 0 && req.Offset > 0 { + countQuery := fmt.Sprintf( + "SELECT COUNT(*) FROM (SELECT 1 FROM %s %s) sub", + fromClause, whereClause, + ) + // Drop the trailing limit, offset args from the data query. + countArgs := args[:len(args)-2] + if err := h.itemsH.browseRepo.Pool().QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count items") + return + } + } + + userStates := h.itemsH.listItemUserStates(r, modelItems) + items := make([]itemListResponse, 0, len(modelItems)) + for _, item := range modelItems { + if h.itemsH.detailSvc != nil { + if localized, locErr := h.itemsH.detailSvc.LocalizeItemModel(r.Context(), item, accessFilter); locErr == nil && localized != nil { + item = localized + } + } + items = append(items, h.itemsH.toItemListResponseWithOverlay(r, item, nil, userStates[item.ContentID])) + } + + writeJSON(w, http.StatusOK, browseResponse{ + Total: total, + HasMore: req.Offset+req.Limit < total, + Items: items, + }) +} + +func (h *CatalogHandler) HandleLegacySearch(w http.ResponseWriter, r *http.Request) { + if h == nil || h.itemsH == nil || h.itemsH.itemRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") + return + } + + if values, ok := buildLegacySearchCatalogValues(r.URL.Query()); ok && h.resolver != nil { + h.itemsH.writeCatalogBrowseResponse(w, r, values) + return + } + + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Search query 'q' is required") + return + } + + limit := catalog.ParseIntParam(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0) + + items, total, err := h.itemsH.itemRepo.Search(r.Context(), query, parseSearchTypes(r.URL.Query()["type"]), limit, offset, h.itemsH.accessFilter(r)) + if err != nil { + slog.Error("search failed", "query", query, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Search failed") + return + } + + userStates := h.itemsH.listItemUserStates(r, items) + resp := make([]itemListResponse, 0, len(items)) + for _, item := range items { + resp = append(resp, h.itemsH.toItemListResponseWithOverlay(r, item, nil, userStates[item.ContentID])) + } + + writeJSON(w, http.StatusOK, browseResponse{ + Total: total, + HasMore: offset+len(resp) < total, + Items: resp, + }) +} diff --git a/internal/api/handlers/catalog_resources.go b/internal/api/handlers/catalog_resources.go new file mode 100644 index 00000000..404e9d42 --- /dev/null +++ b/internal/api/handlers/catalog_resources.go @@ -0,0 +1,551 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +// CatalogResourceHandler serves canonical catalog resource read routes. +type CatalogResourceHandler struct { + items *ItemsHandler +} + +// NewCatalogResourceHandler creates a new canonical catalog resource handler. +func NewCatalogResourceHandler(items *ItemsHandler) *CatalogResourceHandler { + return &CatalogResourceHandler{items: items} +} + +func (h *CatalogResourceHandler) HandleGetItemDetail(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + detail, err := h.items.detailSvc.GetItemDetail(r.Context(), id, h.items.accessFilter(r)) + if err != nil { + if isNotFound(err) { + syntheticDetail, syntheticErr := h.syntheticSeasonDetail(r, id) + if syntheticErr != nil { + if isNotFound(syntheticErr) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item detail") + return + } + h.enrichItemDetail(r, syntheticDetail) + h.items.maybeRequestStaleDetailMetadataRefresh(r.Context(), syntheticDetail) + writeJSON(w, http.StatusOK, syntheticDetail) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item detail") + return + } + + h.enrichItemDetail(r, detail) + h.items.maybeRequestStaleDetailMetadataRefresh(r.Context(), detail) + writeJSON(w, http.StatusOK, detail) +} + +func (h *CatalogResourceHandler) HandleGetItemVersions(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + detail, err := h.items.detailSvc.GetItemDetail(r.Context(), id, h.items.accessFilter(r)) + if err != nil { + if isNotFound(err) { + if _, _, ok := parseSyntheticSeasonID(id); ok { + writeJSON(w, http.StatusOK, []catalog.FileVersion{}) + return + } + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item versions") + return + } + + if !requestIsAdmin(r) { + for i := range detail.Versions { + detail.Versions[i].FilePath = "" + } + } + + writeJSON(w, http.StatusOK, detail.Versions) +} + +func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) { + filter := h.items.accessFilter(r) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + if h.items.seasonRepo == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + season, err := h.items.seasonRepo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, catalog.ErrSeasonNotFound) { + seriesID, seasonNum, ok := parseSyntheticSeasonID(id) + if !ok { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + if err := h.items.itemRepo.EnsureAccessible(r.Context(), seriesID, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes") + return + } + if err := h.items.ensurePresentationLibraryAccess(r.Context(), seriesID, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes") + return + } + episodes, err := h.items.episodeRepo.ListBySeason(r.Context(), seriesID, seasonNum) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes") + return + } + if len(episodes) == 0 { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + h.writeEpisodeResponses(w, r, episodes) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes") + return + } + if err := h.items.itemRepo.EnsureAccessible(r.Context(), season.SeriesID, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes") + return + } + if err := h.items.ensurePresentationLibraryAccess(r.Context(), season.SeriesID, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes") + return + } + + episodes, err := h.items.episodeRepo.ListBySeasonID(r.Context(), season.ContentID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item episodes") + return + } + h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes) + + resp := make([]episodeResponse, 0, len(episodes)) + fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes) + for _, ep := range episodes { + epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID]) + if h.items.fileRepo != nil { + files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID) + if fileErr == nil { + for _, f := range files { + if !catalog.FileAllowedByAccess(f, filter) { + continue + } + epResp.Files = append(epResp.Files, episodeFileResponse{ + FileID: f.ID, + Resolution: f.Resolution, + CodecVideo: f.CodecVideo, + HDR: f.HDR, + AudioChannels: f.AudioChannels, + Container: f.Container, + FileSize: f.FileSize, + }) + } + } + } + epResp.UserData = h.items.getLeafUserData(r, ep.ContentID) + resp = append(resp, epResp) + } + + writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp}) +} + +func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) { + filter := h.items.accessFilter(r) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID is required") + return + } + + if err := h.items.itemRepo.EnsureAccessible(r.Context(), id, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons") + return + } + if err := h.items.ensurePresentationLibraryAccess(r.Context(), id, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons") + return + } + + if h.items.seasonRepo != nil { + seasons, err := h.items.seasonRepo.ListBySeries(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons") + return + } + + if len(seasons) > 0 { + resp := make([]seasonResponse, 0, len(seasons)) + for _, s := range seasons { + sr := h.items.toSeasonResponse(r, id, s) + if sr.EpisodeCount == 0 { + continue + } + resp = append(resp, sr) + } + + writeJSON(w, http.StatusOK, seasonsResponse{Seasons: resp}) + return + } + } + + summaries, err := h.items.episodeRepo.ListSeasons(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons") + return + } + + resp := make([]seasonResponse, 0, len(summaries)) + for _, s := range summaries { + title := "Season " + strconv.Itoa(s.SeasonNumber) + if s.SeasonNumber == 0 { + title = "Specials" + } + episodes, _ := h.items.episodeRepo.ListBySeason(r.Context(), id, s.SeasonNumber) + resp = append(resp, seasonResponse{ + ContentID: fmt.Sprintf("%s-S%02d", id, s.SeasonNumber), + SeasonNumber: s.SeasonNumber, + IsSpecials: s.SeasonNumber == 0, + EpisodeCount: s.EpisodeCount, + Title: title, + UserData: h.items.getAggregateUserData(r, episodes), + }) + } + + writeJSON(w, http.StatusOK, seasonsResponse{Seasons: resp}) +} + +func (h *CatalogResourceHandler) HandleGetSeason(w http.ResponseWriter, r *http.Request) { + filter := h.items.accessFilter(r) + id := chi.URLParam(r, "id") + numStr := chi.URLParam(r, "num") + if id == "" || numStr == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID and season number are required") + return + } + + num, err := strconv.Atoi(numStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid season number") + return + } + + if err := h.items.itemRepo.EnsureAccessible(r.Context(), id, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season") + return + } + if err := h.items.ensurePresentationLibraryAccess(r.Context(), id, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season") + return + } + + if h.items.seasonRepo != nil { + season, err := h.items.seasonRepo.GetBySeriesAndNumber(r.Context(), id, num) + switch { + case err == nil: + episodes, _ := h.items.episodeRepo.ListBySeasonID(r.Context(), season.ContentID) + h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes) + writeJSON(w, http.StatusOK, seasonDetailResponse{ + Season: h.items.toSeasonResponse(r, id, season), + }) + return + case !errors.Is(err, catalog.ErrSeasonNotFound): + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season") + return + } + } + + episodes, err := h.items.episodeRepo.ListBySeason(r.Context(), id, num) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season") + return + } + if len(episodes) == 0 { + writeError(w, http.StatusNotFound, "not_found", "Season not found") + return + } + + title := "Season " + strconv.Itoa(num) + if num == 0 { + title = "Specials" + } + seasonID := fmt.Sprintf("%s-S%02d", id, num) + resp := seasonResponse{ + ContentID: seasonID, + SeasonNumber: num, + IsSpecials: num == 0, + Title: title, + EpisodeCount: len(episodes), + UserData: h.items.getAggregateUserData(r, episodes), + } + writeJSON(w, http.StatusOK, seasonDetailResponse{Season: resp}) +} + +func (h *CatalogResourceHandler) HandleGetEpisodes(w http.ResponseWriter, r *http.Request) { + filter := h.items.accessFilter(r) + id := chi.URLParam(r, "id") + numStr := chi.URLParam(r, "num") + if id == "" || numStr == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID and season number are required") + return + } + + num, err := strconv.Atoi(numStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid season number") + return + } + + if err := h.items.itemRepo.EnsureAccessible(r.Context(), id, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list episodes") + return + } + if err := h.items.ensurePresentationLibraryAccess(r.Context(), id, filter); err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list episodes") + return + } + + episodes, err := h.items.episodeRepo.ListBySeason(r.Context(), id, num) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list episodes") + return + } + if h.items.seasonRepo != nil { + if season, seasonErr := h.items.seasonRepo.GetBySeriesAndNumber(r.Context(), id, num); seasonErr == nil && season != nil { + h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes) + } + } + h.writeEpisodeResponses(w, r, episodes) +} + +func (h *CatalogResourceHandler) syntheticSeasonDetail(r *http.Request, seasonID string) (*catalog.ItemDetail, error) { + seriesID, seasonNum, ok := parseSyntheticSeasonID(seasonID) + if !ok { + return nil, catalog.ErrItemNotFound + } + if h.items.detailSvc == nil || h.items.episodeRepo == nil { + return nil, catalog.ErrItemNotFound + } + + seriesDetail, err := h.items.detailSvc.GetItemDetail(r.Context(), seriesID, h.items.accessFilter(r)) + if err != nil { + return nil, err + } + + episodes, err := h.items.episodeRepo.ListBySeason(r.Context(), seriesID, seasonNum) + if err != nil { + return nil, fmt.Errorf("listing season episodes: %w", err) + } + if len(episodes) == 0 { + return nil, catalog.ErrItemNotFound + } + + season := &models.Season{ + ContentID: seasonID, + SeriesID: seriesID, + SeasonNumber: seasonNum, + } + if seasonNum == 0 { + season.Title = "Specials" + } else { + season.Title = "Season " + strconv.Itoa(seasonNum) + } + seasonResp := h.items.toSeasonResponse(r, seriesID, season) + return &catalog.ItemDetail{ + ContentID: seasonID, + Type: "season", + Title: seasonResp.Title, + Overview: seasonResp.Overview, + SeriesID: seriesID, + SeriesTitle: seriesDetail.Title, + SeasonNumber: &seasonNum, + EpisodeCount: &seasonResp.EpisodeCount, + IsSpecials: seasonNum == 0, + SeasonUserData: seasonResp.UserData, + Cast: seriesDetail.Cast, + Crew: seriesDetail.Crew, + BackdropURL: seriesDetail.BackdropURL, + BackdropThumbhash: seriesDetail.BackdropThumbhash, + PosterThumbhash: seasonResp.PosterThumbhash, + Versions: []catalog.FileVersion{}, + Subtitles: []catalog.SubtitleInfo{}, + }, nil +} + +func (h *CatalogResourceHandler) writeEpisodeResponses(w http.ResponseWriter, r *http.Request, episodes []*models.Episode) { + filter := h.items.accessFilter(r) + resp := make([]episodeResponse, 0, len(episodes)) + fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes) + for _, ep := range episodes { + epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID]) + if h.items.fileRepo != nil { + files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID) + if fileErr == nil { + for _, f := range files { + if !catalog.FileAllowedByAccess(f, filter) { + continue + } + epResp.Files = append(epResp.Files, episodeFileResponse{ + FileID: f.ID, + Resolution: f.Resolution, + CodecVideo: f.CodecVideo, + HDR: f.HDR, + AudioChannels: f.AudioChannels, + Container: f.Container, + FileSize: f.FileSize, + }) + } + } + } + epResp.UserData = h.items.getLeafUserData(r, ep.ContentID) + resp = append(resp, epResp) + } + writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp}) +} + +func parseSyntheticSeasonID(contentID string) (string, int, bool) { + seriesID, seasonPart, ok := strings.Cut(contentID, "-S") + if !ok || seriesID == "" || seasonPart == "" { + return "", 0, false + } + seasonNum, err := strconv.Atoi(seasonPart) + if err != nil { + return "", 0, false + } + return seriesID, seasonNum, true +} + +func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catalog.ItemDetail) { + switch detail.Type { + case "season": + if h.items.episodeRepo != nil { + episodes, err := h.items.episodeRepo.ListBySeasonID(r.Context(), detail.ContentID) + if err == nil { + detail.SeasonUserData = h.items.getAggregateUserData(r, episodes) + } + } + case "series": + if h.items.episodeRepo != nil { + episodes, err := h.items.episodeRepo.ListBySeries(r.Context(), detail.ContentID) + if err == nil { + detail.SeasonUserData = h.items.getAggregateUserData(r, episodes) + } + } + case "movie", "episode": + detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID) + applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey) + } + + if !requestIsAdmin(r) { + for i := range detail.Versions { + detail.Versions[i].FilePath = "" + } + detail.FolderPaths = nil + } + + h.enrichViewerState(r, detail) +} + +func (h *CatalogResourceHandler) enrichViewerState(r *http.Request, detail *catalog.ItemDetail) { + store, profileID, ok := h.items.userStoreForRequest(r) + if !ok || detail == nil { + return + } + + isFavorite, err := store.IsFavorite(r.Context(), profileID, detail.ContentID) + if err != nil { + return + } + inWatchlist, err := store.InWatchlist(r.Context(), profileID, detail.ContentID) + if err != nil { + return + } + + detail.UserState = &catalog.ItemUserState{ + Played: detail.SeasonUserData != nil && detail.SeasonUserData.Played, + IsFavorite: isFavorite, + InWatchlist: inWatchlist, + } + + if h.items.ratingsRepo == nil { + return + } + + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + return + } + + rating, err := h.items.ratingsRepo.Get(r.Context(), userID, profileID, detail.ContentID) + if err != nil || rating == nil { + return + } + + detail.UserRating = &rating.Rating +} diff --git a/internal/api/handlers/catalog_seed.go b/internal/api/handlers/catalog_seed.go new file mode 100644 index 00000000..eeb000ff --- /dev/null +++ b/internal/api/handlers/catalog_seed.go @@ -0,0 +1,717 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/adminjob" + "github.com/Silo-Server/silo-server/internal/catalogseed" + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/Silo-Server/silo-server/internal/s3client" +) + +type CatalogSeedArtifactStore interface { + AdminJobArtifactStore + GetObject(ctx context.Context, bucket, key string) ([]byte, error) + UploadFile(ctx context.Context, bucket, key, path, contentType string) (int64, error) + ListObjectInfos(ctx context.Context, bucket, prefix string) ([]s3client.ObjectInfo, error) + DeleteObject(ctx context.Context, bucket, key string) error + MakeObjectPublic(ctx context.Context, bucket, key string) error + PublicURL(bucket, key string) (string, error) +} + +type CatalogSeedHandler struct { + service *catalogseed.Service + jobRepo *adminjob.Repository + store CatalogSeedArtifactStore + localImportDir string + RealtimeHub *notifications.Hub +} + +func NewCatalogSeedHandler(service *catalogseed.Service, jobRepo *adminjob.Repository, store CatalogSeedArtifactStore) *CatalogSeedHandler { + return &CatalogSeedHandler{service: service, jobRepo: jobRepo, store: store} +} + +type exportCatalogSeedRequest struct { + LibraryIDs []int `json:"library_ids"` +} + +type importCatalogSeedResponse struct { + *catalogseed.ImportResult +} + +type catalogSeedImportSource struct { + Key string `json:"key"` + SizeBytes int64 `json:"size_bytes"` + LastModified *time.Time `json:"last_modified,omitempty"` +} + +type listCatalogSeedImportSourcesResponse struct { + Sources []catalogSeedImportSource `json:"sources"` +} + +type catalogSeedErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + UnmatchedRoots []string `json:"unmatched_roots,omitempty"` +} + +const catalogSeedImportPrefix = "catalog-seeds/" +const remoteCatalogSeedTimeout = 10 * time.Minute +const catalogSeedPublishExpiry = 7 * 24 * time.Hour + +func (h *CatalogSeedHandler) HandleExport(w http.ResponseWriter, r *http.Request) { + var req exportCatalogSeedRequest + if r.Body != nil && r.ContentLength != 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + } + + data, err := h.service.Export(r.Context(), catalogseed.ExportOptions{LibraryIDs: req.LibraryIDs}) + if err != nil { + log.Printf("catalog seed export failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to export catalog seed") + return + } + + filename := "silo-catalog-seed-" + time.Now().UTC().Format("20060102T150405Z") + ".json.gz" + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func (h *CatalogSeedHandler) HandleCreateExportJob(w http.ResponseWriter, r *http.Request) { + if h.jobRepo == nil || h.store == nil { + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Catalog export jobs require the private internal S3 bucket") + return + } + + var req exportCatalogSeedRequest + if r.Body != nil && r.ContentLength != 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + } + + job, err := h.jobRepo.Create(r.Context(), adminjob.CreateJobInput{ + JobType: adminjob.JobTypeCatalogExport, + CreatedByUserID: currentAdminUserID(r), + RequestPayload: catalogseed.ExportOptions{ + LibraryIDs: req.LibraryIDs, + }, + Message: "Queued catalog export", + }) + if err != nil { + var conflict *adminjob.ActiveJobConflictError + switch { + case errors.As(err, &conflict): + var jobsHandler *AdminJobsHandler + if h.jobRepo != nil { + jobsHandler = NewAdminJobsHandler(h.jobRepo, h.store) + } + writeAdminJobConflict(w, "A catalog export is already queued or running", conflict.Job, jobsHandler, r) + default: + log.Printf("catalog seed export job creation failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue catalog export") + } + return + } + + jobsHandler := NewAdminJobsHandler(h.jobRepo, h.store) + if h.RealtimeHub != nil { + publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job) + } + writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, jobsHandler.store)) +} + +func (h *CatalogSeedHandler) HandlePublishExportJob(w http.ResponseWriter, r *http.Request) { + if h.jobRepo == nil || h.store == nil { + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Catalog export publishing requires the private internal S3 bucket") + return + } + + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Job ID is required") + return + } + + job, err := h.jobRepo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, adminjob.ErrJobNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Job not found") + return + } + log.Printf("catalog export publish load failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load catalog export job") + return + } + + if job.JobType != adminjob.JobTypeCatalogExport { + writeError(w, http.StatusBadRequest, "bad_request", "Only catalog export jobs can be published") + return + } + if job.Status != adminjob.StatusCompleted { + writeError(w, http.StatusBadRequest, "bad_request", "Only completed catalog export jobs can be published") + return + } + if job.ArtifactBucket == "" || job.ArtifactKey == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Catalog export job does not have an artifact to publish") + return + } + if job.PublicURL != "" { + writeJSON(w, http.StatusOK, adminJobToResponse(r, job, h.store)) + return + } + + publicURL, err := h.store.PresignGetURL(r.Context(), job.ArtifactBucket, job.ArtifactKey, catalogSeedPublishExpiry) + if err != nil { + log.Printf("catalog export publish failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("Failed to create catalog export URL: %v", err)) + return + } + + publishedAt := time.Now().UTC() + if err := h.jobRepo.MarkPublic(r.Context(), job.ID, publicURL, publishedAt); err != nil { + if errors.Is(err, adminjob.ErrJobNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Job not found") + return + } + log.Printf("catalog export mark public failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to persist catalog export public URL") + return + } + + job.PublicURL = publicURL + job.PublishedAt = &publishedAt + writeJSON(w, http.StatusOK, adminJobToResponse(r, job, h.store)) +} + +func (h *CatalogSeedHandler) HandleListImportSources(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Catalog imports from S3 require the private internal S3 bucket") + return + } + + objects, err := h.store.ListObjectInfos(r.Context(), h.store.Bucket(), catalogSeedImportPrefix) + if err != nil { + log.Printf("catalog seed import source listing failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list catalog seed import sources") + return + } + + sources := make([]catalogSeedImportSource, 0, len(objects)) + for _, obj := range objects { + if !strings.HasSuffix(strings.ToLower(obj.Key), ".json.gz") { + continue + } + sources = append(sources, catalogSeedImportSource{ + Key: obj.Key, + SizeBytes: obj.SizeBytes, + LastModified: obj.LastModified, + }) + } + + sort.Slice(sources, func(i, j int) bool { + left := sources[i].LastModified + right := sources[j].LastModified + switch { + case left == nil && right == nil: + return sources[i].Key > sources[j].Key + case left == nil: + return false + case right == nil: + return true + case left.Equal(*right): + return sources[i].Key > sources[j].Key + default: + return left.After(*right) + } + }) + + writeJSON(w, http.StatusOK, listCatalogSeedImportSourcesResponse{Sources: sources}) +} + +func (h *CatalogSeedHandler) HandleCreateImportJob(w http.ResponseWriter, r *http.Request) { + if h.jobRepo == nil { + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Job repository is not configured") + return + } + + if err := r.ParseMultipartForm(64 << 20); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form") + return + } + + opts, err := parseCatalogImportOptions(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + localPath := r.FormValue("local_path") + if localPath != "" { + cleaned := filepath.Clean(localPath) + abs, absErr := filepath.Abs(cleaned) + if absErr != nil || !strings.HasSuffix(strings.ToLower(abs), ".json.gz") { + writeError(w, http.StatusBadRequest, "bad_request", "Local path must point to a .json.gz file") + return + } + info, statErr := os.Stat(abs) + if statErr != nil || !info.Mode().IsRegular() { + writeError(w, http.StatusBadRequest, "bad_request", "Local file not found or not a regular file") + return + } + + job, err := h.jobRepo.Create(r.Context(), adminjob.CreateJobInput{ + JobType: adminjob.JobTypeCatalogImport, + CreatedByUserID: currentAdminUserID(r), + RequestPayload: adminjob.CatalogImportRequest{ + LocalPath: abs, + SourceLabel: filepath.Base(abs), + Options: opts, + }, + Message: "Queued catalog import from local file", + }) + if err != nil { + var conflict *adminjob.ActiveJobConflictError + if errors.As(err, &conflict) { + jobsHandler := NewAdminJobsHandler(h.jobRepo, h.store) + writeAdminJobConflict(w, "A catalog import is already queued or running", conflict.Job, jobsHandler, r) + } else { + log.Printf("catalog import job creation failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue catalog import") + } + return + } + + jobsHandler := NewAdminJobsHandler(h.jobRepo, h.store) + if h.RealtimeHub != nil { + publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job) + } + writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, jobsHandler.store)) + return + } + + remoteURL := r.FormValue("remote_url") + if remoteURL != "" { + if _, err := readImportDataFromRemoteURL(r.Context(), remoteURL); err != nil { + if errors.Is(err, errCatalogSeedImportInvalidRemoteURL) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + writeError(w, http.StatusBadRequest, "bad_request", "Failed to load catalog seed source") + return + } + + job, err := h.jobRepo.Create(r.Context(), adminjob.CreateJobInput{ + JobType: adminjob.JobTypeCatalogImport, + CreatedByUserID: currentAdminUserID(r), + RequestPayload: adminjob.CatalogImportRequest{ + RemoteURL: remoteURL, + SourceLabel: remoteURL, + Options: opts, + }, + Message: "Queued catalog import from remote URL", + }) + if err != nil { + var conflict *adminjob.ActiveJobConflictError + if errors.As(err, &conflict) { + jobsHandler := NewAdminJobsHandler(h.jobRepo, h.store) + writeAdminJobConflict(w, "A catalog import is already queued or running", conflict.Job, jobsHandler, r) + } else { + log.Printf("catalog import remote job creation failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue catalog import") + } + return + } + + jobsHandler := NewAdminJobsHandler(h.jobRepo, h.store) + if h.RealtimeHub != nil { + publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job) + } + writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, jobsHandler.store)) + return + } + + // S3-based sources — require store + if h.store == nil { + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Catalog import jobs require the private internal S3 bucket") + return + } + + sourceBucket, sourceKey, sourceLabel, cleanupSource, err := h.resolveImportJobSource(r) + if err != nil { + switch { + case errors.Is(err, errCatalogSeedImportSourceRequired), errors.Is(err, errCatalogSeedImportSourceConflict): + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + case errors.Is(err, errCatalogSeedImportSourceUnavailable): + writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error()) + case errors.Is(err, adminjob.ErrJobNotFound), errors.Is(err, s3client.ErrNotFound): + writeError(w, http.StatusBadRequest, "bad_request", "Catalog export artifact not found") + default: + log.Printf("catalog import job source failed: %v", err) + writeError(w, http.StatusBadRequest, "bad_request", "Failed to load catalog seed source") + } + return + } + + job, err := h.jobRepo.Create(r.Context(), adminjob.CreateJobInput{ + JobType: adminjob.JobTypeCatalogImport, + CreatedByUserID: currentAdminUserID(r), + RequestPayload: adminjob.CatalogImportRequest{ + SourceBucket: sourceBucket, + SourceKey: sourceKey, + SourceLabel: sourceLabel, + CleanupSource: cleanupSource, + Options: opts, + }, + Message: "Queued catalog import", + }) + if err != nil { + if cleanupSource { + _ = h.store.DeleteObject(context.Background(), sourceBucket, sourceKey) + } + var conflict *adminjob.ActiveJobConflictError + switch { + case errors.As(err, &conflict): + jobsHandler := NewAdminJobsHandler(h.jobRepo, h.store) + writeAdminJobConflict(w, "A catalog import is already queued or running", conflict.Job, jobsHandler, r) + default: + log.Printf("catalog import job creation failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue catalog import") + } + return + } + + jobsHandler := NewAdminJobsHandler(h.jobRepo, h.store) + if h.RealtimeHub != nil { + publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job) + } + writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, jobsHandler.store)) +} + +func (h *CatalogSeedHandler) HandleListLocalImportSources(w http.ResponseWriter, r *http.Request) { + dir := h.localImportDirectory() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, listCatalogSeedImportSourcesResponse{Sources: []catalogSeedImportSource{}}) + return + } + log.Printf("listing local import sources in %s: %v", dir, err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list local import sources") + return + } + + sources := make([]catalogSeedImportSource, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".json.gz") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + modTime := info.ModTime().UTC() + sources = append(sources, catalogSeedImportSource{ + Key: filepath.Join(dir, entry.Name()), + SizeBytes: info.Size(), + LastModified: &modTime, + }) + } + + sort.Slice(sources, func(i, j int) bool { + if sources[i].LastModified == nil || sources[j].LastModified == nil { + return sources[i].Key > sources[j].Key + } + return sources[i].LastModified.After(*sources[j].LastModified) + }) + + writeJSON(w, http.StatusOK, listCatalogSeedImportSourcesResponse{Sources: sources}) +} + +func (h *CatalogSeedHandler) HandleImport(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(64 << 20); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form") + return + } + + data, err := h.readImportData(r) + if err != nil { + switch { + case errors.Is(err, errCatalogSeedImportSourceRequired), errors.Is(err, errCatalogSeedImportSourceConflict): + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + case errors.Is(err, errCatalogSeedImportInvalidRemoteURL): + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + case errors.Is(err, errCatalogSeedImportInvalidLocalPath): + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + case errors.Is(err, errCatalogSeedImportSourceUnavailable): + writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error()) + case errors.Is(err, adminjob.ErrJobNotFound), errors.Is(err, s3client.ErrNotFound): + writeError(w, http.StatusBadRequest, "bad_request", "Catalog export artifact not found") + default: + log.Printf("catalog seed import source failed: %v", err) + writeError(w, http.StatusBadRequest, "bad_request", "Failed to load catalog seed source") + } + return + } + + opts, err := parseCatalogImportOptions(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + result, err := h.service.Import(r.Context(), data, opts) + if err != nil { + var unmatched *catalogseed.UnmatchedRootsError + switch { + case errors.As(err, &unmatched): + writeCatalogSeedError(w, http.StatusBadRequest, "path_rewrite_required", "Catalog seed import requires additional path rewrites", unmatched.Roots) + case errors.Is(err, catalogseed.ErrInvalidBundle): + writeError(w, http.StatusBadRequest, "bad_request", "Invalid catalog seed bundle") + case errors.Is(err, catalogseed.ErrUnsupportedBundleVersion): + writeError(w, http.StatusBadRequest, "bad_request", "Unsupported catalog seed version") + case errors.Is(err, catalogseed.ErrInvalidConflictMode): + writeError(w, http.StatusBadRequest, "bad_request", "Invalid conflict mode") + default: + log.Printf("catalog seed import failed: %v", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to import catalog seed") + } + return + } + + writeJSON(w, http.StatusOK, importCatalogSeedResponse{ImportResult: result}) +} + +func writeCatalogSeedError(w http.ResponseWriter, status int, code, message string, unmatchedRoots []string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(catalogSeedErrorResponse{ + Error: code, + Message: message, + UnmatchedRoots: unmatchedRoots, + }) +} + +const defaultLocalImportDir = "/catalog-seeds" + +var ( + errCatalogSeedImportSourceRequired = errors.New("Provide exactly one source: local_path, export_job_id, artifact_key, or remote_url") + errCatalogSeedImportSourceConflict = errors.New("Provide only one catalog seed source") + errCatalogSeedImportSourceUnavailable = errors.New("Catalog imports from S3 require the private internal S3 bucket") + errCatalogSeedImportInvalidLocalPath = errors.New("Local path must point to an existing .json.gz file") + errCatalogSeedImportInvalidRemoteURL = errors.New("Remote URL must point to an http(s) .json.gz file") +) + +func (h *CatalogSeedHandler) SetLocalImportDir(dir string) { + h.localImportDir = dir +} + +func (h *CatalogSeedHandler) localImportDirectory() string { + if h.localImportDir != "" { + return h.localImportDir + } + return defaultLocalImportDir +} + +func readLocalImportFile(localPath string) ([]byte, error) { + cleaned := filepath.Clean(localPath) + abs, err := filepath.Abs(cleaned) + if err != nil { + return nil, errCatalogSeedImportInvalidLocalPath + } + if !strings.HasSuffix(strings.ToLower(abs), ".json.gz") { + return nil, errCatalogSeedImportInvalidLocalPath + } + info, err := os.Stat(abs) + if err != nil || !info.Mode().IsRegular() { + return nil, errCatalogSeedImportInvalidLocalPath + } + data, err := os.ReadFile(abs) + if err != nil { + return nil, fmt.Errorf("reading local import file: %w", err) + } + return data, nil +} + +func (h *CatalogSeedHandler) readImportData(r *http.Request) ([]byte, error) { + localPath := r.FormValue("local_path") + jobID := r.FormValue("export_job_id") + artifactKey := r.FormValue("artifact_key") + remoteURL := r.FormValue("remote_url") + hasLocal := localPath != "" + hasJob := jobID != "" + hasArtifact := artifactKey != "" + hasRemoteURL := remoteURL != "" + sourceCount := 0 + if hasLocal { + sourceCount++ + } + if hasJob { + sourceCount++ + } + if hasArtifact { + sourceCount++ + } + if hasRemoteURL { + sourceCount++ + } + + switch { + case sourceCount == 0: + return nil, errCatalogSeedImportSourceRequired + case sourceCount > 1: + return nil, errCatalogSeedImportSourceConflict + case hasLocal: + return readLocalImportFile(localPath) + case hasRemoteURL: + return readImportDataFromRemoteURL(r.Context(), remoteURL) + case hasArtifact: + return h.readImportDataFromArtifactKey(r.Context(), artifactKey) + default: + return h.readImportDataFromExportJob(r.Context(), jobID) + } +} + +func (h *CatalogSeedHandler) readImportDataFromExportJob(ctx context.Context, jobID string) ([]byte, error) { + bucket, key, err := h.resolveExportJobArtifactRef(ctx, jobID) + if err != nil { + return nil, err + } + data, err := h.store.GetObject(ctx, bucket, key) + if err != nil { + return nil, err + } + return data, nil +} + +func (h *CatalogSeedHandler) readImportDataFromArtifactKey(ctx context.Context, artifactKey string) ([]byte, error) { + if h.store == nil { + return nil, errCatalogSeedImportSourceUnavailable + } + return h.store.GetObject(ctx, h.store.Bucket(), artifactKey) +} + +func readImportDataFromRemoteURL(ctx context.Context, remoteURL string) ([]byte, error) { + parsed, err := url.Parse(remoteURL) + if err != nil { + return nil, errCatalogSeedImportInvalidRemoteURL + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, errCatalogSeedImportInvalidRemoteURL + } + if !strings.HasSuffix(strings.ToLower(parsed.Path), ".json.gz") { + return nil, errCatalogSeedImportInvalidRemoteURL + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteURL, nil) + if err != nil { + return nil, fmt.Errorf("building remote catalog seed request: %w", err) + } + + client := &http.Client{Timeout: remoteCatalogSeedTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading remote catalog seed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading remote catalog seed: unexpected status %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading remote catalog seed: %w", err) + } + return data, nil +} + +func (h *CatalogSeedHandler) resolveImportJobSource(r *http.Request) (bucket string, key string, label string, cleanup bool, err error) { + jobID := r.FormValue("export_job_id") + artifactKey := r.FormValue("artifact_key") + remoteURL := r.FormValue("remote_url") + hasJob := jobID != "" + hasArtifact := artifactKey != "" + hasRemoteURL := remoteURL != "" + sourceCount := 0 + if hasJob { + sourceCount++ + } + if hasArtifact { + sourceCount++ + } + if hasRemoteURL { + sourceCount++ + } + + switch { + case sourceCount == 0: + return "", "", "", false, errCatalogSeedImportSourceRequired + case sourceCount > 1: + return "", "", "", false, errCatalogSeedImportSourceConflict + case hasRemoteURL: + return "", "", remoteURL, false, errCatalogSeedImportInvalidRemoteURL + case hasArtifact: + if h.store == nil { + return "", "", "", false, errCatalogSeedImportSourceUnavailable + } + return h.store.Bucket(), artifactKey, filepath.Base(artifactKey), false, nil + default: + bucket, key, resolveErr := h.resolveExportJobArtifactRef(r.Context(), jobID) + if resolveErr != nil { + return "", "", "", false, resolveErr + } + return bucket, key, "Export job " + jobID, false, nil + } +} + +func (h *CatalogSeedHandler) resolveExportJobArtifactRef(ctx context.Context, jobID string) (string, string, error) { + if h.jobRepo == nil || h.store == nil { + return "", "", errCatalogSeedImportSourceUnavailable + } + + job, err := h.jobRepo.GetByID(ctx, jobID) + if err != nil { + return "", "", err + } + if job.JobType != adminjob.JobTypeCatalogExport || job.Status != adminjob.StatusCompleted || job.ArtifactBucket == "" || job.ArtifactKey == "" { + return "", "", fmt.Errorf("catalog export job %s is not ready for import", jobID) + } + return job.ArtifactBucket, job.ArtifactKey, nil +} + +func parseCatalogImportOptions(r *http.Request) (catalogseed.ImportOptions, error) { + var rewrites []catalogseed.PathRewrite + if raw := r.FormValue("path_rewrites"); raw != "" { + if err := json.Unmarshal([]byte(raw), &rewrites); err != nil { + return catalogseed.ImportOptions{}, errors.New("Invalid path_rewrites") + } + } + + return catalogseed.ImportOptions{ + ConflictMode: catalogseed.ConflictMode(r.FormValue("conflict_mode")), + PathRewrites: rewrites, + }, nil +} diff --git a/internal/api/handlers/collection_artwork.go b/internal/api/handlers/collection_artwork.go new file mode 100644 index 00000000..d2e903ae --- /dev/null +++ b/internal/api/handlers/collection_artwork.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/Silo-Server/silo-server/internal/imageutil" + "github.com/Silo-Server/silo-server/internal/s3client" +) + +// Shared artwork helpers used by both the admin library_collections handler +// and the user collections handler. The S3 prefix differs so the two +// namespaces don't collide. +const ( + adminCollectionImagePrefix = "collection-images" + userCollectionImagePrefix = "user-collection-images" + + collectionImageMaxBytes = 10 << 20 // 10 MB +) + +// readCollectionImageMultipart reads a single image file from a multipart +// request, validating MIME type and size. +func readCollectionImageMultipart(r *http.Request, fieldName string) ([]byte, error) { + file, header, err := r.FormFile(fieldName) + if err != nil { + return nil, err + } + defer file.Close() + + switch header.Header.Get("Content-Type") { + case "image/jpeg", "image/png", "image/webp": + default: + return nil, fmt.Errorf("unsupported image type: %s", header.Header.Get("Content-Type")) + } + if header.Size > collectionImageMaxBytes { + return nil, fmt.Errorf("file exceeds 10 MB limit") + } + + data := make([]byte, header.Size) + if _, err := io.ReadFull(file, data); err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + return data, nil +} + +// downloadCollectionImageURL fetches an image from an http(s) URL with size +// limits. +func downloadCollectionImageURL(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("image source URL must use http or https") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("image source returned status %d", resp.StatusCode) + } + if resp.ContentLength > collectionImageMaxBytes { + return nil, fmt.Errorf("image exceeds 10 MB limit") + } + data, err := io.ReadAll(io.LimitReader(resp.Body, collectionImageMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading image response: %w", err) + } + if len(data) > collectionImageMaxBytes { + return nil, fmt.Errorf("image exceeds 10 MB limit") + } + return data, nil +} + +// uploadCollectionImageVariants generates resized variants for the given +// image bytes, uploads them under "{prefix}/{collectionID}/{imageType}/", and +// returns the S3 path of the original variant plus a thumbhash computed from +// the w300 variant. +func uploadCollectionImageVariants( + ctx context.Context, + s3GP *s3client.Client, + prefix, collectionID, imageType string, + fileData []byte, +) (s3Path, thumbhashStr string, err error) { + if s3GP == nil { + return "", "", fmt.Errorf("image upload requires configured S3 storage") + } + var widths []int + switch imageType { + case "poster": + widths = []int{500, 300} + case "backdrop": + widths = []int{1280, 300} + default: + return "", "", fmt.Errorf("invalid image type: %s", imageType) + } + + result, err := imageutil.GenerateVariants(fileData, widths) + if err != nil { + return "", "", fmt.Errorf("generating image variants: %w", err) + } + + bucket := s3GP.Bucket() + var w300Data []byte + for _, v := range result.Variants { + key := fmt.Sprintf("%s/%s/%s/%s%s", prefix, collectionID, imageType, v.Key, result.Ext) + if err := s3GP.PutObject(ctx, bucket, key, v.Data); err != nil { + return "", "", fmt.Errorf("uploading %s: %w", v.Key, err) + } + if v.Key == "w300" { + w300Data = v.Data + } + if v.Key == "original" { + s3Path = key + } + } + + if len(w300Data) > 0 { + thumbhashStr, err = imageutil.Thumbhash(w300Data) + if err != nil { + return "", "", fmt.Errorf("computing thumbhash: %w", err) + } + } + return s3Path, thumbhashStr, nil +} + +// removeCollectionImageVariants deletes every stored variant for the given +// collection / imageType under the supplied S3 prefix. +func removeCollectionImageVariants( + ctx context.Context, + s3GP *s3client.Client, + prefix, collectionID, imageType string, +) error { + if s3GP == nil { + return nil + } + p := fmt.Sprintf("%s/%s/%s/", prefix, collectionID, imageType) + keys, err := s3GP.ListObjects(ctx, s3GP.Bucket(), p) + if err != nil { + return fmt.Errorf("listing objects: %w", err) + } + for _, key := range keys { + if err := s3GP.DeleteObject(ctx, s3GP.Bucket(), key); err != nil { + return fmt.Errorf("deleting %s: %w", key, err) + } + } + return nil +} diff --git a/internal/api/handlers/collection_templates.go b/internal/api/handlers/collection_templates.go new file mode 100644 index 00000000..4153248f --- /dev/null +++ b/internal/api/handlers/collection_templates.go @@ -0,0 +1,29 @@ +package handlers + +import ( + "net/http" + + "github.com/Silo-Server/silo-server/internal/collections/templates" +) + +// CollectionTemplateHandler exposes the curated collection-template catalog +// to the admin UI. It is a read-only window onto templates.Default — the +// gallery uses the response to populate cards, then submits via the existing +// /admin/collections/import/* endpoints. +type CollectionTemplateHandler struct { + registry *templates.Registry +} + +// NewCollectionTemplateHandler returns a handler bound to the supplied +// registry; pass templates.Default for the built-in catalog. +func NewCollectionTemplateHandler(registry *templates.Registry) *CollectionTemplateHandler { + if registry == nil { + registry = templates.Default + } + return &CollectionTemplateHandler{registry: registry} +} + +// HandleListTemplates serves GET /admin/collections/templates. +func (h *CollectionTemplateHandler) HandleListTemplates(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, h.registry.Catalog()) +} diff --git a/internal/api/handlers/collection_templates_test.go b/internal/api/handlers/collection_templates_test.go new file mode 100644 index 00000000..1c382ab4 --- /dev/null +++ b/internal/api/handlers/collection_templates_test.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/collections/templates" +) + +func TestCollectionTemplateHandlerReturnsBuiltinCatalog(t *testing.T) { + h := NewCollectionTemplateHandler(nil) + + req := httptest.NewRequest(http.MethodGet, "/admin/collections/templates", nil) + rec := httptest.NewRecorder() + h.HandleListTemplates(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var body templates.Catalog + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Categories) == 0 { + t.Fatal("expected at least one category in response") + } + totalTemplates := 0 + for _, group := range body.Categories { + totalTemplates += len(group.Templates) + } + if totalTemplates < 5 { + t.Errorf("expected several templates, got %d", totalTemplates) + } +} + +func TestCollectionTemplateHandlerHonoursInjectedRegistry(t *testing.T) { + registry := templates.NewRegistry() + registry.Register(templates.Template{ + ID: "test_only", + Title: "Test only", + Category: templates.CategoryTrending, + Source: templates.SourceTMDB, + MediaKind: templates.MediaMovie, + TMDB: &templates.TMDBSpec{Preset: "popular", MediaType: "movie"}, + }) + + h := NewCollectionTemplateHandler(registry) + rec := httptest.NewRecorder() + h.HandleListTemplates(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + var body templates.Catalog + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Categories) != 1 || len(body.Categories[0].Templates) != 1 { + t.Fatalf("unexpected catalog shape: %+v", body) + } + if body.Categories[0].Templates[0].ID != "test_only" { + t.Errorf("got %q, want test_only", body.Categories[0].Templates[0].ID) + } +} + +func TestLibraryCollectionHandlerListsTemplateBundles(t *testing.T) { + registry := templates.NewRegistry() + registry.Register(templates.Template{ + ID: "test_template", + Title: "Test template", + Category: templates.CategoryTrending, + Source: templates.SourceTMDB, + MediaKind: templates.MediaMovie, + TMDB: &templates.TMDBSpec{Preset: "popular", MediaType: "movie"}, + }) + registry.RegisterBundle(templates.Bundle{ + ID: "test_bundle", + Title: "Test bundle", + Description: "Test description", + TemplateIDs: []string{"test_template"}, + }) + h := &LibraryCollectionHandler{TemplateRegistry: registry} + + rec := httptest.NewRecorder() + h.HandleListTemplateBundles(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + var body templates.BundleCatalog + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Bundles) != 1 || body.Bundles[0].ID != "test_bundle" { + t.Fatalf("unexpected bundle catalog: %+v", body) + } +} diff --git a/internal/api/handlers/collections.go b/internal/api/handlers/collections.go new file mode 100644 index 00000000..1259ee15 --- /dev/null +++ b/internal/api/handlers/collections.go @@ -0,0 +1,1011 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "golang.org/x/sync/errgroup" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/collectionutil" + "github.com/Silo-Server/silo-server/internal/s3client" + "github.com/Silo-Server/silo-server/internal/usercollections" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// CollectionHandler handles personal collection CRUD endpoints. +type CollectionHandler struct { + storeProvider userstore.UserStoreProvider + Executor *catalog.QueryExecutor + S3GP *s3client.Client + HTTPClient *http.Client + PresignTTL time.Duration +} + +// NewCollectionHandler creates a new CollectionHandler. +func NewCollectionHandler(provider userstore.UserStoreProvider) *CollectionHandler { + return &CollectionHandler{storeProvider: provider} +} + +// --- Request/Response types --- + +type createCollectionRequest struct { + Name string `json:"name"` + CollectionType string `json:"collection_type"` + IsShared bool `json:"is_shared"` + AllowedProfileIDs []string `json:"allowed_profile_ids"` + QueryDefinition json.RawMessage `json:"query_definition"` + SortConfig json.RawMessage `json:"sort_config"` + IncludeInServerCollections bool `json:"include_in_server_collections"` + PosterSourceURL string `json:"poster_source_url"` +} + +type updateCollectionRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + IsShared *bool `json:"is_shared"` + AllowedProfileIDs *[]string `json:"allowed_profile_ids"` + QueryDefinition json.RawMessage `json:"query_definition"` + SortConfig json.RawMessage `json:"sort_config"` + SourceURL *string `json:"source_url"` + MaxItems *int `json:"max_items"` + IncludeInServerCollections *bool `json:"include_in_server_collections"` + PosterSourceURL *string `json:"poster_source_url"` + GroupID optionalNullableString `json:"group_id"` +} + +type collectionItemRequest struct { + Position int `json:"position"` +} + +type collectionResponse struct { + ID string `json:"id"` + ProfileID string `json:"profile_id"` + CreatorProfileID string `json:"creator_profile_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CollectionType string `json:"collection_type"` + IsShared bool `json:"is_shared"` + AllowedProfileIDs []string `json:"allowed_profile_ids"` + QueryDefinition json.RawMessage `json:"query_definition"` + SortConfig json.RawMessage `json:"sort_config"` + SortOrder int `json:"sort_order"` + GroupID *string `json:"group_id"` + SourceURL string `json:"source_url,omitempty"` + SourceConfig json.RawMessage `json:"source_config,omitempty"` + SyncSchedule string `json:"sync_schedule,omitempty"` + NextSyncAt string `json:"next_sync_at,omitempty"` + LastSyncAt string `json:"last_sync_at,omitempty"` + LastSyncStatus string `json:"last_sync_status,omitempty"` + LastSyncMessage string `json:"last_sync_message,omitempty"` + ItemCount int `json:"item_count"` + IncludeInServerCollections bool `json:"include_in_server_collections"` + PosterURL string `json:"poster_url,omitempty"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type collectionListResponse struct { + Collections []collectionResponse `json:"collections"` + Groups []collectionGroupResponse `json:"groups"` +} + +type collectionGroupResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + DefaultSortMode string `json:"default_sort_mode"` + SortOrder int `json:"sort_order"` +} + +type createCollectionGroupRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + DefaultSortMode string `json:"default_sort_mode"` +} + +type updateCollectionGroupRequest struct { + Name *string `json:"name"` + Slug *string `json:"slug"` + DefaultSortMode *string `json:"default_sort_mode"` +} + +type reorderCollectionGroupsRequest struct { + OrderedIDs []string `json:"ordered_ids"` +} + +type collectionItemResponse struct { + CollectionID string `json:"collection_id"` + MediaItemID string `json:"media_item_id"` + Position int `json:"position"` + AddedAt string `json:"added_at"` +} + +type collectionItemsListResponse struct { + Items []collectionItemResponse `json:"items"` +} + +type previewCollectionRequest struct { + QueryDefinition json.RawMessage `json:"query_definition"` + Limit int `json:"limit"` +} + +type previewCollectionResponse struct { + Items []previewCollectionItemResponse `json:"items"` + Total int `json:"total"` +} + +type previewCollectionItemResponse struct { + ContentID string `json:"content_id"` + Title string `json:"title"` + Type string `json:"type"` +} + +// --- Handler methods --- + +// HandleListCollections handles GET /collections. +func (h *CollectionHandler) HandleListCollections(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + collectionsCh := make(chan []userstore.Collection, 1) + groupsCh := make(chan []userstore.CollectionGroup, 1) + eg, egCtx := errgroup.WithContext(r.Context()) + eg.Go(func() error { + collections, err := store.ListCollections(egCtx, profileID) + if err != nil { + return err + } + collectionsCh <- collections + return nil + }) + eg.Go(func() error { + groups, err := store.ListCollectionGroups(egCtx) + if err != nil { + return err + } + groupsCh <- groups + return nil + }) + if err := eg.Wait(); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list collections") + return + } + collections := <-collectionsCh + groups := <-groupsCh + + resp := collectionListResponse{ + Collections: make([]collectionResponse, 0, len(collections)), + Groups: make([]collectionGroupResponse, 0, len(groups)), + } + for _, c := range collections { + resp.Collections = append(resp.Collections, h.toCollectionResponse(r, c)) + } + for _, g := range groups { + resp.Groups = append(resp.Groups, collectionGroupResponse{ + ID: g.ID, + Name: g.Name, + Slug: g.Slug, + DefaultSortMode: string(g.DefaultSortMode), + SortOrder: g.SortOrder, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleCreateCollection handles POST /collections. +func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + var req createCollectionRequest + if err := decodeJSONOrMultipart(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Name == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection name is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + queryDefinitionJSON := defaultJSON(req.QueryDefinition) + if firstNonEmptyCollection(req.CollectionType, "manual") == "smart" || len(req.QueryDefinition) > 0 { + queryDefinitionJSON, err = normalizeQueryDefinitionJSON(queryDefinitionJSON, true, true) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + } + queryDefinition := string(queryDefinitionJSON) + sortConfig := string(defaultJSON(req.SortConfig)) + collection, err := store.CreateCollection(r.Context(), userstore.CreateCollectionInput{ + CreatorProfileID: profileID, + Name: req.Name, + CollectionType: firstNonEmptyCollection(req.CollectionType, "manual"), + IsShared: req.IsShared, + AllowedProfileIDs: req.AllowedProfileIDs, + QueryDefinition: queryDefinition, + SortConfig: sortConfig, + IncludeInServerCollections: req.IncludeInServerCollections, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection") + return + } + + if err := h.processCollectionPoster(r, store, collection.ID, profileID, req.PosterSourceURL); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + if h.posterInputProvided(r, req.PosterSourceURL) { + if refreshed, err := store.GetCollection(r.Context(), collection.ID); err == nil { + collection = refreshed + } + } + + writeJSON(w, http.StatusCreated, h.toCollectionResponse(r, *collection)) +} + +// HandleUpdateCollection handles PUT /collections/{id}. +func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + collectionID := chi.URLParam(r, "id") + + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + + var req updateCollectionRequest + if err := decodeJSONOrMultipart(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + profileID := apimw.GetProfileID(r.Context()) + input := userstore.UpdateCollectionInput{ + ID: collectionID, + RequestProfileID: profileID, + Name: req.Name, + Description: req.Description, + AllowedProfileIDs: req.AllowedProfileIDs, + IncludeInServerCollections: req.IncludeInServerCollections, + } + if len(req.QueryDefinition) > 0 { + normalized, err := normalizeQueryDefinitionJSON(req.QueryDefinition, true, true) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + value := string(normalized) + input.QueryDefinition = &value + } + if len(req.SortConfig) > 0 { + value := string(req.SortConfig) + input.SortConfig = &value + } + input.IsShared = req.IsShared + if req.GroupID.Set() { + groupID := req.GroupID.Value() + if groupID != nil && strings.TrimSpace(*groupID) == "" { + groupID = nil + } + if groupID != nil { + if err := store.EnsureCollectionGroup(r.Context(), *groupID); err != nil { + if errors.Is(err, userstore.ErrCollectionGroupNotFound) { + writeError(w, http.StatusBadRequest, "bad_request", "Collection group not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to validate collection group") + return + } + } + input.GroupID = &groupID + } + + // Source URL and max items both live inside source_config (Limit / URL). + // Load the existing collection and re-marshal so the unaffected fields + // (preset, media_type, etc.) survive untouched. + if req.SourceURL != nil || req.MaxItems != nil { + existing, err := store.GetCollection(r.Context(), collectionID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + writeError(w, http.StatusNotFound, "not_found", "Collection not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection") + return + } + cfg, err := usercollections.ParseSourceConfig(existing.SourceConfig) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse source config") + return + } + if req.MaxItems != nil { + if *req.MaxItems < 0 { + writeError(w, http.StatusBadRequest, "bad_request", "max_items must be zero or positive") + return + } + if *req.MaxItems == 0 { + cfg.Limit = nil + } else { + value := *req.MaxItems + cfg.Limit = &value + } + } + if req.SourceURL != nil { + if existing.CollectionType != "mdblist" { + writeError(w, http.StatusBadRequest, "bad_request", "source_url can only be edited for MDBList collections") + return + } + normalized := usercollections.NormalizeMDBListURL(*req.SourceURL) + if normalized == "" { + writeError(w, http.StatusBadRequest, "bad_request", "source_url is required") + return + } + cfg.URL = normalized + topURL := normalized + input.SourceURL = &topURL + } + raw, err := usercollections.MarshalSourceConfig(cfg) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to encode source config") + return + } + input.SourceConfig = &raw + } + + if err := store.UpdateCollection(r.Context(), input); err != nil { + if strings.Contains(err.Error(), "creator") { + writeError(w, http.StatusForbidden, "forbidden", "Only the creator can edit this collection") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update collection") + return + } + + posterSource := pointerStringValue(req.PosterSourceURL) + if err := h.processCollectionPoster(r, store, collectionID, profileID, posterSource); err != nil { + if errors.Is(err, errCollectionForbidden) { + writeError(w, http.StatusForbidden, "forbidden", "Only the creator can edit this collection") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + // Re-read the collection to return updated state. + collection, err := store.GetCollection(r.Context(), collectionID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + writeError(w, http.StatusNotFound, "not_found", "Collection not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve updated collection") + return + } + + writeJSON(w, http.StatusOK, h.toCollectionResponse(r, *collection)) +} + +func (h *CollectionHandler) HandlePreviewCollection(w http.ResponseWriter, r *http.Request) { + if h.Executor == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Preview is not configured") + return + } + + var req previewCollectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + var def catalog.QueryDefinition + if len(req.QueryDefinition) > 0 { + normalized, err := normalizeQueryDefinitionJSON(req.QueryDefinition, true, true) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + if err := json.Unmarshal(normalized, &def); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + } + + items, total, err := h.Executor.Preview(r.Context(), def, requestAccessFilter(r), req.Limit) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + resp := previewCollectionResponse{Items: make([]previewCollectionItemResponse, 0, len(items)), Total: total} + for _, item := range items { + resp.Items = append(resp.Items, previewCollectionItemResponse{ + ContentID: item.ContentID, + Title: item.Title, + Type: item.Type, + }) + } + writeJSON(w, http.StatusOK, resp) +} + +// HandleDeleteCollection handles DELETE /collections/{id}. +func (h *CollectionHandler) HandleDeleteCollection(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + collectionID := chi.URLParam(r, "id") + + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.DeleteCollection(r.Context(), collectionID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete collection") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleListCollectionItems handles GET /collections/{id}/items. +func (h *CollectionHandler) HandleListCollectionItems(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + collectionID := chi.URLParam(r, "id") + + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + items, err := store.ListCollectionItems(r.Context(), collectionID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list collection items") + return + } + + resp := collectionItemsListResponse{ + Items: make([]collectionItemResponse, 0, len(items)), + } + for _, ci := range items { + resp.Items = append(resp.Items, collectionItemResponse{ + CollectionID: ci.CollectionID, + MediaItemID: ci.MediaItemID, + Position: ci.Position, + AddedAt: ci.AddedAt, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleAddCollectionItem handles PUT /collections/{id}/items/{item_id}. +func (h *CollectionHandler) HandleAddCollectionItem(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + collectionID := chi.URLParam(r, "id") + itemID := chi.URLParam(r, "item_id") + + if collectionID == "" || itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID and item ID are required") + return + } + + var req collectionItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Default position to 0 if body is empty or invalid. + req.Position = 0 + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.AddCollectionItem(r.Context(), collectionID, itemID, req.Position); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add item to collection") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type reorderRequest struct { + OrderedIDs []string `json:"ordered_ids"` + // GroupID scopes the reorder to one section. nil/absent targets Ungrouped. + GroupID *string `json:"group_id,omitempty"` +} + +// HandleReorderCollections handles PUT /collections/order. +// The body must contain every collection in scope; concurrent edits that +// would silently drop one are rejected. +func (h *CollectionHandler) HandleReorderCollections(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + var req reorderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.ReorderCollections(r.Context(), profileID, req.GroupID, req.OrderedIDs); err != nil { + if errors.Is(err, collectionutil.ErrOrderedIDsMismatch) { + writeError(w, http.StatusBadRequest, "bad_request", "ordered_ids must include every visible collection in the group exactly once") + return + } + if strings.Contains(err.Error(), "ordered_ids contains duplicates") { + writeError(w, http.StatusBadRequest, "bad_request", "ordered_ids contains duplicates") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder collections") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleCreateCollectionGroup handles POST /collections/groups. +func (h *CollectionHandler) HandleCreateCollectionGroup(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + var req createCollectionGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + req.Name = strings.TrimSpace(req.Name) + req.Slug = strings.TrimSpace(req.Slug) + if req.Name == "" { + writeError(w, http.StatusBadRequest, "bad_request", "name is required") + return + } + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + sortMode := userstore.GroupSortMode(req.DefaultSortMode) + group, err := store.CreateCollectionGroup(r.Context(), req.Name, req.Slug, sortMode) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + writeJSON(w, http.StatusCreated, collectionGroupResponse{ + ID: group.ID, + Name: group.Name, + Slug: group.Slug, + DefaultSortMode: string(group.DefaultSortMode), + SortOrder: group.SortOrder, + }) +} + +// HandleUpdateCollectionGroup handles PUT /collections/groups/{id}. +func (h *CollectionHandler) HandleUpdateCollectionGroup(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "id is required") + return + } + var req updateCollectionGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + req.Name = &name + if name == "" { + writeError(w, http.StatusBadRequest, "bad_request", "name cannot be empty") + return + } + } + if req.Slug != nil { + slug := strings.TrimSpace(*req.Slug) + req.Slug = &slug + } + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + var sortMode *userstore.GroupSortMode + if req.DefaultSortMode != nil { + mode := userstore.GroupSortMode(*req.DefaultSortMode) + sortMode = &mode + } + group, err := store.UpdateCollectionGroup(r.Context(), id, req.Name, req.Slug, sortMode) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + writeJSON(w, http.StatusOK, collectionGroupResponse{ + ID: group.ID, + Name: group.Name, + Slug: group.Slug, + DefaultSortMode: string(group.DefaultSortMode), + SortOrder: group.SortOrder, + }) +} + +// HandleDeleteCollectionGroup handles DELETE /collections/groups/{id}. +func (h *CollectionHandler) HandleDeleteCollectionGroup(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "id is required") + return + } + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + if err := store.DeleteCollectionGroup(r.Context(), id); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleReorderCollectionGroups handles PUT /collections/groups/order. +func (h *CollectionHandler) HandleReorderCollectionGroups(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + var req reorderCollectionGroupsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + if err := store.ReorderCollectionGroups(r.Context(), req.OrderedIDs); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleReorderCollectionItems handles PUT /collections/{id}/items/order. +// The body must contain every item currently in the collection. +func (h *CollectionHandler) HandleReorderCollectionItems(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + collectionID := chi.URLParam(r, "id") + + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + + var req reorderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.ReorderCollectionItems(r.Context(), collectionID, req.OrderedIDs); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleRemoveCollectionItem handles DELETE /collections/{id}/items/{item_id}. +func (h *CollectionHandler) HandleRemoveCollectionItem(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + collectionID := chi.URLParam(r, "id") + itemID := chi.URLParam(r, "item_id") + + if collectionID == "" || itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID and item ID are required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.RemoveCollectionItem(r.Context(), collectionID, itemID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove item from collection") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// --- Helpers --- + +func toCollectionResponse(c userstore.Collection) collectionResponse { + queryDefinition := defaultJSON([]byte(c.QueryDefinition)) + sortConfig := defaultJSON([]byte(c.SortConfig)) + resp := collectionResponse{ + ID: c.ID, + ProfileID: c.ProfileID, + CreatorProfileID: c.CreatorProfileID, + Name: c.Name, + Description: c.Description, + CollectionType: c.CollectionType, + IsShared: c.IsShared, + AllowedProfileIDs: append([]string(nil), c.AllowedProfileIDs...), + QueryDefinition: queryDefinition, + SortConfig: sortConfig, + SortOrder: c.SortOrder, + GroupID: c.GroupID, + SourceURL: c.SourceURL, + LastSyncStatus: c.LastSyncStatus, + LastSyncMessage: c.LastSyncMessage, + ItemCount: c.ItemCount, + IncludeInServerCollections: c.IncludeInServerCollections, + PosterThumbhash: c.PosterThumbhash, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + if c.SourceConfig != "" && c.SourceConfig != "{}" { + resp.SourceConfig = json.RawMessage(c.SourceConfig) + } + if c.SyncSchedule != nil { + resp.SyncSchedule = *c.SyncSchedule + } + if c.NextSyncAt != nil { + resp.NextSyncAt = c.NextSyncAt.UTC().Format(time.RFC3339) + } + if c.LastSyncAt != nil { + resp.LastSyncAt = c.LastSyncAt.UTC().Format(time.RFC3339) + } + return resp +} + +func defaultJSON(raw []byte) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(`{}`) + } + return json.RawMessage(raw) +} + +func normalizeQueryDefinitionJSON(raw []byte, allowPersonalizedSorts, allowPersonalizedFields bool) (json.RawMessage, error) { + var def catalog.QueryDefinition + if len(raw) > 0 { + if err := json.Unmarshal(raw, &def); err != nil { + return nil, err + } + } + def = def.Normalize() + if err := def.ValidateWithOptions(allowPersonalizedSorts, allowPersonalizedFields); err != nil { + return nil, err + } + normalized, err := json.Marshal(def) + if err != nil { + return nil, err + } + return normalized, nil +} + +func firstNonEmptyCollection(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +// errCollectionForbidden is returned by the artwork pipeline when the caller +// is not the collection's creator. It surfaces as a 403 to the client. +var errCollectionForbidden = errors.New("only the creator can edit this collection") + +// HandleDeleteCollectionImage clears the poster on a personal collection. +// The query parameter "type" is required and currently only "poster" is +// supported. +func (h *CollectionHandler) HandleDeleteCollectionImage(w http.ResponseWriter, r *http.Request) { + collectionID := chi.URLParam(r, "id") + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + imageType := r.URL.Query().Get("type") + if imageType != "poster" { + writeError(w, http.StatusBadRequest, "bad_request", `type must be "poster"`) + return + } + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + collection, err := store.GetCollection(r.Context(), collectionID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "Collection not found") + return + } + if collection.CreatorProfileID != profileID { + writeError(w, http.StatusForbidden, "forbidden", "Only the creator can edit this collection") + return + } + + if err := removeCollectionImageVariants(r.Context(), h.S3GP, userCollectionImagePrefix, collectionID, imageType); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete images") + return + } + empty := "" + if err := store.UpdateCollection(r.Context(), userstore.UpdateCollectionInput{ + ID: collectionID, + RequestProfileID: profileID, + PosterURL: &empty, + PosterThumbhash: &empty, + }); err != nil { + if strings.Contains(err.Error(), "creator") { + writeError(w, http.StatusForbidden, "forbidden", "Only the creator can edit this collection") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear poster") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// posterInputProvided reports whether the request body or form contained +// poster artwork inputs that the upload pipeline would act on. +func (h *CollectionHandler) posterInputProvided(r *http.Request, sourceURL string) bool { + if strings.TrimSpace(sourceURL) != "" { + return true + } + if r.MultipartForm == nil { + return false + } + _, ok := r.MultipartForm.File["poster"] + return ok +} + +// processCollectionPoster persists a uploaded or sourced poster image on the +// given user collection. It is a no-op when no poster input was provided. +// The caller is responsible for ensuring the request profile owns the +// collection. +func (h *CollectionHandler) processCollectionPoster( + r *http.Request, + store userstore.UserStore, + collectionID, requestProfileID, sourceURL string, +) error { + source := strings.TrimSpace(sourceURL) + isMultipart := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") + + var fileData []byte + if isMultipart { + data, err := readCollectionImageMultipart(r, "poster") + switch { + case err == nil: + fileData = data + case err == http.ErrMissingFile: + // fall through to source URL handling + default: + return fmt.Errorf("poster: %w", err) + } + } + if fileData == nil { + if source == "" { + return nil + } + downloaded, err := downloadCollectionImageURL(r.Context(), h.HTTPClient, source) + if err != nil { + return fmt.Errorf("poster source: %w", err) + } + fileData = downloaded + } + + if h.S3GP == nil { + return fmt.Errorf("poster upload requires configured object storage") + } + if err := removeCollectionImageVariants(r.Context(), h.S3GP, userCollectionImagePrefix, collectionID, "poster"); err != nil { + return fmt.Errorf("clearing previous poster: %w", err) + } + s3Path, thumbhash, err := uploadCollectionImageVariants(r.Context(), h.S3GP, userCollectionImagePrefix, collectionID, "poster", fileData) + if err != nil { + return fmt.Errorf("poster: %w", err) + } + + if err := store.UpdateCollection(r.Context(), userstore.UpdateCollectionInput{ + ID: collectionID, + RequestProfileID: requestProfileID, + PosterURL: &s3Path, + PosterThumbhash: &thumbhash, + }); err != nil { + if strings.Contains(err.Error(), "creator") { + return errCollectionForbidden + } + return fmt.Errorf("persisting poster: %w", err) + } + return nil +} + +// presignUserCollectionPoster returns a presigned URL for the card-sized +// variant of the stored poster, mirroring the admin pipeline. Empty paths +// return "". +func (h *CollectionHandler) presignUserCollectionPoster(ctx context.Context, path string) string { + if path == "" { + return "" + } + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + if h.S3GP == nil { + return "" + } + ttl := h.PresignTTL + if ttl <= 0 { + ttl = 4 * time.Hour + } + url, err := h.S3GP.PresignGetURL(ctx, h.S3GP.Bucket(), cardThumbnailPath(path), ttl) + if err != nil { + return "" + } + return url +} + +// toCollectionResponse mirrors the package-level helper but presigns artwork +// URLs using the handler's S3 client. Use this method when serving HTTP +// responses; the package-level function is reserved for callers without a +// presign capability. +func (h *CollectionHandler) toCollectionResponse(r *http.Request, c userstore.Collection) collectionResponse { + resp := toCollectionResponse(c) + resp.PosterURL = h.presignUserCollectionPoster(r.Context(), c.PosterURL) + return resp +} diff --git a/internal/api/handlers/downloads.go b/internal/api/handlers/downloads.go new file mode 100644 index 00000000..25747fd0 --- /dev/null +++ b/internal/api/handlers/downloads.go @@ -0,0 +1,278 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/download" +) + +// DownloadService is the interface that the download handler depends on. +type DownloadService interface { + CreateQueued(ctx context.Context, userID int, req download.CreateRequest, filter catalog.AccessFilter) (*download.Download, error) + CreateQueuedBatch(ctx context.Context, userID int, seriesContentID string, filter catalog.AccessFilter) ([]*download.Download, string, error) + ServeDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, fileID int, filter catalog.AccessFilter) error + ServeFile(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, downloadID string) error + List(ctx context.Context, userID int) ([]*download.Download, error) + Cancel(ctx context.Context, userID int, downloadID string) error +} + +// DownloadHandler handles download endpoints. +type DownloadHandler struct { + svc DownloadService +} + +// NewDownloadHandler creates a new DownloadHandler. +func NewDownloadHandler(svc DownloadService) *DownloadHandler { + return &DownloadHandler{svc: svc} +} + +// downloadRequest represents the JSON body for POST /downloads. +type downloadRequest struct { + ContentID string `json:"content_id"` + EpisodeID string `json:"episode_id,omitempty"` + FileID int `json:"file_id,omitempty"` + Series bool `json:"series,omitempty"` // if true, downloads all episodes +} + +// downloadResponse represents a download entry in API responses. +type downloadResponse struct { + ID string `json:"id"` + ContentID string `json:"content_id"` + EpisodeID string `json:"episode_id,omitempty"` + BatchID string `json:"batch_id,omitempty"` + MediaFileID int `json:"media_file_id"` + FileSize int64 `json:"file_size"` + BytesSent int64 `json:"bytes_sent"` + Kind string `json:"kind"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + CompletedAt *string `json:"completed_at,omitempty"` +} + +// downloadsListResponse wraps the downloads list for JSON serialization. +type downloadsListResponse struct { + Downloads []downloadResponse `json:"downloads"` +} + +func toDownloadResponse(d *download.Download) downloadResponse { + resp := downloadResponse{ + ID: d.ID, + ContentID: d.ContentID, + EpisodeID: d.EpisodeID, + BatchID: d.BatchID, + MediaFileID: d.MediaFileID, + FileSize: d.FileSize, + BytesSent: d.BytesSent, + Kind: d.Kind, + Status: d.Status, + CreatedAt: d.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if d.CompletedAt != nil { + s := d.CompletedAt.Format("2006-01-02T15:04:05Z07:00") + resp.CompletedAt = &s + } + return resp +} + +// HandleCreateDownload handles POST /downloads. +func (h *DownloadHandler) HandleCreateDownload(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + if h.svc == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured") + return + } + + var req downloadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.ContentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "content_id is required") + return + } + + filter := requestAccessFilter(r) + + if req.Series { + downloads, batchID, err := h.svc.CreateQueuedBatch(r.Context(), userID, req.ContentID, filter) + if err != nil { + h.writeDownloadError(w, err) + return + } + responses := make([]downloadResponse, 0, len(downloads)) + for _, d := range downloads { + resp := toDownloadResponse(d) + resp.BatchID = batchID + responses = append(responses, resp) + } + writeJSON(w, http.StatusAccepted, downloadsListResponse{Downloads: responses}) + return + } + + dl, err := h.svc.CreateQueued(r.Context(), userID, download.CreateRequest{ + ContentID: req.ContentID, + EpisodeID: req.EpisodeID, + FileID: req.FileID, + }, filter) + if err != nil { + h.writeDownloadError(w, err) + return + } + + writeJSON(w, http.StatusAccepted, toDownloadResponse(dl)) +} + +// HandleListDownloads handles GET /downloads. +func (h *DownloadHandler) HandleListDownloads(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + if h.svc == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured") + return + } + + downloads, err := h.svc.List(r.Context(), userID) + if err != nil { + slog.Error("failed to list downloads", "user_id", userID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list downloads") + return + } + + responses := make([]downloadResponse, 0, len(downloads)) + for _, d := range downloads { + responses = append(responses, toDownloadResponse(d)) + } + writeJSON(w, http.StatusOK, downloadsListResponse{Downloads: responses}) +} + +// HandleDeleteDownload handles DELETE /downloads/{id}. +func (h *DownloadHandler) HandleDeleteDownload(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Download ID is required") + return + } + + if h.svc == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured") + return + } + + if err := h.svc.Cancel(r.Context(), userID, id); err != nil { + if errors.Is(err, download.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Download not found") + return + } + slog.Error("failed to cancel download", "download_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel download") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleDownloadFile handles GET /downloads/{id}/file. +func (h *DownloadHandler) HandleDownloadFile(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Download ID is required") + return + } + + if h.svc == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured") + return + } + + if err := h.svc.ServeFile(r.Context(), w, r, userID, id); err != nil { + if errors.Is(err, download.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Download not found") + return + } + if errors.Is(err, download.ErrDownloadNotActive) { + writeError(w, http.StatusConflict, "download_inactive", "This download is no longer active") + return + } + slog.Error("failed to serve download file", "download_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve download") + return + } +} + +// HandleDirectDownload handles GET /direct-download?file_id=N. +func (h *DownloadHandler) HandleDirectDownload(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + if h.svc == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured") + return + } + + fileIDStr := r.URL.Query().Get("file_id") + fileID, err := strconv.Atoi(fileIDStr) + if err != nil || fileID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "file_id query parameter is required") + return + } + + filter := requestAccessFilter(r) + + if err := h.svc.ServeDirect(r.Context(), w, r, userID, fileID, filter); err != nil { + h.writeDownloadError(w, err) + return + } +} + +func (h *DownloadHandler) writeDownloadError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, download.ErrFeatureDisabled): + writeError(w, http.StatusForbidden, "feature_disabled", "Downloads are disabled") + case errors.Is(err, download.ErrDownloadNotAllowed): + writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download") + case errors.Is(err, download.ErrConcurrentLimitReached): + writeError(w, http.StatusTooManyRequests, "download_limit_exceeded", "Maximum concurrent downloads reached") + case errors.Is(err, download.ErrPeriodLimitReached): + writeError(w, http.StatusTooManyRequests, "download_quota_exceeded", "Download quota exceeded for this period") + case errors.Is(err, catalog.ErrItemNotFound): + writeError(w, http.StatusNotFound, "not_found", "Media item not found") + default: + slog.Error("download operation failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process download") + } +} diff --git a/internal/api/handlers/events_publish.go b/internal/api/handlers/events_publish.go new file mode 100644 index 00000000..fcc52f5e --- /dev/null +++ b/internal/api/handlers/events_publish.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "context" + "log/slog" + + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/models" +) + +func publishEventJob(ctx context.Context, hub *evt.Hub, eventName string, job *models.AdminJob) { + if hub == nil || job == nil { + return + } + if err := hub.PublishJSON(ctx, evt.ChannelJobs, eventName, job, evt.PublishOptions{ + AdminOnly: true, + }); err != nil { + slog.Warn("events: failed to publish job event", "job_id", job.ID, "event", eventName, "error", err) + } +} + +func publishEventMetadataUpdate(ctx context.Context, hub *evt.Hub, libraryID int, contentID string) { + if hub == nil { + return + } + if err := hub.PublishJSON(ctx, evt.ChannelCatalog, "metadata.updated", map[string]any{ + "library_id": libraryID, + "content_id": contentID, + }, evt.PublishOptions{}); err != nil { + slog.Warn("events: failed to publish metadata update", "library_id", libraryID, "content_id", contentID, "error", err) + } +} diff --git a/internal/api/handlers/events_ws.go b/internal/api/handlers/events_ws.go new file mode 100644 index 00000000..d0f87b1b --- /dev/null +++ b/internal/api/handlers/events_ws.go @@ -0,0 +1,437 @@ +package handlers + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/Silo-Server/silo-server/internal/adminjob" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/historyimport" + "github.com/Silo-Server/silo-server/internal/scanqueue" + "github.com/Silo-Server/silo-server/internal/taskmanager" + "github.com/gorilla/websocket" + "github.com/oklog/ulid/v2" +) + +type historyImportActiveLister interface { + ListActiveRuns(ctx context.Context, userID int) ([]historyimport.Run, error) + ListAdminActiveRuns(ctx context.Context, sourceID *int) ([]historyimport.Run, error) +} + +type taskInfoLister interface { + ListTasks(includeHidden bool) []taskmanager.TaskInfo +} + +type activeScanLister interface { + ListActive(ctx context.Context) ([]evt.ScanRun, error) +} + +type EventsHandler struct { + hub *evt.Hub + jobs *AdminJobsHandler + admin *AdminHandler + tasks taskInfoLister + scans *evt.ScanRegistry + persistedScans activeScanLister + historyImports historyImportActiveLister +} + +func NewEventsHandler( + hub *evt.Hub, + jobs *AdminJobsHandler, + admin *AdminHandler, + tasks taskInfoLister, + scans *evt.ScanRegistry, + persistedScans *scanqueue.Service, + historyImports historyImportActiveLister, +) *EventsHandler { + return &EventsHandler{ + hub: hub, + jobs: jobs, + admin: admin, + tasks: tasks, + scans: scans, + persistedScans: persistedScans, + historyImports: historyImports, + } +} + +func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + if h == nil || h.hub == nil { + http.Error(w, "events unavailable", http.StatusServiceUnavailable) + return + } + + claims := apimw.GetClaims(r.Context()) + if claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + configureWebSocket(conn) + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + eventsCh, unsubscribe := h.hub.Subscribe() + defer unsubscribe() + startWebSocketPingLoop(ctx, func() error { + return writeWebSocketControl(conn, websocket.PingMessage, nil) + }) + + allowedChannels := allowedChannelsForRole(claims.Role) + connectionID := ulid.Make().String() + if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{ + Type: "hello", + SchemaVersion: 1, + ConnectionID: connectionID, + AvailableChannels: allowedChannels, + RequiredAction: "subscribe", + }); err != nil { + return + } + + readMessages := make(chan []byte, 8) + readDone := make(chan struct{}) + go func() { + defer close(readDone) + for { + _, data, readErr := conn.ReadMessage() + if readErr != nil { + return + } + select { + case readMessages <- data: + case <-ctx.Done(): + return + } + } + }() + + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + + subscriptions := make(map[evt.EventChannel]struct{}) + subscribedOnce := false + + for { + select { + case <-ctx.Done(): + return + case <-readDone: + return + case <-deadline.C: + if subscribedOnce { + continue + } + writeWebSocketError(conn, "bad_request", "subscribe is required within 5 seconds") + _ = writeWebSocketControl( + conn, + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "subscribe required"), + ) + return + case data := <-readMessages: + nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels) + if !ok { + return + } + if !handled { + continue + } + subscribedOnce = true + if !deadline.Stop() { + select { + case <-deadline.C: + default: + } + } + subscriptions = nextSubs + case env, ok := <-eventsCh: + if !ok { + return + } + if _, subscribed := subscriptions[env.Channel]; !subscribed { + continue + } + if !allowsEventForClaims(claims, env) { + continue + } + if err := h.writeEventFrame(conn, r, claims, env); err != nil { + return + } + } + } +} + +func (h *EventsHandler) handleEventsClientMessage( + conn *websocket.Conn, + r *http.Request, + claims *auth.Claims, + data []byte, + allowed []evt.EventChannel, +) (map[evt.EventChannel]struct{}, bool, bool) { + var base struct { + Type string `json:"type"` + } + if err := readWebSocketJSON(data, &base); err != nil { + writeWebSocketError(conn, "bad_request", "Malformed JSON") + _ = writeWebSocketControl( + conn, + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "malformed json"), + ) + return nil, false, false + } + if base.Type != "subscribe" { + writeWebSocketError(conn, "bad_request", "Unknown message type") + _ = writeWebSocketControl( + conn, + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "unknown message type"), + ) + return nil, false, false + } + + var message evt.EventsSubscribeMessage + if err := readWebSocketJSON(data, &message); err != nil { + writeWebSocketError(conn, "bad_request", "Invalid subscribe payload") + _ = writeWebSocketControl( + conn, + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "invalid subscribe payload"), + ) + return nil, false, false + } + + allowedSet := make(map[evt.EventChannel]struct{}, len(allowed)) + for _, channel := range allowed { + allowedSet[channel] = struct{}{} + } + validSet := make(map[evt.EventChannel]struct{}, len(evt.AllChannels)) + for _, channel := range evt.AllChannels { + validSet[channel] = struct{}{} + } + + nextSubs := make(map[evt.EventChannel]struct{}, len(message.Channels)) + accepted := make([]evt.EventChannel, 0, len(message.Channels)) + rejected := make([]evt.EventsRejectedChannel, 0) + + for _, channel := range message.Channels { + if _, ok := validSet[channel]; !ok { + writeWebSocketError(conn, "bad_request", "Invalid channel") + _ = writeWebSocketControl( + conn, + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "invalid channel"), + ) + return nil, false, false + } + if _, ok := allowedSet[channel]; !ok { + rejected = append(rejected, evt.EventsRejectedChannel{ + Channel: channel, + Code: "forbidden", + Message: "Admin access required", + }) + continue + } + if _, seen := nextSubs[channel]; seen { + continue + } + nextSubs[channel] = struct{}{} + accepted = append(accepted, channel) + } + + if err := writeWebSocketJSON(conn, evt.EventsSubscribedMessage{ + Type: "subscribed", + RequestID: message.RequestID, + Channels: accepted, + Rejected: rejected, + }); err != nil { + return nil, false, false + } + + for _, channel := range accepted { + if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil { + return nil, false, false + } + } + + return nextSubs, true, true +} + +func allowedChannelsForRole(role string) []evt.EventChannel { + channels := []evt.EventChannel{ + evt.ChannelCatalog, + evt.ChannelHistoryImport, + evt.ChannelUserState, + } + if role == "admin" { + channels = append(channels, + evt.ChannelJobs, + evt.ChannelSessions, + evt.ChannelTasks, + evt.ChannelScans, + ) + } + return channels +} + +func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool { + if claims == nil { + return false + } + if env.AdminOnly && claims.Role != "admin" { + return false + } + if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID { + return false + } + return true +} + +func marshalJSON(value any) json.RawMessage { + if value == nil { + return json.RawMessage("null") + } + data, err := json.Marshal(value) + if err != nil { + return json.RawMessage("null") + } + return data +} + +func (h *EventsHandler) snapshotForChannel( + r *http.Request, + claims *auth.Claims, + channel evt.EventChannel, +) (json.RawMessage, error) { + switch channel { + case evt.ChannelCatalog, evt.ChannelUserState: + return json.RawMessage("null"), nil + case evt.ChannelJobs: + if h == nil || h.jobs == nil || h.jobs.repo == nil { + return json.RawMessage("[]"), nil + } + jobs, err := h.jobs.repo.List(r.Context(), adminjob.ListJobsOptions{Limit: 50}) + if err != nil { + return nil, err + } + response := make([]adminJobResponse, 0, len(jobs)) + for _, job := range jobs { + response = append(response, adminJobToResponse(r, job, h.jobs.store)) + } + return marshalJSON(response), nil + case evt.ChannelSessions: + if h == nil || h.admin == nil { + return json.RawMessage("[]"), nil + } + sessions, err := h.admin.loadPlaybackSessions(r.Context(), r) + if err != nil { + return nil, err + } + return marshalJSON(sessions), nil + case evt.ChannelTasks: + if h == nil || h.tasks == nil { + return json.RawMessage("[]"), nil + } + return marshalJSON(h.tasks.ListTasks(false)), nil + case evt.ChannelScans: + if h == nil { + return json.RawMessage("[]"), nil + } + runs := make([]evt.ScanRun, 0) + if h.persistedScans != nil { + persisted, err := h.persistedScans.ListActive(r.Context()) + if err != nil { + return nil, err + } + runs = append(runs, persisted...) + } + if h.scans != nil { + runs = append(runs, h.scans.ListActive()...) + } + return marshalJSON(runs), nil + case evt.ChannelHistoryImport: + if h == nil || h.historyImports == nil { + return json.RawMessage("[]"), nil + } + if claims != nil && claims.Role == "admin" { + runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil) + if err != nil { + return nil, err + } + return marshalJSON(runs), nil + } + runs, err := h.historyImports.ListActiveRuns(r.Context(), claims.UserID) + if err != nil { + return nil, err + } + return marshalJSON(runs), nil + default: + return json.RawMessage("null"), nil + } +} + +func (h *EventsHandler) writeSnapshotFrame( + conn *websocket.Conn, + r *http.Request, + claims *auth.Claims, + channel evt.EventChannel, +) error { + data, err := h.snapshotForChannel(r, claims, channel) + if err != nil { + writeWebSocketError(conn, "internal_error", "Failed to load snapshot") + _ = writeWebSocketControl( + conn, + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "snapshot failed"), + ) + return err + } + return writeWebSocketJSON(conn, evt.EventsSnapshotMessage{ + Type: "snapshot", + Channel: channel, + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Data: data, + }) +} + +func (h *EventsHandler) writeEventFrame( + conn *websocket.Conn, + r *http.Request, + claims *auth.Claims, + env evt.Envelope, +) error { + data := env.Data + if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") { + snapshot, err := h.snapshotForChannel(r, claims, env.Channel) + if err != nil { + slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err) + return err + } + data = snapshot + } + if len(data) == 0 { + data = json.RawMessage("null") + } + + return writeWebSocketJSON(conn, evt.EventsEventMessage{ + Type: "event", + Channel: env.Channel, + Event: env.Event, + EventID: env.EventID, + Timestamp: env.Timestamp.UTC().Format(time.RFC3339Nano), + Data: data, + }) +} diff --git a/internal/api/handlers/favorites.go b/internal/api/handlers/favorites.go new file mode 100644 index 00000000..4d7abd54 --- /dev/null +++ b/internal/api/handlers/favorites.go @@ -0,0 +1,803 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/watchsync" +) + +type personalDataItemRepository interface { + GetByID(ctx context.Context, contentID string) (*models.MediaItem, error) + GetByIDs(ctx context.Context, contentIDs []string) ([]*models.MediaItem, error) + EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error +} + +type LocalFavoriteEventDispatcher interface { + HandleLocalFavoriteEvent(ctx context.Context, event watchsync.LocalFavoriteEvent) error +} + +// PersonalDataHandler handles favorites, watchlist, and history endpoints. +type PersonalDataHandler struct { + storeProvider userstore.UserStoreProvider + itemRepo personalDataItemRepository + episodeRepo *catalog.EpisodeRepository + seasonRepo *catalog.SeasonRepository + detailSvc *catalog.DetailService + EventsHub *evt.Hub + localFavoriteDispatcher LocalFavoriteEventDispatcher + profileStaler ProfileStaler + profileRefreshRequester ProfileRefreshRequester +} + +// NewPersonalDataHandler creates a new PersonalDataHandler. +func NewPersonalDataHandler(provider userstore.UserStoreProvider, itemRepo personalDataItemRepository) *PersonalDataHandler { + return &PersonalDataHandler{ + storeProvider: provider, + itemRepo: itemRepo, + } +} + +// SetDetailService configures the detail service for image URL resolution. +func (h *PersonalDataHandler) SetDetailService(svc *catalog.DetailService) { + h.detailSvc = svc +} + +// SetProfileStaler configures an optional staleness trigger for taste profiles. +func (h *PersonalDataHandler) SetProfileStaler(ps ProfileStaler) { + h.profileStaler = ps +} + +// SetProfileRefreshRequester configures an optional background refresh queue for taste profiles. +func (h *PersonalDataHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) { + h.profileRefreshRequester = requester +} + +func (h *PersonalDataHandler) SetEpisodeRepo(repo *catalog.EpisodeRepository) { + h.episodeRepo = repo +} + +func (h *PersonalDataHandler) SetSeasonRepo(repo *catalog.SeasonRepository) { + h.seasonRepo = repo +} + +func (h *PersonalDataHandler) SetLocalFavoriteEventDispatcher(dispatcher LocalFavoriteEventDispatcher) { + h.localFavoriteDispatcher = dispatcher +} + +// --- Response types --- + +type favoriteResponse struct { + MediaItemID string `json:"media_item_id"` + AddedAt string `json:"added_at"` +} + +type favoriteListResponse struct { + Favorites []favoriteResponse `json:"favorites"` +} + +type watchlistEntryResponse struct { + MediaItemID string `json:"media_item_id"` + AddedAt string `json:"added_at"` +} + +type watchlistListResponse struct { + Watchlist []watchlistEntryResponse `json:"watchlist"` +} + +type historyEntryResponse struct { + ID string `json:"id"` + MediaItemID string `json:"media_item_id"` + WatchedAt string `json:"watched_at"` + DurationSeconds float64 `json:"duration_seconds"` + Completed bool `json:"completed"` +} + +type historyListResponse struct { + History []historyEntryResponse `json:"history"` +} + +type historyRemovalTargetRequest struct { + ContentID string `json:"content_id"` + Scope string `json:"scope"` +} + +type removeHistoryRequest struct { + Targets []historyRemovalTargetRequest `json:"targets"` +} + +const ( + historyRemovalScopeItem = "item" + historyRemovalScopeShow = "show" +) + +// --- Favorites --- + +// HandleListFavorites handles GET /favorites. +func (h *PersonalDataHandler) HandleListFavorites(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + limit, offset := parsePagination(r) + + favorites, err := store.ListFavorites(r.Context(), profileID, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list favorites") + return + } + + items, err := resolveItems(h, r, favorites, func(f userstore.Favorite) string { return f.MediaItemID }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve favorite items") + return + } + + writeJSON(w, http.StatusOK, itemsListResponse{Items: items}) +} + +// HandleCheckFavorite handles GET /favorites/{item_id}. +// Returns 204 if the item is a favorite, 404 if not. +func (h *PersonalDataHandler) HandleCheckFavorite(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + if err := h.ensureAccessibleItem(r, itemID); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + ok, err := store.IsFavorite(r.Context(), profileID, itemID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check favorite") + return + } + + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleAddFavorite handles PUT /favorites/{item_id}. +func (h *PersonalDataHandler) HandleAddFavorite(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + if err := h.ensureAccessibleItem(r, itemID); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + if err := store.AddFavorite(r.Context(), profileID, itemID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add favorite") + return + } + + h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventAdded, userID, profileID, itemID) + triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", "favorite.updated") + w.WriteHeader(http.StatusNoContent) +} + +// HandleRemoveFavorite handles DELETE /favorites/{item_id}. +func (h *PersonalDataHandler) HandleRemoveFavorite(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.RemoveFavorite(r.Context(), profileID, itemID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove favorite") + return + } + + h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventRemoved, userID, profileID, itemID) + triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", "favorite.updated") + w.WriteHeader(http.StatusNoContent) +} + +func (h *PersonalDataHandler) dispatchLocalFavoriteEvent(ctx context.Context, kind watchsync.LocalFavoriteEventKind, userID int, profileID string, itemID string) { + if h == nil || h.localFavoriteDispatcher == nil || h.itemRepo == nil { + return + } + item, err := h.itemRepo.GetByID(ctx, itemID) + if err != nil || item == nil { + return + } + favorite := watchsync.LocalFavorite{ + MediaItemID: item.ContentID, + Kind: item.Type, + Title: item.Title, + Year: item.Year, + IMDbID: item.ImdbID, + TMDBID: item.TmdbID, + TVDBID: item.TvdbID, + FavoritedAt: time.Now().UTC(), + } + _ = h.localFavoriteDispatcher.HandleLocalFavoriteEvent(ctx, watchsync.LocalFavoriteEvent{ + Kind: kind, + UserID: userID, + ProfileID: profileID, + Favorites: []watchsync.LocalFavorite{favorite}, + }) +} + +// --- Watchlist --- + +// HandleListWatchlist handles GET /watchlist. +func (h *PersonalDataHandler) HandleListWatchlist(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + limit, offset := parsePagination(r) + + entries, err := store.ListWatchlist(r.Context(), profileID, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list watchlist") + return + } + + items, err := resolveItems(h, r, entries, func(e userstore.WatchlistEntry) string { return e.MediaItemID }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve watchlist items") + return + } + + writeJSON(w, http.StatusOK, itemsListResponse{Items: items}) +} + +// HandleCheckWatchlist handles GET /watchlist/{item_id}. +// Returns 204 if the item is on the watchlist, 404 if not. +func (h *PersonalDataHandler) HandleCheckWatchlist(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + if err := h.ensureAccessibleItem(r, itemID); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + ok, err := store.InWatchlist(r.Context(), profileID, itemID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check watchlist") + return + } + + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleAddToWatchlist handles PUT /watchlist/{item_id}. +func (h *PersonalDataHandler) HandleAddToWatchlist(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + if err := h.ensureAccessibleItem(r, itemID); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + if err := store.AddToWatchlist(r.Context(), profileID, itemID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add to watchlist") + return + } + + triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", "watchlist.updated") + w.WriteHeader(http.StatusNoContent) +} + +// HandleRemoveFromWatchlist handles DELETE /watchlist/{item_id}. +func (h *PersonalDataHandler) HandleRemoveFromWatchlist(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.RemoveFromWatchlist(r.Context(), profileID, itemID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove from watchlist") + return + } + + triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", "watchlist.updated") + w.WriteHeader(http.StatusNoContent) +} + +// --- History --- + +// HandleListHistory handles GET /history. +func (h *PersonalDataHandler) HandleListHistory(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + limit, offset := parsePagination(r) + + entries, err := store.ListHistory(r.Context(), profileID, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list history") + return + } + + ids, err := catalog.ResolveHistoryDisplayIDs(r.Context(), entries, h.episodeRepo) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve history items") + return + } + + items, err := resolveItemsByIDs(h, r, ids) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve history items") + return + } + + writeJSON(w, http.StatusOK, itemsListResponse{Items: items}) +} + +// HandleRemoveHistory handles POST /history/remove. +func (h *PersonalDataHandler) HandleRemoveHistory(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + var req removeHistoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if len(req.Targets) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "At least one history target is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + filter := requestAccessFilter(r) + mediaItemSet := make(map[string]struct{}) + mediaItemIDs := make([]string, 0, len(req.Targets)) + for _, target := range req.Targets { + resolvedIDs, resolveErr := h.resolveHistoryRemovalMediaItemIDs(r.Context(), target, filter) + if resolveErr != nil { + switch { + case isNotFound(resolveErr): + writeError(w, http.StatusNotFound, "not_found", "History target not found") + default: + writeError(w, http.StatusBadRequest, "bad_request", resolveErr.Error()) + } + return + } + for _, mediaItemID := range resolvedIDs { + if _, ok := mediaItemSet[mediaItemID]; ok { + continue + } + mediaItemSet[mediaItemID] = struct{}{} + mediaItemIDs = append(mediaItemIDs, mediaItemID) + } + } + + if err := store.RemoveHistoryItems(r.Context(), profileID, mediaItemIDs, time.Now().UTC()); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove history") + return + } + + triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) + for _, mediaItemID := range mediaItemIDs { + publishUserStateEvent( + r.Context(), + h.EventsHub, + userID, + profileID, + mediaItemID, + "", + "history", + "history.updated", + ) + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Helpers --- + +// itemsListResponse wraps a list of items for favorites/watchlist responses. +type itemsListResponse struct { + Items []itemListResponse `json:"items"` +} + +// resolveItems fetches full media item data for a list of entries. +// It preserves the order of the input slice and silently omits items not found in the catalog. +func resolveItems[T any](h *PersonalDataHandler, r *http.Request, entries []T, getID func(T) string) ([]itemListResponse, error) { + if len(entries) == 0 || h.itemRepo == nil { + return []itemListResponse{}, nil + } + + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = getID(e) + } + return resolveItemsByIDs(h, r, ids) +} + +func resolveItemsByIDs(h *PersonalDataHandler, r *http.Request, ids []string) ([]itemListResponse, error) { + if len(ids) == 0 || h.itemRepo == nil { + return []itemListResponse{}, nil + } + mediaItems, err := h.itemRepo.GetByIDs(r.Context(), ids) + if err != nil { + return nil, err + } + + // Index by content ID for order-preserving lookup. + byID := make(map[string]*itemListResponse, len(mediaItems)) + filter := requestAccessFilter(r) + accessibleItems := make([]*models.MediaItem, 0, len(mediaItems)) + for _, mi := range mediaItems { + if err := h.itemRepo.EnsureAccessible(r.Context(), mi.ContentID, filter); err != nil { + if errors.Is(err, catalog.ErrItemNotFound) { + continue + } + return nil, err + } + accessibleItems = append(accessibleItems, mi) + } + + userStates := map[string]*itemUserStateResponse{} + if store, profileID, ok := h.userStoreForRequest(r); ok { + if resolvedStates, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, accessibleItems); err == nil { + userStates = resolvedStates + } + } + + for _, mi := range accessibleItems { + resp := itemListResponse{ + ContentID: mi.ContentID, + Type: mi.Type, + Title: mi.Title, + Year: mi.Year, + Genres: mi.Genres, + ContentRating: mi.ContentRating, + RatingIMDB: mi.RatingIMDB, + Overview: mi.Overview, + PosterThumbhash: mi.PosterThumbhash, + BackdropThumbhash: mi.BackdropThumbhash, + UserState: userStates[mi.ContentID], + } + resp.PosterURL = h.presignURL(r, featuredPosterPath(mi.PosterPath), "featured") + resp.BackdropURL = h.presignURL(r, cardThumbnailPath(mi.BackdropPath), "card") + byID[mi.ContentID] = &resp + } + + // Resolve any remaining IDs as episodes. + if h.episodeRepo != nil { + var unresolvedIDs []string + for _, id := range ids { + if _, ok := byID[id]; !ok { + unresolvedIDs = append(unresolvedIDs, id) + } + } + if len(unresolvedIDs) > 0 { + episodes, epErr := h.episodeRepo.GetByIDs(r.Context(), unresolvedIDs) + if epErr == nil && len(episodes) > 0 { + // Gather parent series for poster/metadata fallback. + seriesIDs := make([]string, 0, len(episodes)) + for _, ep := range episodes { + seriesIDs = append(seriesIDs, ep.SeriesID) + } + parentItems, _ := h.itemRepo.GetByIDs(r.Context(), seriesIDs) + parentByID := make(map[string]*models.MediaItem, len(parentItems)) + for _, mi := range parentItems { + parentByID[mi.ContentID] = mi + } + + for _, ep := range episodes { + // Verify the parent series is accessible. + if err := h.itemRepo.EnsureAccessible(r.Context(), ep.SeriesID, filter); err != nil { + continue + } + parent := parentByID[ep.SeriesID] + resp := itemListResponse{ + ContentID: ep.ContentID, + Type: "episode", + Title: ep.Title, + RatingIMDB: ep.RatingIMDB, + Overview: ep.Overview, + } + // Use episode still as backdrop, fall back to parent series images. + if ep.StillPath != "" { + resp.BackdropURL = h.presignURL(r, cardThumbnailPath(ep.StillPath), "card") + resp.BackdropThumbhash = ep.StillThumbhash + } else if parent != nil { + resp.BackdropURL = h.presignURL(r, cardThumbnailPath(parent.BackdropPath), "card") + resp.BackdropThumbhash = parent.BackdropThumbhash + } + if parent != nil { + resp.PosterURL = h.presignURL(r, featuredPosterPath(parent.PosterPath), "featured") + resp.PosterThumbhash = parent.PosterThumbhash + resp.Year = parent.Year + resp.Genres = parent.Genres + resp.ContentRating = parent.ContentRating + } + byID[ep.ContentID] = &resp + } + } + } + } + + // Preserve input order. + result := make([]itemListResponse, 0, len(ids)) + for _, id := range ids { + if item, ok := byID[id]; ok { + result = append(result, *item) + } + } + + return result, nil +} + +func (h *PersonalDataHandler) resolveHistoryRemovalMediaItemIDs( + ctx context.Context, + target historyRemovalTargetRequest, + filter catalog.AccessFilter, +) ([]string, error) { + contentID := strings.TrimSpace(target.ContentID) + scope := strings.TrimSpace(target.Scope) + if contentID == "" { + return nil, errors.New("content_id is required") + } + switch scope { + case "", historyRemovalScopeItem: + scope = historyRemovalScopeItem + case historyRemovalScopeShow: + default: + return nil, errors.New("scope must be \"item\" or \"show\"") + } + + if h.itemRepo != nil { + item, err := h.itemRepo.GetByID(ctx, contentID) + switch { + case err == nil && item != nil: + if err := h.itemRepo.EnsureAccessible(ctx, item.ContentID, filter); err != nil { + return nil, err + } + switch item.Type { + case "movie": + return []string{item.ContentID}, nil + case "series": + return h.seriesEpisodeIDs(ctx, item.ContentID) + } + case err != nil && !errors.Is(err, catalog.ErrItemNotFound): + return nil, err + } + } + + if h.seasonRepo != nil { + season, err := h.seasonRepo.GetByID(ctx, contentID) + switch { + case err == nil && season != nil: + if err := h.itemRepo.EnsureAccessible(ctx, season.SeriesID, filter); err != nil { + return nil, err + } + if scope == historyRemovalScopeShow { + return h.seriesEpisodeIDs(ctx, season.SeriesID) + } + return h.seasonEpisodeIDs(ctx, season.ContentID) + case err != nil && !errors.Is(err, catalog.ErrSeasonNotFound): + return nil, err + } + } + + if h.episodeRepo == nil { + return nil, catalog.ErrItemNotFound + } + + episode, err := h.episodeRepo.GetByID(ctx, contentID) + if err != nil { + return nil, err + } + if err := h.itemRepo.EnsureAccessible(ctx, episode.SeriesID, filter); err != nil { + return nil, err + } + if scope == historyRemovalScopeShow { + return h.seriesEpisodeIDs(ctx, episode.SeriesID) + } + return []string{episode.ContentID}, nil +} + +func (h *PersonalDataHandler) seriesEpisodeIDs(ctx context.Context, seriesID string) ([]string, error) { + if h.episodeRepo == nil { + return nil, catalog.ErrItemNotFound + } + episodes, err := h.episodeRepo.ListBySeries(ctx, seriesID) + if err != nil { + return nil, err + } + return historyEpisodeIDs(episodes), nil +} + +func (h *PersonalDataHandler) seasonEpisodeIDs(ctx context.Context, seasonID string) ([]string, error) { + if h.episodeRepo == nil { + return nil, catalog.ErrItemNotFound + } + episodes, err := h.episodeRepo.ListBySeasonID(ctx, seasonID) + if err != nil { + return nil, err + } + return historyEpisodeIDs(episodes), nil +} + +func historyEpisodeIDs(episodes []*models.Episode) []string { + ids := make([]string, 0, len(episodes)) + for _, episode := range episodes { + if episode == nil || strings.TrimSpace(episode.ContentID) == "" { + continue + } + ids = append(ids, episode.ContentID) + } + return ids +} + +func (h *PersonalDataHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) { + if h.storeProvider == nil { + return nil, "", false + } + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + if userID == 0 || profileID == "" { + return nil, "", false + } + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil || store == nil { + return nil, "", false + } + return store, profileID, true +} + +func (h *PersonalDataHandler) ensureAccessibleItem(r *http.Request, itemID string) error { + if h.itemRepo == nil { + return nil + } + err := h.itemRepo.EnsureAccessible(r.Context(), itemID, requestAccessFilter(r)) + if err == nil { + return nil + } + // If not found in media_items, check if it's an episode and verify its parent series is accessible. + if errors.Is(err, catalog.ErrItemNotFound) && h.episodeRepo != nil { + ep, epErr := h.episodeRepo.GetByID(r.Context(), itemID) + if epErr == nil { + return h.itemRepo.EnsureAccessible(r.Context(), ep.SeriesID, requestAccessFilter(r)) + } + } + return err +} + +// presignURL resolves an image path to a usable URL, delegating to the +// DetailService which handles plugin-prefixed paths, HTTP pass-through, +// and legacy S3 presigning. +func (h *PersonalDataHandler) presignURL(r *http.Request, path string, variant string) string { + if h.detailSvc != nil { + return h.detailSvc.PresignURL(r.Context(), path, variant) + } + return "" +} + +// parsePagination extracts limit and offset from query parameters with defaults. +func parsePagination(r *http.Request) (int, int) { + q := r.URL.Query() + + limit := 50 + if v := q.Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + + offset := 0 + if v := q.Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + return limit, offset +} diff --git a/internal/api/handlers/filesystem.go b/internal/api/handlers/filesystem.go new file mode 100644 index 00000000..1726678e --- /dev/null +++ b/internal/api/handlers/filesystem.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "net/http" + "os" + "path/filepath" + "sort" + "strings" +) + +type filesystemBrowseEntry struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type filesystemBrowseResponse struct { + Path string `json:"path"` + Parent string `json:"parent"` + Entries []filesystemBrowseEntry `json:"entries"` +} + +type FilesystemHandler struct{} + +func NewFilesystemHandler() *FilesystemHandler { return &FilesystemHandler{} } + +func (h *FilesystemHandler) HandleBrowse(w http.ResponseWriter, r *http.Request) { + path := strings.TrimSpace(r.URL.Query().Get("path")) + if path == "" { + path = string(filepath.Separator) + } + if !filepath.IsAbs(path) { + writeError(w, http.StatusBadRequest, "bad_request", "path must be an absolute path") + return + } + + cleaned := filepath.Clean(path) + info, err := os.Stat(cleaned) + if err != nil { + if os.IsNotExist(err) { + writeError(w, http.StatusNotFound, "not_found", "Directory not found") + } else { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid path") + } + return + } + if !info.IsDir() { + writeError(w, http.StatusBadRequest, "bad_request", "path must point to a directory") + return + } + + entries, err := os.ReadDir(cleaned) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read directory") + return + } + + result := make([]filesystemBrowseEntry, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + name := entry.Name() + result = append(result, filesystemBrowseEntry{ + Name: name, + Path: filepath.Join(cleaned, name), + }) + } + + sort.Slice(result, func(i, j int) bool { + if result[i].Name == result[j].Name { + return result[i].Path < result[j].Path + } + return result[i].Name < result[j].Name + }) + + parent := filepath.Dir(cleaned) + if cleaned == string(filepath.Separator) || parent == "." || parent == cleaned { + parent = cleaned + } + + writeJSON(w, http.StatusOK, filesystemBrowseResponse{ + Path: cleaned, + Parent: parent, + Entries: result, + }) +} diff --git a/internal/api/handlers/health.go b/internal/api/handlers/health.go new file mode 100644 index 00000000..48155155 --- /dev/null +++ b/internal/api/handlers/health.go @@ -0,0 +1,132 @@ +// Package handlers provides HTTP handler functions for the Silo API. +package handlers + +import ( + "context" + "encoding/json" + "net/http" +) + +// PGPinger is the interface used to check PostgreSQL connectivity. +// *pgxpool.Pool satisfies this interface. +type PGPinger interface { + Ping(ctx context.Context) error +} + +// S3HealthChecker is the interface used to check S3 bucket accessibility. +// *s3client.Client satisfies this interface. +type S3HealthChecker interface { + HeadBucket(ctx context.Context, bucket string) error + Bucket() string +} + +// healthStatus represents the JSON response for the health endpoint. +// +// ServerName and ServerID identify this Silo instance. They are +// populated from server configuration and are stable across restarts, +// which allows clients (notably the iOS/tvOS multi-server picker) to +// display a friendly name and detect the same server reached via +// different URLs. +type healthStatus struct { + Status string `json:"status"` + ServerName string `json:"server_name,omitempty"` + ServerID string `json:"server_id,omitempty"` +} + +// readyStatus represents the JSON response for the readiness endpoint. +type readyStatus struct { + Status string `json:"status"` + Postgres *bool `json:"postgres,omitempty"` + S3 *bool `json:"s3,omitempty"` +} + +// HealthHandler responds to liveness probes and advertises the server's +// identity. Identity fields (ServerName, ServerID) are injected at +// construction time and reused for every request. +type HealthHandler struct { + serverName string + serverID string +} + +// NewHealthHandler creates a HealthHandler with the given identity +// fields. Empty strings are allowed: the corresponding JSON fields are +// omitted from the response. +func NewHealthHandler(serverName, serverID string) *HealthHandler { + return &HealthHandler{ + serverName: serverName, + serverID: serverID, + } +} + +// ServeHTTP responds with 200 OK and a JSON body indicating the service +// is alive, along with server identity fields. This endpoint does not +// check any dependencies. +func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(healthStatus{ + Status: "ok", + ServerName: h.serverName, + ServerID: h.serverID, + }) +} + +// ReadyHandler checks the health of PostgreSQL and S3 dependencies. +type ReadyHandler struct { + pg PGPinger + s3 S3HealthChecker +} + +// NewReadyHandler creates a ReadyHandler with the given PG and S3 dependencies. +// Either dependency may be nil: a nil PG pinger means PG is unavailable, +// and a nil S3 checker means S3 is not configured (treated as healthy). +func NewReadyHandler(pg PGPinger, s3 S3HealthChecker) *ReadyHandler { + return &ReadyHandler{pg: pg, s3: s3} +} + +// ServeHTTP checks both PostgreSQL and S3 health and responds with the +// combined status. Returns 200 if all checks pass, 503 if any fail. +func (h *ReadyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + pgOK := h.checkPostgres(ctx) + s3OK := h.checkS3(ctx) + + status := readyStatus{ + Status: "ok", + } + + if !pgOK || !s3OK { + status.Status = "error" + status.Postgres = new(pgOK) + status.S3 = new(s3OK) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(status) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(status) +} + +// checkPostgres pings the PG pool. Returns false if the pool is nil or +// the ping fails. +func (h *ReadyHandler) checkPostgres(ctx context.Context) bool { + if h.pg == nil { + return false + } + return h.pg.Ping(ctx) == nil +} + +// checkS3 performs a HeadBucket call on the S3 client. Returns true if +// no S3 client is configured (S3 is optional). Returns false if the +// HeadBucket call fails. +func (h *ReadyHandler) checkS3(ctx context.Context) bool { + if h.s3 == nil { + return true + } + return h.s3.HeadBucket(ctx, h.s3.Bucket()) == nil +} diff --git a/internal/api/handlers/history_import.go b/internal/api/handlers/history_import.go new file mode 100644 index 00000000..8f91a904 --- /dev/null +++ b/internal/api/handlers/history_import.go @@ -0,0 +1,278 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/historyimport" +) + +type HistoryImportHandler struct { + service *historyimport.Service +} + +func NewHistoryImportHandler(service *historyimport.Service) *HistoryImportHandler { + return &HistoryImportHandler{service: service} +} + +func (h *HistoryImportHandler) HandleListSources(w http.ResponseWriter, r *http.Request) { + sources, err := h.service.ListUserSources(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list history import sources") + return + } + writeJSON(w, http.StatusOK, sources) +} + +func (h *HistoryImportHandler) HandleLoginConnect(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req historyimport.LoginConnectInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "bad_request", "username and password are required") + return + } + + session, err := h.service.LoginConnect(r.Context(), userID, req) + if err != nil { + slog.Error("history import emby connect login failed", "user_id", userID, "error", err) + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusOK, session) +} + +func (h *HistoryImportHandler) HandleCreateRun(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req historyimport.CreateRunInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + run, err := h.service.CreateRun(r.Context(), userID, req) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusCreated, run) +} + +func (h *HistoryImportHandler) HandleListRuns(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + limit, _ := parsePagination(r) + runs, err := h.service.ListRuns(r.Context(), userID, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list history import runs") + return + } + writeJSON(w, http.StatusOK, runs) +} + +func (h *HistoryImportHandler) HandleGetRun(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + runID := chi.URLParam(r, "id") + if runID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Run ID is required") + return + } + run, err := h.service.GetRun(r.Context(), userID, runID) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusOK, run) +} + +func (h *HistoryImportHandler) HandleCreatePlexPin(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + pin, err := h.service.CreatePlexPin(r.Context(), userID) + if err != nil { + slog.Error("history import plex pin creation failed", "user_id", userID, "error", err) + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusOK, pin) +} + +func (h *HistoryImportHandler) HandleCheckPlexPin(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + var req historyimport.PlexCheckRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.SessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "session_id is required") + return + } + result, err := h.service.CheckPlexPin(r.Context(), userID, req.SessionID) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *HistoryImportHandler) HandleAdminListSources(w http.ResponseWriter, r *http.Request) { + sources, err := h.service.ListAdminSources(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list history import sources") + return + } + writeJSON(w, http.StatusOK, sources) +} + +func (h *HistoryImportHandler) HandleAdminCreateSource(w http.ResponseWriter, r *http.Request) { + var req historyimport.CreateSourceInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + source, err := h.service.CreateSource(r.Context(), req) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusCreated, source) +} + +func (h *HistoryImportHandler) HandleAdminUpdateSource(w http.ResponseWriter, r *http.Request) { + id, err := parseHistoryImportID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid source ID") + return + } + var req historyimport.UpdateSourceInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + source, err := h.service.UpdateSource(r.Context(), id, req) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusOK, source) +} + +func (h *HistoryImportHandler) HandleAdminDeleteSource(w http.ResponseWriter, r *http.Request) { + id, err := parseHistoryImportID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid source ID") + return + } + if err := h.service.DeleteSource(r.Context(), id); err != nil { + h.writeHistoryImportError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *HistoryImportHandler) writeHistoryImportError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, historyimport.ErrSourceNotFound), + errors.Is(err, historyimport.ErrRunNotFound), + errors.Is(err, historyimport.ErrProfileNotFound), + errors.Is(err, historyimport.ErrConnectSessionNotFound), + errors.Is(err, historyimport.ErrPlexSessionNotFound), + errors.Is(err, historyimport.ErrMappingNotFound): + writeError(w, http.StatusNotFound, "not_found", err.Error()) + case errors.Is(err, historyimport.ErrConnectSessionExpired), + errors.Is(err, historyimport.ErrConnectSessionUsed), + errors.Is(err, historyimport.ErrPlexSessionExpired), + errors.Is(err, historyimport.ErrPlexSessionUsed), + errors.Is(err, historyimport.ErrNoAdminToken): + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + case errors.Is(err, historyimport.ErrActiveRunExists), + errors.Is(err, historyimport.ErrMappingDuplicate): + writeError(w, http.StatusConflict, "conflict", err.Error()) + default: + if status := historyimport.UpstreamHTTPStatus(err); status > 0 { + httpStatus, code, message := historyImportUpstreamError(status) + writeError(w, httpStatus, code, message) + return + } + if err != nil && looksLikeValidationError(err) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + slog.Error("history import: unhandled error", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "History import request failed") + } +} + +func historyImportUpstreamError(status int) (int, string, string) { + switch { + case status == http.StatusUnauthorized: + return http.StatusUnauthorized, "unauthorized", "Couldn't connect to that server. Check the URL, username, and password and try again." + case status >= 400 && status < 500: + return http.StatusBadRequest, "bad_request", "Couldn't start the import with those server settings." + default: + return http.StatusBadGateway, "bad_gateway", "The source server couldn't complete the import right now. Please try again." + } +} + +func parseHistoryImportID(r *http.Request) (int, error) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid id") + } + return id, nil +} + +func looksLikeValidationError(err error) bool { + message := err.Error() + return message == "profile_id is required" || + message == "unsupported source type" || + message == "direct server_url imports are no longer supported" || + message == "exactly one of connect_session_id or source_id is required" || + message == "selected server not found in connect session" || + message == "selected server does not expose a usable address" || + message == "jellyfin_base_url, jellyfin_username, and jellyfin_password are required" || + message == "source_id is required" || + message == "username and password are required" || + message == "name, source_type, and base_url are required" || + message == "plex OAuth not completed yet" || + message == "selected Plex server not found in session" || + message == "selected Plex server has no usable address" || + message == "plex_token is required for predefined Plex sources" || + message == "source is not a Plex server" || + message == "plex_session_id or source_id is required for Plex imports" +} diff --git a/internal/api/handlers/history_import_admin.go b/internal/api/handlers/history_import_admin.go new file mode 100644 index 00000000..7ff7e699 --- /dev/null +++ b/internal/api/handlers/history_import_admin.go @@ -0,0 +1,299 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/historyimport" +) + +// HandleAdminSetSourceToken stores an admin token for a source. +// +// PUT /admin/history-imports/sources/{id}/token +func (h *HistoryImportHandler) HandleAdminSetSourceToken(w http.ResponseWriter, r *http.Request) { + id, err := parseHistoryImportID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid source ID") + return + } + var req historyimport.SetAdminTokenInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.Token == "" { + writeError(w, http.StatusBadRequest, "bad_request", "token is required") + return + } + if err := h.service.SetSourceAdminToken(r.Context(), id, req.Token); err != nil { + h.writeHistoryImportError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleAdminClearSourceToken removes the admin token from a source. +// +// DELETE /admin/history-imports/sources/{id}/token +func (h *HistoryImportHandler) HandleAdminClearSourceToken(w http.ResponseWriter, r *http.Request) { + id, err := parseHistoryImportID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid source ID") + return + } + if err := h.service.ClearSourceAdminToken(r.Context(), id); err != nil { + h.writeHistoryImportError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleAdminDiscoverUsers queries the external server for its user list. +// +// GET /admin/history-imports/sources/{id}/users +func (h *HistoryImportHandler) HandleAdminDiscoverUsers(w http.ResponseWriter, r *http.Request) { + id, err := parseHistoryImportID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid source ID") + return + } + users, err := h.service.DiscoverExternalUsers(r.Context(), id) + if err != nil { + slog.Error("history import: discover external users failed", "source_id", id, "error", err) + h.writeDiscoverError(w, err) + return + } + writeJSON(w, http.StatusOK, users) +} + +func (h *HistoryImportHandler) writeDiscoverError(w http.ResponseWriter, err error) { + if status := historyimport.UpstreamHTTPStatus(err); status > 0 { + switch { + case status == 401: + writeError(w, 401, "unauthorized", "The admin token was rejected. Check that it is a valid admin API key for this server.") + case status == 403: + writeError(w, 403, "forbidden", "The admin token does not have permission to list users on this server.") + case status == 404: + writeError(w, 502, "bad_gateway", "Server not found at that URL. Check the server URL is correct.") + case status >= 400 && status < 500: + writeError(w, 502, "bad_gateway", fmt.Sprintf("The server returned an error (%d). Check the URL and admin token.", status)) + default: + writeError(w, 502, "bad_gateway", "The server could not be reached. Check the URL is correct and the server is running.") + } + return + } + if historyimport.IsReachabilityError(err) { + writeError(w, 502, "bad_gateway", "The server could not be reached. Check the URL is correct and the server is running.") + return + } + h.writeHistoryImportError(w, err) +} + +// HandleAdminListMappings returns all user mappings, optionally filtered by source_id. +// +// GET /admin/history-imports/mappings?source_id=N +func (h *HistoryImportHandler) HandleAdminListMappings(w http.ResponseWriter, r *http.Request) { + sourceIDStr := r.URL.Query().Get("source_id") + if sourceIDStr == "" { + writeError(w, http.StatusBadRequest, "bad_request", "source_id is required") + return + } + sourceID, err := strconv.Atoi(sourceIDStr) + if err != nil || sourceID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid source_id") + return + } + mappings, err := h.service.ListMappings(r.Context(), sourceID) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + if mappings == nil { + mappings = []historyimport.UserMapping{} + } + writeJSON(w, http.StatusOK, mappings) +} + +// HandleAdminCreateMapping creates a new user mapping. +// +// POST /admin/history-imports/mappings +func (h *HistoryImportHandler) HandleAdminCreateMapping(w http.ResponseWriter, r *http.Request) { + var req historyimport.CreateMappingInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + mapping, err := h.service.CreateMapping(r.Context(), req) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusCreated, mapping) +} + +// HandleAdminUpdateMapping updates the Silo target of a mapping. +// +// PUT /admin/history-imports/mappings/{id} +func (h *HistoryImportHandler) HandleAdminUpdateMapping(w http.ResponseWriter, r *http.Request) { + id, err := parseMappingID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid mapping ID") + return + } + var req historyimport.UpdateMappingInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + mapping, err := h.service.UpdateMapping(r.Context(), id, req) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusOK, mapping) +} + +// HandleAdminDeleteMapping removes a user mapping. +// +// DELETE /admin/history-imports/mappings/{id} +func (h *HistoryImportHandler) HandleAdminDeleteMapping(w http.ResponseWriter, r *http.Request) { + id, err := parseMappingID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid mapping ID") + return + } + if err := h.service.DeleteMapping(r.Context(), id); err != nil { + h.writeHistoryImportError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleAdminCreateRun triggers an import for a single mapping. +// +// POST /admin/history-imports/mappings/{id}/run +func (h *HistoryImportHandler) HandleAdminCreateRun(w http.ResponseWriter, r *http.Request) { + id, err := parseMappingID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid mapping ID") + return + } + run, err := h.service.CreateAdminRun(r.Context(), id) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusCreated, run) +} + +// HandleAdminBulkRun triggers imports for all eligible mappings on a source. +// +// POST /admin/history-imports/sources/{id}/bulk-run +func (h *HistoryImportHandler) HandleAdminBulkRun(w http.ResponseWriter, r *http.Request) { + id, err := parseHistoryImportID(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid source ID") + return + } + result, err := h.service.BulkCreateAdminRuns(r.Context(), id) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusCreated, result) +} + +// HandleAdminListRuns returns import runs across all users. +// +// GET /admin/history-imports/runs?source_id=N&limit=N +func (h *HistoryImportHandler) HandleAdminListRuns(w http.ResponseWriter, r *http.Request) { + var sourceID *int + if s := r.URL.Query().Get("source_id"); s != "" { + id, err := strconv.Atoi(s) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid source_id") + return + } + sourceID = &id + } + limit, _ := parsePagination(r) + runs, err := h.service.ListAdminRuns(r.Context(), sourceID, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list history import runs") + return + } + if runs == nil { + runs = []historyimport.Run{} + } + writeJSON(w, http.StatusOK, runs) +} + +// HandleAdminGetRun returns any run by ID. +// +// GET /admin/history-imports/runs/{id} +func (h *HistoryImportHandler) HandleAdminGetRun(w http.ResponseWriter, r *http.Request) { + runID := chi.URLParam(r, "id") + if runID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Run ID is required") + return + } + run, err := h.service.GetAdminRun(r.Context(), runID) + if err != nil { + h.writeHistoryImportError(w, err) + return + } + writeJSON(w, http.StatusOK, run) +} + +// HandleAdminCancelRun cancels a queued or running import. +// +// POST /admin/history-imports/runs/{id}/cancel +func (h *HistoryImportHandler) HandleAdminCancelRun(w http.ResponseWriter, r *http.Request) { + runID := chi.URLParam(r, "id") + if runID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Run ID is required") + return + } + if err := h.service.CancelAdminRun(r.Context(), runID); err != nil { + h.writeHistoryImportError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleAdminPlexLogin exchanges Plex credentials for an auth token. +// +// POST /admin/history-imports/plex/login +func (h *HistoryImportHandler) HandleAdminPlexLogin(w http.ResponseWriter, r *http.Request) { + var req historyimport.PlexLoginInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "bad_request", "username and password are required") + return + } + token, err := h.service.AuthenticatePlex(r.Context(), req.Username, req.Password) + if err != nil { + slog.Error("history import: plex login failed", "error", err) + h.writeDiscoverError(w, err) + return + } + writeJSON(w, http.StatusOK, historyimport.PlexLoginResult{Token: token}) +} + +func parseMappingID(r *http.Request) (int, error) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil || id <= 0 { + return 0, errInvalidID + } + return id, nil +} + +var errInvalidID = fmt.Errorf("invalid id") diff --git a/internal/api/handlers/history_import_test.go b/internal/api/handlers/history_import_test.go new file mode 100644 index 00000000..d0b57c30 --- /dev/null +++ b/internal/api/handlers/history_import_test.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "net/http" + "testing" +) + +func TestHistoryImportUpstreamError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + wantStatus int + wantCode string + wantMsg string + }{ + { + name: "unauthorized", + status: http.StatusUnauthorized, + wantStatus: http.StatusUnauthorized, + wantCode: "unauthorized", + wantMsg: "Couldn't connect to that server. Check the URL, username, and password and try again.", + }, + { + name: "bad request", + status: http.StatusBadRequest, + wantStatus: http.StatusBadRequest, + wantCode: "bad_request", + wantMsg: "Couldn't start the import with those server settings.", + }, + { + name: "upstream failure", + status: http.StatusBadGateway, + wantStatus: http.StatusBadGateway, + wantCode: "bad_gateway", + wantMsg: "The source server couldn't complete the import right now. Please try again.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotStatus, gotCode, gotMsg := historyImportUpstreamError(tt.status) + if gotStatus != tt.wantStatus || gotCode != tt.wantCode || gotMsg != tt.wantMsg { + t.Fatalf("got (%d, %q, %q), want (%d, %q, %q)", gotStatus, gotCode, gotMsg, tt.wantStatus, tt.wantCode, tt.wantMsg) + } + }) + } +} diff --git a/internal/api/handlers/home_dismissals.go b/internal/api/handlers/home_dismissals.go new file mode 100644 index 00000000..d090b943 --- /dev/null +++ b/internal/api/handlers/home_dismissals.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +type HomeDismissalHandler struct { + storeProvider userstore.UserStoreProvider +} + +type upsertHomeDismissalRequest struct { + SeriesID string `json:"series_id"` + ProgressUpdatedAt string `json:"progress_updated_at"` +} + +func NewHomeDismissalHandler(provider userstore.UserStoreProvider) *HomeDismissalHandler { + return &HomeDismissalHandler{storeProvider: provider} +} + +func (h *HomeDismissalHandler) HandleUpsertDismissal(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + surface := chi.URLParam(r, "surface") + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + if !validHomeSurface(surface) { + writeError(w, http.StatusBadRequest, "bad_request", "Surface is invalid") + return + } + + var req upsertHomeDismissalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + dismissal := userstore.HomeItemDismissal{ + ProfileID: profileID, + Surface: surface, + MediaItemID: itemID, + DismissedAt: time.Now().UTC().Format(time.RFC3339), + } + + switch surface { + case userstore.HomeSurfaceContinueWatching: + if req.ProgressUpdatedAt == "" { + writeError(w, http.StatusBadRequest, "bad_request", "progress_updated_at is required") + return + } + dismissal.ProgressUpdatedAt = &req.ProgressUpdatedAt + case userstore.HomeSurfaceNextUp: + if req.SeriesID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "series_id is required") + return + } + dismissal.SeriesID = &req.SeriesID + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil || store == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + if err := store.UpsertHomeDismissal(r.Context(), dismissal); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save dismissal") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *HomeDismissalHandler) HandleDeleteDismissal(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + surface := chi.URLParam(r, "surface") + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + if !validHomeSurface(surface) { + writeError(w, http.StatusBadRequest, "bad_request", "Surface is invalid") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil || store == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + if err := store.DeleteHomeDismissal(r.Context(), profileID, surface, itemID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete dismissal") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func validHomeSurface(surface string) bool { + return surface == userstore.HomeSurfaceContinueWatching || surface == userstore.HomeSurfaceNextUp +} diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go new file mode 100644 index 00000000..0b8114cb --- /dev/null +++ b/internal/api/handlers/items.go @@ -0,0 +1,1264 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "log/slog" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/overlays" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/watchstate" + "github.com/Silo-Server/silo-server/internal/watchsync" +) + +// EpisodeFileProvider retrieves media files linked to an episode. +type EpisodeFileProvider interface { + GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error) + GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error) + ListByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error) +} + +type MetadataRefreshRequester interface { + RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error +} + +type LocalWatchEventDispatcher interface { + HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error +} + +// ItemsHandler handles browse, search, item detail, and series endpoints. +type ItemsHandler struct { + browseRepo *catalog.BrowseRepository + itemRepo *catalog.ItemRepository + episodeRepo *catalog.EpisodeRepository + seasonRepo *catalog.SeasonRepository + ratingsRepo ratingsRepository + catalogResolver *catalog.CatalogResolver + fileRepo EpisodeFileProvider + detailSvc *catalog.DetailService + storeProvider userstore.UserStoreProvider + watchState *watchstate.Service + profileStaler ProfileStaler + profileRefreshRequester ProfileRefreshRequester + metadataRefreshRequester MetadataRefreshRequester + localWatchDispatcher LocalWatchEventDispatcher + EventsHub *evt.Hub + UserRepo *auth.UserRepository +} + +// NewItemsHandler creates a new ItemsHandler. +func NewItemsHandler( + browseRepo *catalog.BrowseRepository, + itemRepo *catalog.ItemRepository, + episodeRepo *catalog.EpisodeRepository, + seasonRepo *catalog.SeasonRepository, + ratingsRepo ratingsRepository, + fileRepo EpisodeFileProvider, + storeProvider userstore.UserStoreProvider, + detailSvc *catalog.DetailService, + providerIDRepo *catalog.ProviderIDRepository, +) *ItemsHandler { + return &ItemsHandler{ + browseRepo: browseRepo, + itemRepo: itemRepo, + episodeRepo: episodeRepo, + seasonRepo: seasonRepo, + ratingsRepo: ratingsRepo, + catalogResolver: catalog.NewCatalogResolver(browseRepo, itemRepo). + WithEpisodeRepository(episodeRepo). + WithUserStoreProvider(storeProvider), + fileRepo: fileRepo, + storeProvider: storeProvider, + watchState: watchstate.NewService(storeProvider).WithStableIdentityResolver( + watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo), + ), + detailSvc: detailSvc, + } +} + +// SetProfileStaler configures an optional staleness trigger for taste profiles. +func (h *ItemsHandler) SetProfileStaler(ps ProfileStaler) { + h.profileStaler = ps +} + +// SetProfileRefreshRequester configures an optional background refresh queue for taste profiles. +func (h *ItemsHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) { + h.profileRefreshRequester = requester +} + +func (h *ItemsHandler) SetMetadataRefreshRequester(requester MetadataRefreshRequester) { + h.metadataRefreshRequester = requester +} + +func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDispatcher) { + h.localWatchDispatcher = dispatcher +} + +func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) { + if h == nil || detail == nil || h.metadataRefreshRequester == nil { + return + } + switch detail.Type { + case "episode": + if h.episodeRepo == nil { + return + } + episode, err := h.episodeRepo.GetByID(ctx, detail.ContentID) + if err == nil { + h.maybeRequestStaleEpisodeMetadataRefresh(ctx, episode) + } + case "season": + if h.episodeRepo == nil { + return + } + episodes, err := h.episodeRepo.ListBySeasonID(ctx, detail.ContentID) + if err == nil { + h.maybeRequestStaleSeasonMetadataRefresh(ctx, detail.ContentID, episodes) + } + case "series": + if h.itemRepo == nil { + return + } + item, err := h.itemRepo.GetByID(ctx, detail.ContentID) + if err == nil && item != nil && item.EpisodeMetadataIncomplete { + h.requestStaleMetadataRefresh(ctx, "item", item.ContentID) + } + } +} + +func (h *ItemsHandler) maybeRequestStaleEpisodeMetadataRefresh(ctx context.Context, episode *models.Episode) { + if h == nil || h.metadataRefreshRequester == nil || episode == nil { + return + } + if metadata.EpisodeHasIncompleteMetadata(episode, time.Now()) { + h.requestStaleMetadataRefresh(ctx, "episode", episode.ContentID) + } +} + +func (h *ItemsHandler) maybeRequestStaleSeasonMetadataRefresh(ctx context.Context, seasonID string, episodes []*models.Episode) { + if h == nil || h.metadataRefreshRequester == nil || strings.TrimSpace(seasonID) == "" { + return + } + now := time.Now() + for _, episode := range episodes { + if metadata.EpisodeHasIncompleteMetadata(episode, now) { + h.requestStaleMetadataRefresh(ctx, "season", seasonID) + return + } + } +} + +func (h *ItemsHandler) requestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) { + if h == nil || h.metadataRefreshRequester == nil || strings.TrimSpace(contentID) == "" { + return + } + if err := h.metadataRefreshRequester.RequestStaleMetadataRefresh(ctx, targetType, contentID); err != nil { + slog.Warn("catalog: failed to request stale metadata refresh", + "target_type", targetType, + "content_id", contentID, + "error", err) + } +} + +// --- Response types --- + +// itemListResponse is the shape of a single item in browse/search list responses. +type itemListResponse struct { + ContentID string `json:"content_id"` + Type string `json:"type"` + Title string `json:"title"` + SeriesTitle string `json:"series_title,omitempty"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` + Year int `json:"year,omitempty"` + Runtime int `json:"runtime,omitempty"` + Genres []string `json:"genres"` + Keywords []string `json:"keywords"` + Studios []string `json:"studios,omitempty"` + Networks []string `json:"networks,omitempty"` + ContentRating string `json:"content_rating,omitempty"` + Status string `json:"status"` + ShowStatus string `json:"show_status,omitempty"` + RatingIMDB *float64 `json:"rating_imdb,omitempty"` + RatingTMDB *float64 `json:"rating_tmdb,omitempty"` + RatingRTCritic *int `json:"rating_rt_critic,omitempty"` + RatingRTAudience *int `json:"rating_rt_audience,omitempty"` + OriginalLanguage string `json:"original_language,omitempty"` + Overview string `json:"overview,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty"` + BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` + ReleaseDate *string `json:"release_date,omitempty"` + LastAirDate *string `json:"last_air_date,omitempty"` + AddedAt *time.Time `json:"added_at,omitempty"` + OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"` + UserState *itemUserStateResponse `json:"user_state,omitempty"` +} + +// browseResponse is the paginated response for the /items endpoint. +type browseResponse struct { + Total int `json:"total"` + HasMore bool `json:"has_more"` + Items []itemListResponse `json:"items"` +} + +type itemFiltersResponse struct { + Genres []string `json:"genres"` + Studios []string `json:"studios"` + Networks []string `json:"networks"` + Countries []string `json:"countries"` + ContentRatings []string `json:"content_ratings"` +} + +// seasonResponse is the shape of a season in API responses. +type seasonResponse struct { + ContentID string `json:"content_id"` + SeasonNumber int `json:"season_number"` + IsSpecials bool `json:"is_specials,omitempty"` + Title string `json:"title"` + Overview string `json:"overview,omitempty"` + AirDate string `json:"air_date,omitempty"` + EpisodeCount int `json:"episode_count"` + PosterURL string `json:"poster_url,omitempty"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + UserData *catalog.SeasonUserData `json:"user_data,omitempty"` +} + +// seasonsResponse wraps the seasons list for JSON serialization. +type seasonsResponse struct { + Seasons []seasonResponse `json:"seasons"` +} + +// seasonDetailResponse wraps a single season for JSON serialization. +type seasonDetailResponse struct { + Season seasonResponse `json:"season"` +} + +// episodesListResponse wraps the episodes list for JSON serialization. +type episodesListResponse struct { + Episodes []episodeResponse `json:"episodes"` +} + +// episodeFileResponse represents a file version available for an episode. +type episodeFileResponse struct { + FileID int `json:"file_id"` + Resolution string `json:"resolution,omitempty"` + CodecVideo string `json:"codec_video,omitempty"` + HDR bool `json:"hdr"` + AudioChannels int `json:"audio_channels,omitempty"` + Container string `json:"container,omitempty"` + FileSize int64 `json:"file_size"` +} + +// episodeResponse is the shape of an episode in API responses. +type episodeResponse struct { + ContentID string `json:"content_id"` + SeasonNumber int `json:"season_number"` + EpisodeNumber int `json:"episode_number"` + Title string `json:"title"` + Overview string `json:"overview,omitempty"` + AirDate string `json:"air_date,omitempty"` + Runtime int `json:"runtime"` + ImdbID string `json:"imdb_id,omitempty"` + TmdbID string `json:"tmdb_id,omitempty"` + TvdbID string `json:"tvdb_id,omitempty"` + StillURL string `json:"still_url,omitempty"` + StillThumbhash string `json:"still_thumbhash,omitempty"` + UserData *catalog.SeasonUserData `json:"user_data,omitempty"` + Files []episodeFileResponse `json:"files,omitempty"` +} + +type episodeImageFallback struct { + Path string + Thumbhash string +} + +type watchedStateResponse struct { + ContentID string `json:"content_id"` + Type string `json:"type"` + AffectedCount int `json:"affected_count"` + Played bool `json:"played"` +} + +// --- Handler methods --- + +// HandleGetItems handles GET /items with filtering, sorting, and pagination. +func (h *ItemsHandler) HandleGetItems(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog") + if values, ok := buildLegacyItemsCatalogValues(r.URL.Query()); ok && h.catalogResolver != nil { + h.writeCatalogBrowseResponse(w, r, values) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") +} + +// HandleGetLatestItems handles GET /items/latest — shortcut for sort=created_at&order=desc. +func (h *ItemsHandler) HandleGetLatestItems(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog") + if h.catalogResolver != nil { + h.writeCatalogBrowseResponse(w, r, buildLegacyLatestCatalogValues(r.URL.Query())) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") +} + +// HandleGetItemFilters handles GET /items/filters for distinct browse values. +func (h *ItemsHandler) HandleGetItemFilters(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog/filters") + if values, ok := buildLegacyItemsCatalogValues(r.URL.Query()); ok && h.catalogResolver != nil { + h.writeCatalogFiltersResponse(w, r, values) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") +} + +// HandleGetItemDetail handles GET /items/{id}. +func (h *ItemsHandler) HandleGetItemDetail(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}") + h.catalogResourceHandler().HandleGetItemDetail(w, r) +} + +// HandleGetWatchDetail handles GET /watch/{id}. +func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Watch target ID is required") + return + } + + detail, err := h.detailSvc.GetWatchDetail(r.Context(), id, h.accessFilter(r)) + if err != nil { + switch { + case catalog.IsWatchTargetNotPlayable(err): + writeError(w, http.StatusBadRequest, "invalid_watch_target", "Content is not directly playable") + return + case isNotFound(err): + writeError(w, http.StatusNotFound, "not_found", "Watch target not found") + return + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get watch detail") + return + } + } + + if detail.Type == "movie" || detail.Type == "episode" { + detail.UserData = h.getLeafUserData(r, detail.ContentID) + applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey) + } + + writeJSON(w, http.StatusOK, detail) +} + +// HandleMarkWatched handles POST /watched/{id}. +func (h *ItemsHandler) HandleMarkWatched(w http.ResponseWriter, r *http.Request) { + h.handleSetWatchedState(w, r, true) +} + +// HandleMarkUnwatched handles DELETE /watched/{id}. +func (h *ItemsHandler) HandleMarkUnwatched(w http.ResponseWriter, r *http.Request) { + h.handleSetWatchedState(w, r, false) +} + +func (h *ItemsHandler) dispatchLocalWatchEvent( + ctx context.Context, + kind watchsync.LocalWatchEventKind, + userID int, + profileID string, + result watchstate.ManualMarkResult, +) { + if h == nil || h.localWatchDispatcher == nil { + return + } + plays := watchsync.LocalPlaysFromHistory(result.Entries) + if len(plays) == 0 { + return + } + if err := h.localWatchDispatcher.HandleLocalWatchEvent(ctx, watchsync.LocalWatchEvent{ + Kind: kind, + UserID: userID, + ProfileID: profileID, + Plays: plays, + }); err != nil { + slog.Warn("failed to queue local watch provider event", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err) + } +} + +func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Request, played bool) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + id := chi.URLParam(r, "id") + + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required") + return + } + + targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, h.accessFilter(r)) + if err != nil { + switch { + case isNotFound(err): + writeError(w, http.StatusNotFound, "not_found", "Item not found") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state") + } + return + } + + if h.watchState == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if played { + leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets)) + for _, target := range targets { + leafTargets = append(leafTargets, watchstate.LeafWatchTarget{ + MediaItemID: target.ContentID, + DurationSeconds: target.DurationSeconds, + }) + } + updatedAt := time.Now().UTC() + var result watchstate.ManualMarkResult + result, err = h.watchState.RecordManualMarkWatchedWithResult(r.Context(), userID, profileID, leafTargets, updatedAt) + if err == nil { + h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result) + } + } else { + targetIDs := make([]string, 0, len(targets)) + for _, target := range targets { + targetIDs = append(targetIDs, target.ContentID) + } + var result watchstate.ManualMarkResult + result, err = h.watchState.RecordManualMarkUnwatchedWithResult(r.Context(), userID, profileID, targetIDs) + if err == nil { + h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedUnwatched, userID, profileID, result) + } + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state") + return + } + + triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) + publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", "watched.updated") + + writeJSON(w, http.StatusOK, watchedStateResponse{ + ContentID: id, + Type: targetType, + AffectedCount: len(targets), + Played: played, + }) +} + +// HandleGetItemVersions handles GET /items/{id}/versions. +func (h *ItemsHandler) HandleGetItemVersions(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}/versions") + h.catalogResourceHandler().HandleGetItemVersions(w, r) +} + +// HandleGetItemEpisodes handles GET /items/{id}/episodes for season IDs. +func (h *ItemsHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}/episodes") + h.catalogResourceHandler().HandleGetItemEpisodes(w, r) +} + +// HandleGetSeasons handles GET /series/{id}/seasons. +func (h *ItemsHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons") + h.catalogResourceHandler().HandleGetSeasons(w, r) +} + +// HandleGetSeason handles GET /series/{id}/seasons/{num}. +func (h *ItemsHandler) HandleGetSeason(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons/{num}") + h.catalogResourceHandler().HandleGetSeason(w, r) +} + +// HandleGetEpisodes handles GET /series/{id}/seasons/{num}/episodes. +func (h *ItemsHandler) HandleGetEpisodes(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons/{num}/episodes") + h.catalogResourceHandler().HandleGetEpisodes(w, r) +} + +func (h *ItemsHandler) catalogResourceHandler() *CatalogResourceHandler { + return NewCatalogResourceHandler(h) +} + +func (h *ItemsHandler) catalogHandler() *CatalogHandler { + return NewCatalogHandler(h.catalogResolver, h) +} + +func (h *ItemsHandler) writeCatalogBrowseResponse(w http.ResponseWriter, r *http.Request, values map[string][]string) bool { + req, err := catalog.ParseCatalogRequest(values) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return true + } + + result, err := h.catalogResolver.Resolve(r.Context(), req, h.accessFilter(r)) + if err != nil { + if errors.Is(err, catalog.ErrInvalidCatalogRequest) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return true + } + if errors.Is(err, catalog.ErrCatalogSourceNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Catalog source not found") + return true + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to browse items") + return true + } + + overlaySummaries := h.listOverlaySummaries(r.Context(), result.Items, h.accessFilter(r)) + userStates := h.listItemUserStates(r, result.Items) + items := make([]itemListResponse, 0, len(result.Items)) + for _, item := range result.Items { + items = append(items, h.toItemListResponseWithOverlay(r, item, overlaySummaries[item.ContentID], userStates[item.ContentID])) + } + + writeJSON(w, http.StatusOK, browseResponse{ + Total: result.Total, + HasMore: result.HasMore, + Items: items, + }) + return true +} + +func (h *ItemsHandler) writeCatalogFiltersResponse(w http.ResponseWriter, r *http.Request, values map[string][]string) bool { + req, err := catalog.ParseCatalogRequest(values) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return true + } + + filters, err := h.catalogResolver.ListFiltersWithOptions( + r.Context(), + req, + h.accessFilter(r), + catalog.CatalogFilterOptions{IncludeTechnical: false}, + ) + if err != nil { + if errors.Is(err, catalog.ErrInvalidCatalogRequest) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return true + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item filters") + return true + } + + writeJSON(w, http.StatusOK, itemFiltersResponse{ + Genres: filters.Genres, + Studios: filters.Studios, + Networks: filters.Networks, + Countries: filters.Countries, + ContentRatings: filters.ContentRatings, + }) + return true +} + +// toItemListResponse converts a MediaItem to an itemListResponse with presigned URLs. +func (h *ItemsHandler) toItemListResponse(r *http.Request, item *models.MediaItem) itemListResponse { + return h.toItemListResponseWithOverlay(r, item, nil, nil) +} + +func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *models.MediaItem, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse) itemListResponse { + if h.detailSvc != nil { + if localized, err := h.detailSvc.LocalizeItemModel(r.Context(), item, h.accessFilter(r)); err == nil && localized != nil { + item = localized + } + } + resp := itemListResponse{ + ContentID: item.ContentID, + Type: item.Type, + Title: item.Title, + Year: item.Year, + Runtime: item.Runtime, + Genres: item.Genres, + Keywords: item.Keywords, + Studios: item.Studios, + Networks: item.Networks, + ContentRating: item.ContentRating, + Status: item.Status, + ShowStatus: item.ShowStatus, + RatingIMDB: item.RatingIMDB, + RatingTMDB: item.RatingTMDB, + RatingRTCritic: item.RatingRTCritic, + RatingRTAudience: item.RatingRTAudience, + OriginalLanguage: item.OriginalLanguage, + Overview: item.Overview, + PosterThumbhash: item.PosterThumbhash, + BackdropThumbhash: item.BackdropThumbhash, + OverlaySummary: overlaySummary, + UserState: userState, + } + + resp.AddedAt = item.AddedAt + resp.ReleaseDate = item.ReleaseDate + resp.LastAirDate = item.LastAirDate + resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card") + resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card") + if resp.Genres == nil { + resp.Genres = []string{} + } + if resp.Keywords == nil { + resp.Keywords = []string{} + } + + return resp +} + +func (h *ItemsHandler) listEpisodeBrowseMetadata( + ctx context.Context, + items []*models.MediaItem, +) map[string]struct { + SeriesTitle string + SeasonNumber *int + EpisodeNumber *int +} { + if h == nil || h.episodeRepo == nil { + return map[string]struct { + SeriesTitle string + SeasonNumber *int + EpisodeNumber *int + }{} + } + + episodeIDs := make([]string, 0) + for _, item := range items { + if item == nil || item.Type != "episode" || strings.TrimSpace(item.ContentID) == "" { + continue + } + episodeIDs = append(episodeIDs, item.ContentID) + } + if len(episodeIDs) == 0 { + return map[string]struct { + SeriesTitle string + SeasonNumber *int + EpisodeNumber *int + }{} + } + + episodes, err := h.episodeRepo.GetByIDs(ctx, episodeIDs) + if err != nil || len(episodes) == 0 { + return map[string]struct { + SeriesTitle string + SeasonNumber *int + EpisodeNumber *int + }{} + } + + seriesIDs := make([]string, 0, len(episodes)) + seenSeriesIDs := make(map[string]struct{}, len(episodes)) + for _, episode := range episodes { + if episode == nil || strings.TrimSpace(episode.SeriesID) == "" { + continue + } + if _, ok := seenSeriesIDs[episode.SeriesID]; ok { + continue + } + seenSeriesIDs[episode.SeriesID] = struct{}{} + seriesIDs = append(seriesIDs, episode.SeriesID) + } + + seriesTitles := make(map[string]string, len(seriesIDs)) + if len(seriesIDs) > 0 { + if seriesItems, err := h.itemRepo.GetByIDs(ctx, seriesIDs); err == nil { + for _, seriesItem := range seriesItems { + if seriesItem == nil { + continue + } + seriesTitles[seriesItem.ContentID] = seriesItem.Title + } + } + } + + result := make(map[string]struct { + SeriesTitle string + SeasonNumber *int + EpisodeNumber *int + }, len(episodes)) + for _, episode := range episodes { + if episode == nil { + continue + } + seasonNumber := episode.SeasonNumber + episodeNumber := episode.EpisodeNumber + result[episode.ContentID] = struct { + SeriesTitle string + SeasonNumber *int + EpisodeNumber *int + }{ + SeriesTitle: seriesTitles[episode.SeriesID], + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + } + } + return result +} + +func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.MediaItem) map[string]*itemUserStateResponse { + store, profileID, ok := h.userStoreForRequest(r) + if !ok { + return map[string]*itemUserStateResponse{} + } + states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items) + if err != nil { + return map[string]*itemUserStateResponse{} + } + return states +} + +// toEpisodeResponse converts an Episode model to an API response. +func (h *ItemsHandler) toEpisodeResponse(r *http.Request, ep *models.Episode) episodeResponse { + return h.toEpisodeResponseWithFallback(r, ep, episodeImageFallback{}) +} + +func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models.Episode, fallback episodeImageFallback) episodeResponse { + if h.detailSvc != nil { + if localized, err := h.detailSvc.LocalizeEpisodeModel(r.Context(), ep, h.accessFilter(r)); err == nil && localized != nil { + ep = localized + } + } + stillPath := ep.StillPath + stillThumbhash := ep.StillThumbhash + if strings.TrimSpace(stillPath) == "" && strings.TrimSpace(fallback.Path) != "" { + stillPath = fallback.Path + stillThumbhash = fallback.Thumbhash + } + resp := episodeResponse{ + ContentID: ep.ContentID, + SeasonNumber: ep.SeasonNumber, + EpisodeNumber: ep.EpisodeNumber, + Title: ep.Title, + Overview: ep.Overview, + Runtime: ep.Runtime, + ImdbID: ep.ImdbID, + TmdbID: ep.TmdbID, + TvdbID: ep.TvdbID, + StillThumbhash: stillThumbhash, + } + + if ep.AirDate != nil { + resp.AirDate = ep.AirDate.Format("2006-01-02") + } + + resp.StillURL = h.presignURL(r, cardThumbnailPath(stillPath), "card") + + return resp +} + +func (h *ItemsHandler) episodeImageFallbacks(ctx context.Context, episodes []*models.Episode) map[string]episodeImageFallback { + if h.itemRepo == nil || len(episodes) == 0 { + return map[string]episodeImageFallback{} + } + seriesIDs := make([]string, 0, 1) + seen := make(map[string]struct{}) + for _, ep := range episodes { + if ep == nil || strings.TrimSpace(ep.SeriesID) == "" { + continue + } + if _, ok := seen[ep.SeriesID]; ok { + continue + } + seen[ep.SeriesID] = struct{}{} + seriesIDs = append(seriesIDs, ep.SeriesID) + } + if len(seriesIDs) == 0 { + return map[string]episodeImageFallback{} + } + + seriesItems, err := h.itemRepo.GetByIDs(ctx, seriesIDs) + if err != nil { + return map[string]episodeImageFallback{} + } + fallbacks := make(map[string]episodeImageFallback, len(seriesItems)) + for _, series := range seriesItems { + if series == nil { + continue + } + path := strings.TrimSpace(series.BackdropPath) + thumbhash := series.BackdropThumbhash + if path == "" { + path = strings.TrimSpace(series.PosterPath) + thumbhash = series.PosterThumbhash + } + if path == "" { + continue + } + fallbacks[series.ContentID] = episodeImageFallback{Path: path, Thumbhash: thumbhash} + } + return fallbacks +} + +func (h *ItemsHandler) listOverlaySummaries(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string]*models.OverlaySummary { + summaries := make(map[string]*models.OverlaySummary, len(items)) + if h.fileRepo == nil || len(items) == 0 { + return summaries + } + + contentIDs := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + if item == nil || item.ContentID == "" { + continue + } + if _, ok := seen[item.ContentID]; ok { + continue + } + seen[item.ContentID] = struct{}{} + contentIDs = append(contentIDs, item.ContentID) + } + if len(contentIDs) == 0 { + return summaries + } + + groupedFiles, err := h.fileRepo.ListByContentIDs(ctx, contentIDs) + if err != nil { + return summaries + } + for contentID, files := range groupedFiles { + files = catalog.FilterMediaFilesByAccess(files, filter) + if summary := overlays.BuildSummary(files); summary != nil { + summaries[contentID] = summary + } + } + return summaries +} + +// toSeasonResponse converts a Season model to an API response. +func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *models.Season) seasonResponse { + if h.detailSvc != nil { + if localized, err := h.detailSvc.LocalizeSeasonModel(r.Context(), s, h.accessFilter(r)); err == nil && localized != nil { + s = localized + } + } + episodes, _ := h.episodeRepo.ListBySeason(r.Context(), seriesID, s.SeasonNumber) + + resp := seasonResponse{ + ContentID: s.ContentID, + SeasonNumber: s.SeasonNumber, + IsSpecials: s.SeasonNumber == 0, + Title: s.Title, + Overview: s.Overview, + EpisodeCount: len(episodes), + PosterThumbhash: s.PosterThumbhash, + } + if s.AirDate != nil { + resp.AirDate = s.AirDate.Format("2006-01-02") + } + resp.PosterURL = h.presignURL(r, featuredPosterPath(s.PosterPath), "featured") + resp.UserData = h.getAggregateUserData(r, episodes) + + return resp +} + +func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData { + store, profileID, ok := h.userStoreForRequest(r) + if !ok { + return nil + } + + progress, err := store.GetProgress(r.Context(), profileID, contentID) + if err != nil || progress == nil { + return nil + } + + return &catalog.SeasonUserData{ + PositionSeconds: progress.PositionSeconds, + DurationSeconds: progress.DurationSeconds, + IsInProgress: !progress.Completed && progress.PositionSeconds > 0, + Played: progress.Completed, + LastFileID: progress.LastFileID, + LastResolution: progress.LastResolution, + LastHDR: progress.LastHDR, + LastCodecVideo: progress.LastCodecVideo, + LastEditionKey: progress.LastEditionKey, + } +} + +func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) { + if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" { + return + } + if target != nil && *target == nil { + *target = userData.LastEditionKey + } +} + +func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.Episode) *catalog.SeasonUserData { + if len(episodes) == 0 { + return nil + } + + store, profileID, ok := h.userStoreForRequest(r) + if !ok { + return nil + } + + var watchedCount int + var inProgressCount int + for _, ep := range episodes { + progress, progressErr := store.GetProgress(r.Context(), profileID, ep.ContentID) + if progressErr != nil || progress == nil { + continue + } + if progress.Completed { + watchedCount++ + continue + } + if progress.PositionSeconds > 0 { + inProgressCount++ + } + } + + unplayedCount := len(episodes) - watchedCount + return &catalog.SeasonUserData{ + WatchedCount: watchedCount, + UnplayedCount: unplayedCount, + InProgressCount: inProgressCount, + Played: watchedCount == len(episodes), + } +} + +func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) { + if h.storeProvider == nil { + return nil, "", false + } + + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + return nil, "", false + } + + profileID := apimw.GetProfileID(r.Context()) + if profileID == "" { + profileID = r.Header.Get("X-Profile-Id") + } + if profileID == "" { + return nil, "", false + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil || store == nil { + return nil, "", false + } + + return store, profileID, true +} + +// cardThumbnailPath converts an S3 image path from original to w300 for use in +// browse/card views (posters, backdrops, and stills at card size). +// Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is — +// variant selection is handled at resolution time for plugin paths. +func cardThumbnailPath(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + // Plugin-prefixed paths pass through — variant handled at resolution time. + if strings.Contains(path, "://") { + return path + } + return strings.Replace(path, "/original.", "/w300.", 1) +} + +// featuredPosterPath converts an S3 poster path from original to w500 for +// featured/hero contexts (displayed at ~220px CSS / 440px retina). +// Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. +func featuredPosterPath(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + if strings.Contains(path, "://") { + return path + } + return strings.Replace(path, "/original.", "/w500.", 1) +} + +// featuredBackdropPath converts an S3 backdrop path from original to w1920 for +// featured/hero contexts (displayed at full viewport width). +// Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. +func featuredBackdropPath(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + if strings.Contains(path, "://") { + return path + } + return strings.Replace(path, "/original.", "/w1920.", 1) +} + +// presignURL resolves an image path to a usable URL, delegating to the +// DetailService which handles plugin-prefixed paths, HTTP pass-through, +// and legacy S3 presigning. +func (h *ItemsHandler) presignURL(r *http.Request, path string, variant string) string { + if h.detailSvc != nil { + return h.detailSvc.PresignURL(r.Context(), path, variant) + } + return "" +} + +// HandleFilterItems handles POST /items/filter with a full rule-group filter body. +func (h *ItemsHandler) HandleFilterItems(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog/query") + h.catalogHandler().HandlePostCatalogQuery(w, r) +} + +type filterItemsRequest struct { + sections.FilterConfig + LibraryID int `json:"library_id"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +func filterItemColumns(alias string) string { + cols := []string{ + "content_id", "type", "title", "sort_title", "original_title", "year", "genres", + "content_rating", "runtime", "overview", "tagline", + "rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience", + "imdb_id", "tmdb_id", "tvdb_id", + "poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path", + "metadata_s3_path", "metadata_etag", "season_count", + "studios", "networks", "countries", "first_air_date", "last_air_date", + "matched_at", "status", "created_at", "updated_at", + } + prefixed := make([]string, len(cols)) + for i, c := range cols { + prefixed[i] = alias + "." + c + } + return strings.Join(prefixed, ", ") +} + +func filterSortClause(sort, order string) string { + dir := "DESC" + if order == "asc" { + dir = "ASC" + } + switch sort { + case "rating": + return fmt.Sprintf("ORDER BY mi.rating_imdb %s NULLS LAST", dir) + case "year": + return fmt.Sprintf("ORDER BY mi.year %s", dir) + case "title": + return fmt.Sprintf("ORDER BY LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) %s, LOWER(mi.title) %s, mi.content_id ASC", dir, dir) + default: + return fmt.Sprintf("ORDER BY mi.created_at %s", dir) + } +} + +func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter { + selectedFileID := 0 + if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" { + if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 { + selectedFileID = fileID + } + } + + var presentationLibraryID *int + if libraryIDRaw := strings.TrimSpace(r.URL.Query().Get("library_id")); libraryIDRaw != "" { + if libraryID, err := strconv.Atoi(libraryIDRaw); err == nil && libraryID > 0 { + presentationLibraryID = &libraryID + } + } + + if scope, ok := access.GetScope(r.Context()); ok { + return catalog.AccessFilter{ + AllowedLibraryIDs: scope.AllowedLibraryIDs, + DisabledLibraryIDs: scope.DisabledLibraryIDs, + MaxContentRating: scope.MaxContentRating, + MaxPlaybackQuality: scope.MaxPlaybackQuality, + PresentationLibraryID: presentationLibraryID, + SelectedFileID: selectedFileID, + UserID: apimw.GetUserID(r.Context()), + ProfileID: apimw.GetProfileID(r.Context()), + } + } + + var libraryIDs []int + var maxPlaybackQuality string + if h.UserRepo != nil { + userID := apimw.GetUserID(r.Context()) + if userID != 0 { + user, userErr := h.UserRepo.GetByID(r.Context(), userID) + if userErr != nil { + slog.Error("looking up user for library access", "error", userErr) + } else { + if user.LibraryIDs != nil { + libraryIDs = user.LibraryIDs + } + maxPlaybackQuality = access.NormalizePlaybackQuality(user.MaxPlaybackQuality) + } + } + } + + return catalog.AccessFilter{ + AllowedLibraryIDs: libraryIDs, + MaxPlaybackQuality: maxPlaybackQuality, + PresentationLibraryID: presentationLibraryID, + SelectedFileID: selectedFileID, + UserID: apimw.GetUserID(r.Context()), + ProfileID: apimw.GetProfileID(r.Context()), + } +} + +func (h *ItemsHandler) ensurePresentationLibraryAccess(ctx context.Context, contentID string, filter catalog.AccessFilter) error { + if filter.PresentationLibraryID == nil { + return nil + } + membership, err := h.itemRepo.GetItemsInLibrary(ctx, []string{contentID}, *filter.PresentationLibraryID) + if err != nil { + return err + } + if !membership[contentID] { + return catalog.ErrItemNotFound + } + return nil +} + +type watchedLeafTarget struct { + ContentID string + DurationSeconds float64 +} + +func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID string, filter catalog.AccessFilter) (string, []watchedLeafTarget, error) { + item, err := h.itemRepo.GetByID(ctx, contentID) + switch { + case err == nil: + if err := h.itemRepo.EnsureAccessible(ctx, contentID, filter); err != nil { + return "", nil, err + } + switch item.Type { + case "movie": + return "movie", []watchedLeafTarget{{ + ContentID: item.ContentID, + DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime), + }}, nil + case "series": + if h.episodeRepo == nil { + return "", nil, catalog.ErrItemNotFound + } + episodes, err := h.episodeRepo.ListBySeries(ctx, item.ContentID) + if err != nil { + return "", nil, err + } + return "series", h.episodeTargets(ctx, episodes), nil + default: + return "", nil, catalog.ErrItemNotFound + } + case !errors.Is(err, catalog.ErrItemNotFound): + return "", nil, err + } + + if h.seasonRepo != nil { + season, err := h.seasonRepo.GetByID(ctx, contentID) + switch { + case err == nil: + if err := h.itemRepo.EnsureAccessible(ctx, season.SeriesID, filter); err != nil { + return "", nil, err + } + if h.episodeRepo == nil { + return "", nil, catalog.ErrItemNotFound + } + episodes, err := h.episodeRepo.ListBySeasonID(ctx, season.ContentID) + if err != nil { + return "", nil, err + } + return "season", h.episodeTargets(ctx, episodes), nil + case !errors.Is(err, catalog.ErrSeasonNotFound): + return "", nil, err + } + } + + if h.episodeRepo == nil { + return "", nil, catalog.ErrItemNotFound + } + + episode, err := h.episodeRepo.GetByID(ctx, contentID) + if err != nil { + return "", nil, err + } + if err := h.itemRepo.EnsureAccessible(ctx, episode.SeriesID, filter); err != nil { + return "", nil, err + } + + return "episode", []watchedLeafTarget{{ + ContentID: episode.ContentID, + DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime), + }}, nil +} + +func (h *ItemsHandler) episodeTargets(ctx context.Context, episodes []*models.Episode) []watchedLeafTarget { + targets := make([]watchedLeafTarget, 0, len(episodes)) + for _, episode := range episodes { + targets = append(targets, watchedLeafTarget{ + ContentID: episode.ContentID, + DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime), + }) + } + return targets +} + +func (h *ItemsHandler) contentDurationSeconds(ctx context.Context, contentID, episodeID string, fallbackRuntimeMinutes int) float64 { + if h.fileRepo == nil { + if fallbackRuntimeMinutes > 0 { + return float64(fallbackRuntimeMinutes * 60) + } + return 0 + } + + var ( + files []*models.MediaFile + err error + ) + + switch { + case episodeID != "": + files, err = h.fileRepo.GetByEpisodeID(ctx, episodeID) + case contentID != "": + files, err = h.fileRepo.GetByContentID(ctx, contentID) + } + if err == nil { + for _, file := range files { + if file != nil && file.Duration > 0 { + return float64(file.Duration) + } + } + } + + if fallbackRuntimeMinutes > 0 { + return float64(fallbackRuntimeMinutes * 60) + } + return 0 +} + +// isNotFound checks if an error is a "not found" sentinel. +func isNotFound(err error) bool { + return errors.Is(err, catalog.ErrItemNotFound) || + errors.Is(err, catalog.ErrEpisodeNotFound) || + errors.Is(err, catalog.ErrSeasonNotFound) +} + +func requestIsAdmin(r *http.Request) bool { + claims := apimw.GetClaims(r.Context()) + return claims != nil && claims.Role == "admin" +} diff --git a/internal/api/handlers/legacy_read_routes.go b/internal/api/handlers/legacy_read_routes.go new file mode 100644 index 00000000..57019c07 --- /dev/null +++ b/internal/api/handlers/legacy_read_routes.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +const legacyReadSunset = "Wed, 01 Jul 2026 00:00:00 GMT" + +func writeDeprecatedReadHeaders(w http.ResponseWriter, successor string) { + headers := w.Header() + headers.Set("Deprecation", "true") + headers.Set("Sunset", legacyReadSunset) + if successor != "" { + headers.Set("Link", fmt.Sprintf("<%s>; rel=\"successor-version\"", successor)) + } +} + +func buildLegacyItemsCatalogValues(values url.Values) (url.Values, bool) { + translated := cloneCatalogValues(values) + if strings.TrimSpace(values.Get("person_id")) != "" { + translated.Set("source", "person") + } else { + translated.Set("source", "query") + } + + if sort := strings.TrimSpace(values.Get("sort")); sort != "" { + if sort == "created_at" { + sort = "added_at" + } + translated.Set("sort", sort) + } + + return translated, true +} + +func buildLegacyLatestCatalogValues(values url.Values) url.Values { + translated := cloneCatalogValues(values) + translated.Set("source", "query") + translated.Set("sort", "added_at") + translated.Set("order", "desc") + return translated +} + +func buildLegacySearchCatalogValues(values url.Values) (url.Values, bool) { + query := strings.TrimSpace(values.Get("q")) + if query == "" { + return nil, false + } + + types := parseSearchTypes(values["type"]) + if len(types) > 1 { + return nil, false + } + if len(types) == 1 && types[0] != "movie" && types[0] != "series" { + return nil, false + } + + translated := url.Values{} + translated.Set("source", "query") + translated.Set("q", query) + translated.Set("sort", "relevance") + translated.Set("order", "desc") + copyCatalogValue(translated, values, "limit") + copyCatalogValue(translated, values, "offset") + if len(types) == 1 { + translated.Set("type", types[0]) + } + return translated, true +} + +func cloneCatalogValues(values url.Values) url.Values { + translated := url.Values{} + for _, key := range []string{ + "q", + "name_prefix", + "type", + "genre", + "year_min", + "year_max", + "content_rating", + "library_id", + "person_id", + "status", + "limit", + "offset", + "order", + } { + copyCatalogValue(translated, values, key) + } + return translated +} + +func copyCatalogValue(dst, src url.Values, key string) { + if value := strings.TrimSpace(src.Get(key)); value != "" { + dst.Set(key, value) + } +} diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go new file mode 100644 index 00000000..2bf90584 --- /dev/null +++ b/internal/api/handlers/libraries.go @@ -0,0 +1,2357 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/oklog/ulid/v2" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/adminjob" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/catalog" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/libraryingest" + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/plugins" + "github.com/Silo-Server/silo-server/internal/scanner" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// LibraryHandler handles HTTP endpoints for library (media folder) management +// and scan triggering. +type LibraryHandler struct { + folderRepo *catalog.FolderRepository + ingester libraryIngester + userRepo *auth.UserRepository + pool *pgxpool.Pool + refresher AdminMetadataRefresher + chainCacheInvalidator interface{ InvalidateChainCache() } + JobRepo AdminJobCreator + ChainRepo *metadata.ChainRepository + PluginInstallations pluginInstallationLister + SkippedRootRepo *metadata.SkippedRootRepository + StaleIDRepo *metadata.StaleMediaIDRepository + ScannedGroupRepo *scanner.ScannedGroupRepository + GroupOverrideRepo *scanner.MediaGroupOverrideRepository + ObservedLocationRepo *scanner.ObservedLocationRepository + SectionRepo *sections.Repository + StoreProvider userstore.UserStoreProvider + S3Meta LibraryImageStore + PresignTTL time.Duration + appCtx context.Context + EventBus cache.EventBus + EventsHub *evt.Hub + ScanRegistry *evt.ScanRegistry + ScanQueue libraryScanQueuer +} + +// LibraryImageStore provides S3 operations for library poster images. +type LibraryImageStore interface { + PutObject(ctx context.Context, bucket, key string, data []byte) error + DeleteObject(ctx context.Context, bucket, key string) error + PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) + Bucket() string +} + +// pluginInstallationLister provides access to plugin installations and capabilities +// for seeding default provider chains from manifest metadata. +type pluginInstallationLister interface { + ListEnabled(ctx context.Context) ([]*plugins.Installation, error) + ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error) +} + +type libraryIngester interface { + IngestFolder(ctx context.Context, folder *models.MediaFolder) (*libraryingest.Result, error) + IngestSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*libraryingest.Result, error) + IngestFile(ctx context.Context, folder *models.MediaFolder, filePath string) (*libraryingest.Result, error) + CancelLibrary(folderID int) int +} + +type libraryScanQueuer interface { + EnqueueLibraryScan(ctx context.Context, folderID int, trigger string) (bool, error) + EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error) + CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error) + CancelByLibrary(ctx context.Context, libraryID int) (int, error) +} + +// NewLibraryHandler creates a new LibraryHandler backed by the given folder +// repository and ingest executor. The ingester may be nil if scan endpoints are not needed. +func NewLibraryHandler( + folderRepo *catalog.FolderRepository, + ingester libraryIngester, + userRepo *auth.UserRepository, + pool *pgxpool.Pool, + refresher AdminMetadataRefresher, + appCtx ...context.Context, +) *LibraryHandler { + ctx := context.Background() + if len(appCtx) > 0 && appCtx[0] != nil { + ctx = appCtx[0] + } + var scannedGroupRepo *scanner.ScannedGroupRepository + var groupOverrideRepo *scanner.MediaGroupOverrideRepository + var observedLocationRepo *scanner.ObservedLocationRepository + if pool != nil { + scannedGroupRepo = scanner.NewScannedGroupRepository(pool) + groupOverrideRepo = scanner.NewMediaGroupOverrideRepository(pool) + observedLocationRepo = scanner.NewObservedLocationRepository(pool) + } + return &LibraryHandler{ + folderRepo: folderRepo, + ingester: ingester, + userRepo: userRepo, + pool: pool, + refresher: refresher, + ScannedGroupRepo: scannedGroupRepo, + GroupOverrideRepo: groupOverrideRepo, + ObservedLocationRepo: observedLocationRepo, + appCtx: ctx, + } +} + +func (h *LibraryHandler) SetChainCacheInvalidator(invalidator interface{ InvalidateChainCache() }) { + if h == nil { + return + } + h.chainCacheInvalidator = invalidator +} + +// validMetadataLanguages is the set of ISO 639-1 codes accepted for +// per-library metadata language. Kept in sync with the frontend LANGUAGES list. +var validMetadataLanguages = map[string]bool{ + "en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true, + "nl": true, "pl": true, "ru": true, "zh": true, "ja": true, "ko": true, + "ar": true, "tr": true, "sv": true, "da": true, "no": true, "fi": true, + "hu": true, "cs": true, "ro": true, "he": true, "th": true, "vi": true, + "el": true, "bg": true, "hr": true, "sk": true, "sl": true, "uk": true, + "id": true, "ms": true, "hi": true, "ta": true, "te": true, "bn": true, + "fa": true, +} + +// --- Request/Response types --- + +// createLibraryRequest represents the JSON body for POST /libraries. +type createLibraryRequest struct { + Paths []string `json:"paths"` + Type string `json:"type"` + Name string `json:"name"` + MetadataLanguage string `json:"metadata_language,omitempty"` + ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled,omitempty"` + IntroDetectionEnabled bool `json:"intro_detection_enabled,omitempty"` +} + +// updateLibraryRequest represents the JSON body for PUT /libraries/{id}. +type updateLibraryRequest struct { + Paths *[]string `json:"paths,omitempty"` + Type *string `json:"type,omitempty"` + Name *string `json:"name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + MetadataLanguage *string `json:"metadata_language,omitempty"` + ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"` + IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"` +} + +// scanRequest represents the JSON body for POST /scan. +type scanRequest struct { + LibraryID *int `json:"library_id,omitempty"` + Path string `json:"path,omitempty"` +} + +type scanResponse struct { + Status string `json:"status"` + Mode string `json:"mode"` + LibraryID int `json:"library_id"` +} + +// scanCancelRequest represents the JSON body for POST /scan/cancel. +type scanCancelRequest struct { + LibraryID int `json:"library_id"` +} + +type scanCancelResponse struct { + Cancelled int `json:"cancelled"` + LibraryID int `json:"library_id"` +} + +// libraryResponse represents a library (media folder) in JSON responses. +type libraryResponse struct { + ID int `json:"id"` + Paths []string `json:"paths"` + Type string `json:"type"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + MetadataLanguage string `json:"metadata_language"` + ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"` + ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"` + IntroDetectionEnabled bool `json:"intro_detection_enabled"` + SortOrder int `json:"sort_order"` + PosterURL string `json:"poster_url,omitempty"` + LastScannedAt *time.Time `json:"last_scanned_at,omitempty"` + ScanWarningCode *string `json:"scan_warning_code,omitempty"` + ScanWarningMessage *string `json:"scan_warning_message,omitempty"` + ScanWarningAt *time.Time `json:"scan_warning_at,omitempty"` +} + +type libraryMountCheckRootResponse struct { + Path string `json:"path"` + Reachable bool `json:"reachable"` + ErrorCode *string `json:"error_code"` + ErrorMessage *string `json:"error_message"` +} + +type libraryMountCheckResponse struct { + Status string `json:"status"` + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + Healthy bool `json:"healthy"` + CheckedAt time.Time `json:"checked_at"` + Summary string `json:"summary"` + Roots []libraryMountCheckRootResponse `json:"roots"` +} + +type librarySkippedRootResponse struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + RootPath string `json:"root_path"` + Reason string `json:"reason"` + SampleFilePath string `json:"sample_file_path"` + FileCount int `json:"file_count"` + FirstSeenAt time.Time `json:"first_seen_at"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +type staleMediaIDResponse struct { + ContentID string `json:"content_id"` + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + Title string `json:"title"` + Year int `json:"year"` + ContentType string `json:"content_type"` + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` + FirstSeenAt string `json:"first_seen_at"` + LastSeenAt string `json:"last_seen_at"` +} + +type libraryRootResponse struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + RootPath string `json:"root_path"` + State string `json:"state"` + InferredType string `json:"inferred_type"` + TypeConfidence string `json:"type_confidence"` + Title string `json:"title"` + Year int `json:"year"` + TmdbID string `json:"tmdb_id,omitempty"` + ImdbID string `json:"imdb_id,omitempty"` + TvdbID string `json:"tvdb_id,omitempty"` + ObservedFiles int `json:"observed_file_count"` + SampleFilePath string `json:"sample_file_path,omitempty"` + Evidence json.RawMessage `json:"evidence_json,omitempty"` + OverrideSource string `json:"override_source,omitempty"` + FirstSeenAt time.Time `json:"first_seen_at"` + LastSeenAt time.Time `json:"last_seen_at"` + ActiveOverride *rootOverride `json:"active_override,omitempty"` +} + +type rootOverride struct { + ForcedType string `json:"forced_type,omitempty"` + ForcedTitle string `json:"forced_title,omitempty"` + ForcedYear int `json:"forced_year,omitempty"` + ForcedTmdbID string `json:"forced_tmdb_id,omitempty"` + ForcedImdbID string `json:"forced_imdb_id,omitempty"` + ForcedTvdbID string `json:"forced_tvdb_id,omitempty"` + Note string `json:"note,omitempty"` +} + +type libraryRootsListResponse struct { + Items []libraryRootResponse `json:"items"` + Total int `json:"total"` +} + +type rootOverrideUpsertRequest struct { + LibraryID int `json:"library_id"` + RootPath string `json:"root_path"` + ForcedType string `json:"forced_type,omitempty"` + ForcedTitle string `json:"forced_title,omitempty"` + ForcedYear int `json:"forced_year,omitempty"` + ForcedTmdbID string `json:"forced_tmdb_id,omitempty"` + ForcedImdbID string `json:"forced_imdb_id,omitempty"` + ForcedTvdbID string `json:"forced_tvdb_id,omitempty"` + Note string `json:"note,omitempty"` +} + +type rootOverrideDeleteRequest struct { + LibraryID int `json:"library_id"` + RootPath string `json:"root_path"` +} + +func groupOverrideLookupKey(groupKeyVersion int, contentGroupKey string) string { + return strconv.Itoa(groupKeyVersion) + "|" + contentGroupKey +} + +// toLibraryResponse converts a MediaFolder model to a libraryResponse. +func toLibraryResponse(f *models.MediaFolder) libraryResponse { + paths := f.Paths + if paths == nil { + paths = []string{} + } + return libraryResponse{ + ID: f.ID, + Paths: paths, + Type: f.Type, + Name: f.Name, + Enabled: f.Enabled, + MetadataLanguage: f.MetadataLanguage, + ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled, + ChapterThumbnailsSupported: false, + IntroDetectionEnabled: f.IntroDetectionEnabled, + SortOrder: f.SortOrder, + LastScannedAt: f.LastScannedAt, + ScanWarningCode: f.ScanWarningCode, + ScanWarningMessage: f.ScanWarningMessage, + ScanWarningAt: f.ScanWarningAt, + } +} + +// toLibraryResponseWithPoster converts a MediaFolder model to a libraryResponse +// and presigns the poster URL if a poster path is set. +func (h *LibraryHandler) toLibraryResponseWithPoster(ctx context.Context, f *models.MediaFolder) libraryResponse { + resp := toLibraryResponse(f) + resp.ChapterThumbnailsSupported = h.S3Meta != nil + if f.PosterPath != "" && h.S3Meta != nil { + ttl := h.PresignTTL + if ttl <= 0 { + ttl = 4 * time.Hour + } + url, err := h.S3Meta.PresignGetURL(ctx, h.S3Meta.Bucket(), f.PosterPath, ttl) + if err == nil { + resp.PosterURL = url + } + } + return resp +} + +// userLibraryResponse is a simplified library view for non-admin users. +type userLibraryResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + SortOrder int `json:"sort_order"` + PosterURL string `json:"poster_url,omitempty"` +} + +// --- Handler methods --- + +// HandleListUserLibraries handles GET /user/libraries. +// It returns only enabled libraries the current user has access to, with +// simplified fields (no paths, last scan metadata, etc.). +func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http.Request) { + var folders []*models.MediaFolder + var err error + if scope, ok := access.GetScope(r.Context()); ok { + if scope.LibrariesRestricted { + folders, err = h.folderRepo.ListByIDs(r.Context(), scope.AllowedLibraryIDs) + } else { + folders, err = h.folderRepo.GetEnabled(r.Context()) + } + } else { + userID := apimw.GetUserID(r.Context()) + + if h.userRepo != nil { + user, userErr := h.userRepo.GetByID(r.Context(), userID) + if userErr != nil { + slog.Error("looking up user for library access", "error", userErr) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up user") + return + } + + if user.LibraryIDs != nil { + folders, err = h.folderRepo.ListByIDs(r.Context(), user.LibraryIDs) + } else { + folders, err = h.folderRepo.GetEnabled(r.Context()) + } + } else { + folders, err = h.folderRepo.GetEnabled(r.Context()) + } + } + + if err != nil { + slog.Error("listing user libraries", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") + return + } + + resp := make([]userLibraryResponse, 0, len(folders)) + for _, f := range folders { + entry := userLibraryResponse{ + ID: f.ID, + Name: f.Name, + Type: f.Type, + SortOrder: f.SortOrder, + } + if f.PosterPath != "" && h.S3Meta != nil { + ttl := h.PresignTTL + if ttl <= 0 { + ttl = 4 * time.Hour + } + if url, err := h.S3Meta.PresignGetURL(r.Context(), h.S3Meta.Bucket(), f.PosterPath, ttl); err == nil { + entry.PosterURL = url + } + } + resp = append(resp, entry) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleListLibraries handles GET /libraries. +func (h *LibraryHandler) HandleListLibraries(w http.ResponseWriter, r *http.Request) { + folders, err := h.folderRepo.List(r.Context()) + if err != nil { + slog.Error("listing libraries", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") + return + } + + resp := make([]libraryResponse, 0, len(folders)) + for _, f := range folders { + resp = append(resp, h.toLibraryResponseWithPoster(r.Context(), f)) + } + + writeJSON(w, http.StatusOK, resp) +} + +// reorderLibrariesRequest is the JSON body for PUT /libraries/reorder. +type reorderLibrariesRequest struct { + Entries []catalog.FolderReorderEntry `json:"entries"` +} + +// HandleReorderLibraries handles PUT /libraries/reorder. +func (h *LibraryHandler) HandleReorderLibraries(w http.ResponseWriter, r *http.Request) { + var req reorderLibrariesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if err := h.folderRepo.Reorder(r.Context(), req.Entries); err != nil { + slog.Error("reordering libraries", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder libraries") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleListSkippedRoots handles GET /libraries/skipped-roots. +func (h *LibraryHandler) HandleListSkippedRoots(w http.ResponseWriter, r *http.Request) { + if h.SkippedRootRepo == nil { + writeJSON(w, http.StatusOK, []librarySkippedRootResponse{}) + return + } + + folders, err := h.folderRepo.List(r.Context()) + if err != nil { + slog.Error("listing libraries for skipped roots", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") + return + } + + folderNames := make(map[int]string, len(folders)) + for _, folder := range folders { + folderNames[folder.ID] = folder.Name + } + + roots, err := h.SkippedRootRepo.ListAll(r.Context()) + if err != nil { + slog.Error("listing skipped roots", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list skipped roots") + return + } + + resp := make([]librarySkippedRootResponse, 0, len(roots)) + for _, root := range roots { + resp = append(resp, librarySkippedRootResponse{ + LibraryID: root.MediaFolderID, + LibraryName: folderNames[root.MediaFolderID], + RootPath: root.RootPath, + Reason: root.Reason, + SampleFilePath: root.SampleFilePath, + FileCount: root.FileCount, + FirstSeenAt: root.FirstSeenAt, + LastSeenAt: root.LastSeenAt, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleCreateLibrary handles POST /libraries. +func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Request) { + var req createLibraryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if len(req.Paths) == 0 || req.Type == "" || req.Name == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Paths, type, and name are required") + return + } + if req.MetadataLanguage != "" && !validMetadataLanguages[req.MetadataLanguage] { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid metadata_language; must be a valid ISO 639-1 code") + return + } + if req.ChapterThumbnailsEnabled && h.S3Meta == nil { + writeError(w, http.StatusBadRequest, "bad_request", "Chapter thumbnails require configured public asset S3 storage") + return + } + + folder, err := h.folderRepo.Create(r.Context(), catalog.CreateFolderInput{ + Paths: req.Paths, + Type: req.Type, + Name: req.Name, + MetadataLanguage: req.MetadataLanguage, + ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled, + IntroDetectionEnabled: req.IntroDetectionEnabled, + }) + if err != nil { + if errors.Is(err, catalog.ErrDuplicatePath) { + writeError(w, http.StatusConflict, "conflict", "A library with this path already exists") + return + } + slog.Error("creating library", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create library") + return + } + + // Seed default sections for the new library. + if h.SectionRepo != nil { + if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil { + slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr) + } + if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name); seedErr != nil { + slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr) + } + } + + // Seed default provider chain from plugin manifest defaults. + if h.ChainRepo != nil { + entries := h.seedDefaultChain(r.Context(), req.Type) + if len(entries) > 0 { + if seedErr := h.ChainRepo.SetChain(r.Context(), folder.ID, entries); seedErr != nil { + slog.Warn("seed default chain failed", "folder_id", folder.ID, "error", seedErr) + } + } + } + + // Kick off an initial scan so content appears immediately. + if h.ScanQueue != nil { + if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_created"); err != nil { + slog.Warn("queue initial library scan failed", "library_id", folder.ID, "error", err) + } + } else { + initialScanID := ulid.Make().String() + h.recordAcceptedScan(initialScanID, &resolvedScanTarget{ + folder: folder, + mode: scanModeLibrary, + trigger: "library_created", + }) + h.runFolderScanAsync(initialScanID, folder, "library_created") + } + + writeJSON(w, http.StatusCreated, h.toLibraryResponseWithPoster(r.Context(), folder)) +} + +// HandleUpdateLibrary handles PUT /libraries/{id}. +func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + var req updateLibraryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.MetadataLanguage != nil && *req.MetadataLanguage != "" && !validMetadataLanguages[*req.MetadataLanguage] { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid metadata_language; must be a valid ISO 639-1 code") + return + } + if req.ChapterThumbnailsEnabled != nil && *req.ChapterThumbnailsEnabled && h.S3Meta == nil { + writeError(w, http.StatusBadRequest, "bad_request", "Chapter thumbnails require configured public asset S3 storage") + return + } + + // Fetch the folder before updating so we can detect path changes. + oldFolder, err := h.folderRepo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + slog.Error("fetching library for update", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + err = h.folderRepo.Update(r.Context(), id, catalog.UpdateFolderInput{ + Paths: req.Paths, + Type: req.Type, + Name: req.Name, + Enabled: req.Enabled, + MetadataLanguage: req.MetadataLanguage, + ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled, + IntroDetectionEnabled: req.IntroDetectionEnabled, + }) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + if errors.Is(err, catalog.ErrDuplicatePath) { + writeError(w, http.StatusConflict, "conflict", "A library with this path already exists") + return + } + slog.Error("updating library", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update library") + return + } + + // Fetch the updated folder to return it. + folder, err := h.folderRepo.GetByID(r.Context(), id) + if err != nil { + slog.Error("fetching updated library", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated library") + return + } + + if h.SectionRepo != nil && oldFolder.Name != folder.Name { + if syncErr := h.SectionRepo.SyncGeneratedHomeLibraryRecentTitles(r.Context(), id, oldFolder.Name, folder.Name); syncErr != nil { + slog.Warn("sync generated home section titles", "library_id", id, "error", syncErr) + } + } + + // Rescan when paths have changed (folders added or removed). + if req.Paths != nil && !slices.Equal(oldFolder.Paths, *req.Paths) { + if h.ScanQueue != nil { + if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_paths_changed"); err != nil { + slog.Warn("queue library path-change scan failed", "library_id", folder.ID, "error", err) + } + } else { + updateScanID := ulid.Make().String() + h.recordAcceptedScan(updateScanID, &resolvedScanTarget{ + folder: folder, + mode: scanModeLibrary, + trigger: "library_paths_changed", + }) + h.runFolderScanAsync(updateScanID, folder, "library_paths_changed") + } + } + + writeJSON(w, http.StatusOK, h.toLibraryResponseWithPoster(r.Context(), folder)) +} + +// HandleDeleteLibrary handles DELETE /libraries/{id}. +func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + if h.JobRepo == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Library delete jobs are not configured") + return + } + + folder, err := h.folderRepo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + slog.Error("fetching library before delete", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library") + return + } + + wasEnabled := folder.Enabled + if wasEnabled { + disabled := false + if err := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &disabled}); err != nil { + slog.Error("disabling library before delete", "library_id", folder.ID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare library deletion") + return + } + folder.Enabled = false + } + + job, err := h.JobRepo.Create(r.Context(), adminjob.CreateJobInput{ + JobType: adminjob.JobTypeDeleteLibrary, + CreatedByUserID: currentAdminUserID(r), + RequestPayload: adminjob.DeleteLibraryRequest{ + LibraryID: folder.ID, + LibraryName: folder.Name, + }, + Message: "Queued library deletion", + }) + if err != nil { + if wasEnabled { + enabled := true + if revertErr := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &enabled}); revertErr != nil { + slog.Error("re-enabling library after failed delete queue", + "library_id", folder.ID, + "queue_error", err, + "revert_error", revertErr, + ) + } + } + var conflict *adminjob.ActiveJobConflictError + if errors.As(err, &conflict) { + jobsHandler := NewAdminJobsHandler(nil, nil) + writeAdminJobConflict(w, "A library deletion is already queued or running", conflict.Job, jobsHandler, r) + return + } + slog.Error("queuing library delete job", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library delete") + return + } + + if h.ingester != nil { + cancelled := h.ingester.CancelLibrary(folder.ID) + slog.Info("library delete: cancelled running scans", "library_id", folder.ID, "cancelled", cancelled) + } + if h.ScanQueue != nil { + queuedCancelled, err := h.ScanQueue.CancelAcceptedByLibrary(r.Context(), folder.ID) + if err != nil { + slog.Warn("library delete: failed to cancel queued scans", "library_id", folder.ID, "error", err) + } else if queuedCancelled > 0 { + slog.Info("library delete: cancelled queued scans", "library_id", folder.ID, "cancelled", queuedCancelled) + } + } + publishEventJob(r.Context(), h.EventsHub, "job.created", job) + + writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil)) +} + +// HandleCheckLibraryMount handles POST /libraries/{id}/check-mount. +// It verifies that each configured library root exists and can be listed. +func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + folder, err := h.folderRepo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + slog.Error("fetching library for mount check", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + resp := checkLibraryMount(folder) + if resp.Healthy && folder.ScanWarningCode != nil && *folder.ScanWarningCode == "empty_root" { + if err := h.folderRepo.ClearScanWarning(r.Context(), folder.ID); err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + slog.Error("clearing empty-root warning after successful mount check", "library_id", folder.ID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear library warning") + return + } + } + + writeJSON(w, http.StatusOK, resp) +} + +type scanMode string + +const ( + scanModeLibrary scanMode = "library" + scanModeSubtree scanMode = "subtree" + scanModeFile scanMode = "file" +) + +type resolvedScanTarget struct { + folder *models.MediaFolder + mode scanMode + path string + trigger string +} + +type scanRequestError struct { + status int + code string + message string +} + +func (e *scanRequestError) Error() string { return e.message } + +// HandleScan handles POST /scan. It accepts either a library_id, a path, or both +// and dispatches to full-library, subtree, or single-file scanning. +func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { + var req scanRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + target, err := h.resolveScanTarget(r.Context(), req) + if err != nil { + var reqErr *scanRequestError + if errors.As(err, &reqErr) { + writeError(w, reqErr.status, reqErr.code, reqErr.message) + return + } + slog.Error("resolving scan target", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve scan target") + return + } + + if h.ScanQueue != nil { + if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.folder.ID, string(target.mode), target.path, target.trigger); err != nil { + slog.Error("queueing library scan", "library_id", target.folder.ID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan") + return + } + } else if h.ingester != nil { + scanID := ulid.Make().String() + h.recordAcceptedScan(scanID, target) + switch target.mode { + case scanModeFile: + h.runFileScanAsync(scanID, target.folder, target.path, target.trigger) + case scanModeSubtree: + h.runSubtreeScanAsync(scanID, target.folder, target.path, target.trigger) + default: + h.runFolderScanAsync(scanID, target.folder, target.trigger) + } + } else { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") + return + } + + writeJSON(w, http.StatusAccepted, scanResponse{ + Status: "accepted", + Mode: string(target.mode), + LibraryID: target.folder.ID, + }) +} + +// HandleScanCancel handles POST /scan/cancel. It cancels all running scans +// for a given library. +func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request) { + var req scanCancelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.LibraryID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "library_id is required") + return + } + if h.ingester == nil && h.ScanQueue == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") + return + } + + cancelled := 0 + if h.ScanQueue != nil { + queuedCancelled, err := h.ScanQueue.CancelByLibrary(r.Context(), req.LibraryID) + if err != nil { + slog.Error("cancel library scans", "library_id", req.LibraryID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel scans") + return + } + cancelled += queuedCancelled + } + if h.ingester != nil { + cancelled += h.ingester.CancelLibrary(req.LibraryID) + } + for _, run := range h.cancelActiveScans(req.LibraryID) { + h.publishScanEvent(r.Context(), "scan.cancelled", run) + } + slog.Info("scan: cancelled running scans", + "library_id", req.LibraryID, + "cancelled", cancelled, + ) + + writeJSON(w, http.StatusOK, scanCancelResponse{ + Cancelled: cancelled, + LibraryID: req.LibraryID, + }) +} + +func (h *LibraryHandler) resolveScanTarget(ctx context.Context, req scanRequest) (*resolvedScanTarget, error) { + if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" { + return nil, &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "Either library_id or path is required", + } + } + + var ( + folder *models.MediaFolder + err error + ) + if req.LibraryID != nil { + folder, err = h.folderRepo.GetByID(ctx, *req.LibraryID) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + return nil, &scanRequestError{ + status: http.StatusNotFound, + code: "not_found", + message: "Library not found", + } + } + return nil, fmt.Errorf("fetching library for scan: %w", err) + } + } + + if strings.TrimSpace(req.Path) == "" { + if folder != nil && !folder.Enabled { + return nil, &scanRequestError{ + status: http.StatusConflict, + code: "conflict", + message: "Library is disabled", + } + } + return &resolvedScanTarget{ + folder: folder, + mode: scanModeLibrary, + trigger: "manual", + }, nil + } + + cleanPath := filepath.Clean(req.Path) + var matchedRoot string + if folder != nil { + matchedRoot, err = longestMatchingRoot(cleanPath, folder.Paths) + if err != nil { + return nil, err + } + if matchedRoot == "" { + return nil, &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "Path does not belong to the specified library", + } + } + } else { + folders, err := h.folderRepo.List(ctx) + if err != nil { + return nil, fmt.Errorf("listing libraries for scan: %w", err) + } + folder, matchedRoot, err = matchFolderForPath(cleanPath, folders) + if err != nil { + return nil, err + } + } + if folder != nil && !folder.Enabled { + return nil, &scanRequestError{ + status: http.StatusConflict, + code: "conflict", + message: "Library is disabled", + } + } + + mode, err := classifyScanPath(cleanPath, matchedRoot) + if err != nil { + return nil, err + } + + trigger := "path" + if req.LibraryID != nil { + trigger = "library_id_path" + } + + return &resolvedScanTarget{ + folder: folder, + mode: mode, + path: cleanPath, + trigger: trigger, + }, nil +} + +func longestMatchingRoot(targetPath string, roots []string) (string, error) { + bestRoot := "" + bestLen := -1 + for _, root := range roots { + if !pathWithinRoot(targetPath, root) { + continue + } + cleanRoot := filepath.Clean(root) + rootLen := len(cleanRoot) + if rootLen > bestLen { + bestRoot = cleanRoot + bestLen = rootLen + } + } + return bestRoot, nil +} + +func matchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) { + var ( + bestFolder *models.MediaFolder + bestRoot string + bestLen = -1 + ambiguous bool + ) + + for _, folder := range folders { + if folder == nil { + continue + } + root, err := longestMatchingRoot(targetPath, folder.Paths) + if err != nil { + return nil, "", err + } + if root == "" { + continue + } + rootLen := len(root) + if rootLen > bestLen { + bestFolder = folder + bestRoot = root + bestLen = rootLen + ambiguous = false + continue + } + if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID { + ambiguous = true + } + } + + if ambiguous { + return nil, "", &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "Path matches multiple libraries", + } + } + if bestFolder == nil { + return nil, "", &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "No library matches the given path", + } + } + return bestFolder, bestRoot, nil +} + +func classifyScanPath(targetPath, matchedRoot string) (scanMode, error) { + if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) { + return scanModeLibrary, nil + } + + info, err := os.Stat(targetPath) + if err != nil { + switch { + case errors.Is(err, os.ErrNotExist): + return "", &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "Path does not exist", + } + case errors.Is(err, os.ErrPermission): + return "", &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "Permission denied for path", + } + default: + return "", &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "Path could not be inspected", + } + } + } + if info.IsDir() { + return scanModeSubtree, nil + } + if !info.Mode().IsRegular() { + return "", &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "Path must be a file or directory", + } + } + if !scanner.SupportsVideoFile(targetPath) { + return "", &scanRequestError{ + status: http.StatusBadRequest, + code: "bad_request", + message: "Unsupported media file extension", + } + } + return scanModeFile, nil +} + +func pathWithinRoot(targetPath, rootPath string) bool { + cleanTarget := filepath.Clean(targetPath) + cleanRoot := filepath.Clean(rootPath) + rel, err := filepath.Rel(cleanRoot, cleanTarget) + if err != nil { + return false + } + if rel == "." || rel == "" { + return true + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func (h *LibraryHandler) runFolderScanAsync(scanID string, folder *models.MediaFolder, trigger string) { + go func() { + h.markScanRunning(scanID) + slog.Info("scan: starting library scan", + "trigger", trigger, + "library_id", folder.ID, + "name", folder.Name, + "paths", folder.Paths, + ) + + start := time.Now() + + result, ingestErr := h.ingester.IngestFolder(h.appCtx, folder) + if ingestErr != nil { + if errors.Is(ingestErr, context.Canceled) { + h.markScanCancelled(scanID) + slog.Info("scan: library scan canceled", + "trigger", trigger, + "library_id", folder.ID, + "elapsed", time.Since(start).Round(time.Millisecond), + ) + return + } + h.markScanFailed(scanID, ingestErr) + slog.Error("scan: library ingest failed", + "trigger", trigger, + "library_id", folder.ID, + "paths", folder.Paths, + "error", ingestErr, + "elapsed", time.Since(start).Round(time.Millisecond), + ) + return + } + h.markScanCompleted(scanID, result) + + slog.Info("scan: library ingest complete", + "trigger", trigger, + "library_id", folder.ID, + "name", folder.Name, + "new", scanMetric(result, func(r *scanner.ScanResult) int { return r.New }), + "updated", scanMetric(result, func(r *scanner.ScanResult) int { return r.Updated }), + "unchanged", scanMetric(result, func(r *scanner.ScanResult) int { return r.Unchanged }), + "missing", scanMetric(result, func(r *scanner.ScanResult) int { return r.Missing }), + "files_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.FilesDeleted }), + "memberships_removed", scanMetric(result, func(r *scanner.ScanResult) int { return r.MembershipsRemoved }), + "items_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.ItemsDeleted }), + "empty_root_guarded", scanBoolMetric(result, func(r *scanner.ScanResult) bool { return r.EmptyRootGuarded }), + "errors", scanMetric(result, func(r *scanner.ScanResult) int { return r.Errors }), + "matched_files", result.MatchedFiles, + "retried_items", result.RetriedItems, + "still_unmatched_warnings", result.StillUnmatchedWarnings, + "skipped", result.Skipped, + "elapsed", time.Since(start).Round(time.Millisecond), + ) + }() +} + +func (h *LibraryHandler) runSubtreeScanAsync(scanID string, folder *models.MediaFolder, subtreePath, trigger string) { + go func() { + h.markScanRunning(scanID) + slog.Info("scan: starting subtree scan", + "trigger", trigger, + "library_id", folder.ID, + "name", folder.Name, + "path", subtreePath, + ) + + start := time.Now() + + result, ingestErr := h.ingester.IngestSubtree(h.appCtx, folder, subtreePath) + if ingestErr != nil { + if errors.Is(ingestErr, context.Canceled) { + h.markScanCancelled(scanID) + slog.Info("scan: subtree scan canceled", + "trigger", trigger, + "library_id", folder.ID, + "path", subtreePath, + "elapsed", time.Since(start).Round(time.Millisecond), + ) + return + } + h.markScanFailed(scanID, ingestErr) + slog.Error("scan: subtree ingest failed", + "trigger", trigger, + "library_id", folder.ID, + "path", subtreePath, + "error", ingestErr, + "elapsed", time.Since(start).Round(time.Millisecond), + ) + return + } + h.markScanCompleted(scanID, result) + + slog.Info("scan: subtree ingest complete", + "trigger", trigger, + "library_id", folder.ID, + "name", folder.Name, + "path", subtreePath, + "new", scanMetric(result, func(r *scanner.ScanResult) int { return r.New }), + "updated", scanMetric(result, func(r *scanner.ScanResult) int { return r.Updated }), + "unchanged", scanMetric(result, func(r *scanner.ScanResult) int { return r.Unchanged }), + "missing", scanMetric(result, func(r *scanner.ScanResult) int { return r.Missing }), + "files_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.FilesDeleted }), + "memberships_removed", scanMetric(result, func(r *scanner.ScanResult) int { return r.MembershipsRemoved }), + "items_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.ItemsDeleted }), + "errors", scanMetric(result, func(r *scanner.ScanResult) int { return r.Errors }), + "matched_files", result.MatchedFiles, + "retried_items", result.RetriedItems, + "still_unmatched_warnings", result.StillUnmatchedWarnings, + "skipped", result.Skipped, + "elapsed", time.Since(start).Round(time.Millisecond), + ) + }() +} + +func (h *LibraryHandler) runFileScanAsync(scanID string, folder *models.MediaFolder, filePath, trigger string) { + go func() { + h.markScanRunning(scanID) + slog.Info("scan: starting file scan", + "trigger", trigger, + "library_id", folder.ID, + "name", folder.Name, + "path", filePath, + ) + + result, ingestErr := h.ingester.IngestFile(h.appCtx, folder, filePath) + if ingestErr != nil { + if errors.Is(ingestErr, context.Canceled) { + h.markScanCancelled(scanID) + slog.Info("scan: file scan canceled", + "trigger", trigger, + "library_id", folder.ID, + "path", filePath, + ) + return + } + h.markScanFailed(scanID, ingestErr) + slog.Error("scan: file ingest failed", + "trigger", trigger, + "library_id", folder.ID, + "path", filePath, + "error", ingestErr, + ) + return + } + h.markScanCompleted(scanID, result) + + slog.Info("scan: file ingest complete", + "trigger", trigger, + "library_id", folder.ID, + "name", folder.Name, + "path", filePath, + "matched_files", result.MatchedFiles, + "retried_items", result.RetriedItems, + "still_unmatched_warnings", result.StillUnmatchedWarnings, + "skipped", result.Skipped, + ) + }() +} + +func (h *LibraryHandler) recordAcceptedScan(scanID string, target *resolvedScanTarget) { + if h == nil || h.ScanRegistry == nil || target == nil || target.folder == nil { + return + } + h.ScanRegistry.Upsert(evt.ScanRun{ + ID: scanID, + LibraryID: target.folder.ID, + Mode: string(target.mode), + Path: target.path, + Trigger: target.trigger, + Status: "accepted", + }) +} + +func (h *LibraryHandler) markScanRunning(scanID string) { + if h == nil || h.ScanRegistry == nil { + return + } + run, ok := h.ScanRegistry.Get(scanID) + if !ok { + return + } + now := time.Now().UTC() + run.Status = "running" + run.StartedAt = &now + h.ScanRegistry.Upsert(run) + h.publishScanEvent(context.Background(), "scan.started", run) +} + +func (h *LibraryHandler) markScanCompleted(scanID string, result *libraryingest.Result) { + if h == nil || h.ScanRegistry == nil { + return + } + run, ok := h.ScanRegistry.Get(scanID) + if !ok { + return + } + now := time.Now().UTC() + run.Status = "completed" + run.CompletedAt = &now + run.Result = scanRunResultFromIngest(result) + h.ScanRegistry.MarkTerminal(run) + h.publishScanEvent(context.Background(), "scan.completed", run) +} + +func (h *LibraryHandler) markScanFailed(scanID string, err error) { + if h == nil || h.ScanRegistry == nil { + return + } + run, ok := h.ScanRegistry.Get(scanID) + if !ok { + return + } + now := time.Now().UTC() + run.Status = "failed" + run.CompletedAt = &now + if err != nil { + run.ErrorMessage = err.Error() + } + h.ScanRegistry.MarkTerminal(run) + h.publishScanEvent(context.Background(), "scan.failed", run) +} + +func (h *LibraryHandler) markScanCancelled(scanID string) { + if h == nil || h.ScanRegistry == nil { + return + } + run, ok := h.ScanRegistry.Get(scanID) + if !ok { + return + } + now := time.Now().UTC() + run.Status = "cancelled" + run.CompletedAt = &now + h.ScanRegistry.MarkTerminal(run) + h.publishScanEvent(context.Background(), "scan.cancelled", run) +} + +func (h *LibraryHandler) cancelActiveScans(libraryID int) []evt.ScanRun { + if h == nil || h.ScanRegistry == nil { + return nil + } + return h.ScanRegistry.CancelLibrary(libraryID, time.Now().UTC()) +} + +func (h *LibraryHandler) publishScanEvent(ctx context.Context, eventName string, run evt.ScanRun) { + if h == nil || h.EventsHub == nil { + return + } + _ = h.EventsHub.PublishJSON(ctx, evt.ChannelScans, eventName, run, evt.PublishOptions{ + AdminOnly: true, + }) +} + +func scanRunResultFromIngest(result *libraryingest.Result) *evt.ScanRunResult { + if result == nil { + return nil + } + resp := &evt.ScanRunResult{ + MatchedFiles: result.MatchedFiles, + RetriedItems: result.RetriedItems, + StillUnmatchedWarnings: result.StillUnmatchedWarnings, + } + if result.Skipped { + resp.Skipped = 1 + } + if result.ScanResult != nil { + resp.New = result.ScanResult.New + resp.Updated = result.ScanResult.Updated + resp.Unchanged = result.ScanResult.Unchanged + resp.Missing = result.ScanResult.Missing + resp.FilesDeleted = result.ScanResult.FilesDeleted + resp.MembershipsRemoved = result.ScanResult.MembershipsRemoved + resp.ItemsDeleted = result.ScanResult.ItemsDeleted + resp.Errors = result.ScanResult.Errors + } + return resp +} + +func scanMetric(result *libraryingest.Result, pick func(*scanner.ScanResult) int) int { + if result == nil || result.ScanResult == nil { + return 0 + } + return pick(result.ScanResult) +} + +func scanBoolMetric(result *libraryingest.Result, pick func(*scanner.ScanResult) bool) bool { + if result == nil || result.ScanResult == nil { + return false + } + return pick(result.ScanResult) +} + +func checkLibraryMount(folder *models.MediaFolder) libraryMountCheckResponse { + resp := libraryMountCheckResponse{ + Status: "ok", + LibraryID: folder.ID, + LibraryName: folder.Name, + Healthy: true, + CheckedAt: time.Now().UTC(), + Roots: make([]libraryMountCheckRootResponse, 0, len(folder.Paths)), + } + + if len(folder.Paths) == 0 { + resp.Healthy = false + resp.Summary = "Library has no configured roots" + return resp + } + + unreachable := 0 + for _, path := range folder.Paths { + root := libraryMountCheckRootResponse{ + Path: path, + Reachable: true, + } + + info, err := os.Stat(path) + if err != nil { + code, message := classifyMountCheckError(err, false) + root.Reachable = false + root.ErrorCode = stringPtr(code) + root.ErrorMessage = stringPtr(message) + } else if !info.IsDir() { + root.Reachable = false + root.ErrorCode = stringPtr("not_directory") + root.ErrorMessage = stringPtr("Path is not a directory") + } else if _, err := os.ReadDir(path); err != nil { + code, message := classifyMountCheckError(err, true) + root.Reachable = false + root.ErrorCode = stringPtr(code) + root.ErrorMessage = stringPtr(message) + } + + if !root.Reachable { + unreachable++ + resp.Healthy = false + } + resp.Roots = append(resp.Roots, root) + } + + switch unreachable { + case 0: + resp.Summary = "All configured roots are reachable" + case 1: + resp.Summary = fmt.Sprintf("1 of %d roots unreachable", len(folder.Paths)) + default: + resp.Summary = fmt.Sprintf("%d of %d roots unreachable", unreachable, len(folder.Paths)) + } + + return resp +} + +func classifyMountCheckError(err error, isRead bool) (string, string) { + switch { + case errors.Is(err, os.ErrNotExist): + return "not_found", "Path does not exist" + case errors.Is(err, os.ErrPermission): + return "permission_denied", "Permission denied" + case isRead: + return "read_failed", "Failed to read directory" + default: + return "stat_failed", "Failed to stat path" + } +} + +func stringPtr(v string) *string { + return &v +} + +func boolPtr(v bool) *bool { + return &v +} + +func (h *LibraryHandler) publishCatalogStatsInvalidation(eventType, payload string) { + if h.EventBus == nil { + return + } + if err := h.EventBus.Publish(h.appCtx, cache.ChannelCatalog, cache.Event{Type: eventType, Payload: payload}); err != nil { + slog.Warn("scan: failed to publish catalog invalidation event", + "type", eventType, + "payload", payload, + "error", err, + ) + } +} + +type refreshLibraryMetadataRequest struct { + Mode string `json:"mode"` +} + +// HandleRefreshLibraryMetadata handles POST /libraries/{id}/refresh-metadata. +// It queues a background admin job to refresh metadata for items in the +// specified library. Quick mode is the default. +func (h *LibraryHandler) HandleRefreshLibraryMetadata(w http.ResponseWriter, r *http.Request) { + if h.JobRepo == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Library refresh jobs are not configured") + return + } + + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + mode := adminjob.LibraryRefreshModeQuick + if r.Body != nil { + var req refreshLibraryMetadataRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.Mode != "" { + switch adminjob.LibraryRefreshMode(req.Mode) { + case adminjob.LibraryRefreshModeQuick, adminjob.LibraryRefreshModeFull: + mode = adminjob.LibraryRefreshMode(req.Mode) + default: + writeError(w, http.StatusBadRequest, "bad_request", "Invalid refresh mode") + return + } + } + } + + folder, err := h.folderRepo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + job, err := h.JobRepo.CreateLibraryRefresh(r.Context(), currentAdminUserID(r), adminjob.LibraryRefreshRequest{ + LibraryID: folder.ID, + LibraryName: folder.Name, + Mode: mode, + }, fmt.Sprintf("Queued %s library metadata refresh", mode)) + if err != nil { + var conflict *adminjob.ActiveJobConflictError + if errors.As(err, &conflict) { + jobsHandler := NewAdminJobsHandler(nil, nil) + writeAdminJobConflict(w, "A metadata refresh is already queued or running for this library", conflict.Job, jobsHandler, r) + return + } + slog.Error("library: queue library refresh failed", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library metadata refresh") + return + } + publishEventJob(r.Context(), h.EventsHub, "job.created", job) + + writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil)) +} + +// HandleConfirmEmptyRootCleanup handles POST /libraries/{id}/confirm-empty-root-cleanup. +// It arms the next empty-root scan for destructive cleanup. +func (h *LibraryHandler) HandleConfirmEmptyRootCleanup(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + if err := h.folderRepo.AllowEmptyCleanupOnce(r.Context(), id); err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to confirm cleanup") + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "message": "Empty-root cleanup confirmed for next scan", + }) +} + +// --- Library poster handlers --- + +// HandleUploadPoster handles PUT /libraries/{id}/poster. +// Accepts a multipart form upload with a single "poster" file field. +func (h *LibraryHandler) HandleUploadPoster(w http.ResponseWriter, r *http.Request) { + if h.S3Meta == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Image storage is not configured") + return + } + + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + folder, err := h.folderRepo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + // Parse multipart form (max 10 MB). + if err := r.ParseMultipartForm(10 << 20); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form") + return + } + + file, header, err := r.FormFile("poster") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Missing poster file") + return + } + defer file.Close() + + // Validate content type. + ct := header.Header.Get("Content-Type") + ext := posterExtension(ct) + if ext == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Unsupported image type; use JPEG, PNG, or WebP") + return + } + + data, err := io.ReadAll(io.LimitReader(file, 10<<20+1)) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload") + return + } + if len(data) > 10<<20 { + writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Poster must be under 10 MB") + return + } + + // Delete old poster if it exists and has a different key. + s3Key := fmt.Sprintf("library-posters/%d%s", id, ext) + if folder.PosterPath != "" && folder.PosterPath != s3Key { + _ = h.S3Meta.DeleteObject(r.Context(), h.S3Meta.Bucket(), folder.PosterPath) + } + + if err := h.S3Meta.PutObject(r.Context(), h.S3Meta.Bucket(), s3Key, data); err != nil { + slog.Error("uploading library poster", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to upload poster") + return + } + + if err := h.folderRepo.SetPosterPath(r.Context(), id, s3Key); err != nil { + slog.Error("saving library poster path", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save poster") + return + } + + folder.PosterPath = s3Key + writeJSON(w, http.StatusOK, h.toLibraryResponseWithPoster(r.Context(), folder)) +} + +// HandleDeletePoster handles DELETE /libraries/{id}/poster. +func (h *LibraryHandler) HandleDeletePoster(w http.ResponseWriter, r *http.Request) { + if h.S3Meta == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Image storage is not configured") + return + } + + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + folder, err := h.folderRepo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + if folder.PosterPath != "" { + _ = h.S3Meta.DeleteObject(r.Context(), h.S3Meta.Bucket(), folder.PosterPath) + if err := h.folderRepo.ClearPosterPath(r.Context(), id); err != nil { + slog.Error("clearing library poster path", "library_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear poster") + return + } + } + + w.WriteHeader(http.StatusNoContent) +} + +// posterExtension returns the file extension for a valid poster content type. +func posterExtension(contentType string) string { + switch contentType { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/webp": + return ".webp" + default: + return "" + } +} + +// --- Provider chain types --- + +// chainLevelEntry represents a single entry in a per-level provider chain response. +type chainLevelEntry struct { + PluginInstallationID int `json:"plugin_installation_id"` + CapabilityID string `json:"capability_id"` + ProviderSlug string `json:"provider_slug"` + Priority int `json:"priority"` + Enabled bool `json:"enabled"` +} + +// setChainLevelRequest is the JSON body for PUT /libraries/{id}/providers. +type setChainLevelRequest struct { + Levels map[string][]chainEntryInput `json:"levels"` +} + +// chainEntryInput is a single entry in a set-chain request. +type chainEntryInput struct { + PluginInstallationID int `json:"plugin_installation_id"` + CapabilityID string `json:"capability_id"` + Priority int `json:"priority"` + Enabled bool `json:"enabled"` +} + +// HandleGetLibraryProviders handles GET /libraries/{id}/providers. +// It returns the provider chain for the given library grouped by content level. +func (h *LibraryHandler) HandleGetLibraryProviders(w http.ResponseWriter, r *http.Request) { + if h.ChainRepo == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Provider chain management is not configured") + return + } + + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + // Verify the library exists. + if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + slog.Error("fetching library for provider chain", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + entries, err := h.ChainRepo.GetAllChainEntries(r.Context(), id) + if err != nil { + slog.Error("getting provider chain", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get provider chain") + return + } + + // Group by content level — capability_id is the provider slug. + levels := make(map[string][]chainLevelEntry) + for _, e := range entries { + levels[e.ContentLevel] = append(levels[e.ContentLevel], chainLevelEntry{ + PluginInstallationID: e.PluginInstallationID, + CapabilityID: e.CapabilityID, + ProviderSlug: e.CapabilityID, + Priority: e.Priority, + Enabled: e.Enabled, + }) + } + + writeJSON(w, http.StatusOK, map[string]any{"levels": levels}) +} + +// HandleSetLibraryProviders handles PUT /libraries/{id}/providers. +// It replaces the entire provider chain for the given library. +func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *http.Request) { + if h.ChainRepo == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Provider chain management is not configured") + return + } + + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + // Verify the library exists. + if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + slog.Error("fetching library for provider chain update", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") + return + } + + var req setChainLevelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + var entries []metadata.ChainEntry + for level, inputs := range req.Levels { + for _, input := range inputs { + entries = append(entries, metadata.ChainEntry{ + PluginInstallationID: input.PluginInstallationID, + CapabilityID: input.CapabilityID, + CapabilityType: "metadata_provider.v1", + ContentLevel: level, + Priority: input.Priority, + Enabled: input.Enabled, + }) + } + } + + if err := h.ChainRepo.SetChain(r.Context(), id, entries); err != nil { + slog.Error("setting provider chain", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set provider chain") + return + } + if h.chainCacheInvalidator != nil { + h.chainCacheInvalidator.InvalidateChainCache() + } + + w.WriteHeader(http.StatusNoContent) +} + +// seedDefaultChain builds a default provider chain from plugin manifest defaults +// for the given library type. Returns entries for all applicable content levels. +func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType string) []metadata.ChainEntry { + if h.ChainRepo == nil { + return nil + } + + caps, err := metadata.ListEnabledMetadataCapabilities(ctx, h.ChainRepo.Pool()) + if err != nil { + slog.Warn("seed chain: failed to list metadata capabilities", "error", err) + return nil + } + + // Determine which content levels apply. + var levels []string + switch libraryType { + case "series": + levels = []string{"series", "season", "episode"} + case "movies", "movie": + levels = []string{"movie"} + case "mixed": + levels = []string{"movie", "series", "season", "episode"} + default: + return nil + } + + var entries []metadata.ChainEntry + for _, level := range levels { + type candidate struct { + installationID int + capabilityID string + priority int + enabled bool + } + var candidates []candidate + + for _, c := range caps { + defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level) + if defaultPriority > 0 { + candidates = append(candidates, candidate{ + installationID: c.PluginInstallationID, + capabilityID: c.CapabilityID, + priority: defaultPriority, + enabled: true, + }) + } else { + // Plugin doesn't declare this level — include but disabled. + candidates = append(candidates, candidate{ + installationID: c.PluginInstallationID, + capabilityID: c.CapabilityID, + priority: 999, + enabled: false, + }) + } + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].priority < candidates[j].priority + }) + + for i, cand := range candidates { + entries = append(entries, metadata.ChainEntry{ + PluginInstallationID: cand.installationID, + CapabilityID: cand.capabilityID, + CapabilityType: "metadata_provider.v1", + ContentLevel: level, + Priority: i, + Enabled: cand.enabled, + }) + } + } + + return entries +} + +// HandleListStaleIDs handles GET /libraries/stale-ids. +func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) { + if h.StaleIDRepo == nil { + writeJSON(w, http.StatusOK, []staleMediaIDResponse{}) + return + } + + staleIDs, err := h.StaleIDRepo.ListAll(r.Context()) + if err != nil { + slog.Error("listing stale media IDs", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list stale IDs") + return + } + if len(staleIDs) == 0 { + writeJSON(w, http.StatusOK, []staleMediaIDResponse{}) + return + } + + // Collect unique content IDs for batch lookup. + contentIDs := make([]string, 0, len(staleIDs)) + seen := make(map[string]bool, len(staleIDs)) + for _, s := range staleIDs { + if !seen[s.ContentID] { + contentIDs = append(contentIDs, s.ContentID) + seen[s.ContentID] = true + } + } + + // Batch-load item metadata and library associations. + type itemInfo struct { + Title string + Year int + ContentType string + LibraryID int + LibraryName string + } + items := make(map[string]itemInfo, len(contentIDs)) + + rows, err := h.pool.Query(r.Context(), ` + SELECT mi.content_id, mi.title, mi.year, mi.type, + COALESCE(mf_lib.folder_id, 0), + COALESCE(mf_lib.folder_name, '') + FROM media_items mi + LEFT JOIN LATERAL ( + SELECT mf2.media_folder_id AS folder_id, f.name AS folder_name + FROM media_files mf2 + JOIN media_folders f ON f.id = mf2.media_folder_id + WHERE mf2.content_id = mi.content_id + LIMIT 1 + ) mf_lib ON true + WHERE mi.content_id = ANY($1) + `, contentIDs) + if err != nil { + slog.Error("loading items for stale IDs", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item data") + return + } + defer rows.Close() + + for rows.Next() { + var cid, title, ctype, libName string + var year, libID int + if err := rows.Scan(&cid, &title, &year, &ctype, &libID, &libName); err != nil { + slog.Error("scanning item for stale IDs", "error", err) + continue + } + items[cid] = itemInfo{Title: title, Year: year, ContentType: ctype, LibraryID: libID, LibraryName: libName} + } + + resp := make([]staleMediaIDResponse, 0, len(staleIDs)) + for _, s := range staleIDs { + info := items[s.ContentID] + resp = append(resp, staleMediaIDResponse{ + ContentID: s.ContentID, + LibraryID: info.LibraryID, + LibraryName: info.LibraryName, + Title: info.Title, + Year: info.Year, + ContentType: info.ContentType, + Provider: s.Provider, + ProviderID: s.ProviderID, + FirstSeenAt: s.FirstSeenAt.Format("2006-01-02T15:04:05Z"), + LastSeenAt: s.LastSeenAt.Format("2006-01-02T15:04:05Z"), + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleRematchStaleID handles POST /libraries/stale-ids/{contentID}/rematch. +// Deprecated: prefer the explicit admin match search/apply flow via +// POST /admin/items/{id}/match/search and POST /admin/items/{id}/match/apply. +func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "contentID") + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Missing content ID") + return + } + + // Clear the stale external IDs from the media_items row. + // We clear all provider IDs so the re-match starts fresh from title/year. + _, err := h.pool.Exec(r.Context(), ` + UPDATE media_items + SET tmdb_id = '', tvdb_id = '', imdb_id = '' + WHERE content_id = $1 + `, contentID) + if err != nil { + slog.Error("clearing stale IDs from media item", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear IDs") + return + } + + // Remove stale_media_ids records. + if h.StaleIDRepo != nil { + if err := h.StaleIDRepo.DeleteByContentID(r.Context(), contentID); err != nil { + slog.Error("deleting stale media ID records", "content_id", contentID, "error", err) + } + } + + // Re-trigger metadata match. + if h.refresher != nil { + go func() { + if err := h.refresher.RefreshItem(h.appCtx, contentID); err != nil { + slog.Warn("metadata: rematch refresh failed", "content_id", contentID, "error", err) + } + }() + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request) { + if h.ScannedGroupRepo == nil || h.folderRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Group snapshots not configured") + return + } + + q := r.URL.Query() + libraryID, err := strconv.Atoi(q.Get("library_id")) + if err != nil || libraryID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "library_id is required") + return + } + limit := 100 + if value := strings.TrimSpace(q.Get("limit")); value != "" { + if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 && parsed <= 500 { + limit = parsed + } + } + offset := 0 + if value := strings.TrimSpace(q.Get("offset")); value != "" { + if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed >= 0 { + offset = parsed + } + } + state := strings.TrimSpace(q.Get("state")) + + folder, err := h.folderRepo.GetByID(r.Context(), libraryID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + + groups, total, err := h.ScannedGroupRepo.ListByFolder(r.Context(), libraryID, state, limit, offset) + if err != nil { + slog.Error("listing scanned groups", "library_id", libraryID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list roots") + return + } + + overrideByGroup := map[string]models.MediaGroupOverride{} + if h.GroupOverrideRepo != nil { + overrides, err := h.GroupOverrideRepo.ListByFolder(r.Context(), libraryID) + if err != nil { + slog.Warn("listing group overrides", "library_id", libraryID, "error", err) + } else { + for _, override := range overrides { + overrideByGroup[groupOverrideLookupKey(override.GroupKeyVersion, override.ContentGroupKey)] = override + } + } + } + + items := make([]libraryRootResponse, 0, len(groups)) + for _, group := range groups { + rootPath := strings.TrimSpace(group.SampleObservedRootPath) + if rootPath == "" { + rootPath = filepath.Dir(group.SampleFilePath) + } + resp := libraryRootResponse{ + LibraryID: libraryID, + LibraryName: folder.Name, + RootPath: rootPath, + State: group.State, + InferredType: group.InferredType, + TypeConfidence: group.TypeConfidence, + Title: group.BaseTitle, + Year: group.BaseYear, + TmdbID: group.TmdbID, + ImdbID: group.ImdbID, + TvdbID: group.TvdbID, + ObservedFiles: group.ObservedFileCount, + SampleFilePath: group.SampleFilePath, + Evidence: append(json.RawMessage(nil), group.EvidenceJSON...), + OverrideSource: group.OverrideSource, + FirstSeenAt: group.FirstSeenAt, + LastSeenAt: group.LastSeenAt, + } + if override, ok := overrideByGroup[groupOverrideLookupKey(group.GroupKeyVersion, group.ContentGroupKey)]; ok { + resp.ActiveOverride = &rootOverride{ + ForcedType: override.ForcedType, + ForcedTitle: override.ForcedTitle, + ForcedYear: override.ForcedYear, + ForcedTmdbID: override.ForcedTmdbID, + ForcedImdbID: override.ForcedImdbID, + ForcedTvdbID: override.ForcedTvdbID, + Note: override.Note, + } + } + items = append(items, resp) + } + + writeJSON(w, http.StatusOK, libraryRootsListResponse{Items: items, Total: total}) +} + +func (h *LibraryHandler) HandleUpsertRootOverride(w http.ResponseWriter, r *http.Request) { + if h.GroupOverrideRepo == nil || h.ObservedLocationRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Root overrides not configured") + return + } + + var req rootOverrideUpsertRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + req.RootPath = filepath.Clean(strings.TrimSpace(req.RootPath)) + if req.LibraryID <= 0 || req.RootPath == "" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id and root_path are required") + return + } + location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath) + if err != nil { + slog.Error("loading observed media location", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root") + return + } + if location == nil || location.PrimaryContentGroupKey == "" { + if location != nil && location.ContentGroupCount > 1 { + writeError(w, http.StatusConflict, "ambiguous_root", "Root contains multiple logical groups; override the group after splitting or selecting a specific item") + return + } + writeError(w, http.StatusNotFound, "not_found", "Root not found") + return + } + + userID := apimw.GetUserID(r.Context()) + override := models.MediaGroupOverride{ + MediaFolderID: req.LibraryID, + GroupKeyVersion: location.PrimaryGroupKeyVersion, + ContentGroupKey: location.PrimaryContentGroupKey, + ForcedType: strings.TrimSpace(req.ForcedType), + ForcedTitle: strings.TrimSpace(req.ForcedTitle), + ForcedYear: req.ForcedYear, + ForcedTmdbID: strings.TrimSpace(req.ForcedTmdbID), + ForcedImdbID: strings.TrimSpace(req.ForcedImdbID), + ForcedTvdbID: strings.TrimSpace(req.ForcedTvdbID), + Note: strings.TrimSpace(req.Note), + CreatedByUserID: nil, + UpdatedByUserID: nil, + } + if userID > 0 { + override.CreatedByUserID = &userID + override.UpdatedByUserID = &userID + } + if err := h.GroupOverrideRepo.Upsert(r.Context(), override); err != nil { + slog.Error("upserting group override", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save override") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *LibraryHandler) HandleDeleteRootOverride(w http.ResponseWriter, r *http.Request) { + if h.GroupOverrideRepo == nil || h.ObservedLocationRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Root overrides not configured") + return + } + + var req rootOverrideDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + req.RootPath = filepath.Clean(strings.TrimSpace(req.RootPath)) + if req.LibraryID <= 0 || req.RootPath == "" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id and root_path are required") + return + } + location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath) + if err != nil { + slog.Error("loading observed media location", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root") + return + } + if location == nil || location.PrimaryContentGroupKey == "" { + if location != nil && location.ContentGroupCount > 1 { + writeError(w, http.StatusConflict, "ambiguous_root", "Root contains multiple logical groups; delete the override from a specific group instead") + return + } + writeError(w, http.StatusNotFound, "not_found", "Root not found") + return + } + + if err := h.GroupOverrideRepo.Delete(r.Context(), req.LibraryID, location.PrimaryGroupKeyVersion, location.PrimaryContentGroupKey); err != nil { + slog.Error("deleting group override", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete override") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// unmatchedItemResponse represents an item in the unmatched-items list. +type unmatchedItemResponse struct { + ContentID string `json:"content_id"` + Title string `json:"title"` + Year int `json:"year"` + ContentType string `json:"content_type"` + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + Status string `json:"status"` +} + +type unmatchedItemsListResponse struct { + Items []unmatchedItemResponse `json:"items"` + Total int `json:"total"` +} + +// HandleListUnmatchedItems handles GET /libraries/unmatched-items. +// Returns items that are in unmatched, pending, or ambiguous status, enriched with +// library context so the admin maintenance page can link to them. +func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http.Request) { + if h.pool == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured") + return + } + + q := r.URL.Query() + limit := 100 + if v := q.Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { + limit = n + } + } + offset := 0 + if v := q.Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + var total int + if err := h.pool.QueryRow(r.Context(), + `SELECT COUNT(*) FROM media_items WHERE status IN ('unmatched', 'pending', 'ambiguous')`, + ).Scan(&total); err != nil { + slog.Error("counting unmatched items", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items") + return + } + + rows, err := h.pool.Query(r.Context(), ` + SELECT mi.content_id, mi.title, mi.year, mi.type, mi.status, + COALESCE(lib.folder_id, 0), + COALESCE(lib.folder_name, '') + FROM media_items mi + LEFT JOIN LATERAL ( + SELECT mil.media_folder_id AS folder_id, f.name AS folder_name + FROM media_item_libraries mil + JOIN media_folders f ON f.id = mil.media_folder_id + WHERE mil.content_id = mi.content_id + LIMIT 1 + ) lib ON true + WHERE mi.status IN ('unmatched', 'pending', 'ambiguous') + ORDER BY mi.title ASC, mi.content_id ASC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + slog.Error("listing unmatched items", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items") + return + } + defer rows.Close() + + items := make([]unmatchedItemResponse, 0) + for rows.Next() { + var item unmatchedItemResponse + if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &item.ContentType, &item.Status, &item.LibraryID, &item.LibraryName); err != nil { + slog.Error("scanning unmatched item", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan item") + return + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + slog.Error("iterating unmatched items", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate items") + return + } + + writeJSON(w, http.StatusOK, unmatchedItemsListResponse{ + Items: items, + Total: total, + }) +} + +// parseIDParam extracts and parses the "id" URL parameter as an integer. +func parseIDParam(r *http.Request) (int, error) { + idStr := chi.URLParam(r, "id") + return strconv.Atoi(idStr) +} diff --git a/internal/api/handlers/libraries_chapter_thumbnail_test.go b/internal/api/handlers/libraries_chapter_thumbnail_test.go new file mode 100644 index 00000000..18f89bfd --- /dev/null +++ b/internal/api/handlers/libraries_chapter_thumbnail_test.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleCreateLibrary_RejectsChapterThumbnailsWithoutS3(t *testing.T) { + handler := &LibraryHandler{} + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/libraries", + strings.NewReader(`{ + "name":"Movies", + "type":"movies", + "paths":["/mnt/media/movies"], + "chapter_thumbnails_enabled":true + }`), + ) + rr := httptest.NewRecorder() + + handler.HandleCreateLibrary(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } + if body := rr.Body.String(); !strings.Contains(body, "Chapter thumbnails require configured public asset S3 storage") { + t.Fatalf("unexpected body: %s", body) + } +} + +func TestHandleUpdateLibrary_RejectsChapterThumbnailsWithoutS3(t *testing.T) { + handler := &LibraryHandler{} + req := httptest.NewRequest( + http.MethodPut, + "/api/v1/libraries/42", + strings.NewReader(`{"chapter_thumbnails_enabled":true}`), + ) + req = withPlaybackRouteParam(req, "id", "42") + rr := httptest.NewRecorder() + + handler.HandleUpdateLibrary(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } + if body := rr.Body.String(); !strings.Contains(body, "Chapter thumbnails require configured public asset S3 storage") { + t.Fatalf("unexpected body: %s", body) + } +} diff --git a/internal/api/handlers/libraries_match_test.go b/internal/api/handlers/libraries_match_test.go new file mode 100644 index 00000000..fd8017e6 --- /dev/null +++ b/internal/api/handlers/libraries_match_test.go @@ -0,0 +1,175 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +// TestLibraryMatchUnmatchedItems_NilPool verifies that the unmatched-items +// endpoint returns a clear error when the database pool is not configured. +func TestLibraryMatchUnmatchedItems_NilPool(t *testing.T) { + h := &LibraryHandler{} + + r := chi.NewRouter() + r.Get("/libraries/unmatched-items", h.HandleListUnmatchedItems) + + req := httptest.NewRequest(http.MethodGet, "/libraries/unmatched-items", nil) + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 for nil pool, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp errorResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decoding error response: %v", err) + } + if resp.Error != "internal_error" { + t.Errorf("expected error code 'internal_error', got %q", resp.Error) + } +} + +// TestLibraryMatchUnmatchedItems_ResponseShape verifies that the +// unmatchedItemResponse type has the expected JSON field tags for the +// admin maintenance page. This is a compile-time structure test. +func TestLibraryMatchUnmatchedItems_ResponseShape(t *testing.T) { + item := unmatchedItemResponse{ + ContentID: "abc-123", + Title: "Test Movie", + Year: 2024, + ContentType: "movie", + LibraryID: 1, + LibraryName: "Movies", + Status: "unmatched", + } + + data, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + expectedKeys := []string{ + "content_id", "title", "year", "content_type", + "library_id", "library_name", "status", + } + for _, key := range expectedKeys { + if _, ok := m[key]; !ok { + t.Errorf("expected JSON key %q in response, got keys: %v", key, keys(m)) + } + } +} + +// TestLibraryMatchStaleIDs_NilRepo verifies that the stale-IDs endpoint +// returns an empty array when the repository is nil, confirming backward +// compatibility after the new match endpoints are introduced. +func TestLibraryMatchStaleIDs_NilRepo(t *testing.T) { + h := &LibraryHandler{} // StaleIDRepo is nil + + r := chi.NewRouter() + r.Get("/libraries/stale-ids", h.HandleListStaleIDs) + + req := httptest.NewRequest(http.MethodGet, "/libraries/stale-ids", nil) + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for nil StaleIDRepo, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp []staleMediaIDResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(resp) != 0 { + t.Errorf("expected empty stale IDs list, got %d entries", len(resp)) + } +} + +// TestLibraryMatchStaleIDs_ResponseShape verifies the stale media ID response +// JSON structure still contains the expected fields. +func TestLibraryMatchStaleIDs_ResponseShape(t *testing.T) { + item := staleMediaIDResponse{ + ContentID: "content-1", + LibraryID: 2, + LibraryName: "TV Shows", + Title: "Test Show", + Year: 2023, + ContentType: "series", + Provider: "tvdb", + ProviderID: "12345", + FirstSeenAt: "2023-01-01T00:00:00Z", + LastSeenAt: "2023-06-01T00:00:00Z", + } + + data, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + expectedKeys := []string{ + "content_id", "library_id", "library_name", "title", + "year", "content_type", "provider", "provider_id", + "first_seen_at", "last_seen_at", + } + for _, key := range expectedKeys { + if _, ok := m[key]; !ok { + t.Errorf("expected JSON key %q in response, got keys: %v", key, keys(m)) + } + } +} + +// TestLibraryMatchRematch_DeprecatedStillRoutes verifies that the deprecated +// HandleRematchStaleID handler method still exists and can be registered as a +// route alongside the new match endpoints. This is a compile-time routing test. +func TestLibraryMatchRematch_DeprecatedStillRoutes(t *testing.T) { + h := &LibraryHandler{} + + r := chi.NewRouter() + // Register both old and new endpoints to prove they coexist. + r.Post("/libraries/stale-ids/{contentID}/rematch", h.HandleRematchStaleID) + r.Get("/libraries/unmatched-items", h.HandleListUnmatchedItems) + r.Get("/libraries/stale-ids", h.HandleListStaleIDs) + + // Verify the routes are registered by checking that a walk finds them. + found := map[string]bool{} + chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + found[method+" "+route] = true + return nil + }) + + expectedRoutes := []string{ + "POST /libraries/stale-ids/{contentID}/rematch", + "GET /libraries/unmatched-items", + "GET /libraries/stale-ids", + } + for _, route := range expectedRoutes { + if !found[route] { + t.Errorf("expected route %q to be registered, registered routes: %v", route, found) + } + } +} + +func keys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/api/handlers/library_collection_groups.go b/internal/api/handlers/library_collection_groups.go new file mode 100644 index 00000000..da8c3238 --- /dev/null +++ b/internal/api/handlers/library_collection_groups.go @@ -0,0 +1,251 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +type LibraryCollectionGroupHandler struct { + groupRepo *catalog.LibraryCollectionGroupRepository + collRepo *catalog.LibraryCollectionRepository + pool *pgxpool.Pool +} + +func NewLibraryCollectionGroupHandler( + groupRepo *catalog.LibraryCollectionGroupRepository, + collRepo *catalog.LibraryCollectionRepository, + pool *pgxpool.Pool, +) *LibraryCollectionGroupHandler { + return &LibraryCollectionGroupHandler{groupRepo: groupRepo, collRepo: collRepo, pool: pool} +} + +type createGroupRequest struct { + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` + DefaultSortMode *string `json:"default_sort_mode,omitempty"` +} + +type updateGroupRequest struct { + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` + DefaultSortMode *string `json:"default_sort_mode,omitempty"` +} + +type listGroupsResponse struct { + Groups []libraryCollectionGroupResponse `json:"groups"` + UngroupedSortOrder int `json:"ungrouped_sort_order"` +} + +type collectionGroupReorderRequest struct { + IDs []string `json:"ids"` +} + +func (h *LibraryCollectionGroupHandler) HandleListGroups(w http.ResponseWriter, r *http.Request) { + libraryID, ok := parseLibraryIDFromRouteParam(w, r, "libraryID") + if !ok { + return + } + groups, err := h.groupRepo.ListByLibrary(r.Context(), libraryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load groups") + return + } + if groups == nil { + groups = []models.LibraryCollectionGroup{} + } + + ungroupedSortOrder := 9999 + if h.groupRepo != nil { + var err error + if ungroupedSortOrder, err = h.groupRepo.GetUngroupedSortOrder(r.Context(), libraryID); err != nil { + ungroupedSortOrder = 9999 + } + } + + writeJSON(w, http.StatusOK, listGroupsResponse{ + Groups: toLibraryCollectionGroupResponses(groups), + UngroupedSortOrder: ungroupedSortOrder, + }) +} + +func (h *LibraryCollectionGroupHandler) HandleCreateGroup(w http.ResponseWriter, r *http.Request) { + libraryID, ok := parseLibraryIDFromRouteParam(w, r, "libraryID") + if !ok { + return + } + var req createGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "bad_request", "name required") + return + } + in := catalog.CreateLibraryCollectionGroupInput{ + LibraryID: libraryID, + Name: req.Name, + Kind: models.GroupKindRegular, + } + if req.Slug != nil { + in.Slug = *req.Slug + } + if req.DefaultSortMode != nil { + in.DefaultSortMode = models.GroupSortMode(*req.DefaultSortMode) + } + g, err := h.groupRepo.Create(r.Context(), in) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create group") + return + } + writeJSON(w, http.StatusCreated, toLibraryCollectionGroupResponse(*g)) +} + +func (h *LibraryCollectionGroupHandler) HandleUpdateGroup(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "id required") + return + } + var req updateGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body") + return + } + in := catalog.UpdateLibraryCollectionGroupInput{Name: req.Name, Slug: req.Slug} + if req.DefaultSortMode != nil { + mode := models.GroupSortMode(*req.DefaultSortMode) + in.DefaultSortMode = &mode + } + g, err := h.groupRepo.Update(r.Context(), id, in) + if err != nil { + if errors.Is(err, catalog.ErrLibraryCollectionGroupNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Group not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update group") + return + } + writeJSON(w, http.StatusOK, toLibraryCollectionGroupResponse(*g)) +} + +func (h *LibraryCollectionGroupHandler) HandleDeleteGroup(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "id required") + return + } + if err := h.groupRepo.Delete(r.Context(), id); err != nil { + if errors.Is(err, catalog.ErrLibraryCollectionGroupNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Group not found") + return + } + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *LibraryCollectionGroupHandler) HandleReorderGroups(w http.ResponseWriter, r *http.Request) { + libraryID, ok := parseLibraryIDFromRouteParam(w, r, "libraryID") + if !ok { + return + } + var req collectionGroupReorderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body") + return + } + if err := h.groupRepo.Reorder(r.Context(), libraryID, req.IDs); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *LibraryCollectionGroupHandler) HandleReorderCollectionsInGroup(w http.ResponseWriter, r *http.Request) { + groupID := chi.URLParam(r, "groupID") + if groupID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "groupID required") + return + } + + moveOmitted := r.URL.Query().Get("move_omitted") + strict := moveOmitted != "" && moveOmitted != "ungrouped" + + var libraryID int + var targetGroupID *string + if groupID == "ungrouped" { + raw := r.URL.Query().Get("library_id") + if raw == "" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id query param required for ungrouped reorder") + return + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "invalid library_id") + return + } + libraryID = id + } else { + g, err := h.groupRepo.GetByID(r.Context(), groupID) + if err != nil { + if errors.Is(err, catalog.ErrLibraryCollectionGroupNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Group not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load group") + return + } + libraryID = g.LibraryID + targetGroupID = &g.ID + } + + var req collectionGroupReorderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body") + return + } + + err := h.collRepo.MoveAndReorder(r.Context(), catalog.MoveAndReorderInput{ + LibraryID: libraryID, + TargetGroupID: targetGroupID, + OrderedIDs: req.IDs, + Strict: strict, + }) + var strictErr *catalog.StrictReorderError + if errors.As(err, &strictErr) { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "strict_reorder_rejected", + "missing_ids": strictErr.MissingIDs, + }) + return + } + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func parseLibraryIDFromRouteParam(w http.ResponseWriter, r *http.Request, param string) (int, bool) { + raw := chi.URLParam(r, param) + if raw == "" { + writeError(w, http.StatusBadRequest, "bad_request", "library id required") + return 0, false + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "invalid library id") + return 0, false + } + return id, true +} diff --git a/internal/api/handlers/library_collections.go b/internal/api/handlers/library_collections.go new file mode 100644 index 00000000..601723fb --- /dev/null +++ b/internal/api/handlers/library_collections.go @@ -0,0 +1,3061 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "golang.org/x/sync/errgroup" + + "log/slog" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/collage" + "github.com/Silo-Server/silo-server/internal/collections/templates" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/s3client" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/usercollections" +) + +type LibraryCollectionHandler struct { + repo *catalog.LibraryCollectionRepository + service *catalog.LibraryCollectionService + itemRepo *catalog.ItemRepository + Executor *catalog.QueryExecutor + detailSvc *catalog.DetailService + presignTTL time.Duration + httpClient *http.Client + s3GP *s3client.Client + SectionRepo *sections.Repository + UserCollectionPool *pgxpool.Pool + GroupRepo *catalog.LibraryCollectionGroupRepository + FolderRepo *catalog.FolderRepository + TemplateRegistry *templates.Registry + SmartCountRefresher *catalog.SmartCountRefresher +} + +var errLibraryCollectionInUse = errors.New("collection is used by one or more sections") + +const ( + templateBundleSyncConcurrency = 4 + templateBundleCollageConcurrency = 2 + templateBundleCollageTimeout = 5 * time.Minute + + collectionManagementModeManual = "manual" + collectionManagementModeSection = "section" + collectionManagementModeTemplateBundle = "template_bundle" + + // collectionSourceModeTMDBCollection is the `source_config.mode` value used + // for TMDB-franchise collections (curated `/collection/{id}` results). + collectionSourceModeTMDBCollection = "tmdb_collection" + + // collectionSourceModeTMDBDiscover is the `source_config.mode` value used + // for TMDB discover collections (genre matrices, decade slices, etc.). + collectionSourceModeTMDBDiscover = "tmdb_discover" +) + +func NewLibraryCollectionHandler( + repo *catalog.LibraryCollectionRepository, + service *catalog.LibraryCollectionService, + itemRepo *catalog.ItemRepository, + presignTTL time.Duration, + httpClient *http.Client, + s3GP *s3client.Client, +) *LibraryCollectionHandler { + if httpClient == nil { + httpClient = http.DefaultClient + } + + return &LibraryCollectionHandler{ + repo: repo, + service: service, + itemRepo: itemRepo, + presignTTL: presignTTL, + httpClient: httpClient, + s3GP: s3GP, + TemplateRegistry: templates.Default, + } +} + +// SetDetailService configures the detail service for image URL resolution. +func (h *LibraryCollectionHandler) SetDetailService(svc *catalog.DetailService) { + h.detailSvc = svc +} + +// SetupCollage enables automatic poster collage generation for collections. +// Must be called after SetDetailService. +func (h *LibraryCollectionHandler) SetupCollage() { + if h.s3GP == nil || h.detailSvc == nil { + slog.Info("collage: auto-generation disabled", "s3_configured", h.s3GP != nil, "detail_svc_configured", h.detailSvc != nil) + return + } + h.service.CollageGen = h + slog.Info("collage: auto-generation enabled") +} + +// GenerateCollectionPoster implements catalog.CollageGenerator. +// It fetches item poster images, composes a collage, and stores the result. +func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, collectionID string) error { + const maxItems = 4 + + paths, err := h.repo.ListItemPosterPaths(ctx, collectionID, maxItems) + if err != nil { + return fmt.Errorf("listing item poster paths: %w", err) + } + if len(paths) == 0 { + return collage.ErrNotEnoughImages + } + + slog.Info("collage: generating poster", "collection_id", collectionID, "item_poster_count", len(paths)) + + // Resolve poster paths to fetchable URLs. + resolved := h.detailSvc.PresignImageURLs(ctx, paths, "poster", "small") + + // Fetch each image. + imageData := make([][]byte, 0, len(paths)) + fetchFailed := 0 + for _, path := range paths { + url := resolved[path] + if url == "" { + fetchFailed++ + continue + } + data, err := h.fetchImageURL(ctx, url) + if err != nil { + slog.Debug("collage: failed to fetch poster image", "url", url, "error", err) + fetchFailed++ + continue + } + imageData = append(imageData, data) + } + if fetchFailed > 0 && len(imageData) == 0 { + slog.Warn("collage: all poster image fetches failed", + "collection_id", collectionID, "total", len(paths), "failed", fetchFailed) + } + + // Compose the collage. + composited, err := collage.ComposePoster(imageData) + if err != nil { + return err + } + + // Delete any existing auto-generated images before uploading new ones. + if err := h.deleteCollectionImages(ctx, collectionID, "poster"); err != nil { + slog.Warn("collage: failed to clean up old poster images", "collection_id", collectionID, "error", err) + } + + // Process through the standard image pipeline (generates WebP variants + thumbhash). + s3Path, thumbhash, err := h.processCollectionImage(ctx, collectionID, "poster", composited) + if err != nil { + return fmt.Errorf("processing collage image: %w", err) + } + + autoGen := true + if err := h.repo.Update(ctx, catalog.UpdateLibraryCollectionInput{ + ID: collectionID, + PosterURL: &s3Path, + PosterThumbhash: &thumbhash, + PosterAutoGenerated: &autoGen, + }); err != nil { + return fmt.Errorf("updating collection poster: %w", err) + } + + slog.Info("collage: poster generated successfully", "collection_id", collectionID, "s3_path", s3Path) + return nil +} + +// fetchImageURL downloads an image from a resolved URL. +func (h *LibraryCollectionHandler) fetchImageURL(ctx context.Context, imageURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return nil, err + } + client := h.httpClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("image fetch returned status %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, collectionImageMaxBytes)) +} + +type libraryCollectionResponse struct { + ID string `json:"id"` + LibraryID int `json:"library_id"` + LibraryIDs []int `json:"library_ids"` + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + CollectionType string `json:"collection_type"` + Visibility string `json:"visibility"` + SortOrder int `json:"sort_order"` + GroupID *string `json:"group_id"` + Featured bool `json:"featured"` + PosterURL string `json:"poster_url"` + BackdropURL string `json:"backdrop_url"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` + SourceURL string `json:"source_url"` + QueryDefinition json.RawMessage `json:"query_definition"` + SortConfig json.RawMessage `json:"sort_config"` + SourceConfig json.RawMessage `json:"source_config"` + ManagementMode string `json:"management_mode"` + ManagementSource string `json:"management_source"` + ManagementKey string `json:"management_key"` + LastSyncStatus string `json:"last_sync_status"` + LastSyncMessage string `json:"last_sync_message"` + LastSyncAt string `json:"last_sync_at,omitempty"` + SyncSchedule string `json:"sync_schedule,omitempty"` + NextSyncAt string `json:"next_sync_at,omitempty"` + ItemCount int `json:"item_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type libraryCollectionGroupResponse struct { + ID string `json:"id"` + LibraryID int `json:"library_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Kind string `json:"kind"` + DefaultSortMode string `json:"default_sort_mode"` + SortOrder int `json:"sort_order"` +} + +type libraryCollectionGroupsListResponse struct { + Groups []libraryCollectionGroupResponse `json:"groups"` + UngroupedSortOrder int `json:"ungrouped_sort_order"` +} + +type createLibraryCollectionGroupRequest struct { + LibraryID int `json:"library_id"` + Name string `json:"name"` + Slug string `json:"slug"` + DefaultSortMode string `json:"default_sort_mode"` +} + +type updateLibraryCollectionGroupRequest struct { + Name *string `json:"name"` + Slug *string `json:"slug"` + DefaultSortMode *string `json:"default_sort_mode"` +} + +type reorderLibraryCollectionGroupsRequest struct { + LibraryID int `json:"library_id"` + OrderedIDs []string `json:"ordered_ids"` +} + +type libraryCollectionsListResponse struct { + Collections []libraryCollectionResponse `json:"collections"` + Groups []libraryCollectionGroupResponse `json:"groups,omitempty"` +} + +type createLibraryCollectionRequest struct { + LibraryID int `json:"library_id"` + LibraryIDs []int `json:"library_ids"` + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + CollectionType string `json:"collection_type"` + Visibility string `json:"visibility"` + SortOrder int `json:"sort_order"` + GroupID *string `json:"group_id"` + Featured bool `json:"featured"` + PosterURL string `json:"poster_url"` + BackdropURL string `json:"backdrop_url"` + PosterSourceURL string `json:"poster_source_url"` + BackdropSourceURL string `json:"backdrop_source_url"` + SourceURL string `json:"source_url"` + QueryDefinition json.RawMessage `json:"query_definition"` + SortConfig json.RawMessage `json:"sort_config"` + SourceConfig json.RawMessage `json:"source_config"` + ManagementMode string `json:"management_mode"` + ManagementSource string `json:"management_source"` + ManagementKey string `json:"management_key"` + SyncSchedule string `json:"sync_schedule"` +} + +type updateLibraryCollectionRequest struct { + LibraryIDs *[]int `json:"library_ids"` + Slug *string `json:"slug"` + Title *string `json:"title"` + Description *string `json:"description"` + CollectionType *string `json:"collection_type"` + Visibility *string `json:"visibility"` + SortOrder *int `json:"sort_order"` + GroupID optionalNullableString `json:"group_id"` + Featured *bool `json:"featured"` + PosterURL *string `json:"poster_url"` + BackdropURL *string `json:"backdrop_url"` + PosterSourceURL *string `json:"poster_source_url"` + BackdropSourceURL *string `json:"backdrop_source_url"` + SourceURL *string `json:"source_url"` + QueryDefinition json.RawMessage `json:"query_definition"` + SortConfig json.RawMessage `json:"sort_config"` + SourceConfig json.RawMessage `json:"source_config"` + ManagementMode *string `json:"management_mode"` + ManagementSource *string `json:"management_source"` + ManagementKey *string `json:"management_key"` + SyncSchedule *string `json:"sync_schedule"` +} + +type importMDBListRequest struct { + LibraryID int `json:"library_id"` + LibraryIDs []int `json:"library_ids"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Limit *int `json:"limit,omitempty"` + Featured bool `json:"featured"` + PosterSourceURL string `json:"poster_source_url"` + BackdropSourceURL string `json:"backdrop_source_url"` + SyncSchedule string `json:"sync_schedule"` + ManagementMode string `json:"management_mode,omitempty"` + ManagementSource string `json:"management_source,omitempty"` + ManagementKey string `json:"management_key,omitempty"` +} + +type importTMDBRequest struct { + LibraryID int `json:"library_id"` + LibraryIDs []int `json:"library_ids"` + Title string `json:"title"` + Description string `json:"description"` + Preset string `json:"preset"` + TimeWindow string `json:"time_window"` + MediaType string `json:"media_type"` + Limit *int `json:"limit,omitempty"` + Featured bool `json:"featured"` + PosterSourceURL string `json:"poster_source_url"` + BackdropSourceURL string `json:"backdrop_source_url"` + SyncSchedule string `json:"sync_schedule"` + ManagementMode string `json:"management_mode,omitempty"` + ManagementSource string `json:"management_source,omitempty"` + ManagementKey string `json:"management_key,omitempty"` +} + +// importTMDBFranchiseRequest is the request body for creating a collection +// backed by a TMDB `/collection/{id}` franchise/saga. Naming distinguishes +// this from the older importTMDBRequest, which targets TMDB preset listings +// (popular, trending, etc.) — the two share the on-disk collection_type +// "tmdb" but differ in source_config.mode (`tmdb_preset` vs `tmdb_collection`). +type importTMDBFranchiseRequest struct { + LibraryID int `json:"library_id"` + LibraryIDs []int `json:"library_ids"` + Title string `json:"title"` + Description string `json:"description"` + CollectionID int `json:"collection_id"` + Limit *int `json:"limit,omitempty"` + Featured bool `json:"featured"` + PosterSourceURL string `json:"poster_source_url"` + BackdropSourceURL string `json:"backdrop_source_url"` + SyncSchedule string `json:"sync_schedule"` + ManagementMode string `json:"management_mode,omitempty"` + ManagementSource string `json:"management_source,omitempty"` + ManagementKey string `json:"management_key,omitempty"` +} + +// importTMDBDiscoverRequest is the request body for creating a collection +// backed by TMDB `/discover/{movie,tv}`. The full discover filter set is +// stored inline so the same shape can round-trip through source_config. +// +// The on-disk collection_type is "tmdb" — the discover specialization lives +// entirely in source_config.mode = "tmdb_discover". +type importTMDBDiscoverRequest struct { + LibraryID int `json:"library_id"` + LibraryIDs []int `json:"library_ids"` + Title string `json:"title"` + Description string `json:"description"` + MediaType string `json:"media_type"` + Spec importTMDBDiscoverSpecBody `json:"spec"` + Limit *int `json:"limit,omitempty"` + Featured bool `json:"featured"` + PosterSourceURL string `json:"poster_source_url"` + BackdropSourceURL string `json:"backdrop_source_url"` + SyncSchedule string `json:"sync_schedule"` + ManagementMode string `json:"management_mode,omitempty"` + ManagementSource string `json:"management_source,omitempty"` + ManagementKey string `json:"management_key,omitempty"` +} + +// importTMDBDiscoverSpecBody mirrors templates.TMDBDiscoverSpec without the +// media_type field, which lives on the request envelope. +type importTMDBDiscoverSpecBody struct { + WithGenres []int `json:"with_genres,omitempty"` + WithoutGenres []int `json:"without_genres,omitempty"` + SortBy string `json:"sort_by"` + VoteCountGte int `json:"vote_count_gte,omitempty"` + VoteAverageGte float64 `json:"vote_average_gte,omitempty"` + ReleaseDateGte string `json:"release_date_gte,omitempty"` + ReleaseDateLte string `json:"release_date_lte,omitempty"` + Certifications []string `json:"certifications,omitempty"` + CertificationLte string `json:"certification_lte,omitempty"` + WithRuntimeGte int `json:"with_runtime_gte,omitempty"` + WithRuntimeLte int `json:"with_runtime_lte,omitempty"` + OriginalLanguage string `json:"original_language,omitempty"` +} + +type importTraktRequest struct { + LibraryID int `json:"library_id"` + LibraryIDs []int `json:"library_ids"` + Title string `json:"title"` + Description string `json:"description"` + Preset string `json:"preset"` + MediaType string `json:"media_type"` + ProfileID string `json:"profile_id,omitempty"` + Limit *int `json:"limit,omitempty"` + Featured bool `json:"featured"` + PosterSourceURL string `json:"poster_source_url"` + BackdropSourceURL string `json:"backdrop_source_url"` + SyncSchedule string `json:"sync_schedule"` + ManagementMode string `json:"management_mode,omitempty"` + ManagementSource string `json:"management_source,omitempty"` + ManagementKey string `json:"management_key,omitempty"` +} + +type importCollectionResponse struct { + Collection libraryCollectionResponse `json:"collection"` + SyncRun *models.LibraryCollectionSyncRun `json:"sync_run,omitempty"` +} + +type applyTemplateBundleRequest struct { + LibraryIDs []int `json:"library_ids"` + DryRun bool `json:"dry_run"` + DeleteExisting bool `json:"delete_existing"` + Featured *templateBundleFeaturedRequest `json:"featured,omitempty"` +} + +type templateBundleFeaturedRequest struct { + Home *templateBundleFeaturedHome `json:"home,omitempty"` + Libraries map[int]string `json:"libraries,omitempty"` +} + +type templateBundleFeaturedHome struct { + LibraryID int `json:"library_id"` + TemplateID string `json:"template_id"` +} + +type templateBundleApplyEntry struct { + TemplateID string `json:"template_id"` + TemplateTitle string `json:"template_title"` + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + CollectionID string `json:"collection_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type templateBundleCollectionEntry struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + CollectionID string `json:"collection_id"` + CollectionTitle string `json:"collection_title"` + Reason string `json:"reason,omitempty"` +} + +type templateBundleFeaturedEntry struct { + Surface string `json:"surface"` + LibraryID int `json:"library_id,omitempty"` + LibraryName string `json:"library_name,omitempty"` + TemplateID string `json:"template_id"` + TemplateTitle string `json:"template_title"` + CollectionID string `json:"collection_id,omitempty"` + SectionID string `json:"section_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type applyTemplateBundleResponse struct { + BundleID string `json:"bundle_id"` + DryRun bool `json:"dry_run"` + DeleteExisting bool `json:"delete_existing"` + Deleted []templateBundleCollectionEntry `json:"deleted"` + DeleteSkipped []templateBundleCollectionEntry `json:"delete_skipped"` + DeleteFailed []templateBundleCollectionEntry `json:"delete_failed"` + Created []templateBundleApplyEntry `json:"created"` + Skipped []templateBundleApplyEntry `json:"skipped"` + Failed []templateBundleApplyEntry `json:"failed"` + Featured []templateBundleFeaturedEntry `json:"featured"` + FeaturedFailed []templateBundleFeaturedEntry `json:"featured_failed"` +} + +type pendingTemplateBundleSync struct { + CollectionID string + Entry templateBundleApplyEntry +} + +type templateBundleCollectionRef struct { + CollectionID string + Template templates.Template + Library *models.MediaFolder +} + +type templateBundleCollectionRefKey struct { + LibraryID int + TemplateID string +} + +type requestValidationError struct { + err error +} + +func (e requestValidationError) Error() string { return e.err.Error() } +func (e requestValidationError) Unwrap() error { return e.err } + +func hasLibrarySelection(libraryID int, libraryIDs []int) bool { + return libraryID > 0 || len(libraryIDs) > 0 +} + +func uniquePositiveInts(values []int) []int { + seen := make(map[int]struct{}, len(values)) + out := make([]int, 0, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func templateBundleManagementKey(bundleID, templateID string, libraryID int) string { + return fmt.Sprintf("%s:%s:library:%d", bundleID, templateID, libraryID) +} + +func templateLimitPtr(tmpl templates.Template) *int { + if tmpl.DefaultLimit <= 0 { + return nil + } + limit := tmpl.DefaultLimit + return &limit +} + +func templateBundleEntry( + tmpl templates.Template, + templateID string, + library *models.MediaFolder, + collectionID string, + reason string, +) templateBundleApplyEntry { + title := tmpl.Title + if title == "" { + title = templateID + } + return templateBundleApplyEntry{ + TemplateID: templateID, + TemplateTitle: title, + LibraryID: library.ID, + LibraryName: library.Name, + CollectionID: collectionID, + Reason: reason, + } +} + +func templateBundleCollectionEntryForLibrary( + collection *models.LibraryCollection, + library *models.MediaFolder, + reason string, +) templateBundleCollectionEntry { + return templateBundleCollectionEntry{ + LibraryID: library.ID, + LibraryName: library.Name, + CollectionID: collection.ID, + CollectionTitle: collection.Title, + Reason: reason, + } +} + +func allCollectionLibrariesSelected(collection *models.LibraryCollection, selected map[int]struct{}) bool { + for _, libraryID := range collection.LibraryIDs { + if _, ok := selected[libraryID]; !ok { + return false + } + } + return true +} + +func templateEligibleForLibrary(tmpl templates.Template, library *models.MediaFolder) bool { + if library == nil { + return false + } + switch strings.TrimSpace(strings.ToLower(library.Type)) { + case "", "mixed": + return true + case "movie", "movies": + return tmpl.MediaKind == templates.MediaMovie || tmpl.MediaKind == templates.MediaMixed + case "series", "tv", "show", "shows", "tvshows": + return tmpl.MediaKind == templates.MediaTV || tmpl.MediaKind == templates.MediaMixed + default: + return true + } +} + +type previewLibraryCollectionResponse struct { + Items []itemListResponse `json:"items"` + Total int `json:"total"` +} + +type serverUserCollectionsListResponse struct { + Collections []usercollections.ServerVisibleCollection `json:"collections"` +} + +type libraryTabCollection struct { + ID string `json:"id"` + Title string `json:"title"` + PosterURL string `json:"poster_url"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + ItemCount int `json:"item_count"` + Featured bool `json:"featured,omitempty"` + CreatorProfileID *string `json:"creator_profile_id,omitempty"` +} + +type libraryTabGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Kind models.LibraryCollectionGroupKind `json:"kind"` + SortMode models.GroupSortMode `json:"sort_mode"` + SortOrder int `json:"sort_order"` + Collections []libraryTabCollection `json:"collections"` +} + +type libraryTabUngrouped struct { + SortOrder int `json:"sort_order"` + Collections []libraryTabCollection `json:"collections"` +} + +type libraryTabResponse struct { + LibraryID int `json:"library_id"` + Collections []libraryCollectionResponse `json:"collections,omitempty"` + Groups []libraryTabGroup `json:"groups"` + Ungrouped *libraryTabUngrouped `json:"ungrouped,omitempty"` +} + +// HandleListLibraryUserCollections returns the viewer's own personal +// collections that they've opted into their library Collections tab and whose +// library scope matches the requested library. Personal collections are +// private to their owner; this endpoint never reveals other users' rows. +func (h *LibraryCollectionHandler) HandleListLibraryUserCollections(w http.ResponseWriter, r *http.Request) { + libraryID, ok := parsePathLibraryID(w, r) + if !ok { + return + } + if !requestCanAccessLibrary(r, libraryID) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + if h.UserCollectionPool == nil { + writeJSON(w, http.StatusOK, serverUserCollectionsListResponse{Collections: []usercollections.ServerVisibleCollection{}}) + return + } + + userID := apimw.GetUserID(r.Context()) + collections, err := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, libraryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user collections") + return + } + + if collections == nil { + collections = []usercollections.ServerVisibleCollection{} + } + for i := range collections { + collections[i].PosterURL = h.presignGPURL(r, collections[i].PosterPath) + } + writeJSON(w, http.StatusOK, serverUserCollectionsListResponse{Collections: collections}) +} + +func (h *LibraryCollectionHandler) HandleListLibraryCollections(w http.ResponseWriter, r *http.Request) { + libraryID, ok := parsePathLibraryID(w, r) + if !ok { + return + } + if !requestCanAccessLibrary(r, libraryID) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + + adminCollections, err := h.repo.ListByLibrary(r.Context(), libraryID, catalog.ListLibraryCollectionsOptions{}) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collections") + return + } + if h.GroupRepo == nil { + writeJSON(w, http.StatusOK, libraryCollectionsListResponse{ + Collections: h.toLibraryCollectionResponses(r, adminCollections), + }) + return + } + + userID := apimw.GetUserID(r.Context()) + groups, err := h.GroupRepo.ListByLibrary(r.Context(), libraryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load groups") + return + } + adminCollectionsByGroup := groupLibraryTabCollections(adminCollections) + + resp := libraryTabResponse{ + LibraryID: libraryID, + Collections: h.toLibraryCollectionResponses(r, adminCollections), + Groups: []libraryTabGroup{}, + } + var userCollections []usercollections.ServerVisibleCollection + userCollectionsLoaded := false + for _, g := range groups { + var colls []libraryTabCollection + switch g.Kind { + case models.GroupKindUserCollections: + if h.UserCollectionPool == nil || userID == 0 { + continue + } + if !userCollectionsLoaded { + loadedUserCollections, loadErr := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, libraryID) + if loadErr != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user collections") + return + } + userCollections = loadedUserCollections + userCollectionsLoaded = true + } + sorted := applyUserCollectionSort(userCollections, g.DefaultSortMode) + for i := range sorted { + posterURL := h.presignGPURL(r, sorted[i].PosterPath) + creatorProfileID := sorted[i].CreatorProfileID + colls = append(colls, libraryTabCollection{ + ID: sorted[i].ID, + Title: sorted[i].Name, + PosterURL: posterURL, + PosterThumbhash: sorted[i].PosterThumbhash, + ItemCount: sorted[i].ItemCount, + CreatorProfileID: &creatorProfileID, + }) + } + default: + collections := adminCollectionsByGroup[g.ID] + collections = applyCollectionSort(collections, g.DefaultSortMode) + for _, c := range collections { + colls = append(colls, libraryTabCollection{ + ID: c.ID, + Title: c.Title, + PosterURL: h.presignGPURL(r, c.PosterURL), + PosterThumbhash: c.PosterThumbhash, + ItemCount: c.ItemCount, + Featured: c.Featured, + }) + } + } + if len(colls) == 0 { + continue + } + resp.Groups = append(resp.Groups, libraryTabGroup{ + ID: g.ID, + Name: g.Name, + Kind: g.Kind, + SortMode: g.DefaultSortMode, + SortOrder: g.SortOrder, + Collections: colls, + }) + } + + ungrouped := adminCollectionsByGroup[groupKeyPtr(nil)] + if len(ungrouped) > 0 { + uColls := make([]libraryTabCollection, 0, len(ungrouped)) + for _, c := range ungrouped { + uColls = append(uColls, libraryTabCollection{ + ID: c.ID, + Title: c.Title, + PosterURL: h.presignGPURL(r, c.PosterURL), + PosterThumbhash: c.PosterThumbhash, + ItemCount: c.ItemCount, + Featured: c.Featured, + }) + } + sortOrder := 9999 + if h.GroupRepo != nil { + if sortOrder, err = h.GroupRepo.GetUngroupedSortOrder(r.Context(), libraryID); err != nil { + sortOrder = 9999 + } + } + resp.Ungrouped = &libraryTabUngrouped{SortOrder: sortOrder, Collections: uColls} + } + + writeJSON(w, http.StatusOK, resp) +} + +func toLibraryCollectionGroupResponses(groups []models.LibraryCollectionGroup) []libraryCollectionGroupResponse { + out := make([]libraryCollectionGroupResponse, 0, len(groups)) + for _, g := range groups { + out = append(out, toLibraryCollectionGroupResponse(g)) + } + return out +} + +func groupLibraryTabCollections(collections []*models.LibraryCollection) map[string][]*models.LibraryCollection { + grouped := make(map[string][]*models.LibraryCollection) + for _, collection := range collections { + key := groupKeyPtr(collection.GroupID) + grouped[key] = append(grouped[key], collection) + } + return grouped +} + +func groupKeyPtr(id *string) string { + if id == nil { + return "" + } + return *id +} + +func toLibraryCollectionGroupResponse(g models.LibraryCollectionGroup) libraryCollectionGroupResponse { + return libraryCollectionGroupResponse{ + ID: g.ID, + LibraryID: g.LibraryID, + Name: g.Name, + Slug: g.Slug, + Kind: string(g.Kind), + DefaultSortMode: string(g.DefaultSortMode), + SortOrder: g.SortOrder, + } +} + +func applyCollectionSort(collections []*models.LibraryCollection, mode models.GroupSortMode) []*models.LibraryCollection { + out := append([]*models.LibraryCollection(nil), collections...) + switch mode { + case models.GroupSortNameAsc: + sort.Slice(out, func(i, j int) bool { return out[i].Title < out[j].Title }) + case models.GroupSortNameDesc: + sort.Slice(out, func(i, j int) bool { return out[i].Title > out[j].Title }) + case models.GroupSortRecent: + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) }) + case models.GroupSortMostItems: + sort.Slice(out, func(i, j int) bool { return out[i].ItemCount > out[j].ItemCount }) + } + return out +} + +func applyUserCollectionSort(collections []usercollections.ServerVisibleCollection, mode models.GroupSortMode) []usercollections.ServerVisibleCollection { + out := append([]usercollections.ServerVisibleCollection(nil), collections...) + switch mode { + case models.GroupSortNameAsc: + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + case models.GroupSortNameDesc: + sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name }) + case models.GroupSortRecent: + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) + case models.GroupSortMostItems: + sort.Slice(out, func(i, j int) bool { return out[i].ItemCount > out[j].ItemCount }) + } + return out +} + +func (h *LibraryCollectionHandler) refreshSmartCountAsync(collectionID string) { + if h.SmartCountRefresher == nil || collectionID == "" { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := h.SmartCountRefresher.RefreshOne(ctx, collectionID); err != nil { + slog.Warn("smart-count refresh failed", "collection_id", collectionID, "error", err) + } + }() +} + +func (h *LibraryCollectionHandler) HandleGetLibraryCollectionItems(w http.ResponseWriter, r *http.Request) { + libraryID, ok := parsePathLibraryID(w, r) + if !ok { + return + } + if !requestCanAccessLibrary(r, libraryID) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + + collectionID := chi.URLParam(r, "collection_id") + collection, err := h.repo.GetByID(r.Context(), collectionID) + if err != nil || collection.LibraryID != libraryID || collection.Visibility != "visible" { + writeError(w, http.StatusNotFound, "not_found", "Collection not found") + return + } + + var items []itemListResponse + if catalog.IsLiveQueryType(collection.CollectionType) { + items, err = h.loadLiveCollectionItems(r, collection) + } else { + items, err = h.loadOrderedCollectionItems(r, collectionID) + } + if err != nil { + var queryErr smartCollectionQueryError + if errors.As(err, &queryErr) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection items") + return + } + + writeJSON(w, http.StatusOK, browseResponse{Items: items, Total: len(items), HasMore: false}) +} + +func (h *LibraryCollectionHandler) HandleListAdminCollections(w http.ResponseWriter, r *http.Request) { + var libraryID *int + if raw := r.URL.Query().Get("library_id"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id") + return + } + libraryID = &parsed + } + + collectionsCh := make(chan []*models.LibraryCollection, 1) + eg, egCtx := errgroup.WithContext(r.Context()) + eg.Go(func() error { + collections, err := h.repo.ListAll(egCtx, libraryID, catalog.ListLibraryCollectionsOptions{IncludeHidden: true}) + if err != nil { + return err + } + collectionsCh <- collections + return nil + }) + var groupsCh chan []models.LibraryCollectionGroup + if libraryID != nil && h.GroupRepo != nil { + scopedID := *libraryID + groupsCh = make(chan []models.LibraryCollectionGroup, 1) + eg.Go(func() error { + groups, err := h.GroupRepo.ListByLibrary(egCtx, scopedID) + if err != nil { + return err + } + groupsCh <- groups + return nil + }) + } + if err := eg.Wait(); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collections") + return + } + collections := <-collectionsCh + var groups []models.LibraryCollectionGroup + if groupsCh != nil { + groups = <-groupsCh + } + + resp := libraryCollectionsListResponse{ + Collections: make([]libraryCollectionResponse, 0, len(collections)), + } + for _, collection := range collections { + resp.Collections = append(resp.Collections, h.toLibraryCollectionResponse(r, collection)) + } + if libraryID != nil { + resp.Groups = toLibraryCollectionGroupResponses(groups) + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWriter, r *http.Request) { + var req createLibraryCollectionRequest + if err := decodeJSONOrMultipart(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids and title are required") + return + } + if req.Slug == "" { + req.Slug = slugifyCollectionName(req.Title) + } + if req.CollectionType == "" { + req.CollectionType = "manual" + } + queryDefinition := defaultJSON(req.QueryDefinition) + if req.CollectionType == "smart" || len(req.QueryDefinition) > 0 { + var err error + queryDefinition, err = normalizeQueryDefinitionJSON(queryDefinition, false, false) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + } + + var syncSchedule *string + if s := strings.TrimSpace(req.SyncSchedule); s != "" { + if err := catalog.ParseCronExpression(s); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + syncSchedule = &s + } + managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + collection, err := h.repo.Create(r.Context(), catalog.CreateLibraryCollectionInput{ + LibraryID: req.LibraryID, + LibraryIDs: req.LibraryIDs, + Slug: req.Slug, + Title: req.Title, + Description: req.Description, + CollectionType: req.CollectionType, + Visibility: defaultCollectionVisibility(req.Visibility), + SortOrder: req.SortOrder, + GroupID: req.GroupID, + Featured: req.Featured, + PosterURL: req.PosterURL, + BackdropURL: req.BackdropURL, + SourceURL: req.SourceURL, + QueryDefinition: queryDefinition, + SortConfig: defaultJSON(req.SortConfig), + SourceConfig: defaultCollectionSourceConfig(req.SourceConfig), + ManagementMode: managementMode, + ManagementSource: managementSource, + ManagementKey: managementKey, + SyncSchedule: syncSchedule, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection") + return + } + + if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images") + return + } + if r.MultipartForm != nil || strings.TrimSpace(req.PosterSourceURL) != "" || strings.TrimSpace(req.BackdropSourceURL) != "" { + collection, err = h.repo.GetByID(r.Context(), collection.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection") + return + } + } + h.refreshSmartCountAsync(collection.ID) + + writeJSON(w, http.StatusCreated, h.toLibraryCollectionResponse(r, collection)) +} + +func (h *LibraryCollectionHandler) HandleUpdateAdminCollection(w http.ResponseWriter, r *http.Request) { + collectionID := chi.URLParam(r, "id") + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + + var req updateLibraryCollectionRequest + if err := decodeJSONOrMultipart(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + queryDefinition := req.QueryDefinition + if len(req.QueryDefinition) > 0 { + var err error + queryDefinition, err = normalizeQueryDefinitionJSON(req.QueryDefinition, false, false) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + } + + // Validate sync_schedule if provided. + if req.SyncSchedule != nil && *req.SyncSchedule != "" { + if err := catalog.ParseCronExpression(*req.SyncSchedule); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + } + + var setGroupID **string + if req.GroupID.Set() { + groupID := req.GroupID.Value() + if groupID != nil && strings.TrimSpace(*groupID) == "" { + groupID = nil + } + setGroupID = &groupID + } + + if err := h.repo.Update(r.Context(), catalog.UpdateLibraryCollectionInput{ + ID: collectionID, + LibraryIDs: req.LibraryIDs, + Slug: req.Slug, + Title: req.Title, + Description: req.Description, + CollectionType: req.CollectionType, + Visibility: req.Visibility, + SortOrder: req.SortOrder, + SetGroupID: setGroupID, + Featured: req.Featured, + PosterURL: req.PosterURL, + BackdropURL: req.BackdropURL, + SourceURL: req.SourceURL, + QueryDefinition: queryDefinition, + SortConfig: req.SortConfig, + SourceConfig: req.SourceConfig, + ManagementMode: normalizeOptionalCollectionManagementMode(req.ManagementMode), + ManagementSource: req.ManagementSource, + ManagementKey: req.ManagementKey, + SyncSchedule: req.SyncSchedule, + }); err != nil { + if err == catalog.ErrLibraryCollectionNotFound { + writeError(w, http.StatusNotFound, "not_found", "Collection not found") + return + } + if errors.Is(err, catalog.ErrLibraryCollectionGroupNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Collection group not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update collection") + return + } + + if err := h.processArtworkInputs(r, collectionID, pointerStringValue(req.PosterSourceURL), pointerStringValue(req.BackdropSourceURL)); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images") + return + } + + updated, err := h.repo.GetByID(r.Context(), collectionID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection") + return + } + if len(queryDefinition) > 0 || req.CollectionType != nil { + h.refreshSmartCountAsync(collectionID) + } + writeJSON(w, http.StatusOK, h.toLibraryCollectionResponse(r, updated)) +} + +func (h *LibraryCollectionHandler) HandlePreviewAdminCollection(w http.ResponseWriter, r *http.Request) { + if h.Executor == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Preview is not configured") + return + } + + var req previewCollectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + var def catalog.QueryDefinition + if len(req.QueryDefinition) > 0 { + normalized, err := normalizeQueryDefinitionJSON(req.QueryDefinition, false, false) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + if err := json.Unmarshal(normalized, &def); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + } + + items, total, err := h.Executor.Preview(r.Context(), def, catalog.AccessFilter{}, req.Limit) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + resp := previewLibraryCollectionResponse{Items: make([]itemListResponse, 0, len(items)), Total: total} + for _, item := range items { + resp.Items = append(resp.Items, h.toItemListResponse(r, item)) + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *LibraryCollectionHandler) HandleDeleteCollectionImage(w http.ResponseWriter, r *http.Request) { + collectionID := chi.URLParam(r, "id") + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + + imageType := r.URL.Query().Get("type") + switch imageType { + case "poster", "backdrop": + default: + writeError(w, http.StatusBadRequest, "bad_request", "type must be \"poster\" or \"backdrop\"") + return + } + + if err := h.deleteCollectionImages(r.Context(), collectionID, imageType); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete images") + return + } + + empty := "" + notAutoGen := false + input := catalog.UpdateLibraryCollectionInput{ID: collectionID} + if imageType == "poster" { + input.PosterURL = &empty + input.PosterThumbhash = &empty + input.PosterAutoGenerated = ¬AutoGen + } else { + input.BackdropURL = &empty + input.BackdropThumbhash = &empty + } + if err := h.repo.Update(r.Context(), input); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update collection") + return + } + + // After clearing, attempt to auto-generate a poster collage. + if imageType == "poster" && h.service.CollageGen != nil { + if err := h.GenerateCollectionPoster(r.Context(), collectionID); err != nil { + if errors.Is(err, collage.ErrNotEnoughImages) { + slog.Debug("collage: not enough images to regenerate after delete", "collection_id", collectionID) + } else { + slog.Warn("collage: failed to regenerate poster after delete", "collection_id", collectionID, "error", err) + } + } + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *LibraryCollectionHandler) HandleDeleteAdminCollection(w http.ResponseWriter, r *http.Request) { + collectionID := chi.URLParam(r, "id") + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + if err := h.deleteServerCollection(r.Context(), collectionID); err != nil { + switch { + case errors.Is(err, errLibraryCollectionInUse): + writeError(w, http.StatusConflict, "collection_in_use", "Collection is used by one or more sections") + case errors.Is(err, catalog.ErrLibraryCollectionNotFound): + writeError(w, http.StatusNotFound, "not_found", "Collection not found") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete collection") + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *LibraryCollectionHandler) deleteServerCollection(ctx context.Context, collectionID string) error { + if h.SectionRepo != nil { + refs, err := h.SectionRepo.CountLibraryCollectionReferences(ctx, collectionID, "") + if err != nil { + return fmt.Errorf("checking collection usage: %w", err) + } + if refs > 0 { + return errLibraryCollectionInUse + } + } + + // Clean up S3 images before deleting the collection row. Failures here + // only leak storage; the collection row delete must still proceed so the + // admin's request succeeds. + if h.s3GP != nil { + prefix := fmt.Sprintf("collection-images/%s/", collectionID) + keys, err := h.s3GP.ListObjects(ctx, h.s3GP.Bucket(), prefix) + if err != nil { + slog.Warn("collection delete: listing S3 images failed; image keys may leak", + "collection_id", collectionID, "prefix", prefix, "error", err) + } + for _, key := range keys { + if err := h.s3GP.DeleteObject(ctx, h.s3GP.Bucket(), key); err != nil { + slog.Warn("collection delete: removing S3 image failed; key leaks", + "collection_id", collectionID, "key", key, "error", err) + } + } + } + + return h.repo.Delete(ctx, collectionID) +} + +type adminReorderCollectionsRequest struct { + LibraryID int `json:"library_id"` + OrderedIDs []string `json:"ordered_ids"` + GroupID *string `json:"group_id,omitempty"` +} + +type adminReorderItemsRequest struct { + OrderedIDs []string `json:"ordered_ids"` +} + +type adminCollectionItemRequest struct { + Position int `json:"position"` +} + +// HandleReorderAdminCollections handles PUT /admin/collections/order. +// Accepts a library_id and the full list of collection ids in the new order. +func (h *LibraryCollectionHandler) HandleReorderAdminCollections(w http.ResponseWriter, r *http.Request) { + var req adminReorderCollectionsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.LibraryID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "library_id is required") + return + } + if err := h.repo.ReorderCollections(r.Context(), req.LibraryID, req.GroupID, req.OrderedIDs); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// requireManualCollection loads a collection by URL param "id" and rejects +// the request unless it is a manual collection. Returns nil after writing +// the appropriate HTTP error response if the collection is missing, of the +// wrong type, or the database lookup failed. +func (h *LibraryCollectionHandler) requireManualCollection(w http.ResponseWriter, r *http.Request) (string, bool) { + collectionID := chi.URLParam(r, "id") + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return "", false + } + collection, err := h.repo.GetByID(r.Context(), collectionID) + if err != nil { + if errors.Is(err, catalog.ErrLibraryCollectionNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Collection not found") + return "", false + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection") + return "", false + } + if collection.CollectionType != "manual" { + writeError(w, http.StatusConflict, "collection_not_manual", "Only manual collections support manual items") + return "", false + } + return collectionID, true +} + +// HandleReorderAdminCollectionItems handles PUT /admin/collections/{id}/items/order. +// Manual collections only — synced types (mdblist/tmdb/trakt) and smart +// collections reject the call. +func (h *LibraryCollectionHandler) HandleReorderAdminCollectionItems(w http.ResponseWriter, r *http.Request) { + collectionID, ok := h.requireManualCollection(w, r) + if !ok { + return + } + + var req adminReorderItemsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if err := h.repo.ReorderItems(r.Context(), collectionID, req.OrderedIDs); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleAddAdminCollectionItem handles PUT /admin/collections/{id}/items/{item_id}. +// Adds an item or updates its position. Manual collections only. +func (h *LibraryCollectionHandler) HandleAddAdminCollectionItem(w http.ResponseWriter, r *http.Request) { + collectionID, ok := h.requireManualCollection(w, r) + if !ok { + return + } + itemID := chi.URLParam(r, "item_id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "item ID is required") + return + } + + var req adminCollectionItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + // Empty body is allowed; defaults to position 0. + } + if err := h.repo.AddItem(r.Context(), collectionID, itemID, req.Position); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add collection item") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleRemoveAdminCollectionItem handles DELETE /admin/collections/{id}/items/{item_id}. +// Manual collections only. +func (h *LibraryCollectionHandler) HandleRemoveAdminCollectionItem(w http.ResponseWriter, r *http.Request) { + collectionID, ok := h.requireManualCollection(w, r) + if !ok { + return + } + itemID := chi.URLParam(r, "item_id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "item ID is required") + return + } + if err := h.repo.RemoveItem(r.Context(), collectionID, itemID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove collection item") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *LibraryCollectionHandler) HandleSyncAdminCollection(w http.ResponseWriter, r *http.Request) { + collectionID := chi.URLParam(r, "id") + run, err := h.service.SyncCollection(r.Context(), collectionID) + if err != nil { + if errors.Is(err, catalog.ErrLibraryCollectionSyncUnsupported) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync collection") + return + } + writeJSON(w, http.StatusOK, run) +} + +func (h *LibraryCollectionHandler) templateRegistry() *templates.Registry { + if h.TemplateRegistry != nil { + return h.TemplateRegistry + } + return templates.Default +} + +func (h *LibraryCollectionHandler) HandleListTemplateBundles(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, h.templateRegistry().BundleCatalog()) +} + +func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWriter, r *http.Request) { + bundleID := chi.URLParam(r, "bundleID") + bundle, ok := h.templateRegistry().GetBundle(bundleID) + if !ok { + writeError(w, http.StatusNotFound, "not_found", "Template bundle not found") + return + } + + var req applyTemplateBundleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + libraryIDs := uniquePositiveInts(req.LibraryIDs) + if len(libraryIDs) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "library_ids is required") + return + } + if h.FolderRepo == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Library lookup is not configured") + return + } + if h.repo == nil || (!req.DryRun && h.service == nil) { + writeError(w, http.StatusInternalServerError, "internal_error", "Collection service is not configured") + return + } + + libraries, err := h.FolderRepo.ListByIDs(r.Context(), libraryIDs) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load libraries") + return + } + librariesByID := make(map[int]*models.MediaFolder, len(libraries)) + for _, library := range libraries { + librariesByID[library.ID] = library + } + if len(librariesByID) != len(libraryIDs) { + writeError(w, http.StatusBadRequest, "bad_request", "library_ids must reference enabled libraries") + return + } + + // Detach from the request context so a closed client tab does not abort + // the apply mid-way and leave the bundle in a partially-created state. + // Re-runs are idempotent via the management_key uniqueness, but only if + // the server completes the work it started. + workCtx := context.WithoutCancel(r.Context()) + + resp := applyTemplateBundleResponse{ + BundleID: bundle.ID, + DryRun: req.DryRun, + DeleteExisting: req.DeleteExisting, + Deleted: []templateBundleCollectionEntry{}, + DeleteSkipped: []templateBundleCollectionEntry{}, + DeleteFailed: []templateBundleCollectionEntry{}, + Created: []templateBundleApplyEntry{}, + Skipped: []templateBundleApplyEntry{}, + Failed: []templateBundleApplyEntry{}, + Featured: []templateBundleFeaturedEntry{}, + FeaturedFailed: []templateBundleFeaturedEntry{}, + } + + selectedLibraryIDs := make(map[int]struct{}, len(libraryIDs)) + for _, libraryID := range libraryIDs { + selectedLibraryIDs[libraryID] = struct{}{} + } + if req.DeleteExisting { + if !req.DryRun && h.SectionRepo != nil { + // Generated featured sections must go before we delete the + // collections they reference; otherwise every following delete + // trips the in-use guard and the admin sees a misleading wall of + // "collection_in_use" errors. + if err := h.SectionRepo.DeleteGeneratedTemplateBundleFeaturedSections(workCtx, bundle.ID, libraryIDs); err != nil { + slog.Error("deleting generated template bundle featured sections", "bundle_id", bundle.ID, "error", err) + writeError(w, http.StatusInternalServerError, "delete_setup_failed", "Failed to clear generated featured sections before delete") + return + } + } + seenCollections := make(map[string]struct{}) + for _, libraryID := range libraryIDs { + library := librariesByID[libraryID] + collections, err := h.repo.ListByLibrary(workCtx, libraryID, catalog.ListLibraryCollectionsOptions{IncludeHidden: true}) + if err != nil { + resp.DeleteFailed = append(resp.DeleteFailed, templateBundleCollectionEntry{ + LibraryID: library.ID, + LibraryName: library.Name, + Reason: err.Error(), + }) + continue + } + for _, collection := range collections { + if _, ok := seenCollections[collection.ID]; ok { + continue + } + seenCollections[collection.ID] = struct{}{} + entry := templateBundleCollectionEntryForLibrary(collection, library, "") + if !allCollectionLibrariesSelected(collection, selectedLibraryIDs) { + entry.Reason = "shared_with_unselected_library" + resp.DeleteSkipped = append(resp.DeleteSkipped, entry) + continue + } + if req.DryRun { + entry.Reason = "would_delete" + resp.Deleted = append(resp.Deleted, entry) + continue + } + if err := h.deleteServerCollection(workCtx, collection.ID); err != nil { + entry.Reason = err.Error() + resp.DeleteFailed = append(resp.DeleteFailed, entry) + continue + } + entry.Reason = "deleted" + resp.Deleted = append(resp.Deleted, entry) + } + } + } + + collectionRefs := make(map[templateBundleCollectionRefKey]templateBundleCollectionRef) + pendingSyncs := make([]pendingTemplateBundleSync, 0) + for _, libraryID := range libraryIDs { + library := librariesByID[libraryID] + for _, templateID := range bundle.TemplateIDs { + tmpl, ok := h.templateRegistry().Get(templateID) + if !ok { + resp.Failed = append(resp.Failed, templateBundleEntry(tmpl, templateID, library, "", "template_not_found")) + continue + } + entry := templateBundleEntry(tmpl, templateID, library, "", "") + if !templateEligibleForLibrary(tmpl, library) { + entry.Reason = "ineligible_library" + resp.Skipped = append(resp.Skipped, entry) + continue + } + key := templateBundleManagementKey(bundle.ID, templateID, library.ID) + existing, err := h.repo.GetByManagementKey(workCtx, collectionManagementModeTemplateBundle, bundle.ID, key) + if err == nil { + entry.CollectionID = existing.ID + entry.Reason = "already_exists" + resp.Skipped = append(resp.Skipped, entry) + collectionRefs[templateBundleCollectionRefKey{LibraryID: library.ID, TemplateID: tmpl.ID}] = templateBundleCollectionRef{ + CollectionID: existing.ID, + Template: tmpl, + Library: library, + } + continue + } + if !errors.Is(err, catalog.ErrLibraryCollectionNotFound) { + entry.Reason = err.Error() + resp.Failed = append(resp.Failed, entry) + continue + } + if req.DryRun { + entry.Reason = "would_create" + resp.Created = append(resp.Created, entry) + collectionRefs[templateBundleCollectionRefKey{LibraryID: library.ID, TemplateID: tmpl.ID}] = templateBundleCollectionRef{ + Template: tmpl, + Library: library, + } + continue + } + + collection, err := h.createCollectionFromTemplate(workCtx, bundle.ID, tmpl, library.ID, key) + if err != nil { + entry.Reason = err.Error() + resp.Failed = append(resp.Failed, entry) + continue + } + entry.CollectionID = collection.ID + pendingSyncs = append(pendingSyncs, pendingTemplateBundleSync{ + CollectionID: collection.ID, + Entry: entry, + }) + } + } + + if len(pendingSyncs) > 0 { + created, failed := h.syncTemplateBundleCollections(workCtx, pendingSyncs) + resp.Created = append(resp.Created, created...) + resp.Failed = append(resp.Failed, failed...) + for _, entry := range created { + library := librariesByID[entry.LibraryID] + tmpl, _ := h.templateRegistry().Get(entry.TemplateID) + collectionRefs[templateBundleCollectionRefKey{LibraryID: entry.LibraryID, TemplateID: entry.TemplateID}] = templateBundleCollectionRef{ + CollectionID: entry.CollectionID, + Template: tmpl, + Library: library, + } + } + h.generateTemplateBundleCollagesAsync(created) + } + + if req.Featured != nil { + featured, failed := h.applyTemplateBundleFeaturedSections( + workCtx, + bundle, + req.Featured, + librariesByID, + collectionRefs, + req.DryRun, + ) + resp.Featured = append(resp.Featured, featured...) + resp.FeaturedFailed = append(resp.FeaturedFailed, failed...) + } + + writeJSON(w, http.StatusOK, resp) +} + +func (h *LibraryCollectionHandler) syncTemplateBundleCollections( + ctx context.Context, + pending []pendingTemplateBundleSync, +) ([]templateBundleApplyEntry, []templateBundleApplyEntry) { + type syncResult struct { + entry templateBundleApplyEntry + err error + } + results := make([]syncResult, len(pending)) + var eg errgroup.Group + eg.SetLimit(templateBundleSyncConcurrency) + for i, item := range pending { + i, item := i, item + eg.Go(func() error { + _, err := h.service.SyncCollectionWithOptions(ctx, item.CollectionID, catalog.SyncCollectionOptions{ + SkipCollage: true, + }) + results[i] = syncResult{entry: item.Entry, err: err} + return nil + }) + } + _ = eg.Wait() + + created := make([]templateBundleApplyEntry, 0, len(pending)) + failed := make([]templateBundleApplyEntry, 0) + for _, result := range results { + if result.err != nil { + result.entry.Reason = result.err.Error() + failed = append(failed, result.entry) + continue + } + created = append(created, result.entry) + } + return created, failed +} + +func (h *LibraryCollectionHandler) generateTemplateBundleCollagesAsync(entries []templateBundleApplyEntry) { + if h.service == nil || h.service.CollageGen == nil || len(entries) == 0 { + return + } + collectionIDs := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.CollectionID != "" { + collectionIDs = append(collectionIDs, entry.CollectionID) + } + } + if len(collectionIDs) == 0 { + return + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), templateBundleCollageTimeout) + defer cancel() + + var eg errgroup.Group + eg.SetLimit(templateBundleCollageConcurrency) + for _, collectionID := range collectionIDs { + collectionID := collectionID + eg.Go(func() error { + if err := h.service.CollageGen.GenerateCollectionPoster(ctx, collectionID); err != nil { + if errors.Is(err, collage.ErrNotEnoughImages) { + slog.Debug("collage: not enough images", "collection_id", collectionID) + } else { + slog.Warn("collage: async poster generation failed", "collection_id", collectionID, "error", err) + } + } + return nil + }) + } + _ = eg.Wait() + }() +} + +func (h *LibraryCollectionHandler) applyTemplateBundleFeaturedSections( + ctx context.Context, + bundle templates.Bundle, + req *templateBundleFeaturedRequest, + librariesByID map[int]*models.MediaFolder, + collectionRefs map[templateBundleCollectionRefKey]templateBundleCollectionRef, + dryRun bool, +) ([]templateBundleFeaturedEntry, []templateBundleFeaturedEntry) { + if req == nil { + return nil, nil + } + featured := make([]templateBundleFeaturedEntry, 0) + failed := make([]templateBundleFeaturedEntry, 0) + + if req.Home != nil && strings.TrimSpace(req.Home.TemplateID) != "" { + ok, fail := h.applyTemplateBundleFeaturedSelection(ctx, bundle, "home", req.Home.LibraryID, req.Home.TemplateID, librariesByID, collectionRefs, dryRun) + if fail != nil { + failed = append(failed, *fail) + } else if ok != nil { + featured = append(featured, *ok) + } + } + + libraryIDs := make([]int, 0, len(req.Libraries)) + for libraryID := range req.Libraries { + libraryIDs = append(libraryIDs, libraryID) + } + sort.Ints(libraryIDs) + for _, libraryID := range libraryIDs { + templateID := strings.TrimSpace(req.Libraries[libraryID]) + if templateID == "" { + continue + } + ok, fail := h.applyTemplateBundleFeaturedSelection(ctx, bundle, "library", libraryID, templateID, librariesByID, collectionRefs, dryRun) + if fail != nil { + failed = append(failed, *fail) + continue + } + if ok != nil { + featured = append(featured, *ok) + } + } + + return featured, failed +} + +func (h *LibraryCollectionHandler) applyTemplateBundleFeaturedSelection( + ctx context.Context, + bundle templates.Bundle, + surface string, + libraryID int, + templateID string, + librariesByID map[int]*models.MediaFolder, + collectionRefs map[templateBundleCollectionRefKey]templateBundleCollectionRef, + dryRun bool, +) (*templateBundleFeaturedEntry, *templateBundleFeaturedEntry) { + library := librariesByID[libraryID] + tmpl, ok := h.templateRegistry().Get(templateID) + entry := templateBundleFeaturedEntry{ + Surface: surface, + LibraryID: libraryID, + TemplateID: templateID, + } + if library != nil { + entry.LibraryName = library.Name + } + if ok { + entry.TemplateTitle = tmpl.Title + } + + fail := func(reason string) (*templateBundleFeaturedEntry, *templateBundleFeaturedEntry) { + entry.Reason = reason + return nil, &entry + } + + if library == nil { + return fail("library_not_selected") + } + if !ok { + return fail("template_not_found") + } + if !slices.Contains(bundle.TemplateIDs, templateID) { + return fail("template_not_in_bundle") + } + if !templateEligibleForLibrary(tmpl, library) { + return fail("ineligible_library") + } + ref, ok := collectionRefs[templateBundleCollectionRefKey{LibraryID: libraryID, TemplateID: templateID}] + if !ok { + return fail("collection_not_available") + } + entry.CollectionID = ref.CollectionID + if dryRun { + entry.Reason = "would_create" + return &entry, nil + } + if h.SectionRepo == nil { + return fail("section_repo_not_configured") + } + + sectionID, err := h.upsertTemplateBundleFeaturedSection(ctx, bundle.ID, surface, ref) + if err != nil { + return fail(err.Error()) + } + entry.SectionID = sectionID + entry.Reason = "created" + return &entry, nil +} + +func (h *LibraryCollectionHandler) upsertTemplateBundleFeaturedSection( + ctx context.Context, + bundleID string, + surface string, + ref templateBundleCollectionRef, +) (string, error) { + if h.SectionRepo == nil { + return "", fmt.Errorf("section repo not configured") + } + if ref.Library == nil { + return "", fmt.Errorf("library not available") + } + if ref.CollectionID == "" { + return "", fmt.Errorf("collection not available") + } + + scope := "home" + var sectionLibraryID *int + if surface == "library" { + scope = "library" + libraryID := ref.Library.ID + sectionLibraryID = &libraryID + } + + config, err := templateBundleFeaturedSectionConfig(bundleID, surface, ref) + if err != nil { + return "", err + } + + existing, err := h.SectionRepo.GetGeneratedTemplateBundleFeaturedSection(ctx, bundleID, scope, sectionLibraryID) + if err == nil { + existing.SectionType = sections.SectionCollection + existing.Title = ref.Template.Title + existing.Featured = true + existing.ItemLimit = templateBundleFeaturedSectionItemLimit + existing.Config = config + existing.Enabled = true + if err := h.SectionRepo.Update(ctx, existing); err != nil { + return "", err + } + if err := h.SectionRepo.ClearFeaturedForSurface(ctx, scope, sectionLibraryID, existing.ID); err != nil { + return "", err + } + return existing.ID, nil + } + if !errors.Is(err, sections.ErrSectionNotFound) { + return "", err + } + + created, err := h.SectionRepo.Create(ctx, §ions.PageSection{ + Scope: scope, + LibraryID: sectionLibraryID, + Position: 0, + SectionType: sections.SectionCollection, + Title: ref.Template.Title, + Featured: true, + ItemLimit: templateBundleFeaturedSectionItemLimit, + Config: config, + Enabled: true, + }) + if err != nil { + return "", err + } + if err := h.SectionRepo.ClearFeaturedForSurface(ctx, scope, sectionLibraryID, created.ID); err != nil { + return "", err + } + return created.ID, nil +} + +const templateBundleFeaturedSectionItemLimit = 12 + +func templateBundleFeaturedSectionConfig( + bundleID string, + surface string, + ref templateBundleCollectionRef, +) (json.RawMessage, error) { + payload := map[string]any{ + "library_collection_id": ref.CollectionID, + "generated_source": "template_bundle_featured", + "template_bundle": bundleID, + "template_id": ref.Template.ID, + "surface": surface, + "library_id": ref.Library.ID, + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("building featured section config: %w", err) + } + return raw, nil +} + +func (h *LibraryCollectionHandler) createCollectionFromTemplate( + ctx context.Context, + bundleID string, + tmpl templates.Template, + libraryID int, + managementKey string, +) (*models.LibraryCollection, error) { + limit := templateLimitPtr(tmpl) + switch tmpl.Source { + case templates.SourceTMDB: + if tmpl.TMDB == nil { + return nil, fmt.Errorf("template %q is missing TMDB config", tmpl.ID) + } + return h.createTMDBCollection(ctx, importTMDBRequest{ + LibraryID: libraryID, + Title: tmpl.Title, + Description: tmpl.Description, + Preset: tmpl.TMDB.Preset, + TimeWindow: tmpl.TMDB.TimeWindow, + MediaType: tmpl.TMDB.MediaType, + Limit: limit, + Featured: tmpl.Featured, + SyncSchedule: tmpl.DefaultSyncSchedule, + ManagementMode: collectionManagementModeTemplateBundle, + ManagementSource: bundleID, + ManagementKey: managementKey, + }) + case templates.SourceMDBList: + if tmpl.MDBList == nil || strings.TrimSpace(tmpl.MDBList.URL) == "" { + return nil, fmt.Errorf("template %q is missing MDBList URL", tmpl.ID) + } + return h.createMDBListCollection(ctx, importMDBListRequest{ + LibraryID: libraryID, + Title: tmpl.Title, + Description: tmpl.Description, + URL: tmpl.MDBList.URL, + Limit: limit, + Featured: tmpl.Featured, + SyncSchedule: tmpl.DefaultSyncSchedule, + ManagementMode: collectionManagementModeTemplateBundle, + ManagementSource: bundleID, + ManagementKey: managementKey, + }) + case templates.SourceTMDBCollection: + if tmpl.TMDBCollection == nil { + return nil, fmt.Errorf("template %q is missing TMDBCollection config", tmpl.ID) + } + return h.createTMDBFranchiseCollection(ctx, importTMDBFranchiseRequest{ + LibraryID: libraryID, + Title: tmpl.Title, + Description: tmpl.Description, + CollectionID: tmpl.TMDBCollection.CollectionID, + Limit: limit, + Featured: tmpl.Featured, + SyncSchedule: tmpl.DefaultSyncSchedule, + ManagementMode: collectionManagementModeTemplateBundle, + ManagementSource: bundleID, + ManagementKey: managementKey, + }) + case templates.SourceTMDBDiscover: + if tmpl.TMDBDiscover == nil { + return nil, fmt.Errorf("template %q is missing TMDBDiscover config", tmpl.ID) + } + return h.createTMDBDiscoverCollection(ctx, importTMDBDiscoverRequest{ + LibraryID: libraryID, + Title: tmpl.Title, + Description: tmpl.Description, + MediaType: tmpl.TMDBDiscover.MediaType, + Spec: importTMDBDiscoverSpecBody{ + WithGenres: tmpl.TMDBDiscover.WithGenres, + WithoutGenres: tmpl.TMDBDiscover.WithoutGenres, + SortBy: tmpl.TMDBDiscover.SortBy, + VoteCountGte: tmpl.TMDBDiscover.VoteCountGte, + VoteAverageGte: tmpl.TMDBDiscover.VoteAverageGte, + ReleaseDateGte: tmpl.TMDBDiscover.ReleaseDateGte, + ReleaseDateLte: tmpl.TMDBDiscover.ReleaseDateLte, + Certifications: tmpl.TMDBDiscover.Certifications, + CertificationLte: tmpl.TMDBDiscover.CertificationLte, + WithRuntimeGte: tmpl.TMDBDiscover.WithRuntimeGte, + WithRuntimeLte: tmpl.TMDBDiscover.WithRuntimeLte, + OriginalLanguage: tmpl.TMDBDiscover.OriginalLanguage, + }, + Limit: limit, + Featured: tmpl.Featured, + SyncSchedule: tmpl.DefaultSyncSchedule, + ManagementMode: collectionManagementModeTemplateBundle, + ManagementSource: bundleID, + ManagementKey: managementKey, + }) + default: + return nil, fmt.Errorf("template source %q is not supported by bundles", tmpl.Source) + } +} + +func (h *LibraryCollectionHandler) createMDBListCollection( + ctx context.Context, + req importMDBListRequest, +) (*models.LibraryCollection, error) { + var syncSchedule *string + if s := strings.TrimSpace(req.SyncSchedule); s != "" { + if err := catalog.ParseCronExpression(s); err != nil { + return nil, requestValidationError{err: err} + } + syncSchedule = &s + } + + normalizedURL := usercollections.NormalizeMDBListURL(req.URL) + sourceConfig, err := buildMDBListSourceConfig(normalizedURL, req.Limit) + if err != nil { + return nil, fmt.Errorf("building MDBList source config: %w", err) + } + managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey) + if err != nil { + return nil, requestValidationError{err: err} + } + collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{ + LibraryID: req.LibraryID, + LibraryIDs: req.LibraryIDs, + Slug: slugifyCollectionName(req.Title), + Title: req.Title, + Description: req.Description, + CollectionType: "mdblist", + Visibility: "visible", + Featured: req.Featured, + SourceURL: req.URL, + SourceConfig: sourceConfig, + ManagementMode: managementMode, + ManagementSource: managementSource, + ManagementKey: managementKey, + SyncSchedule: syncSchedule, + }) + if err != nil { + return nil, fmt.Errorf("creating collection: %w", err) + } + return collection, nil +} + +func (h *LibraryCollectionHandler) createTMDBCollection( + ctx context.Context, + req importTMDBRequest, +) (*models.LibraryCollection, error) { + preset, mediaType, timeWindow, err := normalizeTMDBPresetRequest(req.Preset, req.MediaType, req.TimeWindow) + if err != nil { + return nil, requestValidationError{err: err} + } + + var syncSchedule *string + if s := strings.TrimSpace(req.SyncSchedule); s != "" { + if err := catalog.ParseCronExpression(s); err != nil { + return nil, requestValidationError{err: err} + } + syncSchedule = &s + } + + sourceConfig, err := buildTMDBSourceConfig(preset, mediaType, timeWindow, req.Limit) + if err != nil { + return nil, fmt.Errorf("building TMDB source config: %w", err) + } + managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey) + if err != nil { + return nil, requestValidationError{err: err} + } + collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{ + LibraryID: req.LibraryID, + LibraryIDs: req.LibraryIDs, + Slug: slugifyCollectionName(req.Title), + Title: req.Title, + Description: req.Description, + CollectionType: "tmdb", + Visibility: "visible", + Featured: req.Featured, + SourceURL: buildTMDBSourceURL(preset, mediaType, timeWindow), + SourceConfig: sourceConfig, + ManagementMode: managementMode, + ManagementSource: managementSource, + ManagementKey: managementKey, + SyncSchedule: syncSchedule, + }) + if err != nil { + return nil, fmt.Errorf("creating collection: %w", err) + } + return collection, nil +} + +// createTMDBFranchiseCollection persists a TMDB-franchise-backed library +// collection. It re-uses collection_type "tmdb" (set on the existing TMDB +// preset path) so listing/admin filters that group by collection_type don't +// need to learn a new discriminator — the source distinction lives entirely +// in source_config.mode. +// +// A CollectionID of 0 is allowed at create time: this is how the bundle-apply +// flow materializes the catalog's generic "TMDB Franchise" placeholder +// template. The first sync against such a row records a failed run with a +// human-readable explanation prompting the admin to edit the config. +func (h *LibraryCollectionHandler) createTMDBFranchiseCollection( + ctx context.Context, + req importTMDBFranchiseRequest, +) (*models.LibraryCollection, error) { + if req.CollectionID < 0 { + return nil, requestValidationError{err: fmt.Errorf("collection_id must be >= 0")} + } + + var syncSchedule *string + if s := strings.TrimSpace(req.SyncSchedule); s != "" { + if err := catalog.ParseCronExpression(s); err != nil { + return nil, requestValidationError{err: err} + } + syncSchedule = &s + } + + sourceConfig, err := buildTMDBCollectionSourceConfig(req.CollectionID, req.Limit) + if err != nil { + return nil, fmt.Errorf("building TMDB franchise source config: %w", err) + } + managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey) + if err != nil { + return nil, requestValidationError{err: err} + } + collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{ + LibraryID: req.LibraryID, + LibraryIDs: req.LibraryIDs, + Slug: slugifyCollectionName(req.Title), + Title: req.Title, + Description: req.Description, + CollectionType: "tmdb", + Visibility: "visible", + Featured: req.Featured, + SourceURL: buildTMDBCollectionSourceURL(req.CollectionID), + SourceConfig: sourceConfig, + ManagementMode: managementMode, + ManagementSource: managementSource, + ManagementKey: managementKey, + SyncSchedule: syncSchedule, + }) + if err != nil { + return nil, fmt.Errorf("creating collection: %w", err) + } + return collection, nil +} + +// createTMDBDiscoverCollection persists a TMDB-discover-backed library +// collection. Like createTMDBFranchiseCollection, it re-uses collection_type +// "tmdb" so admin listings don't need to learn another discriminator — the +// discover specialization lives entirely in source_config.mode. +func (h *LibraryCollectionHandler) createTMDBDiscoverCollection( + ctx context.Context, + req importTMDBDiscoverRequest, +) (*models.LibraryCollection, error) { + if err := validateTMDBDiscoverRequest(req); err != nil { + return nil, requestValidationError{err: err} + } + + var syncSchedule *string + if s := strings.TrimSpace(req.SyncSchedule); s != "" { + if err := catalog.ParseCronExpression(s); err != nil { + return nil, requestValidationError{err: err} + } + syncSchedule = &s + } + + sourceConfig, err := buildTMDBDiscoverSourceConfig(req.MediaType, req.Spec, req.Limit) + if err != nil { + return nil, fmt.Errorf("building TMDB discover source config: %w", err) + } + managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey) + if err != nil { + return nil, requestValidationError{err: err} + } + collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{ + LibraryID: req.LibraryID, + LibraryIDs: req.LibraryIDs, + Slug: slugifyCollectionName(req.Title), + Title: req.Title, + Description: req.Description, + CollectionType: "tmdb", + Visibility: "visible", + Featured: req.Featured, + SourceURL: buildTMDBDiscoverSourceURL(req.MediaType, req.Spec.SortBy), + SourceConfig: sourceConfig, + ManagementMode: managementMode, + ManagementSource: managementSource, + ManagementKey: managementKey, + SyncSchedule: syncSchedule, + }) + if err != nil { + return nil, fmt.Errorf("creating collection: %w", err) + } + return collection, nil +} + +// validateTMDBDiscoverRequest performs basic sanity checks on the request +// body. The deeper spec validation (sort_by, date formats, etc.) lives on +// templates.validateTMDBDiscover; reuse via importTMDBDiscoverSpecBody isn't +// straightforward, so this duplicates the most user-facing checks and leaves +// nuance to the catalog sync. +func validateTMDBDiscoverRequest(req importTMDBDiscoverRequest) error { + switch req.MediaType { + case "movie", "tv": + default: + return fmt.Errorf("media_type must be \"movie\" or \"tv\"") + } + if strings.TrimSpace(req.Spec.SortBy) == "" { + return fmt.Errorf("spec.sort_by is required") + } + if req.Limit != nil && *req.Limit <= 0 { + return fmt.Errorf("limit must be greater than 0") + } + return nil +} + +func (h *LibraryCollectionHandler) HandleImportMDBList(w http.ResponseWriter, r *http.Request) { + var req importMDBListRequest + if err := decodeJSONOrMultipart(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.URL) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids, title, and url are required") + return + } + if req.Limit != nil && *req.Limit <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be greater than 0") + return + } + + collection, err := h.createMDBListCollection(r.Context(), req) + if err != nil { + var validationErr requestValidationError + if errors.As(err, &validationErr) { + writeError(w, http.StatusBadRequest, "bad_request", validationErr.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection") + return + } + + // Process admin artwork before sync so maybeGenerateCollage sees the + // uploaded poster and skips collage generation. + if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images") + return + } + + run, err := h.service.SyncCollection(r.Context(), collection.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to import MDBList collection") + return + } + + refreshed, err := h.repo.GetByID(r.Context(), collection.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection") + return + } + + writeJSON(w, http.StatusCreated, importCollectionResponse{ + Collection: h.toLibraryCollectionResponse(r, refreshed), + SyncRun: run, + }) +} + +func (h *LibraryCollectionHandler) HandleImportTMDBCollection(w http.ResponseWriter, r *http.Request) { + var req importTMDBRequest + if err := decodeJSONOrMultipart(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids and title are required") + return + } + if _, _, _, err := normalizeTMDBPresetRequest(req.Preset, req.MediaType, req.TimeWindow); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if req.Limit != nil && *req.Limit <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be greater than 0") + return + } + + collection, err := h.createTMDBCollection(r.Context(), req) + if err != nil { + var validationErr requestValidationError + if errors.As(err, &validationErr) { + writeError(w, http.StatusBadRequest, "bad_request", validationErr.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection") + return + } + + // Process admin artwork before sync so maybeGenerateCollage sees the + // uploaded poster and skips collage generation. + if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images") + return + } + + run, err := h.service.SyncCollection(r.Context(), collection.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync TMDB collection") + return + } + + refreshed, err := h.repo.GetByID(r.Context(), collection.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection") + return + } + + writeJSON(w, http.StatusCreated, importCollectionResponse{ + Collection: h.toLibraryCollectionResponse(r, refreshed), + SyncRun: run, + }) +} + +func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWriter, r *http.Request) { + var req importTraktRequest + if err := decodeJSONOrMultipart(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids and title are required") + return + } + preset, mediaType, profileID, err := normalizeTraktPresetRequest(req.Preset, req.MediaType, req.ProfileID) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if req.Limit != nil && *req.Limit <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be greater than 0") + return + } + + var syncSchedule *string + if s := strings.TrimSpace(req.SyncSchedule); s != "" { + if err := catalog.ParseCronExpression(s); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + syncSchedule = &s + } + + sourceConfig, err := buildTraktSourceConfig(preset, mediaType, profileID, req.Limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build source config") + return + } + managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + collection, err := h.repo.Create(r.Context(), catalog.CreateLibraryCollectionInput{ + LibraryID: req.LibraryID, + LibraryIDs: req.LibraryIDs, + Slug: slugifyCollectionName(req.Title), + Title: req.Title, + Description: req.Description, + CollectionType: "trakt", + Visibility: "visible", + Featured: req.Featured, + SourceURL: buildTraktSourceURL(preset, mediaType, profileID), + SourceConfig: sourceConfig, + ManagementMode: managementMode, + ManagementSource: managementSource, + ManagementKey: managementKey, + SyncSchedule: syncSchedule, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection") + return + } + + if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images") + return + } + + run, err := h.service.SyncCollection(r.Context(), collection.ID) + if err != nil { + slog.Error("failed to sync imported Trakt collection", + "collection_id", collection.ID, + "library_id", req.LibraryID, + "title", req.Title, + "preset", preset, + "media_type", mediaType, + "error", err, + ) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync Trakt collection") + return + } + + refreshed, err := h.repo.GetByID(r.Context(), collection.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection") + return + } + + writeJSON(w, http.StatusCreated, importCollectionResponse{ + Collection: h.toLibraryCollectionResponse(r, refreshed), + SyncRun: run, + }) +} + +func (h *LibraryCollectionHandler) loadOrderedCollectionItems(r *http.Request, collectionID string) ([]itemListResponse, error) { + collectionItems, err := h.repo.ListItems(r.Context(), collectionID) + if err != nil { + return nil, err + } + + contentIDs := make([]string, 0, len(collectionItems)) + for _, item := range collectionItems { + contentIDs = append(contentIDs, item.MediaItemID) + } + + items, err := h.itemRepo.GetByIDs(r.Context(), contentIDs) + if err != nil { + return nil, err + } + + itemByID := make(map[string]*models.MediaItem, len(items)) + for _, item := range items { + itemByID[item.ContentID] = item + } + + resp := make([]itemListResponse, 0, len(collectionItems)) + for _, collectionItem := range collectionItems { + item, ok := itemByID[collectionItem.MediaItemID] + if !ok { + continue + } + resp = append(resp, h.toItemListResponse(r, item)) + } + return resp, nil +} + +func (h *LibraryCollectionHandler) loadLiveCollectionItems(r *http.Request, collection *models.LibraryCollection) ([]itemListResponse, error) { + if h.Executor == nil { + return nil, fmt.Errorf("query executor is not configured") + } + + var def catalog.QueryDefinition + if len(collection.QueryDefinition) > 0 { + if err := json.Unmarshal(collection.QueryDefinition, &def); err != nil { + return nil, smartCollectionQueryError{fmt.Errorf("parsing smart collection query definition: %w", err)} + } + } + def = def.Normalize() + if err := def.ValidateWithOptions(false, false); err != nil { + return nil, smartCollectionQueryError{fmt.Errorf("validating smart collection query definition: %w", err)} + } + + switch { + case len(collection.LibraryIDs) > 0: + def.LibraryIDs = intersectCollectionLibraryIDs(def.LibraryIDs, collection.LibraryIDs) + if len(def.LibraryIDs) == 0 { + return []itemListResponse{}, nil + } + case collection.LibraryID > 0: + def.LibraryIDs = intersectCollectionLibraryIDs(def.LibraryIDs, []int{collection.LibraryID}) + if len(def.LibraryIDs) == 0 { + return []itemListResponse{}, nil + } + } + + items, total, err := h.Executor.Preview(r.Context(), def, requestAccessFilter(r), 1) + if err != nil { + return nil, err + } + if total == 0 { + return []itemListResponse{}, nil + } + if total > len(items) { + items, _, err = h.Executor.Preview(r.Context(), def, requestAccessFilter(r), total) + if err != nil { + return nil, err + } + } + + resp := make([]itemListResponse, 0, len(items)) + for _, item := range items { + resp = append(resp, h.toItemListResponse(r, item)) + } + return resp, nil +} + +type smartCollectionQueryError struct { + err error +} + +func (e smartCollectionQueryError) Error() string { + return e.err.Error() +} + +func (e smartCollectionQueryError) Unwrap() error { + return e.err +} + +func intersectCollectionLibraryIDs(existing, required []int) []int { + if len(required) == 0 { + return append([]int(nil), existing...) + } + if len(existing) == 0 { + return append([]int(nil), required...) + } + + allowed := make(map[int]struct{}, len(required)) + for _, id := range required { + allowed[id] = struct{}{} + } + + result := make([]int, 0, len(existing)) + seen := make(map[int]struct{}, len(existing)) + for _, id := range existing { + if _, ok := allowed[id]; !ok { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func (h *LibraryCollectionHandler) toItemListResponse(r *http.Request, item *models.MediaItem) itemListResponse { + resp := itemListResponse{ + ContentID: item.ContentID, + Type: item.Type, + Title: item.Title, + Year: item.Year, + Genres: item.Genres, + ContentRating: item.ContentRating, + Status: item.Status, + RatingIMDB: item.RatingIMDB, + Overview: item.Overview, + PosterThumbhash: item.PosterThumbhash, + BackdropThumbhash: item.BackdropThumbhash, + } + resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card") + resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card") + return resp +} + +func (h *LibraryCollectionHandler) presignURL(r *http.Request, path string, variant string) string { + if h.detailSvc != nil { + return h.detailSvc.PresignURL(r.Context(), path, variant) + } + return "" +} + +func (h *LibraryCollectionHandler) presignGPURL(r *http.Request, path string) string { + if path == "" { + return "" + } + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + if h.s3GP == nil { + return "" + } + url, err := h.s3GP.PresignGetURL(r.Context(), h.s3GP.Bucket(), cardThumbnailPath(path), h.presignTTL) + if err != nil { + return "" + } + return url +} + +func (h *LibraryCollectionHandler) toLibraryCollectionResponse(r *http.Request, collection *models.LibraryCollection) libraryCollectionResponse { + resp := libraryCollectionResponse{ + ID: collection.ID, + LibraryID: collection.LibraryID, + LibraryIDs: append([]int(nil), collection.LibraryIDs...), + Slug: collection.Slug, + Title: collection.Title, + Description: collection.Description, + CollectionType: collection.CollectionType, + Visibility: collection.Visibility, + SortOrder: collection.SortOrder, + GroupID: collection.GroupID, + Featured: collection.Featured, + PosterURL: h.presignGPURL(r, collection.PosterURL), + BackdropURL: h.presignGPURL(r, collection.BackdropURL), + PosterThumbhash: collection.PosterThumbhash, + BackdropThumbhash: collection.BackdropThumbhash, + SourceURL: collection.SourceURL, + QueryDefinition: defaultJSON(collection.QueryDefinition), + SortConfig: defaultJSON(collection.SortConfig), + SourceConfig: collection.SourceConfig, + ManagementMode: collection.ManagementMode, + ManagementSource: collection.ManagementSource, + ManagementKey: collection.ManagementKey, + LastSyncStatus: collection.LastSyncStatus, + LastSyncMessage: collection.LastSyncMessage, + ItemCount: collection.ItemCount, + CreatedAt: collection.CreatedAt.Format(time.RFC3339), + UpdatedAt: collection.UpdatedAt.Format(time.RFC3339), + } + if collection.LastSyncAt != nil { + resp.LastSyncAt = collection.LastSyncAt.Format(time.RFC3339) + } + if collection.SyncSchedule != nil { + resp.SyncSchedule = *collection.SyncSchedule + } + if collection.NextSyncAt != nil { + resp.NextSyncAt = collection.NextSyncAt.Format(time.RFC3339) + } + return resp +} + +func (h *LibraryCollectionHandler) toLibraryCollectionResponses(r *http.Request, collections []*models.LibraryCollection) []libraryCollectionResponse { + out := make([]libraryCollectionResponse, 0, len(collections)) + for _, collection := range collections { + out = append(out, h.toLibraryCollectionResponse(r, collection)) + } + return out +} + +func parsePathLibraryID(w http.ResponseWriter, r *http.Request) (int, bool) { + libraryID, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return 0, false + } + return libraryID, true +} + +func requestCanAccessLibrary(r *http.Request, libraryID int) bool { + scope, ok := access.GetScope(r.Context()) + if !ok || scope.AllowedLibraryIDs == nil { + return true + } + return slices.Contains(scope.AllowedLibraryIDs, libraryID) +} + +func slugifyCollectionName(title string) string { + title = strings.ToLower(strings.TrimSpace(title)) + var builder strings.Builder + lastHyphen := false + for _, r := range title { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + builder.WriteRune(r) + lastHyphen = false + continue + } + if !lastHyphen { + builder.WriteByte('-') + lastHyphen = true + } + } + return strings.Trim(builder.String(), "-") +} + +func defaultCollectionVisibility(visibility string) string { + if visibility == "" { + return "visible" + } + return visibility +} + +func normalizeCollectionManagementMode(mode string) string { + switch strings.TrimSpace(strings.ToLower(mode)) { + case collectionManagementModeSection, collectionManagementModeTemplateBundle: + return strings.TrimSpace(strings.ToLower(mode)) + default: + return collectionManagementModeManual + } +} + +func normalizeCollectionManagementFields(mode, source, key string) (string, string, string, error) { + normalizedMode := normalizeCollectionManagementMode(mode) + normalizedSource := strings.TrimSpace(source) + normalizedKey := strings.TrimSpace(key) + if normalizedMode != collectionManagementModeManual && normalizedKey == "" { + return "", "", "", fmt.Errorf("management_key is required for managed collections") + } + if normalizedMode == collectionManagementModeManual { + normalizedSource = "" + normalizedKey = "" + } + return normalizedMode, normalizedSource, normalizedKey, nil +} + +func normalizeOptionalCollectionManagementMode(mode *string) *string { + if mode == nil { + return nil + } + normalized := normalizeCollectionManagementMode(*mode) + return &normalized +} + +func defaultCollectionSourceConfig(config json.RawMessage) json.RawMessage { + if len(config) == 0 { + return json.RawMessage(`{}`) + } + return config +} + +func buildMDBListSourceConfig(url string, limit *int) (json.RawMessage, error) { + payload := struct { + Mode string `json:"mode"` + URL string `json:"url"` + Limit *int `json:"limit,omitempty"` + }{ + Mode: "mdblist_json", + URL: url, + } + if limit != nil && *limit > 0 { + payload.Limit = limit + } + + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return raw, nil +} + +func buildTMDBSourceConfig(preset, mediaType, timeWindow string, limit *int) (json.RawMessage, error) { + payload := struct { + Mode string `json:"mode"` + Preset string `json:"preset"` + MediaType string `json:"media_type"` + TimeWindow string `json:"time_window,omitempty"` + Limit *int `json:"limit,omitempty"` + }{ + Mode: "tmdb_preset", + Preset: preset, + MediaType: mediaType, + Limit: limit, + } + if preset == "trending" { + payload.TimeWindow = timeWindow + } + + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return raw, nil +} + +// buildTMDBCollectionSourceConfig emits the `source_config` JSON for a TMDB +// franchise/saga collection. CollectionID == 0 is permitted and round-trips +// through omitempty as no key at all — the catalog sync path uses that to +// detect a placeholder template that an admin still needs to configure. +func buildTMDBCollectionSourceConfig(collectionID int, limit *int) (json.RawMessage, error) { + payload := struct { + Mode string `json:"mode"` + CollectionID int `json:"collection_id,omitempty"` + Limit *int `json:"limit,omitempty"` + }{ + Mode: collectionSourceModeTMDBCollection, + CollectionID: collectionID, + Limit: limit, + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return raw, nil +} + +// buildTMDBCollectionSourceURL composes the deterministic source URL string +// stored alongside the collection. Mirrors buildTMDBSourceURL. +func buildTMDBCollectionSourceURL(collectionID int) string { + return fmt.Sprintf("tmdb://collection/%d", collectionID) +} + +// tmdbDiscoverConfigBody is the on-disk shape of the `discover` object inside +// a tmdb_discover collection's source_config. Field names match the +// templates.TMDBDiscoverSpec JSON contract so the catalog sync can decode the +// same JSON either way. +type tmdbDiscoverConfigBody struct { + WithGenres []int `json:"with_genres,omitempty"` + WithoutGenres []int `json:"without_genres,omitempty"` + SortBy string `json:"sort_by"` + VoteCountGte int `json:"vote_count_gte,omitempty"` + VoteAverageGte float64 `json:"vote_average_gte,omitempty"` + ReleaseDateGte string `json:"release_date_gte,omitempty"` + ReleaseDateLte string `json:"release_date_lte,omitempty"` + Certifications []string `json:"certifications,omitempty"` + CertificationLte string `json:"certification_lte,omitempty"` + WithRuntimeGte int `json:"with_runtime_gte,omitempty"` + WithRuntimeLte int `json:"with_runtime_lte,omitempty"` + OriginalLanguage string `json:"original_language,omitempty"` +} + +// buildTMDBDiscoverSourceConfig emits the `source_config` JSON for a TMDB +// discover collection. The on-disk shape is: +// +// { "mode":"tmdb_discover", "media_type":"movie", +// "discover": { "sort_by":"...", ... }, "limit": 50 } +// +// The discover sub-object exists so additional discover-only fields don't +// pollute the top-level libraryCollectionSourceConfig struct used for the +// existing tmdb_preset / trakt_preset / mdblist_json modes. +func buildTMDBDiscoverSourceConfig(mediaType string, spec importTMDBDiscoverSpecBody, limit *int) (json.RawMessage, error) { + payload := struct { + Mode string `json:"mode"` + MediaType string `json:"media_type"` + Limit *int `json:"limit,omitempty"` + Discover tmdbDiscoverConfigBody `json:"discover"` + }{ + Mode: collectionSourceModeTMDBDiscover, + MediaType: mediaType, + Limit: limit, + Discover: tmdbDiscoverConfigBody{ + WithGenres: spec.WithGenres, + WithoutGenres: spec.WithoutGenres, + SortBy: spec.SortBy, + VoteCountGte: spec.VoteCountGte, + VoteAverageGte: spec.VoteAverageGte, + ReleaseDateGte: spec.ReleaseDateGte, + ReleaseDateLte: spec.ReleaseDateLte, + Certifications: spec.Certifications, + CertificationLte: spec.CertificationLte, + WithRuntimeGte: spec.WithRuntimeGte, + WithRuntimeLte: spec.WithRuntimeLte, + OriginalLanguage: spec.OriginalLanguage, + }, + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return raw, nil +} + +// buildTMDBDiscoverSourceURL composes a deterministic source URL string for +// a TMDB discover collection. The URL is purely informational — sync uses +// source_config. +func buildTMDBDiscoverSourceURL(mediaType, sortBy string) string { + return fmt.Sprintf("tmdb://discover/%s?sort_by=%s", mediaType, sortBy) +} + +func buildTraktSourceConfig(preset, mediaType, profileID string, limit *int) (json.RawMessage, error) { + payload := struct { + Mode string `json:"mode"` + Provider string `json:"provider"` + Preset string `json:"preset"` + MediaType string `json:"media_type"` + ProfileID string `json:"profile_id,omitempty"` + Limit *int `json:"limit,omitempty"` + }{ + Mode: "trakt_preset", + Provider: "trakt", + Preset: preset, + MediaType: mediaType, + ProfileID: profileID, + Limit: limit, + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return raw, nil +} + +func normalizeTMDBPresetRequest(preset, mediaType, timeWindow string) (string, string, string, error) { + switch preset { + case "trending": + switch mediaType { + case "movie", "tv", "all": + default: + return "", "", "", fmt.Errorf("media_type must be \"movie\", \"tv\", or \"all\"") + } + switch timeWindow { + case "day", "week": + default: + return "", "", "", fmt.Errorf("time_window must be \"day\" or \"week\"") + } + return preset, mediaType, timeWindow, nil + case "popular", "top_rated": + switch mediaType { + case "movie", "tv": + return preset, mediaType, "", nil + default: + return "", "", "", fmt.Errorf("media_type must be \"movie\" or \"tv\" for preset %q", preset) + } + case "now_playing", "upcoming": + if mediaType != "movie" { + return "", "", "", fmt.Errorf("media_type must be \"movie\" for preset %q", preset) + } + return preset, mediaType, "", nil + case "airing_today", "on_the_air": + if mediaType != "tv" { + return "", "", "", fmt.Errorf("media_type must be \"tv\" for preset %q", preset) + } + return preset, mediaType, "", nil + default: + return "", "", "", fmt.Errorf("preset must be one of \"trending\", \"popular\", \"top_rated\", \"now_playing\", \"upcoming\", \"airing_today\", or \"on_the_air\"") + } +} + +func normalizeTraktPresetRequest(preset, mediaType, profileID string) (string, string, string, error) { + preset = strings.TrimSpace(strings.ToLower(preset)) + mediaType = strings.TrimSpace(strings.ToLower(mediaType)) + profileID = strings.TrimSpace(profileID) + + switch preset { + case "trending", "popular": + case "recommended": + if profileID == "" { + return "", "", "", fmt.Errorf("profile_id is required for Trakt recommended collections") + } + default: + return "", "", "", fmt.Errorf("preset must be one of \"trending\", \"popular\", or \"recommended\"") + } + switch mediaType { + case "movie", "tv": + default: + return "", "", "", fmt.Errorf("media_type must be \"movie\" or \"tv\"") + } + if preset != "recommended" { + profileID = "" + } + return preset, mediaType, profileID, nil +} + +func buildTMDBSourceURL(preset, mediaType, timeWindow string) string { + if preset == "trending" { + return fmt.Sprintf("tmdb://%s/%s/%s", preset, mediaType, timeWindow) + } + return fmt.Sprintf("tmdb://%s/%s", preset, mediaType) +} + +func buildTraktSourceURL(preset, mediaType, profileID string) string { + if preset == "recommended" { + return fmt.Sprintf("trakt://%s/%s/%s", preset, mediaType, profileID) + } + return fmt.Sprintf("trakt://%s/%s", preset, mediaType) +} + +func (h *LibraryCollectionHandler) processCollectionImage( + ctx context.Context, + collectionID string, + imageType string, + fileData []byte, +) (s3Path string, thumbhashStr string, err error) { + return uploadCollectionImageVariants(ctx, h.s3GP, adminCollectionImagePrefix, collectionID, imageType, fileData) +} + +func (h *LibraryCollectionHandler) deleteCollectionImages(ctx context.Context, collectionID, imageType string) error { + return removeCollectionImageVariants(ctx, h.s3GP, adminCollectionImagePrefix, collectionID, imageType) +} + +func (h *LibraryCollectionHandler) processArtworkInputs(r *http.Request, collectionID, posterSourceURL, backdropSourceURL string) error { + sourceByType := map[string]string{ + "poster": strings.TrimSpace(posterSourceURL), + "backdrop": strings.TrimSpace(backdropSourceURL), + } + isMultipart := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") + + for _, imageType := range []string{"poster", "backdrop"} { + var ( + fileData []byte + err error + ) + if isMultipart { + fileData, err = readCollectionImageMultipart(r, imageType) + } else { + err = http.ErrMissingFile + } + + switch { + case err == nil: + case err == http.ErrMissingFile: + if sourceByType[imageType] == "" { + continue + } + fileData, err = downloadCollectionImageURL(r.Context(), h.httpClient, sourceByType[imageType]) + if err != nil { + return fmt.Errorf("%s source: %w", imageType, err) + } + default: + return fmt.Errorf("%s: %w", imageType, err) + } + + if err := h.deleteCollectionImages(r.Context(), collectionID, imageType); err != nil { + return fmt.Errorf("deleting %s images: %w", imageType, err) + } + + s3Path, thumbhash, err := h.processCollectionImage(r.Context(), collectionID, imageType, fileData) + if err != nil { + return fmt.Errorf("%s: %w", imageType, err) + } + + posterURL, backdropURL := (*string)(nil), (*string)(nil) + posterThumbhash, backdropThumbhash := (*string)(nil), (*string)(nil) + if imageType == "poster" { + posterURL = &s3Path + posterThumbhash = &thumbhash + } else { + backdropURL = &s3Path + backdropThumbhash = &thumbhash + } + + update := catalog.UpdateLibraryCollectionInput{ + ID: collectionID, + PosterURL: posterURL, + BackdropURL: backdropURL, + PosterThumbhash: posterThumbhash, + BackdropThumbhash: backdropThumbhash, + } + if imageType == "poster" { + adminUpload := false + update.PosterAutoGenerated = &adminUpload + } + if err := h.repo.Update(r.Context(), update); err != nil { + return fmt.Errorf("updating %s: %w", imageType, err) + } + } + return nil +} + +func decodeJSONOrMultipart(r *http.Request, dest interface{}) error { + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/") { + if err := r.ParseMultipartForm(20 << 20); err != nil { + return fmt.Errorf("parsing multipart form: %w", err) + } + dataField := r.FormValue("data") + if dataField == "" { + return fmt.Errorf("missing 'data' field in multipart form") + } + return json.Unmarshal([]byte(dataField), dest) + } + return json.NewDecoder(r.Body).Decode(dest) +} + +func pointerStringValue(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/internal/api/handlers/library_collections_test.go b/internal/api/handlers/library_collections_test.go new file mode 100644 index 00000000..51cbb0bf --- /dev/null +++ b/internal/api/handlers/library_collections_test.go @@ -0,0 +1,485 @@ +package handlers + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Silo-Server/silo-server/internal/collections/templates" + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestNormalizeTraktPresetRequest(t *testing.T) { + preset, mediaType, profileID, err := normalizeTraktPresetRequest("recommended", "tv", "prof-1") + if err != nil { + t.Fatalf("normalizeTraktPresetRequest: %v", err) + } + if preset != "recommended" || mediaType != "tv" || profileID != "prof-1" { + t.Fatalf("got %q/%q/%q", preset, mediaType, profileID) + } + + if _, _, _, err := normalizeTraktPresetRequest("recommended", "movie", ""); err == nil { + t.Fatal("expected profile_id error") + } + if _, _, _, err := normalizeTraktPresetRequest("popular", "all", ""); err == nil { + t.Fatal("expected media_type error") + } +} + +func TestBuildTMDBCollectionSourceConfig(t *testing.T) { + limit := 12 + raw, err := buildTMDBCollectionSourceConfig(86311, &limit) + if err != nil { + t.Fatalf("buildTMDBCollectionSourceConfig: %v", err) + } + + // Wire-format assertion: mode is set, collection_id round-trips, limit + // round-trips. Empty fields (provider, preset, etc.) must not appear. + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["mode"] != collectionSourceModeTMDBCollection { + t.Errorf("mode = %v, want %q", got["mode"], collectionSourceModeTMDBCollection) + } + if cid, ok := got["collection_id"].(float64); !ok || int(cid) != 86311 { + t.Errorf("collection_id = %v, want 86311", got["collection_id"]) + } + if lim, ok := got["limit"].(float64); !ok || int(lim) != 12 { + t.Errorf("limit = %v, want 12", got["limit"]) + } + for _, unwanted := range []string{"preset", "media_type", "time_window", "provider", "url", "profile_id"} { + if _, ok := got[unwanted]; ok { + t.Errorf("unexpected field %q in payload: %#v", unwanted, got) + } + } + + // Decode into the catalog source config struct via JSON so we pin the + // round-trip stability of every field name and the omitempty behavior + // of collection_id. This protects against a silent rename on the + // catalog side breaking the bundle apply path. + type sourceConfigShape struct { + Mode string `json:"mode"` + CollectionID int `json:"collection_id,omitempty"` + Limit *int `json:"limit,omitempty"` + } + var decoded sourceConfigShape + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode source config shape: %v", err) + } + if decoded.Mode != collectionSourceModeTMDBCollection { + t.Fatalf("decoded mode = %q", decoded.Mode) + } + if decoded.CollectionID != 86311 { + t.Fatalf("decoded collection_id = %d, want 86311", decoded.CollectionID) + } + if decoded.Limit == nil || *decoded.Limit != 12 { + t.Fatalf("decoded limit = %v, want 12", decoded.Limit) + } +} + +func TestBuildTMDBCollectionSourceConfig_PlaceholderOmitsCollectionID(t *testing.T) { + // CollectionID == 0 is a documented placeholder; with omitempty the JSON + // must drop the field entirely so older sync code that didn't know about + // the field still works. + raw, err := buildTMDBCollectionSourceConfig(0, nil) + if err != nil { + t.Fatalf("buildTMDBCollectionSourceConfig: %v", err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["mode"] != collectionSourceModeTMDBCollection { + t.Fatalf("mode = %v, want %q", got["mode"], collectionSourceModeTMDBCollection) + } + if _, ok := got["collection_id"]; ok { + t.Errorf("collection_id should be omitted when zero, got %v", got["collection_id"]) + } + if _, ok := got["limit"]; ok { + t.Errorf("limit should be omitted when nil, got %v", got["limit"]) + } +} + +func TestBuildTMDBCollectionSourceURL(t *testing.T) { + if url := buildTMDBCollectionSourceURL(86311); url != "tmdb://collection/86311" { + t.Fatalf("source url = %q", url) + } + if url := buildTMDBCollectionSourceURL(0); url != "tmdb://collection/0" { + t.Fatalf("placeholder source url = %q", url) + } +} + +func TestBuildTMDBDiscoverSourceConfigRoundTrips(t *testing.T) { + limit := 50 + spec := importTMDBDiscoverSpecBody{ + WithGenres: []int{28, 12}, + WithoutGenres: []int{99}, + SortBy: "popularity.desc", + VoteCountGte: 300, + VoteAverageGte: 6.5, + ReleaseDateGte: "2010-01-01", + ReleaseDateLte: "2025-12-31", + Certifications: []string{"PG", "PG-13"}, + WithRuntimeGte: 60, + WithRuntimeLte: 240, + OriginalLanguage: "en", + } + raw, err := buildTMDBDiscoverSourceConfig("movie", spec, &limit) + if err != nil { + t.Fatalf("buildTMDBDiscoverSourceConfig: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["mode"] != "tmdb_discover" { + t.Errorf("mode = %v, want tmdb_discover", got["mode"]) + } + if got["media_type"] != "movie" { + t.Errorf("media_type = %v, want movie", got["media_type"]) + } + if got["limit"].(float64) != 50 { + t.Errorf("limit = %v, want 50", got["limit"]) + } + discover, ok := got["discover"].(map[string]any) + if !ok { + t.Fatalf("discover missing or wrong shape: %#v", got["discover"]) + } + if discover["sort_by"] != "popularity.desc" { + t.Errorf("discover.sort_by = %v, want popularity.desc", discover["sort_by"]) + } + if discover["vote_count_gte"].(float64) != 300 { + t.Errorf("discover.vote_count_gte = %v, want 300", discover["vote_count_gte"]) + } + if discover["vote_average_gte"].(float64) != 6.5 { + t.Errorf("discover.vote_average_gte = %v, want 6.5", discover["vote_average_gte"]) + } + if discover["release_date_gte"] != "2010-01-01" { + t.Errorf("discover.release_date_gte = %v, want 2010-01-01", discover["release_date_gte"]) + } + if discover["original_language"] != "en" { + t.Errorf("discover.original_language = %v, want en", discover["original_language"]) + } + genres, ok := discover["with_genres"].([]any) + if !ok || len(genres) != 2 { + t.Fatalf("discover.with_genres = %#v", discover["with_genres"]) + } + if genres[0].(float64) != 28 || genres[1].(float64) != 12 { + t.Errorf("discover.with_genres values = %v %v", genres[0], genres[1]) + } + certs, ok := discover["certifications"].([]any) + if !ok || len(certs) != 2 { + t.Fatalf("discover.certifications = %#v", discover["certifications"]) + } + if certs[0].(string) != "PG" || certs[1].(string) != "PG-13" { + t.Errorf("discover.certifications values = %v %v", certs[0], certs[1]) + } + + // Round-trip into the same runtime shape that catalog.SyncCollectionWithOptions + // uses so the on-disk JSON layout stays compatible with the sync decoder. + type runtimeDiscover struct { + WithGenres []int `json:"with_genres,omitempty"` + WithoutGenres []int `json:"without_genres,omitempty"` + SortBy string `json:"sort_by"` + VoteCountGte int `json:"vote_count_gte,omitempty"` + VoteAverageGte float64 `json:"vote_average_gte,omitempty"` + ReleaseDateGte string `json:"release_date_gte,omitempty"` + ReleaseDateLte string `json:"release_date_lte,omitempty"` + Certifications []string `json:"certifications,omitempty"` + CertificationLte string `json:"certification_lte,omitempty"` + WithRuntimeGte int `json:"with_runtime_gte,omitempty"` + WithRuntimeLte int `json:"with_runtime_lte,omitempty"` + OriginalLanguage string `json:"original_language,omitempty"` + } + type runtimeConfig struct { + Mode string `json:"mode"` + MediaType string `json:"media_type"` + Limit *int `json:"limit,omitempty"` + Discover *runtimeDiscover `json:"discover,omitempty"` + } + var cfg runtimeConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("decode into runtime config: %v", err) + } + if cfg.Mode != "tmdb_discover" { + t.Errorf("runtime mode = %q, want tmdb_discover", cfg.Mode) + } + if cfg.MediaType != "movie" { + t.Errorf("runtime media_type = %q, want movie", cfg.MediaType) + } + if cfg.Limit == nil || *cfg.Limit != 50 { + t.Errorf("runtime limit = %v, want 50", cfg.Limit) + } + if cfg.Discover == nil { + t.Fatal("runtime discover missing") + } + if cfg.Discover.SortBy != "popularity.desc" { + t.Errorf("runtime sort_by = %q", cfg.Discover.SortBy) + } + if cfg.Discover.VoteCountGte != 300 || cfg.Discover.VoteAverageGte != 6.5 { + t.Errorf("runtime vote thresholds = %d / %v", cfg.Discover.VoteCountGte, cfg.Discover.VoteAverageGte) + } + if cfg.Discover.OriginalLanguage != "en" { + t.Errorf("runtime original_language = %q", cfg.Discover.OriginalLanguage) + } + if len(cfg.Discover.WithGenres) != 2 || cfg.Discover.WithGenres[0] != 28 || cfg.Discover.WithGenres[1] != 12 { + t.Errorf("runtime with_genres = %v", cfg.Discover.WithGenres) + } +} + +func TestBuildTMDBDiscoverSourceConfigMinimal(t *testing.T) { + raw, err := buildTMDBDiscoverSourceConfig("tv", importTMDBDiscoverSpecBody{ + SortBy: "vote_average.desc", + }, nil) + if err != nil { + t.Fatalf("buildTMDBDiscoverSourceConfig: %v", err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["mode"] != "tmdb_discover" || got["media_type"] != "tv" { + t.Errorf("mode/media_type = %v / %v", got["mode"], got["media_type"]) + } + if _, ok := got["limit"]; ok { + t.Errorf("limit should be omitted when nil, got = %#v", got["limit"]) + } + discover, ok := got["discover"].(map[string]any) + if !ok { + t.Fatalf("discover missing: %#v", got["discover"]) + } + if discover["sort_by"] != "vote_average.desc" { + t.Errorf("discover.sort_by = %v", discover["sort_by"]) + } + if _, ok := discover["with_genres"]; ok { + t.Errorf("with_genres should be omitted when empty") + } +} + +func TestValidateTMDBDiscoverRequest(t *testing.T) { + good := importTMDBDiscoverRequest{ + LibraryID: 1, + Title: "Action", + MediaType: "movie", + Spec: importTMDBDiscoverSpecBody{SortBy: "popularity.desc"}, + } + if err := validateTMDBDiscoverRequest(good); err != nil { + t.Fatalf("valid request rejected: %v", err) + } + + bad := good + bad.MediaType = "all" + if err := validateTMDBDiscoverRequest(bad); err == nil { + t.Fatal("expected error for media_type=all") + } + + bad = good + bad.Spec.SortBy = "" + if err := validateTMDBDiscoverRequest(bad); err == nil { + t.Fatal("expected error for missing sort_by") + } + + bad = good + limit := 0 + bad.Limit = &limit + if err := validateTMDBDiscoverRequest(bad); err == nil { + t.Fatal("expected error for limit=0") + } +} + +func TestBuildTraktSourceConfig(t *testing.T) { + limit := 25 + raw, err := buildTraktSourceConfig("recommended", "movie", "prof-1", &limit) + if err != nil { + t.Fatalf("buildTraktSourceConfig: %v", err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["mode"] != "trakt_preset" || got["provider"] != "trakt" || + got["preset"] != "recommended" || got["media_type"] != "movie" || + got["profile_id"] != "prof-1" || got["limit"].(float64) != 25 { + t.Fatalf("source config = %#v", got) + } + if url := buildTraktSourceURL("recommended", "movie", "prof-1"); url != "trakt://recommended/movie/prof-1" { + t.Fatalf("source url = %q", url) + } +} + +func TestNormalizeCollectionManagementFields(t *testing.T) { + mode, source, key, err := normalizeCollectionManagementFields("section", "recipe_gallery", "trakt:trending:movie:library:1") + if err != nil { + t.Fatalf("normalize section management: %v", err) + } + if mode != "section" || source != "recipe_gallery" || key != "trakt:trending:movie:library:1" { + t.Fatalf("got %q/%q/%q", mode, source, key) + } + + mode, source, key, err = normalizeCollectionManagementFields("", "ignored", "ignored") + if err != nil { + t.Fatalf("normalize manual management: %v", err) + } + if mode != "manual" || source != "" || key != "" { + t.Fatalf("manual fields should be cleared, got %q/%q/%q", mode, source, key) + } + + if _, _, _, err := normalizeCollectionManagementFields("section", "", ""); err == nil { + t.Fatal("expected section-managed collection without key to fail") + } + + mode, source, key, err = normalizeCollectionManagementFields("template_bundle", "core_defaults", "core_defaults:x:library:1") + if err != nil { + t.Fatalf("normalize template bundle management: %v", err) + } + if mode != "template_bundle" || source != "core_defaults" || key != "core_defaults:x:library:1" { + t.Fatalf("got %q/%q/%q", mode, source, key) + } +} + +func TestTemplateBundleEligibilityByLibraryType(t *testing.T) { + movieTemplate := templates.Template{ID: "movie", MediaKind: templates.MediaMovie} + tvTemplate := templates.Template{ID: "tv", MediaKind: templates.MediaTV} + + movies := &models.MediaFolder{ID: 1, Name: "Movies", Type: "movies"} + series := &models.MediaFolder{ID: 2, Name: "TV", Type: "series"} + mixed := &models.MediaFolder{ID: 3, Name: "Mixed", Type: "mixed"} + + if !templateEligibleForLibrary(movieTemplate, movies) { + t.Fatal("movie template should be eligible for movie libraries") + } + if templateEligibleForLibrary(tvTemplate, movies) { + t.Fatal("tv template should not be eligible for movie libraries") + } + if templateEligibleForLibrary(movieTemplate, series) { + t.Fatal("movie template should not be eligible for series libraries") + } + if !templateEligibleForLibrary(tvTemplate, series) { + t.Fatal("tv template should be eligible for series libraries") + } + if !templateEligibleForLibrary(movieTemplate, mixed) || !templateEligibleForLibrary(tvTemplate, mixed) { + t.Fatal("mixed libraries should accept movie and tv templates") + } +} + +func TestTemplateBundleManagementKeyIsStable(t *testing.T) { + got := templateBundleManagementKey("core_defaults", "tmdb_popular_movies", 42) + want := "core_defaults:tmdb_popular_movies:library:42" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestTemplateBundleFeaturedDryRun(t *testing.T) { + bundle, ok := templates.GetBundle("core_defaults") + if !ok { + t.Fatal("core_defaults bundle missing") + } + tmpl, ok := templates.Get("tmdb_trending_movies_week") + if !ok { + t.Fatal("tmdb_trending_movies_week template missing") + } + library := &models.MediaFolder{ID: 1, Name: "Movies", Type: "movies"} + refs := map[templateBundleCollectionRefKey]templateBundleCollectionRef{ + {LibraryID: library.ID, TemplateID: tmpl.ID}: { + Template: tmpl, + Library: library, + }, + } + handler := &LibraryCollectionHandler{} + + featured, failed := handler.applyTemplateBundleFeaturedSections( + context.Background(), + bundle, + &templateBundleFeaturedRequest{ + Home: &templateBundleFeaturedHome{LibraryID: library.ID, TemplateID: tmpl.ID}, + Libraries: map[int]string{library.ID: tmpl.ID}, + }, + map[int]*models.MediaFolder{library.ID: library}, + refs, + true, + ) + if len(failed) != 0 { + t.Fatalf("failed = %+v", failed) + } + if len(featured) != 2 { + t.Fatalf("featured count = %d, want 2", len(featured)) + } + if featured[0].Surface != "home" || featured[0].Reason != "would_create" { + t.Fatalf("home featured = %+v", featured[0]) + } + if featured[1].Surface != "library" || featured[1].LibraryID != library.ID { + t.Fatalf("library featured = %+v", featured[1]) + } +} + +func TestTemplateBundleFeaturedSectionConfig(t *testing.T) { + tmpl := templates.Template{ID: "tmdb_trending_movies_week"} + library := &models.MediaFolder{ID: 42} + raw, err := templateBundleFeaturedSectionConfig("core_defaults", "library", templateBundleCollectionRef{ + CollectionID: "lc_123", + Template: tmpl, + Library: library, + }) + if err != nil { + t.Fatalf("config: %v", err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["library_collection_id"] != "lc_123" || + got["generated_source"] != "template_bundle_featured" || + got["template_bundle"] != "core_defaults" || + got["template_id"] != "tmdb_trending_movies_week" || + got["surface"] != "library" || + got["library_id"].(float64) != 42 { + t.Fatalf("unexpected config: %#v", got) + } +} + +func TestAllCollectionLibrariesSelected(t *testing.T) { + selected := map[int]struct{}{1: {}, 2: {}} + if !allCollectionLibrariesSelected(&models.LibraryCollection{LibraryIDs: []int{1, 2}}, selected) { + t.Fatal("collection scoped only to selected libraries should be deletable") + } + if allCollectionLibrariesSelected(&models.LibraryCollection{LibraryIDs: []int{1, 3}}, selected) { + t.Fatal("collection shared with unselected libraries should not be deletable") + } +} + +func TestLibraryCollectionGroupResponseUsesAPIFieldNames(t *testing.T) { + body, err := json.Marshal(listGroupsResponse{ + Groups: toLibraryCollectionGroupResponses([]models.LibraryCollectionGroup{{ + ID: "lcg_movies", + LibraryID: 1, + Name: "Movies", + Slug: "movies", + Kind: models.GroupKindRegular, + DefaultSortMode: models.GroupSortManual, + SortOrder: 2, + }}), + UngroupedSortOrder: 9999, + }) + if err != nil { + t.Fatalf("marshal group response: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal group response: %v", err) + } + groups := got["groups"].([]any) + group := groups[0].(map[string]any) + if group["id"] != "lcg_movies" || + group["name"] != "Movies" || + group["default_sort_mode"] != "manual" || + group["sort_order"].(float64) != 2 { + t.Fatalf("unexpected group JSON: %#v", group) + } + if _, ok := group["ID"]; ok { + t.Fatalf("group response leaked Go field names: %#v", group) + } +} diff --git a/internal/api/handlers/library_playback_prefs.go b/internal/api/handlers/library_playback_prefs.go new file mode 100644 index 00000000..09fce200 --- /dev/null +++ b/internal/api/handlers/library_playback_prefs.go @@ -0,0 +1,247 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +type libraryLookup interface { + GetByID(context.Context, int) (*models.MediaFolder, error) +} + +// LibraryPlaybackPrefHandler handles per-library playback preference endpoints. +type LibraryPlaybackPrefHandler struct { + storeProvider userstore.UserStoreProvider + libraryLookup libraryLookup +} + +// NewLibraryPlaybackPrefHandler creates a new LibraryPlaybackPrefHandler. +func NewLibraryPlaybackPrefHandler(provider userstore.UserStoreProvider) *LibraryPlaybackPrefHandler { + return &LibraryPlaybackPrefHandler{storeProvider: provider} +} + +// SetLibraryLookup wires in an optional library lookup used to reject +// nonexistent library IDs before mutating playback preferences. +func (h *LibraryPlaybackPrefHandler) SetLibraryLookup(lookup libraryLookup) { + h.libraryLookup = lookup +} + +type setLibraryPlaybackPrefRequest struct { + AudioLanguage *string `json:"audio_language"` + SubtitleLanguage *string `json:"subtitle_language"` + SubtitleMode *string `json:"subtitle_mode"` + ShowForcedSubtitles *bool `json:"show_forced_subtitles"` +} + +type libraryPlaybackPrefResponse struct { + ProfileID string `json:"profile_id"` + LibraryID int `json:"library_id"` + AudioLanguage *string `json:"audio_language,omitempty"` + SubtitleLanguage *string `json:"subtitle_language,omitempty"` + SubtitleMode *string `json:"subtitle_mode,omitempty"` + ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type libraryPlaybackPrefsListResponse struct { + Preferences []libraryPlaybackPrefResponse `json:"preferences"` +} + +// HandleListLibraryPlaybackPrefs handles GET /library-playback-prefs. +func (h *LibraryPlaybackPrefHandler) HandleListLibraryPlaybackPrefs(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + prefs, err := store.ListLibraryPlaybackPreferences(r.Context(), profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list library playback preferences") + return + } + + resp := libraryPlaybackPrefsListResponse{ + Preferences: make([]libraryPlaybackPrefResponse, 0, len(prefs)), + } + for _, pref := range prefs { + resp.Preferences = append(resp.Preferences, toLibraryPlaybackPrefResponse(pref)) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleSetLibraryPlaybackPref handles PUT /library-playback-prefs/{library_id}. +func (h *LibraryPlaybackPrefHandler) HandleSetLibraryPlaybackPref(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + libraryID, ok := parseLibraryID(w, r) + if !ok { + return + } + if !h.ensureLibraryExists(w, r, libraryID) { + return + } + + var req setLibraryPlaybackPrefRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if !isValidLibrarySubtitleMode(req.SubtitleMode) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid subtitle_mode") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + pref := userstore.LibraryPlaybackPreference{ + ProfileID: profileID, + LibraryID: libraryID, + HasAudioLanguage: req.AudioLanguage != nil, + HasSubtitleLanguage: req.SubtitleLanguage != nil, + HasSubtitleMode: req.SubtitleMode != nil, + HasShowForcedSubtitles: req.ShowForcedSubtitles != nil, + } + if req.AudioLanguage != nil { + pref.AudioLanguage = *req.AudioLanguage + } + if req.SubtitleLanguage != nil { + pref.SubtitleLanguage = *req.SubtitleLanguage + } + if req.SubtitleMode != nil { + pref.SubtitleMode = *req.SubtitleMode + } + if req.ShowForcedSubtitles != nil { + pref.ShowForcedSubtitles = *req.ShowForcedSubtitles + } + + if !pref.HasAudioLanguage && !pref.HasSubtitleLanguage && !pref.HasSubtitleMode && !pref.HasShowForcedSubtitles { + if err := store.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete library playback preference") + return + } + w.WriteHeader(http.StatusNoContent) + return + } + + if err := store.UpsertLibraryPlaybackPreference(r.Context(), pref); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set library playback preference") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleDeleteLibraryPlaybackPref handles DELETE /library-playback-prefs/{library_id}. +func (h *LibraryPlaybackPrefHandler) HandleDeleteLibraryPlaybackPref(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + libraryID, ok := parseLibraryID(w, r) + if !ok { + return + } + if !h.ensureLibraryExists(w, r, libraryID) { + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete library playback preference") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func parseLibraryID(w http.ResponseWriter, r *http.Request) (int, bool) { + libraryIDStr := chi.URLParam(r, "library_id") + if libraryIDStr == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Library ID is required") + return 0, false + } + + libraryID, err := strconv.Atoi(libraryIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Library ID must be a valid integer") + return 0, false + } + if libraryID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Library ID must be a positive integer") + return 0, false + } + return libraryID, true +} + +func (h *LibraryPlaybackPrefHandler) ensureLibraryExists(w http.ResponseWriter, r *http.Request, libraryID int) bool { + if h.libraryLookup == nil { + return true + } + + if _, err := h.libraryLookup.GetByID(r.Context(), libraryID); err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return false + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up library") + return false + } + + return true +} + +func isValidLibrarySubtitleMode(mode *string) bool { + if mode == nil { + return true + } + switch *mode { + case "", "auto", "always", "off": + return true + default: + return false + } +} + +func toLibraryPlaybackPrefResponse(p userstore.LibraryPlaybackPreference) libraryPlaybackPrefResponse { + resp := libraryPlaybackPrefResponse{ + ProfileID: p.ProfileID, + LibraryID: p.LibraryID, + UpdatedAt: p.UpdatedAt, + } + if p.HasAudioLanguage { + resp.AudioLanguage = stringPtr(p.AudioLanguage) + } + if p.HasSubtitleLanguage { + resp.SubtitleLanguage = stringPtr(p.SubtitleLanguage) + } + if p.HasSubtitleMode { + resp.SubtitleMode = stringPtr(p.SubtitleMode) + } + if p.HasShowForcedSubtitles { + resp.ShowForcedSubtitles = boolPtr(p.ShowForcedSubtitles) + } + return resp +} diff --git a/internal/api/handlers/metadata.go b/internal/api/handlers/metadata.go new file mode 100644 index 00000000..46c3f2d3 --- /dev/null +++ b/internal/api/handlers/metadata.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/metadata" +) + +// MetadataHandler handles metadata refresh, identify, and search endpoints. +type MetadataHandler struct { + service *metadata.MetadataService + queue *metadata.RefreshQueue +} + +// NewMetadataHandler creates a new MetadataHandler. +func NewMetadataHandler(service *metadata.MetadataService, queue *metadata.RefreshQueue) *MetadataHandler { + return &MetadataHandler{service: service, queue: queue} +} + +// RefreshItem handles POST /api/v1/items/{id}/refresh. +func (h *MetadataHandler) RefreshItem(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + http.Error(w, "missing item id", http.StatusBadRequest) + return + } + + h.queue.Enqueue(contentID, metadata.PriorityHigh, metadata.ModeManualRefresh) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]string{ + "status": "queued", + "content_id": contentID, + }) +} + +// RefreshLibrary handles POST /api/v1/libraries/{id}/refresh. +func (h *MetadataHandler) RefreshLibrary(w http.ResponseWriter, r *http.Request) { + // This would need to look up all items in the library and enqueue them. + // For now, return accepted. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]string{"status": "queued"}) +} + +// identifyRequest is the body for POST /api/v1/items/{id}/identify. +type identifyRequest struct { + ProviderIDs map[string]string `json:"provider_ids"` +} + +// IdentifyItem handles POST /api/v1/items/{id}/identify. +func (h *MetadataHandler) IdentifyItem(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + http.Error(w, "missing item id", http.StatusBadRequest) + return + } + + var req identifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + result, err := h.service.Process(r.Context(), metadata.ProcessRequest{ + ContentID: contentID, + ProviderIDs: req.ProviderIDs, + Mode: metadata.ModeIdentify, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// SearchProviders handles GET /api/v1/items/{id}/search. +func (h *MetadataHandler) SearchProviders(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + yearStr := r.URL.Query().Get("year") + contentType := r.URL.Query().Get("type") + + year := 0 + if yearStr != "" { + year, _ = strconv.Atoi(yearStr) + } + + results, err := h.service.SearchProviders(r.Context(), metadata.SearchQuery{ + Title: query, + Year: year, + ContentType: contentType, + }, 0) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(results) +} diff --git a/internal/api/handlers/nodes.go b/internal/api/handlers/nodes.go new file mode 100644 index 00000000..b96e851e --- /dev/null +++ b/internal/api/handlers/nodes.go @@ -0,0 +1,376 @@ +package handlers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "sync" + "time" + + "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/nodepool" + "github.com/go-chi/chi/v5" + "github.com/redis/go-redis/v9" +) + +// NodeRepository defines the operations the NodeHandler needs on the node store. +type NodeRepository interface { + List(ctx context.Context) ([]*nodepool.Node, error) + GetByID(ctx context.Context, id int) (*nodepool.Node, error) + Create(ctx context.Context, input nodepool.CreateNodeInput) (*nodepool.Node, error) + Update(ctx context.Context, id int, input nodepool.UpdateNodeInput) (*nodepool.Node, error) + Delete(ctx context.Context, id int) error + UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs int) error +} + +// NodeListEnabled queries enabled nodes by type for pool reload. +type NodeListEnabled interface { + ListEnabled(ctx context.Context, nodeType string) ([]*nodepool.Node, error) +} + +// NodeHandler handles CRUD operations and health checks for stream nodes. +type NodeHandler struct { + repo NodeRepository + proxyPool *nodepool.ProxyPool + transcodePool *nodepool.TranscodePool + lister NodeListEnabled + eventBus cache.EventBus + redisClient *redis.Client // for reading session keys + jwtSecret string // for bearer auth when calling force-reload on nodes +} + +// NewNodeHandler creates a new NodeHandler. +func NewNodeHandler(repo NodeRepository, proxyPool *nodepool.ProxyPool, transcodePool *nodepool.TranscodePool, lister NodeListEnabled, eventBus cache.EventBus, redisClient *redis.Client, jwtSecret string) *NodeHandler { + return &NodeHandler{ + repo: repo, + proxyPool: proxyPool, + transcodePool: transcodePool, + lister: lister, + eventBus: eventBus, + redisClient: redisClient, + jwtSecret: jwtSecret, + } +} + +// ForceReloadResult represents the result of a force-reload on a single node. +type ForceReloadResult struct { + NodeID int `json:"node_id"` + NodeName string `json:"node_name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// checkNodeResult is the JSON response for a node health check. +type checkNodeResult struct { + Healthy bool `json:"healthy"` + ActiveJobs int `json:"active_jobs"` +} + +// HandleListNodes handles GET /admin/nodes. +func (h *NodeHandler) HandleListNodes(w http.ResponseWriter, r *http.Request) { + nodes, err := h.repo.List(r.Context()) + if err != nil { + slog.Error("listing nodes", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list nodes") + return + } + + writeJSON(w, http.StatusOK, nodes) +} + +// HandleCreateNode handles POST /admin/nodes. +func (h *NodeHandler) HandleCreateNode(w http.ResponseWriter, r *http.Request) { + var input nodepool.CreateNodeInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + node, err := h.repo.Create(r.Context(), input) + if err != nil { + // Validation errors from CreateNodeInput.Validate() are treated as 400. + if !errors.Is(err, nodepool.ErrNodeNotFound) { + // Check if it's a validation error (non-sentinel, non-wrapped). + // The repository calls input.Validate() which returns plain errors. + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + slog.Error("creating node", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create node") + return + } + + writeJSON(w, http.StatusCreated, node) + h.reloadPools(r.Context()) +} + +// HandleUpdateNode handles PUT /admin/nodes/{id}. +func (h *NodeHandler) HandleUpdateNode(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid node ID") + return + } + + var input nodepool.UpdateNodeInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + node, err := h.repo.Update(r.Context(), id, input) + if err != nil { + if errors.Is(err, nodepool.ErrNodeNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Node not found") + return + } + slog.Error("updating node", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update node") + return + } + + writeJSON(w, http.StatusOK, node) + h.reloadPools(r.Context()) +} + +// HandleDeleteNode handles DELETE /admin/nodes/{id}. +func (h *NodeHandler) HandleDeleteNode(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid node ID") + return + } + + err = h.repo.Delete(r.Context(), id) + if err != nil { + if errors.Is(err, nodepool.ErrNodeNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Node not found") + return + } + slog.Error("deleting node", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete node") + return + } + + w.WriteHeader(http.StatusNoContent) + h.reloadPools(r.Context()) +} + +// HandleCheckNode handles POST /admin/nodes/{id}/check. +func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid node ID") + return + } + + node, err := h.repo.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, nodepool.ErrNodeNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Node not found") + return + } + slog.Error("fetching node for check", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch node") + return + } + + healthy, activeJobs := nodepool.CheckNode(r.Context(), node) + + if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs); err != nil { + slog.Error("persisting health check result", "node_id", id, "error", err) + } + + writeJSON(w, http.StatusOK, checkNodeResult{ + Healthy: healthy, + ActiveJobs: activeJobs, + }) +} + +// HandleForceReloadNodes handles POST /admin/nodes/force-reload — sends a +// force-reload signal to every enabled node in parallel. +func (h *NodeHandler) HandleForceReloadNodes(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + allNodes, err := h.repo.List(ctx) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + var nodes []*nodepool.Node + for _, n := range allNodes { + if n.Enabled { + nodes = append(nodes, n) + } + } + + results := make([]ForceReloadResult, len(nodes)) + var wg sync.WaitGroup + for i, n := range nodes { + wg.Add(1) + go func(idx int, node *nodepool.Node) { + defer wg.Done() + result := ForceReloadResult{NodeID: node.ID, NodeName: node.Name} + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, node.URL+"/admin/force-reload", nil) + if err != nil { + result.Status = "error" + result.Error = err.Error() + results[idx] = result + return + } + req.Header.Set("Authorization", "Bearer "+h.jwtSecret) + resp, err := client.Do(req) + if err != nil { + result.Status = "error" + result.Error = err.Error() + results[idx] = result + return + } + resp.Body.Close() + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + result.Status = "ok" + } else { + result.Status = "error" + result.Error = fmt.Sprintf("unexpected status %d", resp.StatusCode) + } + results[idx] = result + }(i, n) + } + wg.Wait() + + type forceReloadResponse struct { + Results []ForceReloadResult `json:"results"` + } + writeJSON(w, http.StatusOK, forceReloadResponse{Results: results}) +} + +// HandleForceReloadNode handles POST /admin/nodes/{id}/force-reload — sends a +// force-reload signal to a single node identified by its ID. +func (h *NodeHandler) HandleForceReloadNode(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "node ID must be an integer") + return + } + + node, err := h.repo.GetByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "node not found") + return + } + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, node.URL+"/admin/force-reload", nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + req.Header.Set("Authorization", "Bearer "+h.jwtSecret) + + resp, err := client.Do(req) + if err != nil { + type forceReloadResponse struct { + Results []ForceReloadResult `json:"results"` + } + writeJSON(w, http.StatusOK, forceReloadResponse{Results: []ForceReloadResult{{ + NodeID: node.ID, NodeName: node.Name, + Status: "error", Error: err.Error(), + }}}) + return + } + resp.Body.Close() + + status := "ok" + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + status = "error" + } + type forceReloadResponse struct { + Results []ForceReloadResult `json:"results"` + } + writeJSON(w, http.StatusOK, forceReloadResponse{Results: []ForceReloadResult{{ + NodeID: node.ID, NodeName: node.Name, Status: status, + }}}) +} + +// HandleListSessions handles GET /admin/nodes/sessions — lists active playback +// sessions from Redis, optionally filtered by node_id query parameter. +func (h *NodeHandler) HandleListSessions(w http.ResponseWriter, r *http.Request) { + if h.redisClient == nil { + writeError(w, http.StatusServiceUnavailable, "redis_unavailable", "Redis not configured") + return + } + + ctx := r.Context() + pattern := "silo:sessions:*" + + if nodeIDStr := r.URL.Query().Get("node_id"); nodeIDStr != "" { + nodeID, err := strconv.Atoi(nodeIDStr) + if err == nil { + node, err := h.repo.GetByID(ctx, nodeID) + if err == nil { + hashBytes := sha256.Sum256([]byte(node.URL)) + nodeHash := hex.EncodeToString(hashBytes[:4]) + pattern = "silo:sessions:" + nodeHash + ":*" + } + } + } + + var sessions []json.RawMessage + var cursor uint64 + for { + keys, next, err := h.redisClient.Scan(ctx, cursor, pattern, 100).Result() + if err != nil { + writeError(w, http.StatusInternalServerError, "redis_error", err.Error()) + return + } + for _, key := range keys { + val, err := h.redisClient.Get(ctx, key).Result() + if err != nil { + continue + } + sessions = append(sessions, json.RawMessage(val)) + } + cursor = next + if cursor == 0 { + break + } + } + + if sessions == nil { + sessions = []json.RawMessage{} + } + + type sessionsResponse struct { + Sessions []json.RawMessage `json:"sessions"` + } + writeJSON(w, http.StatusOK, sessionsResponse{Sessions: sessions}) +} + +// reloadPools refreshes the in-memory proxy and transcode pools from the database. +func (h *NodeHandler) reloadPools(ctx context.Context) { + if h.lister == nil { + return + } + proxyNodes, proxyErr := h.lister.ListEnabled(ctx, nodepool.NodeTypeProxy) + transcodeNodes, tcErr := h.lister.ListEnabled(ctx, nodepool.NodeTypeTranscode) + if proxyErr != nil || tcErr != nil { + slog.Warn("node pool reload failed", "proxy_err", proxyErr, "transcode_err", tcErr) + return + } + if h.proxyPool != nil { + h.proxyPool.SetNodes(proxyNodes) + } + if h.transcodePool != nil { + h.transcodePool.SetNodes(transcodeNodes) + } + + if h.eventBus != nil { + _ = h.eventBus.Publish(ctx, cache.ChannelAdmin, cache.Event{Type: cache.EventNodePoolChanged}) + } +} diff --git a/internal/api/handlers/nullable.go b/internal/api/handlers/nullable.go new file mode 100644 index 00000000..1cbe0129 --- /dev/null +++ b/internal/api/handlers/nullable.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "bytes" + "encoding/json" +) + +type optionalNullableString struct { + set bool + value *string +} + +func (o *optionalNullableString) UnmarshalJSON(data []byte) error { + o.set = true + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + o.value = nil + return nil + } + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + o.value = &value + return nil +} + +func (o optionalNullableString) Set() bool { + return o.set +} + +func (o optionalNullableString) Value() *string { + return o.value +} diff --git a/internal/api/handlers/people.go b/internal/api/handlers/people.go new file mode 100644 index 00000000..2d71f678 --- /dev/null +++ b/internal/api/handlers/people.go @@ -0,0 +1,383 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/ratelimit" +) + +type peopleRepository interface { + Get(ctx context.Context, id int64) (*models.Person, error) + Search(ctx context.Context, query string, limit int) ([]models.Person, error) + Update(ctx context.Context, p models.Person) error +} + +type PersonRefreshQueue interface { + Enqueue(id int64) +} + +type PersonRefresher interface { + RefreshPerson(ctx context.Context, id int64) (*models.Person, error) +} + +var personRefreshRate = ratelimit.Rate{ + RequestsPerSecond: 10, + RequestsPerMinute: 10, + Burst: 10, +} + +// PeopleHandler serves person-related API endpoints. +type PeopleHandler struct { + personRepo peopleRepository + catalogResolver *catalog.CatalogResolver + detailSvc *catalog.DetailService + itemsHandler *ItemsHandler + refreshQueue PersonRefreshQueue + refresher PersonRefresher + refreshLimiter ratelimit.RateLimiter +} + +// NewPeopleHandler creates a new people handler. +func NewPeopleHandler( + personRepo peopleRepository, + browseRepo *catalog.BrowseRepository, + itemRepo *catalog.ItemRepository, + detailSvc *catalog.DetailService, +) *PeopleHandler { + return &PeopleHandler{ + personRepo: personRepo, + catalogResolver: catalog.NewCatalogResolver(browseRepo, itemRepo), + detailSvc: detailSvc, + refreshLimiter: ratelimit.NewMemoryLimiter(), + } +} + +// SetItemsHandler sets the items handler for browse response formatting. +func (h *PeopleHandler) SetItemsHandler(ih *ItemsHandler) { + h.itemsHandler = ih +} + +func (h *PeopleHandler) SetRefreshQueue(queue PersonRefreshQueue) { + h.refreshQueue = queue +} + +func (h *PeopleHandler) SetRefreshService(refresher PersonRefresher) { + h.refresher = refresher +} + +type personResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Bio string `json:"bio,omitempty"` + BirthDate *string `json:"birth_date,omitempty"` + DeathDate *string `json:"death_date,omitempty"` + Birthplace string `json:"birthplace,omitempty"` + Homepage string `json:"homepage,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + PhotoThumbhash string `json:"photo_thumbhash,omitempty"` + TmdbID string `json:"tmdb_id,omitempty"` + ImdbID string `json:"imdb_id,omitempty"` + TvdbID string `json:"tvdb_id,omitempty"` + PlexGUID string `json:"plex_guid,omitempty"` +} + +// HandleSearch serves GET /api/people?q=&limit= +func (h *PeopleHandler) HandleSearch(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 20 + } + + people, err := h.personRepo.Search(r.Context(), query, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "search_failed", err.Error()) + return + } + + resp := make([]personResponse, len(people)) + for i, p := range people { + resp[i] = h.toResponse(r.Context(), p) + } + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetPerson serves GET /api/people/:id +func (h *PeopleHandler) HandleGetPerson(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "invalid person ID") + return + } + + person, err := h.personRepo.Get(r.Context(), id) + if err != nil { + slog.Warn("people: get person failed", "id", id, "id_str", idStr, "error", err) + writeError(w, http.StatusNotFound, "not_found", "person not found") + return + } + + writeJSON(w, http.StatusOK, h.toResponse(r.Context(), *person)) +} + +// HandleRefreshPerson serves POST /api/v1/people/:id/refresh. +func (h *PeopleHandler) HandleRefreshPerson(w http.ResponseWriter, r *http.Request) { + if h.refreshQueue == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Person refresh is not configured") + return + } + + id, ok := parsePersonID(w, r) + if !ok { + return + } + + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + result := h.refreshLimiter.Allow(r.Context(), strconv.Itoa(userID), personRefreshRate) + if !result.Allowed { + if result.RetryAfter > 0 { + w.Header().Set("Retry-After", strconv.Itoa(max(1, int(result.RetryAfter.Seconds())))) + } + writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many person refresh requests") + return + } + + person, err := h.personRepo.Get(r.Context(), id) + if err != nil || person == nil { + writeError(w, http.StatusNotFound, "not_found", "person not found") + return + } + + h.refreshQueue.Enqueue(id) + writeJSON(w, http.StatusAccepted, map[string]any{ + "status": "queued", + "person_id": id, + }) +} + +// HandleAdminRefreshPerson serves POST /api/v1/admin/people/:id/refresh. +func (h *PeopleHandler) HandleAdminRefreshPerson(w http.ResponseWriter, r *http.Request) { + if h.refresher == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Person refresh is not configured") + return + } + + id, ok := parsePersonID(w, r) + if !ok { + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + + person, err := h.refresher.RefreshPerson(ctx, id) + if err != nil { + switch { + case errors.Is(err, metadata.ErrPersonNotFound): + writeError(w, http.StatusNotFound, "not_found", "person not found") + case errors.Is(err, metadata.ErrPersonMetadataNotFound): + writeError(w, http.StatusBadGateway, "provider_error", "No person metadata found") + default: + slog.Warn("people: admin refresh failed", "id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to refresh person") + } + return + } + + writeJSON(w, http.StatusOK, h.toResponse(r.Context(), *person)) +} + +type UpdatePersonRequest struct { + Name *string `json:"name"` + Bio *string `json:"bio"` + BirthDate *string `json:"birth_date"` + DeathDate *string `json:"death_date"` + Birthplace *string `json:"birthplace"` + Homepage *string `json:"homepage"` + TmdbID *string `json:"tmdb_id"` + ImdbID *string `json:"imdb_id"` + TvdbID *string `json:"tvdb_id"` +} + +// HandleAdminUpdatePerson serves PATCH /api/v1/admin/people/:id. +func (h *PeopleHandler) HandleAdminUpdatePerson(w http.ResponseWriter, r *http.Request) { + id, ok := parsePersonID(w, r) + if !ok { + return + } + + var req UpdatePersonRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + person, err := h.personRepo.Get(r.Context(), id) + if err != nil || person == nil { + writeError(w, http.StatusNotFound, "not_found", "person not found") + return + } + + if req.Name != nil { + person.Name = *req.Name + person.SortName = *req.Name + } + if req.Bio != nil { + person.Bio = *req.Bio + } + if req.BirthDate != nil { + parsed, err := parseOptionalPersonDate(*req.BirthDate) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid birth_date") + return + } + person.BirthDate = parsed + } + if req.DeathDate != nil { + parsed, err := parseOptionalPersonDate(*req.DeathDate) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid death_date") + return + } + person.DeathDate = parsed + } + if req.Birthplace != nil { + person.Birthplace = *req.Birthplace + } + if req.Homepage != nil { + person.Homepage = *req.Homepage + } + if req.TmdbID != nil { + person.TmdbID = *req.TmdbID + } + if req.ImdbID != nil { + person.ImdbID = *req.ImdbID + } + if req.TvdbID != nil { + person.TvdbID = *req.TvdbID + } + + if err := h.personRepo.Update(r.Context(), *person); err != nil { + slog.Error("people: admin update failed", "id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update person") + return + } + + writeJSON(w, http.StatusOK, h.toResponse(r.Context(), *person)) +} + +// HandleGetPersonItems serves GET /api/people/:id/items?type=&limit=&offset= +func (h *PeopleHandler) HandleGetPersonItems(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog?source=person") + idStr := chi.URLParam(r, "id") + if _, err := strconv.ParseInt(idStr, 10, 64); err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "invalid person ID") + return + } + + values := url.Values{} + for key, rawValues := range r.URL.Query() { + for _, value := range rawValues { + values.Add(key, value) + } + } + values.Set("source", "person") + values.Set("person_id", idStr) + if values.Get("limit") == "" { + values.Set("limit", "24") + } + + req, err := catalog.ParseCatalogRequest(values) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + result, err := h.catalogResolver.Resolve(r.Context(), req, h.itemsHandler.accessFilter(r)) + if err != nil { + writeError(w, http.StatusInternalServerError, "browse_failed", err.Error()) + return + } + + items := make([]itemListResponse, 0, len(result.Items)) + for _, item := range result.Items { + items = append(items, h.itemsHandler.toItemListResponse(r, item)) + } + + writeJSON(w, http.StatusOK, browseResponse{ + Total: result.Total, + HasMore: req.Offset+len(items) < result.Total, + Items: items, + }) +} + +func (h *PeopleHandler) toResponse(ctx context.Context, p models.Person) personResponse { + resp := personResponse{ + ID: p.ID, + Name: p.Name, + Bio: p.Bio, + Birthplace: p.Birthplace, + Homepage: p.Homepage, + TmdbID: p.TmdbID, + ImdbID: p.ImdbID, + TvdbID: p.TvdbID, + PlexGUID: p.PlexGUID, + } + if p.BirthDate != nil { + s := p.BirthDate.Format("2006-01-02") + resp.BirthDate = &s + } + if p.DeathDate != nil { + s := p.DeathDate.Format("2006-01-02") + resp.DeathDate = &s + } + if p.PhotoPath != "" && p.PhotoPath != "-" && h.detailSvc != nil { + resp.PhotoURL = h.detailSvc.PresignURL(ctx, featuredPosterPath(p.PhotoPath), "featured") + } + if p.PhotoThumbhash != "" && p.PhotoThumbhash != "-" { + resp.PhotoThumbhash = p.PhotoThumbhash + } + return resp +} + +func parseOptionalPersonDate(raw string) (*time.Time, error) { + if raw == "" { + return nil, nil + } + + parsed, err := time.Parse("2006-01-02", raw) + if err != nil { + return nil, err + } + + return &parsed, nil +} + +func parsePersonID(w http.ResponseWriter, r *http.Request) (int64, bool) { + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "invalid person ID") + return 0, false + } + return id, true +} diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go new file mode 100644 index 00000000..e0b9d602 --- /dev/null +++ b/internal/api/handlers/playback.go @@ -0,0 +1,2578 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/clientip" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/markers" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodepool" + "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/streamtoken" + "github.com/Silo-Server/silo-server/internal/subtitles" + "github.com/Silo-Server/silo-server/internal/transcodenode" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/watchstate" + "github.com/Silo-Server/silo-server/internal/watchsync" +) + +// SessionManagerInterface defines the operations the PlaybackHandler needs +// on the session manager. +type SessionManagerInterface interface { + StartSession(userID int, profileID string, fileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error) + StartSessionWithFiles(userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error) + UpdateProgress(sessionID string, position float64, isPaused bool) error + UpdateAudioTrack(sessionID string, audioTrackIndex int, method playback.PlayMethod) error + UpdateStreamState(sessionID string, state playback.SessionStreamState) error + TouchActivity(sessionID string) error + BeginTransport(sessionID string) error + EndTransport(sessionID string) error + SetEffectiveMediaFileID(sessionID string, fileID int) error + SetTranscodeNodeURL(sessionID, url string) error + SetWebSocket(sessionID string, connected bool) error + StopSession(sessionID string) error + GetSession(sessionID string) (*playback.Session, error) +} + +type sessionStarterWithFilesContext interface { + StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error) +} + +// ProxyPicker selects a proxy node for stream routing. +type ProxyPicker interface { + Pick() *nodepool.Node +} + +// TranscodeAcquirer selects a transcode node for transcoding jobs. +type TranscodeAcquirer interface { + Acquire() *nodepool.Node + FindByURL(url string) *nodepool.Node +} + +type PlaybackItemAccessChecker interface { + EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error +} + +type PlaybackEpisodeLookup interface { + GetByID(ctx context.Context, contentID string) (*models.Episode, error) +} + +type PlaybackSessionSyncer interface { + SyncNow(ctx context.Context) error +} + +// PlaybackSettingsReader reads server settings for playback decisions. +type PlaybackSettingsReader interface { + Get(ctx context.Context, key string) (string, error) +} + +// PlaybackFileVersionFetcher retrieves alternate file versions for a content item. +type PlaybackFileVersionFetcher interface { + GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error) + GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error) +} + +type PlaybackProbeEnsurer interface { + Ensure(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error) +} + +type PlaybackChapterThumbnailQueuer interface { + QueuePriorityFileAtPosition(ctx context.Context, fileID int, targetSeconds float64) +} + +// PlaybackOriginalLanguageLookup fetches the original language for a content item. +type PlaybackOriginalLanguageLookup interface { + GetOriginalLanguage(ctx context.Context, contentID string) (string, error) +} + +// PlaybackHandler handles playback session HTTP endpoints. +type PlaybackHandler struct { + sessionMgr SessionManagerInterface + fileResolver FilePathResolver // optional; enables stream_url in responses + StoreProvider userstore.UserStoreProvider // optional; enables progress/history persistence + WatchScrobbler PlaybackWatchScrobbler + StableIdentityResolver *watchstate.StableIdentityResolver + profileStaler ProfileStaler + profileRefreshRequester ProfileRefreshRequester + AdminStore PlaybackAdminStore // optional; enables admin playback history/live session cleanup + SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view + EventsHub *evt.Hub + MissingMarker MissingFileMarker + ProxyPool ProxyPicker // optional; enables proxy-based stream URLs + TranscodePool TranscodeAcquirer // optional; enables transcode node selection + JWTSecret string // needed for signing stream tokens + ItemAccess PlaybackItemAccessChecker // optional; enables file authorization checks + EpisodeLookup PlaybackEpisodeLookup // optional; resolves episode files to their series + OriginalLangLookup PlaybackOriginalLanguageLookup + SettingsRepo PlaybackSettingsReader // optional; reads server settings (e.g., allow_4k_transcode) + FileVersionFetcher PlaybackFileVersionFetcher // optional; queries sibling file versions for 4K guard + ProbeEnsurer PlaybackProbeEnsurer // optional; repairs missing probe metadata on demand + ChapterThumbnailQueuer PlaybackChapterThumbnailQueuer + IntroAnalyzer IntroEpisodeAnalyzer + IntroRepository PlaybackIntroEligibilityChecker + MarkerRegistry *markers.Registry + MarkerUpdateNotifier PlaybackMarkerUpdateNotifier + MarkerLazyContext context.Context + MarkerLazyInFlight sync.Map + SubtitleRepo subtitles.Repository // optional; enables downloaded subtitles in playback + RealtimeHub *playback.RealtimeHub + CommandTracker *playback.CommandTracker + CommandDispatcher *playback.CommandDispatcher + FFmpegPath string + HWAccel string + HWDevice string + TranscodeDir string + FFmpegLogSink playback.FFmpegLogSink + transcodeMu sync.RWMutex + transcodes map[string]*playback.TranscodeSession + realtimeCommandMu sync.Mutex + realtimeCommands map[string]playbackCommandRecord +} + +type PlaybackWatchScrobbler interface { + ScrobbleStart(ctx context.Context, event watchsync.ScrobbleEvent) error + ScrobblePause(ctx context.Context, event watchsync.ScrobbleEvent) error + ScrobbleStop(ctx context.Context, event watchsync.ScrobbleEvent) error +} + +type sessionExpirationHookSetter interface { + SetExpirationHook(func(*playback.Session)) +} + +// NewPlaybackHandler creates a new PlaybackHandler backed by the given +// session manager. Pass optional FilePathResolver to enable stream_url +// and subtitle_urls in start playback responses. +func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathResolver) *PlaybackHandler { + h := &PlaybackHandler{ + sessionMgr: sessionMgr, + TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"), + transcodes: make(map[string]*playback.TranscodeSession), + realtimeCommands: make(map[string]playbackCommandRecord), + } + if len(opts) > 0 { + h.fileResolver = opts[0] + } + if setter, ok := sessionMgr.(sessionExpirationHookSetter); ok { + setter.SetExpirationHook(h.handleExpiredSession) + } + return h +} + +// SetProfileStaler configures an optional staleness trigger for taste profiles. +func (h *PlaybackHandler) SetProfileStaler(ps ProfileStaler) { + h.profileStaler = ps +} + +// SetProfileRefreshRequester configures an optional background refresh queue for taste profiles. +func (h *PlaybackHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) { + h.profileRefreshRequester = requester +} + +// CleanupOrphanedTranscodes removes stale per-session temp directories for +// transcodes that are no longer tracked in memory. +func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) { + h.transcodeMu.RLock() + active := make(map[string]struct{}, len(h.transcodes)) + for sessionID := range h.transcodes { + active[sessionID] = struct{}{} + } + h.transcodeMu.RUnlock() + + return playback.CleanupOrphanedTranscodeDirs(h.TranscodeDir, active) +} + +// playbackThresholds reads the playback.watched_threshold and +// playback.min_resume_threshold settings. Zero values mean "use defaults". +func (h *PlaybackHandler) playbackThresholds(ctx context.Context) userstore.ProgressThresholds { + if h.SettingsRepo == nil { + return userstore.ProgressThresholds{} + } + var t userstore.ProgressThresholds + if v, _ := h.SettingsRepo.Get(ctx, "playback.watched_threshold"); v != "" { + if pct, err := strconv.Atoi(v); err == nil && pct > 0 { + t.WatchedPct = pct + } + } + if v, _ := h.SettingsRepo.Get(ctx, "playback.min_resume_threshold"); v != "" { + if pct, err := strconv.Atoi(v); err == nil && pct > 0 { + t.MinResumePct = pct + } + } + return t +} + +// --- Request/Response types --- + +// hdrDetails describes granular HDR support advertised by the client. +// Optional — absent means the resolver falls back to the boolean HDR flag. +// Dolby Vision profile numbers follow MediaCodec: +// +// 5 = DvheStn / DvheSt (single-layer) +// 7 = DvheDtb / DvheDtr / DvheDth (dual-layer BL+EL — needs multi-instance) +// 8 = DvheSt4k / DvavSe +type hdrDetails struct { + HDR10 bool `json:"hdr10"` + HDR10Plus bool `json:"hdr10_plus"` + HLG bool `json:"hlg"` + DolbyVisionProfiles []int `json:"dolby_vision_profiles"` +} + +// audioPassthroughCapabilities describes what the connected audio sink can +// decode bit-exact. Distinct from `codecs_audio`, which describes what the +// client can decode itself. Passthrough codecs come from `AudioCapabilities` +// (HDMI EDID / Bluetooth / USB DAC capability probing on Android; equivalent +// on iOS/tvOS). +type audioPassthroughCapabilities struct { + PassthroughCodecs []string `json:"passthrough_codecs"` + SpatializerEnabled bool `json:"spatializer_enabled"` + MaxChannels int `json:"max_channels"` +} + +// startPlaybackRequest represents the JSON body for POST /playback/start. +type startPlaybackRequest struct { + FileID int `json:"file_id"` + ProfileID string `json:"profile_id"` + PlayMethod string `json:"play_method"` + StartPosition *float64 `json:"start_position,omitempty"` + AudioTrackIndex *int `json:"audio_track_index,omitempty"` + PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"` + CodecsVideo []string `json:"codecs_video"` + CodecsAudio []string `json:"codecs_audio"` + Containers []string `json:"containers"` + MaxResolution string `json:"max_resolution"` + HDR bool `json:"hdr"` + HdrDetails *hdrDetails `json:"hdr_details,omitempty"` + AudioPassthrough *audioPassthroughCapabilities `json:"audio_passthrough,omitempty"` +} + +// progressRequest represents the JSON body for POST /playback/{session_id}/progress. +type progressRequest struct { + Position float64 `json:"position"` + IsPaused bool `json:"is_paused"` +} + +// playbackSessionResponse represents a playback session in JSON responses. +type playbackSessionResponse struct { + SessionID string `json:"session_id"` + UserID int `json:"user_id"` + ProfileID string `json:"profile_id"` + MediaFileID int `json:"media_file_id"` + PlayMethod string `json:"play_method"` + Position float64 `json:"position"` + IsPaused bool `json:"is_paused"` + StreamURL string `json:"stream_url"` + AudioTrackIndex int `json:"audio_track_index"` + DurationSeconds *float64 `json:"duration_seconds"` + SubtitleURLs []subtitleURL `json:"subtitle_urls,omitempty"` + PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"` +} + +type playbackInfoResult struct { + StreamType string `json:"stream_type"` + TranscodeAudio bool `json:"transcode_audio"` + VideoCodec string `json:"video_codec"` + AudioCodec string `json:"audio_codec"` +} + +// subtitleURL represents a subtitle track URL in a playback response. +type subtitleURL struct { + Index int `json:"index"` + Language string `json:"language"` + Codec string `json:"codec,omitempty"` + Label string `json:"label"` + Source string `json:"source"` + Forced bool `json:"forced"` + HearingImpaired bool `json:"hearing_impaired"` + URL string `json:"url"` +} + +// changeAudioRequest represents the JSON body for PATCH /playback/{session_id}/audio. +type changeAudioRequest struct { + AudioTrackIndex int `json:"audio_track_index"` + Position float64 `json:"position"` +} + +// changeAudioResponse represents the JSON response for PATCH /playback/{session_id}/audio. +type changeAudioResponse struct { + AudioTrackIndex int `json:"audio_track_index"` + PlayMethod string `json:"play_method"` + StreamURL string `json:"stream_url"` + SwitchMode string `json:"switch_mode"` + PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"` +} + +type transcodeStartRequest struct { + SessionID string `json:"session_id"` + SeekSeconds float64 `json:"seek_seconds"` + TargetResolution string `json:"target_resolution"` + TargetCodecVideo string `json:"target_codec_video"` + TargetCodecAudio string `json:"target_codec_audio"` + TargetBitrateKbps int `json:"target_bitrate_kbps"` + SegmentDuration int `json:"segment_duration"` + SubtitleTrackIndex int `json:"subtitle_track_index"` + SubtitleBurnIn bool `json:"subtitle_burn_in"` +} + +type transcodeStartResponse struct { + SessionID string `json:"session_id"` + Status string `json:"status"` + SwitchedFileID *int `json:"switched_file_id,omitempty"` + ManifestURL string `json:"manifest_url"` + DurationSeconds *float64 `json:"duration_seconds"` + PlayerStartSeconds float64 `json:"player_start_seconds"` + StreamOriginSeconds float64 `json:"stream_origin_seconds"` + TimelineOffsetSeconds float64 `json:"timeline_offset_seconds"` + CanSeekAnywhere bool `json:"can_seek_anywhere"` +} + +// toPlaybackSessionResponse converts a playback.Session to an API response. +func toPlaybackSessionResponse(s *playback.Session) playbackSessionResponse { + return playbackSessionResponse{ + SessionID: s.ID, + UserID: s.UserID, + ProfileID: s.ProfileID, + MediaFileID: s.MediaFileID, + PlayMethod: string(semanticPlayMethod(s)), + Position: s.Position, + IsPaused: s.IsPaused, + StreamURL: playbackStreamURL(s), + AudioTrackIndex: s.AudioTrackIndex, + } +} + +func semanticPlayMethod(s *playback.Session) playback.PlayMethod { + if s == nil { + return "" + } + if s.BasePlayMethod != "" { + return s.BasePlayMethod + } + return s.PlayMethod +} + +func fileDurationSeconds(file *models.MediaFile) *float64 { + if file == nil || file.Duration <= 0 { + return nil + } + duration := float64(file.Duration) + return &duration +} + +func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool { + if file == nil || file.Duration <= 0 { + return false + } + // Copy-video HLS sessions use FFmpeg's real manifest so the player only + // seeks within the currently exposed window. Out-of-window seeks should + // restart explicitly instead of relying on segment 404s to move FFmpeg. + return !strings.EqualFold(req.TargetCodecVideo, "copy") +} + +func buildTranscodeStartResponse( + req transcodeStartRequest, + file *models.MediaFile, + switchedFileID *int, + manifestURL string, +) transcodeStartResponse { + resp := transcodeStartResponse{ + SessionID: req.SessionID, + Status: "started", + SwitchedFileID: switchedFileID, + ManifestURL: manifestURL, + DurationSeconds: fileDurationSeconds(file), + } + if canSeekAnywhere(req, file) { + resp.PlayerStartSeconds = req.SeekSeconds + resp.StreamOriginSeconds = 0 + resp.TimelineOffsetSeconds = 0 + resp.CanSeekAnywhere = true + return resp + } + resp.PlayerStartSeconds = 0 + resp.StreamOriginSeconds = req.SeekSeconds + resp.TimelineOffsetSeconds = req.SeekSeconds + resp.CanSeekAnywhere = false + return resp +} + +func (h *PlaybackHandler) ensurePlaybackProbe(ctx context.Context, file *models.MediaFile) *models.MediaFile { + if h == nil || h.ProbeEnsurer == nil || file == nil { + return file + } + repaired, err := h.ProbeEnsurer.Ensure(ctx, file) + if err != nil { + slog.Warn("playback probe repair failed", "file_id", file.ID, "path", file.FilePath, "error", err) + return file + } + if repaired != nil { + return repaired + } + return file +} + +func playbackStreamURL(s *playback.Session) string { + if s == nil { + return "" + } + if s.PlayMethod == playback.PlayTranscode { + return fmt.Sprintf("/playback/transcode/%s/master.m3u8", s.ID) + } + return fmt.Sprintf("/stream/%s", s.ID) +} + +func buildPlaybackInfo(session *playback.Session, file *models.MediaFile) *playbackInfoResult { + if session == nil { + return nil + } + + info := &playbackInfoResult{ + TranscodeAudio: session.TranscodeAudio, + } + + switch session.PlayMethod { + case playback.PlayTranscode: + info.StreamType = "hls" + if strings.EqualFold(session.TargetVideoCodec, "copy") || session.TargetVideoCodec == "" { + info.VideoCodec = sourceVideoCodec(file) + } else { + info.VideoCodec = session.TargetVideoCodec + } + if session.TranscodeAudio { + info.AudioCodec = "aac" + } else if strings.EqualFold(session.TargetAudioCodec, "copy") || session.TargetAudioCodec == "" { + info.AudioCodec = sourceAudioCodec(file) + } else { + info.AudioCodec = session.TargetAudioCodec + } + case playback.PlayRemux, playback.PlayDirect: + info.StreamType = "progressive" + info.VideoCodec = sourceVideoCodec(file) + if session.TranscodeAudio { + info.AudioCodec = "aac" + } else { + info.AudioCodec = sourceAudioCodec(file) + } + default: + info.StreamType = "progressive" + info.VideoCodec = sourceVideoCodec(file) + info.AudioCodec = sourceAudioCodec(file) + } + + return info +} + +func requestedMediaFileID(session *playback.Session) int { + if session == nil { + return 0 + } + if session.RequestedMediaFileID > 0 { + return session.RequestedMediaFileID + } + return session.MediaFileID +} + +func (h *PlaybackHandler) loadFileByPreferredID( + ctx context.Context, + preferredID int, + fallbackID int, +) (*models.MediaFile, error) { + if h.fileResolver == nil { + return nil, fmt.Errorf("file resolver not configured") + } + if preferredID > 0 { + file, err := h.fileResolver.GetByID(ctx, preferredID) + if err == nil && file != nil { + return file, nil + } + if err != nil && (fallbackID == 0 || fallbackID == preferredID) { + return nil, err + } + } + if fallbackID > 0 && fallbackID != preferredID { + return h.fileResolver.GetByID(ctx, fallbackID) + } + return nil, nil +} + +func sourceVideoCodec(file *models.MediaFile) string { + if file == nil { + return "" + } + if len(file.VideoTracks) > 0 && file.VideoTracks[0].Codec != "" { + return file.VideoTracks[0].Codec + } + return file.CodecVideo +} + +func sourceAudioCodec(file *models.MediaFile) string { + if file == nil { + return "" + } + if len(file.AudioTracks) > 0 && file.AudioTracks[0].Codec != "" { + return file.AudioTracks[0].Codec + } + return file.CodecAudio +} + +func directPlayAudioTrackIndex(file *models.MediaFile) int { + if file == nil || len(file.AudioTracks) == 0 { + return 0 + } + for i, track := range file.AudioTracks { + if track.Default { + return i + } + } + return 0 +} + +func clientSupportsAudioCodec(req startPlaybackRequest, codec string) bool { + if codec == "" { + return true + } + if len(req.CodecsAudio) == 0 { + return playback.BrowserSupportsAudioCodec(codec) + } + for _, supported := range req.CodecsAudio { + if strings.EqualFold(supported, codec) { + return true + } + } + if req.AudioPassthrough != nil { + for _, supported := range req.AudioPassthrough.PassthroughCodecs { + if strings.EqualFold(supported, codec) { + return true + } + } + } + return false +} + +func adjustPlaybackForSelectedAudio( + file *models.MediaFile, + req startPlaybackRequest, + method playback.PlayMethod, + transcodeAudio bool, + audioTrackIndex int, + preserveDirectAudioSelection bool, +) (playback.PlayMethod, bool) { + if file == nil || len(file.AudioTracks) == 0 || audioTrackIndex < 0 || audioTrackIndex >= len(file.AudioTracks) { + return method, transcodeAudio + } + + selectedTrack := file.AudioTracks[audioTrackIndex] + audioSupported := clientSupportsAudioCodec(req, selectedTrack.Codec) + + switch method { + case playback.PlayDirect: + if preserveDirectAudioSelection { + return playback.PlayDirect, false + } + // Direct play cannot force the browser onto a non-default audio stream. + // Promote to remux so ffmpeg can map the selected track explicitly. + if audioTrackIndex != directPlayAudioTrackIndex(file) { + return playback.PlayRemux, !audioSupported + } + if !audioSupported { + return playback.PlayRemux, true + } + return method, false + case playback.PlayRemux: + return method, !audioSupported + default: + return method, transcodeAudio + } +} + +func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int { + if file == nil || len(file.AudioTracks) == 0 { + return 0 + } + if audioTrackIndex >= 0 && audioTrackIndex < len(file.AudioTracks) { + return audioTrackIndex + } + return directPlayAudioTrackIndex(file) +} + +func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader) playback.AdminSettings { + settings := playback.AdminSettings{ + TranscodeEnabled: true, + } + if repo != nil { + if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" { + settings.Allow4KTranscode = true + } + } + return settings +} + +func resolvePlaybackMethodForFile( + file *models.MediaFile, + req startPlaybackRequest, + audioTrackIndex int, + adminSettings playback.AdminSettings, +) (playback.PlayMethod, bool) { + if file == nil { + return "", false + } + + caps := playback.ClientCapabilities{ + CodecsVideo: req.CodecsVideo, + CodecsAudio: req.CodecsAudio, + Containers: req.Containers, + MaxResolution: req.MaxResolution, + HDR: req.HDR, + } + if req.AudioPassthrough != nil { + caps.AudioPassthroughCodecs = req.AudioPassthrough.PassthroughCodecs + } + decision := playback.Resolve(file, caps, adminSettings) + return adjustPlaybackForSelectedAudio(file, req, decision.Method, decision.TranscodeAudio, audioTrackIndex, false) +} + +func (h *PlaybackHandler) resolveCapabilityPlaybackSelection( + ctx context.Context, + req startPlaybackRequest, + requestedFile *models.MediaFile, + audioTrackIndex int, +) (*models.MediaFile, playback.PlayMethod, bool, int) { + if requestedFile == nil { + return requestedFile, "", false, 0 + } + + audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex) + adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo) + method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings) + + if requestedFile.Resolution == "2160p" && + method == playback.PlayTranscode && + !adminSettings.Allow4KTranscode && + h.FileVersionFetcher != nil { + alt, err := h.findAlternateFile(ctx, requestedFile) + if err == nil && alt != nil { + effectiveFile := h.ensurePlaybackProbe(ctx, alt) + audioTrackIndex = normalizeAudioTrackIndex(effectiveFile, audioTrackIndex) + method, transcodeAudio = resolvePlaybackMethodForFile(effectiveFile, req, audioTrackIndex, adminSettings) + return effectiveFile, method, transcodeAudio, audioTrackIndex + } + } + + return requestedFile, method, transcodeAudio, audioTrackIndex +} + +func (h *PlaybackHandler) resolveSeriesID(ctx context.Context, file *models.MediaFile) string { + if file.EpisodeID == "" || h.EpisodeLookup == nil { + return "" + } + ep, err := h.EpisodeLookup.GetByID(ctx, file.EpisodeID) + if err != nil || ep == nil { + return "" + } + return ep.SeriesID +} + +// resolveOriginalLanguage fetches the original language for a media file's content item. +// For episodes, it looks up the parent series. Returns empty string if unavailable. +func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *models.MediaFile) string { + if h.OriginalLangLookup == nil { + return "" + } + contentID := file.ContentID + if file.EpisodeID != "" { + contentID = h.resolveSeriesID(ctx, file) + } + if contentID == "" { + return "" + } + lang, err := h.OriginalLangLookup.GetOriginalLanguage(ctx, contentID) + if err != nil { + return "" + } + return lang +} + +func (h *PlaybackHandler) restoreSessionProgress( + ctx context.Context, + session *playback.Session, + file *models.MediaFile, +) { + if h.StoreProvider == nil || session == nil || file == nil { + return + } + + targetID := playbackProgressTarget(file) + if targetID == "" { + return + } + + store, err := h.StoreProvider.ForUser(ctx, session.UserID) + if err != nil { + slog.Error("failed to get user store", "user_id", session.UserID, "error", err) + return + } + + progress, err := store.GetProgress(ctx, session.ProfileID, targetID) + if err != nil { + slog.Error("failed to load progress", "target", targetID, "error", err) + return + } + + if progress == nil || progress.Completed || progress.PositionSeconds <= 0 { + return + } + + if err := h.sessionMgr.UpdateProgress(session.ID, progress.PositionSeconds, false); err != nil { + slog.Error("failed to restore progress", "session", session.ID, "error", err) + return + } + + session.Position = progress.PositionSeconds + session.IsPaused = false +} + +// --- Persistence helpers --- + +// persistProgress saves the current playback position to the UserStore. +// It resolves the mediaFileID to a mediaItemID via the file resolver. +// Errors are logged but do not fail the HTTP request. +func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback.Session) { + if h.StoreProvider == nil || h.fileResolver == nil { + return + } + + file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID) + targetID := playbackProgressTarget(file) + if err != nil || targetID == "" { + return // file not found or not yet matched to a media item + } + + store, err := h.StoreProvider.ForUser(ctx, session.UserID) + if err != nil { + slog.Error("failed to get user store", "user_id", session.UserID, "error", err) + return + } + + duration := float64(file.Duration) + if err := store.UpdateProgress(ctx, session.ProfileID, targetID, session.Position, duration, h.playbackThresholds(ctx)); err != nil { + slog.Error("failed to persist progress", "session", session.ID, "error", err) + } else { + triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID) + } + + if err := store.UpdateProgressHints(ctx, session.ProfileID, targetID, userstore.VersionHints{ + FileID: file.ID, + Resolution: file.Resolution, + HDR: file.HDR, + CodecVideo: file.CodecVideo, + EditionKey: file.EditionKey, + }); err != nil { + slog.Error("failed to persist version hints", "session", session.ID, "error", err) + } +} + +// persistStopAndHistory saves the final position and adds a watch history entry +// when a playback session is stopped. Errors are logged but do not fail the +// HTTP request. +func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *playback.Session) watchstate.PlaybackStopResult { + if h.StoreProvider == nil || h.fileResolver == nil { + return watchstate.PlaybackStopResult{} + } + if session == nil || session.Position <= 0 { + return watchstate.PlaybackStopResult{} + } + + file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID) + targetID := playbackProgressTarget(file) + if err != nil || targetID == "" { + return watchstate.PlaybackStopResult{} + } + + duration := float64(file.Duration) + thresholds := h.playbackThresholds(ctx) + watchSvc := watchstate.NewService(h.StoreProvider).WithStableIdentityResolver(h.StableIdentityResolver) + stoppedAt := time.Now().UTC() + result, err := watchSvc.RecordPlaybackStop(ctx, session.UserID, session.ProfileID, targetID, duration, session.Position, stoppedAt, userstore.VersionHints{ + FileID: file.ID, + Resolution: file.Resolution, + HDR: file.HDR, + CodecVideo: file.CodecVideo, + EditionKey: file.EditionKey, + }, thresholds) + if err != nil { + slog.Error("failed to persist playback stop", "session", session.ID, "error", err) + } else { + triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID) + } + return result +} + +func (h *PlaybackHandler) scrobbleEventForSession(ctx context.Context, session *playback.Session, mediaItemID string, duration, position float64) watchsync.ScrobbleEvent { + event := watchsync.ScrobbleEvent{ + PlaybackSessionID: session.ID, + UserID: session.UserID, + ProfileID: session.ProfileID, + MediaItemID: mediaItemID, + PositionSeconds: position, + DurationSeconds: duration, + OccurredAt: time.Now().UTC(), + } + if h.StableIdentityResolver == nil { + event.Kind = "movie" + return event + } + identity := h.StableIdentityResolver.ResolveHistoryIdentity(ctx, mediaItemID) + event.Kind = identity.StableType + if event.Kind == "" { + event.Kind = "movie" + } + event.SeasonNumber = intPtrValue(identity.Season) + event.EpisodeNumber = intPtrValue(identity.Episode) + if identity.ProviderIDs != nil { + event.IMDbID = identity.ProviderIDs["imdb"] + event.TMDBID = identity.ProviderIDs["tmdb"] + event.TVDBID = identity.ProviderIDs["tvdb"] + } + if identity.SeriesProviderIDs != nil { + event.SeriesIMDbID = identity.SeriesProviderIDs["imdb"] + event.SeriesTMDBID = identity.SeriesProviderIDs["tmdb"] + event.SeriesTVDBID = identity.SeriesProviderIDs["tvdb"] + } + return event +} + +func intPtrValue(value *int) int { + if value == nil { + return 0 + } + return *value +} + +func (h *PlaybackHandler) buildAdminHistoryEntry( + ctx context.Context, + session *playback.Session, +) (*AdminPlaybackHistoryEntry, error) { + if h.AdminStore == nil || h.fileResolver == nil || session == nil { + return nil, nil + } + + file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID) + if err != nil { + return nil, fmt.Errorf("loading media file: %w", err) + } + + targetID := playbackProgressTarget(file) + profileName := session.ProfileID + if h.StoreProvider != nil { + store, storeErr := h.StoreProvider.ForUser(ctx, session.UserID) + if storeErr != nil { + slog.Error("failed to get user store for admin history", "session", session.ID, "error", storeErr) + } else if store != nil { + profile, profileErr := store.GetProfile(ctx, session.ProfileID) + if profileErr != nil { + slog.Error("failed to load profile for admin history", "session", session.ID, "error", profileErr) + } else if profile != nil && strings.TrimSpace(profile.Name) != "" { + profileName = profile.Name + } + } + } + + var durationPtr *float64 + completed := false + if file != nil { + duration := float64(file.Duration) + durationPtr = &duration + if duration > 0 && session.Position/duration > userstore.WatchedFraction(h.playbackThresholds(ctx).WatchedPct) { + completed = true + } + } + + entry := &AdminPlaybackHistoryEntry{ + SessionID: session.ID, + UserID: session.UserID, + ProfileID: session.ProfileID, + ProfileName: profileName, + MediaItemID: targetID, + MediaFileID: requestedMediaFileID(session), + PlayMethod: string(semanticPlayMethod(session)), + StartedAt: session.StartedAt.UTC().Format(time.RFC3339Nano), + EndedAt: time.Now().UTC().Format(time.RFC3339Nano), + WatchedSeconds: session.Position, + DurationSeconds: durationPtr, + Completed: completed, + ClientIP: clientip.FromContext(ctx), + } + return entry, nil +} + +func (h *PlaybackHandler) syncSessionsNow(ctx context.Context, reason string) { + if h.SessionSyncer == nil { + return + } + if err := h.SessionSyncer.SyncNow(ctx); err != nil { + slog.Error("failed to sync sessions", "reason", reason, "error", err) + } +} + +func (h *PlaybackHandler) touchSessionActivity(sessionID string) { + if h == nil || sessionID == "" { + return + } + if err := h.sessionMgr.TouchActivity(sessionID); err != nil && !errors.Is(err, playback.ErrSessionNotFound) { + slog.Warn("failed to refresh playback activity", "session", sessionID, "error", err, "playback_session_id", sessionID) + } +} + +func (h *PlaybackHandler) finalizeSessionStop(ctx context.Context, session *playback.Session, syncNow bool, syncReason string) { + if h == nil || session == nil || session.ID == "" { + return + } + if ctx == nil { + ctx = context.Background() + } + + stopResult := h.persistStopAndHistory(ctx, session) + if h.WatchScrobbler != nil && stopResult.MediaItemID != "" { + event := h.scrobbleEventForSession(ctx, session, stopResult.MediaItemID, stopResult.DurationSeconds, stopResult.FinalPositionSeconds) + event.HistoryID = stopResult.HistoryID + event.Completed = stopResult.Completed + if stopResult.Completed { + if err := h.WatchScrobbler.ScrobbleStop(ctx, event); err != nil { + slog.Warn("failed to queue watch provider stop scrobble", "session", session.ID, "error", err) + } + } else if !stopResult.SkippedBelowMinResume { + if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil { + slog.Warn("failed to queue watch provider pause scrobble", "session", session.ID, "error", err) + } + } + } + if entry, buildErr := h.buildAdminHistoryEntry(ctx, session); buildErr != nil { + slog.Error("failed to build admin history", "session", session.ID, "error", buildErr) + } else if entry != nil && h.AdminStore != nil { + if err := h.AdminStore.RecordHistory(ctx, *entry); err != nil { + slog.Error("failed to record admin history", "session", session.ID, "error", err) + } + } + + if h.AdminStore != nil { + if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil { + slog.Error("failed to delete synced session", "session", session.ID, "error", err) + } + } + + h.closeTranscodeSession(session.ID, session.TranscodeNodeURL) + if syncNow { + h.syncSessionsNow(ctx, syncReason) + } +} + +func (h *PlaybackHandler) finalizeSessionAbort(ctx context.Context, session *playback.Session, syncNow bool, syncReason string) { + if h == nil || session == nil || session.ID == "" { + return + } + if ctx == nil { + ctx = context.Background() + } + + if h.WatchScrobbler != nil && h.fileResolver != nil { + if file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID); err == nil && file != nil { + targetID := playbackProgressTarget(file) + if targetID != "" { + event := h.scrobbleEventForSession(ctx, session, targetID, float64(file.Duration), session.Position) + if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil { + slog.Warn("failed to queue watch provider abort scrobble", "session", session.ID, "error", err) + } + } + } + } + + if h.AdminStore != nil { + if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil { + slog.Error("failed to delete synced session", "session", session.ID, "error", err) + } + } + + h.closeTranscodeSession(session.ID, session.TranscodeNodeURL) + if syncNow { + h.syncSessionsNow(ctx, syncReason) + } +} + +func (h *PlaybackHandler) handleExpiredSession(session *playback.Session) { + if h == nil || session == nil { + return + } + sessionCopy := *session + go func() { + slog.Info("expired inactive playback session", "session", sessionCopy.ID, "playback_session_id", sessionCopy.ID) + h.finalizeSessionStop(context.Background(), &sessionCopy, false, "") + }() +} + +func playbackProgressTarget(file *models.MediaFile) string { + if file == nil { + return "" + } + if file.EpisodeID != "" { + return file.EpisodeID + } + return file.ContentID +} + +func (h *PlaybackHandler) persistSeriesPlaybackPreference( + ctx context.Context, + userID int, + profileID string, + file *models.MediaFile, +) { + if h.StoreProvider == nil || file == nil { + return + } + + seriesID := h.resolveSeriesID(ctx, file) + if seriesID == "" { + return + } + + store, err := h.StoreProvider.ForUser(ctx, userID) + if err != nil { + slog.Error("failed to access user store for series playback preference", "user_id", userID, "error", err) + return + } + + if err := store.SetSeriesPlaybackPreference(ctx, userstore.SeriesPlaybackPreference{ + ProfileID: profileID, + SeriesID: seriesID, + Resolution: file.Resolution, + HDR: file.HDR, + CodecVideo: file.CodecVideo, + }); err != nil { + slog.Error("failed to persist series playback preference", "series_id", seriesID, "profile_id", profileID, "error", err) + } +} + +func (h *PlaybackHandler) persistAudioPreference( + ctx context.Context, + userID int, + profileID string, + file *models.MediaFile, + trackIndex int, +) { + if h.StoreProvider == nil || file == nil || trackIndex < 0 || trackIndex >= len(file.AudioTracks) { + return + } + + seriesID := h.resolveSeriesID(ctx, file) + if seriesID == "" { + return + } + + store, err := h.StoreProvider.ForUser(ctx, userID) + if err != nil { + slog.Error("failed to access user store for audio preference", "user_id", userID, "error", err) + return + } + + track := file.AudioTracks[trackIndex] + if err := store.SetAudioPreference(ctx, userstore.AudioPreference{ + ProfileID: profileID, + SeriesID: seriesID, + AudioTrackIndex: trackIndex, + AudioLanguage: track.Language, + TrackSignature: playback.AudioTrackSignatureFromTrack(track), + }); err != nil { + slog.Error("failed to persist audio preference", "series_id", seriesID, "profile_id", profileID, "error", err) + } +} + +// --- Handler methods --- + +// HandleStartPlayback handles POST /playback/start. +func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req startPlaybackRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.FileID == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "File ID is required") + return + } + profileID := apimw.GetProfileID(r.Context()) + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required") + return + } + if req.ProfileID != "" && req.ProfileID != profileID { + writeError(w, http.StatusBadRequest, "bad_request", "profile_id must match X-Profile-Id") + return + } + file, err := h.loadAuthorizedFile(r, req.FileID) + if err != nil { + switch { + case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound): + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file") + } + return + } + file = h.ensurePlaybackProbe(r.Context(), file) + + // Determine audio track. + audioTrackIndex := 0 + if req.AudioTrackIndex != nil && *req.AudioTrackIndex >= 0 { + audioTrackIndex = *req.AudioTrackIndex + } else if file != nil && len(file.AudioTracks) > 0 && h.StoreProvider != nil { + var seriesPref *playback.AudioTrackPreference + var preferredLang string + store, storeErr := h.StoreProvider.ForUser(r.Context(), userID) + if storeErr == nil { + seriesID := h.resolveSeriesID(r.Context(), file) + if seriesID != "" { + if ap, apErr := store.GetAudioPreference(r.Context(), profileID, seriesID); apErr == nil && ap != nil { + seriesPref = &playback.AudioTrackPreference{ + AudioTrackIndex: ap.AudioTrackIndex, + AudioLanguage: ap.AudioLanguage, + TrackSignature: ap.TrackSignature, + } + } + } + if seriesPref != nil && seriesPref.AudioLanguage == playback.OriginalLanguageSentinel { + seriesPref.AudioLanguage = h.resolveOriginalLanguage(r.Context(), file) + } + if profile, profErr := store.GetProfile(r.Context(), profileID); profErr == nil && profile != nil { + preferredLang = profile.Language + } + + // Resolve library override (if no series sticky pref exists). + var libraryAudioLang string + if seriesPref == nil { + if lp, lpErr := store.GetLibraryPlaybackPreference(r.Context(), profileID, file.MediaFolderID); lpErr == nil && lp != nil && lp.AudioLanguage != "" { + libraryAudioLang = lp.AudioLanguage + } + } + + // Resolve "original" sentinel at each preference level. + needsOriginalLang := preferredLang == playback.OriginalLanguageSentinel || + libraryAudioLang == playback.OriginalLanguageSentinel + if needsOriginalLang { + originalLang := h.resolveOriginalLanguage(r.Context(), file) + if preferredLang == playback.OriginalLanguageSentinel { + preferredLang = originalLang + } + if libraryAudioLang == playback.OriginalLanguageSentinel { + libraryAudioLang = originalLang + } + } + + // Apply library language override (skip if resolved to empty). + if libraryAudioLang != "" { + preferredLang = libraryAudioLang + } + } + audioTrackIndex = playback.SelectAudioTrack(file.AudioTracks, preferredLang, seriesPref) + } + + requestedFile := file + effectiveFile := requestedFile + method := playback.PlayMethod(req.PlayMethod) + transcodeAudio := false + + // If the client sent codec capabilities and no explicit play method, + // use the resolver to determine the best play strategy. + if method == "" && h.fileResolver != nil && len(req.CodecsVideo) > 0 { + effectiveFile, method, transcodeAudio, audioTrackIndex = h.resolveCapabilityPlaybackSelection( + r.Context(), + req, + requestedFile, + audioTrackIndex, + ) + } + + if method == "" { + method = playback.PlayDirect + } + audioTrackIndex = normalizeAudioTrackIndex(effectiveFile, audioTrackIndex) + preserveDirectAudioSelection := method == playback.PlayDirect && + strings.EqualFold(req.PlayMethod, string(playback.PlayDirect)) && + req.PreserveDirectAudioSelection + method, transcodeAudio = adjustPlaybackForSelectedAudio( + effectiveFile, + req, + method, + transcodeAudio, + audioTrackIndex, + preserveDirectAudioSelection, + ) + if requestedFile != nil && effectiveFile != nil && requestedFile.ID != effectiveFile.ID { + if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) { + slog.Warn("requested playback file preflight failed; continuing with alternate file", + "requested_file_id", requestedFile.ID, + "effective_file_id", effectiveFile.ID, + "error", err, + ) + } + } + if err := preflightPlaybackFile(r.Context(), effectiveFile, h.MissingMarker, h.EventsHub); err != nil { + writePlaybackFilePreflightError(w, err) + return + } + + var session *playback.Session + if starter, ok := h.sessionMgr.(sessionStarterWithFilesContext); ok { + session, err = starter.StartSessionWithFilesContext( + r.Context(), + userID, + profileID, + effectiveFile.ID, + req.FileID, + method, + transcodeAudio, + ) + } else { + session, err = h.sessionMgr.StartSessionWithFiles( + userID, + profileID, + effectiveFile.ID, + req.FileID, + method, + transcodeAudio, + ) + } + if err != nil { + if errors.Is(err, playback.ErrTooManyStreams) { + writeError(w, http.StatusTooManyRequests, "too_many_streams", "Too many concurrent streams") + return + } + if errors.Is(err, playback.ErrTooManyTranscodes) { + writeError(w, http.StatusTooManyRequests, "too_many_transcodes", "Too many concurrent transcodes") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start playback session") + return + } + setPlaybackSessionLogContext(r, session.ID) + + if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil { + slog.Error("failed to set audio track", "session", session.ID, "error", err) + } + targetAudioCodec := "" + if session.TranscodeAudio { + targetAudioCodec = "aac" + } + streamBitrateKbps := 0 + if effectiveFile != nil { + streamBitrateKbps = effectiveFile.Bitrate + } + if err := h.sessionMgr.UpdateStreamState(session.ID, playback.SessionStreamState{ + PlayMethod: session.PlayMethod, + BasePlayMethod: session.BasePlayMethod, + AudioTrackIndex: audioTrackIndex, + TranscodeAudio: session.TranscodeAudio, + ClientIP: clientip.FromContext(r.Context()), + StreamBitrateKbps: streamBitrateKbps, + TargetAudioCodec: targetAudioCodec, + }); err != nil { + slog.Error("failed to set stream state", "session", session.ID, "error", err) + } + session.AudioTrackIndex = audioTrackIndex + session.ClientIP = clientip.FromContext(r.Context()) + session.StreamBitrateKbps = streamBitrateKbps + session.TargetAudioCodec = targetAudioCodec + h.persistSeriesPlaybackPreference(r.Context(), userID, profileID, effectiveFile) + + if req.StartPosition != nil { + if err := h.sessionMgr.UpdateProgress(session.ID, *req.StartPosition, false); err != nil { + slog.Error("failed to set explicit start position", "session", session.ID, "error", err) + } else { + session.Position = *req.StartPosition + session.IsPaused = false + } + } else { + h.restoreSessionProgress(r.Context(), session, file) + } + if h.WatchScrobbler != nil && effectiveFile != nil { + targetID := playbackProgressTarget(effectiveFile) + if targetID != "" { + event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position) + if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil { + slog.Warn("failed to queue watch provider start scrobble", "session", session.ID, "error", err) + } + } + } + if h.ChapterThumbnailQueuer != nil && effectiveFile != nil { + slog.Info( + "queueing chapter thumbnails", + "source", + "playback_start", + "content_id", + effectiveFile.ContentID, + "file_id", + effectiveFile.ID, + "target_seconds", + session.Position, + ) + h.ChapterThumbnailQueuer.QueuePriorityFileAtPosition( + r.Context(), + effectiveFile.ID, + session.Position, + ) + } + h.maybeQueueLazyPlaybackMarkers(r.Context(), session, effectiveFile) + + resp := toPlaybackSessionResponse(session) + resp.DurationSeconds = fileDurationSeconds(effectiveFile) + resp.PlaybackInfo = buildPlaybackInfo(session, effectiveFile) + + var downloadedSubs []subtitles.DownloadedSubtitle + if h.SubtitleRepo != nil && effectiveFile != nil { + downloadedSubs, _ = h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), effectiveFile.ID) + } + resp.SubtitleURLs = buildSubtitleURLs(session.ID, effectiveFile, downloadedSubs) + + // If proxy nodes are available, generate proxy-based stream URLs. + if h.ProxyPool != nil && h.JWTSecret != "" { + proxyNode := h.ProxyPool.Pick() + if proxyNode != nil { + tokenClaims := streamtoken.Claims{ + SessionID: session.ID, + PlayMethod: string(session.PlayMethod), + } + + // Resolve media path if possible. + if effectiveFile != nil { + tokenClaims.MediaPath = effectiveFile.FilePath + } + + tokenClaims.TranscodeAudio = session.TranscodeAudio + tokenClaims.AudioTrackIndex = session.AudioTrackIndex + + // Remux and transcode both use HLS via a transcode node. + if (session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux) && h.TranscodePool != nil { + if tcNode := h.TranscodePool.Acquire(); tcNode != nil { + tokenClaims.TranscodeNode = tcNode.URL + _ = h.sessionMgr.SetTranscodeNodeURL(session.ID, tcNode.URL) + } + } + + token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour) + if signErr == nil { + switch session.PlayMethod { + case playback.PlayDirect: + resp.StreamURL = proxyNode.URL + "/stream/direct/" + token + case playback.PlayRemux, playback.PlayTranscode: + resp.StreamURL = proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8" + } + + // Update subtitle URLs to use proxy for embedded subs only. + // External and downloaded subs stay on the API server since + // the proxy doesn't have access to those files. + embeddedOffset := 0 + if file != nil { + embeddedOffset = len(file.ExternalSubtitles) + } + for i := range resp.SubtitleURLs { + if resp.SubtitleURLs[i].Source == "embedded" { + // Pass the ffmpeg-relative subtitle stream index to the proxy. + embeddedIdx := resp.SubtitleURLs[i].Index - embeddedOffset + resp.SubtitleURLs[i].URL = proxyNode.URL + "/stream/subtitles/" + token + "/" + strconv.Itoa(embeddedIdx) + subtitleURLExt(resp.SubtitleURLs[i].Codec) + } + } + } + } + } + + h.syncSessionsNow(r.Context(), "start") + writeJSON(w, http.StatusCreated, resp) +} + +// subtitleURLExt returns the URL file extension for a subtitle codec. +// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for +// client-side rendering (JASSUB); all other text formats get ".vtt". +func subtitleURLExt(codec string) string { + if playback.IsASS(codec) { + return ".ass" + } + return ".vtt" +} + +func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []subtitles.DownloadedSubtitle) []subtitleURL { + if file == nil { + return nil + } + + urls := make([]subtitleURL, 0, len(file.ExternalSubtitles)+len(file.SubtitleTracks)+len(downloaded)) + + for i, sub := range file.ExternalSubtitles { + urls = append(urls, subtitleURL{ + Index: i, + Language: sub.Language, + Codec: sub.Format, + Label: firstNonEmptyString(sub.Title, sub.EmbeddedTitle, filepath.Base(sub.Path), sub.Language), + Source: "external", + Forced: sub.Forced, + HearingImpaired: sub.HearingImpaired, + URL: fmt.Sprintf("/stream/%s/subtitles/%d%s", sessionID, i, subtitleURLExt(sub.Format)), + }) + } + + embeddedOffset := len(file.ExternalSubtitles) + for i, track := range file.SubtitleTracks { + if playback.NeedsBurnIn(track.Codec) { + continue + } + + urls = append(urls, subtitleURL{ + Index: embeddedOffset + i, + Language: track.Language, + Codec: track.Codec, + Label: firstNonEmptyString(track.Title, track.EmbeddedTitle, track.Language), + Source: "embedded", + Forced: track.Forced, + HearingImpaired: track.HearingImpaired, + URL: fmt.Sprintf("/stream/%s/subtitles/%d%s", sessionID, embeddedOffset+i, subtitleURLExt(track.Codec)), + }) + } + + downloadedOffset := embeddedOffset + len(file.SubtitleTracks) + for i, dl := range downloaded { + urls = append(urls, subtitleURL{ + Index: downloadedOffset + i, + Language: dl.Language, + Codec: string(dl.Format), + Label: dl.ReleaseName + " (" + dl.Provider + ")", + Source: "downloaded", + HearingImpaired: dl.HearingImpaired, + URL: fmt.Sprintf("/stream/%s/subtitles/%d%s", sessionID, downloadedOffset+i, subtitleURLExt(string(dl.Format))), + }) + } + + return urls +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +// HandleUpdateProgress handles POST /playback/{session_id}/progress. +func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + sessionID := chi.URLParam(r, "session_id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required") + return + } + setPlaybackSessionLogContext(r, sessionID) + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writePlaybackSessionNotFound(w) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session") + return + } + if session.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") + return + } + wasPaused := session.IsPaused + + var req progressRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + err = h.sessionMgr.UpdateProgress(sessionID, req.Position, req.IsPaused) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writePlaybackSessionNotFound(w) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update progress") + return + } + + // Persist progress to UserStore (best-effort). + if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil { + h.persistProgress(r.Context(), sess) + if h.WatchScrobbler != nil && wasPaused != sess.IsPaused { + if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil { + targetID := playbackProgressTarget(file) + if targetID != "" { + event := h.scrobbleEventForSession(r.Context(), sess, targetID, float64(file.Duration), sess.Position) + if sess.IsPaused { + if err := h.WatchScrobbler.ScrobblePause(r.Context(), event); err != nil { + slog.Warn("failed to queue watch provider pause scrobble", "session", sessionID, "error", err) + } + } else if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil { + slog.Warn("failed to queue watch provider resume scrobble", "session", sessionID, "error", err) + } + } + } + } + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleStopPlayback handles DELETE /playback/{session_id}. +func (h *PlaybackHandler) HandleStopPlayback(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + sessionID := chi.URLParam(r, "session_id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required") + return + } + setPlaybackSessionLogContext(r, sessionID) + + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writePlaybackSessionNotFound(w) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session") + return + } + if session.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") + return + } + + err = h.stopPlaybackSession(r.Context(), session) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writePlaybackSessionNotFound(w) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to stop playback session") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleChangeAudioTrack handles PATCH /playback/{session_id}/audio. +func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + sessionID := chi.URLParam(r, "session_id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required") + return + } + setPlaybackSessionLogContext(r, sessionID) + + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writePlaybackSessionNotFound(w) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session") + return + } + if session.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") + return + } + + var req changeAudioRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + // Load file to validate track index. + if h.fileResolver == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "File resolver not configured") + return + } + file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID) + if err != nil || file == nil { + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + return + } + if req.AudioTrackIndex < 0 || req.AudioTrackIndex >= len(file.AudioTracks) { + writeError(w, http.StatusBadRequest, "bad_request", "Audio track index out of range") + return + } + + baseMethod := semanticPlayMethod(session) + newMethod := baseMethod + transcodeAudio := session.TranscodeAudio + + newTrack := file.AudioTracks[req.AudioTrackIndex] + audioCodecNeedsTranscode := !playback.BrowserSupportsAudioCodec(newTrack.Codec) + + if baseMethod == playback.PlayDirect { + newMethod = playback.PlayRemux + transcodeAudio = audioCodecNeedsTranscode + } else if baseMethod == playback.PlayRemux { + transcodeAudio = audioCodecNeedsTranscode + } else if baseMethod == playback.PlayTranscode { + transcodeAudio = true + } + + targetResolution := "" + targetVideoCodec := "" + targetAudioCodec := "" + targetBitrateKbps := 0 + streamBitrateKbps := session.StreamBitrateKbps + if session.PlayMethod == playback.PlayTranscode { + targetResolution = session.TargetResolution + targetVideoCodec = session.TargetVideoCodec + targetBitrateKbps = session.TargetBitrateKbps + if newMethod == playback.PlayTranscode || transcodeAudio { + targetAudioCodec = "aac" + } else { + targetAudioCodec = "copy" + } + } else if transcodeAudio { + targetAudioCodec = "aac" + } + slog.Info("audio switch computed playback state", + "playback_session_id", sessionID, + "previous_base_play_method", baseMethod, + "new_base_play_method", newMethod, + "transport_play_method", session.PlayMethod, + "audio_track_index", req.AudioTrackIndex, + "audio_codec", newTrack.Codec, + "transcode_audio", transcodeAudio, + ) + if err := h.sessionMgr.UpdateAudioTrack(sessionID, req.AudioTrackIndex, newMethod); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update audio track") + return + } + if err := h.sessionMgr.UpdateStreamState(sessionID, playback.SessionStreamState{ + PlayMethod: session.PlayMethod, + BasePlayMethod: newMethod, + AudioTrackIndex: req.AudioTrackIndex, + TranscodeAudio: transcodeAudio, + ClientIP: session.ClientIP, + StreamBitrateKbps: streamBitrateKbps, + TargetResolution: targetResolution, + TargetVideoCodec: targetVideoCodec, + TargetAudioCodec: targetAudioCodec, + TargetBitrateKbps: targetBitrateKbps, + }); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update stream state") + return + } + + // Handle transcode restart. + if session.PlayMethod == playback.PlayTranscode { + if ts := h.getTranscodeSession(sessionID); ts != nil { + ts.SetAudioTrackIndex(req.AudioTrackIndex) + seekSeconds := req.Position + startSegment := computeStartSegment(seekSeconds, ts.Opts().SegmentDuration) + if restartErr := ts.Restart(context.WithoutCancel(r.Context()), seekSeconds, startSegment); restartErr != nil { + slog.Error("failed to restart transcode for audio switch", "session", sessionID, "error", restartErr) + } else { + h.maybeStartThrottler(r.Context(), ts) + } + } + } + + updatedSession := *session + updatedSession.AudioTrackIndex = req.AudioTrackIndex + updatedSession.BasePlayMethod = newMethod + if session.PlayMethod != playback.PlayTranscode || newMethod == playback.PlayTranscode { + updatedSession.PlayMethod = newMethod + } + updatedSession.TranscodeAudio = transcodeAudio + updatedSession.TargetResolution = targetResolution + updatedSession.TargetVideoCodec = targetVideoCodec + updatedSession.TargetAudioCodec = targetAudioCodec + updatedSession.TargetBitrateKbps = targetBitrateKbps + h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex) + + resp := changeAudioResponse{ + AudioTrackIndex: req.AudioTrackIndex, + PlayMethod: string(newMethod), + StreamURL: playbackStreamURL(&updatedSession), + SwitchMode: "reload", + PlaybackInfo: buildPlaybackInfo(&updatedSession, file), + } + + if h.ProxyPool != nil && h.JWTSecret != "" { + if proxyNode := h.ProxyPool.Pick(); proxyNode != nil { + tokenClaims := streamtoken.Claims{ + SessionID: sessionID, + PlayMethod: string(updatedSession.PlayMethod), + MediaPath: file.FilePath, + TranscodeAudio: updatedSession.TranscodeAudio, + AudioTrackIndex: req.AudioTrackIndex, + } + if updatedSession.PlayMethod == playback.PlayTranscode && h.TranscodePool != nil { + if tcNode := h.TranscodePool.Acquire(); tcNode != nil { + tokenClaims.TranscodeNode = tcNode.URL + _ = h.sessionMgr.SetTranscodeNodeURL(sessionID, tcNode.URL) + } + } + if token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour); signErr == nil { + switch updatedSession.PlayMethod { + case playback.PlayRemux: + resp.StreamURL = proxyNode.URL + "/stream/remux/" + token + case playback.PlayTranscode: + resp.StreamURL = proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8" + } + } + } + } + + h.syncSessionsNow(r.Context(), "audio_change") + writeJSON(w, http.StatusOK, resp) +} + +func (h *PlaybackHandler) loadAuthorizedFile(r *http.Request, fileID int) (*models.MediaFile, error) { + if h.fileResolver == nil || h.ItemAccess == nil { + return nil, fmt.Errorf("playback authorization dependencies not configured") + } + file, err := h.fileResolver.GetByID(r.Context(), fileID) + if err != nil { + return nil, err + } + if file == nil || file.MissingSince != nil { + return nil, catalog.ErrItemNotFound + } + + filter := requestAccessFilter(r) + switch { + case file.EpisodeID != "": + if h.EpisodeLookup == nil { + return nil, fmt.Errorf("episode lookup not configured") + } + episode, err := h.EpisodeLookup.GetByID(r.Context(), file.EpisodeID) + if err != nil { + return nil, err + } + if episode == nil { + return nil, catalog.ErrEpisodeNotFound + } + if err := h.ItemAccess.EnsureAccessible(r.Context(), episode.SeriesID, filter); err != nil { + return nil, err + } + case file.ContentID != "": + if err := h.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil { + return nil, err + } + default: + return nil, catalog.ErrItemNotFound + } + + if !catalog.FileAllowedByAccess(file, filter) { + return nil, catalog.ErrItemNotFound + } + + return file, nil +} + +// computeStartSegment returns the HLS segment number corresponding to a seek +// position given the segment duration. Both remote and local transcode paths +// use this to align ffmpeg output filenames with the VOD manifest. +func computeStartSegment(seekSeconds float64, segmentDuration int) int { + if segmentDuration <= 0 { + segmentDuration = 2 + } + if seekSeconds <= 0 { + return 0 + } + return int(seekSeconds / float64(segmentDuration)) +} + +// transcodeStartState holds the common parameters needed to finalize a +// transcode start (update session state, log, and sync) for both remote +// and local paths. +type transcodeStartState struct { + req transcodeStartRequest + file *models.MediaFile + session *playback.Session + switchedFileID *int +} + +// finalizeTranscodeStart updates the playback session state after a transcode +// has been started (either locally or on a remote node). +func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeStartState) { + if st.switchedFileID != nil { + if err := h.sessionMgr.SetEffectiveMediaFileID(st.req.SessionID, *st.switchedFileID); err != nil { + slog.Error("failed to update effective media file", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) + } + } + + streamBitrateKbps := st.req.TargetBitrateKbps + if streamBitrateKbps <= 0 { + streamBitrateKbps = st.file.Bitrate + } + transcodeAudio := st.req.TargetCodecAudio != "" && !strings.EqualFold(st.req.TargetCodecAudio, "copy") + baseMethod := semanticPlayMethod(st.session) + + slog.Info("transcode start preserved base playback state", + "playback_session_id", st.req.SessionID, + "base_play_method", baseMethod, + "transport_play_method", playback.PlayTranscode, + "audio_track_index", st.session.AudioTrackIndex, + "target_codec_video", st.req.TargetCodecVideo, + "target_codec_audio", st.req.TargetCodecAudio, + "copy_video_original", strings.EqualFold(st.req.TargetCodecVideo, "copy"), + "transcode_audio", transcodeAudio, + ) + + if err := h.sessionMgr.UpdateStreamState(st.req.SessionID, playback.SessionStreamState{ + PlayMethod: playback.PlayTranscode, + BasePlayMethod: baseMethod, + AudioTrackIndex: st.session.AudioTrackIndex, + TranscodeAudio: transcodeAudio, + ClientIP: st.session.ClientIP, + StreamBitrateKbps: streamBitrateKbps, + TargetResolution: st.req.TargetResolution, + TargetVideoCodec: st.req.TargetCodecVideo, + TargetAudioCodec: st.req.TargetCodecAudio, + TargetBitrateKbps: st.req.TargetBitrateKbps, + }); err != nil { + slog.Error("failed to update transcode stream state", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) + } + + h.syncSessionsNow(r.Context(), "transcode_start") +} + +// HandleStartTranscode handles POST /playback/transcode/start. +func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req transcodeStartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.SessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "session_id is required") + return + } + setPlaybackSessionLogContext(r, req.SessionID) + + session, err := h.sessionMgr.GetSession(req.SessionID) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writePlaybackSessionNotFound(w) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session") + return + } + if session.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") + return + } + // Close any existing transcode so a new one can start at different quality. + // Check both local sessions AND remote node assignments — without the + // remote check, switching quality on a transcode node never sends DELETE, + // leaving the old ffmpeg running and its segments on disk. + if h.getTranscodeSession(req.SessionID) != nil || session.TranscodeNodeURL != "" { + h.closeTranscodeSession(req.SessionID, session.TranscodeNodeURL) + } + abortCurrentSession := func(reason string, cause error) { + if abortErr := h.abortPlaybackSession(r.Context(), session); abortErr != nil && !errors.Is(abortErr, playback.ErrSessionNotFound) { + slog.Error("failed to abort playback session", + "session", req.SessionID, + "reason", reason, + "cause", cause, + "error", abortErr, + "playback_session_id", req.SessionID, + ) + } + } + + file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID) + if err != nil { + if isPlaybackFileLookupMissing(err) { + abortCurrentSession("load_media_file", err) + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load media file") + return + } + if file == nil { + abortCurrentSession("load_media_file", nil) + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + return + } + file = h.ensurePlaybackProbe(r.Context(), file) + requestedFile := file + + // Resume and seek-start requests generally cannot safely stream-copy video + // into HLS output. Arbitrary HEVC seek points often land on non-keyframes, + // which can leave Chromium stuck on a frozen frame while audio continues + // advancing. MPEG-2 compatibility HLS is allowed to keep copy-video so Apple + // devices can avoid a full video transcode for those files. + if req.SeekSeconds > 0 && strings.EqualFold(req.TargetCodecVideo, "copy") && !playback.IsMPEG2VideoCodec(file.CodecVideo) { + slog.Info("forcing video transcode for seeked copy request", + "playback_session_id", req.SessionID, + "seek_seconds", req.SeekSeconds, + "source_video_codec", file.CodecVideo, + "requested_target_codec_video", req.TargetCodecVideo, + "effective_target_codec_video", "h264", + ) + req.TargetCodecVideo = "h264" + } + + // 4K transcode guard: if source is 4K and allow_4k_transcode is disabled, + // switch to an alternate non-4K file version for transcoding. + // Skip the guard when target_codec_video is "copy" — no actual video + // encoding happens, so the 4K cost concern doesn't apply. + var switchedFileID *int + videoCopy := strings.EqualFold(req.TargetCodecVideo, "copy") + if file.Resolution == "2160p" && h.SettingsRepo != nil && !videoCopy { + allow4K, _ := h.SettingsRepo.Get(r.Context(), "allow_4k_transcode") + if allow4K != "true" { + alt, altErr := h.findAlternateFile(r.Context(), file) + if altErr != nil || alt == nil { + writeError(w, http.StatusUnprocessableEntity, "no_alternate_version", + "No lower resolution version available for transcoding") + return + } + file = alt + file = h.ensurePlaybackProbe(r.Context(), file) + switchedFileID = &alt.ID + } + } + if requestedFile != nil && file != nil && requestedFile.ID != file.ID { + if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) { + slog.Warn("requested transcode file preflight failed; continuing with alternate file", + "requested_file_id", requestedFile.ID, + "effective_file_id", file.ID, + "error", err, + ) + } + } + if err := preflightPlaybackFile(r.Context(), file, h.MissingMarker, h.EventsHub); err != nil { + if isPlaybackFileMissing(err) { + abortCurrentSession("preflight_file", err) + } + writePlaybackFilePreflightError(w, err) + return + } + + // Determine whether to run locally or forward to a remote transcode node. + tcNode := h.PickTranscodeNode(session.TranscodeNodeURL) + + if tcNode != nil { + // Remote transcode: forward to the assigned node. + if err := h.sessionMgr.SetTranscodeNodeURL(req.SessionID, tcNode.URL); err != nil { + slog.Error("set transcode node URL", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) + } + + nodeReq := transcodenode.TranscodeStartRequest{ + SessionID: req.SessionID, + InputPath: file.FilePath, + SourceVideoCodec: file.CodecVideo, + SeekSeconds: req.SeekSeconds, + StartSegmentNumber: computeStartSegment(req.SeekSeconds, req.SegmentDuration), + TargetResolution: req.TargetResolution, + TargetCodecVideo: req.TargetCodecVideo, + TargetCodecAudio: req.TargetCodecAudio, + TargetBitrateKbps: req.TargetBitrateKbps, + SegmentDuration: req.SegmentDuration, + HWAccel: h.HWAccel, + AudioTrackIndex: session.AudioTrackIndex, + SubtitleTrackIndex: req.SubtitleTrackIndex, + SubtitleBurnIn: req.SubtitleBurnIn, + TotalDuration: float64(file.Duration), + } + + body, _ := json.Marshal(nodeReq) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, tcNode.URL+"/transcode/start", bytes.NewReader(body)) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build transcode request") + return + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+h.JWTSecret) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + slog.Error("remote transcode start failed", "error", err, "node", tcNode.URL, "session", req.SessionID, "playback_session_id", req.SessionID) + writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable") + return + } + resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + slog.Error("remote transcode start rejected", "status", resp.StatusCode, "node", tcNode.URL) + writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request") + return + } + + manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL) + h.finalizeTranscodeStart(r, transcodeStartState{req: req, file: file, session: session, switchedFileID: switchedFileID}) + writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL)) + return + } + + // Local transcode (integrated mode — no transcode nodes available). + if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory") + return + } + + transcodeSession, err := playback.StartTranscode(context.WithoutCancel(r.Context()), playback.TranscodeOpts{ + InputPath: file.FilePath, + OutputDir: filepath.Join(h.TranscodeDir, req.SessionID), + SessionID: req.SessionID, + SourceVideoCodec: file.CodecVideo, + SeekSeconds: req.SeekSeconds, + StartSegmentNumber: computeStartSegment(req.SeekSeconds, req.SegmentDuration), + TargetResolution: req.TargetResolution, + TargetCodecVideo: req.TargetCodecVideo, + TargetCodecAudio: req.TargetCodecAudio, + TargetBitrateKbps: req.TargetBitrateKbps, + SegmentDuration: req.SegmentDuration, + FFmpegPath: h.FFmpegPath, + HWAccel: h.HWAccel, + HWDevice: h.HWDevice, + AudioTrackIndex: session.AudioTrackIndex, + SubtitleTrackIndex: req.SubtitleTrackIndex, + SubtitleBurnIn: req.SubtitleBurnIn, + TotalDuration: float64(file.Duration), + FastStart: true, + NodeType: "integrated", + ExecutionMode: "integrated", + FFmpegLogSink: h.FFmpegLogSink, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start transcode session") + return + } + + h.transcodeMu.Lock() + h.transcodes[req.SessionID] = transcodeSession + h.transcodeMu.Unlock() + + h.maybeStartThrottler(r.Context(), transcodeSession) + h.monitorLocalTranscodeExit(req.SessionID, transcodeSession) + + manifestURL := fmt.Sprintf("/playback/transcode/%s/master.m3u8", req.SessionID) + h.finalizeTranscodeStart(r, transcodeStartState{req: req, file: file, session: session, switchedFileID: switchedFileID}) + writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL)) +} + +// HandleGetTranscodeManifest handles GET /playback/transcode/{session_id}/master.m3u8. +// Auth is optional — the session UUID serves as an access token (same pattern +// as /stream/{session_id}). When auth context is present, ownership is verified. +// +// Known-duration sessions expose a synthetic full VOD manifest so the player +// can seek immediately. Copy-mode seeks that would start mid-GOP are forced to +// encoded HLS earlier in HandleStartTranscode; otherwise BuildPlaybackManifest +// still uses the same synthetic VOD path when the session duration is known. +func (h *PlaybackHandler) HandleGetTranscodeManifest(w http.ResponseWriter, r *http.Request) { + sessionID := chi.URLParam(r, "session_id") + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writePlaybackSessionNotFound(w) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session") + return + } + if userID := apimw.GetUserID(r.Context()); userID != 0 && session.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") + return + } + + transcodeSession := h.getTranscodeSession(sessionID) + if transcodeSession == nil { + // No local session — try proxying to remote transcode node. + if session.TranscodeNodeURL != "" { + h.touchSessionActivity(sessionID) + h.proxyToTranscodeNode(w, r, session.TranscodeNodeURL, + "/transcode/"+sessionID+"/master.m3u8") + return + } + writeError(w, http.StatusNotFound, "not_found", "Transcode session not found") + return + } + h.touchSessionActivity(sessionID) + + manifest, err := transcodeSession.BuildPlaybackManifest("segment/", r.URL.RawQuery) + if err != nil { + slog.Error("build transcode manifest", "error", err, "session", sessionID, "playback_session_id", sessionID) + writeError(w, http.StatusServiceUnavailable, "unavailable", "Transcode manifest not ready") + return + } + + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(manifest) +} + +// HandleGetTranscodeSegment handles GET /playback/transcode/{session_id}/segment/{name}. +// Auth is optional — the session UUID serves as an access token. +func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *http.Request) { + sessionID := chi.URLParam(r, "session_id") + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + writePlaybackSessionNotFound(w) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session") + return + } + if userID := apimw.GetUserID(r.Context()); userID != 0 && session.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") + return + } + + transcodeSession := h.getTranscodeSession(sessionID) + if transcodeSession == nil { + if session.TranscodeNodeURL != "" { + h.touchSessionActivity(sessionID) + segmentName := chi.URLParam(r, "name") + h.proxyToTranscodeNode(w, r, session.TranscodeNodeURL, + "/transcode/"+sessionID+"/segment/"+segmentName) + return + } + writeError(w, http.StatusNotFound, "not_found", "Transcode session not found") + return + } + h.touchSessionActivity(sessionID) + + segmentName := chi.URLParam(r, "name") + segmentPath, err := transcodeSession.GetSegment(segmentName) + if err != nil && errors.Is(err, playback.ErrSegmentNotFound) { + segNum, parseErr := playback.ParseSegmentNumber(segmentName) + if parseErr == nil { + now := time.Now() + decision := transcodeSession.SegmentRecoveryDecision(segNum, now) + lastProducedAgeMS := int64(-1) + if !decision.Progress.LastProducedAt.IsZero() { + lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds() + } + slog.Info("transcode segment missing", + "segment", segmentName, + "requested_segment", segNum, + "produced_head", decision.Progress.ProducedHead, + "last_requested_segment", decision.Progress.LastRequestedSegment, + "start_segment_number", decision.Progress.StartSegmentNumber, + "last_produced_age_ms", lastProducedAgeMS, + "wait_timeout_ms", decision.WaitTimeout.Milliseconds(), + "reason", decision.Reason, + "session", sessionID, + "playback_session_id", sessionID, + ) + if decision.Wait { + slog.Info("transcode segment wait", + "segment", segmentName, + "requested_segment", segNum, + "produced_head", decision.Progress.ProducedHead, + "last_requested_segment", decision.Progress.LastRequestedSegment, + "start_segment_number", decision.Progress.StartSegmentNumber, + "last_produced_age_ms", lastProducedAgeMS, + "wait_timeout_ms", decision.WaitTimeout.Milliseconds(), + "reason", decision.Reason, + "session", sessionID, + "playback_session_id", sessionID, + ) + segmentPath, err = transcodeSession.WaitForSegment(segmentName, decision.WaitTimeout) + if err != nil && errors.Is(err, playback.ErrSegmentNotFound) { + slog.Info("transcode segment wait timeout", + "segment", segmentName, + "requested_segment", segNum, + "produced_head", decision.Progress.ProducedHead, + "last_requested_segment", decision.Progress.LastRequestedSegment, + "start_segment_number", decision.Progress.StartSegmentNumber, + "last_produced_age_ms", lastProducedAgeMS, + "wait_timeout_ms", decision.WaitTimeout.Milliseconds(), + "reason", decision.Reason, + "session", sessionID, + "playback_session_id", sessionID, + ) + } + } + + // If the segment is still missing (timed out, or outside the + // active encode range), either restart at the exact manifest-derived + // timeline position or return 404 for copy-mode segments outside the + // current manifest window. + if err != nil && errors.Is(err, playback.ErrSegmentNotFound) { + seekSeconds, ok, seekErr := transcodeSession.RestartSeekTarget(segNum) + if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) { + slog.Error("resolve transcode seek target", "error", seekErr, "segment", segmentName, "session", sessionID, "playback_session_id", sessionID) + } + + if ok { + slog.Info("transcode seek restart", + "segment", segmentName, + "requested_segment", segNum, + "produced_head", decision.Progress.ProducedHead, + "last_requested_segment", decision.Progress.LastRequestedSegment, + "start_segment_number", decision.Progress.StartSegmentNumber, + "last_produced_age_ms", lastProducedAgeMS, + "wait_timeout_ms", decision.WaitTimeout.Milliseconds(), + "reason", decision.Reason, + "seek_seconds", seekSeconds, + "session", sessionID, + "playback_session_id", sessionID, + ) + if restartErr := transcodeSession.Restart( + context.WithoutCancel(r.Context()), + seekSeconds, + segNum, + ); restartErr == nil { + h.maybeStartThrottler(r.Context(), transcodeSession) + h.monitorLocalTranscodeExit(sessionID, transcodeSession) + segmentPath, err = transcodeSession.WaitForSegment(segmentName, 30*time.Second) + if err == nil && strings.EqualFold(transcodeSession.Opts().TargetCodecVideo, "copy") { + // Copy-mode seeks can resume as soon as the target segment + // exists, but that sometimes leaves the player one segment + // away from stalling while FFmpeg catches up. Briefly wait + // for a single lookahead fragment when available so the + // first resumed playback window is less brittle. + nextSegmentName := fmt.Sprintf("seg_%05d.m4s", segNum+1) + _, _ = transcodeSession.WaitForSegment(nextSegmentName, 1200*time.Millisecond) + } + } + } + } + } else if transcodeSession.IsRunning() { + // Non-numbered segment (e.g., init.mp4 for fMP4 HLS). + // Wait briefly — the init segment is written almost immediately. + segmentPath, err = transcodeSession.WaitForSegment(segmentName, 10*time.Second) + } + } + if err != nil { + if errors.Is(err, playback.ErrSegmentNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Segment not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load segment") + return + } + + // Report segment download for throttle tracking. + if segNum, parseErr := playback.ParseSegmentNumber(segmentName); parseErr == nil { + transcodeSession.ReportSegmentDownloaded(segNum) + } + + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Header().Set("Pragma", "no-cache") + http.ServeFile(w, r, segmentPath) +} + +func (h *PlaybackHandler) getTranscodeSession(sessionID string) *playback.TranscodeSession { + h.transcodeMu.RLock() + defer h.transcodeMu.RUnlock() + return h.transcodes[sessionID] +} + +func (h *PlaybackHandler) monitorLocalTranscodeExit(sessionID string, session *playback.TranscodeSession) { + if h == nil || sessionID == "" || session == nil { + return + } + + done := session.Done() + if done == nil { + return + } + + go func() { + <-done + time.Sleep(2 * time.Second) + + h.transcodeMu.RLock() + current := h.transcodes[sessionID] + h.transcodeMu.RUnlock() + if current != session { + return + } + if session.IsRunning() { + return + } + + // When ffmpeg exits cleanly (no error), the segments are fully + // written and should remain servable until the client stops the + // session. This is critical for copy-mode where ffmpeg finishes + // writing all content much faster than real-time playback. Only + // tear down the session on error exits. + if session.WaitError() == nil { + return + } + + if err := h.stopPlaybackSessionByID(context.Background(), sessionID); err != nil && !errors.Is(err, playback.ErrSessionNotFound) { + slog.Error("failed to stop playback after local transcode exit", "session", sessionID, "error", err, "playback_session_id", sessionID) + } + }() +} + +// closeTranscodeSession stops a transcode session. If transcodeNodeURL is non-empty, +// sends DELETE to the remote transcode node. Otherwise closes the local session. +func (h *PlaybackHandler) closeTranscodeSession(sessionID, transcodeNodeURL string) { + // Clean up local session if one exists (defensive). + h.transcodeMu.Lock() + session := h.transcodes[sessionID] + delete(h.transcodes, sessionID) + h.transcodeMu.Unlock() + if session != nil { + _ = session.Close() + } + + // Send DELETE to remote transcode node if assigned (synchronous with timeout). + if transcodeNodeURL != "" { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + deleteURL := transcodeNodeURL + "/transcode/" + sessionID + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, deleteURL, nil) + if err != nil { + slog.Error("remote transcode delete: build request", "error", err, "session", sessionID, "playback_session_id", sessionID) + return + } + req.Header.Set("Authorization", "Bearer "+h.JWTSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Warn("remote transcode delete failed", "error", err, "session", sessionID, "node", transcodeNodeURL, "playback_session_id", sessionID) + return + } + resp.Body.Close() + } +} + +// PickTranscodeNode selects a transcode node with soft affinity. +// If currentURL is set and that node is healthy, reuse it unless another node +// has significantly fewer active jobs (2+ difference). +func (h *PlaybackHandler) PickTranscodeNode(currentURL string) *nodepool.Node { + if h.TranscodePool == nil { + return nil + } + best := h.TranscodePool.Acquire() + if best == nil { + return nil + } + if currentURL == "" || best.URL == currentURL { + return best + } + // Reuse current node unless the best alternative has significantly fewer jobs. + current := h.TranscodePool.FindByURL(currentURL) + if current != nil && current.Healthy && current.Enabled && + best.ActiveJobs+2 <= current.ActiveJobs { + return best // switch to less-loaded node + } + if current != nil && current.Healthy && current.Enabled { + return current // soft affinity — stay on current + } + return best // current node is gone or unhealthy +} + +// buildProxyManifestURL signs a stream token and builds the proxy-based manifest URL. +func (h *PlaybackHandler) buildProxyManifestURL(sessionID string, session *playback.Session, transcodeNodeURL string) string { + if h.ProxyPool == nil { + return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID) + } + proxyNode := h.ProxyPool.Pick() + if proxyNode == nil { + // No proxy — fall back to API-local path. + return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID) + } + + tokenClaims := streamtoken.Claims{ + SessionID: sessionID, + PlayMethod: string(playback.PlayTranscode), + TranscodeNode: transcodeNodeURL, + } + if session != nil { + tokenClaims.AudioTrackIndex = session.AudioTrackIndex + } + + token, err := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour) + if err != nil { + return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID) + } + + return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8" +} + +// proxyToTranscodeNode forwards a request to the remote transcode node. +func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, transcodeNodeURL, path string) { + sessionID := chi.URLParam(r, "session_id") + targetURL := transcodeNodeURL + path + if rawQuery := r.URL.RawQuery; rawQuery != "" { + targetURL += "?" + rawQuery + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL, nil) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + req.Header.Set("Authorization", "Bearer "+h.JWTSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Error("proxy to transcode node", "error", err, "url", targetURL, "playback_session_id", sessionID) + http.Error(w, "transcode node unavailable", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + for k, vv := range resp.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +// maybeStartThrottler reads throttle settings and starts the throttler if enabled. +func (h *PlaybackHandler) maybeStartThrottler(ctx context.Context, session *playback.TranscodeSession) { + if h.SettingsRepo == nil { + return + } + enableThrottle, _ := h.SettingsRepo.Get(ctx, "enable_transcode_throttle") + if enableThrottle != "true" { + return + } + thresholdStr, _ := h.SettingsRepo.Get(ctx, "transcode_throttle_seconds") + threshold := 300 // default + if v, err := strconv.Atoi(thresholdStr); err == nil && v > 0 { + threshold = v + } + session.StartThrottler(threshold) +} + +// findAlternateFile finds a non-4K file version for the same content. +// Prefers SDR over HDR, then highest resolution, then highest bitrate. +func (h *PlaybackHandler) findAlternateFile(ctx context.Context, source *models.MediaFile) (*models.MediaFile, error) { + if h.FileVersionFetcher == nil { + return nil, fmt.Errorf("file version fetcher not configured") + } + + var files []*models.MediaFile + var err error + if source.EpisodeID != "" { + files, err = h.FileVersionFetcher.GetByEpisodeID(ctx, source.EpisodeID) + } else { + files, err = h.FileVersionFetcher.GetByContentID(ctx, source.ContentID) + } + if err != nil { + return nil, err + } + + // Filter to non-4K candidates. + candidates := make([]*models.MediaFile, 0, len(files)) + for _, f := range files { + if f.ID == source.ID || f.Resolution == "2160p" { + continue + } + if source.EditionKey != "" && f.EditionKey != source.EditionKey { + continue + } + if source.EditionKey == "" && f.EditionKey != "" { + continue + } + if source.PresentationGroupKey != "" && f.PresentationGroupKey != "" && f.PresentationGroupKey != source.PresentationGroupKey { + continue + } + if source.PresentationKind != "" && f.PresentationKind != "" && f.PresentationKind != source.PresentationKind { + continue + } + candidates = append(candidates, f) + } + if len(candidates) == 0 { + return nil, nil + } + + // Sort: SDR before HDR, then highest resolution, then highest bitrate. + sort.Slice(candidates, func(i, j int) bool { + a, b := candidates[i], candidates[j] + // Prefer SDR over HDR (SDR = !HDR, so !HDR < HDR means SDR first). + if a.HDR != b.HDR { + return !a.HDR + } + aRes := resolutionRank(a.Resolution) + bRes := resolutionRank(b.Resolution) + if aRes != bRes { + return aRes > bRes + } + return a.Bitrate > b.Bitrate + }) + + return candidates[0], nil +} + +// resolutionRank returns a numeric rank for resolution sorting. +func resolutionRank(res string) int { + switch res { + case "2160p": + return 4 + case "1080p": + return 3 + case "720p": + return 2 + case "480p": + return 1 + case "328p": + return 0 + default: + return 0 + } +} diff --git a/internal/api/handlers/playback_admin_store.go b/internal/api/handlers/playback_admin_store.go new file mode 100644 index 00000000..bd978760 --- /dev/null +++ b/internal/api/handlers/playback_admin_store.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + evt "github.com/Silo-Server/silo-server/internal/events" +) + +// AdminPlaybackHistoryEntry is the finalized admin-facing record for a +// playback attempt. +type AdminPlaybackHistoryEntry struct { + SessionID string + UserID int + ProfileID string + ProfileName string + MediaItemID string + MediaFileID int + PlayMethod string + StartedAt string + EndedAt string + WatchedSeconds float64 + DurationSeconds *float64 + Completed bool + ClientIP string +} + +// PlaybackAdminStore persists finalized playback history and manages the +// shared active-session sync rows used by admin monitoring. +type PlaybackAdminStore interface { + RecordHistory(ctx context.Context, entry AdminPlaybackHistoryEntry) error + DeleteSession(ctx context.Context, sessionID string) error +} + +// PGPlaybackAdminStore stores admin playback data in PostgreSQL. +type PGPlaybackAdminStore struct { + pool *pgxpool.Pool + eventsHub *evt.Hub +} + +// NewPGPlaybackAdminStore creates a PostgreSQL-backed admin playback store. +func NewPGPlaybackAdminStore(pool *pgxpool.Pool, eventsHub *evt.Hub) *PGPlaybackAdminStore { + return &PGPlaybackAdminStore{pool: pool, eventsHub: eventsHub} +} + +func (s *PGPlaybackAdminStore) RecordHistory(ctx context.Context, entry AdminPlaybackHistoryEntry) error { + if s == nil || s.pool == nil { + return nil + } + + var duration any + if entry.DurationSeconds != nil { + duration = *entry.DurationSeconds + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO playback_history_admin + (session_id, user_id, profile_id, profile_name, media_item_id, media_file_id, + play_method, started_at, ended_at, watched_seconds, duration_seconds, completed, client_ip) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::inet) + ON CONFLICT (session_id) DO NOTHING + `, + entry.SessionID, + entry.UserID, + entry.ProfileID, + entry.ProfileName, + entry.MediaItemID, + entry.MediaFileID, + entry.PlayMethod, + entry.StartedAt, + entry.EndedAt, + entry.WatchedSeconds, + duration, + entry.Completed, + nullableIP(entry.ClientIP), + ) + if err != nil { + return fmt.Errorf("recording playback history: %w", err) + } + + return nil +} + +func (s *PGPlaybackAdminStore) DeleteSession(ctx context.Context, sessionID string) error { + if s == nil || s.pool == nil { + return nil + } + + tag, err := s.pool.Exec(ctx, `DELETE FROM playback_sessions_sync WHERE session_id = $1`, sessionID) + if err != nil { + return fmt.Errorf("deleting synced playback session: %w", err) + } + if tag.RowsAffected() > 0 && s.eventsHub != nil { + if err := s.eventsHub.PublishJSON( + ctx, + evt.ChannelSessions, + "sessions.replaced", + nil, + evt.PublishOptions{AdminOnly: true}, + ); err != nil { + return fmt.Errorf("publishing session change: %w", err) + } + } + + return nil +} + +// nullableIP returns nil for empty IP strings so PostgreSQL receives NULL +// instead of an invalid inet value. +func nullableIP(ip string) interface{} { + if ip == "" { + return nil + } + return ip +} diff --git a/internal/api/handlers/playback_lazy_markers.go b/internal/api/handlers/playback_lazy_markers.go new file mode 100644 index 00000000..d294ee3d --- /dev/null +++ b/internal/api/handlers/playback_lazy_markers.go @@ -0,0 +1,267 @@ +package handlers + +import ( + "context" + "log/slog" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/markers" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/playback" +) + +const playbackLazyMarkerTimeout = 10 * time.Minute + +type PlaybackIntroEligibilityChecker interface { + IntroDetectionEligibleForPlayback(ctx context.Context, fileID int) (bool, error) +} + +type PlaybackMarkerUpdateNotifier interface { + MarkersUpdated(ctx context.Context, file *models.MediaFile) +} + +func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( + ctx context.Context, + session *playback.Session, + file *models.MediaFile, +) { + if h == nil || session == nil || file == nil || file.ID <= 0 { + return + } + if file.IntroStart != nil && file.IntroEnd != nil { + return + } + if strings.TrimSpace(file.EpisodeID) == "" || file.MediaFolderID <= 0 { + return + } + if h.SettingsRepo == nil || h.IntroRepository == nil { + return + } + + lazy, err := h.SettingsRepo.Get(ctx, markers.SettingLazyPlayback) + if err != nil { + slog.Warn("playback lazy markers: load lazy setting failed", + "session_id", session.ID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "error", err) + return + } + if strings.ToLower(strings.TrimSpace(lazy)) != "true" { + return + } + + mode := markers.ModeLocal + rawMode, err := h.SettingsRepo.Get(ctx, markers.SettingMode) + if err != nil { + slog.Warn("playback lazy markers: load marker mode failed", + "session_id", session.ID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "error", err) + return + } + mode = markers.NormalizeMode(rawMode) + if mode == markers.ModeOff { + slog.Debug("playback lazy markers: skipped; marker mode is off", + "session_id", session.ID, + "file_id", file.ID, + "episode_id", file.EpisodeID) + return + } + + eligible, err := h.IntroRepository.IntroDetectionEligibleForPlayback(ctx, file.ID) + if err != nil { + slog.Warn("playback lazy markers: eligibility check failed", + "session_id", session.ID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode, + "error", err) + return + } + if !eligible { + return + } + + hasOnline := h.hasOnlineMarkerProviders() + shouldRunLocal := markers.ShouldRunLocal(mode) + if mode == markers.ModeOnline && !hasOnline { + slog.Debug("playback lazy markers: skipped; online-only mode has no providers", + "session_id", session.ID, + "file_id", file.ID, + "episode_id", file.EpisodeID) + return + } + if !shouldRunLocal && mode != markers.ModeOnline { + slog.Debug("playback lazy markers: skipped; marker mode does not allow playback detection", + "session_id", session.ID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode) + return + } + if shouldRunLocal && h.IntroAnalyzer == nil { + slog.Warn("playback lazy markers: local analyzer unavailable", + "session_id", session.ID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode) + return + } + + if _, loaded := h.MarkerLazyInFlight.LoadOrStore(file.ID, struct{}{}); loaded { + return + } + + sessionID := session.ID + fileSnapshot := *file + slog.Info("playback lazy markers: queued", + "session_id", sessionID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode) + go h.runLazyPlaybackMarkers(sessionID, &fileSnapshot, mode) +} + +func (h *PlaybackHandler) runLazyPlaybackMarkers(sessionID string, file *models.MediaFile, mode markers.Mode) { + if file == nil { + return + } + defer h.MarkerLazyInFlight.Delete(file.ID) + + base := h.MarkerLazyContext + if base == nil { + base = context.Background() + } + ctx, cancel := context.WithTimeout(base, playbackLazyMarkerTimeout) + defer cancel() + + slog.Info("playback lazy markers: started", + "session_id", sessionID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode) + + hasOnline := h.hasOnlineMarkerProviders() + if (mode == markers.ModeOnline || mode == markers.ModeBoth) && hasOnline { + wrote, err := h.fetchOnlineMarkersForPlayback(ctx, file) + if err != nil { + slog.Warn("playback lazy markers: online fetch failed", + "session_id", sessionID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode, + "error", err) + } + if wrote { + if refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID); hasIntroMarker(refreshed) { + h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode) + return + } + } + } + + refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID) + if hasIntroMarker(refreshed) { + h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode) + return + } + + if markers.ShouldRunLocal(mode) { + if h.IntroAnalyzer == nil { + slog.Warn("playback lazy markers: local analyzer unavailable", + "session_id", sessionID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode) + return + } + slog.Info("playback lazy markers: local analyzer started", + "session_id", sessionID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode) + summary, err := h.IntroAnalyzer.AnalyzeEpisode(ctx, file.EpisodeID) + if err != nil { + slog.Warn("playback lazy markers: local analyzer failed", + "session_id", sessionID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode, + "error", err) + return + } + slog.Info("playback lazy markers: local analyzer finished", + "session_id", sessionID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode, + "files_considered", summary.FilesConsidered, + "season_groups_considered", summary.SeasonGroupsConsidered, + "chapter_markers_written", summary.ChapterMarkersWritten, + "chromaprint_markers_written", summary.ChromaprintMarkersWritten, + "fingerprint_cache_hits", summary.FingerprintCacheHits, + "fingerprints_computed", summary.FingerprintsComputed, + "errors", len(summary.Errors)) + + refreshed = h.reloadPlaybackMarkerFile(ctx, file.ID) + if hasIntroMarker(refreshed) { + h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode) + } + } +} + +func (h *PlaybackHandler) hasOnlineMarkerProviders() bool { + return h != nil && h.MarkerRegistry != nil && len(h.MarkerRegistry.Providers()) > 0 +} + +func (h *PlaybackHandler) fetchOnlineMarkersForPlayback(ctx context.Context, file *models.MediaFile) (bool, error) { + if h == nil || file == nil || !h.hasOnlineMarkerProviders() { + return false, nil + } + + // The provider abstraction exists, but this repo does not yet wire durable + // external IDs into playback file state. Keep online playback fetch as a + // structured no-op until a marker provider contract can be supplied safely. + slog.Debug("playback lazy markers: online fetch skipped; provider request identity unavailable", + "file_id", file.ID, + "episode_id", file.EpisodeID, + "season_number", file.SeasonNumber, + "episode_number", file.EpisodeNumber) + return false, nil +} + +func (h *PlaybackHandler) reloadPlaybackMarkerFile(ctx context.Context, fileID int) *models.MediaFile { + if h == nil || h.fileResolver == nil || fileID <= 0 { + return nil + } + refreshed, err := h.fileResolver.GetByID(ctx, fileID) + if err != nil { + slog.Warn("playback lazy markers: reload file failed", "file_id", fileID, "error", err) + return nil + } + return refreshed +} + +func (h *PlaybackHandler) notifyPlaybackMarkers( + ctx context.Context, + sessionID string, + file *models.MediaFile, + mode markers.Mode, +) { + if h == nil || h.MarkerUpdateNotifier == nil || file == nil { + return + } + h.MarkerUpdateNotifier.MarkersUpdated(ctx, file) + slog.Info("playback lazy markers: emitted marker update", + "session_id", sessionID, + "file_id", file.ID, + "episode_id", file.EpisodeID, + "mode", mode) +} + +func hasIntroMarker(file *models.MediaFile) bool { + return file != nil && file.IntroStart != nil && file.IntroEnd != nil +} diff --git a/internal/api/handlers/playback_lazy_markers_test.go b/internal/api/handlers/playback_lazy_markers_test.go new file mode 100644 index 00000000..4eb09940 --- /dev/null +++ b/internal/api/handlers/playback_lazy_markers_test.go @@ -0,0 +1,349 @@ +package handlers + +import ( + "context" + "log/slog" + "sync" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/intromarkers" + "github.com/Silo-Server/silo-server/internal/markers" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/playback" +) + +type fakePlaybackMarkerProvider struct{} + +func (fakePlaybackMarkerProvider) ID() string { return "fake-online" } + +func (fakePlaybackMarkerProvider) FetchMarkers(context.Context, markers.Request) (markers.Result, error) { + return markers.Result{}, nil +} + +type fakePlaybackIntroAnalyzer struct { + mu sync.Mutex + calls int + started chan struct{} + release chan struct{} + onCall func() + summary intromarkers.RunSummary + err error +} + +func (a *fakePlaybackIntroAnalyzer) AnalyzeEpisode(context.Context, string) (intromarkers.RunSummary, error) { + a.mu.Lock() + a.calls++ + a.mu.Unlock() + if a.onCall != nil { + a.onCall() + } + if a.started != nil { + select { + case a.started <- struct{}{}: + default: + } + } + if a.release != nil { + <-a.release + } + summary := a.summary + if summary.FilesConsidered == 0 { + summary = intromarkers.RunSummary{FilesConsidered: 1, ChapterMarkersWritten: 1} + } + return summary, a.err +} + +func (a *fakePlaybackIntroAnalyzer) callCount() int { + a.mu.Lock() + defer a.mu.Unlock() + return a.calls +} + +type fakePlaybackIntroEligibility struct { + eligible bool + err error +} + +func (e fakePlaybackIntroEligibility) IntroDetectionEligibleForPlayback(context.Context, int) (bool, error) { + return e.eligible, e.err +} + +type fakePlaybackMarkerFileResolver struct { + mu sync.Mutex + file *models.MediaFile +} + +func (r *fakePlaybackMarkerFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.file == nil { + return nil, nil + } + cp := *r.file + return &cp, nil +} + +func (r *fakePlaybackMarkerFileResolver) setFile(file *models.MediaFile) { + r.mu.Lock() + r.file = file + r.mu.Unlock() +} + +type fakePlaybackMarkerNotifier struct { + ch chan *models.MediaFile +} + +func (n fakePlaybackMarkerNotifier) MarkersUpdated(_ context.Context, file *models.MediaFile) { + if n.ch == nil { + return + } + n.ch <- file +} + +func TestMaybeQueueLazyPlaybackMarkersGates(t *testing.T) { + tests := []struct { + name string + lazy string + mode string + file *models.MediaFile + eligible bool + }{ + { + name: "lazy disabled", + lazy: "false", + mode: "local", + file: lazyMarkerTestFile(), + eligible: true, + }, + { + name: "mode off", + lazy: "true", + mode: "off", + file: lazyMarkerTestFile(), + eligible: true, + }, + { + name: "online mode without providers", + lazy: "true", + mode: "online", + file: lazyMarkerTestFile(), + eligible: true, + }, + { + name: "intro already present", + lazy: "true", + mode: "local", + file: func() *models.MediaFile { + file := lazyMarkerTestFile() + start := 10.0 + end := 60.0 + file.IntroStart = &start + file.IntroEnd = &end + return file + }(), + eligible: true, + }, + { + name: "missing episode id", + lazy: "true", + mode: "local", + file: func() *models.MediaFile { + file := lazyMarkerTestFile() + file.EpisodeID = "" + return file + }(), + eligible: true, + }, + { + name: "library ineligible", + lazy: "true", + mode: "local", + file: lazyMarkerTestFile(), + eligible: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + analyzer := &fakePlaybackIntroAnalyzer{started: make(chan struct{}, 1)} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), &fakePlaybackMarkerFileResolver{file: tt.file}) + handler.SettingsRepo = testPlaybackSettingsRepo{values: map[string]string{ + markers.SettingLazyPlayback: tt.lazy, + markers.SettingMode: tt.mode, + }} + handler.IntroRepository = fakePlaybackIntroEligibility{eligible: tt.eligible} + handler.IntroAnalyzer = analyzer + handler.MarkerLazyContext = context.Background() + + handler.maybeQueueLazyPlaybackMarkers(context.Background(), &playback.Session{ID: "session-1"}, tt.file) + + select { + case <-analyzer.started: + t.Fatal("AnalyzeEpisode started, want gated") + case <-time.After(25 * time.Millisecond): + } + if got := analyzer.callCount(); got != 0 { + t.Fatalf("AnalyzeEpisode calls = %d, want 0", got) + } + }) + } +} + +func TestMaybeQueueLazyPlaybackMarkersLocalModeRunsAnalyzerAndEmitsUpdate(t *testing.T) { + file := lazyMarkerTestFile() + resolver := &fakePlaybackMarkerFileResolver{file: file} + start := 12.0 + end := 75.0 + analyzer := &fakePlaybackIntroAnalyzer{ + started: make(chan struct{}, 1), + onCall: func() { + updated := *file + updated.IntroStart = &start + updated.IntroEnd = &end + resolver.setFile(&updated) + }, + } + notifier := fakePlaybackMarkerNotifier{ch: make(chan *models.MediaFile, 1)} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), resolver) + handler.SettingsRepo = testPlaybackSettingsRepo{values: map[string]string{ + markers.SettingLazyPlayback: "true", + markers.SettingMode: "local", + }} + handler.IntroRepository = fakePlaybackIntroEligibility{eligible: true} + handler.IntroAnalyzer = analyzer + handler.MarkerUpdateNotifier = notifier + handler.MarkerLazyContext = context.Background() + + handler.maybeQueueLazyPlaybackMarkers(context.Background(), &playback.Session{ID: "session-1"}, file) + + select { + case <-analyzer.started: + case <-time.After(time.Second): + t.Fatal("AnalyzeEpisode did not start") + } + + select { + case notified := <-notifier.ch: + if notified.ID != file.ID { + t.Fatalf("notified file ID = %d, want %d", notified.ID, file.ID) + } + if notified.IntroStart == nil || notified.IntroEnd == nil { + t.Fatalf("notified file missing intro marker: %#v", notified) + } + case <-time.After(time.Second): + t.Fatal("marker update was not emitted") + } + if got := analyzer.callCount(); got != 1 { + t.Fatalf("AnalyzeEpisode calls = %d, want 1", got) + } +} + +func TestMaybeQueueLazyPlaybackMarkersBothModeFallsBackToLocalWithoutProviders(t *testing.T) { + analyzer := &fakePlaybackIntroAnalyzer{started: make(chan struct{}, 1)} + file := lazyMarkerTestFile() + handler := newLazyMarkerTestHandler(file, analyzer, nil) + handler.SettingsRepo = testPlaybackSettingsRepo{values: map[string]string{ + markers.SettingLazyPlayback: "true", + markers.SettingMode: "both", + }} + + handler.maybeQueueLazyPlaybackMarkers(context.Background(), &playback.Session{ID: "session-1"}, file) + + select { + case <-analyzer.started: + case <-time.After(time.Second): + t.Fatal("AnalyzeEpisode did not start") + } + if got := analyzer.callCount(); got != 1 { + t.Fatalf("AnalyzeEpisode calls = %d, want 1", got) + } +} + +func TestMaybeQueueLazyPlaybackMarkersDedupesInFlightFile(t *testing.T) { + analyzer := &fakePlaybackIntroAnalyzer{started: make(chan struct{}, 1), release: make(chan struct{})} + file := lazyMarkerTestFile() + handler := newLazyMarkerTestHandler(file, analyzer, nil) + + session := &playback.Session{ID: "session-1"} + handler.maybeQueueLazyPlaybackMarkers(context.Background(), session, file) + handler.maybeQueueLazyPlaybackMarkers(context.Background(), session, file) + time.Sleep(25 * time.Millisecond) + + if got := analyzer.callCount(); got != 1 { + t.Fatalf("AnalyzeEpisode calls = %d, want 1", got) + } + close(analyzer.release) +} + +func TestMaybeQueueLazyPlaybackMarkersOnlineModeWithProviderDoesNotRunLocalAnalyzer(t *testing.T) { + analyzer := &fakePlaybackIntroAnalyzer{started: make(chan struct{}, 1)} + file := lazyMarkerTestFile() + handler := newLazyMarkerTestHandler(file, analyzer, nil) + registry := markers.NewRegistry(slog.Default()) + if err := registry.Register(fakePlaybackMarkerProvider{}); err != nil { + t.Fatalf("register provider: %v", err) + } + handler.MarkerRegistry = registry + handler.SettingsRepo = testPlaybackSettingsRepo{values: map[string]string{ + markers.SettingLazyPlayback: "true", + markers.SettingMode: "online", + }} + + handler.maybeQueueLazyPlaybackMarkers(context.Background(), &playback.Session{ID: "session-1"}, file) + + select { + case <-analyzer.started: + t.Fatal("AnalyzeEpisode started, want online-only provider mode to skip local analyzer") + case <-time.After(25 * time.Millisecond): + } + if got := analyzer.callCount(); got != 0 { + t.Fatalf("AnalyzeEpisode calls = %d, want 0", got) + } +} + +func TestMaybeQueueLazyPlaybackMarkersAnalyzerSuccessWithoutMarkerDoesNotEmitUpdate(t *testing.T) { + analyzer := &fakePlaybackIntroAnalyzer{started: make(chan struct{}, 1)} + file := lazyMarkerTestFile() + notifier := fakePlaybackMarkerNotifier{ch: make(chan *models.MediaFile, 1)} + handler := newLazyMarkerTestHandler(file, analyzer, notifier) + + handler.maybeQueueLazyPlaybackMarkers(context.Background(), &playback.Session{ID: "session-1"}, file) + + select { + case <-analyzer.started: + case <-time.After(time.Second): + t.Fatal("AnalyzeEpisode did not start") + } + select { + case notified := <-notifier.ch: + t.Fatalf("unexpected marker update: %#v", notified) + case <-time.After(25 * time.Millisecond): + } +} + +func newLazyMarkerTestHandler( + file *models.MediaFile, + analyzer *fakePlaybackIntroAnalyzer, + notifier PlaybackMarkerUpdateNotifier, +) *PlaybackHandler { + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), &fakePlaybackMarkerFileResolver{file: file}) + handler.SettingsRepo = testPlaybackSettingsRepo{values: map[string]string{ + markers.SettingLazyPlayback: "true", + markers.SettingMode: "local", + }} + handler.IntroRepository = fakePlaybackIntroEligibility{eligible: true} + handler.IntroAnalyzer = analyzer + handler.MarkerUpdateNotifier = notifier + handler.MarkerLazyContext = context.Background() + return handler +} + +func lazyMarkerTestFile() *models.MediaFile { + return &models.MediaFile{ + ID: 42, + EpisodeID: "episode-1", + MediaFolderID: 7, + Duration: 1800, + } +} diff --git a/internal/api/handlers/playback_log_context.go b/internal/api/handlers/playback_log_context.go new file mode 100644 index 00000000..96dacd3c --- /dev/null +++ b/internal/api/handlers/playback_log_context.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "net/http" + + "github.com/Silo-Server/silo-server/internal/activitylog" +) + +func setPlaybackSessionLogContext(r *http.Request, sessionID string) { + if sessionID == "" { + return + } + if lc := activitylog.GetPlaybackLogContext(r.Context()); lc != nil { + lc.PlaybackSessionID = sessionID + } +} diff --git a/internal/api/handlers/playback_missing.go b/internal/api/handlers/playback_missing.go new file mode 100644 index 00000000..44690c82 --- /dev/null +++ b/internal/api/handlers/playback_missing.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "time" + + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/scanner" +) + +// MissingFileMarker marks media files as missing when playback discovers they +// no longer exist on disk. +type MissingFileMarker interface { + MarkMissing(ctx context.Context, id int, since time.Time) error +} + +type playbackFilePreflightError struct { + notFound bool + err error +} + +func (e *playbackFilePreflightError) Error() string { + if e == nil { + return "" + } + if e.notFound { + return "source media file is missing" + } + if e.err != nil { + return fmt.Sprintf("failed to access media file: %v", e.err) + } + return "failed to access media file" +} + +func (e *playbackFilePreflightError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +func isPlaybackFileMissing(err error) bool { + var target *playbackFilePreflightError + return errors.As(err, &target) && target.notFound +} + +func isPlaybackFileLookupMissing(err error) bool { + return errors.Is(err, scanner.ErrFileNotFound) || errors.Is(err, playback.ErrFileNotFound) +} + +func writePlaybackFilePreflightError(w http.ResponseWriter, err error) { + if isPlaybackFileMissing(err) { + writeError(w, http.StatusNotFound, "not_found", "Source media file is missing") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access source media file") +} + +const playbackSessionNotFoundErrorCode = "playback_session_not_found" + +func writePlaybackSessionNotFound(w http.ResponseWriter) { + writeError(w, http.StatusNotFound, playbackSessionNotFoundErrorCode, "Playback session not found") +} + +func preflightPlaybackFile( + ctx context.Context, + file *models.MediaFile, + marker MissingFileMarker, + eventsHub *evt.Hub, +) error { + if file == nil { + return &playbackFilePreflightError{notFound: true} + } + if file.MissingSince != nil { + return &playbackFilePreflightError{notFound: true} + } + + if _, err := os.Stat(file.FilePath); err != nil { + if os.IsNotExist(err) { + markPlaybackFileMissing(ctx, file, marker, eventsHub) + return &playbackFilePreflightError{notFound: true, err: err} + } + return &playbackFilePreflightError{err: err} + } + + return nil +} + +func markPlaybackFileMissing( + ctx context.Context, + file *models.MediaFile, + marker MissingFileMarker, + eventsHub *evt.Hub, +) { + if file == nil { + return + } + + since := time.Now().UTC() + file.MissingSince = &since + + if marker != nil { + if err := marker.MarkMissing(ctx, file.ID, since); err != nil { + slog.Warn("failed to mark playback file missing", + "file_id", file.ID, + "path", file.FilePath, + "error", err, + ) + } + } + + contentID := playbackProgressTarget(file) + if contentID != "" && eventsHub != nil { + publishEventMetadataUpdate(ctx, eventsHub, file.MediaFolderID, contentID) + } +} diff --git a/internal/api/handlers/playback_realtime.go b/internal/api/handlers/playback_realtime.go new file mode 100644 index 00000000..c4a990b5 --- /dev/null +++ b/internal/api/handlers/playback_realtime.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "context" + "errors" + "log/slog" + + "github.com/Silo-Server/silo-server/internal/playback" +) + +type playbackCommandRecord struct { + SessionID string + Name playback.CommandName +} + +func (h *PlaybackHandler) stopPlaybackSession(ctx context.Context, session *playback.Session) error { + if h == nil || session == nil || session.ID == "" { + return playback.ErrSessionNotFound + } + + if err := h.sessionMgr.StopSession(session.ID); err != nil { + return err + } + h.finalizeSessionStop(ctx, session, true, "stop") + return nil +} + +func (h *PlaybackHandler) stopPlaybackSessionByID(ctx context.Context, sessionID string) error { + if h == nil || sessionID == "" { + return playback.ErrSessionNotFound + } + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + return err + } + return h.stopPlaybackSession(ctx, session) +} + +func (h *PlaybackHandler) abortPlaybackSession(ctx context.Context, session *playback.Session) error { + if h == nil || session == nil || session.ID == "" { + return playback.ErrSessionNotFound + } + + if err := h.sessionMgr.StopSession(session.ID); err != nil { + return err + } + h.finalizeSessionAbort(ctx, session, true, "abort") + return nil +} + +func (h *PlaybackHandler) abortPlaybackSessionByID(ctx context.Context, sessionID string) error { + if h == nil || sessionID == "" { + return playback.ErrSessionNotFound + } + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + return err + } + return h.abortPlaybackSession(ctx, session) +} + +func (h *PlaybackHandler) rememberRealtimeCommand(commandID, sessionID string, name playback.CommandName) { + if h == nil || commandID == "" || sessionID == "" { + return + } + + h.realtimeCommandMu.Lock() + h.realtimeCommands[commandID] = playbackCommandRecord{ + SessionID: sessionID, + Name: name, + } + h.realtimeCommandMu.Unlock() +} + +func (h *PlaybackHandler) forgetRealtimeCommand(commandID string) { + if h == nil || commandID == "" { + return + } + + h.realtimeCommandMu.Lock() + delete(h.realtimeCommands, commandID) + h.realtimeCommandMu.Unlock() +} + +func (h *PlaybackHandler) getRealtimeCommand(commandID string) (playbackCommandRecord, bool) { + if h == nil || commandID == "" { + return playbackCommandRecord{}, false + } + + h.realtimeCommandMu.Lock() + record, ok := h.realtimeCommands[commandID] + h.realtimeCommandMu.Unlock() + return record, ok +} + +func (h *PlaybackHandler) setRealtimeConnectionState(sessionID string, connected bool) bool { + if h == nil || sessionID == "" { + return false + } + + type realtimeStateSetter interface { + SetRealtimeConnection(sessionID string, connected bool) error + } + + mgr, ok := h.sessionMgr.(realtimeStateSetter) + if !ok { + return false + } + + session, err := h.sessionMgr.GetSession(sessionID) + if err == nil && session != nil && session.HasRealtimeConnection == connected { + return false + } + + if err := mgr.SetRealtimeConnection(sessionID, connected); err != nil { + if errors.Is(err, playback.ErrSessionNotFound) { + return false + } + slog.Warn("failed to update realtime connection state", "session", sessionID, "connected", connected, "error", err) + return false + } + return true +} diff --git a/internal/api/handlers/playback_session_missing_test.go b/internal/api/handlers/playback_session_missing_test.go new file mode 100644 index 00000000..64c5817d --- /dev/null +++ b/internal/api/handlers/playback_session_missing_test.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/playback" +) + +func TestPlaybackSessionMissingResponsesUseStableErrorCode(t *testing.T) { + const missingSessionID = "missing-session" + + sessionMgr := playback.NewSessionManager(0, 0) + playbackHandler := NewPlaybackHandler(sessionMgr) + playbackHandler.RealtimeHub = playback.NewRealtimeHub() + streamHandler := NewStreamHandler(sessionMgr, testPlaybackFileResolver{}) + + tests := []struct { + name string + request func() *http.Request + handle func(http.ResponseWriter, *http.Request) + }{ + { + name: "progress", + request: func() *http.Request { + return playbackTestRequest( + http.MethodPost, + "/api/v1/playback/"+missingSessionID+"/progress", + []byte(`{"position":12,"is_paused":false}`), + map[string]string{"session_id": missingSessionID}, + ) + }, + handle: playbackHandler.HandleUpdateProgress, + }, + { + name: "stop", + request: func() *http.Request { + return playbackTestRequest( + http.MethodDelete, + "/api/v1/playback/"+missingSessionID, + nil, + map[string]string{"session_id": missingSessionID}, + ) + }, + handle: playbackHandler.HandleStopPlayback, + }, + { + name: "audio", + request: func() *http.Request { + return playbackTestRequest( + http.MethodPatch, + "/api/v1/playback/"+missingSessionID+"/audio", + []byte(`{"audio_track_index":1}`), + map[string]string{"session_id": missingSessionID}, + ) + }, + handle: playbackHandler.HandleChangeAudioTrack, + }, + { + name: "websocket", + request: func() *http.Request { + return playbackTestRequest( + http.MethodGet, + "/api/v1/playback/sessions/"+missingSessionID+"/control/ws", + nil, + map[string]string{"session_id": missingSessionID}, + ) + }, + handle: playbackHandler.HandleSessionWebSocket, + }, + { + name: "stream", + request: func() *http.Request { + return playbackTestRequest( + http.MethodGet, + "/api/v1/stream/"+missingSessionID, + nil, + map[string]string{"session_id": missingSessionID}, + ) + }, + handle: streamHandler.HandleStream, + }, + { + name: "subtitle", + request: func() *http.Request { + return playbackTestRequest( + http.MethodGet, + "/api/v1/stream/"+missingSessionID+"/subtitles/0.vtt", + nil, + map[string]string{"session_id": missingSessionID, "track": "0.vtt"}, + ) + }, + handle: streamHandler.HandleSubtitle, + }, + { + name: "transcode start", + request: func() *http.Request { + return playbackTestRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + []byte(`{"session_id":"`+missingSessionID+`","seek_seconds":0,"target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), + nil, + ) + }, + handle: playbackHandler.HandleStartTranscode, + }, + { + name: "transcode manifest", + request: func() *http.Request { + return playbackTestRequest( + http.MethodGet, + "/api/v1/playback/transcode/"+missingSessionID+"/master.m3u8", + nil, + map[string]string{"session_id": missingSessionID}, + ) + }, + handle: playbackHandler.HandleGetTranscodeManifest, + }, + { + name: "transcode segment", + request: func() *http.Request { + return playbackTestRequest( + http.MethodGet, + "/api/v1/playback/transcode/"+missingSessionID+"/segment/000.ts", + nil, + map[string]string{"session_id": missingSessionID, "name": "000.ts"}, + ) + }, + handle: playbackHandler.HandleGetTranscodeSegment, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := httptest.NewRecorder() + tt.handle(rr, tt.request()) + assertPlaybackSessionMissingResponse(t, rr) + }) + } +} + +func playbackTestRequest(method, target string, body []byte, params map[string]string) *http.Request { + var reader *bytes.Reader + if body == nil { + reader = bytes.NewReader(nil) + } else { + reader = bytes.NewReader(body) + } + req := httptest.NewRequest(method, target, reader) + req = req.WithContext(newAuthorizedPlaybackContext()) + if len(params) == 0 { + return req + } + routeCtx := chi.NewRouteContext() + for key, value := range params { + routeCtx.URLParams.Add(key, value) + } + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) +} + +func assertPlaybackSessionMissingResponse(t *testing.T, rr *httptest.ResponseRecorder) { + t.Helper() + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var resp errorResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode error response: %v; body = %s", err, rr.Body.String()) + } + if resp.Error != playbackSessionNotFoundErrorCode { + t.Fatalf("error = %q, want %q; body = %s", resp.Error, playbackSessionNotFoundErrorCode, rr.Body.String()) + } + if resp.Message != "Playback session not found" { + t.Fatalf("message = %q", resp.Message) + } +} diff --git a/internal/api/handlers/playback_sessions.go b/internal/api/handlers/playback_sessions.go new file mode 100644 index 00000000..78569003 --- /dev/null +++ b/internal/api/handlers/playback_sessions.go @@ -0,0 +1,371 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// playbackSessionRow represents a live playback row from playback_sessions_sync +// enriched with user and media information. play_method is the current transport +// method for the active stream; component-level behavior is exposed separately +// via video_decision and audio_decision. +type playbackSessionRow struct { + SessionID string `json:"session_id"` + UserID int `json:"user_id"` + Username string `json:"username"` + ProfileID string `json:"profile_id"` + ProfileName string `json:"profile_name,omitempty"` + MediaFileID int `json:"media_file_id"` + RequestedMediaFileID int `json:"requested_media_file_id"` + ContentID string `json:"content_id,omitempty"` + MediaTitle string `json:"media_title"` + MediaType string `json:"media_type"` + SeriesName string `json:"series_name,omitempty"` + EpisodeName string `json:"episode_name,omitempty"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + PlayMethod string `json:"play_method"` + ReportingNode string `json:"reporting_node"` + NodeDisplayName string `json:"node_display_name,omitempty"` + FileDuration *int `json:"file_duration"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + IsPaused bool `json:"is_paused"` + HasPlaybackControl bool `json:"has_playback_control"` + ClientIP string `json:"client_ip,omitempty"` + AudioTrackIndex int `json:"audio_track_index"` + TranscodeAudio bool `json:"transcode_audio"` + StreamBitrateKbps *int `json:"stream_bitrate_kbps"` + TranscodeNodeURL string `json:"-"` + TargetResolution string `json:"target_resolution,omitempty"` + TargetVideoCodec string `json:"target_video_codec,omitempty"` + TargetAudioCodec string `json:"target_audio_codec,omitempty"` + TargetBitrateKbps *int `json:"target_bitrate_kbps"` + SourceContainer string `json:"source_container,omitempty"` + SourceBitrateKbps *int `json:"source_bitrate_kbps"` + SourceVideoCodec string `json:"source_video_codec,omitempty"` + SourceVideoResolution string `json:"source_video_resolution,omitempty"` + SourceAudioCodec string `json:"source_audio_codec,omitempty"` + SourceAudioChannels *int `json:"source_audio_channels"` + SourceAudioLanguage string `json:"source_audio_language,omitempty"` + SourceAudioTitle string `json:"source_audio_title,omitempty"` + SourceAudioLayout string `json:"source_audio_layout,omitempty"` + RequestedVideoCodec string `json:"requested_video_codec,omitempty"` + RequestedVideoResolution string `json:"requested_video_resolution,omitempty"` + VideoDecision string `json:"video_decision,omitempty"` + AudioDecision string `json:"audio_decision,omitempty"` +} + +// PlaybackSessionsQuery scopes live session listing. +type PlaybackSessionsQuery struct { + // UserID, when positive, limits results to sessions owned by that account. + UserID int +} + +type playbackSessionsReader interface { + Load(ctx context.Context, r *http.Request, query PlaybackSessionsQuery) ([]playbackSessionRow, error) +} + +func resolvePlaybackSessionsLoader( + loader *PlaybackSessionsLoader, + pool *pgxpool.Pool, + storeProv userstore.UserStoreProvider, + detailSvc *catalog.DetailService, +) (*PlaybackSessionsLoader, error) { + if loader != nil { + return loader, nil + } + if pool == nil { + return nil, errors.New("database not configured") + } + return NewPlaybackSessionsLoader(pool, storeProv, detailSvc), nil +} + +// PlaybackSessionsLoader reads enriched rows from playback_sessions_sync. +type PlaybackSessionsLoader struct { + pool *pgxpool.Pool + storeProv userstore.UserStoreProvider + DetailSvc *catalog.DetailService +} + +func NewPlaybackSessionsLoader( + pool *pgxpool.Pool, + storeProv userstore.UserStoreProvider, + detailSvc *catalog.DetailService, +) *PlaybackSessionsLoader { + return &PlaybackSessionsLoader{ + pool: pool, + storeProv: storeProv, + DetailSvc: detailSvc, + } +} + +func (l *PlaybackSessionsLoader) Load( + ctx context.Context, + r *http.Request, + query PlaybackSessionsQuery, +) ([]playbackSessionRow, error) { + if l == nil || l.pool == nil { + return nil, errors.New("database not configured") + } + + sql := ` + SELECT + s.session_id, + s.user_id, + COALESCE(u.username, ''), + COALESCE(s.profile_id, ''), + s.media_file_id, + COALESCE(s.requested_media_file_id, s.media_file_id, 0), + COALESCE(mf.episode_id, mf.content_id, ''), + COALESCE(mi.title, ''), + COALESCE(mi.type, ''), + COALESCE(series_mi.title, ''), + COALESCE(e.title, ''), + e.season_number, + e.episode_number, + COALESCE(CASE WHEN e.series_id IS NOT NULL THEN series_mi.poster_path ELSE mi.poster_path END, ''), + s.play_method, + s.reporting_node, + COALESCE(remote_node.name, ''), + mf.duration, + s.started_at, + s.updated_at, + COALESCE(s.is_paused, FALSE), + COALESCE(s.has_websocket, FALSE), + COALESCE(HOST(s.client_ip), ''), + COALESCE(s.audio_track_index, 0), + COALESCE(s.transcode_audio, FALSE), + s.stream_bitrate_kbps, + COALESCE(s.transcode_node_url, ''), + COALESCE(s.target_resolution, ''), + COALESCE(s.target_video_codec, ''), + COALESCE(s.target_audio_codec, ''), + s.target_bitrate_kbps, + COALESCE(mf.container, ''), + mf.bitrate, + COALESCE(mf.codec_video, ''), + COALESCE(mf.resolution, ''), + COALESCE(mf.codec_audio, ''), + mf.audio_channels, + COALESCE(mf.audio_tracks::text, '[]'), + COALESCE(requested_mf.codec_video, ''), + COALESCE(requested_mf.resolution, '') + FROM playback_sessions_sync s + LEFT JOIN users u ON u.id = s.user_id + LEFT JOIN media_files mf ON mf.id = s.media_file_id + LEFT JOIN media_files requested_mf ON requested_mf.id = COALESCE(s.requested_media_file_id, s.media_file_id) + LEFT JOIN media_items mi ON mi.content_id = mf.content_id + LEFT JOIN episodes e ON e.content_id = mf.episode_id + LEFT JOIN media_items series_mi ON series_mi.content_id = e.series_id + LEFT JOIN stream_nodes remote_node ON remote_node.url = s.transcode_node_url` + + var args []any + if query.UserID > 0 { + sql += " WHERE s.user_id = $1" + args = append(args, query.UserID) + } + sql += " ORDER BY s.started_at DESC LIMIT 200" + + rows, err := l.pool.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("querying playback sessions: %w", err) + } + defer rows.Close() + + sessions := make([]playbackSessionRow, 0) + for rows.Next() { + var s playbackSessionRow + var posterPath string + var streamBitrateKbps *int + var targetBitrateKbps *int + var sourceBitrateKbps *int + var sourceAudioChannels *int + var audioTracksJSON []byte + if err := rows.Scan( + &s.SessionID, &s.UserID, &s.Username, &s.ProfileID, &s.MediaFileID, &s.RequestedMediaFileID, &s.ContentID, + &s.MediaTitle, &s.MediaType, &s.SeriesName, &s.EpisodeName, &s.SeasonNumber, &s.EpisodeNumber, + &posterPath, + &s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt, + &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps, + &s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps, + &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution, + &s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution, + ); err != nil { + return nil, fmt.Errorf("scanning playback session: %w", err) + } + s.PosterURL = l.presignPosterURL(r, posterPath) + s.StreamBitrateKbps = streamBitrateKbps + s.TargetBitrateKbps = targetBitrateKbps + s.SourceBitrateKbps = sourceBitrateKbps + s.SourceAudioChannels = sourceAudioChannels + enrichPlaybackSessionRow(&s, audioTracksJSON) + sessions = append(sessions, s) + } + if err := rows.Err(); err != nil { + return nil, err + } + l.populateProfileNames(ctx, sessions) + + return sessions, nil +} + +func (l *PlaybackSessionsLoader) presignPosterURL(r *http.Request, path string) string { + if l != nil && l.DetailSvc != nil { + return l.DetailSvc.PresignURL(r.Context(), cardThumbnailPath(path), "card") + } + return "" +} + +func enrichPlaybackSessionRow(row *playbackSessionRow, audioTracksJSON []byte) { + if row == nil { + return + } + + row.VideoDecision, row.AudioDecision = sessionComponentDecision(row.PlayMethod, row.TranscodeAudio, row.TargetVideoCodec) + + var audioTracks []models.AudioTrack + if len(audioTracksJSON) > 0 { + _ = json.Unmarshal(audioTracksJSON, &audioTracks) + } + if track := pickAdminAudioTrack(audioTracks, row.AudioTrackIndex); track != nil { + if codec := strings.TrimSpace(track.Codec); codec != "" { + row.SourceAudioCodec = codec + } + if track.Channels > 0 { + channels := track.Channels + row.SourceAudioChannels = &channels + } + row.SourceAudioLanguage = strings.TrimSpace(track.Language) + row.SourceAudioTitle = firstNonEmptyValue(track.Title, track.EmbeddedTitle) + row.SourceAudioLayout = strings.TrimSpace(track.Layout) + } + + if row.StreamBitrateKbps == nil { + switch { + case row.TargetBitrateKbps != nil: + row.StreamBitrateKbps = row.TargetBitrateKbps + case row.SourceBitrateKbps != nil: + row.StreamBitrateKbps = row.SourceBitrateKbps + } + } + + if row.AudioDecision == "transcode" && strings.TrimSpace(row.TargetAudioCodec) == "" { + row.TargetAudioCodec = "aac" + } + if row.VideoDecision == "transcode" && strings.TrimSpace(row.TargetVideoCodec) == "" { + row.TargetVideoCodec = "h264" + } + if row.VideoDecision == "transcode" && strings.TrimSpace(row.TargetResolution) == "" { + row.TargetResolution = row.SourceVideoResolution + } + if strings.TrimSpace(row.NodeDisplayName) == "" { + if strings.TrimSpace(row.TranscodeNodeURL) != "" { + row.NodeDisplayName = "Remote transcode" + } else { + row.NodeDisplayName = "Local server" + } + } +} + +func pickAdminAudioTrack(tracks []models.AudioTrack, index int) *models.AudioTrack { + if len(tracks) == 0 { + return nil + } + if index >= 0 && index < len(tracks) { + track := tracks[index] + return &track + } + for _, track := range tracks { + if track.Default { + cp := track + return &cp + } + } + track := tracks[0] + return &track +} + +func sessionComponentDecision(playMethod string, transcodeAudio bool, targetVideoCodec string) (string, string) { + switch strings.TrimSpace(playMethod) { + case "direct": + return "direct", "direct" + case "remux": + if transcodeAudio { + return "remux", "transcode" + } + return "remux", "remux" + case "transcode": + videoDec := "transcode" + if strings.EqualFold(strings.TrimSpace(targetVideoCodec), "copy") { + videoDec = "remux" + } + audioDec := "transcode" + if !transcodeAudio { + audioDec = "direct" + } + return videoDec, audioDec + default: + return "", "" + } +} + +func firstNonEmptyValue(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func (l *PlaybackSessionsLoader) populateProfileNames(ctx context.Context, sessions []playbackSessionRow) { + if l == nil || l.storeProv == nil || len(sessions) == 0 { + return + } + + profileNamesByUser := make(map[int]map[string]string) + for i := range sessions { + if strings.TrimSpace(sessions[i].ProfileID) == "" { + continue + } + if _, ok := profileNamesByUser[sessions[i].UserID]; ok { + continue + } + store, err := l.storeProv.ForUser(ctx, sessions[i].UserID) + if err != nil || store == nil { + continue + } + profiles, err := store.ListProfiles(ctx) + if err != nil { + continue + } + names := make(map[string]string, len(profiles)) + for _, profile := range profiles { + if trimmed := strings.TrimSpace(profile.Name); trimmed != "" { + names[profile.ID] = trimmed + } + } + profileNamesByUser[sessions[i].UserID] = names + } + + for i := range sessions { + names := profileNamesByUser[sessions[i].UserID] + if names == nil { + continue + } + sessions[i].ProfileName = names[sessions[i].ProfileID] + } +} diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go new file mode 100644 index 00000000..30da0cfa --- /dev/null +++ b/internal/api/handlers/playback_test.go @@ -0,0 +1,1434 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodepool" + "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/transcodenode" + "github.com/Silo-Server/silo-server/internal/userdb" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +type testUserStoreProvider struct { + store userstore.UserStore +} + +func (p testUserStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) { + return p.store, nil +} + +func (p testUserStoreProvider) Close() error { return nil } + +type testPlaybackFileResolver struct { + file *models.MediaFile +} + +func (r testPlaybackFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) { + return r.file, nil +} + +type mapPlaybackFileResolver struct { + files map[int]*models.MediaFile +} + +func (r mapPlaybackFileResolver) GetByID(_ context.Context, id int) (*models.MediaFile, error) { + return r.files[id], nil +} + +type testPlaybackFileVersionFetcher struct { + byContent map[string][]*models.MediaFile + byEpisode map[string][]*models.MediaFile +} + +func (f testPlaybackFileVersionFetcher) GetByContentID(_ context.Context, id string) ([]*models.MediaFile, error) { + return f.byContent[id], nil +} + +func (f testPlaybackFileVersionFetcher) GetByEpisodeID(_ context.Context, id string) ([]*models.MediaFile, error) { + return f.byEpisode[id], nil +} + +type testPlaybackSettingsRepo struct { + values map[string]string +} + +func (r testPlaybackSettingsRepo) Get(_ context.Context, key string) (string, error) { + return r.values[key], nil +} + +type allowAllPlaybackItemAccess struct{} + +func (allowAllPlaybackItemAccess) EnsureAccessible( + context.Context, + string, + catalog.AccessFilter, +) error { + return nil +} + +type noopPlaybackAdminStore struct{} + +func (noopPlaybackAdminStore) RecordHistory(context.Context, AdminPlaybackHistoryEntry) error { + return nil +} + +func (noopPlaybackAdminStore) DeleteSession(context.Context, string) error { return nil } + +type testEpisodeLookup struct { + episode *models.Episode +} + +func (l testEpisodeLookup) GetByID(context.Context, string) (*models.Episode, error) { + return l.episode, nil +} + +type failingSessionManager struct{} + +func (failingSessionManager) StartSession(int, string, int, playback.PlayMethod, bool) (*playback.Session, error) { + return nil, errors.New("boom") +} + +func (failingSessionManager) StartSessionWithFiles(int, string, int, int, playback.PlayMethod, bool) (*playback.Session, error) { + return nil, errors.New("boom") +} + +func (failingSessionManager) UpdateProgress(string, float64, bool) error { return nil } + +func (failingSessionManager) UpdateAudioTrack(string, int, playback.PlayMethod) error { return nil } + +func (failingSessionManager) UpdateStreamState(string, playback.SessionStreamState) error { + return nil +} + +func (failingSessionManager) TouchActivity(string) error { return nil } + +func (failingSessionManager) BeginTransport(string) error { return nil } + +func (failingSessionManager) EndTransport(string) error { return nil } + +func (failingSessionManager) SetEffectiveMediaFileID(string, int) error { return nil } + +func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return nil } + +func (failingSessionManager) SetWebSocket(string, bool) error { return nil } + +func (failingSessionManager) StopSession(string) error { return nil } + +func (failingSessionManager) GetSession(string) (*playback.Session, error) { return nil, nil } + +func newPlaybackTestStore(t *testing.T) userstore.UserStore { + t.Helper() + + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + if err := userdb.InitSchema(db); err != nil { + t.Fatalf("init schema: %v", err) + } + + store := userdb.NewSQLiteUserStore(db) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Main"}); err != nil { + t.Fatalf("create profile: %v", err) + } + + return store +} + +func newAuthorizedPlaybackContext() context.Context { + ctx := context.Background() + ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess}) + return apimw.SetProfileID(ctx, "profile-1") +} + +func withPlaybackRouteParam(req *http.Request, key, value string) *http.Request { + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add(key, value) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) +} + +func writePlaybackTestFFmpeg(t *testing.T) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "fake-ffmpeg.sh") + if err := os.WriteFile(path, []byte("#!/bin/sh\nsleep 30\n"), 0o755); err != nil { + t.Fatalf("write fake ffmpeg: %v", err) + } + return path +} + +func writePlaybackTestMediaFile(t *testing.T, name string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir media path: %v", err) + } + if err := os.WriteFile(path, []byte("video"), 0o644); err != nil { + t.Fatalf("write media file: %v", err) + } + return path +} + +type recordingMissingMarker struct { + ids []int +} + +func (m *recordingMissingMarker) MarkMissing(_ context.Context, id int, _ time.Time) error { + m.ids = append(m.ids, id) + return nil +} + +type recordingSessionSyncer struct { + calls int +} + +func (s *recordingSessionSyncer) SyncNow(context.Context) error { + s.calls++ + return nil +} + +type recordingPlaybackAdminStore struct { + history []AdminPlaybackHistoryEntry + deleted []string +} + +func (s *recordingPlaybackAdminStore) RecordHistory(_ context.Context, entry AdminPlaybackHistoryEntry) error { + s.history = append(s.history, entry) + return nil +} + +func (s *recordingPlaybackAdminStore) DeleteSession(_ context.Context, sessionID string) error { + s.deleted = append(s.deleted, sessionID) + return nil +} + +func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume(t *testing.T) { + store := newPlaybackTestStore(t) + if err := store.SetProgress(context.Background(), "profile-1", "movie-1", 900, 3600, userstore.ProgressThresholds{}); err != nil { + t.Fatalf("seed progress: %v", err) + } + + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie.mkv"), + Resolution: "1080p", + Duration: 3600, + } + sessionMgr := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.StoreProvider = testUserStoreProvider{store: store} + handler.ItemAccess = allowAllPlaybackItemAccess{} + + tests := []struct { + name string + body string + wantPosition float64 + }{ + { + name: "restore saved resume when start_position is omitted", + body: `{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`, + wantPosition: 900, + }, + { + name: "respect explicit zero start position", + body: `{"file_id":42,"profile_id":"profile-1","start_position":0,"codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`, + wantPosition: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(tt.body)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != 201 { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.Position != tt.wantPosition { + t.Fatalf("position = %v, want %v", resp.Position, tt.wantPosition) + } + }) + } +} + +func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) { + file := &models.MediaFile{Duration: 3600} + + copyResp := buildTranscodeStartResponse( + transcodeStartRequest{ + SessionID: "session-copy", + SeekSeconds: 18.261, + TargetCodecVideo: "copy", + }, + file, + nil, + "/playback/transcode/session-copy/master.m3u8", + ) + if copyResp.CanSeekAnywhere { + t.Fatal("copy-mode response should require explicit restart seeks") + } + if copyResp.PlayerStartSeconds != 0 { + t.Fatalf("copy-mode PlayerStartSeconds = %v, want 0", copyResp.PlayerStartSeconds) + } + if copyResp.StreamOriginSeconds != 18.261 { + t.Fatalf("copy-mode StreamOriginSeconds = %v, want 18.261", copyResp.StreamOriginSeconds) + } + if copyResp.TimelineOffsetSeconds != 18.261 { + t.Fatalf("copy-mode TimelineOffsetSeconds = %v, want 18.261", copyResp.TimelineOffsetSeconds) + } + + encodedResp := buildTranscodeStartResponse( + transcodeStartRequest{ + SessionID: "session-encoded", + SeekSeconds: 18.261, + TargetCodecVideo: "h264", + }, + file, + nil, + "/playback/transcode/session-encoded/master.m3u8", + ) + if !encodedResp.CanSeekAnywhere { + t.Fatal("encoded response should advertise seek-anywhere") + } + if encodedResp.PlayerStartSeconds != 18.261 { + t.Fatalf("encoded PlayerStartSeconds = %v, want 18.261", encodedResp.PlayerStartSeconds) + } + if encodedResp.StreamOriginSeconds != 0 { + t.Fatalf("encoded StreamOriginSeconds = %v, want 0", encodedResp.StreamOriginSeconds) + } + if encodedResp.TimelineOffsetSeconds != 0 { + t.Fatalf("encoded TimelineOffsetSeconds = %v, want 0", encodedResp.TimelineOffsetSeconds) + } +} + +func TestHandleStartPlayback_PersistsSeriesPlaybackPreferenceForEpisodes(t *testing.T) { + store := newPlaybackTestStore(t) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + EpisodeID: "episode-1", + FilePath: writePlaybackTestMediaFile(t, "episode.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + HDR: false, + Duration: 3600, + } + + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file}) + handler.StoreProvider = testUserStoreProvider{store: store} + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: "episode-1", SeriesID: "series-1"}} + + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != 201 { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + pref, err := store.GetSeriesPlaybackPreference(context.Background(), "profile-1", "series-1") + if err != nil { + t.Fatalf("GetSeriesPlaybackPreference: %v", err) + } + if pref == nil { + t.Fatal("expected series playback preference to be stored") + } + if pref.Resolution != "1080p" || pref.CodecVideo != "h264" || pref.HDR { + t.Fatalf("stored pref = %+v, want 1080p/h264/false", pref) + } +} + +func TestHandleStartPlayback_DoesNotPersistSeriesPlaybackPreferenceOnFailure(t *testing.T) { + store := newPlaybackTestStore(t) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + EpisodeID: "episode-1", + FilePath: writePlaybackTestMediaFile(t, "episode.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + HDR: false, + Duration: 3600, + } + + handler := NewPlaybackHandler(failingSessionManager{}, testPlaybackFileResolver{file: file}) + handler.StoreProvider = testUserStoreProvider{store: store} + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: "episode-1", SeriesID: "series-1"}} + + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != 500 { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + pref, err := store.GetSeriesPlaybackPreference(context.Background(), "profile-1", "series-1") + if err != nil { + t.Fatalf("GetSeriesPlaybackPreference: %v", err) + } + if pref != nil { + t.Fatalf("expected no persisted preference on failure, got %+v", pref) + } +} + +func TestHandleChangeAudioTrack_PersistsSeriesAudioPreferenceSignature(t *testing.T) { + store := newPlaybackTestStore(t) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + EpisodeID: "episode-1", + FilePath: writePlaybackTestMediaFile(t, "episode.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mp4", + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Language: "eng", Codec: "aac", Layout: "stereo", Channels: 2, Title: "English Stereo", Default: true}, + {Language: "eng", Codec: "aac", Layout: "5.1", Channels: 6, Title: "English 5.1", EmbeddedTitle: "Surround"}, + }, + } + + sessionMgr := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.StoreProvider = testUserStoreProvider{store: store} + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: "episode-1", SeriesID: "series-1"}} + + startReq := httptest.NewRequest( + "POST", + "/api/v1/playback/start", + strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`), + ) + startReq = startReq.WithContext(newAuthorizedPlaybackContext()) + + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + + var startResp playbackSessionResponse + if err := json.NewDecoder(startRR.Body).Decode(&startResp); err != nil { + t.Fatalf("decode start response: %v", err) + } + + changeReq := httptest.NewRequest( + http.MethodPatch, + "/api/v1/playback/"+startResp.SessionID+"/audio", + strings.NewReader(`{"audio_track_index":1,"position":120}`), + ) + changeReq = changeReq.WithContext(newAuthorizedPlaybackContext()) + changeReq = withPlaybackRouteParam(changeReq, "session_id", startResp.SessionID) + + changeRR := httptest.NewRecorder() + handler.HandleChangeAudioTrack(changeRR, changeReq) + if changeRR.Code != http.StatusOK { + t.Fatalf("change status = %d, body = %s", changeRR.Code, changeRR.Body.String()) + } + + pref, err := store.GetAudioPreference(context.Background(), "profile-1", "series-1") + if err != nil { + t.Fatalf("GetAudioPreference: %v", err) + } + if pref == nil { + t.Fatal("expected series audio preference to be stored") + } + if pref.AudioTrackIndex != 1 { + t.Fatalf("AudioTrackIndex = %d, want 1", pref.AudioTrackIndex) + } + if pref.AudioLanguage != "eng" { + t.Fatalf("AudioLanguage = %q, want eng", pref.AudioLanguage) + } + if pref.TrackSignature == nil { + t.Fatal("expected audio track signature to be stored") + } + if pref.TrackSignature.Title != "English 5.1" { + t.Fatalf("TrackSignature.Title = %q, want %q", pref.TrackSignature.Title, "English 5.1") + } + if pref.TrackSignature.EmbeddedTitle != "Surround" { + t.Fatalf("TrackSignature.EmbeddedTitle = %q, want %q", pref.TrackSignature.EmbeddedTitle, "Surround") + } + if pref.TrackSignature.Codec != "aac" { + t.Fatalf("TrackSignature.Codec = %q, want aac", pref.TrackSignature.Codec) + } + if pref.TrackSignature.Layout != "5.1" { + t.Fatalf("TrackSignature.Layout = %q, want 5.1", pref.TrackSignature.Layout) + } + if pref.TrackSignature.Channels != 6 { + t.Fatalf("TrackSignature.Channels = %d, want 6", pref.TrackSignature.Channels) + } + if pref.TrackSignature.Default { + t.Fatal("TrackSignature.Default = true, want false") + } +} + +func TestBuildAdminHistoryEntry_UsesRequestedMediaFileID(t *testing.T) { + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), mapPlaybackFileResolver{ + files: map[int]*models.MediaFile{ + 42: { + ID: 42, + ContentID: "movie-1", + FilePath: "/media/movie-4k.mkv", + Resolution: "2160p", + CodecVideo: "hevc", + Duration: 3600, + }, + 99: { + ID: 99, + ContentID: "movie-1", + FilePath: "/media/movie-1080p.mkv", + Resolution: "1080p", + CodecVideo: "h264", + Duration: 3600, + }, + }, + }) + handler.AdminStore = noopPlaybackAdminStore{} + + entry, err := handler.buildAdminHistoryEntry(context.Background(), &playback.Session{ + ID: "session-1", + UserID: 1, + ProfileID: "profile-1", + MediaFileID: 99, + RequestedMediaFileID: 42, + PlayMethod: playback.PlayTranscode, + Position: 120, + StartedAt: time.Now().Add(-2 * time.Minute), + }) + if err != nil { + t.Fatalf("buildAdminHistoryEntry: %v", err) + } + if entry == nil { + t.Fatal("expected admin history entry") + } + if entry.MediaFileID != 42 { + t.Fatalf("MediaFileID = %d, want 42", entry.MediaFileID) + } + if entry.MediaItemID != "movie-1" { + t.Fatalf("MediaItemID = %q, want movie-1", entry.MediaItemID) + } +} + +func TestPersistStopAndHistory_SkipsZeroProgressStops(t *testing.T) { + store := newPlaybackTestStore(t) + if err := store.SetProgress(context.Background(), "profile-1", "movie-1", 900, 3600, userstore.ProgressThresholds{}); err != nil { + t.Fatalf("seed progress: %v", err) + } + + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: "/media/movie.mkv", + Resolution: "1080p", + Duration: 3600, + } + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file}) + handler.StoreProvider = testUserStoreProvider{store: store} + + handler.persistStopAndHistory(context.Background(), &playback.Session{ + ID: "session-1", + UserID: 1, + ProfileID: "profile-1", + MediaFileID: 42, + Position: 0, + }) + + progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1") + if err != nil { + t.Fatalf("get progress: %v", err) + } + if progress == nil || progress.PositionSeconds != 900 { + t.Fatalf("position after zero stop = %v, want 900", progress) + } + + history, err := store.ListHistory(context.Background(), "profile-1", 10, 0) + if err != nil { + t.Fatalf("list history: %v", err) + } + if len(history) != 0 { + t.Fatalf("history len = %d, want 0", len(history)) + } +} + +func TestPersistStopAndHistory_PersistsPositiveProgressStops(t *testing.T) { + store := newPlaybackTestStore(t) + + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: "/media/movie.mkv", + Resolution: "1080p", + Duration: 3600, + } + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file}) + handler.StoreProvider = testUserStoreProvider{store: store} + + handler.persistStopAndHistory(context.Background(), &playback.Session{ + ID: "session-2", + UserID: 1, + ProfileID: "profile-1", + MediaFileID: 42, + Position: 240, + }) + + progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1") + if err != nil { + t.Fatalf("get progress: %v", err) + } + if progress == nil || progress.PositionSeconds != 240 { + t.Fatalf("position after positive stop = %v, want 240", progress) + } + + history, err := store.ListHistory(context.Background(), "profile-1", 10, 0) + if err != nil { + t.Fatalf("list history: %v", err) + } + if len(history) != 1 { + t.Fatalf("history len = %d, want 1", len(history)) + } + if history[0].Source != userstore.WatchHistorySourcePlayback { + t.Fatalf("history source = %q, want %q", history[0].Source, userstore.WatchHistorySourcePlayback) + } +} + +func TestHandleStartPlayback_Recomputes4KFallbackAsRemux(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + requested := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-4k.mkv"), + Resolution: "2160p", + CodecVideo: "hevc", + CodecAudio: "eac3", + Container: "mkv", + HDR: true, + Bitrate: 15000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "eac3", Default: true}, + }, + } + effective := &models.MediaFile{ + ID: 99, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-1080p.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "eac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "eac3", Default: true}, + }, + } + + handler := NewPlaybackHandler(sessionMgr, mapPlaybackFileResolver{ + files: map[int]*models.MediaFile{ + 42: requested, + 99: effective, + }, + }) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.SettingsRepo = testPlaybackSettingsRepo{ + values: map[string]string{"allow_4k_transcode": "false"}, + } + handler.FileVersionFetcher = testPlaybackFileVersionFetcher{ + byContent: map[string][]*models.MediaFile{ + "movie-1": {requested, effective}, + }, + } + + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != 201 { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.MediaFileID != 99 { + t.Fatalf("MediaFileID = %d, want 99", resp.MediaFileID) + } + if resp.PlayMethod != string(playback.PlayRemux) { + t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayRemux) + } + + session, err := sessionMgr.GetSession(resp.SessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if session.MediaFileID != 99 { + t.Fatalf("session.MediaFileID = %d, want 99", session.MediaFileID) + } + if session.RequestedMediaFileID != 42 { + t.Fatalf("session.RequestedMediaFileID = %d, want 42", session.RequestedMediaFileID) + } + if session.BasePlayMethod != playback.PlayRemux { + t.Fatalf("session.BasePlayMethod = %q, want %q", session.BasePlayMethod, playback.PlayRemux) + } +} + +func TestHandleStartPlayback_Recomputes4KFallbackAsDirect(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + requested := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-4k.mkv"), + Resolution: "2160p", + CodecVideo: "hevc", + CodecAudio: "eac3", + Container: "mkv", + HDR: true, + Bitrate: 15000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "eac3", Default: true}, + }, + } + effective := &models.MediaFile{ + ID: 99, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-1080p.mp4"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mp4", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + }, + } + + handler := NewPlaybackHandler(sessionMgr, mapPlaybackFileResolver{ + files: map[int]*models.MediaFile{ + 42: requested, + 99: effective, + }, + }) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.SettingsRepo = testPlaybackSettingsRepo{ + values: map[string]string{"allow_4k_transcode": "false"}, + } + handler.FileVersionFetcher = testPlaybackFileVersionFetcher{ + byContent: map[string][]*models.MediaFile{ + "movie-1": {requested, effective}, + }, + } + + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != 201 { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.MediaFileID != 99 { + t.Fatalf("MediaFileID = %d, want 99", resp.MediaFileID) + } + if resp.PlayMethod != string(playback.PlayDirect) { + t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayDirect) + } +} + +func TestHandleStartPlayback_AppleExplicitDirectPreservesSelectedAudio(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-h264.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mkv", + Bitrate: 12000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + {Codec: "dts"}, + }, + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + + req := httptest.NewRequest( + "POST", + "/api/v1/playback/start", + strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","audio_track_index":1,"preserve_direct_audio_selection":true,"codecs_video":["h264","hevc"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`), + ) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.PlayMethod != string(playback.PlayDirect) { + t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayDirect) + } + if resp.AudioTrackIndex != 1 { + t.Fatalf("AudioTrackIndex = %d, want 1", resp.AudioTrackIndex) + } + + session, err := sessionMgr.GetSession(resp.SessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if session.TranscodeAudio { + t.Fatal("TranscodeAudio = true, want false") + } + if session.AudioTrackIndex != 1 { + t.Fatalf("session.AudioTrackIndex = %d, want 1", session.AudioTrackIndex) + } +} + +func TestHandleStartPlayback_ExplicitDirectPromotesSelectedAudioWithoutApplePreserveFlag(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-h264.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mkv", + Bitrate: 12000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + {Codec: "dts"}, + }, + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + + req := httptest.NewRequest( + "POST", + "/api/v1/playback/start", + strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","audio_track_index":1,"codecs_video":["h264","hevc"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`), + ) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.PlayMethod != string(playback.PlayRemux) { + t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayRemux) + } + if resp.AudioTrackIndex != 1 { + t.Fatalf("AudioTrackIndex = %d, want 1", resp.AudioTrackIndex) + } + + session, err := sessionMgr.GetSession(resp.SessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if !session.TranscodeAudio { + t.Fatal("TranscodeAudio = false, want true for unsupported selected audio") + } + if session.AudioTrackIndex != 1 { + t.Fatalf("session.AudioTrackIndex = %d, want 1", session.AudioTrackIndex) + } +} + +func TestHandleStartPlayback_AutoDirectIgnoresApplePreserveFlagForSelectedAudio(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-h264.mp4"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mp4", + Bitrate: 12000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + {Codec: "dts"}, + }, + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + + req := httptest.NewRequest( + "POST", + "/api/v1/playback/start", + strings.NewReader(`{"file_id":42,"profile_id":"profile-1","audio_track_index":1,"preserve_direct_audio_selection":true,"codecs_video":["h264","hevc"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`), + ) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.PlayMethod != string(playback.PlayRemux) { + t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayRemux) + } + if resp.AudioTrackIndex != 1 { + t.Fatalf("AudioTrackIndex = %d, want 1", resp.AudioTrackIndex) + } + + session, err := sessionMgr.GetSession(resp.SessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if !session.TranscodeAudio { + t.Fatal("TranscodeAudio = false, want true for unsupported selected audio") + } + if session.AudioTrackIndex != 1 { + t.Fatalf("session.AudioTrackIndex = %d, want 1", session.AudioTrackIndex) + } +} + +func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + requested := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-4k.mkv"), + Resolution: "2160p", + CodecVideo: "hevc", + CodecAudio: "eac3", + Container: "mkv", + HDR: true, + Bitrate: 15000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "eac3", Default: true}, + }, + } + effective := &models.MediaFile{ + ID: 99, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-1080p.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "eac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "eac3", Default: true}, + }, + } + + handler := NewPlaybackHandler(sessionMgr, mapPlaybackFileResolver{ + files: map[int]*models.MediaFile{ + 42: requested, + 99: effective, + }, + }) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.SettingsRepo = testPlaybackSettingsRepo{ + values: map[string]string{"allow_4k_transcode": "false"}, + } + handler.FileVersionFetcher = testPlaybackFileVersionFetcher{ + byContent: map[string][]*models.MediaFile{ + "movie-1": {requested, effective}, + }, + } + + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + if rr.Code != 201 { + t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var startResp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&startResp); err != nil { + t.Fatalf("decode start response: %v", err) + } + if startResp.PlayMethod != string(playback.PlayRemux) { + t.Fatalf("start PlayMethod = %q, want %q", startResp.PlayMethod, playback.PlayRemux) + } + if err := sessionMgr.UpdateAudioTrack(startResp.SessionID, 1, playback.PlayRemux); err != nil { + t.Fatalf("UpdateAudioTrack: %v", err) + } + + var remoteStartRequests int + var remoteStartReq transcodenode.TranscodeStartRequest + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/transcode/start" { + t.Fatalf("unexpected remote request %s %s", r.Method, r.URL.Path) + } + remoteStartRequests++ + if err := json.NewDecoder(r.Body).Decode(&remoteStartReq); err != nil { + t.Fatalf("decode remote start request: %v", err) + } + w.WriteHeader(http.StatusAccepted) + })) + defer remote.Close() + + pool := nodepool.NewTranscodePool() + pool.SetNodes([]*nodepool.Node{{ + Name: "transcode-1", + Type: nodepool.NodeTypeTranscode, + URL: remote.URL, + Enabled: true, + Healthy: true, + ActiveJobs: 0, + }}) + handler.TranscodePool = pool + + transcodeReq := httptest.NewRequest( + "POST", + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), + ) + transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) + + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) + + if transcodeRR.Code != 202 { + t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) + } + if remoteStartRequests != 1 { + t.Fatalf("remote start requests = %d, want 1", remoteStartRequests) + } + if remoteStartReq.AudioTrackIndex != 1 { + t.Fatalf("remote audio_track_index = %d, want 1", remoteStartReq.AudioTrackIndex) + } + + session, err := sessionMgr.GetSession(startResp.SessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if session.PlayMethod != playback.PlayTranscode { + t.Fatalf("session.PlayMethod = %q, want %q", session.PlayMethod, playback.PlayTranscode) + } + if session.BasePlayMethod != playback.PlayRemux { + t.Fatalf("session.BasePlayMethod = %q, want %q", session.BasePlayMethod, playback.PlayRemux) + } +} + +func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + filePath := writePlaybackTestMediaFile(t, "movie.mkv") + + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: filePath, + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "ac3", Default: true}, + {Codec: "aac"}, + }, + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.FFmpegPath = writePlaybackTestFFmpeg(t) + handler.TranscodeDir = t.TempDir() + + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","audio_track_index":1,"codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + if rr.Code != 201 { + t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var startResp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&startResp); err != nil { + t.Fatalf("decode start response: %v", err) + } + + transcodeReq := httptest.NewRequest( + "POST", + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), + ) + transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) + + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) + if transcodeRR.Code != 202 { + t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) + } + + transcodeSession := handler.getTranscodeSession(startResp.SessionID) + if transcodeSession == nil { + t.Fatal("expected local transcode session") + } + t.Cleanup(func() { + _ = transcodeSession.Close() + }) + if got := transcodeSession.Opts().AudioTrackIndex; got != 1 { + t.Fatalf("local transcode audio track index = %d, want 1", got) + } +} + +func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + filePath := writePlaybackTestMediaFile(t, "movie-mpeg2.mkv") + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: filePath, + Resolution: "1080p", + CodecVideo: "mpeg2video", + CodecAudio: "dts", + Container: "mkv", + Bitrate: 25000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "dts", Default: true}, + }, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.FFmpegPath = writePlaybackTestFFmpeg(t) + handler.TranscodeDir = t.TempDir() + + transcodeReq := httptest.NewRequest( + "POST", + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"1080p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), + ) + transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) + + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) + if transcodeRR.Code != 202 { + t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) + } + + transcodeSession := handler.getTranscodeSession(session.ID) + if transcodeSession == nil { + t.Fatal("expected local transcode session") + } + t.Cleanup(func() { + _ = transcodeSession.Close() + }) + opts := transcodeSession.Opts() + if got := opts.TargetCodecVideo; got != "copy" { + t.Fatalf("mpeg2 seeked copy target video codec = %q, want copy", got) + } + if got := opts.SourceVideoCodec; got != "mpeg2video" { + t.Fatalf("mpeg2 seeked copy source video codec = %q, want mpeg2video", got) + } + if got := opts.TargetCodecAudio; got != "aac" { + t.Fatalf("mpeg2 seeked copy target audio codec = %q, want aac", got) + } +} + +func TestHandleStartPlayback_MarksMissingFileAndSkipsSessionCreation(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + marker := &recordingMissingMarker{} + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: filepath.Join(t.TempDir(), "missing.mkv"), + Duration: 3600, + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.MissingMarker = marker + + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + if got := len(sessionMgr.AllSessions()); got != 0 { + t.Fatalf("session count = %d, want 0", got) + } + if len(marker.ids) != 1 || marker.ids[0] != 42 { + t.Fatalf("marked ids = %v, want [42]", marker.ids) + } +} + +func TestHandleStartTranscode_AbortsSessionWhenBackingFileIsMissing(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + filePath := writePlaybackTestMediaFile(t, "movie.mkv") + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: filePath, + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + }, + } + marker := &recordingMissingMarker{} + syncer := &recordingSessionSyncer{} + adminStore := &recordingPlaybackAdminStore{} + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.MissingMarker = marker + handler.SessionSyncer = syncer + handler.AdminStore = adminStore + + startReq := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + startReq = startReq.WithContext(newAuthorizedPlaybackContext()) + + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + + var startResp playbackSessionResponse + if err := json.NewDecoder(startRR.Body).Decode(&startResp); err != nil { + t.Fatalf("decode start response: %v", err) + } + if err := os.Remove(filePath); err != nil { + t.Fatalf("remove file: %v", err) + } + + transcodeReq := httptest.NewRequest( + "POST", + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), + ) + transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) + + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) + + if transcodeRR.Code != http.StatusNotFound { + t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) + } + if _, err := sessionMgr.GetSession(startResp.SessionID); !errors.Is(err, playback.ErrSessionNotFound) { + t.Fatalf("GetSession error = %v, want %v", err, playback.ErrSessionNotFound) + } + if len(marker.ids) != 1 || marker.ids[0] != 42 { + t.Fatalf("marked ids = %v, want [42]", marker.ids) + } + if len(adminStore.deleted) != 1 || adminStore.deleted[0] != startResp.SessionID { + t.Fatalf("deleted sessions = %v, want [%s]", adminStore.deleted, startResp.SessionID) + } + if len(adminStore.history) != 0 { + t.Fatalf("history entries = %d, want 0", len(adminStore.history)) + } + if syncer.calls == 0 { + t.Fatal("expected session sync after abort") + } +} + +func TestHandleStartTranscode_KeepsSessionWhenStartupFailsForNonMissingReason(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + filePath := writePlaybackTestMediaFile(t, "movie.mkv") + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: filePath, + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + }, + } + syncer := &recordingSessionSyncer{} + adminStore := &recordingPlaybackAdminStore{} + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.SessionSyncer = syncer + handler.AdminStore = adminStore + handler.TranscodeDir = writePlaybackTestMediaFile(t, "occupied-transcode-dir") + + startReq := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) + startReq = startReq.WithContext(newAuthorizedPlaybackContext()) + + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + initialSyncCalls := syncer.calls + + var startResp playbackSessionResponse + if err := json.NewDecoder(startRR.Body).Decode(&startResp); err != nil { + t.Fatalf("decode start response: %v", err) + } + + transcodeReq := httptest.NewRequest( + "POST", + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), + ) + transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) + + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) + + if transcodeRR.Code != http.StatusInternalServerError { + t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) + } + if _, err := sessionMgr.GetSession(startResp.SessionID); err != nil { + t.Fatalf("GetSession error = %v, want live session", err) + } + if len(adminStore.deleted) != 0 { + t.Fatalf("deleted sessions = %v, want none", adminStore.deleted) + } + if len(adminStore.history) != 0 { + t.Fatalf("history entries = %d, want 0", len(adminStore.history)) + } + if syncer.calls != initialSyncCalls { + t.Fatalf("sync calls = %d, want unchanged value %d", syncer.calls, initialSyncCalls) + } +} + +func TestFindAlternateFile_DoesNotCrossEdition(t *testing.T) { + source := &models.MediaFile{ + ID: 1, + ContentID: "movie-1", + Resolution: "2160p", + HDR: true, + Bitrate: 30_000_000, + EditionKey: "final_cut", + } + + handler := &PlaybackHandler{ + FileVersionFetcher: testPlaybackFileVersionFetcher{ + byContent: map[string][]*models.MediaFile{ + "movie-1": { + source, + { + ID: 2, + ContentID: "movie-1", + Resolution: "1080p", + HDR: false, + Bitrate: 12_000_000, + EditionKey: "theatrical", + }, + { + ID: 3, + ContentID: "movie-1", + Resolution: "1080p", + HDR: false, + Bitrate: 10_000_000, + EditionKey: "final_cut", + }, + }, + }, + }, + } + + alternate, err := handler.findAlternateFile(context.Background(), source) + if err != nil { + t.Fatalf("findAlternateFile: %v", err) + } + if alternate == nil { + t.Fatal("expected alternate file") + } + if alternate.ID != 3 { + t.Fatalf("alternate.ID = %d, want 3", alternate.ID) + } +} diff --git a/internal/api/handlers/plugins.go b/internal/api/handlers/plugins.go new file mode 100644 index 00000000..005b7583 --- /dev/null +++ b/internal/api/handlers/plugins.go @@ -0,0 +1,1368 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/pluginhost" + "github.com/Silo-Server/silo-server/internal/plugins" +) + +const maxPluginUploadSize = 256 << 20 + +type PluginHandler struct { + repositories *plugins.RepositoryStore + installations *plugins.InstallationStore + configs *plugins.RuntimeConfigStore + service *plugins.Service + userConfig *plugins.UserConfigStore + proxy *plugins.HTTPProxy + chainRepo *metadata.ChainRepository + imageResolver *metadata.PluginImageResolver +} + +func NewPluginHandler( + repositories *plugins.RepositoryStore, + installations *plugins.InstallationStore, + configs *plugins.RuntimeConfigStore, + service *plugins.Service, + userConfig *plugins.UserConfigStore, + proxy *plugins.HTTPProxy, + chainRepo *metadata.ChainRepository, + imageResolver *metadata.PluginImageResolver, +) *PluginHandler { + return &PluginHandler{ + repositories: repositories, + installations: installations, + configs: configs, + service: service, + userConfig: userConfig, + proxy: proxy, + chainRepo: chainRepo, + imageResolver: imageResolver, + } +} + +type pluginRepositoryRequest struct { + URL string `json:"url"` + DisplayName string `json:"display_name"` + Enabled *bool `json:"enabled,omitempty"` +} + +type pluginInstallationCreateRequest struct { + RepositoryID *int `json:"repository_id,omitempty"` + PluginID string `json:"plugin_id,omitempty"` + Version string `json:"version,omitempty"` + ArchiveURL string `json:"archive_url,omitempty"` +} + +type pluginInstallationUpdateRequest struct { + Enabled *bool `json:"enabled,omitempty"` + UpdatePolicy *string `json:"update_policy,omitempty"` +} + +type pluginConfigRequest struct { + Key string `json:"key"` + Value map[string]any `json:"value"` +} + +type pluginAuthBindingRequest struct { + CapabilityID string `json:"capability_id"` + Enabled bool `json:"enabled"` + DisplayOrder int `json:"display_order"` + AutoProvision bool `json:"auto_provision"` + DefaultLogin bool `json:"default_login"` +} + +type pluginTaskBindingRequest struct { + Enabled bool `json:"enabled"` + Trigger map[string]any `json:"trigger"` +} + +type userPluginSettingsRequest struct { + Values map[string]string `json:"values"` +} + +type pluginRepositoryResponse struct { + ID int `json:"id"` + URL string `json:"url"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + LastFetchedAt *time.Time `json:"last_fetched_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type pluginCatalogResponse struct { + RepositoryID int `json:"repository_id"` + PluginID string `json:"plugin_id"` + Version string `json:"version"` + ArchiveURL string `json:"archive_url"` + Capabilities []pluginCapabilityJSON `json:"capabilities"` + GlobalConfigSchema []pluginConfigSchemaJSON `json:"global_config_schema"` + UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"` + Routes []pluginRouteJSON `json:"routes"` + Assets []pluginAssetJSON `json:"assets"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type pluginInstallationResponse struct { + ID int `json:"id"` + RepositoryID *int `json:"repository_id,omitempty"` + PluginID string `json:"plugin_id"` + Version string `json:"version"` + InstallPath string `json:"install_path"` + Enabled bool `json:"enabled"` + UpdatePolicy string `json:"update_policy"` + AvailableVersion *string `json:"available_version,omitempty"` + Capabilities []pluginCapabilityJSON `json:"capabilities"` + GlobalConfigSchema []pluginConfigSchemaJSON `json:"global_config_schema"` + UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"` + Routes []pluginRouteJSON `json:"routes"` + Assets []pluginAssetJSON `json:"assets"` + Metadata map[string]any `json:"metadata,omitempty"` + GlobalConfigs []pluginConfigValueJSON `json:"global_configs"` + AuthBindings []pluginAuthBindingJSON `json:"auth_bindings"` + TaskBindings []pluginTaskBindingJSON `json:"task_bindings"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type pluginConfigSchemaJSON struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + JSONSchema string `json:"json_schema"` + Required bool `json:"required"` + AdminForm *pluginAdminFormJSON `json:"admin_form,omitempty"` +} + +type pluginAdminFormJSON struct { + Fields []pluginAdminFormFieldJSON `json:"fields"` + SubmitLabel string `json:"submit_label,omitempty"` +} + +type pluginAdminFormFieldJSON struct { + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Control string `json:"control"` + Placeholder string `json:"placeholder,omitempty"` + Required bool `json:"required"` + Secret bool `json:"secret"` + Multiline bool `json:"multiline"` + DefaultValue any `json:"default_value,omitempty"` + Options []pluginAdminFormOptionJSON `json:"options,omitempty"` + Rows int32 `json:"rows,omitempty"` +} + +type pluginAdminFormOptionJSON struct { + Value string `json:"value"` + Label string `json:"label"` + Description string `json:"description,omitempty"` +} + +type pluginCapabilityJSON struct { + Type string `json:"type"` + ID string `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Subscriptions []string `json:"subscriptions,omitempty"` + ConfigSchema []pluginConfigSchemaJSON `json:"config_schema,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type pluginRouteJSON struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Access string `json:"access"` + Navigable bool `json:"navigable"` + NavigationLabel string `json:"navigation_label"` + NavigationKind string `json:"navigation_kind"` + StaticAsset bool `json:"static_asset"` +} + +type pluginAssetJSON struct { + Path string `json:"path"` + ContentType string `json:"content_type"` + Integrity string `json:"integrity"` +} + +type pluginConfigValueJSON struct { + Key string `json:"key"` + Value map[string]any `json:"value"` +} + +type pluginAuthBindingJSON struct { + CapabilityID string `json:"capability_id"` + Enabled bool `json:"enabled"` + DisplayOrder int `json:"display_order"` + AutoProvision bool `json:"auto_provision"` + DefaultLogin bool `json:"default_login"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type pluginTaskBindingJSON struct { + CapabilityID string `json:"capability_id"` + Enabled bool `json:"enabled"` + Trigger map[string]any `json:"trigger"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type pluginUserSettingsSummary struct { + ID int `json:"id"` + PluginID string `json:"plugin_id"` + Version string `json:"version"` + UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"` + Routes []pluginRouteJSON `json:"routes"` + Assets []pluginAssetJSON `json:"assets"` +} + +type pluginUserSettingsListResponse struct { + Installations []pluginUserSettingsSummary `json:"installations"` +} + +type pluginUserSettingsDetailResponse struct { + Installation pluginUserSettingsSummary `json:"installation"` + Values map[string]string `json:"values"` +} + +type pluginTaskBindingUpdateResponse struct { + RestartRequired bool `json:"restart_required"` +} + +func (h *PluginHandler) HandleListRepositories(w http.ResponseWriter, r *http.Request) { + repositories, err := h.repositories.List(r.Context()) + if err != nil { + slog.Error("listing plugin repositories", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin repositories") + return + } + + response := make([]pluginRepositoryResponse, 0, len(repositories)) + for _, repository := range repositories { + response = append(response, toPluginRepositoryResponse(repository)) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *PluginHandler) HandleCreateRepository(w http.ResponseWriter, r *http.Request) { + var req pluginRepositoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.DisplayName) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "url and display_name are required") + return + } + + repository, err := h.repositories.Create(r.Context(), plugins.CreateRepositoryInput{ + URL: req.URL, + DisplayName: req.DisplayName, + Enabled: req.Enabled, + }) + if err != nil { + slog.Error("creating plugin repository", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create plugin repository") + return + } + + writeJSON(w, http.StatusCreated, toPluginRepositoryResponse(repository)) +} + +func (h *PluginHandler) HandleUpdateRepository(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid repository ID") + return + } + + var req pluginRepositoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + input := plugins.UpdateRepositoryInput{ + Enabled: req.Enabled, + } + if strings.TrimSpace(req.URL) != "" { + input.URL = &req.URL + } + if strings.TrimSpace(req.DisplayName) != "" { + input.DisplayName = &req.DisplayName + } + + if err := h.repositories.Update(r.Context(), id, input); err != nil { + if errors.Is(err, plugins.ErrRepositoryNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found") + return + } + slog.Error("updating plugin repository", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin repository") + return + } + + repository, err := h.repositories.GetByID(r.Context(), id) + if err != nil { + slog.Error("loading updated plugin repository", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin repository") + return + } + + writeJSON(w, http.StatusOK, toPluginRepositoryResponse(repository)) +} + +func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid repository ID") + return + } + + if err := h.repositories.Delete(r.Context(), id); err != nil { + if errors.Is(err, plugins.ErrRepositoryNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found") + return + } + slog.Error("deleting plugin repository", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin repository") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { + entries, err := h.service.FetchCatalog(r.Context()) + if err != nil { + slog.Error("fetching plugin catalog", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch plugin catalog") + return + } + + response := make([]pluginCatalogResponse, 0, len(entries)) + for _, entry := range entries { + response = append(response, pluginCatalogResponse{ + RepositoryID: entry.RepositoryID, + PluginID: entry.Manifest.GetPluginId(), + Version: entry.Manifest.GetVersion(), + ArchiveURL: entry.ArchiveURL, + Capabilities: capabilitiesToJSON(entry.Manifest.GetCapabilities()), + GlobalConfigSchema: configSchemasToJSON(entry.Manifest.GetGlobalConfigSchema()), + UserConfigSchema: configSchemasToJSON(entry.Manifest.GetUserConfigSchema()), + Routes: routesToJSON(entry.Manifest.GetHttpRoutes()), + Assets: assetsToJSON(entry.Manifest.GetAssets()), + Metadata: structToMap(entry.Manifest.GetMetadata()), + }) + } + + writeJSON(w, http.StatusOK, response) +} + +func (h *PluginHandler) HandleListInstallations(w http.ResponseWriter, r *http.Request) { + installations, err := h.installations.List(r.Context()) + if err != nil { + slog.Error("listing plugin installations", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin installations") + return + } + + response, err := h.buildInstallationResponses(r.Context(), installations) + if err != nil { + slog.Error("building plugin installations response", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response") + return + } + + writeJSON(w, http.StatusOK, response) +} + +func (h *PluginHandler) HandleCreateInstallation(w http.ResponseWriter, r *http.Request) { + var req pluginInstallationCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + hasRepositoryFields := req.RepositoryID != nil || strings.TrimSpace(req.PluginID) != "" || strings.TrimSpace(req.Version) != "" + + var ( + result *plugins.InstallResult + err error + ) + switch { + case hasRepositoryFields: + if req.RepositoryID == nil || strings.TrimSpace(req.PluginID) == "" || strings.TrimSpace(req.Version) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "repository_id, plugin_id, and version are required") + return + } + if strings.TrimSpace(req.ArchiveURL) != "" { + writeError(w, http.StatusBadRequest, "bad_request", "archive_url cannot be combined with repository install fields") + return + } + result, err = h.service.InstallCatalog(r.Context(), plugins.InstallCatalogRequest{ + RepositoryID: *req.RepositoryID, + PluginID: req.PluginID, + Version: req.Version, + }) + default: + if strings.TrimSpace(req.ArchiveURL) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "archive_url is required") + return + } + result, err = h.service.InstallRemote(r.Context(), plugins.InstallArchiveRequest{ + ArchiveURL: req.ArchiveURL, + RepositoryID: req.RepositoryID, + }) + } + if err != nil { + slog.Error("installing plugin archive", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install plugin") + return + } + + h.syncMetadataProviders(r.Context(), result.Installation) + h.syncImageResolvers(r.Context(), result.Installation) + + response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest) + if err != nil { + slog.Error("building installed plugin response", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response") + return + } + + writeJSON(w, http.StatusCreated, response) +} + +func (h *PluginHandler) HandleUploadInstallation(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadSize) + if err := r.ParseMultipartForm(maxPluginUploadSize); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid plugin upload") + return + } + + file, header, err := r.FormFile("archive") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "archive upload is required") + return + } + defer file.Close() + + tempFile, err := os.CreateTemp("", "silo-plugin-*.zip") + if err != nil { + slog.Error("creating temp plugin upload file", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload") + return + } + tempPath := tempFile.Name() + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempPath) + }() + + if _, err := io.Copy(tempFile, file); err != nil { + slog.Error("writing temp plugin upload file", "filename", header.Filename, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload") + return + } + + if err := tempFile.Close(); err != nil { + slog.Error("closing temp plugin upload file", "filename", header.Filename, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload") + return + } + + uploadData, err := os.ReadFile(tempPath) + if err != nil { + slog.Error("reading temp plugin upload file", "filename", header.Filename, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload") + return + } + + var result *plugins.InstallResult + if isZipUpload(uploadData) { + result, err = h.service.InstallLocal(r.Context(), plugins.InstallArchiveRequest{ + ArchivePath: tempPath, + }) + } else { + result, err = h.service.InstallBinaryUpload(r.Context(), uploadData) + } + if err != nil { + slog.Error("installing uploaded plugin", "filename", header.Filename, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install uploaded plugin") + return + } + + h.syncMetadataProviders(r.Context(), result.Installation) + h.syncImageResolvers(r.Context(), result.Installation) + + response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest) + if err != nil { + slog.Error("building uploaded plugin response", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response") + return + } + + writeJSON(w, http.StatusCreated, response) +} + +// syncMetadataProviders appends any metadata_provider.v1 capabilities from the +// given installation to all existing library chains. +func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation *plugins.Installation) { + if h.chainRepo == nil { + return + } + + caps, err := h.installations.ListCapabilities(ctx, installation.ID) + if err != nil { + slog.Error("listing capabilities for metadata provider sync", + "installation_id", installation.ID, "error", err) + return + } + + for _, cap := range caps { + if cap.Type != "metadata_provider.v1" { + continue + } + if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int { + return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, level) + }); err != nil { + slog.Warn("failed to append provider to library chains", + "installation_id", installation.ID, + "capability_id", cap.ID, + "error", err) + } + } +} + +// syncImageResolvers registers image resolver sources for any metadata_provider.v1 +// capabilities from the given installation so that plugin-prefixed image URLs +// (e.g. "tmdb://poster/abc.jpg") can be resolved at runtime. +func (h *PluginHandler) syncImageResolvers(ctx context.Context, installation *plugins.Installation) { + if h.imageResolver == nil || h.service == nil { + return + } + + caps, err := h.installations.ListCapabilities(ctx, installation.ID) + if err != nil { + slog.Error("listing capabilities for image resolver sync", + "installation_id", installation.ID, "error", err) + return + } + + for _, cap := range caps { + if cap.Type != "metadata_provider.v1" { + continue + } + source := metadata.NewPluginClientSource(installation.ID, cap.ID, func( + ctx context.Context, installationID int, capabilityID string, + ) (metadata.PluginMetadataClient, error) { + return h.service.MetadataProviderClient(ctx, installationID, capabilityID) + }) + h.imageResolver.RegisterSource(cap.ID, source) + slog.Info("registered plugin image resolver", "capability_id", cap.ID, "installation_id", installation.ID) + } +} + +func isZipUpload(data []byte) bool { + if len(data) < 4 { + return false + } + + return bytes.Equal(data[:4], []byte("PK\x03\x04")) || + bytes.Equal(data[:4], []byte("PK\x05\x06")) || + bytes.Equal(data[:4], []byte("PK\x07\x08")) +} + +func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + + var req pluginInstallationUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + currentInstallation, err := h.installations.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, plugins.ErrInstallationNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + return + } + slog.Error("loading current plugin installation", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation") + return + } + + if req.Enabled != nil && !*req.Enabled && currentInstallation.Enabled && h.service != nil { + if err := h.service.Stop(id); err != nil && !errors.Is(err, pluginhost.ErrClientNotFound) { + slog.Error("stopping plugin before disable", "installation_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to disable plugin installation") + return + } + } + + if err := h.installations.Update(r.Context(), id, plugins.UpdateInstallationInput{ + Enabled: req.Enabled, + UpdatePolicy: req.UpdatePolicy, + }); err != nil { + if errors.Is(err, plugins.ErrInstallationNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + return + } + slog.Error("updating plugin installation", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin installation") + return + } + + // Rebuild the event dispatcher's capability-subscriber index whenever the + // enabled state changes (enable or disable). + if req.Enabled != nil && h.service != nil { + h.service.OnLifecycleChange(r.Context()) + } + + installation, err := h.installations.GetByID(r.Context(), id) + if err != nil { + slog.Error("loading updated plugin installation", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation") + return + } + + // When a plugin is being enabled, register its image resolvers. + if req.Enabled != nil && *req.Enabled && !currentInstallation.Enabled { + h.syncImageResolvers(r.Context(), installation) + } + + response, err := h.buildInstallationResponse(r.Context(), installation, nil) + if err != nil { + slog.Error("building updated plugin installation response", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response") + return + } + + writeJSON(w, http.StatusOK, response) +} + +func (h *PluginHandler) HandleApplyUpdate(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + + installation, err := h.service.UpdateToAvailableVersion(r.Context(), id) + if err != nil { + if errors.Is(err, plugins.ErrInstallationNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + return + } + slog.Error("apply plugin update", "installation_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin") + return + } + + h.syncMetadataProviders(r.Context(), installation) + h.syncImageResolvers(r.Context(), installation) + + response, err := h.buildInstallationResponse(r.Context(), installation, nil) + if err != nil { + slog.Error("building updated plugin installation response", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build response") + return + } + + writeJSON(w, http.StatusOK, response) +} + +func (h *PluginHandler) HandlePutInstallationConfig(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + + var req pluginConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if h.service == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Plugin service not configured") + return + } + + if err := h.service.SetGlobalConfig(r.Context(), id, req.Key, req.Value); err != nil { + var validationErr *plugins.ConfigValidationError + switch { + case errors.As(err, &validationErr): + writeError(w, http.StatusBadRequest, "bad_request", validationErr.Error()) + case errors.Is(err, plugins.ErrInstallationNotFound): + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + default: + slog.Error("setting plugin global config", "installation_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save plugin config") + } + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *PluginHandler) HandleTestInstallationConfig(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + if h.service == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Plugin service not configured") + return + } + + var req pluginConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if strings.TrimSpace(req.Key) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "key is required") + return + } + + if err := h.service.TestGlobalConfig(r.Context(), id, req.Key, req.Value); err != nil { + if errors.Is(err, plugins.ErrInstallationNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + return + } + + var connectionErr *plugins.ConnectionTestError + if errors.As(err, &connectionErr) { + writeJSON(w, http.StatusOK, connectionCheckResponse{ + Success: false, + Message: connectionErr.Error(), + }) + return + } + + slog.Error("testing plugin config", "installation_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to test plugin config") + return + } + + writeJSON(w, http.StatusOK, connectionCheckResponse{ + Success: true, + Message: "Connection successful.", + }) +} + +func (h *PluginHandler) HandlePutAuthBinding(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + + var req pluginAuthBindingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if strings.TrimSpace(req.CapabilityID) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required") + return + } + + if err := h.configs.UpsertAuthBinding(r.Context(), plugins.AuthBinding{ + InstallationID: id, + CapabilityID: req.CapabilityID, + Enabled: req.Enabled, + DisplayOrder: req.DisplayOrder, + AutoProvision: req.AutoProvision, + DefaultLogin: req.DefaultLogin, + }); err != nil { + slog.Error("saving plugin auth binding", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save auth binding") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *PluginHandler) HandlePutTaskBinding(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + + capabilityID := chi.URLParam(r, "capability_id") + if strings.TrimSpace(capabilityID) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required") + return + } + + var req pluginTaskBindingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if err := h.configs.UpsertTaskBinding(r.Context(), plugins.TaskBinding{ + InstallationID: id, + CapabilityID: capabilityID, + Enabled: req.Enabled, + Trigger: req.Trigger, + }); err != nil { + slog.Error("saving plugin task binding", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save task binding") + return + } + + writeJSON(w, http.StatusOK, pluginTaskBindingUpdateResponse{RestartRequired: true}) +} + +func (h *PluginHandler) HandleDeleteInstallation(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + + if err := h.installations.Delete(r.Context(), id); err != nil { + if errors.Is(err, plugins.ErrInstallationNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + return + } + slog.Error("deleting plugin installation", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin installation") + return + } + + // Rebuild the event dispatcher's capability-subscriber index after removal. + if h.service != nil { + h.service.OnLifecycleChange(r.Context()) + } + + w.WriteHeader(http.StatusNoContent) +} + +// manifestHasUserNavigableRoute returns true if the manifest declares any +// navigable HTTP route with navigation_kind="user". Such plugins should +// appear in the user-facing plugin list (and therefore in the user sidebar) +// even if they expose no user_config_schema. +func manifestHasUserNavigableRoute(manifest *pluginv1.PluginManifest) bool { + for _, r := range manifest.GetHttpRoutes() { + if r.GetNavigable() && r.GetNavigationKind() == "user" { + return true + } + } + return false +} + +func (h *PluginHandler) HandleListUserPluginSettings(w http.ResponseWriter, r *http.Request) { + installations, err := h.installations.ListEnabled(r.Context()) + if err != nil { + slog.Error("listing enabled plugin installations", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin settings") + return + } + + response := pluginUserSettingsListResponse{ + Installations: make([]pluginUserSettingsSummary, 0, len(installations)), + } + for _, installation := range installations { + manifest, err := plugins.LoadManifestFile(plugins.InstalledManifestPath(installation.InstallPath)) + if err != nil { + slog.Error("loading plugin manifest", "installation_id", installation.ID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings") + return + } + if len(manifest.GetUserConfigSchema()) == 0 && !manifestHasUserNavigableRoute(manifest) { + continue + } + response.Installations = append(response.Installations, toUserPluginSettingsSummary(installation, manifest)) + } + + writeJSON(w, http.StatusOK, response) +} + +func (h *PluginHandler) HandleGetUserPluginSettings(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "installation_id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + + installation, manifest, err := h.loadUserConfigInstallation(w, r, id) + if err != nil { + return + } + + userID := apimw.GetUserID(r.Context()) + values, err := h.userConfig.Get(r.Context(), userID, id) + if err != nil { + slog.Error("loading plugin user config", "installation_id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings") + return + } + + writeJSON(w, http.StatusOK, pluginUserSettingsDetailResponse{ + Installation: toUserPluginSettingsSummary(installation, manifest), + Values: values, + }) +} + +func (h *PluginHandler) HandlePutUserPluginSettings(w http.ResponseWriter, r *http.Request) { + id, err := parseNamedIDParam(r, "installation_id") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID") + return + } + + if _, _, err := h.loadUserConfigInstallation(w, r, id); err != nil { + return + } + + var req userPluginSettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + userID := apimw.GetUserID(r.Context()) + if err := h.userConfig.Set(r.Context(), userID, id, req.Values); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *PluginHandler) loadUserConfigInstallation( + w http.ResponseWriter, + r *http.Request, + installationID int, +) (*plugins.Installation, *pluginv1.PluginManifest, error) { + installation, err := h.installations.GetByID(r.Context(), installationID) + if err != nil { + if errors.Is(err, plugins.ErrInstallationNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + return nil, nil, err + } + slog.Error("loading plugin installation", "installation_id", installationID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation") + return nil, nil, err + } + if !installation.Enabled { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") + return nil, nil, plugins.ErrInstallationNotFound + } + + manifest, err := h.loadInstallationManifest(r.Context(), installation) + if err != nil { + slog.Error("loading plugin manifest", "installation_id", installationID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings") + return nil, nil, err + } + if len(manifest.GetUserConfigSchema()) == 0 && !manifestHasUserNavigableRoute(manifest) { + writeError(w, http.StatusNotFound, "not_found", "Plugin installation does not expose user settings") + return nil, nil, plugins.ErrInstallationNotFound + } + + return installation, manifest, nil +} + +func (h *PluginHandler) buildInstallationResponses( + ctx context.Context, + installations []*plugins.Installation, +) ([]pluginInstallationResponse, error) { + authBindings, err := h.configs.ListAuthBindings(ctx) + if err != nil { + return nil, err + } + taskBindings, err := h.configs.ListTaskBindings(ctx) + if err != nil { + return nil, err + } + response := make([]pluginInstallationResponse, 0, len(installations)) + for _, installation := range installations { + item, err := h.buildInstallationResponseWithBindings( + ctx, + installation, + nil, + authBindings, + taskBindings, + ) + if err != nil { + return nil, err + } + response = append(response, item) + } + return response, nil +} + +func (h *PluginHandler) buildInstallationResponse( + ctx context.Context, + installation *plugins.Installation, + manifest *pluginv1.PluginManifest, +) (pluginInstallationResponse, error) { + authBindings, err := h.configs.ListAuthBindings(ctx) + if err != nil { + return pluginInstallationResponse{}, err + } + taskBindings, err := h.configs.ListTaskBindings(ctx) + if err != nil { + return pluginInstallationResponse{}, err + } + return h.buildInstallationResponseWithBindings( + ctx, + installation, + manifest, + authBindings, + taskBindings, + ) +} + +func (h *PluginHandler) buildInstallationResponseWithBindings( + ctx context.Context, + installation *plugins.Installation, + manifest *pluginv1.PluginManifest, + authBindings []*plugins.AuthBinding, + taskBindings []*plugins.TaskBinding, +) (pluginInstallationResponse, error) { + if manifest == nil { + var err error + manifest, err = h.loadInstallationManifest(ctx, installation) + if err != nil && !errors.Is(err, plugins.ErrArchiveNotFound) { + return pluginInstallationResponse{}, err + } + } + + capabilities, err := h.loadInstallationCapabilities(ctx, installation, manifest) + if err != nil { + return pluginInstallationResponse{}, err + } + configs, err := h.configs.ListGlobalConfigs(ctx, installation.ID) + if err != nil { + return pluginInstallationResponse{}, err + } + + var ( + globalConfigSchema []pluginConfigSchemaJSON + userConfigSchema []pluginConfigSchemaJSON + routes []pluginRouteJSON + assets []pluginAssetJSON + metadata map[string]any + ) + if manifest != nil { + globalConfigSchema = configSchemasToJSON(manifest.GetGlobalConfigSchema()) + userConfigSchema = configSchemasToJSON(manifest.GetUserConfigSchema()) + routes = routesToJSON(manifest.GetHttpRoutes()) + assets = assetsToJSON(manifest.GetAssets()) + metadata = structToMap(manifest.GetMetadata()) + } + + return pluginInstallationResponse{ + ID: installation.ID, + RepositoryID: installation.RepositoryID, + PluginID: installation.PluginID, + Version: installation.Version, + InstallPath: installation.InstallPath, + Enabled: installation.Enabled, + UpdatePolicy: installation.UpdatePolicy, + AvailableVersion: installation.AvailableVersion, + Capabilities: capabilities, + GlobalConfigSchema: globalConfigSchema, + UserConfigSchema: userConfigSchema, + Routes: routes, + Assets: assets, + Metadata: metadata, + GlobalConfigs: configValuesToJSON(configs), + AuthBindings: authBindingsForInstallation(installation.ID, authBindings), + TaskBindings: taskBindingsForInstallation(installation.ID, taskBindings), + CreatedAt: installation.CreatedAt, + UpdatedAt: installation.UpdatedAt, + }, nil +} + +func toPluginRepositoryResponse(repository *plugins.Repository) pluginRepositoryResponse { + return pluginRepositoryResponse{ + ID: repository.ID, + URL: repository.URL, + DisplayName: repository.DisplayName, + Enabled: repository.Enabled, + LastFetchedAt: repository.LastFetchedAt, + CreatedAt: repository.CreatedAt, + UpdatedAt: repository.UpdatedAt, + } +} + +func toUserPluginSettingsSummary( + installation *plugins.Installation, + manifest *pluginv1.PluginManifest, +) pluginUserSettingsSummary { + return pluginUserSettingsSummary{ + ID: installation.ID, + PluginID: installation.PluginID, + Version: installation.Version, + UserConfigSchema: configSchemasToJSON(manifest.GetUserConfigSchema()), + Routes: routesToJSON(manifest.GetHttpRoutes()), + Assets: assetsToJSON(manifest.GetAssets()), + } +} + +func configSchemasToJSON(schemas []*pluginv1.ConfigSchema) []pluginConfigSchemaJSON { + response := make([]pluginConfigSchemaJSON, 0, len(schemas)) + for _, schema := range schemas { + if schema == nil { + continue + } + response = append(response, pluginConfigSchemaJSON{ + Key: schema.GetKey(), + Title: schema.GetTitle(), + Description: schema.GetDescription(), + JSONSchema: schema.GetJsonSchema(), + Required: schema.GetRequired(), + AdminForm: adminFormToJSON(schema.GetAdminForm()), + }) + } + return response +} + +func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON { + if form == nil { + return nil + } + fields := make([]pluginAdminFormFieldJSON, 0, len(form.GetFields())) + for _, field := range form.GetFields() { + if field == nil { + continue + } + options := make([]pluginAdminFormOptionJSON, 0, len(field.GetOptions())) + for _, option := range field.GetOptions() { + if option == nil { + continue + } + options = append(options, pluginAdminFormOptionJSON{ + Value: option.GetValue(), + Label: option.GetLabel(), + Description: option.GetDescription(), + }) + } + var defaultValue any + if field.GetDefaultValue() != nil { + defaultValue = field.GetDefaultValue().AsInterface() + } + fields = append(fields, pluginAdminFormFieldJSON{ + Key: field.GetKey(), + Label: field.GetLabel(), + Description: field.GetDescription(), + Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"), + Placeholder: field.GetPlaceholder(), + Required: field.GetRequired(), + Secret: field.GetSecret(), + Multiline: field.GetMultiline(), + DefaultValue: defaultValue, + Options: options, + Rows: field.GetRows(), + }) + } + return &pluginAdminFormJSON{ + Fields: fields, + SubmitLabel: form.GetSubmitLabel(), + } +} + +func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON { + response := make([]pluginCapabilityJSON, 0, len(descriptors)) + for _, descriptor := range descriptors { + if descriptor == nil { + continue + } + response = append(response, pluginCapabilityJSON{ + Type: descriptor.GetType(), + ID: descriptor.GetId(), + DisplayName: descriptor.GetDisplayName(), + Description: descriptor.GetDescription(), + Subscriptions: append([]string(nil), descriptor.GetSubscriptions()...), + ConfigSchema: configSchemasToJSON(descriptor.GetConfigSchema()), + Metadata: structToMap(descriptor.GetMetadata()), + }) + } + return response +} + +func routesToJSON(routes []*pluginv1.HttpRouteDescriptor) []pluginRouteJSON { + response := make([]pluginRouteJSON, 0, len(routes)) + for _, route := range routes { + if route == nil { + continue + } + response = append(response, pluginRouteJSON{ + ID: route.GetId(), + Method: route.GetMethod(), + Path: route.GetPath(), + Access: route.GetAccess(), + Navigable: route.GetNavigable(), + NavigationLabel: route.GetNavigationLabel(), + NavigationKind: route.GetNavigationKind(), + StaticAsset: route.GetStaticAsset(), + }) + } + return response +} + +func assetsToJSON(assets []*pluginv1.PackagedAsset) []pluginAssetJSON { + response := make([]pluginAssetJSON, 0, len(assets)) + for _, asset := range assets { + if asset == nil { + continue + } + response = append(response, pluginAssetJSON{ + Path: asset.GetPath(), + ContentType: asset.GetContentType(), + Integrity: asset.GetIntegrity(), + }) + } + return response +} + +func configValuesToJSON(configs []*plugins.RuntimeConfig) []pluginConfigValueJSON { + response := make([]pluginConfigValueJSON, 0, len(configs)) + for _, config := range configs { + if config == nil { + continue + } + response = append(response, pluginConfigValueJSON{ + Key: config.Key, + Value: config.Value, + }) + } + return response +} + +func authBindingsForInstallation(installationID int, bindings []*plugins.AuthBinding) []pluginAuthBindingJSON { + response := make([]pluginAuthBindingJSON, 0) + for _, binding := range bindings { + if binding == nil || binding.InstallationID != installationID { + continue + } + response = append(response, pluginAuthBindingJSON{ + CapabilityID: binding.CapabilityID, + Enabled: binding.Enabled, + DisplayOrder: binding.DisplayOrder, + AutoProvision: binding.AutoProvision, + DefaultLogin: binding.DefaultLogin, + CreatedAt: binding.CreatedAt, + UpdatedAt: binding.UpdatedAt, + }) + } + return response +} + +func taskBindingsForInstallation(installationID int, bindings []*plugins.TaskBinding) []pluginTaskBindingJSON { + response := make([]pluginTaskBindingJSON, 0) + for _, binding := range bindings { + if binding == nil || binding.InstallationID != installationID { + continue + } + response = append(response, pluginTaskBindingJSON{ + CapabilityID: binding.CapabilityID, + Enabled: binding.Enabled, + Trigger: binding.Trigger, + CreatedAt: binding.CreatedAt, + UpdatedAt: binding.UpdatedAt, + }) + } + return response +} + +func (h *PluginHandler) loadInstallationManifest( + ctx context.Context, + installation *plugins.Installation, +) (*pluginv1.PluginManifest, error) { + if installation == nil { + return nil, errors.New("plugin installation is required") + } + if h.service != nil { + manifest, err := h.service.ManifestForInstallation(ctx, installation.ID) + if err == nil { + return manifest, nil + } + if errors.Is(err, plugins.ErrArchiveNotFound) { + return nil, plugins.ErrArchiveNotFound + } + if !errors.Is(err, plugins.ErrArchiveNotFound) { + return nil, err + } + } + manifest, err := loadPluginManifest(installation) + if err != nil && errors.Is(err, os.ErrNotExist) { + return nil, plugins.ErrArchiveNotFound + } + return manifest, err +} + +func (h *PluginHandler) loadInstallationCapabilities( + ctx context.Context, + installation *plugins.Installation, + manifest *pluginv1.PluginManifest, +) ([]pluginCapabilityJSON, error) { + if manifest != nil { + return capabilitiesToJSON(manifest.GetCapabilities()), nil + } + + records, err := h.installations.ListCapabilities(ctx, installation.ID) + if err != nil { + return nil, err + } + + response := make([]pluginCapabilityJSON, 0, len(records)) + for _, record := range records { + descriptor, err := plugins.DecodeCapability(record) + if err != nil { + return nil, err + } + response = append(response, capabilitiesToJSON([]*pluginv1.CapabilityDescriptor{descriptor})...) + } + return response, nil +} + +func loadPluginManifest(installation *plugins.Installation) (*pluginv1.PluginManifest, error) { + return plugins.LoadManifestFile(plugins.InstalledManifestPath(installation.InstallPath)) +} + +func structToMap(value interface{ AsMap() map[string]any }) map[string]any { + if value == nil { + return nil + } + return value.AsMap() +} + +func parseNamedIDParam(r *http.Request, name string) (int, error) { + return strconv.Atoi(chi.URLParam(r, name)) +} diff --git a/internal/api/handlers/profile_avatars.go b/internal/api/handlers/profile_avatars.go new file mode 100644 index 00000000..a1a8ab35 --- /dev/null +++ b/internal/api/handlers/profile_avatars.go @@ -0,0 +1,330 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/imageutil" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +const ( + profileAvatarPresetPrefix = "preset:" + profileAvatarUploadPrefix = "upload:" + profileAvatarMaxFileSize = 10 << 20 +) + +var legacyProfileAvatarIDs = map[string]struct{}{ + "avatar-1": {}, + "avatar-2": {}, + "avatar-3": {}, + "avatar-4": {}, + "avatar-5": {}, + "avatar-6": {}, + "avatar-7": {}, + "avatar-8": {}, +} + +var supportedDiceBearAvatarStyles = map[string]struct{}{ + "identicon": {}, + "initials": {}, + "bottts-neutral": {}, + "fun-emoji": {}, + "pixel-art-neutral": {}, +} + +type profileAvatarStore interface { + PutObject(ctx context.Context, bucket, key string, data []byte) error + DeleteObject(ctx context.Context, bucket, key string) error + ListObjects(ctx context.Context, bucket, prefix string) ([]string, error) + PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) + Bucket() string +} + +func normalizePresetAvatarReference(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", nil + } + if strings.HasPrefix(value, profileAvatarUploadPrefix) { + return "", fmt.Errorf("custom upload avatar references are not allowed in JSON profile updates") + } + if strings.HasPrefix(value, profileAvatarPresetPrefix) { + presetID := strings.TrimPrefix(value, profileAvatarPresetPrefix) + if !isKnownPresetAvatarID(presetID) { + return "", fmt.Errorf("unknown avatar preset") + } + return profileAvatarPresetPrefix + presetID, nil + } + if !isKnownPresetAvatarID(value) { + return "", fmt.Errorf("unknown avatar preset") + } + return profileAvatarPresetPrefix + value, nil +} + +func isKnownPresetAvatarID(id string) bool { + if _, ok := legacyProfileAvatarIDs[id]; ok { + return true + } + _, _, ok := parseDiceBearPresetID(id) + return ok +} + +func isUploadedAvatarRef(ref string) bool { + return strings.HasPrefix(strings.TrimSpace(ref), profileAvatarUploadPrefix) +} + +func avatarRefReplacesUpload(currentRef, nextRef string) bool { + return isUploadedAvatarRef(currentRef) && strings.TrimSpace(currentRef) != strings.TrimSpace(nextRef) +} + +func resolveProfileAvatar(ctx context.Context, store profileAvatarStore, ttl time.Duration, ref string) (source string, url string) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "none", "" + } + if strings.HasPrefix(trimmed, profileAvatarPresetPrefix) { + presetID := strings.TrimPrefix(trimmed, profileAvatarPresetPrefix) + if !isKnownPresetAvatarID(presetID) { + return "none", "" + } + return "preset", bundledProfileAvatarURL(presetID) + } + if strings.HasPrefix(trimmed, profileAvatarUploadPrefix) { + if store == nil { + return "upload", "" + } + displayKey := uploadedAvatarDisplayKey(strings.TrimPrefix(trimmed, profileAvatarUploadPrefix)) + presignTTL := ttl + if presignTTL <= 0 { + presignTTL = 15 * time.Minute + } + presignedURL, err := store.PresignGetURL(ctx, store.Bucket(), displayKey, presignTTL) + if err != nil { + return "upload", "" + } + return "upload", presignedURL + } + if isKnownPresetAvatarID(trimmed) { + return "preset", bundledProfileAvatarURL(trimmed) + } + return "none", "" +} + +func bundledProfileAvatarURL(id string) string { + if style, seed, ok := parseDiceBearPresetID(id); ok { + return diceBearAvatarURL(style, seed) + } + return "/profile-avatars/" + id + ".svg" +} + +func parseDiceBearPresetID(id string) (style string, seed string, ok bool) { + parts := strings.Split(id, ":") + if len(parts) != 3 || parts[0] != "dicebear" { + return "", "", false + } + style = strings.TrimSpace(parts[1]) + seed = strings.TrimSpace(parts[2]) + if _, supported := supportedDiceBearAvatarStyles[style]; !supported { + return "", "", false + } + if !isSafeAvatarSeed(seed) { + return "", "", false + } + return style, seed, true +} + +func isSafeAvatarSeed(seed string) bool { + if seed == "" || len(seed) > 64 { + return false + } + for _, char := range seed { + switch { + case char >= 'a' && char <= 'z': + case char >= 'A' && char <= 'Z': + case char >= '0' && char <= '9': + case char == '-': + default: + return false + } + } + return true +} + +func diceBearAvatarURL(style string, seed string) string { + query := url.Values{} + query.Set("seed", seed) + query.Set("size", "128") + query.Set("radius", "24") + query.Set("backgroundType", "gradientLinear") + return "https://api.dicebear.com/9.x/" + style + "/svg?" + query.Encode() +} + +func profileAvatarPrefix(userID int, profileID string) string { + return fmt.Sprintf("profile-avatars/%d/%s", userID, profileID) +} + +func uploadedAvatarOriginalKey(userID int, profileID string) string { + return profileAvatarPrefix(userID, profileID) + "/original.webp" +} + +func uploadedAvatarDisplayKey(originalKey string) string { + if strings.HasSuffix(originalKey, "/original.webp") { + return strings.TrimSuffix(originalKey, "/original.webp") + "/w256.webp" + } + return strings.TrimRight(originalKey, "/") + "/w256.webp" +} + +func deleteUploadedAvatarObjects(ctx context.Context, store profileAvatarStore, userID int, profileID string) error { + if store == nil { + return nil + } + keys, err := store.ListObjects(ctx, store.Bucket(), profileAvatarPrefix(userID, profileID)+"/") + if err != nil { + return err + } + for _, key := range keys { + if err := store.DeleteObject(ctx, store.Bucket(), key); err != nil { + return err + } + } + return nil +} + +func (h *ProfileHandler) HandleUploadAvatar(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + if h.AvatarStore == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Avatar upload storage is not configured") + return + } + + profileID := chi.URLParam(r, "id") + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + profile, err := store.GetProfile(r.Context(), profileID) + if err != nil || profile == nil { + writeError(w, http.StatusNotFound, "not_found", "Profile not found") + return + } + + if err := r.ParseMultipartForm(profileAvatarMaxFileSize); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form") + return + } + + file, header, err := r.FormFile("avatar") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Missing avatar file") + return + } + defer file.Close() + + if posterExtension(header.Header.Get("Content-Type")) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Unsupported image type; use JPEG, PNG, or WebP") + return + } + + data, err := io.ReadAll(io.LimitReader(file, profileAvatarMaxFileSize+1)) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload") + return + } + if len(data) > profileAvatarMaxFileSize { + writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Avatar must be under 10 MB") + return + } + + result, err := imageutil.GenerateSquareVariants(data, []int{256}) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid image file") + return + } + + bucket := h.AvatarStore.Bucket() + originalKey := uploadedAvatarOriginalKey(userID, profileID) + for _, variant := range result.Variants { + key := profileAvatarPrefix(userID, profileID) + "/" + variant.Key + result.Ext + if err := h.AvatarStore.PutObject(r.Context(), bucket, key, variant.Data); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store avatar") + return + } + } + + avatarRef := profileAvatarUploadPrefix + originalKey + if err := store.UpdateProfile(r.Context(), profileID, userstore.UpdateProfileInput{Avatar: &avatarRef}); err != nil { + // Uploaded avatar keys are stable per profile, so rolling back by deleting the + // prefix here can remove the avatar the profile still references after a DB failure. + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save avatar") + return + } + + updatedProfile, err := store.GetProfile(r.Context(), profileID) + if err != nil || updatedProfile == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve updated profile") + return + } + + writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *updatedProfile)) +} + +func (h *ProfileHandler) HandleDeleteAvatar(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + profileID := chi.URLParam(r, "id") + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + profile, err := store.GetProfile(r.Context(), profileID) + if err != nil || profile == nil { + writeError(w, http.StatusNotFound, "not_found", "Profile not found") + return + } + + if isUploadedAvatarRef(profile.Avatar) { + emptyRef := "" + if err := store.UpdateProfile(r.Context(), profileID, userstore.UpdateProfileInput{Avatar: &emptyRef}); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear avatar") + return + } + _ = deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID) + } + + updatedProfile, err := store.GetProfile(r.Context(), profileID) + if err != nil || updatedProfile == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve updated profile") + return + } + + writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *updatedProfile)) +} diff --git a/internal/api/handlers/profile_refresh.go b/internal/api/handlers/profile_refresh.go new file mode 100644 index 00000000..7e9809d7 --- /dev/null +++ b/internal/api/handlers/profile_refresh.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "context" + "log/slog" +) + +// ProfileStaler marks a user's taste profile as stale so it gets refreshed. +type ProfileStaler interface { + MarkProfileStale(ctx context.Context, userID int, profileID string) error +} + +// ProfileRefreshRequester enqueues an asynchronous profile-scoped recommendation refresh. +type ProfileRefreshRequester interface { + RequestProfileRefresh(ctx context.Context, userID int, profileID string) +} + +func triggerProfileRefresh(ctx context.Context, staler ProfileStaler, requester ProfileRefreshRequester, userID int, profileID string) { + if staler != nil { + if err := staler.MarkProfileStale(ctx, userID, profileID); err != nil { + slog.Warn("failed to mark profile stale", "user_id", userID, "profile_id", profileID, "error", err) + } + } + if requester != nil { + requester.RequestProfileRefresh(ctx, userID, profileID) + } +} diff --git a/internal/api/handlers/profiles.go b/internal/api/handlers/profiles.go new file mode 100644 index 00000000..d66c19f4 --- /dev/null +++ b/internal/api/handlers/profiles.go @@ -0,0 +1,724 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// ProfileHandler handles profile CRUD endpoints. +type ProfileHandler struct { + storeProvider userstore.UserStoreProvider + SessionsReader playbackSessionsReader + UserRepo interface { + GetByID(ctx context.Context, id int) (*models.User, error) + } + ProfileTokens *access.ProfileTokenService + AvatarStore profileAvatarStore + AvatarTTL time.Duration +} + +// NewProfileHandler creates a new ProfileHandler. +func NewProfileHandler(provider userstore.UserStoreProvider) *ProfileHandler { + return &ProfileHandler{ + storeProvider: provider, + AvatarTTL: 15 * time.Minute, + } +} + +// --- Request/Response types --- + +type createProfileRequest struct { + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + PIN string `json:"pin,omitempty"` + IsChild bool `json:"is_child"` + MaxContentRating string `json:"max_content_rating,omitempty"` + QualityPreference string `json:"quality_preference,omitempty"` + Language string `json:"language,omitempty"` + SubtitleLanguage string `json:"subtitle_language,omitempty"` + SubtitleMode string `json:"subtitle_mode,omitempty"` + AutoSkipIntro bool `json:"auto_skip_intro"` + AutoSkipCredits bool `json:"auto_skip_credits"` + ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"` + LibraryRestrictionsEnabled bool `json:"library_restrictions_enabled"` + AllowedLibraryIDs []int `json:"allowed_library_ids"` + MaxPlaybackQuality string `json:"max_playback_quality"` +} + +type updateProfileRequest struct { + Name *string `json:"name,omitempty"` + Avatar *string `json:"avatar,omitempty"` + PIN *string `json:"pin,omitempty"` + IsChild *bool `json:"is_child,omitempty"` + MaxContentRating *string `json:"max_content_rating,omitempty"` + QualityPreference *string `json:"quality_preference,omitempty"` + Language *string `json:"language,omitempty"` + SubtitleLanguage *string `json:"subtitle_language,omitempty"` + SubtitleMode *string `json:"subtitle_mode,omitempty"` + AutoSkipIntro *bool `json:"auto_skip_intro,omitempty"` + AutoSkipCredits *bool `json:"auto_skip_credits,omitempty"` + ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"` + LibraryRestrictionsEnabled *bool `json:"library_restrictions_enabled,omitempty"` + AllowedLibraryIDs *[]int `json:"allowed_library_ids,omitempty"` + MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"` +} + +type verifyPINRequest struct { + PIN string `json:"pin"` +} + +type profileResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + AvatarSource string `json:"avatar_source,omitempty"` + HasPIN bool `json:"has_pin"` + IsChild bool `json:"is_child"` + IsPrimary bool `json:"is_primary"` + MaxContentRating string `json:"max_content_rating,omitempty"` + QualityPreference string `json:"quality_preference,omitempty"` + Language string `json:"language,omitempty"` + SubtitleLanguage string `json:"subtitle_language,omitempty"` + SubtitleMode string `json:"subtitle_mode,omitempty"` + AutoSkipIntro bool `json:"auto_skip_intro"` + AutoSkipCredits bool `json:"auto_skip_credits"` + ShowForcedSubtitles bool `json:"show_forced_subtitles"` + LibraryRestrictionsEnabled bool `json:"library_restrictions_enabled"` + AllowedLibraryIDs []int `json:"allowed_library_ids"` + MaxPlaybackQuality string `json:"max_playback_quality"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type profileListResponse struct { + Profiles []profileResponse `json:"profiles"` + AvatarUploadEnabled bool `json:"avatar_upload_enabled"` +} + +type verifyPINResponse struct { + Valid bool `json:"valid"` + ProfileToken string `json:"profile_token,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +func isAdminProfileRequest(ctx context.Context) bool { + claims := apimw.GetClaims(ctx) + return claims != nil && claims.Role == "admin" +} + +// canManageHouseholdProfiles reports whether the caller may create/update/delete +// profiles belonging to their user. Server admins always can. Otherwise the +// caller's active profile must be the primary profile for the household. +// +// When that primary profile has a PIN, management also requires a valid +// X-Profile-Token from `/profiles/{id}/verify-pin`; otherwise a client could +// bypass the profile lock by sending only X-Profile-Id. +func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) { + ctx := r.Context() + if isAdminProfileRequest(ctx) { + return true, nil + } + activeProfileID := apimw.GetProfileID(ctx) + if activeProfileID == "" { + activeProfileID = r.Header.Get("X-Profile-Id") + } + if activeProfileID == "" { + return false, nil + } + active, err := store.GetProfile(ctx, activeProfileID) + if err != nil { + return false, err + } + if active == nil { + return false, nil + } + if !active.IsPrimary { + return false, nil + } + if active.PINHash == "" { + return true, nil + } + if err := h.requireVerifiedProfileToken(r, active.ID); err != nil { + return false, err + } + return true, nil +} + +func (h *ProfileHandler) requireVerifiedProfileToken(r *http.Request, profileID string) error { + if h.UserRepo == nil || h.ProfileTokens == nil { + return access.ErrProfileUnverified + } + + claims := apimw.GetClaims(r.Context()) + if claims == nil || claims.SessionID == "" { + return access.ErrProfileUnverified + } + + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + return access.ErrProfileUnverified + } + + user, err := h.UserRepo.GetByID(r.Context(), userID) + if err != nil { + return fmt.Errorf("loading user policy: %w", err) + } + if user == nil { + return access.ErrProfileUnverified + } + + profileClaims, err := h.ProfileTokens.Validate(r.Header.Get("X-Profile-Token")) + if err != nil { + return err + } + if profileClaims.UserID != userID || + profileClaims.SessionID != claims.SessionID || + profileClaims.ProfileID != profileID || + profileClaims.PolicyRevision != user.AccessPolicyRevision { + return access.ErrProfileUnverified + } + + return nil +} + +func writeProfileManagementPermissionError(w http.ResponseWriter, err error) { + if errors.Is(err, access.ErrProfileUnverified) { + writeError(w, http.StatusForbidden, "forbidden", "Profile management requires verifying the primary profile PIN") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check profile permissions") +} + +// isAllowedSelfServiceProfileUpdate reports whether a non-admin update request +// only touches fields the user is allowed to change on their own profiles. +// Admin-only fields (access policy: library restrictions, content rating, +// playback-quality cap, child-profile flag) must be rejected for non-admins. +func isAllowedSelfServiceProfileUpdate(req updateProfileRequest) bool { + return req.IsChild == nil && + req.MaxContentRating == nil && + req.LibraryRestrictionsEnabled == nil && + req.AllowedLibraryIDs == nil && + req.MaxPlaybackQuality == nil +} + +// --- Handler methods --- + +// HandleListProfiles handles GET /profiles. +func (h *ProfileHandler) HandleListProfiles(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + profiles, err := store.ListProfiles(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles") + return + } + + resp := profileListResponse{ + Profiles: make([]profileResponse, 0, len(profiles)), + AvatarUploadEnabled: h.AvatarStore != nil, + } + for _, p := range profiles { + resp.Profiles = append(resp.Profiles, h.toProfileResponse(r.Context(), p)) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleCreateProfile handles POST /profiles. +func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req createProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Name == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile name is required") + return + } + avatarRef, err := normalizePresetAvatarReference(req.Avatar) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality) + if !ok { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + existingProfiles, err := store.ListProfiles(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles") + return + } + // The very first profile on a user can be bootstrapped without + // primary/admin privileges (it becomes the primary); everything after + // requires either the server admin role or the caller's active profile + // being primary. + isBootstrap := len(existingProfiles) == 0 + if !isBootstrap { + allowed, err := h.canManageHouseholdProfiles(r, store) + if err != nil { + writeProfileManagementPermissionError(w, err) + return + } + if !allowed { + writeError(w, http.StatusForbidden, "forbidden", "Profile management requires the primary profile or admin access") + return + } + } + // Access-policy fields only make sense when set by a manager on a managed + // profile. On bootstrap the caller is becoming primary themselves, so non- + // admin bootstrap creations must leave those fields at their defaults. + if isBootstrap && !isAdminProfileRequest(r.Context()) && + (req.IsChild || req.MaxContentRating != "" || + req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 || + req.MaxPlaybackQuality != "") { + writeError( + w, + http.StatusForbidden, + "forbidden", + "Profile access settings require the primary profile or admin access", + ) + return + } + if h.UserRepo != nil { + user, err := h.UserRepo.GetByID(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user") + return + } + if user != nil && user.MaxProfiles >= 1 && len(existingProfiles) >= user.MaxProfiles { + writeError( + w, + http.StatusConflict, + "profile_limit_reached", + fmt.Sprintf("This account has reached its profile limit (%d)", user.MaxProfiles), + ) + return + } + } + + showForcedSubtitles := true + if req.ShowForcedSubtitles != nil { + showForcedSubtitles = *req.ShowForcedSubtitles + } + + profileID := uuid.New().String() + profile := userstore.Profile{ + ID: profileID, + Name: req.Name, + Avatar: avatarRef, + IsChild: req.IsChild, + MaxContentRating: req.MaxContentRating, + QualityPreference: req.QualityPreference, + Language: req.Language, + SubtitleLanguage: req.SubtitleLanguage, + SubtitleMode: req.SubtitleMode, + AutoSkipIntro: req.AutoSkipIntro, + AutoSkipCredits: req.AutoSkipCredits, + ShowForcedSubtitles: showForcedSubtitles, + LibraryRestrictionsEnabled: req.LibraryRestrictionsEnabled, + AllowedLibraryIDs: req.AllowedLibraryIDs, + MaxPlaybackQuality: maxPlaybackQuality, + } + + if err := store.CreateProfile(r.Context(), profile); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create profile") + return + } + + // Fetch the created profile directly by ID (no race condition). + createdPtr, err := store.GetProfile(r.Context(), profileID) + if err != nil || createdPtr == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve created profile") + return + } + created := *createdPtr + + // If PIN was provided, update the profile to set it. + if req.PIN != "" { + if err := store.UpdateProfile(r.Context(), created.ID, userstore.UpdateProfileInput{ + PIN: &req.PIN, + }); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set profile PIN") + return + } + // Re-read the profile to get the updated state. + p, err := store.GetProfile(r.Context(), created.ID) + if err != nil || p == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve profile after PIN set") + return + } + created = *p + } + if req.ShowForcedSubtitles != nil && !*req.ShowForcedSubtitles { + if err := store.UpdateProfile(r.Context(), created.ID, userstore.UpdateProfileInput{ + ShowForcedSubtitles: req.ShowForcedSubtitles, + }); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set forced subtitle preference") + return + } + p, err := store.GetProfile(r.Context(), created.ID) + if err != nil || p == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve profile after forced subtitle update") + return + } + created = *p + } + + writeJSON(w, http.StatusCreated, h.toProfileResponse(r.Context(), created)) +} + +// HandleUpdateProfile handles PUT /profiles/{id}. +func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + profileID := chi.URLParam(r, "id") + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required") + return + } + + var req updateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + var avatarRef *string + if req.Avatar != nil { + normalized, err := normalizePresetAvatarReference(*req.Avatar) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + avatarRef = &normalized + } + + var maxPlaybackQuality *string + if req.MaxPlaybackQuality != nil { + normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality) + if !ok { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality") + return + } + maxPlaybackQuality = &normalized + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + currentProfile, err := store.GetProfile(r.Context(), profileID) + if err != nil || currentProfile == nil { + writeError(w, http.StatusNotFound, "not_found", "Profile not found") + return + } + + canManage, err := h.canManageHouseholdProfiles(r, store) + if err != nil { + writeProfileManagementPermissionError(w, err) + return + } + if !canManage { + // Non-managers may only update their own active profile and only a + // narrow set of playback preferences. + activeProfileID := apimw.GetProfileID(r.Context()) + if activeProfileID == "" { + activeProfileID = r.Header.Get("X-Profile-Id") + } + if activeProfileID == "" || activeProfileID != profileID { + writeError( + w, + http.StatusForbidden, + "forbidden", + "You can only update the active profile's playback preferences", + ) + return + } + if !isAllowedSelfServiceProfileUpdate(req) { + writeError( + w, + http.StatusForbidden, + "forbidden", + "Profile access settings require the primary profile or admin access", + ) + return + } + } + + input := userstore.UpdateProfileInput{ + Name: req.Name, + Avatar: avatarRef, + PIN: req.PIN, + IsChild: req.IsChild, + MaxContentRating: req.MaxContentRating, + QualityPreference: req.QualityPreference, + Language: req.Language, + SubtitleLanguage: req.SubtitleLanguage, + SubtitleMode: req.SubtitleMode, + AutoSkipIntro: req.AutoSkipIntro, + AutoSkipCredits: req.AutoSkipCredits, + ShowForcedSubtitles: req.ShowForcedSubtitles, + LibraryRestrictionsEnabled: req.LibraryRestrictionsEnabled, + AllowedLibraryIDs: req.AllowedLibraryIDs, + MaxPlaybackQuality: maxPlaybackQuality, + } + + if err := store.UpdateProfile(r.Context(), profileID, input); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Profile not found") + return + } + if currentProfile.Avatar != "" && avatarRef != nil && avatarRefReplacesUpload(currentProfile.Avatar, *avatarRef) { + if cleanupErr := deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID); cleanupErr != nil { + slog.Warn("profile avatar cleanup failed after update", "user_id", userID, "profile_id", profileID, "error", cleanupErr) + } + } + + // Re-read the profile to return the updated state. + profile, err := store.GetProfile(r.Context(), profileID) + if err != nil || profile == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve updated profile") + return + } + + writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *profile)) +} + +// HandleDeleteProfile handles DELETE /profiles/{id}. +func (h *ProfileHandler) HandleDeleteProfile(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + profileID := chi.URLParam(r, "id") + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + allowed, err := h.canManageHouseholdProfiles(r, store) + if err != nil { + writeProfileManagementPermissionError(w, err) + return + } + if !allowed { + writeError(w, http.StatusForbidden, "forbidden", "Profile management requires the primary profile or admin access") + return + } + profile, err := store.GetProfile(r.Context(), profileID) + if err != nil || profile == nil { + writeError(w, http.StatusNotFound, "not_found", "Profile not found") + return + } + if profile.IsPrimary { + writeError( + w, + http.StatusConflict, + "primary_profile_protected", + "The primary profile cannot be deleted. Delete the user account instead.", + ) + return + } + + if err := store.DeleteProfile(r.Context(), profileID); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Profile not found") + return + } + if isUploadedAvatarRef(profile.Avatar) { + if cleanupErr := deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID); cleanupErr != nil { + slog.Warn("profile avatar cleanup failed after delete", "user_id", userID, "profile_id", profileID, "error", cleanupErr) + } + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleVerifyPIN handles POST /profiles/{id}/verify-pin. +func (h *ProfileHandler) HandleVerifyPIN(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + profileID := chi.URLParam(r, "id") + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required") + return + } + + var req verifyPINRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.PIN == "" { + writeError(w, http.StatusBadRequest, "bad_request", "PIN is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + valid, err := store.VerifyPIN(r.Context(), profileID, req.PIN) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "Profile not found or has no PIN") + return + } + if !valid || h.UserRepo == nil || h.ProfileTokens == nil { + writeJSON(w, http.StatusOK, verifyPINResponse{Valid: valid}) + return + } + + claims := apimw.GetClaims(r.Context()) + if claims == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + user, err := h.UserRepo.GetByID(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user policy") + return + } + + token, expiresAt, err := h.ProfileTokens.Mint(access.ProfileTokenClaims{ + UserID: userID, + SessionID: claims.SessionID, + ProfileID: profileID, + PolicyRevision: user.AccessPolicyRevision, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue profile token") + return + } + + resp := verifyPINResponse{ + Valid: true, + ProfileToken: token, + } + if !expiresAt.IsZero() { + resp.ExpiresAt = expiresAt.UTC().Format(time.RFC3339) + } + + writeJSON(w, http.StatusOK, resp) +} + +// --- Helpers --- + +func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Profile) profileResponse { + avatarSource, avatarURL := resolveProfileAvatar(ctx, h.AvatarStore, h.AvatarTTL, p.Avatar) + return profileResponse{ + ID: p.ID, + Name: p.Name, + Avatar: p.Avatar, + AvatarURL: avatarURL, + AvatarSource: avatarSource, + HasPIN: p.PINHash != "", + IsChild: p.IsChild, + IsPrimary: p.IsPrimary, + MaxContentRating: p.MaxContentRating, + QualityPreference: p.QualityPreference, + Language: p.Language, + SubtitleLanguage: p.SubtitleLanguage, + SubtitleMode: p.SubtitleMode, + AutoSkipIntro: p.AutoSkipIntro, + AutoSkipCredits: p.AutoSkipCredits, + ShowForcedSubtitles: p.ShowForcedSubtitles, + LibraryRestrictionsEnabled: p.LibraryRestrictionsEnabled, + AllowedLibraryIDs: append([]int(nil), p.AllowedLibraryIDs...), + MaxPlaybackQuality: access.NormalizePlaybackQuality(p.MaxPlaybackQuality), + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +} + +// HandleListHouseholdSessions handles GET /profiles/household/sessions. +func (h *ProfileHandler) HandleListHouseholdSessions(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + allowed, err := h.canManageHouseholdProfiles(r, store) + if err != nil { + writeProfileManagementPermissionError(w, err) + return + } + if !allowed { + writeError(w, http.StatusForbidden, "forbidden", "Profile management requires the primary profile or admin access") + return + } + if h.SessionsReader == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Playback sessions are not configured") + return + } + + sessions, err := h.SessionsReader.Load(r.Context(), r, PlaybackSessionsQuery{UserID: userID}) + if err != nil { + slog.Error("failed to list household playback sessions", "user_id", userID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list playback sessions") + return + } + writeJSON(w, http.StatusOK, sessions) +} diff --git a/internal/api/handlers/profiles_test.go b/internal/api/handlers/profiles_test.go new file mode 100644 index 00000000..0ca51c15 --- /dev/null +++ b/internal/api/handlers/profiles_test.go @@ -0,0 +1,579 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userdb" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +type testProfileUserRepo struct { + user *models.User + err error +} + +func (r testProfileUserRepo) GetByID(context.Context, int) (*models.User, error) { + return r.user, r.err +} + +func newAuthorizedProfileRequest(body string) *http.Request { + return newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles", body, "user", "") +} + +func newAuthorizedProfileRequestWithRole( + method, path, body, role, activeProfileID string, +) *http.Request { + return newAuthorizedProfileRequestWithSession(method, path, body, role, activeProfileID, "") +} + +func newAuthorizedProfileRequestWithSession( + method, path, body, role, activeProfileID, sessionID string, +) *http.Request { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + ctx := apimw.SetClaims(context.Background(), &auth.Claims{ + UserID: 1, + Role: role, + SessionID: sessionID, + TokenType: auth.TokenTypeAccess, + }) + if activeProfileID != "" { + ctx = apimw.SetProfileID(ctx, activeProfileID) + } + return req.WithContext(ctx) +} + +func withProfileRouteParam(req *http.Request, key, value string) *http.Request { + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add(key, value) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) +} + +func newProfileTestStore(t *testing.T) userstore.UserStore { + return newProfileTestStoreWithSeed(t, true) +} + +func newEmptyProfileTestStore(t *testing.T) userstore.UserStore { + return newProfileTestStoreWithSeed(t, false) +} + +func newProfileTestStoreWithSeed(t *testing.T, seedProfile bool) userstore.UserStore { + t.Helper() + + dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + "?mode=memory&cache=shared" + db, err := sql.Open("sqlite3", dsn) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + if err := userdb.InitSchema(db); err != nil { + t.Fatalf("init schema: %v", err) + } + + store := userdb.NewSQLiteUserStore(db) + if seedProfile { + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Main"}); err != nil { + t.Fatalf("create profile: %v", err) + } + } + + return store +} + +func TestHandleCreateProfile_EnforcesUserProfileLimit(t *testing.T) { + store := newProfileTestStore(t) + handler := NewProfileHandler(testUserStoreProvider{store: store}) + handler.UserRepo = testProfileUserRepo{ + user: &models.User{ID: 1, MaxProfiles: 1}, + } + + req := newAuthorizedProfileRequestWithRole( + http.MethodPost, + "/profiles", + `{"name":"Kids","max_playback_quality":"1080p"}`, + "admin", + "", + ) + rr := httptest.NewRecorder() + + handler.HandleCreateProfile(rr, req) + + if rr.Code != http.StatusConflict { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp errorResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Error != "profile_limit_reached" { + t.Fatalf("error = %q, want %q", resp.Error, "profile_limit_reached") + } + + profiles, err := store.ListProfiles(context.Background()) + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if len(profiles) != 1 { + t.Fatalf("profile count = %d, want 1", len(profiles)) + } +} + +func TestHandleCreateProfile_AllowsNonAdminUpToCap(t *testing.T) { + store := newProfileTestStore(t) + handler := NewProfileHandler(testUserStoreProvider{store: store}) + handler.UserRepo = testProfileUserRepo{ + user: &models.User{ID: 1, MaxProfiles: 5}, + } + + req := newAuthorizedProfileRequestWithRole( + http.MethodPost, + "/profiles", + `{"name":"Kids"}`, + "user", + "profile-1", + ) + rr := httptest.NewRecorder() + + handler.HandleCreateProfile(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + profiles, err := store.ListProfiles(context.Background()) + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if len(profiles) != 2 { + t.Fatalf("profile count = %d, want 2", len(profiles)) + } +} + +func TestHandleCreateProfile_BlocksNonPrimaryNonAdmin(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + handler.UserRepo = testProfileUserRepo{ + user: &models.User{ID: 1, MaxProfiles: 5}, + } + + req := newAuthorizedProfileRequestWithRole( + http.MethodPost, + "/profiles", + `{"name":"Extra"}`, + "user", + "profile-2", + ) + rr := httptest.NewRecorder() + + handler.HandleCreateProfile(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } +} + +func TestHandleCreateProfile_RequiresVerifiedPrimaryPINWhenPrimaryHasPIN(t *testing.T) { + store := newProfileTestStore(t) + if err := store.UpdateProfile(context.Background(), "profile-1", userstore.UpdateProfileInput{ + PIN: ptr("1234"), + }); err != nil { + t.Fatalf("set primary pin: %v", err) + } + + handler := NewProfileHandler(testUserStoreProvider{store: store}) + handler.UserRepo = testProfileUserRepo{ + user: &models.User{ID: 1, MaxProfiles: 5, AccessPolicyRevision: 7}, + } + handler.ProfileTokens = access.NewProfileTokenService("test-secret", time.Minute) + + req := newAuthorizedProfileRequestWithSession( + http.MethodPost, + "/profiles", + `{"name":"Kids"}`, + "user", + "profile-1", + "sess-1", + ) + rr := httptest.NewRecorder() + + handler.HandleCreateProfile(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "verifying the primary profile PIN") { + t.Fatalf("body = %s", rr.Body.String()) + } +} + +func TestHandleCreateProfile_AllowsVerifiedPrimaryPIN(t *testing.T) { + store := newProfileTestStore(t) + if err := store.UpdateProfile(context.Background(), "profile-1", userstore.UpdateProfileInput{ + PIN: ptr("1234"), + }); err != nil { + t.Fatalf("set primary pin: %v", err) + } + + handler := NewProfileHandler(testUserStoreProvider{store: store}) + handler.UserRepo = testProfileUserRepo{ + user: &models.User{ID: 1, MaxProfiles: 5, AccessPolicyRevision: 7}, + } + handler.ProfileTokens = access.NewProfileTokenService("test-secret", time.Minute) + + token, _, err := handler.ProfileTokens.Mint(access.ProfileTokenClaims{ + UserID: 1, + SessionID: "sess-1", + ProfileID: "profile-1", + PolicyRevision: 7, + }) + if err != nil { + t.Fatalf("mint profile token: %v", err) + } + + req := newAuthorizedProfileRequestWithSession( + http.MethodPost, + "/profiles", + `{"name":"Kids"}`, + "user", + "profile-1", + "sess-1", + ) + req.Header.Set("X-Profile-Token", token) + rr := httptest.NewRecorder() + + handler.HandleCreateProfile(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } +} + +func TestHandleCreateProfile_BlocksAdminOnlyFieldsForNonAdmin(t *testing.T) { + store := newEmptyProfileTestStore(t) + handler := NewProfileHandler(testUserStoreProvider{store: store}) + handler.UserRepo = testProfileUserRepo{ + user: &models.User{ID: 1, MaxProfiles: 5}, + } + + req := newAuthorizedProfileRequest(`{"name":"Kids","is_child":true}`) + rr := httptest.NewRecorder() + + handler.HandleCreateProfile(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + profiles, err := store.ListProfiles(context.Background()) + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if len(profiles) != 0 { + t.Fatalf("profile count = %d, want 0", len(profiles)) + } +} + +func TestHandleCreateProfile_AllowsFirstProfileBootstrapForNonAdmin(t *testing.T) { + store := newEmptyProfileTestStore(t) + handler := NewProfileHandler(testUserStoreProvider{store: store}) + + req := newAuthorizedProfileRequest(`{"name":"Main"}`) + rr := httptest.NewRecorder() + + handler.HandleCreateProfile(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + profiles, err := store.ListProfiles(context.Background()) + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if len(profiles) != 1 { + t.Fatalf("profile count = %d, want 1", len(profiles)) + } +} + +func ptr[T any](value T) *T { + return &value +} + +func TestHandleUpdateProfile_AllowsPrimaryToSetAccessFields(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + + req := newAuthorizedProfileRequestWithRole( + http.MethodPut, + "/profiles/profile-2", + `{"max_content_rating":"PG-13"}`, + "user", + "profile-1", + ) + rr := httptest.NewRecorder() + + handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-2")) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + profile, err := store.GetProfile(context.Background(), "profile-2") + if err != nil || profile == nil { + t.Fatalf("get profile: %v, %v", profile, err) + } + if profile.MaxContentRating != "PG-13" { + t.Fatalf("max_content_rating = %q, want PG-13", profile.MaxContentRating) + } +} + +func TestHandleUpdateProfile_BlocksAccessFieldsForNonPrimary(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + + req := newAuthorizedProfileRequestWithRole( + http.MethodPut, + "/profiles/profile-2", + `{"max_content_rating":"PG-13"}`, + "user", + "profile-2", + ) + rr := httptest.NewRecorder() + + handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-2")) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + profile, err := store.GetProfile(context.Background(), "profile-2") + if err != nil || profile == nil { + t.Fatalf("get profile: %v, %v", profile, err) + } + if profile.MaxContentRating != "" { + t.Fatalf("max_content_rating = %q, want empty", profile.MaxContentRating) + } +} + +func TestHandleUpdateProfile_AllowsSelfServiceFieldsForNonAdmin(t *testing.T) { + store := newProfileTestStore(t) + handler := NewProfileHandler(testUserStoreProvider{store: store}) + + req := newAuthorizedProfileRequestWithRole( + http.MethodPut, + "/profiles/profile-1", + `{"subtitle_mode":"always","pin":"1234"}`, + "user", + "profile-1", + ) + rr := httptest.NewRecorder() + + handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-1")) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + profile, err := store.GetProfile(context.Background(), "profile-1") + if err != nil { + t.Fatalf("get profile: %v", err) + } + if profile == nil { + t.Fatal("profile missing") + } + if profile.SubtitleMode != "always" { + t.Fatalf("subtitle_mode = %q, want %q", profile.SubtitleMode, "always") + } + if profile.PINHash == "" { + t.Fatal("expected pin hash to be set") + } +} + +func TestHandleUpdateProfile_AllowsNonAdminToUpdateAnyOwnedProfile(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + + req := newAuthorizedProfileRequestWithRole( + http.MethodPut, + "/profiles/profile-2", + `{"subtitle_mode":"always"}`, + "user", + "profile-1", + ) + rr := httptest.NewRecorder() + + handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-2")) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } +} + +func TestHandleDeleteProfile_AllowsPrimaryToDeleteOther(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + + req := newAuthorizedProfileRequestWithRole( + http.MethodDelete, + "/profiles/profile-2", + "", + "user", + "profile-1", + ) + rr := httptest.NewRecorder() + + handler.HandleDeleteProfile(rr, withProfileRouteParam(req, "id", "profile-2")) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + profile, err := store.GetProfile(context.Background(), "profile-2") + if err == nil && profile != nil { + t.Fatal("expected profile to be deleted") + } +} + +func TestHandleDeleteProfile_BlocksPrimaryDeletion(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + + req := newAuthorizedProfileRequestWithRole( + http.MethodDelete, + "/profiles/profile-1", + "", + "user", + "profile-1", + ) + rr := httptest.NewRecorder() + + handler.HandleDeleteProfile(rr, withProfileRouteParam(req, "id", "profile-1")) + + if rr.Code != http.StatusConflict { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + profile, err := store.GetProfile(context.Background(), "profile-1") + if err != nil || profile == nil { + t.Fatalf("expected primary profile to be preserved: %v", err) + } +} + +type stubPlaybackSessionsLoader struct { + sessions []playbackSessionRow + userID int + err error +} + +func (s *stubPlaybackSessionsLoader) Load( + _ context.Context, + _ *http.Request, + query PlaybackSessionsQuery, +) ([]playbackSessionRow, error) { + if s.err != nil { + return nil, s.err + } + if query.UserID != s.userID { + return []playbackSessionRow{}, nil + } + return s.sessions, nil +} + +func TestHandleListHouseholdSessions_ForbiddenForNonPrimary(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + handler.SessionsReader = &stubPlaybackSessionsLoader{userID: 1} + + req := newAuthorizedProfileRequestWithRole( + http.MethodGet, + "/profiles/household/sessions", + "", + "user", + "profile-2", + ) + rr := httptest.NewRecorder() + + handler.HandleListHouseholdSessions(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } +} + +func TestHandleListHouseholdSessions_ReturnsAccountSessionsForPrimary(t *testing.T) { + store := newProfileTestStore(t) + handler := NewProfileHandler(testUserStoreProvider{store: store}) + handler.SessionsReader = &stubPlaybackSessionsLoader{ + userID: 1, + sessions: []playbackSessionRow{ + { + SessionID: "sess-1", + UserID: 1, + ProfileID: "profile-2", + ProfileName: "Kids", + MediaTitle: "Example Movie", + PlayMethod: "direct", + }, + }, + } + + req := newAuthorizedProfileRequestWithRole( + http.MethodGet, + "/profiles/household/sessions", + "", + "user", + "profile-1", + ) + rr := httptest.NewRecorder() + + handler.HandleListHouseholdSessions(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp []playbackSessionRow + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp) != 1 || resp[0].SessionID != "sess-1" || resp[0].ProfileName != "Kids" { + t.Fatalf("sessions = %#v", resp) + } +} diff --git a/internal/api/handlers/progress.go b/internal/api/handlers/progress.go new file mode 100644 index 00000000..8125985b --- /dev/null +++ b/internal/api/handlers/progress.go @@ -0,0 +1,267 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// ProgressLibraryLookup resolves which progress items belong to a library. +type ProgressLibraryLookup interface { + GetItemsInFolder(ctx context.Context, contentIDs []string, folderID int) (map[string]bool, error) +} + +// ProgressHandler handles watch progress and sync endpoints. +type ProgressHandler struct { + storeProvider userstore.UserStoreProvider + LibraryLookup ProgressLibraryLookup + SettingsRepo PlaybackSettingsReader + EventsHub *evt.Hub + profileStaler ProfileStaler + profileRefreshRequester ProfileRefreshRequester +} + +// NewProgressHandler creates a new ProgressHandler. +func NewProgressHandler(provider userstore.UserStoreProvider) *ProgressHandler { + return &ProgressHandler{storeProvider: provider} +} + +// SetProfileStaler configures an optional staleness trigger for taste profiles. +func (h *ProgressHandler) SetProfileStaler(ps ProfileStaler) { + h.profileStaler = ps +} + +// SetProfileRefreshRequester configures an optional background refresh queue for taste profiles. +func (h *ProgressHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) { + h.profileRefreshRequester = requester +} + +// --- Request/Response types --- + +type progressEntryResponse struct { + MediaItemID string `json:"media_item_id"` + PositionSeconds float64 `json:"position_seconds"` + DurationSeconds float64 `json:"duration_seconds"` + Completed bool `json:"completed"` + UpdatedAt string `json:"updated_at"` +} + +type progressListResponse struct { + Progress []progressEntryResponse `json:"progress"` +} + +type syncProgressItem struct { + MediaItemID string `json:"media_item_id"` + Position float64 `json:"position"` + Duration float64 `json:"duration"` + ForceOverwrite bool `json:"force_overwrite"` +} + +type syncProgressRequest struct { + Items []syncProgressItem `json:"items"` +} + +type syncProgressResultItem struct { + MediaItemID string `json:"media_item_id"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +type syncProgressResponse struct { + Results []syncProgressResultItem `json:"results"` +} + +// --- Handler methods --- + +// HandleListProgress handles GET /progress?status=in_progress&limit=20&offset=0. +func (h *ProgressHandler) HandleListProgress(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + status := r.URL.Query().Get("status") + limit, offset := parsePagination(r) + libraryID, err := parseLibraryIDParam(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id") + return + } + + entries, err := store.ListProgress(r.Context(), profileID, status, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list progress") + return + } + if libraryID > 0 { + if h.LibraryLookup == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply library filter") + return + } + entries, err = filterProgressEntriesByLibrary(r.Context(), entries, libraryID, h.LibraryLookup) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply library filter") + return + } + } + + resp := progressListResponse{ + Progress: make([]progressEntryResponse, 0, len(entries)), + } + for _, e := range entries { + resp.Progress = append(resp.Progress, progressEntryResponse{ + MediaItemID: e.MediaItemID, + PositionSeconds: e.PositionSeconds, + DurationSeconds: e.DurationSeconds, + Completed: e.Completed, + UpdatedAt: e.UpdatedAt, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +func parseLibraryIDParam(r *http.Request) (int, error) { + raw := r.URL.Query().Get("library_id") + if raw == "" { + return 0, nil + } + + libraryID, err := strconv.Atoi(raw) + if err != nil || libraryID <= 0 { + return 0, strconv.ErrSyntax + } + + return libraryID, nil +} + +func filterProgressEntriesByLibrary( + ctx context.Context, + entries []userstore.WatchProgress, + libraryID int, + lookup ProgressLibraryLookup, +) ([]userstore.WatchProgress, error) { + if len(entries) == 0 { + return entries, nil + } + + contentIDs := make([]string, 0, len(entries)) + for _, entry := range entries { + contentIDs = append(contentIDs, entry.MediaItemID) + } + + allowed, err := lookup.GetItemsInFolder(ctx, contentIDs, libraryID) + if err != nil { + return nil, err + } + + filtered := make([]userstore.WatchProgress, 0, len(entries)) + for _, entry := range entries { + if allowed[entry.MediaItemID] { + filtered = append(filtered, entry) + } + } + + return filtered, nil +} + +// HandleSyncProgress handles POST /sync/progress. +// It accepts a batch of progress updates and returns per-item results. +func (h *ProgressHandler) HandleSyncProgress(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + var req syncProgressRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if len(req.Items) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "At least one progress item is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + var thresholds userstore.ProgressThresholds + if h.SettingsRepo != nil { + if v, _ := h.SettingsRepo.Get(r.Context(), "playback.watched_threshold"); v != "" { + if pct, err := strconv.Atoi(v); err == nil && pct > 0 { + thresholds.WatchedPct = pct + } + } + if v, _ := h.SettingsRepo.Get(r.Context(), "playback.min_resume_threshold"); v != "" { + if pct, err := strconv.Atoi(v); err == nil && pct > 0 { + thresholds.MinResumePct = pct + } + } + } + + results := make([]syncProgressResultItem, 0, len(req.Items)) + hadSuccessfulUpdate := false + + for _, item := range req.Items { + result := syncProgressResultItem{ + MediaItemID: item.MediaItemID, + } + + if item.MediaItemID == "" { + result.Status = "error" + result.Error = "media_item_id is required" + results = append(results, result) + continue + } + + var updateErr error + if item.ForceOverwrite { + updateErr = store.SetProgress(r.Context(), profileID, item.MediaItemID, item.Position, item.Duration, thresholds) + } else { + updateErr = store.UpdateProgress(r.Context(), profileID, item.MediaItemID, item.Position, item.Duration, thresholds) + } + + if updateErr != nil { + result.Status = "error" + result.Error = "failed to update progress" + } else { + result.Status = "ok" + hadSuccessfulUpdate = true + } + + results = append(results, result) + } + + if hadSuccessfulUpdate { + triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) + for _, item := range req.Items { + if item.MediaItemID == "" { + continue + } + publishUserStateEvent( + r.Context(), + h.EventsHub, + userID, + profileID, + item.MediaItemID, + "", + "progress", + "progress.updated", + ) + } + } + + writeJSON(w, http.StatusOK, syncProgressResponse{Results: results}) +} diff --git a/internal/api/handlers/query_definition_validation_test.go b/internal/api/handlers/query_definition_validation_test.go new file mode 100644 index 00000000..7a802000 --- /dev/null +++ b/internal/api/handlers/query_definition_validation_test.go @@ -0,0 +1,19 @@ +package handlers + +import "testing" + +func TestNormalizeQueryDefinitionJSON_RejectsPersonalizedSortWithoutProfileScope(t *testing.T) { + raw := []byte(`{"match":"all","groups":[],"sort":{"field":"progress","order":"desc"}}`) + + if _, err := normalizeQueryDefinitionJSON(raw, false, true); err == nil { + t.Fatal("expected personalized sort to be rejected for admin/global validation") + } +} + +func TestNormalizeQueryDefinitionJSON_RejectsPersonalizedFieldWithoutProfileScope(t *testing.T) { + raw := []byte(`{"match":"all","groups":[{"match":"all","rules":[{"field":"watched","op":"is","value":true}]}]}`) + + if _, err := normalizeQueryDefinitionJSON(raw, true, false); err == nil { + t.Fatal("expected personalized field to be rejected for non-profile validation") + } +} diff --git a/internal/api/handlers/rate_limits.go b/internal/api/handlers/rate_limits.go new file mode 100644 index 00000000..de2d3fa8 --- /dev/null +++ b/internal/api/handlers/rate_limits.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/Silo-Server/silo-server/internal/cache" + "github.com/Silo-Server/silo-server/internal/ratelimit" +) + +// RateLimitHandler handles rate limit config admin endpoints. +type RateLimitHandler struct { + store ratelimit.SettingsStore + mw *ratelimit.Middleware + eventBus cache.EventBus +} + +// NewRateLimitHandler creates a new RateLimitHandler. +func NewRateLimitHandler(store ratelimit.SettingsStore, mw *ratelimit.Middleware, eventBus cache.EventBus) *RateLimitHandler { + return &RateLimitHandler{store: store, mw: mw, eventBus: eventBus} +} + +type rateLimitConfigResponse struct { + Enabled bool `json:"enabled"` + Backend string `json:"backend"` + GlobalReqPerSecond float64 `json:"global_requests_per_second"` + Tiers map[string]tierConfigResponse `json:"tiers"` + IPReqPerSecond float64 `json:"ip_requests_per_second"` + IPReqPerMinute float64 `json:"ip_requests_per_minute"` + IPBurst int `json:"ip_burst"` + AuthEndpoints map[string]authEndpointConfigResponse `json:"auth_endpoints"` +} + +type tierConfigResponse struct { + RequestsPerSecond float64 `json:"requests_per_second"` + RequestsPerMinute float64 `json:"requests_per_minute"` + Burst int `json:"burst"` +} + +type authEndpointConfigResponse struct { + RequestsPerMinute float64 `json:"requests_per_minute"` + Burst int `json:"burst"` +} + +// HandleGetConfig handles GET /admin/rate-limits/config. +func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := ratelimit.LoadConfig(r.Context(), h.store) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load rate limit config") + return + } + + backend, _ := h.store.Get(r.Context(), "ratelimit.backend") + if backend == "" { + backend = "memory" + } + + resp := rateLimitConfigResponse{ + Enabled: cfg.Enabled, + Backend: backend, + GlobalReqPerSecond: cfg.GlobalReqPerSecond, + Tiers: make(map[string]tierConfigResponse), + IPReqPerSecond: cfg.IPReqPerSecond, + IPReqPerMinute: cfg.IPReqPerMinute, + IPBurst: cfg.IPBurst, + AuthEndpoints: make(map[string]authEndpointConfigResponse), + } + for name, tier := range cfg.Tiers { + resp.Tiers[name] = tierConfigResponse{ + RequestsPerSecond: tier.RequestsPerSecond, + RequestsPerMinute: tier.RequestsPerMinute, + Burst: tier.Burst, + } + } + for name, ep := range cfg.AuthEndpoints { + resp.AuthEndpoints[name] = authEndpointConfigResponse{ + RequestsPerMinute: ep.RequestsPerMinute, + Burst: ep.Burst, + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// HandleUpdateConfig handles PUT /admin/rate-limits/config. +func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Request) { + var req rateLimitConfigResponse + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + // Load existing config so we can preserve fields not included in the request. + existing, err := ratelimit.LoadConfig(r.Context(), h.store) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load existing config") + return + } + + cfg := ratelimit.Config{ + Enabled: req.Enabled, + GlobalReqPerSecond: req.GlobalReqPerSecond, + Tiers: make(map[string]ratelimit.TierConfig), + IPReqPerSecond: req.IPReqPerSecond, + IPReqPerMinute: req.IPReqPerMinute, + IPBurst: req.IPBurst, + AuthEndpoints: make(map[string]ratelimit.AuthEndpointConfig), + } + + // Preserve IP settings if not provided (zero values mean omitted from request). + if cfg.IPReqPerSecond == 0 { + cfg.IPReqPerSecond = existing.IPReqPerSecond + } + if cfg.IPReqPerMinute == 0 { + cfg.IPReqPerMinute = existing.IPReqPerMinute + } + if cfg.IPBurst == 0 { + cfg.IPBurst = existing.IPBurst + } + + for name, tier := range req.Tiers { + cfg.Tiers[name] = ratelimit.TierConfig{ + RequestsPerSecond: tier.RequestsPerSecond, + RequestsPerMinute: tier.RequestsPerMinute, + Burst: tier.Burst, + } + } + + // Preserve existing auth endpoint settings if not provided in request. + for name, ep := range existing.AuthEndpoints { + cfg.AuthEndpoints[name] = ep + } + for name, ep := range req.AuthEndpoints { + cfg.AuthEndpoints[name] = ratelimit.AuthEndpointConfig{ + RequestsPerMinute: ep.RequestsPerMinute, + Burst: ep.Burst, + } + } + + if err := ratelimit.SaveConfig(r.Context(), h.store, cfg); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save rate limit config") + return + } + + // Save backend setting (infrastructure-level, requires restart) + if req.Backend == "memory" || req.Backend == "redis" { + if err := h.store.Set(r.Context(), "ratelimit.backend", req.Backend); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save backend setting") + return + } + } + + // Hot-reload: apply new config immediately on this instance + if err := h.mw.Reload(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed") + return + } + + // Publish for multi-instance reload (if EventBus is available/backed by Redis) + if h.eventBus != nil { + _ = h.eventBus.Publish(r.Context(), cache.ChannelAdmin, cache.Event{ + Type: cache.EventSettingsChanged, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/internal/api/handlers/ratings.go b/internal/api/handlers/ratings.go new file mode 100644 index 00000000..dc79d359 --- /dev/null +++ b/internal/api/handlers/ratings.go @@ -0,0 +1,183 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// ratingsRepository defines the data access interface for user ratings. +type ratingsRepository interface { + Set(ctx context.Context, userID int, profileID, mediaItemID string, rating int) error + Get(ctx context.Context, userID int, profileID, mediaItemID string) (*catalog.UserRating, error) + Delete(ctx context.Context, userID int, profileID, mediaItemID string) error + List(ctx context.Context, userID int, profileID string, limit, offset int) ([]catalog.UserRating, error) +} + +// RatingsHandler handles user rating operations. +type RatingsHandler struct { + ratingsRepo ratingsRepository + itemRepo personalDataItemRepository + profileStaler ProfileStaler + profileRefreshRequester ProfileRefreshRequester +} + +// NewRatingsHandler creates a new RatingsHandler. +func NewRatingsHandler(ratingsRepo ratingsRepository, itemRepo personalDataItemRepository) *RatingsHandler { + return &RatingsHandler{ratingsRepo: ratingsRepo, itemRepo: itemRepo} +} + +// SetProfileStaler configures an optional staleness trigger for taste profiles. +func (h *RatingsHandler) SetProfileStaler(ps ProfileStaler) { + h.profileStaler = ps +} + +// SetProfileRefreshRequester configures an optional background refresh queue for taste profiles. +func (h *RatingsHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) { + h.profileRefreshRequester = requester +} + +func (h *RatingsHandler) markStale(ctx context.Context, userID int, profileID string) { + triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, userID, profileID) +} + +// --- Response types --- + +type ratingResponse struct { + Rating int `json:"rating"` + RatedAt string `json:"rated_at"` +} + +type ratingListItem struct { + MediaItemID string `json:"media_item_id"` + Rating int `json:"rating"` + RatedAt string `json:"rated_at"` +} + +type ratingListResponse struct { + Ratings []ratingListItem `json:"ratings"` +} + +// --- Request types --- + +type setRatingRequest struct { + Rating int `json:"rating"` +} + +// HandleSetRating handles PUT /ratings/{item_id}. +// Accepts {"rating": N} where N is 1-5. Returns 204 on success. +func (h *RatingsHandler) HandleSetRating(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + var req setRatingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Rating < 1 || req.Rating > 5 { + writeError(w, http.StatusBadRequest, "bad_request", "Rating must be between 1 and 5") + return + } + + if err := h.itemRepo.EnsureAccessible(r.Context(), itemID, requestAccessFilter(r)); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + if err := h.ratingsRepo.Set(r.Context(), userID, profileID, itemID, req.Rating); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set rating") + return + } + + h.markStale(r.Context(), userID, profileID) + w.WriteHeader(http.StatusNoContent) +} + +// HandleDeleteRating handles DELETE /ratings/{item_id}. +// Returns 204 on success. +func (h *RatingsHandler) HandleDeleteRating(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + if err := h.ratingsRepo.Delete(r.Context(), userID, profileID, itemID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete rating") + return + } + + h.markStale(r.Context(), userID, profileID) + w.WriteHeader(http.StatusNoContent) +} + +// HandleGetRating handles GET /ratings/{item_id}. +// Returns the rating or 404 if not found. +func (h *RatingsHandler) HandleGetRating(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + itemID := chi.URLParam(r, "item_id") + + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + rating, err := h.ratingsRepo.Get(r.Context(), userID, profileID, itemID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get rating") + return + } + + if rating == nil { + writeError(w, http.StatusNotFound, "not_found", "Rating not found") + return + } + + writeJSON(w, http.StatusOK, ratingResponse{ + Rating: rating.Rating, + RatedAt: rating.RatedAt.UTC().Format("2006-01-02T15:04:05Z"), + }) +} + +// HandleListRatings handles GET /ratings/. +// Returns paginated ratings for the current user+profile. +func (h *RatingsHandler) HandleListRatings(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + limit, offset := parsePagination(r) + + ratings, err := h.ratingsRepo.List(r.Context(), userID, profileID, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list ratings") + return + } + + items := make([]ratingListItem, 0, len(ratings)) + for _, ur := range ratings { + items = append(items, ratingListItem{ + MediaItemID: ur.MediaItemID, + Rating: ur.Rating, + RatedAt: ur.RatedAt.UTC().Format("2006-01-02T15:04:05Z"), + }) + } + + writeJSON(w, http.StatusOK, ratingListResponse{Ratings: items}) +} diff --git a/internal/api/handlers/recipes.go b/internal/api/handlers/recipes.go new file mode 100644 index 00000000..17d0653f --- /dev/null +++ b/internal/api/handlers/recipes.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/sections/recipes" +) + +// RecipeHandler exposes recipe-registry metadata over HTTP. +type RecipeHandler struct{} + +// HandleList returns all recipe definitions grouped by category. +// GET /api/sections/recipes +func (h *RecipeHandler) HandleList(w http.ResponseWriter, _ *http.Request) { + groups := map[string][]recipes.RecipeDefinition{} + for _, rec := range recipes.List() { + def := rec.Definition() + if def.Hidden { + continue + } + // Guarantee Presets serialises as `[]` rather than `null` so the UI can + // iterate without a guard. Recipes that take no preset (e.g. custom_filter) + // still need a present-but-empty array. + if def.Presets == nil { + def.Presets = []recipes.GalleryPreset{} + } + key := string(def.Category) + groups[key] = append(groups[key], def) + } + resp := map[string]any{"categories": groups} + writeJSON(w, http.StatusOK, resp) +} + +// CandidateSource provides UI-pickable candidates for a parameterized recipe. +// Recipe families register their own sources via RegisterCandidateSource. +type CandidateSource func(r *http.Request) ([]Candidate, error) + +// Candidate is a generic shape: the UI shows DisplayName + optional Subtitle and uses Value as the param. +type Candidate struct { + Value string `json:"value"` + DisplayName string `json:"display_name"` + Subtitle string `json:"subtitle,omitempty"` +} + +var candidateSources = map[string]CandidateSource{} + +// RegisterCandidateSource is called by recipe families that need parameter helpers. +func RegisterCandidateSource(recipeType string, src CandidateSource) { + candidateSources[recipeType] = src +} + +// HandleCandidates is GET /api/sections/recipes/{type}/candidates. +func (h *RecipeHandler) HandleCandidates(w http.ResponseWriter, r *http.Request) { + typ := chi.URLParam(r, "type") + src, ok := candidateSources[typ] + if !ok { + writeError(w, http.StatusNotFound, "unknown_recipe", "no candidate source for this recipe type") + return + } + cands, err := src(r) + if err != nil { + writeError(w, http.StatusInternalServerError, "candidate_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"candidates": cands}) +} diff --git a/internal/api/handlers/recipes_test.go b/internal/api/handlers/recipes_test.go new file mode 100644 index 00000000..52e769d2 --- /dev/null +++ b/internal/api/handlers/recipes_test.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/sections/recipes" +) + +func TestHandleCandidatesUnknownTypeReturns404(t *testing.T) { + h := &RecipeHandler{} + req := httptest.NewRequest(http.MethodGet, "/api/sections/recipes/missing/candidates", nil) + req = withProfileRouteParam(req, "type", "missing") + rec := httptest.NewRecorder() + + h.HandleCandidates(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status %d", rec.Code) + } +} + +func TestHandleListRecipesGroupsByCategory(t *testing.T) { + h := &RecipeHandler{} + req := httptest.NewRequest(http.MethodGet, "/api/sections/recipes", nil) + rec := httptest.NewRecorder() + + h.HandleList(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Categories map[string][]recipes.RecipeDefinition `json:"categories"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Categories) == 0 { + t.Fatal("expected categories") + } + if _, ok := resp.Categories[string(recipes.CategoryLibraryStaples)]; !ok { + t.Errorf("missing library_staples category in response") + } +} + +func TestHandleListExcludesHiddenRecipes(t *testing.T) { + h := &RecipeHandler{} + req := httptest.NewRequest(http.MethodGet, "/api/sections/recipes", nil) + rec := httptest.NewRecorder() + + h.HandleList(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Categories map[string][]recipes.RecipeDefinition `json:"categories"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + for cat, defs := range resp.Categories { + for _, def := range defs { + if def.Type == "genre" { + t.Errorf("category %q: hidden recipe type %q should not appear in catalog list", cat, def.Type) + } + if def.Hidden { + t.Errorf("category %q: recipe %q has Hidden=true but was returned in catalog list", cat, def.Type) + } + } + } + + // custom_filter should still appear (it is not hidden) + var foundCustomFilter bool + for _, defs := range resp.Categories { + for _, def := range defs { + if def.Type == "custom_filter" { + foundCustomFilter = true + } + } + } + if !foundCustomFilter { + t.Error("custom_filter recipe should appear in catalog list") + } +} diff --git a/internal/api/handlers/recommendations.go b/internal/api/handlers/recommendations.go new file mode 100644 index 00000000..c6a57bb2 --- /dev/null +++ b/internal/api/handlers/recommendations.go @@ -0,0 +1,1258 @@ +package handlers + +import ( + "context" + "log/slog" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +type recommendationsEngine interface { + SimilarItems(ctx context.Context, itemID string, limit int) ([]recommendations.ScoredItem, error) + BecauseYouWatched(ctx context.Context, userID int, profileID string, sourceItemID string, limit int) ([]recommendations.ScoredItem, error) + GetTasteProfileSummary(ctx context.Context, userID int, profileID string) (*recommendations.TasteProfileSummary, error) +} + +type recommendationsReader interface { + GetForYouMain(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) (*recommendations.ForYouRow, error) + GetForYouRows(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]recommendations.ForYouRow, error) + GetSimilarUsersLiked(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]recommendations.ScoredItem, error) + GetDiscoverRows(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]recommendations.ForYouRow, error) + GetSection(ctx context.Context, userID int, profileID, kind, key string, limit int, filter catalog.AccessFilter) (*recommendations.ForYouRow, error) + GetWatchTonight(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) (recommendations.WatchTonightResult, error) +} + +// RecommendationsHandler handles recommendation API endpoints. +type RecommendationsHandler struct { + engine recommendationsEngine + reader recommendationsReader + storeProvider userstore.UserStoreProvider + ratingsRepo *catalog.RatingsRepo + recsRepo *recommendations.Repo + enabled bool + Fetcher discoverFetcher + DetailSvc discoverPresigner + CalendarRepo calendarRepository + EpisodeRepo *catalog.EpisodeRepository + WatchTonightFetcher watchTonightSectionFetcher + CastFetcher cardsCastFetcher + // RecWorker enqueues asynchronous taste-profile refreshes after writes + // (taste seeding). Optional — when nil, refresh is simply skipped. + RecWorker ProfileRefreshRequester + nowFn func() time.Time +} + +type discoverFetcher interface { + FetchItemsByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, error) + FetchEpisodesByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error) + ListOverlaySummaries(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) (map[string]*models.OverlaySummary, error) +} + +type discoverPresigner interface { + PresignURL(ctx context.Context, path string, variant string) string +} + +// NewRecommendationsHandler creates a new RecommendationsHandler. +func NewRecommendationsHandler(engine recommendationsEngine, reader recommendationsReader, storeProvider userstore.UserStoreProvider, ratingsRepo *catalog.RatingsRepo, recsRepo *recommendations.Repo, enabled bool) *RecommendationsHandler { + return &RecommendationsHandler{ + engine: engine, + reader: reader, + storeProvider: storeProvider, + ratingsRepo: ratingsRepo, + recsRepo: recsRepo, + enabled: enabled, + nowFn: time.Now, + } +} + +// --- Response types --- + +type scoredItemsResponse struct { + Items []recommendations.ScoredItem `json:"items"` +} + +type forYouMainResponse struct { + Row *recommendations.ForYouRow `json:"row"` +} + +// HandleSimilar handles GET /recommendations/similar/{item_id}. +func (h *RecommendationsHandler) HandleSimilar(w http.ResponseWriter, r *http.Request) { + if !h.enabled { + writeJSON(w, http.StatusOK, scoredItemsResponse{Items: []recommendations.ScoredItem{}}) + return + } + + itemID := chi.URLParam(r, "item_id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + limit, _ := parsePagination(r) + if limit > 50 { + limit = 50 + } + if limit <= 0 { + limit = 20 + } + + items, err := h.engine.SimilarItems(r.Context(), itemID, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch similar items") + return + } + + if items == nil { + items = []recommendations.ScoredItem{} + } + + writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items}) +} + +// HandleForYouMain handles GET /recommendations/for-you/main. +func (h *RecommendationsHandler) HandleForYouMain(w http.ResponseWriter, r *http.Request) { + if h.reader == nil { + writeJSON(w, http.StatusOK, forYouMainResponse{Row: nil}) + return + } + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + limit, _ := parsePagination(r) + if limit <= 0 { + limit = 20 + } + + row, err := h.reader.GetForYouMain(r.Context(), userID, profileID, limit, filter) + if err != nil { + slog.Error("ForYouMain failed", "user_id", userID, "profile_id", profileID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") + return + } + + writeJSON(w, http.StatusOK, forYouMainResponse{Row: row}) +} + +// HandleForYouRows handles GET /recommendations/for-you/rows. +func (h *RecommendationsHandler) HandleForYouRows(w http.ResponseWriter, r *http.Request) { + if h.reader == nil { + writeJSON(w, http.StatusOK, recommendations.ForYouResponse{Rows: []recommendations.ForYouRow{}}) + return + } + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + limit, _ := parsePagination(r) + if limit <= 0 { + limit = 20 + } + + rows, err := h.reader.GetForYouRows(r.Context(), userID, profileID, limit, filter) + if err != nil { + slog.Error("ForYouRows failed", "user_id", userID, "profile_id", profileID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") + return + } + + if rows == nil { + rows = []recommendations.ForYouRow{} + } + + writeJSON(w, http.StatusOK, recommendations.ForYouResponse{Rows: rows}) +} + +// HandleBecauseWatched handles GET /recommendations/because-watched/{item_id}. +func (h *RecommendationsHandler) HandleBecauseWatched(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + itemID := chi.URLParam(r, "item_id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + limit, _ := parsePagination(r) + if limit <= 0 { + limit = 20 + } + + items, err := h.engine.BecauseYouWatched(r.Context(), userID, profileID, itemID, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") + return + } + + items = h.filterRecommendations(r, userID, profileID, items) + + if items == nil { + items = []recommendations.ScoredItem{} + } + + items = h.excludeWatchedRecommendations(r, userID, profileID, items) + + writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items}) +} + +// HandleSimilarUsers handles GET /recommendations/similar-users. +func (h *RecommendationsHandler) HandleSimilarUsers(w http.ResponseWriter, r *http.Request) { + if h.reader == nil { + writeJSON(w, http.StatusOK, scoredItemsResponse{Items: []recommendations.ScoredItem{}}) + return + } + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + limit, _ := parsePagination(r) + if limit <= 0 { + limit = 20 + } + + items, err := h.reader.GetSimilarUsersLiked(r.Context(), userID, profileID, limit, filter) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") + return + } + + if items == nil { + items = []recommendations.ScoredItem{} + } + + writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items}) +} + +// HandleTasteProfile handles GET /recommendations/taste-profile. +func (h *RecommendationsHandler) HandleTasteProfile(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + summary, err := h.engine.GetTasteProfileSummary(r.Context(), userID, profileID) + if err != nil || summary == nil { + writeJSON(w, http.StatusOK, recommendations.TasteProfileSummary{ + TopGenres: []string{}, + FavoriteDirectors: []string{}, + SignalCounts: map[string]int{}, + }) + return + } + + writeJSON(w, http.StatusOK, summary) +} + +// HandlePopular handles GET /recommendations/popular?days=30&limit=20. +func (h *RecommendationsHandler) HandlePopular(w http.ResponseWriter, r *http.Request) { + days := 30 + if d := r.URL.Query().Get("days"); d != "" { + if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 { + days = parsed + } + } + + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 { + limit = parsed + } + } + + items, err := h.recsRepo.GetPopularItems(r.Context(), days, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch popular items") + return + } + if items == nil { + items = []recommendations.ScoredItem{} + } + + items = h.excludeWatchedRecommendations(r, apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), items) + + writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items}) +} + +// HandleRecentlyAdded handles GET /recommendations/recently-added?days=14&limit=20. +func (h *RecommendationsHandler) HandleRecentlyAdded(w http.ResponseWriter, r *http.Request) { + days := 14 + if d := r.URL.Query().Get("days"); d != "" { + if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 { + days = parsed + } + } + + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 { + limit = parsed + } + } + + items, err := h.recsRepo.GetRecentlyAddedItems(r.Context(), days, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recently added items") + return + } + if items == nil { + items = []recommendations.ScoredItem{} + } + + items = h.excludeWatchedRecommendations(r, apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), items) + + writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items}) +} + +// filterRecommendations removes watched items and low-rated items from the list. +func (h *RecommendationsHandler) filterRecommendations(r *http.Request, userID int, profileID string, items []recommendations.ScoredItem) []recommendations.ScoredItem { + items = h.excludeWatchedRecommendations(r, userID, profileID, items) + return h.excludeLowRatedRecommendations(r, userID, profileID, items) +} + +func (h *RecommendationsHandler) excludeWatchedRecommendations(r *http.Request, userID int, profileID string, items []recommendations.ScoredItem) []recommendations.ScoredItem { + if len(items) == 0 { + return items + } + watchedSet, err := h.watchedItemIDSet(r.Context(), userID, profileID) + if err != nil || len(watchedSet) == 0 { + return items + } + + filtered := make([]recommendations.ScoredItem, 0, len(items)) + for _, item := range items { + if _, ok := watchedSet[item.MediaItemID]; ok { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func (h *RecommendationsHandler) watchedItemIDSet(ctx context.Context, userID int, profileID string) (map[string]struct{}, error) { + if h.recsRepo == nil { + return map[string]struct{}{}, nil + } + + if h.storeProvider != nil { + store, err := h.storeProvider.ForUser(ctx, userID) + if err == nil && store != nil { + set, err := h.recsRepo.GetWatchedItemIDSetFromStore(ctx, store, profileID) + if err == nil { + return set, nil + } + } + } + + return h.recsRepo.GetWatchedItemIDSet(ctx, userID, profileID) +} + +func (h *RecommendationsHandler) excludeLowRatedRecommendations(r *http.Request, userID int, profileID string, items []recommendations.ScoredItem) []recommendations.ScoredItem { + if len(items) == 0 || h.ratingsRepo == nil { + return items + } + + itemIDs := make([]string, len(items)) + for i, item := range items { + itemIDs[i] = item.MediaItemID + } + + ratings, err := h.ratingsRepo.ListForItems(r.Context(), userID, profileID, itemIDs) + if err != nil { + return items + } + + filtered := make([]recommendations.ScoredItem, 0, len(items)) + for _, item := range items { + if rating, ok := ratings[item.MediaItemID]; ok && rating <= 2 { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +// --- Discover endpoint --- + +type discoverRowResponse struct { + Type string `json:"type"` + Label string `json:"label"` + SectionKind string `json:"section_kind,omitempty"` + SectionKey string `json:"section_key,omitempty"` + Items []sectionItemResponse `json:"items"` +} + +type discoverResponse struct { + Rows []discoverRowResponse `json:"rows"` +} + +type sectionDetailResponse struct { + Kind string `json:"kind"` + Key string `json:"key,omitempty"` + Type string `json:"type"` + Label string `json:"label"` + Items []sectionItemResponse `json:"items"` +} + +const ( + discoverForYouMaxItems = 28 + discoverUpcomingWindowDays = 14 + discoverForYouLabel = "For You" + sectionDetailDefaultLimit = recommendations.CacheCandidateLimit +) + +type discoverRowModel struct { + Type string + Label string + ClusterIndex int + Items []recommendations.ScoredItem + UpcomingEvents map[string]upcomingEventResponse +} + +type upcomingDiscoverCandidate struct { + DisplayID string + Event upcomingEventResponse + AirDateTime time.Time + BaseIndex int + EffectiveIndex int + InWatchlist bool + IsFavorite bool +} + +type combinedForYouItem struct { + Item recommendations.ScoredItem + BaseIndex int + EffectiveIndex int + HasUpcoming bool + UpcomingEvent *upcomingEventResponse + AirDateTime time.Time +} + +// HandleDiscover handles GET /recommendations/discover. +// Returns all recommendation rows with fully enriched item metadata for the +// carousel-based discover page. +func (h *RecommendationsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) { + if h.reader == nil || h.Fetcher == nil { + writeJSON(w, http.StatusOK, discoverResponse{Rows: []discoverRowResponse{}}) + return + } + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + rows, err := h.reader.GetDiscoverRows(r.Context(), userID, profileID, 20, filter) + if err != nil { + slog.Error("Discover failed", "user_id", userID, "profile_id", profileID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") + return + } + + discoverRows := discoverRowModelsFromRecommendations(rows) + + discoverRows, upcomingErr := h.blendUpcomingIntoDiscoverRows( + r.Context(), + userID, + profileID, + filter, + discoverRows, + rows, + ) + if upcomingErr != nil { + slog.Warn( + "Discover: schedule-aware blending unavailable; falling back to legacy rows", + "user_id", + userID, + "profile_id", + profileID, + "error", + upcomingErr, + ) + } + + if len(discoverRows) == 0 { + writeJSON(w, http.StatusOK, discoverResponse{Rows: []discoverRowResponse{}}) + return + } + + // Collect unique item IDs across all rows for batched enrichment. + seen := make(map[string]struct{}) + var allIDs []string + for _, row := range discoverRows { + for _, item := range row.Items { + if _, ok := seen[item.MediaItemID]; ok { + continue + } + seen[item.MediaItemID] = struct{}{} + allIDs = append(allIDs, item.MediaItemID) + } + } + + enrichment, err := h.loadItemEnrichment(r.Context(), userID, profileID, filter, allIDs) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch item details") + return + } + + resp := discoverResponse{Rows: make([]discoverRowResponse, 0, len(discoverRows))} + for _, row := range discoverRows { + kind, key := discoverRowSectionKey(row.Type, row.Label, row.ClusterIndex) + respRow := discoverRowResponse{ + Type: row.Type, + Label: row.Label, + SectionKind: kind, + SectionKey: key, + Items: h.buildSectionItems(r.Context(), row.Items, enrichment, row.UpcomingEvents), + } + if len(respRow.Items) > 0 { + resp.Rows = append(resp.Rows, respRow) + } + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleSection handles GET /recommendations/section/{kind} and +// /recommendations/section/{kind}/{key}. It returns the full contents of a +// single recommendation row so the UI can render a dedicated "see all" page. +func (h *RecommendationsHandler) HandleSection(w http.ResponseWriter, r *http.Request) { + if h.reader == nil || h.Fetcher == nil { + writeError(w, http.StatusNotFound, "not_found", "Section not found") + return + } + + kind := chi.URLParam(r, "kind") + key := chi.URLParam(r, "key") + if kind == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Section kind is required") + return + } + + limit := sectionDetailDefaultLimit + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + if limit > recommendations.CacheCandidateLimit { + limit = recommendations.CacheCandidateLimit + } + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + row, err := h.reader.GetSection(r.Context(), userID, profileID, kind, key, limit, filter) + if err != nil { + slog.Error( + "Section failed", + "user_id", userID, + "profile_id", profileID, + "kind", kind, + "key", key, + "error", err, + ) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch section") + return + } + if row == nil { + writeJSON(w, http.StatusOK, sectionDetailResponse{ + Kind: kind, + Key: key, + Items: []sectionItemResponse{}, + }) + return + } + + ids := make([]string, 0, len(row.Items)) + seen := make(map[string]struct{}, len(row.Items)) + for _, item := range row.Items { + if _, ok := seen[item.MediaItemID]; ok { + continue + } + seen[item.MediaItemID] = struct{}{} + ids = append(ids, item.MediaItemID) + } + + enrichment, err := h.loadItemEnrichment(r.Context(), userID, profileID, filter, ids) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch item details") + return + } + + writeJSON(w, http.StatusOK, sectionDetailResponse{ + Kind: kind, + Key: key, + Type: row.Type, + Label: row.Label, + Items: h.buildSectionItems(r.Context(), row.Items, enrichment, nil), + }) +} + +// itemEnrichment caches the data needed to convert ScoredItem records into +// sectionItemResponse for a single request. +type itemEnrichment struct { + items map[string]*models.MediaItem + overlays map[string]*models.OverlaySummary + states map[string]*itemUserStateResponse +} + +func (h *RecommendationsHandler) loadItemEnrichment( + ctx context.Context, + userID int, + profileID string, + filter catalog.AccessFilter, + ids []string, +) (*itemEnrichment, error) { + out := &itemEnrichment{ + items: map[string]*models.MediaItem{}, + } + if h.Fetcher == nil || len(ids) == 0 { + return out, nil + } + + mediaItems, err := h.Fetcher.FetchItemsByContentIDs(ctx, ids, filter) + if err != nil { + slog.Error("Recommendations: fetch items failed", "error", err) + return nil, err + } + for _, mi := range mediaItems { + out.items[mi.ContentID] = mi + } + + overlays, overlayErr := h.Fetcher.ListOverlaySummaries(ctx, ids, filter) + if overlayErr != nil { + slog.Error("Recommendations: overlay summaries failed", "error", overlayErr) + } else { + out.overlays = overlays + } + + if h.storeProvider != nil { + store, storeErr := h.storeProvider.ForUser(ctx, userID) + if storeErr == nil && store != nil { + states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems) + if stateErr == nil { + out.states = states + } + } + } + + return out, nil +} + +func (h *RecommendationsHandler) buildSectionItems( + ctx context.Context, + scoredItems []recommendations.ScoredItem, + enrichment *itemEnrichment, + upcomingEvents map[string]upcomingEventResponse, +) []sectionItemResponse { + out := make([]sectionItemResponse, 0, len(scoredItems)) + for _, scored := range scoredItems { + mi, ok := enrichment.items[scored.MediaItemID] + if !ok || mi == nil { + continue + } + item := sectionItemResponse{ + ContentID: mi.ContentID, + Type: mi.Type, + Title: mi.Title, + Year: mi.Year, + Genres: mi.Genres, + Keywords: mi.Keywords, + Status: mi.Status, + RatingIMDB: mi.RatingIMDB, + RatingTMDB: mi.RatingTMDB, + RatingRTCritic: mi.RatingRTCritic, + RatingRTAudience: mi.RatingRTAudience, + OriginalLanguage: mi.OriginalLanguage, + Overview: mi.Overview, + PosterThumbhash: mi.PosterThumbhash, + BackdropThumbhash: mi.BackdropThumbhash, + } + if item.Genres == nil { + item.Genres = []string{} + } + if item.Keywords == nil { + item.Keywords = []string{} + } + if h.DetailSvc != nil { + item.PosterURL = h.DetailSvc.PresignURL(ctx, featuredPosterPath(mi.PosterPath), "featured") + item.BackdropURL = h.DetailSvc.PresignURL(ctx, featuredBackdropPath(mi.BackdropPath), "featured") + item.LogoURL = h.DetailSvc.PresignURL(ctx, mi.LogoPath, "featured") + } + if enrichment.overlays != nil { + item.OverlaySummary = enrichment.overlays[mi.ContentID] + } + if enrichment.states != nil { + item.UserState = enrichment.states[mi.ContentID] + } + if upcomingEvents != nil { + if upcomingEvent, ok := upcomingEvents[mi.ContentID]; ok { + upcomingEventCopy := upcomingEvent + item.UpcomingEvent = &upcomingEventCopy + } + } + out = append(out, item) + } + return out +} + +func discoverRowModelsFromRecommendations(rows []recommendations.ForYouRow) []discoverRowModel { + if len(rows) == 0 { + return nil + } + + models := make([]discoverRowModel, 0, len(rows)) + for _, row := range rows { + models = append(models, discoverRowModel{ + Type: row.Type, + Label: row.Label, + ClusterIndex: row.ClusterIndex, + Items: append([]recommendations.ScoredItem(nil), row.Items...), + }) + } + return models +} + +// discoverRowSectionKey derives the URL kind/key pair used by the recommendation +// section detail page from a discover row's type/label/cluster_index. Returns +// empty strings when the row has no dedicated detail page (so the frontend +// renders the title without a link). +func discoverRowSectionKey(rowType, label string, clusterIndex int) (string, string) { + switch rowType { + case "cluster": + // "For You" main row uses Type="cluster" with Label="For You" and no + // cluster index assigned. Specific cluster rows have label "Because + // you enjoy ...". + if label == discoverForYouLabel { + return recommendations.SectionKindForYouMain, "" + } + return recommendations.SectionKindCluster, strconv.Itoa(clusterIndex) + case "similar_users_liked": + return recommendations.SectionKindSimilarUsers, "" + case recommendations.RecTypePopular: + return recommendations.SectionKindPopular, "" + case recommendations.RecTypeRecentlyAdded: + return recommendations.SectionKindRecentlyAdded, "" + case recommendations.RecTypeTopRated: + return recommendations.SectionKindTopRated, "" + case "genre_sampler": + // Cold-start labels rows "Top X"; the warm-discover path labels them + // "Popular in X". The genre name is always the label suffix. + for _, prefix := range []string{"Popular in ", "Top "} { + if name, ok := strings.CutPrefix(label, prefix); ok && name != "" { + return recommendations.SectionKindGenre, name + } + } + } + return "", "" +} + +func (h *RecommendationsHandler) blendUpcomingIntoDiscoverRows( + ctx context.Context, + userID int, + profileID string, + filter catalog.AccessFilter, + discoverRows []discoverRowModel, + baseRows []recommendations.ForYouRow, +) ([]discoverRowModel, error) { + if h.CalendarRepo == nil { + return discoverRows, nil + } + + mainRowIndex := indexOfDiscoverForYouRow(discoverRows) + if mainRowIndex < 0 { + return discoverRows, nil + } + + now := time.Now + if h.nowFn != nil { + now = h.nowFn + } + nowUTC := now().UTC() + start := discoverUTCDate(nowUTC) + end := start.AddDate(0, 0, discoverUpcomingWindowDays-1) + + events, err := h.CalendarRepo.ListEvents(ctx, catalog.CalendarFilter{ + Start: start, + End: end, + Filter: "all", + AllowedLibraryIDs: filter.AllowedLibraryIDs, + DisabledLibraryIDs: filter.DisabledLibraryIDs, + MaxContentRating: filter.MaxContentRating, + UserID: userID, + ProfileID: profileID, + }) + if err != nil { + return discoverRows, err + } + if len(events) == 0 { + return discoverRows, nil + } + + premiereCandidates := categorizeUpcomingCandidates(events) + if len(premiereCandidates) == 0 { + return discoverRows, nil + } + + displayIDs := collectUpcomingDisplayIDsFromCandidates(premiereCandidates) + rankMap, firstSeenItems := buildDiscoverRankingData(baseRows) + + watchlistMap := map[string]bool{} + favoritesMap := map[string]bool{} + if h.storeProvider != nil && len(displayIDs) > 0 { + store, err := h.storeProvider.ForUser(ctx, userID) + if err != nil { + return discoverRows, err + } + if store != nil { + favoritesMap, err = store.ListFavoritesByMediaItems(ctx, profileID, displayIDs) + if err != nil { + return discoverRows, err + } + watchlistMap, err = store.ListWatchlistByMediaItems(ctx, profileID, displayIDs) + if err != nil { + return discoverRows, err + } + } + } + + watchedSet, err := h.watchedItemIDSet(ctx, userID, profileID) + if err != nil { + return discoverRows, err + } + + lowRatings := map[string]int{} + if h.ratingsRepo != nil && len(displayIDs) > 0 { + lowRatings, err = h.ratingsRepo.ListForItems(ctx, userID, profileID, displayIDs) + if err != nil { + return discoverRows, err + } + } + + discoverRows[mainRowIndex] = mergePremieresIntoDiscoverRow( + discoverRows[mainRowIndex], + rankMap, + firstSeenItems, + premiereCandidates, + watchedSet, + lowRatings, + watchlistMap, + favoritesMap, + nowUTC, + ) + + return discoverRows, nil +} + +func mergePremieresIntoDiscoverRow( + row discoverRowModel, + rankMap map[string]int, + firstSeenItems map[string]recommendations.ScoredItem, + candidates []upcomingDiscoverCandidate, + watchedSet map[string]struct{}, + lowRatings map[string]int, + watchlistMap map[string]bool, + favoritesMap map[string]bool, + now time.Time, +) discoverRowModel { + if len(candidates) == 0 { + return row + } + + rankedPremieres := rankUpcomingCandidates( + candidates, + rankMap, + watchedSet, + lowRatings, + watchlistMap, + favoritesMap, + now, + ) + if len(rankedPremieres) == 0 { + return row + } + + combined := buildCombinedForYouItems(row.Items, rankMap) + for _, candidate := range rankedPremieres { + scoredItem, ok := firstSeenItems[candidate.DisplayID] + if !ok { + scoredItem = recommendations.ScoredItem{MediaItemID: candidate.DisplayID} + } + + if existing, ok := combined[candidate.DisplayID]; ok { + existing.Item = mergeScoredItem(scoredItem, existing.Item) + existing.BaseIndex = candidate.BaseIndex + existing.EffectiveIndex = candidate.EffectiveIndex + existing.HasUpcoming = true + existing.UpcomingEvent = &candidate.Event + existing.AirDateTime = candidate.AirDateTime + continue + } + + combined[candidate.DisplayID] = &combinedForYouItem{ + Item: scoredItem, + BaseIndex: candidate.BaseIndex, + EffectiveIndex: candidate.EffectiveIndex, + HasUpcoming: true, + UpcomingEvent: &candidate.Event, + AirDateTime: candidate.AirDateTime, + } + } + + sorted := sortCombinedForYouItems(combined) + if len(sorted) > discoverForYouMaxItems { + sorted = sorted[:discoverForYouMaxItems] + } + + row.Items = make([]recommendations.ScoredItem, 0, len(sorted)) + row.UpcomingEvents = make(map[string]upcomingEventResponse) + for _, item := range sorted { + row.Items = append(row.Items, item.Item) + if item.HasUpcoming && item.UpcomingEvent != nil { + row.UpcomingEvents[item.Item.MediaItemID] = *item.UpcomingEvent + } + } + if len(row.UpcomingEvents) == 0 { + row.UpcomingEvents = nil + } + + return row +} + +func categorizeUpcomingCandidates( + events []catalog.CalendarEvent, +) []upcomingDiscoverCandidate { + premieres := make(map[string]upcomingDiscoverCandidate) + + for _, event := range events { + candidate, ok := buildUpcomingCandidate(event) + if !ok { + continue + } + if isUpcomingPremiere(event, candidate.Event.Badges) { + insertUpcomingCandidate(premieres, candidate) + } + } + + return upcomingCandidatesFromMap(premieres) +} + +func buildUpcomingCandidate(event catalog.CalendarEvent) (upcomingDiscoverCandidate, bool) { + displayID := event.ContentID + if event.Type != "movie" { + if event.SeriesID == nil || *event.SeriesID == "" { + return upcomingDiscoverCandidate{}, false + } + displayID = *event.SeriesID + } + + return upcomingDiscoverCandidate{ + DisplayID: displayID, + AirDateTime: calendarEventAirDateTime(event), + Event: upcomingEventResponse{ + Type: event.Type, + AirDate: event.AirDate.Format("2006-01-02"), + AirTime: event.AirTime, + EpisodeTitle: event.EpisodeTitle, + SeasonNumber: event.SeasonNumber, + EpisodeNumber: event.EpisodeNumber, + Badges: buildBadges(event), + }, + }, true +} + +func insertUpcomingCandidate( + target map[string]upcomingDiscoverCandidate, + candidate upcomingDiscoverCandidate, +) { + existing, ok := target[candidate.DisplayID] + if !ok || candidate.AirDateTime.Before(existing.AirDateTime) { + target[candidate.DisplayID] = candidate + } +} + +func upcomingCandidatesFromMap( + items map[string]upcomingDiscoverCandidate, +) []upcomingDiscoverCandidate { + if len(items) == 0 { + return nil + } + + candidates := make([]upcomingDiscoverCandidate, 0, len(items)) + for _, candidate := range items { + candidates = append(candidates, candidate) + } + sort.SliceStable(candidates, func(i, j int) bool { + if !candidates[i].AirDateTime.Equal(candidates[j].AirDateTime) { + return candidates[i].AirDateTime.Before(candidates[j].AirDateTime) + } + return candidates[i].DisplayID < candidates[j].DisplayID + }) + return candidates +} + +func rankUpcomingCandidates( + candidates []upcomingDiscoverCandidate, + rankMap map[string]int, + watchedSet map[string]struct{}, + lowRatings map[string]int, + watchlistMap map[string]bool, + favoritesMap map[string]bool, + now time.Time, +) []upcomingDiscoverCandidate { + if len(candidates) == 0 { + return nil + } + + ranked := make([]upcomingDiscoverCandidate, 0, len(candidates)) + savedOnly := make([]upcomingDiscoverCandidate, 0, len(candidates)) + + for _, candidate := range candidates { + if _, watched := watchedSet[candidate.DisplayID]; watched { + continue + } + if rating, rated := lowRatings[candidate.DisplayID]; rated && rating <= 2 { + continue + } + + candidate.InWatchlist = watchlistMap[candidate.DisplayID] + candidate.IsFavorite = favoritesMap[candidate.DisplayID] + + if baseIndex, ok := rankMap[candidate.DisplayID]; ok { + candidate.BaseIndex = baseIndex + ranked = append(ranked, candidate) + continue + } + if candidate.InWatchlist || candidate.IsFavorite { + savedOnly = append(savedOnly, candidate) + } + } + + sort.SliceStable(savedOnly, func(i, j int) bool { + if !savedOnly[i].AirDateTime.Equal(savedOnly[j].AirDateTime) { + return savedOnly[i].AirDateTime.Before(savedOnly[j].AirDateTime) + } + return savedOnly[i].DisplayID < savedOnly[j].DisplayID + }) + for i := range savedOnly { + savedOnly[i].BaseIndex = len(rankMap) + i + ranked = append(ranked, savedOnly[i]) + } + + for i := range ranked { + ranked[i].EffectiveIndex = effectiveUpcomingIndex( + ranked[i].BaseIndex, + ranked[i].InWatchlist, + ranked[i].IsFavorite, + ranked[i].AirDateTime, + now, + ) + } + + sort.SliceStable(ranked, func(i, j int) bool { + if ranked[i].EffectiveIndex != ranked[j].EffectiveIndex { + return ranked[i].EffectiveIndex < ranked[j].EffectiveIndex + } + if !ranked[i].AirDateTime.Equal(ranked[j].AirDateTime) { + return ranked[i].AirDateTime.Before(ranked[j].AirDateTime) + } + if ranked[i].BaseIndex != ranked[j].BaseIndex { + return ranked[i].BaseIndex < ranked[j].BaseIndex + } + return ranked[i].DisplayID < ranked[j].DisplayID + }) + + return ranked +} + +func buildDiscoverRankingData(rows []recommendations.ForYouRow) (map[string]int, map[string]recommendations.ScoredItem) { + rankMap := make(map[string]int) + firstSeenItems := make(map[string]recommendations.ScoredItem) + nextIndex := 0 + for _, row := range rows { + for _, item := range row.Items { + if _, ok := rankMap[item.MediaItemID]; ok { + continue + } + rankMap[item.MediaItemID] = nextIndex + firstSeenItems[item.MediaItemID] = item + nextIndex++ + } + } + return rankMap, firstSeenItems +} + +func effectiveUpcomingIndex( + baseIndex int, + inWatchlist bool, + isFavorite bool, + airDateTime time.Time, + now time.Time, +) int { + effectiveIndex := baseIndex + if inWatchlist { + effectiveIndex -= 6 + } + if isFavorite { + effectiveIndex -= 3 + } + + timeUntilAiring := airDateTime.Sub(now) + if timeUntilAiring <= 48*time.Hour { + effectiveIndex -= 2 + } else if timeUntilAiring <= 7*24*time.Hour { + effectiveIndex -= 1 + } + + return effectiveIndex +} + +func collectUpcomingDisplayIDsFromCandidates(candidates []upcomingDiscoverCandidate) []string { + if len(candidates) == 0 { + return nil + } + + displayIDs := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + if _, ok := seen[candidate.DisplayID]; ok { + continue + } + seen[candidate.DisplayID] = struct{}{} + displayIDs = append(displayIDs, candidate.DisplayID) + } + + return displayIDs +} + +func isUpcomingPremiere(event catalog.CalendarEvent, badges []string) bool { + if event.Type == "movie" { + return true + } + return hasUpcomingBadge(badges, "series_premiere") || hasUpcomingBadge(badges, "season_premiere") +} + +func indexOfDiscoverForYouRow(rows []discoverRowModel) int { + for i := range rows { + if rows[i].Label == discoverForYouLabel { + return i + } + } + return -1 +} + +func buildCombinedForYouItems( + items []recommendations.ScoredItem, + rankMap map[string]int, +) map[string]*combinedForYouItem { + combined := make(map[string]*combinedForYouItem, len(items)) + nextIndex := len(rankMap) + for _, item := range items { + baseIndex, ok := rankMap[item.MediaItemID] + if !ok { + baseIndex = nextIndex + nextIndex++ + } + combined[item.MediaItemID] = &combinedForYouItem{ + Item: item, + BaseIndex: baseIndex, + EffectiveIndex: baseIndex, + } + } + return combined +} + +func mergeScoredItem( + primary recommendations.ScoredItem, + fallback recommendations.ScoredItem, +) recommendations.ScoredItem { + if primary.Score == 0 { + primary.Score = fallback.Score + } + if primary.Reason == "" { + primary.Reason = fallback.Reason + } + if primary.ReasonDetail == "" { + primary.ReasonDetail = fallback.ReasonDetail + } + return primary +} + +func sortCombinedForYouItems(combined map[string]*combinedForYouItem) []combinedForYouItem { + items := make([]combinedForYouItem, 0, len(combined)) + for _, item := range combined { + items = append(items, *item) + } + + sort.SliceStable(items, func(i, j int) bool { + if items[i].EffectiveIndex != items[j].EffectiveIndex { + return items[i].EffectiveIndex < items[j].EffectiveIndex + } + if items[i].HasUpcoming != items[j].HasUpcoming { + return items[i].HasUpcoming + } + if items[i].HasUpcoming && items[j].HasUpcoming && !items[i].AirDateTime.Equal(items[j].AirDateTime) { + return items[i].AirDateTime.Before(items[j].AirDateTime) + } + if items[i].BaseIndex != items[j].BaseIndex { + return items[i].BaseIndex < items[j].BaseIndex + } + return items[i].Item.MediaItemID < items[j].Item.MediaItemID + }) + + return items +} + +func hasUpcomingBadge(badges []string, target string) bool { + for _, badge := range badges { + if badge == target { + return true + } + } + return false +} + +func calendarEventAirDateTime(event catalog.CalendarEvent) time.Time { + base := discoverUTCDate(event.AirDate.UTC()) + if event.AirTime == nil || *event.AirTime == "" { + return base + } + + layouts := []string{"15:04:05", "15:04"} + for _, layout := range layouts { + parsed, err := time.Parse(layout, *event.AirTime) + if err == nil { + return time.Date( + base.Year(), + base.Month(), + base.Day(), + parsed.Hour(), + parsed.Minute(), + parsed.Second(), + 0, + time.UTC, + ) + } + } + + return base +} + +func discoverUTCDate(t time.Time) time.Time { + t = t.UTC() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +} diff --git a/internal/api/handlers/recommendations_section_test.go b/internal/api/handlers/recommendations_section_test.go new file mode 100644 index 00000000..319d292f --- /dev/null +++ b/internal/api/handlers/recommendations_section_test.go @@ -0,0 +1,157 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/recommendations" +) + +func TestDiscoverRowSectionKey(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + rowType string + label string + cluster int + wantKind string + wantKey string + }{ + {"for-you main", "cluster", "For You", 0, recommendations.SectionKindForYouMain, ""}, + {"cluster row", "cluster", "Because you enjoy Drama", 2, recommendations.SectionKindCluster, "2"}, + {"cluster zero", "cluster", "Because you enjoy Action", 0, recommendations.SectionKindCluster, "0"}, + {"similar users", "similar_users_liked", "Users Like You Also Enjoyed", 0, recommendations.SectionKindSimilarUsers, ""}, + {"popular", "popular", "Popular on This Server", 0, recommendations.SectionKindPopular, ""}, + {"recently added", "recently_added", "Recently Added", 0, recommendations.SectionKindRecentlyAdded, ""}, + {"top rated", "top_rated", "Top Rated", 0, recommendations.SectionKindTopRated, ""}, + {"genre warm", "genre_sampler", "Popular in Drama", 0, recommendations.SectionKindGenre, "Drama"}, + {"genre cold", "genre_sampler", "Top Sci-Fi", 0, recommendations.SectionKindGenre, "Sci-Fi"}, + {"unknown row", "watch_tonight", "Watch Tonight", 0, "", ""}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotKind, gotKey := discoverRowSectionKey(tc.rowType, tc.label, tc.cluster) + if gotKind != tc.wantKind || gotKey != tc.wantKey { + t.Fatalf( + "discoverRowSectionKey(%q,%q,%d) = (%q,%q), want (%q,%q)", + tc.rowType, tc.label, tc.cluster, gotKind, gotKey, tc.wantKind, tc.wantKey, + ) + } + }) + } +} + +type stubSectionReader struct { + stubRecommendationsReader + row *recommendations.ForYouRow + err error +} + +func (s stubSectionReader) GetSection( + context.Context, + int, + string, + string, + string, + int, + catalog.AccessFilter, +) (*recommendations.ForYouRow, error) { + return s.row, s.err +} + +func TestRecommendationsHandleSection_ReturnsEnrichedRow(t *testing.T) { + t.Parallel() + + handler := NewRecommendationsHandler(nil, stubSectionReader{ + row: &recommendations.ForYouRow{ + Type: "genre_sampler", + Label: "Popular in Drama", + Items: []recommendations.ScoredItem{{MediaItemID: "movie-1"}}, + }, + }, nil, nil, nil, false) + handler.Fetcher = stubDiscoverFetcher{ + items: []*models.MediaItem{{ + ContentID: "movie-1", + Type: "movie", + Title: "Movie One", + Genres: []string{"Drama"}, + Status: "matched", + }}, + } + + req := httptest.NewRequest(http.MethodGet, "/recommendations/section/genre/Drama", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("kind", "genre") + rctx.URLParams.Add("key", "Drama") + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 7}) + ctx = apimw.SetProfileID(ctx, "profile-1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + handler.HandleSection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var resp sectionDetailResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Kind != "genre" || resp.Key != "Drama" { + t.Fatalf("kind/key = %q/%q, want genre/Drama", resp.Kind, resp.Key) + } + if resp.Label != "Popular in Drama" { + t.Fatalf("label = %q, want Popular in Drama", resp.Label) + } + if len(resp.Items) != 1 || resp.Items[0].ContentID != "movie-1" { + t.Fatalf("items = %+v, want [movie-1]", resp.Items) + } +} + +func TestRecommendationsHandleSection_ReturnsEmptyWhenRowMissing(t *testing.T) { + t.Parallel() + + handler := NewRecommendationsHandler(nil, stubSectionReader{row: nil}, nil, nil, nil, false) + handler.Fetcher = stubDiscoverFetcher{} + + req := httptest.NewRequest(http.MethodGet, "/recommendations/section/popular", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("kind", "popular") + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 7}) + ctx = apimw.SetProfileID(ctx, "profile-1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + handler.HandleSection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var resp sectionDetailResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Kind != "popular" { + t.Fatalf("kind = %q, want popular", resp.Kind) + } + if len(resp.Items) != 0 { + t.Fatalf("items = %+v, want empty", resp.Items) + } +} diff --git a/internal/api/handlers/recommendations_upcoming_test.go b/internal/api/handlers/recommendations_upcoming_test.go new file mode 100644 index 00000000..04c4181e --- /dev/null +++ b/internal/api/handlers/recommendations_upcoming_test.go @@ -0,0 +1,371 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/sections" +) + +func TestMergePremieresIntoDiscoverRow_BlendsIntoForYouRow(t *testing.T) { + t.Parallel() + + baseRows := []recommendations.ForYouRow{{ + Type: "cluster", + Label: "For You", + Items: []recommendations.ScoredItem{ + {MediaItemID: "series-premiere"}, + {MediaItemID: "series-airing"}, + {MediaItemID: "movie-premiere"}, + }, + }} + + events := []catalog.CalendarEvent{ + movieCalendarEvent("movie-premiere", "2026-04-08", nil), + episodeCalendarEvent("episode-premiere", "series-premiere", 1, 1, "2026-04-10", nil), + episodeCalendarEvent("episode-followup", "series-premiere", 1, 2, "2026-04-11", nil), + episodeCalendarEvent("episode-earliest", "series-airing", 2, 2, "2026-04-09", nil), + } + + discoverRows := discoverRowModelsFromRecommendations(baseRows) + rankMap, firstSeenItems := buildDiscoverRankingData(baseRows) + merged := mergePremieresIntoDiscoverRow( + discoverRows[0], + rankMap, + firstSeenItems, + categorizeUpcomingCandidates(events), + map[string]struct{}{}, + map[string]int{}, + map[string]bool{}, + map[string]bool{}, + time.Date(2026, time.April, 6, 12, 0, 0, 0, time.UTC), + ) + + if merged.Label != discoverForYouLabel { + t.Fatalf("merged.Label = %q, want %q", merged.Label, discoverForYouLabel) + } + if len(merged.Items) != 3 { + t.Fatalf("merged items = %d, want 3", len(merged.Items)) + } + + if _, ok := merged.UpcomingEvents["series-premiere"]; !ok { + t.Fatalf("series premiere should be enriched with upcoming metadata") + } + if _, ok := merged.UpcomingEvents["movie-premiere"]; !ok { + t.Fatalf("movie premiere should be enriched with upcoming metadata") + } + if _, ok := merged.UpcomingEvents["series-airing"]; ok { + t.Fatalf("regular episode airings should not be blended into the For You row") + } +} + +func TestMergePremieresIntoDiscoverRow_PromotesAndRanksCandidates(t *testing.T) { + t.Parallel() + + baseRows := []recommendations.ForYouRow{ + { + Type: "cluster", + Label: "For You", + Items: []recommendations.ScoredItem{ + {MediaItemID: "movie-a"}, + {MediaItemID: "movie-b"}, + }, + }, + { + Type: "genre_sampler", + Label: "Popular in Drama", + Items: []recommendations.ScoredItem{ + {MediaItemID: "movie-c"}, + }, + }, + } + + discoverRows := discoverRowModelsFromRecommendations(baseRows) + rankMap, firstSeenItems := buildDiscoverRankingData(baseRows) + merged := mergePremieresIntoDiscoverRow( + discoverRows[0], + rankMap, + firstSeenItems, + categorizeUpcomingCandidates([]catalog.CalendarEvent{ + movieCalendarEvent("movie-a", "2026-04-12", nil), + movieCalendarEvent("movie-b", "2026-04-07", testStringPtr("10:00")), + movieCalendarEvent("movie-c", "2026-04-13", nil), + movieCalendarEvent("movie-d", "2026-04-08", nil), + movieCalendarEvent("movie-e", "2026-04-09", nil), + }), + map[string]struct{}{"movie-f": {}}, + map[string]int{"movie-e": 2}, + map[string]bool{"movie-b": true, "movie-d": true}, + map[string]bool{"movie-c": true}, + time.Date(2026, time.April, 6, 12, 0, 0, 0, time.UTC), + ) + + if len(merged.Items) != 4 { + t.Fatalf("merged items = %d, want 4", len(merged.Items)) + } + + gotIDs := []string{ + merged.Items[0].MediaItemID, + merged.Items[1].MediaItemID, + merged.Items[2].MediaItemID, + merged.Items[3].MediaItemID, + } + wantIDs := []string{"movie-b", "movie-d", "movie-c", "movie-a"} + for i := range wantIDs { + if gotIDs[i] != wantIDs[i] { + t.Fatalf("row item %d = %q, want %q", i, gotIDs[i], wantIDs[i]) + } + } + + if _, ok := merged.UpcomingEvents["movie-c"]; !ok { + t.Fatalf("promoted discover item should carry upcoming metadata") + } + if _, ok := merged.UpcomingEvents["movie-d"]; !ok { + t.Fatalf("saved-only premiere should be blended into the main For You row") + } + for _, item := range merged.Items { + if item.MediaItemID == "movie-e" { + t.Fatalf("low-rated premiere %q should not be present", item.MediaItemID) + } + } +} + +func TestBlendUpcomingIntoDiscoverRows_LeavesRowsUnchangedWithoutForYou(t *testing.T) { + t.Parallel() + + handler := NewRecommendationsHandler(nil, nil, nil, nil, nil, false) + handler.CalendarRepo = stubCalendarRepository{} + handler.nowFn = func() time.Time { + return time.Date(2026, time.April, 6, 12, 0, 0, 0, time.UTC) + } + + rows := []discoverRowModel{{ + Type: "genre_sampler", + Label: "Popular in Drama", + Items: []recommendations.ScoredItem{{MediaItemID: "movie-1"}}, + }} + + blended, err := handler.blendUpcomingIntoDiscoverRows( + context.Background(), + 7, + "profile-1", + catalog.AccessFilter{}, + rows, + nil, + ) + if err != nil { + t.Fatalf("blendUpcomingIntoDiscoverRows error = %v", err) + } + if len(blended) != 1 || blended[0].Label != "Popular in Drama" { + t.Fatalf("blended rows = %+v, want unchanged rows", blended) + } +} + +func TestRecommendationsHandleDiscover_FallsBackToLegacyRowsWhenUpcomingLoadFails(t *testing.T) { + t.Parallel() + + handler := NewRecommendationsHandler(nil, stubRecommendationsReader{ + discoverRows: []recommendations.ForYouRow{{ + Type: "cluster", + Label: "For You", + Items: []recommendations.ScoredItem{{MediaItemID: "movie-1"}}, + }}, + }, nil, nil, nil, false) + handler.Fetcher = stubDiscoverFetcher{ + items: []*models.MediaItem{{ + ContentID: "movie-1", + Type: "movie", + Title: "Movie One", + Genres: []string{"Drama"}, + Status: "matched", + }}, + } + handler.CalendarRepo = stubCalendarRepository{err: errors.New("calendar unavailable")} + + req := httptest.NewRequest(http.MethodGet, "/recommendations/discover", nil) + ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}) + ctx = apimw.SetProfileID(ctx, "profile-1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + handler.HandleDiscover(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var resp discoverResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(resp.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(resp.Rows)) + } + if resp.Rows[0].Label != "For You" { + t.Fatalf("row label = %q, want For You", resp.Rows[0].Label) + } + if len(resp.Rows[0].Items) != 1 || resp.Rows[0].Items[0].ContentID != "movie-1" { + t.Fatalf("legacy row items = %+v, want movie-1", resp.Rows[0].Items) + } + if resp.Rows[0].Items[0].UpcomingEvent != nil { + t.Fatalf("legacy row should not include upcoming event metadata") + } +} + +type stubRecommendationsReader struct { + discoverRows []recommendations.ForYouRow +} + +func (s stubRecommendationsReader) GetForYouMain( + context.Context, + int, + string, + int, + catalog.AccessFilter, +) (*recommendations.ForYouRow, error) { + return nil, nil +} + +func (s stubRecommendationsReader) GetForYouRows( + context.Context, + int, + string, + int, + catalog.AccessFilter, +) ([]recommendations.ForYouRow, error) { + return nil, nil +} + +func (s stubRecommendationsReader) GetSimilarUsersLiked( + context.Context, + int, + string, + int, + catalog.AccessFilter, +) ([]recommendations.ScoredItem, error) { + return nil, nil +} + +func (s stubRecommendationsReader) GetDiscoverRows( + context.Context, + int, + string, + int, + catalog.AccessFilter, +) ([]recommendations.ForYouRow, error) { + return s.discoverRows, nil +} + +func (s stubRecommendationsReader) GetSection( + context.Context, + int, + string, + string, + string, + int, + catalog.AccessFilter, +) (*recommendations.ForYouRow, error) { + return nil, nil +} + +func (s stubRecommendationsReader) GetWatchTonight( + context.Context, + int, + string, + int, + catalog.AccessFilter, +) (recommendations.WatchTonightResult, error) { + return recommendations.WatchTonightResult{}, nil +} + +type stubDiscoverFetcher struct { + items []*models.MediaItem +} + +func (s stubDiscoverFetcher) FetchItemsByContentIDs( + context.Context, + []string, + catalog.AccessFilter, +) ([]*models.MediaItem, error) { + return s.items, nil +} + +func (s stubDiscoverFetcher) FetchEpisodesByContentIDs( + context.Context, + []string, + catalog.AccessFilter, +) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error) { + return nil, map[string]sections.SectionItemMeta{}, nil +} + +func (s stubDiscoverFetcher) ListOverlaySummaries( + context.Context, + []string, + catalog.AccessFilter, +) (map[string]*models.OverlaySummary, error) { + return map[string]*models.OverlaySummary{}, nil +} + +type stubCalendarRepository struct { + err error +} + +func (s stubCalendarRepository) ListEvents( + context.Context, + catalog.CalendarFilter, +) ([]catalog.CalendarEvent, error) { + return nil, s.err +} + +func movieCalendarEvent(contentID string, airDate string, airTime *string) catalog.CalendarEvent { + return catalog.CalendarEvent{ + ContentID: contentID, + Type: "movie", + Title: contentID, + AirDate: mustCalendarDate(airDate), + AirTime: airTime, + } +} + +func episodeCalendarEvent( + contentID string, + seriesID string, + seasonNumber int, + episodeNumber int, + airDate string, + airTime *string, +) catalog.CalendarEvent { + return catalog.CalendarEvent{ + ContentID: contentID, + Type: "episode", + Title: seriesID, + SeriesID: &seriesID, + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + AirDate: mustCalendarDate(airDate), + AirTime: airTime, + IsPremiere: episodeNumber == 1, + } +} + +func mustCalendarDate(value string) time.Time { + t, err := time.Parse("2006-01-02", value) + if err != nil { + panic(err) + } + return t +} + +func testStringPtr(value string) *string { + return &value +} diff --git a/internal/api/handlers/search.go b/internal/api/handlers/search.go new file mode 100644 index 00000000..ee32b2c5 --- /dev/null +++ b/internal/api/handlers/search.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// SearchHandler handles the search endpoint. +type SearchHandler struct { + itemRepo *catalog.ItemRepository + itemsH *ItemsHandler // reuse toItemListResponse +} + +// NewSearchHandler creates a new SearchHandler. +func NewSearchHandler( + itemRepo *catalog.ItemRepository, + itemsH *ItemsHandler, +) *SearchHandler { + return &SearchHandler{ + itemRepo: itemRepo, + itemsH: itemsH, + } +} + +// HandleSearch handles GET /search?q=...&limit=...&offset=... +func (h *SearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) { + writeDeprecatedReadHeaders(w, "/api/v1/catalog") + if h == nil || h.itemsH == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") + return + } + NewCatalogHandler(h.itemsH.catalogResolver, h.itemsH).HandleLegacySearch(w, r) +} + +func parseSearchTypes(rawValues []string) []string { + if len(rawValues) == 0 { + return nil + } + + seen := map[string]bool{} + result := make([]string, 0, len(rawValues)) + for _, raw := range rawValues { + for part := range strings.SplitSeq(raw, ",") { + normalized := strings.ToLower(strings.TrimSpace(part)) + switch normalized { + case "movie", "series", "season", "episode": + default: + continue + } + if seen[normalized] { + continue + } + seen[normalized] = true + result = append(result, normalized) + } + } + return result +} diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go new file mode 100644 index 00000000..206eedc6 --- /dev/null +++ b/internal/api/handlers/sections.go @@ -0,0 +1,1506 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/sections/recipes" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// SectionHandler handles section management and batch section endpoints. +type SectionHandler struct { + repo *sections.Repository + fetcher *sections.Fetcher + previewFetcher sectionPreviewFetcher // set to fetcher at construction; separate for test injection + FolderRepo *catalog.FolderRepository + EpisodeRepo *catalog.EpisodeRepository + StoreProvider userstore.UserStoreProvider + UserRepo *auth.UserRepository + DetailSvc *catalog.DetailService + Settings *catalog.ServerSettingsRepo + CollectionRepo *catalog.LibraryCollectionRepository +} + +// NewSectionHandler creates a new SectionHandler. +func NewSectionHandler(repo *sections.Repository, fetcher *sections.Fetcher) *SectionHandler { + return &SectionHandler{repo: repo, fetcher: fetcher, previewFetcher: fetcher} +} + +func (h *SectionHandler) defaultHomeSections(ctx context.Context) ([]*sections.PageSection, error) { + if h.FolderRepo == nil { + return sections.DefaultHomeSections(nil), nil + } + + libraries, err := h.FolderRepo.List(ctx) + if err != nil { + return nil, err + } + return sections.DefaultHomeSections(libraries), nil +} + +func (h *SectionHandler) defaultLibrarySections(ctx context.Context, libraryID int) ([]*sections.PageSection, error) { + if h.FolderRepo == nil { + return libraryDefaultSections(nil, libraryID), nil + } + + folder, err := h.FolderRepo.GetByID(ctx, libraryID) + if err != nil { + return nil, err + } + + return libraryDefaultSections(folder, libraryID), nil +} + +func libraryDefaultSections(folder *models.MediaFolder, libraryID int) []*sections.PageSection { + if folder == nil { + return sections.DefaultLibrarySections(&libraryID) + } + return sections.DefaultLibrarySectionsForType(&libraryID, folder.Type) +} + +// --- Request/Response types --- + +type createSectionRequest struct { + Scope string `json:"scope"` + LibraryID *int `json:"library_id"` + Position int `json:"position"` + SectionType string `json:"section_type"` + Title string `json:"title"` + Featured bool `json:"featured"` + ItemLimit int `json:"item_limit"` + Config json.RawMessage `json:"config"` + Enabled bool `json:"enabled"` +} + +type updateSectionRequest struct { + Position *int `json:"position"` + SectionType string `json:"section_type,omitempty"` + Title string `json:"title,omitempty"` + Featured *bool `json:"featured"` + ItemLimit *int `json:"item_limit"` + Config json.RawMessage `json:"config,omitempty"` + Enabled *bool `json:"enabled"` +} + +type reorderSectionsRequest struct { + Entries []sections.ReorderEntry `json:"entries"` +} + +type restoreDefaultsRequest struct { + Scope string `json:"scope"` + LibraryID *int `json:"library_id"` + ResetProfiles bool `json:"reset_profiles"` +} + +type sectionResponse struct { + ID string `json:"id"` + Scope string `json:"scope"` + LibraryID *int `json:"library_id"` + Position int `json:"position"` + SectionType string `json:"section_type"` + Title string `json:"title"` + Featured bool `json:"featured"` + ItemLimit int `json:"item_limit"` + Config json.RawMessage `json:"config"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type sectionListResponse struct { + Sections []sectionResponse `json:"sections"` +} + +func toSectionResponse(s *sections.PageSection) sectionResponse { + return sectionResponse{ + ID: s.ID, + Scope: s.Scope, + LibraryID: s.LibraryID, + Position: s.Position, + SectionType: string(s.SectionType), + Title: s.Title, + Featured: s.Featured, + ItemLimit: s.ItemLimit, + Config: s.Config, + Enabled: s.Enabled, + CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// validateSectionConfig checks config requirements for the given section type. +func validateSectionConfig(sectionType sections.SectionType, config json.RawMessage) (string, bool) { + if sectionType == sections.SectionCollection { + collectionConfig := sections.ParseCollectionConfig(config) + if strings.TrimSpace(collectionConfig.LibraryCollectionID) == "" { + return "library_collection_id is required for collection sections", false + } + } + + cfg := sections.ParseConfigFilters(config) + if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" { + return "filter_type must be 'movie' or 'series'", false + } + + var rawLibraryConfig struct { + FilterLibraryID *int `json:"filter_library_id"` + FilterLibraryIDs []int `json:"filter_library_ids"` + } + if len(config) > 0 { + _ = json.Unmarshal(config, &rawLibraryConfig) + } + if rawLibraryConfig.FilterLibraryID != nil && *rawLibraryConfig.FilterLibraryID <= 0 { + return "filter_library_id must be a positive library ID", false + } + for _, id := range rawLibraryConfig.FilterLibraryIDs { + if id <= 0 { + return "filter_library_ids must contain positive library IDs", false + } + } + if sectionType == sections.SectionCustomFilter || sectionType == sections.SectionGenre || sectionType == sections.SectionRandom || len(config) > 0 { + if _, err := sections.ParseQueryDefinition(config); err != nil { + return err.Error(), false + } + } + return "", true +} + +func validateSectionScope(scope string, libraryID *int) (string, bool) { + switch scope { + case "", "home": + if libraryID != nil { + return "library_id must not be set for home sections", false + } + case "library": + if libraryID == nil { + return "library_id is required for library sections", false + } + default: + return "Invalid scope", false + } + + return "", true +} + +// --- Admin CRUD endpoints --- + +// HandleListSections handles GET /admin/sections?scope=home&library_id=123 +func (h *SectionHandler) HandleListSections(w http.ResponseWriter, r *http.Request) { + scope := r.URL.Query().Get("scope") + if scope == "" { + scope = "home" + } + + var libraryID *int + if lid := r.URL.Query().Get("library_id"); lid != "" { + v, err := strconv.Atoi(lid) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id") + return + } + libraryID = &v + } + + list, err := h.repo.ListByScopeAll(r.Context(), scope, libraryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list sections") + return + } + + resp := sectionListResponse{Sections: make([]sectionResponse, 0, len(list))} + for _, s := range list { + resp.Sections = append(resp.Sections, toSectionResponse(s)) + } + writeJSON(w, http.StatusOK, resp) +} + +// HandleCreateSection handles POST /admin/sections +func (h *SectionHandler) HandleCreateSection(w http.ResponseWriter, r *http.Request) { + var req createSectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Title == "" || req.SectionType == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Title and section_type are required") + return + } + + if !sections.ValidSectionTypes[sections.SectionType(req.SectionType)] { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid section_type") + return + } + + scope := req.Scope + if scope == "" { + scope = "home" + } + + if msg, ok := validateSectionScope(scope, req.LibraryID); !ok { + writeError(w, http.StatusBadRequest, "bad_request", msg) + return + } + + if msg, ok := validateSectionConfig(sections.SectionType(req.SectionType), req.Config); !ok { + writeError(w, http.StatusBadRequest, "bad_request", msg) + return + } + + sec := §ions.PageSection{ + Scope: scope, + LibraryID: req.LibraryID, + Position: req.Position, + SectionType: sections.SectionType(req.SectionType), + Title: req.Title, + Featured: req.Featured, + ItemLimit: req.ItemLimit, + Config: req.Config, + Enabled: req.Enabled, + } + if sec.Scope == "" { + sec.Scope = "home" + } + if sec.ItemLimit <= 0 { + sec.ItemLimit = 20 + } + + created, err := h.repo.Create(r.Context(), sec) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create section") + return + } + + writeJSON(w, http.StatusCreated, toSectionResponse(created)) +} + +// HandleUpdateSection handles PUT /admin/sections/{id} +func (h *SectionHandler) HandleUpdateSection(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Section ID is required") + return + } + + existing, err := h.repo.GetByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "Section not found") + return + } + + var req updateSectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Position != nil { + existing.Position = *req.Position + } + if req.SectionType != "" { + existing.SectionType = sections.SectionType(req.SectionType) + } + if req.Title != "" { + existing.Title = req.Title + } + if req.Featured != nil { + existing.Featured = *req.Featured + } + if req.ItemLimit != nil { + existing.ItemLimit = *req.ItemLimit + } + if len(req.Config) > 0 { + existing.Config = req.Config + } + if req.Enabled != nil { + existing.Enabled = *req.Enabled + } + + if msg, ok := validateSectionScope(existing.Scope, existing.LibraryID); !ok { + writeError(w, http.StatusBadRequest, "bad_request", msg) + return + } + + if msg, ok := validateSectionConfig(existing.SectionType, existing.Config); !ok { + writeError(w, http.StatusBadRequest, "bad_request", msg) + return + } + + if err := h.repo.Update(r.Context(), existing); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update section") + return + } + + updated, _ := h.repo.GetByID(r.Context(), id) + writeJSON(w, http.StatusOK, toSectionResponse(updated)) +} + +// HandleDeleteSection handles DELETE /admin/sections/{id} +func (h *SectionHandler) HandleDeleteSection(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Section ID is required") + return + } + + existing, err := h.repo.GetByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "Section not found") + return + } + collectionID := strings.TrimSpace(sections.ParseCollectionConfig(existing.Config).LibraryCollectionID) + + if err := h.repo.Delete(r.Context(), id); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Section not found") + return + } + h.deleteUnreferencedSectionManagedCollection(r.Context(), collectionID) + w.WriteHeader(http.StatusNoContent) +} + +func (h *SectionHandler) deleteUnreferencedSectionManagedCollection(ctx context.Context, collectionID string) { + if collectionID == "" || h.CollectionRepo == nil { + return + } + collection, err := h.CollectionRepo.GetByID(ctx, collectionID) + if err != nil { + if !errors.Is(err, catalog.ErrLibraryCollectionNotFound) { + slog.Warn("failed to load section-managed collection during section delete", "collection_id", collectionID, "error", err) + } + return + } + if collection.ManagementMode != "section" { + return + } + refs, err := h.repo.CountLibraryCollectionReferences(ctx, collectionID, "") + if err != nil { + slog.Warn("failed to count section-managed collection references", "collection_id", collectionID, "error", err) + return + } + if refs > 0 { + return + } + if err := h.CollectionRepo.Delete(ctx, collectionID); err != nil && !errors.Is(err, catalog.ErrLibraryCollectionNotFound) { + slog.Warn("failed to delete unreferenced section-managed collection", "collection_id", collectionID, "error", err) + } +} + +// HandleReorderSections handles PUT /admin/sections/reorder +func (h *SectionHandler) HandleReorderSections(w http.ResponseWriter, r *http.Request) { + var req reorderSectionsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if err := h.repo.Reorder(r.Context(), req.Entries); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder sections") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Batch section endpoints --- + +type upcomingEventResponse struct { + Type string `json:"type"` + AirDate string `json:"air_date"` + AirTime *string `json:"air_time,omitempty"` + EpisodeTitle *string `json:"episode_title,omitempty"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` + Badges []string `json:"badges"` +} + +type sectionItemResponse struct { + ContentID string `json:"content_id"` + Type string `json:"type"` + Title string `json:"title"` + SeriesID string `json:"series_id,omitempty"` + SeriesTitle string `json:"series_title,omitempty"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` + Year int `json:"year,omitempty"` + Runtime int `json:"runtime,omitempty"` + Genres []string `json:"genres"` + Keywords []string `json:"keywords"` + Studios []string `json:"studios,omitempty"` + Networks []string `json:"networks,omitempty"` + ContentRating string `json:"content_rating,omitempty"` + Status string `json:"status"` + ShowStatus string `json:"show_status,omitempty"` + RatingIMDB *float64 `json:"rating_imdb,omitempty"` + RatingTMDB *float64 `json:"rating_tmdb,omitempty"` + RatingRTCritic *int `json:"rating_rt_critic,omitempty"` + RatingRTAudience *int `json:"rating_rt_audience,omitempty"` + OriginalLanguage string `json:"original_language,omitempty"` + Overview string `json:"overview,omitempty"` + PositionSeconds *float64 `json:"position_seconds,omitempty"` + DurationSeconds *float64 `json:"duration_seconds,omitempty"` + ProgressUpdatedAt *string `json:"progress_updated_at,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty"` + BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"` + Badges []string `json:"badges,omitempty"` + ItemSource string `json:"item_source,omitempty"` + UserState *itemUserStateResponse `json:"user_state,omitempty"` + UpcomingEvent *upcomingEventResponse `json:"upcoming_event,omitempty"` +} + +type resolvedSectionResponse struct { + ID string `json:"id"` + SectionType string `json:"section_type"` + Title string `json:"title"` + Featured bool `json:"featured"` + ItemLimit int `json:"item_limit"` + TotalCount int `json:"total_count"` + IsCustom bool `json:"is_custom"` + Customized bool `json:"customized"` + Items []sectionItemResponse `json:"items"` +} + +type homeSectionsResponse struct { + Sections []resolvedSectionResponse `json:"sections"` +} + +type resolvedSectionLayoutResponse struct { + ID string `json:"id"` + SectionType string `json:"section_type"` + Title string `json:"title"` + Featured bool `json:"featured"` + ItemLimit int `json:"item_limit"` + IsCustom bool `json:"is_custom"` + Customized bool `json:"customized"` +} + +type homeLayoutResponse struct { + Sections []resolvedSectionLayoutResponse `json:"sections"` +} + +type homeSectionItemsResponse struct { + Section resolvedSectionResponse `json:"section"` +} + +// HandleHomeLayout handles GET /home/layout +func (h *SectionHandler) HandleHomeLayout(w http.ResponseWriter, r *http.Request) { + resolved, _, _, _, err := h.loadResolvedHomeSections(r) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections") + return + } + + userID := apimw.GetUserID(r.Context()) + resolved = h.maybeInjectNextUp(r.Context(), resolved, userID) + + resp := homeLayoutResponse{ + Sections: make([]resolvedSectionLayoutResponse, 0, len(resolved)), + } + for _, s := range resolved { + resp.Sections = append(resp.Sections, resolvedSectionLayoutResponse{ + ID: s.ID, + SectionType: string(s.SectionType), + Title: s.Title, + Featured: s.Featured, + ItemLimit: s.ItemLimit, + IsCustom: s.IsCustom, + Customized: s.Customized, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleLibraryLayout handles GET /library/{id}/layout +func (h *SectionHandler) HandleLibraryLayout(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + libraryID, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + resolved, _, _, err := h.loadResolvedLibrarySections(r, libraryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections") + return + } + + resp := homeLayoutResponse{ + Sections: make([]resolvedSectionLayoutResponse, 0, len(resolved)), + } + for _, s := range resolved { + resp.Sections = append(resp.Sections, resolvedSectionLayoutResponse{ + ID: s.ID, + SectionType: string(s.SectionType), + Title: s.Title, + Featured: s.Featured, + ItemLimit: s.ItemLimit, + IsCustom: s.IsCustom, + Customized: s.Customized, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleHomeSections handles GET /home/sections +func (h *SectionHandler) HandleHomeSections(w http.ResponseWriter, r *http.Request) { + resolved, libraryIDs, accessFilter, profileID, err := h.loadResolvedHomeSections(r) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections") + return + } + + userID := apimw.GetUserID(r.Context()) + resolved = h.maybeInjectNextUp(r.Context(), resolved, userID) + withItems := h.fetcher.FetchAll(r.Context(), resolved, nil, libraryIDs, userID, profileID, accessFilter) + withItems = applyDiversityFilter(withItems) + withItems = dropEmptySeasonalSections(withItems) + writeJSON(w, http.StatusOK, h.buildSectionsResponse(r, withItems)) +} + +// HandleHomeSectionItems handles GET /home/sections/{id}/items +func (h *SectionHandler) HandleHomeSectionItems(w http.ResponseWriter, r *http.Request) { + sectionID := chi.URLParam(r, "id") + if sectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Section ID is required") + return + } + + resolved, libraryIDs, accessFilter, profileID, err := h.loadResolvedHomeSections(r) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections") + return + } + userID := apimw.GetUserID(r.Context()) + resolved = h.maybeInjectNextUp(r.Context(), resolved, userID) + + for _, s := range resolved { + if s.ID != sectionID { + continue + } + + withItems, fetchErr := h.fetcher.FetchOne(r.Context(), s, nil, libraryIDs, userID, profileID, accessFilter) + if fetchErr != nil { + slog.Error("fetching section items", "section_id", s.ID, "type", s.SectionType, "error", fetchErr) + withItems = sections.SectionWithItems{ + ResolvedSection: s, + Items: []*models.MediaItem{}, + } + } + + resp := h.buildSectionsResponse(r, []sections.SectionWithItems{withItems}) + if len(resp.Sections) == 0 { + resp.Sections = append(resp.Sections, resolvedSectionResponse{ + ID: withItems.ID, + SectionType: string(withItems.SectionType), + Title: withItems.Title, + Featured: withItems.Featured, + ItemLimit: withItems.ItemLimit, + TotalCount: withItems.TotalCount, + IsCustom: withItems.IsCustom, + Customized: withItems.Customized, + Items: []sectionItemResponse{}, + }) + } + + writeJSON(w, http.StatusOK, homeSectionItemsResponse{ + Section: resp.Sections[0], + }) + return + } + + writeError(w, http.StatusNotFound, "not_found", "Section not found") +} + +// HandleLibrarySections handles GET /library/{id}/sections +func (h *SectionHandler) HandleLibrarySections(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + libraryID, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + resolved, accessFilter, profileID, err := h.loadResolvedLibrarySections(r, libraryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections") + return + } + + userID := apimw.GetUserID(r.Context()) + withItems := h.fetcher.FetchAll(r.Context(), resolved, &libraryID, nil, userID, profileID, accessFilter) + withItems = applyDiversityFilter(withItems) + withItems = dropEmptySeasonalSections(withItems) + writeJSON(w, http.StatusOK, h.buildSectionsResponse(r, withItems)) +} + +// HandleLibrarySectionItems handles GET /library/{id}/sections/{sectionId}/items +func (h *SectionHandler) HandleLibrarySectionItems(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + libraryID, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") + return + } + + sectionID := chi.URLParam(r, "sectionId") + if sectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Section ID is required") + return + } + + resolved, accessFilter, profileID, err := h.loadResolvedLibrarySections(r, libraryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections") + return + } + + userID := apimw.GetUserID(r.Context()) + for _, s := range resolved { + if s.ID != sectionID { + continue + } + + withItems, fetchErr := h.fetcher.FetchOne(r.Context(), s, &libraryID, nil, userID, profileID, accessFilter) + if fetchErr != nil { + slog.Error("fetching section items", "section_id", s.ID, "type", s.SectionType, "error", fetchErr) + withItems = sections.SectionWithItems{ + ResolvedSection: s, + Items: []*models.MediaItem{}, + } + } + + resp := h.buildSectionsResponse(r, []sections.SectionWithItems{withItems}) + if len(resp.Sections) == 0 { + resp.Sections = append(resp.Sections, resolvedSectionResponse{ + ID: withItems.ID, + SectionType: string(withItems.SectionType), + Title: withItems.Title, + Featured: withItems.Featured, + ItemLimit: withItems.ItemLimit, + TotalCount: withItems.TotalCount, + IsCustom: withItems.IsCustom, + Customized: withItems.Customized, + Items: []sectionItemResponse{}, + }) + } + + writeJSON(w, http.StatusOK, homeSectionItemsResponse{ + Section: resp.Sections[0], + }) + return + } + + writeError(w, http.StatusNotFound, "not_found", "Section not found") +} + +func (h *SectionHandler) loadResolvedHomeSections(r *http.Request) ([]sections.ResolvedSection, []int, catalog.AccessFilter, string, error) { + profileID := apimw.GetProfileID(r.Context()) + userID := apimw.GetUserID(r.Context()) + + adminSections, err := h.repo.ListByScope(r.Context(), "home", nil) + if err != nil { + return nil, nil, catalog.AccessFilter{}, profileID, err + } + + // Fall back to default sections when none are admin-configured. + if len(adminSections) == 0 { + adminSections, err = h.defaultHomeSections(r.Context()) + if err != nil { + return nil, nil, catalog.AccessFilter{}, profileID, err + } + } + + var overrides []sections.ProfileSectionOverride + if h.StoreProvider != nil && profileID != "" { + store, storeErr := h.StoreProvider.ForUser(r.Context(), userID) + if storeErr == nil { + userOverrides, _ := store.ListSectionOverrides(r.Context(), profileID, "home", "") + overrides = toSectionOverrides(userOverrides) + } + } + + resolved := sections.Resolve(adminSections, overrides) + + var libraryIDs []int + accessFilter := catalog.AccessFilter{} + if scope, ok := access.GetScope(r.Context()); ok { + libraryIDs = scope.AllowedLibraryIDs + accessFilter.AllowedLibraryIDs = scope.AllowedLibraryIDs + accessFilter.DisabledLibraryIDs = scope.DisabledLibraryIDs + accessFilter.MaxContentRating = scope.MaxContentRating + } else if h.UserRepo != nil { + user, _ := h.UserRepo.GetByID(r.Context(), userID) + if user != nil && user.LibraryIDs != nil { + libraryIDs = user.LibraryIDs + accessFilter.AllowedLibraryIDs = user.LibraryIDs + } + } + + return resolved, libraryIDs, accessFilter, profileID, nil +} + +func (h *SectionHandler) loadResolvedLibrarySections(r *http.Request, libraryID int) ([]sections.ResolvedSection, catalog.AccessFilter, string, error) { + profileID := apimw.GetProfileID(r.Context()) + userID := apimw.GetUserID(r.Context()) + + adminSections, err := h.repo.ListByScope(r.Context(), "library", &libraryID) + if err != nil { + return nil, catalog.AccessFilter{}, profileID, err + } + + // Fall back to default sections when none are admin-configured. + if len(adminSections) == 0 { + defaults, defaultsErr := h.defaultLibrarySections(r.Context(), libraryID) + if defaultsErr != nil { + slog.Warn("loading typed library section defaults", "library_id", libraryID, "error", defaultsErr) + adminSections = sections.DefaultLibrarySections(&libraryID) + } else { + adminSections = defaults + } + } + + var overrides []sections.ProfileSectionOverride + if h.StoreProvider != nil && profileID != "" { + store, storeErr := h.StoreProvider.ForUser(r.Context(), userID) + if storeErr == nil { + libStr := strconv.Itoa(libraryID) + userOverrides, _ := store.ListSectionOverrides(r.Context(), profileID, "library", libStr) + overrides = toSectionOverrides(userOverrides) + } + } + + resolved := sections.Resolve(adminSections, overrides) + resolved = h.maybeInjectNextUp(r.Context(), resolved, userID) + + accessFilter := catalog.AccessFilter{} + if scope, ok := access.GetScope(r.Context()); ok { + accessFilter.AllowedLibraryIDs = scope.AllowedLibraryIDs + accessFilter.DisabledLibraryIDs = scope.DisabledLibraryIDs + accessFilter.MaxContentRating = scope.MaxContentRating + } + + return resolved, accessFilter, profileID, nil +} + +// --- Profile override endpoints --- + +type saveOverridesRequest struct { + Scope string `json:"scope"` + LibraryID string `json:"library_id"` + Overrides []profileOverrideRequest `json:"overrides"` +} + +type profileOverrideRequest struct { + ID string `json:"id"` + SectionID string `json:"section_id"` + Position *int `json:"position"` + Hidden bool `json:"hidden"` + Removed bool `json:"removed"` + SectionType string `json:"section_type"` + Title string `json:"title"` + Featured *bool `json:"featured"` + ItemLimit *int `json:"item_limit"` + Config json.RawMessage `json:"config"` + IsUserAdded bool `json:"is_user_added,omitempty"` + UserSectionType string `json:"user_section_type,omitempty"` + UserConfig json.RawMessage `json:"user_config,omitempty"` + UserTitle string `json:"user_title,omitempty"` +} + +// HandleGetProfileOverrides handles GET /profile/sections?scope=home +func (h *SectionHandler) HandleGetProfileOverrides(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + userID := apimw.GetUserID(r.Context()) + + scope := r.URL.Query().Get("scope") + if scope == "" { + scope = "home" + } + libraryID := r.URL.Query().Get("library_id") + + if h.StoreProvider == nil { + writeJSON(w, http.StatusOK, map[string][]userstore.SectionOverride{"overrides": {}}) + return + } + + store, err := h.StoreProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + overrides, err := store.ListSectionOverrides(r.Context(), profileID, scope, libraryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load overrides") + return + } + + if overrides == nil { + overrides = []userstore.SectionOverride{} + } + writeJSON(w, http.StatusOK, map[string][]userstore.SectionOverride{"overrides": overrides}) +} + +// HandleSaveProfileOverrides handles PUT /profile/sections +func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + userID := apimw.GetUserID(r.Context()) + + var req saveOverridesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Scope == "" { + req.Scope = "home" + } + + // Gate: validate user-added overrides before touching the store. + allowCustom := false + if h.Settings != nil { + v, _ := h.Settings.Get(r.Context(), SectionsAllowProfileCustomSettingKey) + allowCustom = v == "true" + } + isAdmin := false + if claims := apimw.GetClaims(r.Context()); claims != nil { + isAdmin = claims.Role == "admin" + } + + for _, o := range req.Overrides { + // The resolver treats any override with empty SectionID as user-added, + // regardless of the IsUserAdded flag. Match that here so a client cannot + // bypass the recipe gate by omitting is_user_added and sending the legacy + // shape (section_id:"", section_type:"admin_curated_list", config:{…}). + isUserAdded := o.IsUserAdded || o.SectionID == "" + if !isUserAdded { + continue + } + + // Prefer the explicit user_section_type; fall back to legacy section_type + // for parity with resolveUserAdded. + recipeType := o.UserSectionType + if recipeType == "" { + recipeType = o.SectionType + } + rec, ok := recipes.Get(recipeType) + if !ok { + writeError(w, http.StatusBadRequest, "unknown_recipe", "section_type not registered: "+recipeType) + return + } + if rec.Definition().AdminOnly && !isAdmin && !allowCustom { + writeError(w, http.StatusForbidden, "custom_disabled", "this server does not allow profiles to build custom sections") + return + } + // Validate whichever config the resolver will actually use. + cfg := o.UserConfig + if len(cfg) == 0 { + cfg = o.Config + } + if err := rec.Validate(cfg); err != nil { + writeError(w, http.StatusBadRequest, "invalid_config", err.Error()) + return + } + } + + if h.StoreProvider == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "User store not available") + return + } + + store, err := h.StoreProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + overrides := make([]userstore.SectionOverride, len(req.Overrides)) + for i, o := range req.Overrides { + var configStr string + if len(o.Config) > 0 { + configStr = string(o.Config) + } + overrides[i] = userstore.SectionOverride{ + ID: o.ID, + ProfileID: profileID, + Scope: req.Scope, + LibraryID: req.LibraryID, + SectionID: o.SectionID, + Position: o.Position, + Hidden: o.Hidden, + Removed: o.Removed, + SectionType: o.SectionType, + Title: o.Title, + Featured: o.Featured, + ItemLimit: o.ItemLimit, + Config: configStr, + IsUserAdded: o.IsUserAdded, + UserSectionType: o.UserSectionType, + UserConfig: string(o.UserConfig), + UserTitle: o.UserTitle, + } + } + + if err := store.SaveSectionOverrides(r.Context(), profileID, req.Scope, req.LibraryID, overrides); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save overrides") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleResetProfileOverrides handles DELETE /profile/sections/reset?scope=home +func (h *SectionHandler) HandleResetProfileOverrides(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + userID := apimw.GetUserID(r.Context()) + + scope := r.URL.Query().Get("scope") + if scope == "" { + scope = "home" + } + libraryID := r.URL.Query().Get("library_id") + + if h.StoreProvider == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "User store not available") + return + } + + store, err := h.StoreProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.ResetSectionOverrides(r.Context(), profileID, scope, libraryID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reset overrides") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleSectionSettings handles GET /profile/sections/settings?scope=home&library_id=123 +func (h *SectionHandler) HandleSectionSettings(w http.ResponseWriter, r *http.Request) { + profileID := apimw.GetProfileID(r.Context()) + userID := apimw.GetUserID(r.Context()) + + scope := r.URL.Query().Get("scope") + if scope == "" { + scope = "home" + } + + var libIDPtr *int + if lid := r.URL.Query().Get("library_id"); lid != "" { + v, err := strconv.Atoi(lid) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id") + return + } + libIDPtr = &v + } + + adminSections, err := h.repo.ListByScope(r.Context(), scope, libIDPtr) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections") + return + } + + var overrides []sections.ProfileSectionOverride + if h.StoreProvider != nil && profileID != "" { + store, storeErr := h.StoreProvider.ForUser(r.Context(), userID) + if storeErr == nil { + libStr := "" + if libIDPtr != nil { + libStr = strconv.Itoa(*libIDPtr) + } + userOverrides, _ := store.ListSectionOverrides(r.Context(), profileID, scope, libStr) + overrides = toSectionOverrides(userOverrides) + } + } + + resolved := sections.ResolveForSettings(adminSections, overrides) + + type settingsEntry struct { + ID string `json:"id"` + SectionType string `json:"section_type"` + Title string `json:"title"` + Featured bool `json:"featured"` + ItemLimit int `json:"item_limit"` + Hidden bool `json:"hidden"` + IsCustom bool `json:"is_custom"` + Customized bool `json:"customized"` + Position int `json:"position"` + Config json.RawMessage `json:"config,omitempty"` + } + + entries := make([]settingsEntry, 0, len(resolved)) + for _, s := range resolved { + entries = append(entries, settingsEntry{ + ID: s.ID, + SectionType: string(s.SectionType), + Title: s.Title, + Featured: s.Featured, + ItemLimit: s.ItemLimit, + Hidden: s.Hidden, + IsCustom: s.IsCustom, + Customized: s.Customized, + Position: s.Position, + Config: s.Config, + }) + } + + writeJSON(w, http.StatusOK, map[string][]settingsEntry{"sections": entries}) +} + +// applyDiversityFilter removes items from sections whose recipe has +// AvoidDuplicates=true if the same content ID was already surfaced by an +// earlier section in the same render. Operates in-place — preserves order. +// Sections without an AvoidDuplicates recipe are seen-but-not-filtered: +// their items still mark content_ids as "seen" for downstream sections. +func applyDiversityFilter(withItems []sections.SectionWithItems) []sections.SectionWithItems { + seen := map[string]struct{}{} + for i := range withItems { + sectionType := string(withItems[i].SectionType) + rec, ok := recipes.Get(sectionType) + avoid := ok && rec.Definition().AvoidDuplicates + if avoid { + kept := withItems[i].Items[:0] + for _, item := range withItems[i].Items { + if item == nil || item.ContentID == "" { + kept = append(kept, item) + continue + } + if _, dup := seen[item.ContentID]; dup { + continue + } + kept = append(kept, item) + } + withItems[i].Items = kept + withItems[i].TotalCount = len(kept) + } + for _, item := range withItems[i].Items { + if item == nil || item.ContentID == "" { + continue + } + seen[item.ContentID] = struct{}{} + } + } + return withItems +} + +// dropEmptySeasonalSections removes seasonal_themed sections that produced +// no items so the home page doesn't render an empty row off-season. Called +// from the aggregate handlers; per-section endpoints still return empty +// seasonal sections when explicitly requested by ID. +func dropEmptySeasonalSections(withItems []sections.SectionWithItems) []sections.SectionWithItems { + out := withItems[:0] + for _, w := range withItems { + if w.SectionType == sections.SectionSeasonalThemed && len(w.Items) == 0 { + continue + } + out = append(out, w) + } + return out +} + +// --- Helper methods --- + +type sectionItemImageKey struct { + sectionID string + contentID string +} + +type sectionItemImageURLs struct { + posterURL string + backdropURL string + logoURL string +} + +func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sections.SectionWithItems) homeSectionsResponse { + overlaySummaries := make(map[string]*models.OverlaySummary) + contentIDs := make([]string, 0) + seen := make(map[string]struct{}) + for _, section := range withItems { + for _, item := range section.Items { + if item == nil || item.ContentID == "" { + continue + } + if _, ok := seen[item.ContentID]; ok { + continue + } + seen[item.ContentID] = struct{}{} + contentIDs = append(contentIDs, item.ContentID) + } + } + if len(contentIDs) > 0 && h.fetcher != nil { + summaries, err := h.fetcher.ListOverlaySummaries(r.Context(), contentIDs, requestAccessFilter(r)) + if err != nil { + slog.Error("loading overlay summaries", "error", err) + } else { + overlaySummaries = summaries + } + } + + resp := homeSectionsResponse{ + Sections: make([]resolvedSectionResponse, 0, len(withItems)), + } + allItems := make([]*models.MediaItem, 0) + for _, s := range withItems { + allItems = append(allItems, s.Items...) + } + userStates := h.listSectionItemUserStates(r, allItems) + imageURLs := h.resolveSectionItemImageURLs(r.Context(), withItems) + for _, s := range withItems { + items := make([]sectionItemResponse, 0, len(s.Items)) + for _, item := range s.Items { + var meta *sections.SectionItemMeta + if s.ItemMeta != nil { + if value, ok := s.ItemMeta[item.ContentID]; ok { + meta = &value + } + } + imageKey := sectionItemImageKey{sectionID: s.ID, contentID: item.ContentID} + items = append(items, h.toSectionItemResponse(s.SectionType, item, meta, overlaySummaries[item.ContentID], userStates[item.ContentID], imageURLs[imageKey])) + } + resp.Sections = append(resp.Sections, resolvedSectionResponse{ + ID: s.ID, + SectionType: string(s.SectionType), + Title: s.Title, + Featured: s.Featured, + ItemLimit: s.ItemLimit, + TotalCount: s.TotalCount, + IsCustom: s.IsCustom, + Customized: s.Customized, + Items: items, + }) + } + return resp +} + +func (h *SectionHandler) resolveSectionItemImageURLs(ctx context.Context, withItems []sections.SectionWithItems) map[sectionItemImageKey]sectionItemImageURLs { + result := make(map[sectionItemImageKey]sectionItemImageURLs) + if h.DetailSvc == nil { + return result + } + + type pendingImages struct { + key sectionItemImageKey + posterPath string + backdropPath string + logoPath string + } + + pending := make([]pendingImages, 0) + paths := make([]string, 0) + seenPaths := make(map[string]struct{}) + addPath := func(path string) { + if path == "" || path == "-" { + return + } + if _, ok := seenPaths[path]; ok { + return + } + seenPaths[path] = struct{}{} + paths = append(paths, path) + } + + for _, section := range withItems { + for _, item := range section.Items { + if item == nil { + continue + } + images := pendingImages{ + key: sectionItemImageKey{ + sectionID: section.ID, + contentID: item.ContentID, + }, + posterPath: featuredPosterPath(item.PosterPath), + backdropPath: sectionBackdropPath(section.SectionType, item.BackdropPath), + logoPath: item.LogoPath, + } + pending = append(pending, images) + addPath(images.posterPath) + addPath(images.backdropPath) + addPath(images.logoPath) + } + } + + resolved := h.DetailSvc.PresignURLsWithExpiry(ctx, paths, "featured") + for _, images := range pending { + result[images.key] = sectionItemImageURLs{ + posterURL: resolved[images.posterPath].URL, + backdropURL: resolved[images.backdropPath].URL, + logoURL: resolved[images.logoPath].URL, + } + } + return result +} + +func (h *SectionHandler) toSectionItemResponse(sectionType sections.SectionType, item *models.MediaItem, meta *sections.SectionItemMeta, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse, imageURLs sectionItemImageURLs) sectionItemResponse { + resp := sectionItemResponse{ + ContentID: item.ContentID, + Type: item.Type, + Title: item.Title, + Year: item.Year, + Runtime: item.Runtime, + Genres: item.Genres, + Keywords: item.Keywords, + Studios: item.Studios, + Networks: item.Networks, + ContentRating: item.ContentRating, + Status: item.Status, + ShowStatus: item.ShowStatus, + RatingIMDB: item.RatingIMDB, + RatingTMDB: item.RatingTMDB, + RatingRTCritic: item.RatingRTCritic, + RatingRTAudience: item.RatingRTAudience, + OriginalLanguage: item.OriginalLanguage, + Overview: item.Overview, + PosterThumbhash: item.PosterThumbhash, + BackdropThumbhash: item.BackdropThumbhash, + OverlaySummary: overlaySummary, + UserState: userState, + } + if meta != nil { + if meta.SeriesID != nil { + resp.SeriesID = *meta.SeriesID + } + resp.SeriesTitle = meta.SeriesTitle + resp.SeasonNumber = meta.SeasonNumber + resp.EpisodeNumber = meta.EpisodeNumber + resp.Badges = meta.Badges + resp.PositionSeconds = meta.PositionSeconds + resp.DurationSeconds = meta.DurationSeconds + resp.ProgressUpdatedAt = meta.ProgressUpdatedAt + resp.ItemSource = meta.ItemSource + } + + if resp.Genres == nil { + resp.Genres = []string{} + } + if resp.Keywords == nil { + resp.Keywords = []string{} + } + + resp.PosterURL = imageURLs.posterURL + resp.BackdropURL = imageURLs.backdropURL + resp.LogoURL = imageURLs.logoURL + + return resp +} + +// sectionBackdropPath keeps featured-style backdrops for most sections, but +// uses the cached w1280 backdrop for Continue Watching / Next Up rows. +func sectionBackdropPath(sectionType sections.SectionType, path string) string { + if sectionType == sections.SectionContinueWatching || sectionType == sections.SectionNextUp { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + // Plugin-prefixed paths pass through; the resolver still receives the + // existing "featured" semantic variant. + if strings.Contains(path, "://") { + return path + } + return strings.Replace(path, "/original.", "/w1280.", 1) + } + return featuredBackdropPath(path) +} + +func (h *SectionHandler) listSectionItemUserStates(r *http.Request, items []*models.MediaItem) map[string]*itemUserStateResponse { + if h.StoreProvider == nil { + return map[string]*itemUserStateResponse{} + } + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + if userID == 0 || profileID == "" { + return map[string]*itemUserStateResponse{} + } + store, err := h.StoreProvider.ForUser(r.Context(), userID) + if err != nil || store == nil { + return map[string]*itemUserStateResponse{} + } + states, err := resolveItemUserStates(r.Context(), store, profileID, h.EpisodeRepo, items) + if err != nil { + return map[string]*itemUserStateResponse{} + } + return states +} + +func (h *SectionHandler) sectionPresignURL(r *http.Request, path string, variant string) string { + if h.DetailSvc != nil { + return h.DetailSvc.PresignURL(r.Context(), path, variant) + } + return "" +} + +// maybeInjectNextUp injects a SectionNextUp entry after SectionContinueWatching +// if the user's next_up_mode setting is "separate". +func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []sections.ResolvedSection, userID int) []sections.ResolvedSection { + if h.StoreProvider == nil || userID <= 0 { + return resolved + } + store, err := h.StoreProvider.ForUser(ctx, userID) + if err != nil { + return resolved + } + mode, _ := store.GetSetting(ctx, "next_up_mode") + if mode == "separate" { + return injectNextUpSection(resolved) + } + return resolved +} + +// injectNextUpSection inserts a synthetic SectionNextUp entry immediately after +// SectionContinueWatching in the resolved sections list. +func injectNextUpSection(resolved []sections.ResolvedSection) []sections.ResolvedSection { + nextUp := sections.ResolvedSection{ + ID: "system-next-up", + SectionType: sections.SectionNextUp, + Title: "Next Up", + ItemLimit: 20, + } + + for i, s := range resolved { + if s.SectionType == sections.SectionContinueWatching { + result := make([]sections.ResolvedSection, 0, len(resolved)+1) + result = append(result, resolved[:i+1]...) + result = append(result, nextUp) + result = append(result, resolved[i+1:]...) + return result + } + } + + // If no continue_watching found, prepend + return append([]sections.ResolvedSection{nextUp}, resolved...) +} + +func toSectionOverrides(storeOverrides []userstore.SectionOverride) []sections.ProfileSectionOverride { + result := make([]sections.ProfileSectionOverride, len(storeOverrides)) + for i, o := range storeOverrides { + var cfg json.RawMessage + if o.Config != "" { + cfg = json.RawMessage(o.Config) + } + var userCfg json.RawMessage + if o.UserConfig != "" { + userCfg = json.RawMessage(o.UserConfig) + } + result[i] = sections.ProfileSectionOverride{ + ID: o.ID, + ProfileID: o.ProfileID, + Scope: o.Scope, + LibraryID: o.LibraryID, + SectionID: o.SectionID, + Position: o.Position, + Hidden: o.Hidden, + Removed: o.Removed, + SectionType: sections.SectionType(o.SectionType), + Title: o.Title, + Featured: o.Featured, + ItemLimit: o.ItemLimit, + Config: cfg, + CreatedAt: o.CreatedAt, + UpdatedAt: o.UpdatedAt, + // User-added recipe fields. Without these the resolver sees + // SectionType="" / IsUserAdded=false on every load and silently + // drops profile-built sections. + IsUserAdded: o.IsUserAdded, + UserSectionType: sections.SectionType(o.UserSectionType), + UserConfig: userCfg, + UserTitle: o.UserTitle, + } + } + return result +} + +// HandleRestoreDefaults handles POST /admin/sections/restore-defaults. +// It replaces all sections for a scope with the canonical defaults. +func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Request) { + var req restoreDefaultsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if req.Scope == "" { + req.Scope = "home" + } + if req.Scope != "home" && req.Scope != "library" { + writeError(w, http.StatusBadRequest, "bad_request", "Scope must be 'home' or 'library'") + return + } + if req.Scope == "library" && req.LibraryID == nil { + writeError(w, http.StatusBadRequest, "bad_request", "library_id is required for library scope") + return + } + if req.Scope == "home" && req.LibraryID != nil { + writeError(w, http.StatusBadRequest, "bad_request", "library_id must not be set for home scope") + return + } + + var defaults []*sections.PageSection + var err error + if req.Scope == "home" { + defaults, err = h.defaultHomeSections(r.Context()) + if err != nil { + slog.Error("loading default home sections", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load libraries") + return + } + } else { + defaults, err = h.defaultLibrarySections(r.Context(), *req.LibraryID) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Library not found") + return + } + slog.Error("loading default library sections", "library_id", *req.LibraryID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library") + return + } + } + + created, err := h.repo.RestoreDefaults(r.Context(), req.Scope, req.LibraryID, defaults) + if err != nil { + slog.Error("restoring default sections", "scope", req.Scope, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to restore defaults") + return + } + + // Optionally clear all profile overrides for this scope. + if req.ResetProfiles { + libraryIDStr := "" + if req.LibraryID != nil { + libraryIDStr = strconv.Itoa(*req.LibraryID) + } + if err := h.repo.ClearAllProfileOverrides(r.Context(), req.Scope, libraryIDStr); err != nil { + slog.Error("clearing profile overrides", "scope", req.Scope, "error", err) + // Don't fail the whole request — sections were already restored. + } + } + + resp := sectionListResponse{Sections: make([]sectionResponse, 0, len(created))} + for _, s := range created { + resp.Sections = append(resp.Sections, toSectionResponse(s)) + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/sections_bulk.go b/internal/api/handlers/sections_bulk.go new file mode 100644 index 00000000..14b1cad4 --- /dev/null +++ b/internal/api/handlers/sections_bulk.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/sections/recipes" +) + +// BulkSectionRepo is the minimal repo surface needed for bulk-create. +// CreateMany must run all inserts in a single transaction. +type BulkSectionRepo interface { + CreateMany(ctx context.Context, sections []*sections.PageSection) error +} + +// SectionBulkHandler handles the bulk-create sections endpoint. +type SectionBulkHandler struct { + Repo BulkSectionRepo +} + +type bulkCreateSectionRequest struct { + Scope string `json:"scope"` + LibraryIDs []int `json:"library_ids"` + SectionType string `json:"section_type"` + Title string `json:"title"` + Featured bool `json:"featured"` + ItemLimit int `json:"item_limit"` + Config json.RawMessage `json:"config"` + Enabled bool `json:"enabled"` +} + +type bulkCreateSectionResponse struct { + Created int `json:"created"` +} + +// HandleBulkCreate handles POST /api/admin/sections/bulk-create. +// It creates the same section across multiple libraries (library scope) or +// a single home-scope section, all within a single transaction. +func (h *SectionBulkHandler) HandleBulkCreate(w http.ResponseWriter, r *http.Request) { + var req bulkCreateSectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + scope := req.Scope + if scope == "" { + scope = "home" + } + + // Validate scope. + switch scope { + case "home", "library": + default: + writeError(w, http.StatusBadRequest, "bad_request", "scope must be 'home' or 'library'") + return + } + + // Library scope requires at least one library_id. + if scope == "library" && len(req.LibraryIDs) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "library_ids must not be empty for library scope") + return + } + + // Validate section_type via recipe registry (catches unknown types and invalid config). + if req.SectionType == "" { + writeError(w, http.StatusBadRequest, "bad_request", "section_type is required") + return + } + rec, ok := recipes.Get(req.SectionType) + if !ok { + writeError(w, http.StatusBadRequest, "bad_request", "unknown section_type") + return + } + if err := rec.Validate(req.Config); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + itemLimit := req.ItemLimit + if itemLimit <= 0 { + itemLimit = 20 + } + + // Build the rows to insert. + var rows []*sections.PageSection + + switch scope { + case "home": + rows = append(rows, §ions.PageSection{ + Scope: "home", + LibraryID: nil, + SectionType: sections.SectionType(req.SectionType), + Title: req.Title, + Featured: req.Featured, + ItemLimit: itemLimit, + Config: req.Config, + Enabled: req.Enabled, + }) + case "library": + for _, libID := range req.LibraryIDs { + id := libID // capture loop variable + rows = append(rows, §ions.PageSection{ + Scope: "library", + LibraryID: &id, + SectionType: sections.SectionType(req.SectionType), + Title: req.Title, + Featured: req.Featured, + ItemLimit: itemLimit, + Config: req.Config, + Enabled: req.Enabled, + }) + } + } + + if err := h.Repo.CreateMany(r.Context(), rows); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create sections") + return + } + + writeJSON(w, http.StatusOK, bulkCreateSectionResponse{Created: len(rows)}) +} diff --git a/internal/api/handlers/sections_bulk_test.go b/internal/api/handlers/sections_bulk_test.go new file mode 100644 index 00000000..47f6c7b4 --- /dev/null +++ b/internal/api/handlers/sections_bulk_test.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/sections" +) + +type stubBulkRepo struct { + failOn int // if > 0, fail when this many rows have already succeeded (1-indexed) + created []*sections.PageSection +} + +func (s *stubBulkRepo) CreateMany(ctx context.Context, rows []*sections.PageSection) error { + // Simulate transactional semantics: if failOn fires partway through, + // the test sees zero persisted rows. + var local []*sections.PageSection + for i, r := range rows { + if s.failOn > 0 && i+1 >= s.failOn { + return errors.New("simulated insert failure") + } + local = append(local, r) + } + s.created = append(s.created, local...) + return nil +} + +func TestBulkCreateAppliesToAllLibraries(t *testing.T) { + repo := &stubBulkRepo{} + h := &SectionBulkHandler{Repo: repo} + body, _ := json.Marshal(map[string]any{ + "scope": "library", + "library_ids": []int{1, 2, 3}, + "section_type": "recently_added", + "title": "Recently Added", + "item_limit": 20, + "enabled": true, + "config": map[string]any{}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/admin/sections/bulk-create", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.HandleBulkCreate(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if len(repo.created) != 3 { + t.Fatalf("created %d sections want 3", len(repo.created)) + } +} + +func TestBulkCreateRollsBackOnFailure(t *testing.T) { + repo := &stubBulkRepo{failOn: 2} // fail before second insert commits + h := &SectionBulkHandler{Repo: repo} + body, _ := json.Marshal(map[string]any{ + "scope": "library", + "library_ids": []int{1, 2}, + "section_type": "recently_added", + }) + req := httptest.NewRequest(http.MethodPost, "/api/admin/sections/bulk-create", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.HandleBulkCreate(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status %d", rec.Code) + } + if len(repo.created) != 0 { + t.Fatalf("expected rollback; %d rows persisted", len(repo.created)) + } +} + +func TestBulkCreateRejectsUnknownSectionType(t *testing.T) { + repo := &stubBulkRepo{} + h := &SectionBulkHandler{Repo: repo} + body, _ := json.Marshal(map[string]any{ + "scope": "library", + "library_ids": []int{1}, + "section_type": "no_such_recipe", + }) + req := httptest.NewRequest(http.MethodPost, "/api/admin/sections/bulk-create", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.HandleBulkCreate(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } + if len(repo.created) != 0 { + t.Fatalf("expected zero created; got %d", len(repo.created)) + } +} + +func TestBulkCreateRejectsLibraryScopeWithEmptyIDs(t *testing.T) { + repo := &stubBulkRepo{} + h := &SectionBulkHandler{Repo: repo} + body, _ := json.Marshal(map[string]any{ + "scope": "library", + "library_ids": []int{}, + "section_type": "recently_added", + }) + req := httptest.NewRequest(http.MethodPost, "/api/admin/sections/bulk-create", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.HandleBulkCreate(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} diff --git a/internal/api/handlers/sections_preview.go b/internal/api/handlers/sections_preview.go new file mode 100644 index 00000000..74d8b5f4 --- /dev/null +++ b/internal/api/handlers/sections_preview.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/sections/recipes" +) + +// sectionPreviewFetcher is the minimal interface needed by HandlePreview. +// *sections.Fetcher satisfies this interface. +type sectionPreviewFetcher interface { + FetchOne(ctx context.Context, resolved sections.ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) (sections.SectionWithItems, error) +} + +// previewRequest is the request body for POST /api/admin/sections/preview. +type previewRequest struct { + SectionType string `json:"section_type"` + Config json.RawMessage `json:"config"` + ItemLimit int `json:"item_limit"` + LibraryID *int `json:"library_id,omitempty"` + LibraryIDs []int `json:"library_ids,omitempty"` +} + +// previewResponse is the response body for POST /api/admin/sections/preview. +type previewResponse struct { + Items []*models.MediaItem `json:"items"` + TotalCount int `json:"total_count"` +} + +// HandlePreview is POST /api/admin/sections/preview. Returns a sample of items without persisting. +func (h *SectionHandler) HandlePreview(w http.ResponseWriter, r *http.Request) { + var req previewRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", err.Error()) + return + } + + rec, ok := recipes.Get(req.SectionType) + if !ok { + writeError(w, http.StatusBadRequest, "unknown_type", "section_type not registered") + return + } + if err := rec.Validate(req.Config); err != nil { + writeError(w, http.StatusBadRequest, "invalid_config", err.Error()) + return + } + + limit := req.ItemLimit + if limit <= 0 || limit > 50 { + limit = 10 + } + + resolved := sections.ResolvedSection{ + SectionType: sections.SectionType(req.SectionType), + Title: "preview", + ItemLimit: limit, + Config: req.Config, + } + + fetcher := h.previewFetcher + if fetcher == nil { + fetcher = h.fetcher + } + if fetcher == nil { + writeError(w, http.StatusInternalServerError, "preview_unavailable", "section fetcher not configured") + return + } + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + result, err := fetcher.FetchOne(r.Context(), resolved, req.LibraryID, req.LibraryIDs, userID, profileID, filter) + if err != nil { + writeError(w, http.StatusInternalServerError, "preview_failed", err.Error()) + return + } + + items := result.Items + if items == nil { + items = []*models.MediaItem{} + } + writeJSON(w, http.StatusOK, previewResponse{Items: items, TotalCount: result.TotalCount}) +} diff --git a/internal/api/handlers/sections_preview_test.go b/internal/api/handlers/sections_preview_test.go new file mode 100644 index 00000000..0fcae5d3 --- /dev/null +++ b/internal/api/handlers/sections_preview_test.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/sections" +) + +// stubPreviewFetcher is a test double that returns canned items for any inputs. +type stubPreviewFetcher struct { + items []*models.MediaItem + total int +} + +func (s *stubPreviewFetcher) FetchOne(_ context.Context, _ sections.ResolvedSection, _ *int, _ []int, _ int, _ string, _ catalog.AccessFilter) (sections.SectionWithItems, error) { + return sections.SectionWithItems{ + Items: s.items, + TotalCount: s.total, + }, nil +} + +func TestHandlePreviewRejectsUnknownType(t *testing.T) { + h := &SectionHandler{} + body, _ := json.Marshal(map[string]any{ + "section_type": "not_a_real_type", + "config": map[string]any{}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/admin/sections/preview", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.HandlePreview(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } +} + +func TestHandlePreviewValidConfig(t *testing.T) { + h := &SectionHandler{previewFetcher: &stubPreviewFetcher{ + items: []*models.MediaItem{{ContentID: "abc"}}, + total: 1, + }} + body, _ := json.Marshal(map[string]any{ + "section_type": string(sections.SectionRecentlyAdded), + "config": map[string]any{}, + "item_limit": 10, + }) + req := httptest.NewRequest(http.MethodPost, "/api/admin/sections/preview", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.HandlePreview(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/handlers/sections_settings.go b/internal/api/handlers/sections_settings.go new file mode 100644 index 00000000..e699b09b --- /dev/null +++ b/internal/api/handlers/sections_settings.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// SectionsAllowProfileCustomSettingKey is the server_settings key controlling +// whether non-admin profiles may add user-added sections of admin-only recipes. +const SectionsAllowProfileCustomSettingKey = "sections.allow_profile_custom_sections" + +// SectionSettingsHandler exposes GET/PUT for the sections-related server setting. +type SectionSettingsHandler struct { + Settings *catalog.ServerSettingsRepo +} + +type sectionsSettingResponse struct { + AllowProfileCustomSections bool `json:"allow_profile_custom_sections"` +} + +// HandleGet handles GET /api/admin/settings/sections. +func (h *SectionSettingsHandler) HandleGet(w http.ResponseWriter, r *http.Request) { + var resp sectionsSettingResponse + if h.Settings != nil { + v, _ := h.Settings.Get(r.Context(), SectionsAllowProfileCustomSettingKey) + resp.AllowProfileCustomSections = v == "true" + } + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetProfileFlag exposes the allow_profile_custom_sections setting to any +// authenticated profile (no admin required). Read-only. +func (h *SectionSettingsHandler) HandleGetProfileFlag(w http.ResponseWriter, r *http.Request) { + var resp sectionsSettingResponse + if h.Settings != nil { + v, _ := h.Settings.Get(r.Context(), SectionsAllowProfileCustomSettingKey) + resp.AllowProfileCustomSections = v == "true" + } + writeJSON(w, http.StatusOK, resp) +} + +// HandlePut handles PUT /api/admin/settings/sections. +func (h *SectionSettingsHandler) HandlePut(w http.ResponseWriter, r *http.Request) { + var req sectionsSettingResponse + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", err.Error()) + return + } + if h.Settings != nil { + value := "false" + if req.AllowProfileCustomSections { + value = "true" + } + if err := h.Settings.Set(r.Context(), SectionsAllowProfileCustomSettingKey, value); err != nil { + writeError(w, http.StatusInternalServerError, "save_failed", err.Error()) + return + } + } + writeJSON(w, http.StatusOK, req) +} diff --git a/internal/api/handlers/sections_settings_test.go b/internal/api/handlers/sections_settings_test.go new file mode 100644 index 00000000..c47a3d7a --- /dev/null +++ b/internal/api/handlers/sections_settings_test.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetSectionsSettingDefaultsToFalse(t *testing.T) { + h := &SectionSettingsHandler{} // no Settings repo wired + req := httptest.NewRequest(http.MethodGet, "/api/admin/settings/sections", nil) + rec := httptest.NewRecorder() + + h.HandleGet(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"allow_profile_custom_sections":false`) { + t.Fatalf("default not false: %s", rec.Body.String()) + } +} + +func TestGetProfileFlagDefaultsToFalse(t *testing.T) { + h := &SectionSettingsHandler{} // no Settings repo wired + req := httptest.NewRequest(http.MethodGet, "/api/profile/sections/flags", nil) + rec := httptest.NewRecorder() + + h.HandleGetProfileFlag(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"allow_profile_custom_sections":false`) { + t.Fatalf("default not false: %s", rec.Body.String()) + } +} diff --git a/internal/api/handlers/sections_test.go b/internal/api/handlers/sections_test.go new file mode 100644 index 00000000..f7b06e07 --- /dev/null +++ b/internal/api/handlers/sections_test.go @@ -0,0 +1,398 @@ +package handlers + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "sort" + "testing" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) { + tests := []struct { + name string + sectionType sections.SectionType + path string + want string + }{ + { + name: "continue watching uses w1280", + sectionType: sections.SectionContinueWatching, + path: "tmdb/movies/550/backdrop/original.webp", + want: "tmdb/movies/550/backdrop/w1280.webp", + }, + { + name: "next up uses w1280", + sectionType: sections.SectionNextUp, + path: "/tmdb/movies/550/backdrop/original.webp", + want: "/tmdb/movies/550/backdrop/w1280.webp", + }, + { + name: "other sections use w1920", + sectionType: sections.SectionRecentlyAdded, + path: "/tmdb/shows/1399/backdrop/original.jpg", + want: "/tmdb/shows/1399/backdrop/w1920.jpg", + }, + { + name: "http paths pass through", + sectionType: sections.SectionContinueWatching, + path: "https://images.example.com/backdrop/original.jpg", + want: "https://images.example.com/backdrop/original.jpg", + }, + { + name: "plugin paths pass through", + sectionType: sections.SectionContinueWatching, + path: "plugin://tmdb/backdrop/original.jpg", + want: "plugin://tmdb/backdrop/original.jpg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sectionBackdropPath(tt.sectionType, tt.path); got != tt.want { + t.Fatalf("sectionBackdropPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +type countingSectionImageResolver struct { + batchCalls int + singleCalls int + variant string + paths []string +} + +func (r *countingSectionImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string { + r.singleCalls++ + return "single:" + variant + ":" + path +} + +func (r *countingSectionImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string { + resolved := r.ResolveImageURLsWithExpiry(context.Background(), paths, variant) + urls := make(map[string]string, len(resolved)) + for path, value := range resolved { + urls[path] = value.URL + } + return urls +} + +func (r *countingSectionImageResolver) ResolveImageURLWithExpiry(_ context.Context, path string, variant string) catalog.ResolvedImageURL { + r.singleCalls++ + return catalog.ResolvedImageURL{URL: "single:" + variant + ":" + path} +} + +func (r *countingSectionImageResolver) ResolveImageURLsWithExpiry(_ context.Context, paths []string, variant string) map[string]catalog.ResolvedImageURL { + r.batchCalls++ + r.variant = variant + r.paths = append([]string{}, paths...) + resolved := make(map[string]catalog.ResolvedImageURL, len(paths)) + for _, path := range paths { + resolved[path] = catalog.ResolvedImageURL{URL: "resolved:" + path} + } + return resolved +} + +func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) { + resolver := &countingSectionImageResolver{} + detailSvc := &catalog.DetailService{} + detailSvc.SetImageResolver(resolver) + h := &SectionHandler{DetailSvc: detailSvc} + + items := make([]*models.MediaItem, 0, 100) + for i := range 100 { + items = append(items, &models.MediaItem{ + ContentID: fmt.Sprintf("item-%03d", i), + Type: "movie", + Title: fmt.Sprintf("Movie %03d", i), + PosterPath: "/poster/original.jpg", + BackdropPath: "/backdrop/original.jpg", + LogoPath: "/logo/original.png", + }) + } + withItems := []sections.SectionWithItems{ + { + ResolvedSection: sections.ResolvedSection{ID: "continue", SectionType: sections.SectionContinueWatching, Title: "Continue"}, + Items: items, + }, + { + ResolvedSection: sections.ResolvedSection{ID: "next-up", SectionType: sections.SectionNextUp, Title: "Next Up"}, + Items: []*models.MediaItem{{ + ContentID: "item-extra", + Type: "movie", + Title: "Movie Extra", + PosterPath: "/poster/original.jpg", + BackdropPath: "/backdrop/original.jpg", + LogoPath: "/logo/original.png", + }}, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/sections", nil) + resp := h.buildSectionsResponse(req, withItems) + + if resolver.batchCalls != 1 { + t.Fatalf("batch calls = %d, want 1", resolver.batchCalls) + } + if resolver.singleCalls != 0 { + t.Fatalf("single calls = %d, want 0", resolver.singleCalls) + } + if resolver.variant != "featured" { + t.Fatalf("variant = %q, want featured", resolver.variant) + } + sort.Strings(resolver.paths) + wantPaths := []string{"/backdrop/w1280.jpg", "/logo/original.png", "/poster/w500.jpg"} + if fmt.Sprint(resolver.paths) != fmt.Sprint(wantPaths) { + t.Fatalf("resolved paths = %v, want %v", resolver.paths, wantPaths) + } + if got := resp.Sections[0].Items[0].PosterURL; got != "resolved:/poster/w500.jpg" { + t.Fatalf("poster URL = %q", got) + } + if got := resp.Sections[0].Items[0].BackdropURL; got != "resolved:/backdrop/w1280.jpg" { + t.Fatalf("backdrop URL = %q", got) + } + if got := resp.Sections[0].Items[0].LogoURL; got != "resolved:/logo/original.png" { + t.Fatalf("logo URL = %q", got) + } +} + +func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) { + in := []sections.SectionWithItems{ + // empty seasonal — drop + {ResolvedSection: sections.ResolvedSection{ID: "a", SectionType: sections.SectionSeasonalThemed}}, + // non-empty seasonal — keep + {ResolvedSection: sections.ResolvedSection{ID: "b", SectionType: sections.SectionSeasonalThemed}, Items: []*models.MediaItem{{ContentID: "1"}}}, + // empty non-seasonal — keep + {ResolvedSection: sections.ResolvedSection{ID: "c", SectionType: sections.SectionRecentlyAdded}}, + // non-empty non-seasonal — keep + {ResolvedSection: sections.ResolvedSection{ID: "d", SectionType: sections.SectionHiddenGems}, Items: []*models.MediaItem{{ContentID: "2"}}}, + } + out := dropEmptySeasonalSections(in) + if len(out) != 3 { + t.Fatalf("expected 3, got %d", len(out)) + } + for _, w := range out { + if w.ID == "a" { + t.Errorf("empty seasonal section was not dropped") + } + } +} + +func TestDropEmptySeasonalSectionsHandlesNilAndEmpty(t *testing.T) { + if got := dropEmptySeasonalSections(nil); len(got) != 0 { + t.Errorf("expected empty/nil result for nil input, got %v", got) + } + if got := dropEmptySeasonalSections([]sections.SectionWithItems{}); len(got) != 0 { + t.Errorf("expected empty result for empty input, got %v", got) + } +} + +func TestLibraryDefaultSectionsUsesFolderType(t *testing.T) { + got := libraryDefaultSections(&models.MediaFolder{Type: "series"}, 12) + + if len(got) != 7 { + t.Fatalf("expected 7 series default sections, got %d", len(got)) + } + if got[1].Title != "Recently Added TV" { + t.Fatalf("section 1 title = %q, want %q", got[1].Title, "Recently Added TV") + } + if got[2].Title != "Recently Released Episodes" { + t.Fatalf("section 2 title = %q, want %q", got[2].Title, "Recently Released Episodes") + } + if got[5].SectionType != sections.SectionRecommendedForYou { + t.Fatalf("section 5 type = %q, want %q", got[5].SectionType, sections.SectionRecommendedForYou) + } +} + +func TestApplyDiversityFilterSkipsDuplicatesInLaterAvoidSection(t *testing.T) { + in := []sections.SectionWithItems{ + {ResolvedSection: sections.ResolvedSection{ID: "ra", SectionType: sections.SectionRecentlyAdded}, Items: []*models.MediaItem{{ContentID: "abc"}}}, + {ResolvedSection: sections.ResolvedSection{ID: "hg", SectionType: sections.SectionHiddenGems}, Items: []*models.MediaItem{{ContentID: "abc"}, {ContentID: "def"}}}, + } + out := applyDiversityFilter(in) + if len(out) != 2 { + t.Fatalf("expected 2 sections, got %d", len(out)) + } + if len(out[0].Items) != 1 || out[0].Items[0].ContentID != "abc" { + t.Errorf("first section should keep abc") + } + if len(out[1].Items) != 1 || out[1].Items[0].ContentID != "def" { + t.Errorf("hidden_gems should drop abc, keep def; got %v", out[1].Items) + } + if out[1].TotalCount != 1 { + t.Errorf("hidden_gems TotalCount should be 1, got %d", out[1].TotalCount) + } +} + +func TestApplyDiversityFilterPreservesItemsInNonAvoidSections(t *testing.T) { + in := []sections.SectionWithItems{ + {ResolvedSection: sections.ResolvedSection{ID: "ra", SectionType: sections.SectionRecentlyAdded}, Items: []*models.MediaItem{{ContentID: "abc"}}}, + {ResolvedSection: sections.ResolvedSection{ID: "rr", SectionType: sections.SectionRecentlyReleased}, Items: []*models.MediaItem{{ContentID: "abc"}}}, + } + out := applyDiversityFilter(in) + if len(out[0].Items) != 1 || len(out[1].Items) != 1 { + t.Errorf("non-avoid sections should keep their items; got %d/%d", len(out[0].Items), len(out[1].Items)) + } +} + +func TestApplyDiversityFilterAvoidSectionDoesNotShadowItself(t *testing.T) { + in := []sections.SectionWithItems{ + {ResolvedSection: sections.ResolvedSection{ID: "hg", SectionType: sections.SectionHiddenGems}, Items: []*models.MediaItem{{ContentID: "abc"}}}, + } + out := applyDiversityFilter(in) + if len(out[0].Items) != 1 { + t.Errorf("first section should not filter itself; got %d items", len(out[0].Items)) + } +} + +func TestApplyDiversityFilterHandlesNilAndEmpty(t *testing.T) { + if got := applyDiversityFilter(nil); got != nil && len(got) != 0 { + t.Errorf("nil input returned non-empty: %v", got) + } + if got := applyDiversityFilter([]sections.SectionWithItems{}); len(got) != 0 { + t.Errorf("empty input returned non-empty: %v", got) + } +} + +// TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled verifies that +// a non-admin profile attempting to save a user-added admin_curated_list override +// gets 403 when the allow_profile_custom_sections setting is disabled (the default). +func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testing.T) { + // StoreProvider is nil so we test the gate in isolation. + // The gate runs BEFORE the StoreProvider check, so a 403 is returned before + // the nil-StoreProvider 500 path is reached. + h := &SectionHandler{} + body := []byte(`{"scope":"home","library_id":"","overrides":[{"is_user_added":true,"user_section_type":"admin_curated_list","user_config":{"item_ids":["a"]}}]}`) + req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + // Authenticate as a non-admin profile. + ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1}) + ctx = apimw.SetProfileID(ctx, "p1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + h.HandleSaveProfileOverrides(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +// TestSaveProfileOverridesUnknownRecipeReturnsBadRequest verifies that an +// unregistered user_section_type is rejected before reaching the store. +func TestSaveProfileOverridesUnknownRecipeReturnsBadRequest(t *testing.T) { + h := &SectionHandler{} + body := []byte(`{"scope":"home","library_id":"","overrides":[{"is_user_added":true,"user_section_type":"no_such_recipe","user_config":{}}]}`) + req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1}) + ctx = apimw.SetProfileID(ctx, "p1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + h.HandleSaveProfileOverrides(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +// TestSaveProfileOverridesLegacyShapeCannotBypassGate verifies that a non-admin +// cannot smuggle an admin-only recipe past the gate by omitting is_user_added +// and using the legacy section_type/config fields. The resolver treats any +// override with empty section_id as user-added, so the save handler must too. +func TestSaveProfileOverridesLegacyShapeCannotBypassGate(t *testing.T) { + h := &SectionHandler{} + // section_id is empty and is_user_added is omitted — pre-fix this skipped + // the gate entirely, even though the resolver would still surface this as + // a user-added admin_curated_list section. + body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"admin_curated_list","config":{"item_ids":["a"]}}]}`) + req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1}) + ctx = apimw.SetProfileID(ctx, "p1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + h.HandleSaveProfileOverrides(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden for legacy-shape bypass, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +// TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected ensures that +// legacy-shape user-added overrides whose section_type is not a registered +// recipe are rejected as 400, not silently passed through. +func TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected(t *testing.T) { + h := &SectionHandler{} + body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"no_such_recipe","config":{}}]}`) + req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1}) + ctx = apimw.SetProfileID(ctx, "p1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + h.HandleSaveProfileOverrides(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +// TestToSectionOverridesPropagatesUserAddedFields verifies the storage→resolver +// shim copies the four user-added fields (IsUserAdded / UserSectionType / +// UserConfig / UserTitle). Without this propagation the resolver sees zero +// values and silently drops profile-built sections — even after the SQLite +// persistence layer correctly stores them. +func TestToSectionOverridesPropagatesUserAddedFields(t *testing.T) { + stored := []userstore.SectionOverride{ + { + ID: "ov-1", + SectionID: "", + IsUserAdded: true, + UserSectionType: "hidden_gems", + UserConfig: `{"min_rating":7.5}`, + UserTitle: "Hidden Gems", + }, + { + ID: "ov-2", + SectionID: "admin-1", + SectionType: "recently_added", + Title: "Renamed", + }, + } + + got := toSectionOverrides(stored) + if len(got) != 2 { + t.Fatalf("got %d overrides, want 2", len(got)) + } + + if !got[0].IsUserAdded { + t.Error("user-added IsUserAdded = false, want true") + } + if string(got[0].UserSectionType) != "hidden_gems" { + t.Errorf("UserSectionType = %q, want hidden_gems", got[0].UserSectionType) + } + if string(got[0].UserConfig) != `{"min_rating":7.5}` { + t.Errorf("UserConfig = %q, want %q", string(got[0].UserConfig), `{"min_rating":7.5}`) + } + if got[0].UserTitle != "Hidden Gems" { + t.Errorf("UserTitle = %q, want Hidden Gems", got[0].UserTitle) + } + + // Admin-section customization should leave the user-added fields zero. + if got[1].IsUserAdded || got[1].UserSectionType != "" || len(got[1].UserConfig) != 0 || got[1].UserTitle != "" { + t.Errorf("legacy customization leaked user-added fields: %+v", got[1]) + } +} diff --git a/internal/api/handlers/session_ws.go b/internal/api/handlers/session_ws.go new file mode 100644 index 00000000..26b6c9d7 --- /dev/null +++ b/internal/api/handlers/session_ws.go @@ -0,0 +1,194 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/playback" +) + +type realtimeClientMessage struct { + Type playback.RealtimeMessageType `json:"type"` +} + +type sessionRealtimeConn struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +func (c *sessionRealtimeConn) WriteJSON(v any) error { + if c == nil || c.conn == nil { + return playback.ErrRealtimeConnectionNotFound + } + + c.writeMu.Lock() + defer c.writeMu.Unlock() + return writeWebSocketJSON(c.conn, v) +} + +func (c *sessionRealtimeConn) WritePing() error { + if c == nil || c.conn == nil { + return playback.ErrRealtimeConnectionNotFound + } + + c.writeMu.Lock() + defer c.writeMu.Unlock() + return writeWebSocketControl(c.conn, websocket.PingMessage, nil) +} + +// HandleSessionWebSocket handles GET /playback/ws/{session_id}. +// It upgrades to a realtime control WebSocket. Sessions become control-ready +// only after a validated hello message. Disconnects degrade command delivery +// but do not stop an otherwise valid playback session. +func (h *PlaybackHandler) HandleSessionWebSocket(w http.ResponseWriter, r *http.Request) { + if h == nil || h.RealtimeHub == nil { + http.Error(w, "realtime unavailable", http.StatusServiceUnavailable) + return + } + + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + sessionID := chi.URLParam(r, "session_id") + if sessionID == "" { + http.Error(w, "session_id required", http.StatusBadRequest) + return + } + setPlaybackSessionLogContext(r, sessionID) + + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + writePlaybackSessionNotFound(w) + return + } + if session.UserID != userID { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error("websocket upgrade failed", "error", err, "session", sessionID, "playback_session_id", sessionID) + return + } + + realtimeConn := &sessionRealtimeConn{conn: conn} + registration := h.RealtimeHub.Register(sessionID, realtimeConn) + if registration == nil { + conn.Close() + slog.Warn("failed to register realtime websocket", "session", sessionID, "playback_session_id", sessionID) + return + } + + defer func() { + if h.setRealtimeConnectionState(sessionID, false) { + h.syncSessionsNow(context.Background(), "realtime_disconnect") + } + h.RealtimeHub.Unregister(registration) + _ = conn.Close() + }() + + configureWebSocket(conn) + ctx, cancelRead := context.WithCancel(r.Context()) + defer cancelRead() + startWebSocketPingLoop(ctx, realtimeConn.WritePing) + + for { + _, data, err := conn.ReadMessage() + if err != nil { + break + } + if err := h.handleRealtimeClientMessage(sessionID, data); err != nil { + slog.Warn("invalid realtime client message", "session", sessionID, "playback_session_id", sessionID, "error", err) + } + } +} + +func (h *PlaybackHandler) handleRealtimeClientMessage(sessionID string, data []byte) error { + var base realtimeClientMessage + if err := json.Unmarshal(data, &base); err != nil { + return err + } + + switch base.Type { + case playback.RealtimeMessageTypeHello: + var hello playback.HelloEnvelope + if err := json.Unmarshal(data, &hello); err != nil { + return err + } + if err := hello.Validate(); err != nil { + return err + } + if hello.SessionID != sessionID { + return playback.ErrInvalidRealtimePayload + } + if h.setRealtimeConnectionState(sessionID, true) { + h.syncSessionsNow(context.Background(), "realtime_hello") + } + h.touchSessionActivity(sessionID) + return nil + case playback.RealtimeMessageTypeAck: + var ack playback.AckEnvelope + if err := json.Unmarshal(data, &ack); err != nil { + return err + } + if err := ack.Validate(); err != nil { + return err + } + if ack.SessionID != sessionID { + return playback.ErrInvalidRealtimePayload + } + h.touchSessionActivity(sessionID) + if h.CommandTracker != nil { + h.CommandTracker.Ack(ack.CommandID) + } + return nil + case playback.RealtimeMessageTypeResult: + var result playback.ResultEnvelope + if err := json.Unmarshal(data, &result); err != nil { + return err + } + if err := result.Validate(); err != nil { + return err + } + if result.SessionID != sessionID { + return playback.ErrInvalidRealtimePayload + } + h.touchSessionActivity(sessionID) + if h.CommandTracker != nil { + h.CommandTracker.Result(result.CommandID) + } + record, ok := h.getRealtimeCommand(result.CommandID) + if !ok { + return nil + } + h.forgetRealtimeCommand(result.CommandID) + if record.SessionID != sessionID { + return playback.ErrInvalidRealtimePayload + } + if result.Status != playback.RealtimeResultStatusCompleted { + return nil + } + switch record.Name { + case playback.CommandStop, playback.CommandTerminate: + err := h.stopPlaybackSessionByID(context.Background(), sessionID) + if err != nil && !errors.Is(err, playback.ErrSessionNotFound) { + slog.Error("failed to stop playback after realtime completion", "session", sessionID, "playback_session_id", sessionID, "error", err) + } + } + return nil + default: + return playback.ErrInvalidRealtimePayload + } +} diff --git a/internal/api/handlers/session_ws_test.go b/internal/api/handlers/session_ws_test.go new file mode 100644 index 00000000..5c526ce6 --- /dev/null +++ b/internal/api/handlers/session_ws_test.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/playback" +) + +func TestHandleSessionWebSocket_RequiresHelloBeforeRealtimeReady(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + session, err := sessionMgr.StartSession(1, "profile-1", 100, playback.PlayDirect, false) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr) + handler.RealtimeHub = playback.NewRealtimeHub() + + router := chi.NewRouter() + router.Get("/playback/ws/{session_id}", func(w http.ResponseWriter, r *http.Request) { + ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess}) + handler.HandleSessionWebSocket(w, r.WithContext(ctx)) + }) + + server := httptest.NewServer(router) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/playback/ws/" + session.ID + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial websocket: %v", err) + } + defer conn.Close() + + got, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession before hello: %v", err) + } + if got.HasRealtimeConnection { + t.Fatal("session should not be realtime-ready before hello") + } + if got.HasWebSocket { + t.Fatal("session should not report playback control before hello") + } + + if err := conn.WriteJSON(playback.HelloEnvelope{ + Type: playback.RealtimeMessageTypeHello, + SessionID: session.ID, + Client: playback.HelloClientInfo{ + Name: "ios", + Version: "1.0.0", + }, + Capabilities: playback.HelloCapabilities{ + Commands: []playback.CommandName{ + playback.CommandPause, + playback.CommandUnpause, + playback.CommandStop, + playback.CommandTerminate, + }, + }, + }); err != nil { + t.Fatalf("WriteJSON hello: %v", err) + } + + waitForPlaybackRealtimeState(t, sessionMgr, session.ID, true) + + if err := conn.Close(); err != nil { + t.Fatalf("Close websocket: %v", err) + } + + waitForPlaybackRealtimeState(t, sessionMgr, session.ID, false) +} + +func waitForPlaybackRealtimeState(t *testing.T, sessionMgr *playback.SessionManager, sessionID string, want bool) { + t.Helper() + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + session, err := sessionMgr.GetSession(sessionID) + if err == nil && session != nil && session.HasRealtimeConnection == want && session.HasWebSocket == want { + return + } + time.Sleep(10 * time.Millisecond) + } + + session, err := sessionMgr.GetSession(sessionID) + if err != nil { + t.Fatalf("GetSession after wait: %v", err) + } + t.Fatalf( + "session realtime state = %v/%v, want %v/%v", + session.HasRealtimeConnection, + session.HasWebSocket, + want, + want, + ) +} diff --git a/internal/api/handlers/settings.go b/internal/api/handlers/settings.go new file mode 100644 index 00000000..0630b048 --- /dev/null +++ b/internal/api/handlers/settings.go @@ -0,0 +1,819 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +const subtitleAppearanceSettingKey = "subtitle_appearance" + +const ( + deviceIDHeader = "X-Silo-Device-Id" + deviceNameHeader = "X-Silo-Device-Name" + devicePlatformHeader = "X-Silo-Device-Platform" +) + +// ServerSettingReader reads individual keys from the server_settings table. +type ServerSettingReader interface { + Get(ctx context.Context, key string) (string, error) +} + +// SettingsHandler handles user-scoped settings endpoints. +type SettingsHandler struct { + storeProvider userstore.UserStoreProvider + serverSettings ServerSettingReader +} + +// NewSettingsHandler creates a new SettingsHandler. +func NewSettingsHandler(provider userstore.UserStoreProvider) *SettingsHandler { + return &SettingsHandler{storeProvider: provider} +} + +// SetServerSettings configures the optional server settings reader for overlay config etc. +func (h *SettingsHandler) SetServerSettings(reader ServerSettingReader) { + h.serverSettings = reader +} + +// --- Request/Response types --- + +type setSettingRequest struct { + Value string `json:"value"` +} + +type settingResponse struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type settingsListResponse struct { + Settings []settingResponse `json:"settings"` +} + +type effectiveSettingResponse struct { + Key string `json:"key"` + ProfileID string `json:"profile_id,omitempty"` + UserValue string `json:"user_value,omitempty"` + DeviceValue string `json:"device_value,omitempty"` + EffectiveValue string `json:"effective_value"` + Source string `json:"source"` + HasDeviceOverride bool `json:"has_device_override"` + DeviceID string `json:"device_id,omitempty"` + DeviceName string `json:"device_name,omitempty"` + DevicePlatform string `json:"device_platform,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type effectiveSettingsResponse struct { + Settings []effectiveSettingResponse `json:"settings"` +} + +type effectiveSubtitleAppearanceResponse struct { + Key string `json:"key"` + ProfileID string `json:"profile_id,omitempty"` + GlobalValue string `json:"global_value"` + DeviceValue string `json:"device_value,omitempty"` + EffectiveValue string `json:"effective_value"` + HasDeviceOverride bool `json:"has_device_override"` + DeviceID string `json:"device_id,omitempty"` + DeviceName string `json:"device_name,omitempty"` + DevicePlatform string `json:"device_platform,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type settingsScope string + +const ( + scopeUser settingsScope = "user" + scopeDevice settingsScope = "device" +) + +type settingSpec struct { + Scope settingsScope + DefaultValue string + Validate func(string) error +} + +var settingsRegistry = map[string]settingSpec{ + "playback.preferred_quality": { + Scope: scopeDevice, + DefaultValue: "auto", + Validate: validateEnumSetting("playback.preferred_quality", + "auto", "original", "2160p", "1080p-high", "1080p-medium", "1080p", "1080p-8", + "720p-high", "720p-medium", "720p", "480p", "420p", "328p"), + }, + "playback.audio_language": { + Scope: scopeDevice, + DefaultValue: "", + Validate: func(value string) error { + if len(strings.TrimSpace(value)) > 32 { + return fmt.Errorf("playback.audio_language must be 32 characters or fewer") + } + return nil + }, + }, + "playback.auto_skip_intro": { + Scope: scopeDevice, + DefaultValue: "false", + Validate: validateBoolSetting("playback.auto_skip_intro"), + }, + "playback.auto_skip_credits": { + Scope: scopeDevice, + DefaultValue: "false", + Validate: validateBoolSetting("playback.auto_skip_credits"), + }, + "playback.auto_play_next": { + Scope: scopeDevice, + DefaultValue: "true", + Validate: validateBoolSetting("playback.auto_play_next"), + }, + "playback.next_up_prompt_seconds": { + Scope: scopeDevice, + DefaultValue: "30", + Validate: validateIntRange("playback.next_up_prompt_seconds", 0, 120), + }, + subtitleAppearanceSettingKey: { + Scope: scopeDevice, + DefaultValue: "", + Validate: validateJSONSetting(subtitleAppearanceSettingKey), + }, + "player.hdr_enabled": { + Scope: scopeDevice, + DefaultValue: "true", + Validate: validateBoolSetting("player.hdr_enabled"), + }, + "player.dv_profile7_hdr10_fallback": { + Scope: scopeDevice, + DefaultValue: "false", + Validate: validateBoolSetting("player.dv_profile7_hdr10_fallback"), + }, + "player.playback_speed": { + Scope: scopeDevice, + DefaultValue: "1", + Validate: validateFloatRange("player.playback_speed", 0.25, 3.0), + }, + "player.audio_sync_ms": { + Scope: scopeDevice, + DefaultValue: "0", + Validate: validateIntRange("player.audio_sync_ms", -5000, 5000), + }, + "player.subtitle_sync_ms": { + Scope: scopeDevice, + DefaultValue: "0", + Validate: validateIntRange("player.subtitle_sync_ms", -10000, 10000), + }, + "player.video_gravity": { + Scope: scopeDevice, + DefaultValue: "fit", + Validate: validateEnumSetting("player.video_gravity", "fit", "fill", "stretch"), + }, + "player.orientation_mode": { + Scope: scopeDevice, + DefaultValue: "landscapeLocked", + Validate: validateEnumSetting("player.orientation_mode", "landscapeLocked", "rotateFreely"), + }, +} + +// --- Handler methods --- + +// HandleListSettings handles GET /settings. +func (h *SettingsHandler) HandleListSettings(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + entries, err := store.ListSettings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings") + return + } + + resp := settingsListResponse{ + Settings: make([]settingResponse, 0, len(entries)), + } + for _, e := range entries { + if !keyUsesUserScope(e.Key) { + continue + } + resp.Settings = append(resp.Settings, settingResponse{ + Key: e.Key, + Value: e.Value, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetSetting handles GET /settings/{key}. +func (h *SettingsHandler) HandleGetSetting(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + key := chi.URLParam(r, "key") + + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesUserScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser)) + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + value, err := store.GetSetting(r.Context(), key) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get setting") + return + } + + if value == "" { + writeError(w, http.StatusNotFound, "not_found", "Setting not found") + return + } + + writeJSON(w, http.StatusOK, settingResponse{ + Key: key, + Value: value, + }) +} + +// HandleSetSetting handles PUT /settings/{key}. +func (h *SettingsHandler) HandleSetSetting(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + key := chi.URLParam(r, "key") + + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesUserScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser)) + return + } + + var req setSettingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if err := validateRegisteredSetting(key, req.Value, scopeUser); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.SetSetting(r.Context(), key, req.Value); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set setting") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleDeleteSetting handles DELETE /settings/{key}. +func (h *SettingsHandler) HandleDeleteSetting(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + key := chi.URLParam(r, "key") + + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesUserScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser)) + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.DeleteSetting(r.Context(), key); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete setting") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleGetDeviceSetting handles GET /settings/device/{key}. +func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID, ok := activeProfileIDFromRequest(w, r) + if !ok { + return + } + key := chi.URLParam(r, "key") + device := deviceMetadataFromRequest(r) + + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesDeviceScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice)) + return + } + if device.DeviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Device id is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + value, err := store.GetDeviceSetting(r.Context(), profileID, device.DeviceID, key) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get device setting") + return + } + if value == nil { + writeError(w, http.StatusNotFound, "not_found", "Setting not found") + return + } + + writeJSON(w, http.StatusOK, settingResponse{ + Key: key, + Value: value.Value, + }) +} + +// HandleSetDeviceSetting handles PUT /settings/device/{key}. +func (h *SettingsHandler) HandleSetDeviceSetting(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID, ok := activeProfileIDFromRequest(w, r) + if !ok { + return + } + key := chi.URLParam(r, "key") + device := deviceMetadataFromRequest(r) + + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesDeviceScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice)) + return + } + if device.DeviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Device id is required") + return + } + + var req setSettingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if err := validateRegisteredSetting(key, req.Value, scopeDevice); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.SetDeviceSetting(r.Context(), userstore.DeviceSettingEntry{ + ProfileID: profileID, + DeviceID: device.DeviceID, + DeviceName: device.DeviceName, + DevicePlatform: device.DevicePlatform, + Key: key, + Value: req.Value, + }); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set device setting") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleDeleteDeviceSetting handles DELETE /settings/device/{key}. +func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID, ok := activeProfileIDFromRequest(w, r) + if !ok { + return + } + key := chi.URLParam(r, "key") + device := deviceMetadataFromRequest(r) + + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required") + return + } + if !keyUsesDeviceScope(key) { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice)) + return + } + if device.DeviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Device id is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, key); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleGetEffectiveSettings handles GET /settings/effective?keys=key1,key2 +func (h *SettingsHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID, ok := activeProfileIDFromRequest(w, r) + if !ok { + return + } + keysParam := strings.TrimSpace(r.URL.Query().Get("keys")) + if keysParam == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Query parameter keys is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + device := deviceMetadataFromRequest(r) + keys := parseSettingKeys(keysParam) + resp := effectiveSettingsResponse{ + Settings: make([]effectiveSettingResponse, 0, len(keys)), + } + for _, key := range keys { + resolved, err := h.resolveEffectiveSetting(r.Context(), store, profileID, device, key) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve effective settings") + return + } + resp.Settings = append(resp.Settings, resolved) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleGetEffectiveSubtitleAppearance handles GET /settings/subtitle_appearance/effective. +func (h *SettingsHandler) HandleGetEffectiveSubtitleAppearance(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID, ok := activeProfileIDFromRequest(w, r) + if !ok { + return + } + device := deviceMetadataFromRequest(r) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + resolved, err := h.resolveEffectiveSetting(r.Context(), store, profileID, device, subtitleAppearanceSettingKey) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get setting") + return + } + + resp := effectiveSubtitleAppearanceResponse{ + Key: subtitleAppearanceSettingKey, + ProfileID: resolved.ProfileID, + GlobalValue: resolved.UserValue, + DeviceValue: resolved.DeviceValue, + EffectiveValue: resolved.EffectiveValue, + HasDeviceOverride: resolved.HasDeviceOverride, + DeviceID: resolved.DeviceID, + DeviceName: resolved.DeviceName, + DevicePlatform: resolved.DevicePlatform, + UpdatedAt: resolved.UpdatedAt, + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleSetSubtitleAppearanceDeviceOverride handles PUT /settings/device/subtitle_appearance. +func (h *SettingsHandler) HandleSetSubtitleAppearanceDeviceOverride(w http.ResponseWriter, r *http.Request) { + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("key", subtitleAppearanceSettingKey) + h.HandleSetDeviceSetting(w, r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))) +} + +// HandleDeleteSubtitleAppearanceDeviceOverride handles DELETE /settings/device/subtitle_appearance. +func (h *SettingsHandler) HandleDeleteSubtitleAppearanceDeviceOverride(w http.ResponseWriter, r *http.Request) { + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("key", subtitleAppearanceSettingKey) + h.HandleDeleteDeviceSetting(w, r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))) +} + +type requestDeviceMetadata struct { + DeviceID string + DeviceName string + DevicePlatform string +} + +func activeProfileIDFromRequest(w http.ResponseWriter, r *http.Request) (string, bool) { + profileID := strings.TrimSpace(apimw.GetProfileID(r.Context())) + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required") + return "", false + } + return profileID, true +} + +func deviceMetadataFromRequest(r *http.Request) requestDeviceMetadata { + return requestDeviceMetadata{ + DeviceID: clampHeaderValue(r.Header.Get(deviceIDHeader), 128), + DeviceName: clampHeaderValue(r.Header.Get(deviceNameHeader), 120), + DevicePlatform: clampHeaderValue(r.Header.Get(devicePlatformHeader), 40), + } +} + +func clampHeaderValue(value string, maxLen int) string { + value = strings.TrimSpace(value) + runes := []rune(value) + if len(runes) <= maxLen { + return value + } + return string(runes[:maxLen]) +} + +func parseSettingKeys(raw string) []string { + parts := strings.Split(raw, ",") + keys := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + key := strings.TrimSpace(part) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + keys = append(keys, key) + } + return keys +} + +func validateRegisteredSetting(key, value string, expectedScope settingsScope) error { + spec, ok := settingsRegistry[key] + if !ok { + return nil + } + if spec.Scope != expectedScope { + return fmt.Errorf("%s is not a %s setting", key, expectedScope) + } + if spec.Validate == nil { + return nil + } + return spec.Validate(value) +} + +func keyUsesUserScope(key string) bool { + spec, ok := settingsRegistry[key] + return !ok || spec.Scope == scopeUser +} + +func keyUsesDeviceScope(key string) bool { + spec, ok := settingsRegistry[key] + return ok && spec.Scope == scopeDevice +} + +func isMigratedPlaybackSetting(key string) bool { + switch key { + case "playback.preferred_quality", + "playback.audio_language", + "playback.auto_skip_intro", + "playback.auto_skip_credits", + "playback.auto_play_next", + "playback.next_up_prompt_seconds": + return true + default: + return false + } +} + +func usesLegacyUserFallback(key string) bool { + return key == subtitleAppearanceSettingKey +} + +func validateEnumSetting(key string, allowed ...string) func(string) error { + allowedSet := make(map[string]struct{}, len(allowed)) + for _, value := range allowed { + allowedSet[value] = struct{}{} + } + return func(value string) error { + if _, ok := allowedSet[value]; ok { + return nil + } + return fmt.Errorf("%s must be one of %s", key, strings.Join(allowed, ", ")) + } +} + +func validateBoolSetting(key string) func(string) error { + return func(value string) error { + if value == "true" || value == "false" { + return nil + } + return fmt.Errorf("%s must be true or false", key) + } +} + +func validateIntRange(key string, min, max int) func(string) error { + return func(value string) error { + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("%s must be an integer", key) + } + if parsed < min || parsed > max { + return fmt.Errorf("%s must be between %d and %d", key, min, max) + } + return nil + } +} + +func validateFloatRange(key string, min, max float64) func(string) error { + return func(value string) error { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("%s must be a number", key) + } + if math.IsNaN(parsed) || parsed < min || parsed > max { + return fmt.Errorf("%s must be between %g and %g", key, min, max) + } + return nil + } +} + +func validateJSONSetting(key string) func(string) error { + return func(value string) error { + var decoded any + if err := json.Unmarshal([]byte(value), &decoded); err != nil { + return fmt.Errorf("%s must be valid JSON", key) + } + return nil + } +} + +func (h *SettingsHandler) resolveEffectiveSetting( + ctx context.Context, + store userstore.UserStore, + profileID string, + device requestDeviceMetadata, + key string, +) (effectiveSettingResponse, error) { + spec, hasSpec := settingsRegistry[key] + resolved := effectiveSettingResponse{ + Key: key, + ProfileID: profileID, + DeviceID: device.DeviceID, + DeviceName: device.DeviceName, + DevicePlatform: device.DevicePlatform, + } + + if hasSpec && spec.Scope == scopeDevice { + resolved.EffectiveValue = "" + resolved.Source = "default" + if spec.DefaultValue != "" { + resolved.EffectiveValue = spec.DefaultValue + } else { + resolved.Source = "unset" + } + if device.DeviceID != "" { + override, err := store.GetDeviceSetting(ctx, profileID, device.DeviceID, key) + if err != nil { + return effectiveSettingResponse{}, err + } + if override != nil { + resolved.DeviceValue = override.Value + resolved.EffectiveValue = override.Value + resolved.Source = "device" + resolved.HasDeviceOverride = true + resolved.DeviceName = override.DeviceName + resolved.DevicePlatform = override.DevicePlatform + resolved.UpdatedAt = override.UpdatedAt + return resolved, nil + } + if isMigratedPlaybackSetting(key) { + legacyValue, err := store.GetSetting(ctx, key) + if err != nil { + return effectiveSettingResponse{}, err + } + if legacyValue != "" { + entry := userstore.DeviceSettingEntry{ + ProfileID: profileID, + DeviceID: device.DeviceID, + DeviceName: device.DeviceName, + DevicePlatform: device.DevicePlatform, + Key: key, + Value: legacyValue, + } + if err := store.SetDeviceSetting(ctx, entry); err != nil { + return effectiveSettingResponse{}, err + } + resolved.DeviceValue = legacyValue + resolved.EffectiveValue = legacyValue + resolved.Source = "device" + resolved.HasDeviceOverride = true + return resolved, nil + } + } + } + if usesLegacyUserFallback(key) { + legacyValue, err := store.GetSetting(ctx, key) + if err != nil { + return effectiveSettingResponse{}, err + } + if legacyValue != "" { + resolved.UserValue = legacyValue + resolved.EffectiveValue = legacyValue + resolved.Source = "user" + return resolved, nil + } + } + return resolved, nil + } + + userValue, err := store.GetSetting(ctx, key) + if err != nil { + return effectiveSettingResponse{}, err + } + resolved.UserValue = userValue + resolved.EffectiveValue = userValue + resolved.Source = "user" + + if resolved.EffectiveValue != "" { + return resolved, nil + } + + if hasSpec && spec.DefaultValue != "" { + resolved.EffectiveValue = spec.DefaultValue + resolved.Source = "default" + return resolved, nil + } + + resolved.Source = "unset" + return resolved, nil +} + +// overlayConfigResponse is returned by GET /settings/overlay-config. +type overlayConfigResponse struct { + Enabled bool `json:"enabled"` + Defaults string `json:"defaults,omitempty"` +} + +// HandleGetOverlayConfig returns the server-wide overlay configuration. +// Available to all authenticated users (not admin-only). +func (h *SettingsHandler) HandleGetOverlayConfig(w http.ResponseWriter, r *http.Request) { + resp := overlayConfigResponse{Enabled: true} + + if h.serverSettings != nil { + if v, _ := h.serverSettings.Get(r.Context(), "overlays.enabled"); v == "false" { + resp.Enabled = false + } + if v, _ := h.serverSettings.Get(r.Context(), "defaults.card_overlays"); v != "" { + resp.Defaults = v + } + } + + w.Header().Set("Cache-Control", "private, max-age=60") + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/settings_device_test.go b/internal/api/handlers/settings_device_test.go new file mode 100644 index 00000000..d66d9b63 --- /dev/null +++ b/internal/api/handlers/settings_device_test.go @@ -0,0 +1,444 @@ +package handlers + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userdb" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +type testAdminUserRepo struct { + users map[int]*models.User +} + +func (r testAdminUserRepo) List(context.Context) ([]*models.User, error) { + out := make([]*models.User, 0, len(r.users)) + for _, user := range r.users { + out = append(out, user) + } + return out, nil +} + +func (r testAdminUserRepo) Create(context.Context, models.CreateUserInput) (*models.User, error) { + panic("unexpected Create call") +} + +func (r testAdminUserRepo) Update(context.Context, int, models.UpdateUserInput) error { + panic("unexpected Update call") +} + +func (r testAdminUserRepo) Delete(context.Context, int) error { + panic("unexpected Delete call") +} + +func (r testAdminUserRepo) GetByID(_ context.Context, id int) (*models.User, error) { + return r.users[id], nil +} + +type mappedTestUserStoreProvider struct { + stores map[int]userstore.UserStore +} + +func (p mappedTestUserStoreProvider) ForUser(_ context.Context, userID int) (userstore.UserStore, error) { + return p.stores[userID], nil +} + +func (p mappedTestUserStoreProvider) Close() error { return nil } + +func newIsolatedProfileTestStore(t *testing.T, suffix string) userstore.UserStore { + t.Helper() + + dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()+"-"+suffix) + "?mode=memory&cache=shared" + db, err := sql.Open("sqlite3", dsn) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + if err := userdb.InitSchema(db); err != nil { + t.Fatalf("init schema: %v", err) + } + + store := userdb.NewSQLiteUserStore(db) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Main"}); err != nil { + t.Fatalf("create profile: %v", err) + } + return store +} + +func TestEffectiveSubtitleAppearancePrefersDeviceOverride(t *testing.T) { + store := newProfileTestStore(t) + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "device-1", + DeviceName: "Living Room", + DevicePlatform: "tvOS", + Key: subtitleAppearanceSettingKey, + Value: `{"fontSize":"small"}`, + }); err != nil { + t.Fatalf("SetDeviceSetting: %v", err) + } + + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + req := httptest.NewRequest(http.MethodGet, "/settings/subtitle_appearance/effective", nil) + req.Header.Set(deviceIDHeader, "device-1") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleGetEffectiveSubtitleAppearance(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var resp effectiveSubtitleAppearanceResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.HasDeviceOverride || resp.EffectiveValue != `{"fontSize":"small"}` || resp.GlobalValue != "" { + t.Fatalf("response = %#v", resp) + } +} + +func TestSubtitleAppearanceDeviceOverrideRoundTrip(t *testing.T) { + store := newProfileTestStore(t) + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + + body := bytes.NewBufferString(`{"value":"{\"fontSize\":\"xxlarge\"}"}`) + req := httptest.NewRequest(http.MethodPut, "/settings/device/subtitle_appearance", body) + req.Header.Set(deviceIDHeader, "iphone") + req.Header.Set(deviceNameHeader, "Example iPhone") + req.Header.Set(devicePlatformHeader, "iOS") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleSetSubtitleAppearanceDeviceOverride(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("set status = %d body=%s", rec.Code, rec.Body.String()) + } + entry, err := store.GetDeviceSetting(context.Background(), "profile-1", "iphone", subtitleAppearanceSettingKey) + if err != nil { + t.Fatalf("GetDeviceSetting: %v", err) + } + if entry == nil || entry.Value != `{"fontSize":"xxlarge"}` || entry.DevicePlatform != "iOS" { + t.Fatalf("entry = %#v", entry) + } + + req = httptest.NewRequest(http.MethodDelete, "/settings/device/subtitle_appearance", nil) + req.Header.Set(deviceIDHeader, "iphone") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec = httptest.NewRecorder() + handler.HandleDeleteSubtitleAppearanceDeviceOverride(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d body=%s", rec.Code, rec.Body.String()) + } + entry, err = store.GetDeviceSetting(context.Background(), "profile-1", "iphone", subtitleAppearanceSettingKey) + if err != nil { + t.Fatalf("GetDeviceSetting after delete: %v", err) + } + if entry != nil { + t.Fatalf("entry after delete = %#v, want nil", entry) + } +} + +func TestGetEffectiveSettingsResolvesUserDeviceAndDefaultSources(t *testing.T) { + store := newProfileTestStore(t) + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "apple-tv", + Key: "playback.preferred_quality", + Value: "1080p", + }); err != nil { + t.Fatalf("SetDeviceSetting(preferred_quality): %v", err) + } + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "apple-tv", + Key: "player.playback_speed", + Value: "1.25", + }); err != nil { + t.Fatalf("SetDeviceSetting: %v", err) + } + + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + req := httptest.NewRequest( + http.MethodGet, + "/settings/effective?keys=playback.preferred_quality,player.playback_speed,player.hdr_enabled", + nil, + ) + req.Header.Set(deviceIDHeader, "apple-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1")) + rec := httptest.NewRecorder() + + handler.HandleGetEffectiveSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var resp effectiveSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Settings) != 3 { + t.Fatalf("settings len = %d", len(resp.Settings)) + } + byKey := make(map[string]effectiveSettingResponse, len(resp.Settings)) + for _, entry := range resp.Settings { + byKey[entry.Key] = entry + } + if got := byKey["playback.preferred_quality"]; got.EffectiveValue != "1080p" || got.Source != "device" || !got.HasDeviceOverride { + t.Fatalf("preferred_quality = %#v", got) + } + if got := byKey["player.playback_speed"]; got.EffectiveValue != "1.25" || got.Source != "device" || !got.HasDeviceOverride { + t.Fatalf("playback_speed = %#v", got) + } + if got := byKey["player.hdr_enabled"]; got.EffectiveValue != "true" || got.Source != "default" { + t.Fatalf("hdr_enabled = %#v", got) + } +} + +func TestGenericSettingsRejectInvalidRegisteredValues(t *testing.T) { + store := newProfileTestStore(t) + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + + req := httptest.NewRequest( + http.MethodPut, + "/settings/playback.auto_skip_intro", + bytes.NewBufferString(`{"value":"maybe"}`), + ) + req = withRouteParams(req, map[string]string{"key": "playback.auto_skip_intro"}) + req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})) + rec := httptest.NewRecorder() + + handler.HandleSetSetting(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAdminCanResetSubtitleAppearanceDeviceOverrides(t *testing.T) { + store := newProfileTestStore(t) + for _, deviceID := range []string{"apple-tv", "iphone"} { + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: deviceID, + Key: subtitleAppearanceSettingKey, + Value: `{"fontSize":"small"}`, + }); err != nil { + t.Fatalf("SetDeviceSetting(%s): %v", deviceID, err) + } + } + handler := &AdminHandler{storeProv: testUserStoreProvider{store: store}} + + req := httptest.NewRequest(http.MethodDelete, "/admin/users/7/profiles/profile-1/device-settings/subtitle_appearance/apple-tv", nil) + req = withRouteParams(req, map[string]string{ + "id": "7", "profile_id": "profile-1", "key": subtitleAppearanceSettingKey, "device_id": "apple-tv", + }) + rec := httptest.NewRecorder() + handler.HandleDeleteUserDeviceSetting(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("delete one status = %d body=%s", rec.Code, rec.Body.String()) + } + remaining, err := store.ListDeviceSettings(context.Background(), subtitleAppearanceSettingKey) + if err != nil { + t.Fatalf("ListDeviceSettings: %v", err) + } + if len(remaining) != 1 || remaining[0].DeviceID != "iphone" { + t.Fatalf("remaining = %#v", remaining) + } + + req = httptest.NewRequest(http.MethodDelete, "/admin/users/7/device-settings/subtitle_appearance", nil) + req = withRouteParams(req, map[string]string{"id": "7", "key": subtitleAppearanceSettingKey}) + rec = httptest.NewRecorder() + handler.HandleDeleteUserDeviceSettingsByKey(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("delete all status = %d body=%s", rec.Code, rec.Body.String()) + } + remaining, err = store.ListDeviceSettings(context.Background(), subtitleAppearanceSettingKey) + if err != nil { + t.Fatalf("ListDeviceSettings after delete all: %v", err) + } + if len(remaining) != 0 { + t.Fatalf("remaining after delete all = %#v", remaining) + } +} + +func TestEffectiveSettingsAreIsolatedPerProfileOnSameDevice(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Guest"}); err != nil { + t.Fatalf("CreateProfile: %v", err) + } + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "shared-tv", + Key: "player.playback_speed", + Value: "1.5", + }); err != nil { + t.Fatalf("SetDeviceSetting profile-1: %v", err) + } + if err := store.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-2", + DeviceID: "shared-tv", + Key: "player.playback_speed", + Value: "0.75", + }); err != nil { + t.Fatalf("SetDeviceSetting profile-2: %v", err) + } + + handler := NewSettingsHandler(testUserStoreProvider{store: store}) + + req := httptest.NewRequest(http.MethodGet, "/settings/effective?keys=player.playback_speed", nil) + req.Header.Set(deviceIDHeader, "shared-tv") + req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-2")) + rec := httptest.NewRecorder() + + handler.HandleGetEffectiveSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var resp effectiveSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Settings) != 1 { + t.Fatalf("settings len = %d", len(resp.Settings)) + } + if got := resp.Settings[0]; got.ProfileID != "profile-2" || got.EffectiveValue != "0.75" { + t.Fatalf("effective setting = %#v", got) + } +} + +func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) { + store1 := newIsolatedProfileTestStore(t, "one") + store2 := newIsolatedProfileTestStore(t, "two") + if err := store1.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "living-room", + DeviceName: "Living Room TV", + DevicePlatform: "tvOS", + Key: "player.playback_speed", + Value: "1.25", + }); err != nil { + t.Fatalf("SetDeviceSetting store1: %v", err) + } + if err := store1.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "living-room", + DeviceName: "Living Room TV", + DevicePlatform: "tvOS", + Key: "player.audio_sync_ms", + Value: "120", + }); err != nil { + t.Fatalf("SetDeviceSetting store1 second: %v", err) + } + if err := store2.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{ + ProfileID: "profile-1", + DeviceID: "phone", + DeviceName: "Travel Phone", + DevicePlatform: "iOS", + Key: "player.hdr_enabled", + Value: "false", + }); err != nil { + t.Fatalf("SetDeviceSetting store2: %v", err) + } + + handler := &AdminHandler{ + userRepo: testAdminUserRepo{ + users: map[int]*models.User{ + 7: {ID: 7, Username: "alice", Email: "alice@example.com"}, + 11: {ID: 11, Username: "bob", Email: "bob@example.com"}, + }, + }, + storeProv: mappedTestUserStoreProvider{ + stores: map[int]userstore.UserStore{ + 7: store1, + 11: store2, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/admin/devices", nil) + rec := httptest.NewRecorder() + handler.HandleListDevices(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list status = %d body=%s", rec.Code, rec.Body.String()) + } + var listResp adminDevicesListResponse + if err := json.NewDecoder(rec.Body).Decode(&listResp); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(listResp.Devices) != 2 { + t.Fatalf("devices len = %d, want 2", len(listResp.Devices)) + } + + req = httptest.NewRequest(http.MethodGet, "/admin/devices/7/living-room", nil) + req = withRouteParams(req, map[string]string{"user_id": "7", "device_id": "living-room"}) + rec = httptest.NewRecorder() + handler.HandleGetDevice(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("detail status = %d body=%s", rec.Code, rec.Body.String()) + } + var detailResp adminDeviceDetailResponse + if err := json.NewDecoder(rec.Body).Decode(&detailResp); err != nil { + t.Fatalf("decode detail: %v", err) + } + if detailResp.Username != "alice" || detailResp.DeviceName != "Living Room TV" { + t.Fatalf("detail response = %#v", detailResp) + } + if len(detailResp.Settings) != 2 { + t.Fatalf("detail settings len = %d, want 2", len(detailResp.Settings)) + } +} + +func TestAdminCanResetAllOverridesForOneDevice(t *testing.T) { + store := newProfileTestStore(t) + for _, entry := range []userstore.DeviceSettingEntry{ + {ProfileID: "profile-1", DeviceID: "living-room", Key: "player.playback_speed", Value: "1.25"}, + {ProfileID: "profile-1", DeviceID: "living-room", Key: "player.audio_sync_ms", Value: "120"}, + {ProfileID: "profile-1", DeviceID: "phone", Key: "player.hdr_enabled", Value: "false"}, + } { + if err := store.SetDeviceSetting(context.Background(), entry); err != nil { + t.Fatalf("SetDeviceSetting: %v", err) + } + } + + handler := &AdminHandler{storeProv: testUserStoreProvider{store: store}} + req := httptest.NewRequest(http.MethodDelete, "/admin/users/7/profiles/profile-1/devices/living-room/settings", nil) + req = withRouteParams(req, map[string]string{"id": "7", "profile_id": "profile-1", "device_id": "living-room"}) + rec := httptest.NewRecorder() + handler.HandleDeleteAllUserDeviceSettings(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d body=%s", rec.Code, rec.Body.String()) + } + + entries, err := store.ListAllDeviceSettings(context.Background()) + if err != nil { + t.Fatalf("ListAllDeviceSettings: %v", err) + } + if len(entries) != 1 || entries[0].DeviceID != "phone" { + t.Fatalf("entries after delete = %#v", entries) + } +} + +func withRouteParams(req *http.Request, params map[string]string) *http.Request { + routeCtx := chi.NewRouteContext() + for key, value := range params { + routeCtx.URLParams.Add(key, value) + } + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) +} diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go new file mode 100644 index 00000000..5a3195c6 --- /dev/null +++ b/internal/api/handlers/stream.go @@ -0,0 +1,400 @@ +package handlers + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "strconv" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/subtitles" +) + +// FilePathResolver looks up a media file by its ID. +type FilePathResolver interface { + GetByID(ctx context.Context, id int) (*models.MediaFile, error) +} + +// StreamHandler handles HTTP endpoints for streaming media content. +type StreamHandler struct { + sessionMgr SessionManagerInterface + fileResolver FilePathResolver + MissingMarker MissingFileMarker + EventsHub *evt.Hub + AdminStore PlaybackAdminStore + SessionSyncer PlaybackSessionSyncer + FFmpegPath string + SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles + S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles + S3Bucket string // bucket for subtitle storage +} + +// NewStreamHandler creates a new StreamHandler backed by the given session +// manager and file resolver. +func NewStreamHandler(sessionMgr SessionManagerInterface, fileResolver FilePathResolver) *StreamHandler { + return &StreamHandler{ + sessionMgr: sessionMgr, + fileResolver: fileResolver, + } +} + +// HandleStream serves the video stream for a playback session. +// For direct play: serves the file with HTTP byte-range support. +// For remux: starts an ffmpeg remux and streams the output. +// For transcode: returns 400 (transcode uses manifest/segment endpoints). +func (h *StreamHandler) HandleStream(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + sessionID := chi.URLParam(r, "session_id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required") + return + } + setPlaybackSessionLogContext(r, sessionID) + + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + writePlaybackSessionNotFound(w) + return + } + + // Verify session ownership. + if session.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") + return + } + + file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID) + if err != nil { + if isPlaybackFileLookupMissing(err) { + h.abortPlaybackSession(r.Context(), session) + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load media file") + return + } + if file == nil { + h.abortPlaybackSession(r.Context(), session) + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + return + } + if err := preflightPlaybackFile(r.Context(), file, h.MissingMarker, h.EventsHub); err != nil { + if isPlaybackFileMissing(err) { + h.abortPlaybackSession(r.Context(), session) + } + writePlaybackFilePreflightError(w, err) + return + } + + switch session.PlayMethod { + case playback.PlayDirect: + if err := h.sessionMgr.BeginTransport(sessionID); err == nil { + defer func() { + _ = h.sessionMgr.EndTransport(sessionID) + }() + } + if err := playback.ServeDirectPlay(w, r, file.FilePath); err != nil { + h.handleTransportStartFailure(r.Context(), session, file, err) + } + + case playback.PlayRemux: + if err := h.sessionMgr.BeginTransport(sessionID); err == nil { + defer func() { + _ = h.sessionMgr.EndTransport(sessionID) + }() + } + seekSeconds := 0.0 + if seekStr := r.URL.Query().Get("seek"); seekStr != "" { + if s, err := strconv.ParseFloat(seekStr, 64); err == nil && s >= 0 { + seekSeconds = s + } + } + if err := playback.ServeRemux(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex); err != nil { + h.handleTransportStartFailure(r.Context(), session, file, err) + } + + case playback.PlayTranscode: + writeError(w, http.StatusBadRequest, "bad_request", + "Transcode streams use manifest/segment endpoints") + + default: + writeError(w, http.StatusInternalServerError, "internal_error", + "Unknown play method") + } +} + +// HandleSubtitle extracts a subtitle track from the media file associated with +// a playback session and serves it as WebVTT or raw ASS depending on the +// URL extension (e.g. /subtitles/2.ass or /subtitles/2.vtt). +func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + sessionID := chi.URLParam(r, "session_id") + if sessionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required") + return + } + setPlaybackSessionLogContext(r, sessionID) + + trackParam := chi.URLParam(r, "track") + trackIndex, _, err := playback.ParseSubtitleTrackParam(trackParam) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid subtitle track index") + return + } + + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + writePlaybackSessionNotFound(w) + return + } + + if session.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") + return + } + + file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + return + } + + externalCount := len(file.ExternalSubtitles) + if trackIndex < externalCount { + sub := file.ExternalSubtitles[trackIndex] + + // Serve ASS/SSA external subtitles as raw data for client-side rendering. + if playback.IsASS(sub.Format) { + data, err := playback.LoadExternalSubtitleRaw(sub.Path) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", + "Failed to load external subtitle") + return + } + playback.ServeSubtitle(w, data, "ass") + return + } + + vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.FFmpegPath) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", + "Failed to load external subtitle") + return + } + playback.ServeSubtitle(w, vttData, "vtt") + return + } + + embeddedIndex := trackIndex - externalCount + + // Check embedded tracks. + if embeddedIndex < len(file.SubtitleTracks) { + track := file.SubtitleTracks[embeddedIndex] + if playback.NeedsBurnIn(track.Codec) { + writeError(w, http.StatusBadRequest, "bad_request", + "Bitmap subtitle tracks cannot be extracted as text") + return + } + + // Dedicated streaming extract — ffmpeg seeks to the current + // playback position and pipes cues to the response as they're + // demuxed, so the first byte lands within ~1s even on network + // storage. Works identically for direct-play, remux, and + // transcode because it doesn't depend on any other ffmpeg. + h.streamEmbeddedSubtitle(w, r, file, embeddedIndex, session) + return + } + + // Check downloaded subtitles (from S3). + if h.SubtitleRepo != nil && h.S3Client != nil { + downloaded, err := h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), file.ID) + if err == nil { + downloadedIndex := embeddedIndex - len(file.SubtitleTracks) + if downloadedIndex >= 0 && downloadedIndex < len(downloaded) { + dl := downloaded[downloadedIndex] + data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, dl.S3Key) + if err != nil { + writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage") + return + } + + // Serve ASS/SSA downloaded subtitles as raw data. + if playback.IsASS(string(dl.Format)) { + playback.ServeSubtitle(w, data, "ass") + return + } + + // If the subtitle is already VTT, serve directly. + if dl.Format == subtitles.FormatVTT { + playback.ServeSubtitle(w, data, "vtt") + return + } + + // Convert to VTT using the playback conversion pipeline. + vttData, err := playback.ConvertToVTT(data, string(dl.Format)) + if err != nil { + writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle") + return + } + playback.ServeSubtitle(w, vttData, "vtt") + return + } + } + } + + writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found") +} + +func (h *StreamHandler) syncSessionsNow(ctx context.Context, reason string) { + if h == nil || h.SessionSyncer == nil { + return + } + if err := h.SessionSyncer.SyncNow(ctx); err != nil { + slog.Error("failed to sync sessions", "reason", reason, "error", err) + } +} + +func (h *StreamHandler) finalizeSessionAbort(ctx context.Context, session *playback.Session, syncNow bool, syncReason string) { + if h == nil || session == nil || session.ID == "" { + return + } + if ctx == nil { + ctx = context.Background() + } + + if h.AdminStore != nil { + if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil { + slog.Error("failed to delete synced session", "session", session.ID, "error", err) + } + } + if syncNow { + h.syncSessionsNow(ctx, syncReason) + } +} + +func (h *StreamHandler) abortPlaybackSession(ctx context.Context, session *playback.Session) { + if h == nil || session == nil || session.ID == "" { + return + } + if err := h.sessionMgr.StopSession(session.ID); err != nil { + return + } + h.finalizeSessionAbort(ctx, session, true, "stream_abort") +} + +func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session *playback.Session, file *models.MediaFile, err error) { + if ctx == nil || session == nil || err == nil { + return + } + if preflightErr := preflightPlaybackFile(ctx, file, h.MissingMarker, h.EventsHub); preflightErr != nil { + err = preflightErr + } + if isPlaybackFileMissing(err) || errors.Is(err, os.ErrNotExist) { + h.abortPlaybackSession(ctx, session) + return + } + slog.Warn("stream transport startup failed", + "session", session.ID, + "file_id", session.MediaFileID, + "error", err, + "playback_session_id", session.ID, + ) +} + +// streamEmbeddedSubtitle runs a dedicated ffmpeg for a single embedded +// track, seeked to the best-known playback position, and pipes its +// stdout directly to w. Because this ffmpeg is independent of the video +// pipeline, it works the same for direct play, remux, and transcode. +func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session) { + track := file.SubtitleTracks[embeddedIndex] + outFormat := "vtt" + if playback.IsASS(track.Codec) { + outFormat = "ass" + } + + seek := subtitleSeekPosition(r, session) + duration := subtitleWindowDuration(r) + slog.InfoContext(r.Context(), "subtitle stream requested", + "file_id", file.ID, + "embedded_index", embeddedIndex, + "track_language", track.Language, + "track_codec", track.Codec, + "track_probed_index", track.Index, + "seek_seconds", seek, + "duration_seconds", duration, + ) + + if outFormat == "ass" { + w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/vtt; charset=utf-8") + } + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + + err := playback.StreamExtractSubtitle(r.Context(), playback.StreamExtractOpts{ + InputPath: file.FilePath, + TrackIndex: embeddedIndex, + SourceCodec: track.Codec, + SeekSeconds: seek, + DurationSeconds: duration, + FFmpegPath: h.FFmpegPath, + Writer: w, + }) + if err != nil { + // Headers already committed — best we can do is log and let + // the client see a truncated response. + playback.LogSubtitleStreamError(r.Context(), err, file.ID, embeddedIndex) + } +} + +// subtitleSeekPosition picks the best-known starting position for a +// subtitle extract. A caller-supplied ?position= query wins (the player +// has the most accurate clock), falling back to the session's last +// reported position, then to 0. +func subtitleSeekPosition(r *http.Request, session *playback.Session) float64 { + if raw := r.URL.Query().Get("position"); raw != "" { + if v, err := strconv.ParseFloat(raw, 64); err == nil && v >= 0 { + return v + } + } + if session != nil && session.Position > 0 { + return session.Position + } + return 0 +} + +// subtitleWindowDuration picks the bounded extract length. The client +// overrides via ?duration=; absent that we use a 10-minute window, +// which is long enough that a single fetch covers many minutes of +// uninterrupted playback but short enough that the ffmpeg process +// finishes (and frees its input handle) well before the next window +// is requested. +func subtitleWindowDuration(r *http.Request) float64 { + const defaultDuration = 600.0 + const maxDuration = 3600.0 + if raw := r.URL.Query().Get("duration"); raw != "" { + if v, err := strconv.ParseFloat(raw, 64); err == nil && v > 0 && v <= maxDuration { + return v + } + } + return defaultDuration +} diff --git a/internal/api/handlers/stream_test.go b/internal/api/handlers/stream_test.go new file mode 100644 index 00000000..5c727692 --- /dev/null +++ b/internal/api/handlers/stream_test.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/playback" +) + +type hookedSessionManager struct { + *playback.SessionManager + beginTransportHook func() +} + +type errStreamFileResolver struct { + err error +} + +func (r errStreamFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) { + return nil, r.err +} + +func (m *hookedSessionManager) BeginTransport(sessionID string) error { + if m.beginTransportHook != nil { + m.beginTransportHook() + } + return m.SessionManager.BeginTransport(sessionID) +} + +func TestHandleStream_AbortsSessionWhenDirectPlayFileDisappearsAfterPreflight(t *testing.T) { + filePath := writePlaybackTestMediaFile(t, "movie.mkv") + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: filePath, + Duration: 3600, + } + baseMgr := playback.NewSessionManager(0, 0) + session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + adminStore := &recordingPlaybackAdminStore{} + syncer := &recordingSessionSyncer{} + marker := &recordingMissingMarker{} + sessionMgr := &hookedSessionManager{ + SessionManager: baseMgr, + beginTransportHook: func() { + _ = os.Remove(filePath) + }, + } + handler := NewStreamHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.AdminStore = adminStore + handler.SessionSyncer = syncer + handler.MissingMarker = marker + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID, nil) + req = req.WithContext(newAuthorizedPlaybackContext()) + req = withPlaybackRouteParam(req, "session_id", session.ID) + + rr := httptest.NewRecorder() + handler.HandleStream(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + if _, err := baseMgr.GetSession(session.ID); !errors.Is(err, playback.ErrSessionNotFound) { + t.Fatalf("GetSession error = %v, want %v", err, playback.ErrSessionNotFound) + } + if len(marker.ids) != 1 || marker.ids[0] != 42 { + t.Fatalf("marked ids = %v, want [42]", marker.ids) + } + if len(adminStore.deleted) != 1 || adminStore.deleted[0] != session.ID { + t.Fatalf("deleted sessions = %v, want [%s]", adminStore.deleted, session.ID) + } + if len(adminStore.history) != 0 { + t.Fatalf("history entries = %d, want 0", len(adminStore.history)) + } + if syncer.calls == 0 { + t.Fatal("expected session sync after abort") + } +} + +func TestHandleStream_KeepsSessionWhenLookupFailsForNonMissingReason(t *testing.T) { + baseMgr := playback.NewSessionManager(0, 0) + session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + adminStore := &recordingPlaybackAdminStore{} + syncer := &recordingSessionSyncer{} + handler := NewStreamHandler(baseMgr, errStreamFileResolver{err: errors.New("db unavailable")}) + handler.AdminStore = adminStore + handler.SessionSyncer = syncer + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID, nil) + req = req.WithContext(newAuthorizedPlaybackContext()) + req = withPlaybackRouteParam(req, "session_id", session.ID) + + rr := httptest.NewRecorder() + handler.HandleStream(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + if _, err := baseMgr.GetSession(session.ID); err != nil { + t.Fatalf("GetSession error = %v, want live session", err) + } + if len(adminStore.deleted) != 0 { + t.Fatalf("deleted sessions = %v, want none", adminStore.deleted) + } + if syncer.calls != 0 { + t.Fatalf("sync calls = %d, want 0", syncer.calls) + } +} + +func TestHandleTransportStartFailure_KeepsSessionForNonMissingError(t *testing.T) { + filePath := writePlaybackTestMediaFile(t, "movie.mkv") + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: filePath, + Duration: 3600, + } + baseMgr := playback.NewSessionManager(0, 0) + session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + adminStore := &recordingPlaybackAdminStore{} + syncer := &recordingSessionSyncer{} + handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file}) + handler.AdminStore = adminStore + handler.SessionSyncer = syncer + + handler.handleTransportStartFailure(context.Background(), session, file, errors.New("ffmpeg unavailable")) + + if _, err := baseMgr.GetSession(session.ID); err != nil { + t.Fatalf("GetSession error = %v, want live session", err) + } + if len(adminStore.deleted) != 0 { + t.Fatalf("deleted sessions = %v, want none", adminStore.deleted) + } + if syncer.calls != 0 { + t.Fatalf("sync calls = %d, want 0", syncer.calls) + } +} diff --git a/internal/api/handlers/subtitle_prefs.go b/internal/api/handlers/subtitle_prefs.go new file mode 100644 index 00000000..079bc846 --- /dev/null +++ b/internal/api/handlers/subtitle_prefs.go @@ -0,0 +1,166 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// SubtitlePrefHandler handles per-series subtitle preference endpoints. +type SubtitlePrefHandler struct { + storeProvider userstore.UserStoreProvider +} + +// NewSubtitlePrefHandler creates a new SubtitlePrefHandler. +func NewSubtitlePrefHandler(provider userstore.UserStoreProvider) *SubtitlePrefHandler { + return &SubtitlePrefHandler{storeProvider: provider} +} + +// --- Request/Response types --- + +type setSubtitlePrefRequest struct { + SubtitleLanguage string `json:"subtitle_language"` + SubtitleTrackIndex int `json:"subtitle_track_index"` + ExternalSubtitlePath string `json:"external_subtitle_path,omitempty"` + SubtitleMode string `json:"subtitle_mode"` + TrackSignature *userstore.SubtitleTrackSignature `json:"track_signature,omitempty"` + ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"` +} + +type subtitlePrefResponse struct { + ProfileID string `json:"profile_id"` + SeriesID string `json:"series_id"` + SubtitleLanguage string `json:"subtitle_language"` + SubtitleTrackIndex int `json:"subtitle_track_index"` + ExternalSubtitlePath string `json:"external_subtitle_path,omitempty"` + SubtitleMode string `json:"subtitle_mode"` + TrackSignature *userstore.SubtitleTrackSignature `json:"track_signature,omitempty"` + ShowForcedSubtitles *bool `json:"show_forced_subtitles,omitempty"` + UpdatedAt string `json:"updated_at"` +} + +// --- Handler methods --- + +// HandleGetSubtitlePref handles GET /subtitle-prefs/{series_id}. +func (h *SubtitlePrefHandler) HandleGetSubtitlePref(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + seriesID := chi.URLParam(r, "series_id") + + if seriesID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + pref, err := store.GetSubtitlePreference(r.Context(), profileID, seriesID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get subtitle preference") + return + } + + if pref == nil { + writeError(w, http.StatusNotFound, "not_found", "Subtitle preference not found") + return + } + + writeJSON(w, http.StatusOK, toSubtitlePrefResponse(*pref)) +} + +// HandleSetSubtitlePref handles PUT /subtitle-prefs/{series_id}. +func (h *SubtitlePrefHandler) HandleSetSubtitlePref(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + seriesID := chi.URLParam(r, "series_id") + + if seriesID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID is required") + return + } + + var req setSubtitlePrefRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + pref := userstore.SubtitlePreference{ + ProfileID: profileID, + SeriesID: seriesID, + SubtitleLanguage: req.SubtitleLanguage, + SubtitleTrackIndex: req.SubtitleTrackIndex, + ExternalSubtitlePath: req.ExternalSubtitlePath, + SubtitleMode: req.SubtitleMode, + TrackSignature: req.TrackSignature, + } + if req.ShowForcedSubtitles != nil { + pref.ShowForcedSubtitles = *req.ShowForcedSubtitles + pref.HasShowForcedSubtitles = true + } + + if err := store.SetSubtitlePreference(r.Context(), pref); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set subtitle preference") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleDeleteSubtitlePref handles DELETE /subtitle-prefs/{series_id}. +func (h *SubtitlePrefHandler) HandleDeleteSubtitlePref(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + seriesID := chi.URLParam(r, "series_id") + + if seriesID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Series ID is required") + return + } + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + if err := store.DeleteSubtitlePreference(r.Context(), profileID, seriesID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete subtitle preference") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// --- Helpers --- + +func toSubtitlePrefResponse(p userstore.SubtitlePreference) subtitlePrefResponse { + resp := subtitlePrefResponse{ + ProfileID: p.ProfileID, + SeriesID: p.SeriesID, + SubtitleLanguage: p.SubtitleLanguage, + SubtitleTrackIndex: p.SubtitleTrackIndex, + ExternalSubtitlePath: p.ExternalSubtitlePath, + SubtitleMode: p.SubtitleMode, + TrackSignature: p.TrackSignature, + UpdatedAt: p.UpdatedAt, + } + if p.HasShowForcedSubtitles { + resp.ShowForcedSubtitles = boolPtr(p.ShowForcedSubtitles) + } + return resp +} diff --git a/internal/api/handlers/subtitle_search.go b/internal/api/handlers/subtitle_search.go new file mode 100644 index 00000000..d85d8d40 --- /dev/null +++ b/internal/api/handlers/subtitle_search.go @@ -0,0 +1,206 @@ +package handlers + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "path/filepath" + "strconv" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/subtitles" + "github.com/go-chi/chi/v5" +) + +// SubtitleMediaResolver looks up media metadata for subtitle search. +type SubtitleMediaResolver interface { + GetMediaFileWithMetadata(ctx context.Context, fileID int) (*MediaFileMetadata, error) +} + +// MediaFileMetadata combines file info with parent item metadata for search. +type MediaFileMetadata struct { + FileID int + FilePath string + FileSize int64 + FileHash string // OSHash (16-char hex) + Resolution string + VideoCodec string + AudioCodec string + Title string + Year int + IMDbID string + Season int + Episode int +} + +// SubtitleSearchHandler handles user-facing subtitle search operations. +type SubtitleSearchHandler struct { + manager *subtitles.Manager + repo subtitles.Repository + mediaResolver SubtitleMediaResolver +} + +// NewSubtitleSearchHandler creates a new SubtitleSearchHandler. +func NewSubtitleSearchHandler( + manager *subtitles.Manager, + repo subtitles.Repository, + mediaResolver SubtitleMediaResolver, +) *SubtitleSearchHandler { + return &SubtitleSearchHandler{ + manager: manager, + repo: repo, + mediaResolver: mediaResolver, + } +} + +type searchSubtitlesRequest struct { + MediaFileID int `json:"media_file_id"` + Languages []string `json:"languages"` +} + +type downloadSubtitleRequest struct { + MediaFileID int `json:"media_file_id"` + Provider string `json:"provider"` + SubtitleID string `json:"subtitle_id"` + Language string `json:"language"` + ReleaseName string `json:"release_name"` + Format string `json:"format"` + Score float64 `json:"score"` + HearingImpaired bool `json:"hearing_impaired"` +} + +// HandleSearch handles POST /api/v1/subtitles/search +func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) { + var req searchSubtitlesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + meta, err := h.mediaResolver.GetMediaFileWithMetadata(r.Context(), req.MediaFileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "metadata_error", "Failed to look up media metadata") + return + } + if meta == nil { + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + return + } + + releaseInfo := subtitles.ParseReleaseInfo(meta.FilePath) + searchReq := subtitles.SearchRequest{ + IMDbID: meta.IMDbID, + Title: meta.Title, + Year: meta.Year, + Season: meta.Season, + Episode: meta.Episode, + Languages: req.Languages, + Filename: filepath.Base(meta.FilePath), + FileHash: meta.FileHash, + MediaInfo: &subtitles.MediaMatchInfo{ + ReleaseGroup: releaseInfo.ReleaseGroup, + Resolution: firstNonEmpty(meta.Resolution, releaseInfo.Resolution), + VideoCodec: firstNonEmpty(meta.VideoCodec, releaseInfo.VideoCodec), + AudioCodec: firstNonEmpty(meta.AudioCodec, releaseInfo.AudioCodec), + Source: releaseInfo.Source, + }, + } + + resp, err := h.manager.Search(r.Context(), searchReq) + if err != nil { + writeError(w, http.StatusInternalServerError, "search_error", "Subtitle search failed") + return + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleDownload handles POST /api/v1/subtitles/download +func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Request) { + var req downloadSubtitleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + userID := apimw.GetUserID(r.Context()) + + sub, err := h.manager.Download(r.Context(), subtitles.DownloadRequest{ + ProviderName: req.Provider, + SubtitleID: req.SubtitleID, + MediaFileID: req.MediaFileID, + UserID: &userID, + Language: req.Language, + ReleaseName: req.ReleaseName, + Score: req.Score, + HearingImpaired: req.HearingImpaired, + }) + if err != nil { + slog.Error("subtitle download failed", "provider", req.Provider, "subtitle_id", req.SubtitleID, "error", err) + writeError(w, http.StatusInternalServerError, "download_error", "Failed to download subtitle") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub}) +} + +// HandleList handles GET /api/v1/subtitles/{media_file_id} +func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Request) { + mediaFileID, err := strconv.Atoi(chi.URLParam(r, "media_file_id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "Invalid media file ID") + return + } + + subs, err := h.repo.ListDownloadedSubtitles(r.Context(), mediaFileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "list_error", "Failed to list subtitles") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"subtitles": subs}) +} + +// HandleDelete handles DELETE /api/v1/subtitles/{id} +func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID") + return + } + + sub, err := h.repo.GetDownloadedSubtitle(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "lookup_error", "Failed to look up subtitle") + return + } + if sub == nil { + writeError(w, http.StatusNotFound, "not_found", "Subtitle not found") + return + } + + claims := apimw.GetClaims(r.Context()) + isAdmin := claims != nil && claims.Role == "admin" + isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID + if !isAdmin && !isOwner { + writeError(w, http.StatusForbidden, "forbidden", "Not authorized to delete this subtitle") + return + } + + if err := h.manager.DeleteSubtitle(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "delete_error", "Failed to delete subtitle") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/api/handlers/system.go b/internal/api/handlers/system.go new file mode 100644 index 00000000..40c058ca --- /dev/null +++ b/internal/api/handlers/system.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/Silo-Server/silo-server/internal/buildinfo" + "github.com/Silo-Server/silo-server/internal/nodepool" + "github.com/Silo-Server/silo-server/internal/playback" +) + +// SystemHandler serves read-only system inspection endpoints. +type SystemHandler struct { + transcodePool *nodepool.TranscodePool + jwtSecret string + buildInfo buildinfo.Info +} + +// NewSystemHandler creates a SystemHandler. +func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string) *SystemHandler { + return &SystemHandler{ + transcodePool: transcodePool, + jwtSecret: jwtSecret, + buildInfo: buildinfo.Current(), + } +} + +// HandleHWAccel handles GET /admin/system/hw-accel. +// When transcode nodes are registered it delegates to the first healthy node. +// Otherwise it probes the local host. +func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) { + if h.transcodePool != nil { + if node := h.transcodePool.Acquire(); node != nil { + info, err := h.fetchRemoteHWAccel(r.Context(), node) + if err == nil { + writeJSON(w, http.StatusOK, info) + return + } + slog.Warn("hw-accel: remote node probe failed, falling back to local", + "node", node.URL, "error", err) + } + } + + info := playback.DetectHWAccel() + writeJSON(w, http.StatusOK, info) +} + +// HandleBuildInfo handles GET /admin/system/build. +func (h *SystemHandler) HandleBuildInfo(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, h.buildInfo) +} + +func (h *SystemHandler) fetchRemoteHWAccel(ctx context.Context, node *nodepool.Node) (playback.HWAccelInfo, error) { + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, node.URL+"/hw-capabilities", nil) + if err != nil { + return playback.HWAccelInfo{}, err + } + req.Header.Set("Authorization", "Bearer "+h.jwtSecret) + + resp, err := client.Do(req) + if err != nil { + return playback.HWAccelInfo{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return playback.HWAccelInfo{}, fmt.Errorf("node returned %d", resp.StatusCode) + } + + var info playback.HWAccelInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return playback.HWAccelInfo{}, err + } + info.Source = "transcode_node" + info.NodeURL = node.URL + return info, nil +} diff --git a/internal/api/handlers/system_test.go b/internal/api/handlers/system_test.go new file mode 100644 index 00000000..f1dda9c1 --- /dev/null +++ b/internal/api/handlers/system_test.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/buildinfo" +) + +func TestSystemBuildInfoResponse(t *testing.T) { + t.Parallel() + + handler := &SystemHandler{ + buildInfo: buildinfo.Info{ + Display: "b4c5aae1+dirty", + Revision: "b4c5aae18aa653725ac697b29a05eac797576008", + Dirty: true, + VCSTime: "2026-04-05T22:24:40Z", + Available: true, + }, + } + + router := chi.NewRouter() + router.Get("/admin/system/build", handler.HandleBuildInfo) + + req := httptest.NewRequest(http.MethodGet, "/admin/system/build", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var got buildinfo.Info + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decoding response: %v", err) + } + + want := handler.buildInfo + if got != want { + t.Fatalf("response = %#v, want %#v", got, want) + } +} + +func TestSystemBuildInfoUnavailableResponseShape(t *testing.T) { + t.Parallel() + + handler := &SystemHandler{ + buildInfo: buildinfo.Info{ + Display: "unavailable", + Revision: "", + Dirty: false, + VCSTime: "", + Available: false, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/admin/system/build", nil) + rec := httptest.NewRecorder() + handler.HandleBuildInfo(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var raw map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decoding raw response: %v", err) + } + + expected := map[string]any{ + "display": "unavailable", + "revision": "", + "dirty": false, + "vcs_time": "", + "available": false, + } + + for key, want := range expected { + if got, ok := raw[key]; !ok || got != want { + t.Fatalf("response[%q] = %#v (present=%v), want %#v", key, got, ok, want) + } + } +} diff --git a/internal/api/handlers/task_metrics.go b/internal/api/handlers/task_metrics.go new file mode 100644 index 00000000..6f8b28c7 --- /dev/null +++ b/internal/api/handlers/task_metrics.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "context" + + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" +) + +type TaskMetricsService struct { + refreshDebtRepo *metadata.RefreshDebtRepository +} + +func NewTaskMetricsService(refreshDebtRepo *metadata.RefreshDebtRepository) *TaskMetricsService { + return &TaskMetricsService{refreshDebtRepo: refreshDebtRepo} +} + +func (s *TaskMetricsService) GetRefreshMetadataMetrics(ctx context.Context) (any, error) { + if s == nil || s.refreshDebtRepo == nil { + return &models.MetadataRefreshMetrics{}, nil + } + return s.refreshDebtRepo.GetMetrics(ctx, 10) +} diff --git a/internal/api/handlers/tasks.go b/internal/api/handlers/tasks.go new file mode 100644 index 00000000..948dfebb --- /dev/null +++ b/internal/api/handlers/tasks.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +const refreshMetadataTaskKey = "refresh_metadata" + +// TaskManagerAPI is the subset of TaskManager used by the handler. +type TaskManagerAPI interface { + ListTasks(includeHidden bool) []taskmanager.TaskInfo + GetTaskInfo(key string) taskmanager.TaskInfo + RunTask(ctx context.Context, key string) error + CancelTask(key string) error + UpdateTriggers(key string, triggers []taskmanager.TriggerConfig) error +} + +// TaskHistoryLister lists execution history for a task. +type TaskHistoryLister interface { + List(ctx context.Context, taskKey string, limit int) ([]taskmanager.ExecutionResult, error) +} + +type TaskMetricsProvider interface { + GetRefreshMetadataMetrics(ctx context.Context) (any, error) +} + +// TaskHandler handles REST API requests for /api/v1/admin/tasks. +type TaskHandler struct { + mgr TaskManagerAPI + history TaskHistoryLister + metrics TaskMetricsProvider +} + +// NewTaskHandler creates a new TaskHandler. +func NewTaskHandler(mgr TaskManagerAPI, history TaskHistoryLister, metrics TaskMetricsProvider) *TaskHandler { + return &TaskHandler{mgr: mgr, history: history, metrics: metrics} +} + +// HandleListTasks handles GET /api/v1/admin/tasks +func (h *TaskHandler) HandleListTasks(w http.ResponseWriter, r *http.Request) { + includeHidden := r.URL.Query().Get("include_hidden") == "true" + tasks := h.mgr.ListTasks(includeHidden) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tasks) +} + +// HandleGetTask handles GET /api/v1/admin/tasks/{key} +func (h *TaskHandler) HandleGetTask(w http.ResponseWriter, r *http.Request) { + key := chi.URLParam(r, "key") + info := h.mgr.GetTaskInfo(key) + if info.Key == "" { + http.Error(w, `{"error":"task not found"}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +// HandleRunTask handles POST /api/v1/admin/tasks/{key}/run +func (h *TaskHandler) HandleRunTask(w http.ResponseWriter, r *http.Request) { + key := chi.URLParam(r, "key") + + info := h.mgr.GetTaskInfo(key) + if info.Key == "" { + http.Error(w, `{"error":"task not found"}`, http.StatusNotFound) + return + } + + if info.State == taskmanager.TaskStateRunning || info.State == taskmanager.TaskStateCancelling { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"error": "task is already running"}) + return + } + + go h.mgr.RunTask(context.Background(), key) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]string{"status": "started"}) +} + +// HandleCancelTask handles POST /api/v1/admin/tasks/{key}/cancel +func (h *TaskHandler) HandleCancelTask(w http.ResponseWriter, r *http.Request) { + key := chi.URLParam(r, "key") + err := h.mgr.CancelTask(key) + if err != nil { + if errors.Is(err, taskmanager.ErrTaskNotFound) { + http.Error(w, `{"error":"task not found"}`, http.StatusNotFound) + return + } + if errors.Is(err, taskmanager.ErrTaskNotRunning) { + http.Error(w, `{"error":"task is not running"}`, http.StatusConflict) + return + } + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "cancelling"}) +} + +// HandleUpdateTriggers handles PUT /api/v1/admin/tasks/{key}/triggers +func (h *TaskHandler) HandleUpdateTriggers(w http.ResponseWriter, r *http.Request) { + key := chi.URLParam(r, "key") + + var triggers []taskmanager.TriggerConfig + if err := json.NewDecoder(r.Body).Decode(&triggers); err != nil { + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + + if err := h.mgr.UpdateTriggers(key, triggers); err != nil { + if errors.Is(err, taskmanager.ErrTaskNotFound) { + http.Error(w, `{"error":"task not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(h.mgr.GetTaskInfo(key)) +} + +// HandleGetHistory handles GET /api/v1/admin/tasks/{key}/history +func (h *TaskHandler) HandleGetHistory(w http.ResponseWriter, r *http.Request) { + key := chi.URLParam(r, "key") + + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + results, err := h.history.List(r.Context(), key, limit) + if err != nil { + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + if results == nil { + results = []taskmanager.ExecutionResult{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(results) +} + +func (h *TaskHandler) HandleGetMetrics(w http.ResponseWriter, r *http.Request) { + key := chi.URLParam(r, "key") + if key != refreshMetadataTaskKey || h.metrics == nil { + http.Error(w, `{"error":"task metrics not found"}`, http.StatusNotFound) + return + } + + metrics, err := h.metrics.GetRefreshMetadataMetrics(r.Context()) + if err != nil { + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(metrics) +} diff --git a/internal/api/handlers/taste_seed.go b/internal/api/handlers/taste_seed.go new file mode 100644 index 00000000..e5ca3204 --- /dev/null +++ b/internal/api/handlers/taste_seed.go @@ -0,0 +1,238 @@ +package handlers + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strconv" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/models" +) + +const ( + tasteSeedDefaultLimit = 30 + tasteSeedMaxLimit = 60 + tasteSeedMaxPicks = 200 +) + +type tasteSeedItemsResponse struct { + Items []sectionItemResponse `json:"items"` + NextOffset *int `json:"next_offset,omitempty"` +} + +type tasteSeedSubmitRequest struct { + ItemIDs []string `json:"item_ids"` +} + +type tasteSeedSubmitResponse struct { + Added int `json:"added"` +} + +// HandleTasteSeedItems handles GET /recommendations/taste-seed/items. +// +// Returns a paginated, hydrated list of "popular on this server" posters used +// for the new-user taste-seeding picker. Blends server-watched popularity with +// TMDB rating so fresh servers (no watch history yet) still surface meaningful +// content. The user_state field carries the existing is_favorite flag, so the +// UI can pre-select items the profile already favorited. +func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r *http.Request) { + if h.recsRepo == nil || h.Fetcher == nil { + writeJSON(w, http.StatusOK, tasteSeedItemsResponse{Items: []sectionItemResponse{}}) + return + } + + limit := parseTasteSeedLimit(r) + offset := parseTasteSeedOffset(r) + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + // Fetch a page-sized window of candidate IDs ordered by engagement + rating. + candidateIDs, err := h.recsRepo.GetTasteSeedCandidates(r.Context(), limit, offset) + if err != nil { + slog.Error("TasteSeedItems: candidate query failed", "user_id", userID, "profile_id", profileID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch taste seed candidates") + return + } + + if len(candidateIDs) == 0 { + writeJSON(w, http.StatusOK, tasteSeedItemsResponse{Items: []sectionItemResponse{}}) + return + } + + // Hydrate items, applying the user's library/content-rating access filter. + mediaItems, err := h.Fetcher.FetchItemsByContentIDs(r.Context(), candidateIDs, filter) + if err != nil { + slog.Error("TasteSeedItems: hydrate failed", "user_id", userID, "profile_id", profileID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to hydrate taste seed items") + return + } + + // Preserve the candidate ordering returned by the repo, since + // FetchItemsByContentIDs does not guarantee input order. + itemMap := make(map[string]*models.MediaItem, len(mediaItems)) + for _, mi := range mediaItems { + itemMap[mi.ContentID] = mi + } + + stateMap := h.resolveTasteSeedUserStates(r.Context(), userID, profileID, mediaItems) + + items := make([]sectionItemResponse, 0, len(candidateIDs)) + for _, id := range candidateIDs { + mi, ok := itemMap[id] + if !ok || mi == nil { + continue + } + items = append(items, h.tasteSeedSectionItem(r.Context(), mi, stateMap)) + } + + resp := tasteSeedItemsResponse{Items: items} + // Pagination is on the underlying SQL candidate stream (offset/limit on + // GetTasteSeedCandidates), so we gate on candidate page fullness, not on + // post-hydration visible count. Comparing on `items` would incorrectly + // terminate pagination whenever access filtering trims the visible page — + // even though more candidate rows exist. The trade-off is that pathologically + // filtered tails may produce one extra empty fetch before next_offset goes + // nil; the infinite-query consumer handles that gracefully. + if len(candidateIDs) == limit { + next := offset + limit + resp.NextOffset = &next + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleTasteSeed handles POST /recommendations/taste-seed. +// +// Adds each of the provided content IDs to the active profile's favorites +// (idempotent — re-adds are safe). After bulk-add, asynchronously requests a +// taste profile refresh so recommendations re-rank using the new signals. +// Already-favorited items are silently skipped. +func (h *RecommendationsHandler) HandleTasteSeed(w http.ResponseWriter, r *http.Request) { + if h.storeProvider == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "User store unavailable") + return + } + + var req tasteSeedSubmitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if len(req.ItemIDs) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "At least one item_id is required") + return + } + if len(req.ItemIDs) > tasteSeedMaxPicks { + writeError(w, http.StatusBadRequest, "bad_request", "Too many items in a single request") + return + } + + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil || store == nil { + slog.Error("TasteSeed: failed to load user store", "user_id", userID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user store") + return + } + + added := 0 + for _, id := range req.ItemIDs { + if id == "" { + continue + } + if err := store.AddFavorite(r.Context(), profileID, id); err != nil { + // Don't fail the whole request on a single item error — log and + // continue so the user's other picks still seed their profile. + slog.Warn("TasteSeed: failed to add favorite", "user_id", userID, "profile_id", profileID, "item_id", id, "error", err) + continue + } + added++ + } + + // Trigger an async taste profile refresh so the next discover/for-you + // fetch sees the new signals. This is fire-and-forget by design — the + // worker handles staleness. + if added > 0 { + var staler ProfileStaler + if h.recsRepo != nil { + staler = h.recsRepo + } + triggerProfileRefresh(r.Context(), staler, h.RecWorker, userID, profileID) + } + + writeJSON(w, http.StatusOK, tasteSeedSubmitResponse{Added: added}) +} + +// resolveTasteSeedUserStates returns the per-item user state map (favorite, +// watchlist, played) so the UI can pre-mark items the profile already +// favorited. Returns nil on any error — the UI falls back to "not favorited" +// in that case. +func (h *RecommendationsHandler) resolveTasteSeedUserStates(ctx context.Context, userID int, profileID string, mediaItems []*models.MediaItem) map[string]*itemUserStateResponse { + if h.storeProvider == nil || len(mediaItems) == 0 { + return nil + } + store, err := h.storeProvider.ForUser(ctx, userID) + if err != nil || store == nil { + return nil + } + states, err := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems) + if err != nil { + return nil + } + return states +} + +// tasteSeedSectionItem builds the trimmed section item used by the seed grid. +// We only populate poster fields — the picker doesn't need overlays, ratings, +// or progress. +func (h *RecommendationsHandler) tasteSeedSectionItem(ctx context.Context, mi *models.MediaItem, stateMap map[string]*itemUserStateResponse) sectionItemResponse { + item := sectionItemResponse{ + ContentID: mi.ContentID, + Type: mi.Type, + Title: mi.Title, + Year: mi.Year, + Genres: mi.Genres, + Status: mi.Status, + PosterThumbhash: mi.PosterThumbhash, + } + if item.Genres == nil { + item.Genres = []string{} + } + if item.Keywords == nil { + item.Keywords = []string{} + } + if h.DetailSvc != nil { + item.PosterURL = h.DetailSvc.PresignURL(ctx, cardThumbnailPath(mi.PosterPath), "card") + } + if stateMap != nil { + item.UserState = stateMap[mi.ContentID] + } + return item +} + +func parseTasteSeedLimit(r *http.Request) int { + if v := r.URL.Query().Get("limit"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + if parsed > tasteSeedMaxLimit { + return tasteSeedMaxLimit + } + return parsed + } + } + return tasteSeedDefaultLimit +} + +func parseTasteSeedOffset(r *http.Request) int { + if v := r.URL.Query().Get("offset"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 { + return parsed + } + } + return 0 +} diff --git a/internal/api/handlers/theme.go b/internal/api/handlers/theme.go new file mode 100644 index 00000000..76d5436a --- /dev/null +++ b/internal/api/handlers/theme.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "sync" + "time" +) + +// ThemeSettingsReader is the subset of ServerSettingsStore needed by ThemeHandler. +type ThemeSettingsReader interface { + Get(ctx context.Context, key string) (string, error) +} + +// ThemeHandler serves theme-related public endpoints. +type ThemeHandler struct { + settings ThemeSettingsReader + + // Catalog proxy cache. + catalogMu sync.RWMutex + catalogCache []byte + catalogFetched time.Time + httpClient *http.Client +} + +// NewThemeHandler creates a ThemeHandler. +func NewThemeHandler(settings ThemeSettingsReader) *ThemeHandler { + return &ThemeHandler{ + settings: settings, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// adminCssResponse is returned by GET /theme/admin-css. +type adminCssResponse struct { + Vars string `json:"vars"` + RawCSS string `json:"raw_css"` +} + +// HandleAdminCSS returns the server-wide admin theme overrides. +// Public endpoint — no authentication required. This allows admin +// branding to apply before login (white-label). +func (h *ThemeHandler) HandleAdminCSS(w http.ResponseWriter, r *http.Request) { + vars, _ := h.settings.Get(r.Context(), "ui.admin_theme_vars") + rawCSS, _ := h.settings.Get(r.Context(), "ui.admin_custom_css") + + w.Header().Set("Cache-Control", "public, max-age=60") + writeJSON(w, http.StatusOK, adminCssResponse{ + Vars: vars, + RawCSS: rawCSS, + }) +} + +// brandingResponse is returned by GET /theme/branding. +type brandingResponse struct { + ServerName string `json:"server_name"` + LoginSubtitle string `json:"login_subtitle"` +} + +// HandleBranding returns the server branding settings. +// Public endpoint — no authentication required so branding appears on the login page. +func (h *ThemeHandler) HandleBranding(w http.ResponseWriter, r *http.Request) { + serverName, _ := h.settings.Get(r.Context(), "branding.server_name") + loginSubtitle, _ := h.settings.Get(r.Context(), "branding.login_subtitle") + + w.Header().Set("Cache-Control", "public, max-age=60") + writeJSON(w, http.StatusOK, brandingResponse{ + ServerName: serverName, + LoginSubtitle: loginSubtitle, + }) +} + +// allowedDownloadHosts restricts which hosts the theme download proxy will fetch from. +var allowedDownloadHosts = map[string]bool{ + "raw.githubusercontent.com": true, + "github.com": true, + "objects.githubusercontent.com": true, +} + +// HandleDownload proxies a theme file download from an allowed host. +// This prevents the browser from directly fetching arbitrary URLs (SSRF). +func (h *ThemeHandler) HandleDownload(w http.ResponseWriter, r *http.Request) { + rawURL := r.URL.Query().Get("url") + if rawURL == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Missing url parameter") + return + } + + parsed, err := url.Parse(rawURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid URL") + return + } + + if !allowedDownloadHosts[parsed.Hostname()] { + writeError(w, http.StatusForbidden, "host_not_allowed", "Theme downloads are only allowed from approved hosts") + return + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, rawURL, nil) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Failed to create request") + return + } + + resp, err := h.httpClient.Do(req) + if err != nil { + writeError(w, http.StatusBadGateway, "download_failed", "Failed to fetch theme file") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + writeError(w, http.StatusBadGateway, "download_failed", "Theme file returned non-200") + return + } + + // Limit to 256 KB to prevent abuse. + body, err := io.ReadAll(io.LimitReader(resp.Body, 256<<10)) + if err != nil { + writeError(w, http.StatusBadGateway, "download_failed", "Failed to read theme file") + return + } + + if !json.Valid(body) { + writeError(w, http.StatusBadGateway, "download_invalid", "Theme file is not valid JSON") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +const ( + defaultCatalogURL = "https://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json" + catalogCacheTTL = 1 * time.Hour +) + +// writeStaleCatalog serves a cached catalog response with the stale header. +func writeStaleCatalog(w http.ResponseWriter, cached []byte) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Theme-Catalog-Stale", "true") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(cached) +} + +// HandleCatalog proxies the theme catalog from a remote URL with caching. +func (h *ThemeHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { + // Check cache first. + h.catalogMu.RLock() + cached := h.catalogCache + age := time.Since(h.catalogFetched) + h.catalogMu.RUnlock() + + if cached != nil && age < catalogCacheTTL { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(cached) + return + } + + // Determine catalog URL from server settings. + catalogURL, _ := h.settings.Get(r.Context(), "theme.catalog_url") + if catalogURL == "" { + catalogURL = defaultCatalogURL + } + + // Fetch from upstream. + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, catalogURL, nil) + if err != nil { + if cached != nil { + writeStaleCatalog(w, cached) + return + } + writeError(w, http.StatusServiceUnavailable, "catalog_unavailable", "Theme catalog is unavailable") + return + } + + resp, err := h.httpClient.Do(req) + if err != nil { + if cached != nil { + writeStaleCatalog(w, cached) + return + } + writeError(w, http.StatusServiceUnavailable, "catalog_unavailable", "Theme catalog is unavailable") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if cached != nil { + writeStaleCatalog(w, cached) + return + } + writeError(w, http.StatusServiceUnavailable, "catalog_unavailable", "Theme catalog returned non-200") + return + } + + // Read body (limit to 1 MB). + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + writeError(w, http.StatusBadGateway, "catalog_read_error", "Failed to read catalog response") + return + } + + // Validate JSON. + if !json.Valid(body) { + writeError(w, http.StatusBadGateway, "catalog_invalid", "Catalog response is not valid JSON") + return + } + + // Cache the response. + h.catalogMu.Lock() + h.catalogCache = body + h.catalogFetched = time.Now() + h.catalogMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +// HandleCatalogRefresh clears the cached catalog so the next request fetches fresh from upstream. +func (h *ThemeHandler) HandleCatalogRefresh(w http.ResponseWriter, r *http.Request) { + h.catalogMu.Lock() + h.catalogCache = nil + h.catalogFetched = time.Time{} + h.catalogMu.Unlock() + + // Immediately fetch fresh data so the caller gets the updated catalog. + h.HandleCatalog(w, r) +} diff --git a/internal/api/handlers/user_collection_imports.go b/internal/api/handlers/user_collection_imports.go new file mode 100644 index 00000000..c8588a50 --- /dev/null +++ b/internal/api/handlers/user_collection_imports.go @@ -0,0 +1,330 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/collections/templates" + "github.com/Silo-Server/silo-server/internal/mdblist" + "github.com/Silo-Server/silo-server/internal/usercollections" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// UserCollectionImportHandler exposes the user-side template gallery and +// import + sync endpoints. Authorization rules (only the creator can sync) are +// enforced here; the underlying sync.Service is intentionally unauthenticated. +type UserCollectionImportHandler struct { + storeProvider userstore.UserStoreProvider + sync *usercollections.Service + scheduler *usercollections.Scheduler + registry *templates.Registry + mdblist *mdblist.Client +} + +func NewUserCollectionImportHandler( + provider userstore.UserStoreProvider, + sync *usercollections.Service, + scheduler *usercollections.Scheduler, + registry *templates.Registry, + mdblistClient *mdblist.Client, +) *UserCollectionImportHandler { + if registry == nil { + registry = templates.Default + } + return &UserCollectionImportHandler{ + storeProvider: provider, + sync: sync, + scheduler: scheduler, + registry: registry, + mdblist: mdblistClient, + } +} + +func (h *UserCollectionImportHandler) HandleListTemplates(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, h.registry.Catalog()) +} + +type userImportSharedFields struct { + Title string `json:"title"` + Description string `json:"description"` + Limit *int `json:"limit,omitempty"` + SyncSchedule string `json:"sync_schedule"` + IsShared bool `json:"is_shared"` + LibraryIDs []int `json:"library_ids,omitempty"` +} + +type userImportMDBListRequest struct { + userImportSharedFields + URL string `json:"url"` +} + +type userImportTMDBRequest struct { + userImportSharedFields + Preset string `json:"preset"` + MediaType string `json:"media_type"` + TimeWindow string `json:"time_window"` +} + +type userImportTraktRequest struct { + userImportSharedFields + Preset string `json:"preset"` + MediaType string `json:"media_type"` +} + +type userImportResponse struct { + Collection collectionResponse `json:"collection"` + Sync *usercollections.SyncResult `json:"sync,omitempty"` +} + +func (h *UserCollectionImportHandler) HandleImportMDBList(w http.ResponseWriter, r *http.Request) { + var req userImportMDBListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.URL) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "title and url are required") + return + } + if !validateOptionalLimit(req.Limit, w) { + return + } + cfg := usercollections.SourceConfig{ + Mode: usercollections.SourceModeMDBList, + URL: usercollections.NormalizeMDBListURL(req.URL), + Limit: req.Limit, + LibraryIDs: req.LibraryIDs, + } + h.createImportedCollection(w, r, "mdblist", cfg, req.userImportSharedFields) +} + +func (h *UserCollectionImportHandler) HandleImportTMDB(w http.ResponseWriter, r *http.Request) { + var req userImportTMDBRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if strings.TrimSpace(req.Title) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "title is required") + return + } + preset, mediaType, timeWindow, err := normalizeTMDBPresetRequest(req.Preset, req.MediaType, req.TimeWindow) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if !validateOptionalLimit(req.Limit, w) { + return + } + cfg := usercollections.SourceConfig{ + Mode: usercollections.SourceModeTMDBPreset, + Preset: preset, + MediaType: mediaType, + TimeWindow: timeWindow, + Limit: req.Limit, + LibraryIDs: req.LibraryIDs, + } + h.createImportedCollection(w, r, "tmdb", cfg, req.userImportSharedFields) +} + +func (h *UserCollectionImportHandler) HandleImportTrakt(w http.ResponseWriter, r *http.Request) { + var req userImportTraktRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if strings.TrimSpace(req.Title) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "title is required") + return + } + profileID := apimw.GetProfileID(r.Context()) + preset, mediaType, normalizedProfileID, err := normalizeTraktPresetRequest(req.Preset, req.MediaType, profileID) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if !validateOptionalLimit(req.Limit, w) { + return + } + cfg := usercollections.SourceConfig{ + Mode: usercollections.SourceModeTraktPreset, + Provider: "trakt", + Preset: preset, + MediaType: mediaType, + ProfileID: normalizedProfileID, + Limit: req.Limit, + LibraryIDs: req.LibraryIDs, + } + h.createImportedCollection(w, r, "trakt", cfg, req.userImportSharedFields) +} + +func (h *UserCollectionImportHandler) createImportedCollection( + w http.ResponseWriter, + r *http.Request, + collectionType string, + cfg usercollections.SourceConfig, + shared userImportSharedFields, +) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + + schedule, err := usercollections.ResolveSyncSchedule(shared.SyncSchedule) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + sourceConfigJSON, err := usercollections.MarshalSourceConfig(cfg) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to encode source config") + return + } + + collection, err := store.CreateCollection(r.Context(), userstore.CreateCollectionInput{ + CreatorProfileID: profileID, + Name: strings.TrimSpace(shared.Title), + Description: strings.TrimSpace(shared.Description), + CollectionType: collectionType, + IsShared: shared.IsShared, + QueryDefinition: "{}", + SortConfig: "{}", + SourceURL: cfg.DisplayURL(), + SourceConfig: sourceConfigJSON, + SyncSchedule: schedule, + NextSyncAt: usercollections.InitialNextSyncAt(schedule), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection") + return + } + + syncResult, updated, syncErr := h.sync.RunSync(r.Context(), store, collection) + if syncErr != nil { + // Persist failure state inline so the UI shows the error and the user + // can retry; the row is intentionally kept around for that retry path. + _ = store.UpdateCollectionSyncState(r.Context(), userstore.UpdateCollectionSyncStateInput{ + ID: collection.ID, + Status: "failed", + Message: syncErr.Error(), + LastSyncAt: time.Now().UTC(), + NextSyncAt: usercollections.InitialNextSyncAt(schedule), + }) + updated = collection + updated.LastSyncStatus = "failed" + updated.LastSyncMessage = syncErr.Error() + } + + writeJSON(w, http.StatusCreated, userImportResponse{ + Collection: toCollectionResponse(*updated), + Sync: syncResult, + }) +} + +func (h *UserCollectionImportHandler) HandleSync(w http.ResponseWriter, r *http.Request) { + collectionID := chi.URLParam(r, "id") + if collectionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required") + return + } + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + + store, err := h.storeProvider.ForUser(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") + return + } + collection, err := store.GetCollection(r.Context(), collectionID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "Collection not found") + return + } + if collection.CreatorProfileID != profileID { + writeError(w, http.StatusForbidden, "forbidden", "Only the creator can sync this collection") + return + } + if h.scheduler != nil && h.scheduler.IsInFlight(collectionID) { + writeError(w, http.StatusConflict, "sync_in_flight", "A sync is already running for this collection") + return + } + + result, _, err := h.sync.RunSync(r.Context(), store, collection) + if err != nil { + if errors.Is(err, usercollections.ErrSyncUnsupported) { + writeError(w, http.StatusBadRequest, "bad_request", "This collection does not support sync") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("Sync failed: %v", err)) + return + } + writeJSON(w, http.StatusOK, result) +} + +type mdblistDiscoveryResponse struct { + Configured bool `json:"configured"` + Lists []mdblist.ListSummary `json:"lists"` +} + +// mdblistConfigured returns true when the discovery client is usable. When +// false it has already written a "not configured" 200 response so callers +// can simply early-return. +func (h *UserCollectionImportHandler) mdblistConfigured(w http.ResponseWriter) bool { + if h.mdblist != nil && h.mdblist.Configured() { + return true + } + writeJSON(w, http.StatusOK, mdblistDiscoveryResponse{Configured: false, Lists: []mdblist.ListSummary{}}) + return false +} + +func (h *UserCollectionImportHandler) HandleSearchMDBList(w http.ResponseWriter, r *http.Request) { + if !h.mdblistConfigured(w) { + return + } + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + writeError(w, http.StatusBadRequest, "bad_request", "q is required") + return + } + lists, err := h.mdblist.Search(r.Context(), query) + if err != nil { + writeError(w, http.StatusBadGateway, "upstream_error", fmt.Sprintf("MDBList search failed: %v", err)) + return + } + writeJSON(w, http.StatusOK, mdblistDiscoveryResponse{Configured: true, Lists: lists}) +} + +func (h *UserCollectionImportHandler) HandleTopMDBList(w http.ResponseWriter, r *http.Request) { + if !h.mdblistConfigured(w) { + return + } + lists, err := h.mdblist.Top(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "upstream_error", fmt.Sprintf("MDBList top failed: %v", err)) + return + } + writeJSON(w, http.StatusOK, mdblistDiscoveryResponse{Configured: true, Lists: lists}) +} + +func validateOptionalLimit(limit *int, w http.ResponseWriter) bool { + if limit == nil { + return true + } + if *limit <= 0 || *limit > 200 { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be between 1 and 200") + return false + } + return true +} diff --git a/internal/api/handlers/user_state.go b/internal/api/handlers/user_state.go new file mode 100644 index 00000000..6857a73f --- /dev/null +++ b/internal/api/handlers/user_state.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "context" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +type itemUserStateResponse struct { + Played bool `json:"played"` + IsFavorite bool `json:"is_favorite"` + InWatchlist bool `json:"in_watchlist"` +} + +func resolveItemUserStates( + ctx context.Context, + store userstore.UserStore, + profileID string, + episodeRepo *catalog.EpisodeRepository, + items []*models.MediaItem, +) (map[string]*itemUserStateResponse, error) { + result := make(map[string]*itemUserStateResponse, len(items)) + if store == nil || profileID == "" || len(items) == 0 { + return result, nil + } + + contentIDs := make([]string, 0, len(items)) + seriesIDs := make([]string, 0) + seasonIDs := make([]string, 0) + seenContent := make(map[string]struct{}, len(items)) + seenSeries := make(map[string]struct{}) + seenSeason := make(map[string]struct{}) + + for _, item := range items { + if item == nil || item.ContentID == "" { + continue + } + if _, ok := seenContent[item.ContentID]; ok { + continue + } + seenContent[item.ContentID] = struct{}{} + contentIDs = append(contentIDs, item.ContentID) + switch item.Type { + case "series": + if _, ok := seenSeries[item.ContentID]; !ok { + seenSeries[item.ContentID] = struct{}{} + seriesIDs = append(seriesIDs, item.ContentID) + } + case "season": + if _, ok := seenSeason[item.ContentID]; !ok { + seenSeason[item.ContentID] = struct{}{} + seasonIDs = append(seasonIDs, item.ContentID) + } + } + } + + favoriteMap, err := store.ListFavoritesByMediaItems(ctx, profileID, contentIDs) + if err != nil { + return nil, err + } + watchlistMap, err := store.ListWatchlistByMediaItems(ctx, profileID, contentIDs) + if err != nil { + return nil, err + } + + progressIDs := append([]string{}, contentIDs...) + seriesEpisodes := map[string][]*models.Episode{} + seasonEpisodes := map[string][]*models.Episode{} + if episodeRepo != nil { + if len(seriesIDs) > 0 { + seriesEpisodes, err = episodeRepo.ListBySeriesIDs(ctx, seriesIDs) + if err != nil { + return nil, err + } + for _, episodes := range seriesEpisodes { + for _, episode := range episodes { + if episode == nil || episode.ContentID == "" { + continue + } + if _, ok := seenContent[episode.ContentID]; ok { + continue + } + seenContent[episode.ContentID] = struct{}{} + progressIDs = append(progressIDs, episode.ContentID) + } + } + } + if len(seasonIDs) > 0 { + seasonEpisodes, err = episodeRepo.ListBySeasonIDs(ctx, seasonIDs) + if err != nil { + return nil, err + } + for _, episodes := range seasonEpisodes { + for _, episode := range episodes { + if episode == nil || episode.ContentID == "" { + continue + } + if _, ok := seenContent[episode.ContentID]; ok { + continue + } + seenContent[episode.ContentID] = struct{}{} + progressIDs = append(progressIDs, episode.ContentID) + } + } + } + } + + progressMap, err := store.ListProgressByMediaItems(ctx, profileID, progressIDs) + if err != nil { + return nil, err + } + + for _, item := range items { + if item == nil || item.ContentID == "" { + continue + } + state := &itemUserStateResponse{ + IsFavorite: favoriteMap[item.ContentID], + InWatchlist: watchlistMap[item.ContentID], + } + switch item.Type { + case "series": + state.Played = allEpisodesCompleted(seriesEpisodes[item.ContentID], progressMap) + case "season": + state.Played = allEpisodesCompleted(seasonEpisodes[item.ContentID], progressMap) + default: + state.Played = progressMap[item.ContentID].Completed + } + result[item.ContentID] = state + } + + return result, nil +} + +func allEpisodesCompleted(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) bool { + if len(episodes) == 0 { + return false + } + for _, episode := range episodes { + if episode == nil || episode.ContentID == "" { + return false + } + progress, ok := progressMap[episode.ContentID] + if !ok || !progress.Completed { + return false + } + } + return true +} diff --git a/internal/api/handlers/user_state_events.go b/internal/api/handlers/user_state_events.go new file mode 100644 index 00000000..24c17b5e --- /dev/null +++ b/internal/api/handlers/user_state_events.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "context" + + evt "github.com/Silo-Server/silo-server/internal/events" +) + +type userStateEventPayload struct { + ProfileID string `json:"profile_id"` + ContentID string `json:"content_id,omitempty"` + SeriesID string `json:"series_id,omitempty"` + Change string `json:"change"` +} + +func publishUserStateEvent( + ctx context.Context, + hub *evt.Hub, + userID int, + profileID, contentID, seriesID, change, eventName string, +) { + if hub == nil || userID == 0 || profileID == "" || change == "" || eventName == "" { + return + } + _ = hub.PublishJSON(ctx, evt.ChannelUserState, eventName, userStateEventPayload{ + ProfileID: profileID, + ContentID: contentID, + SeriesID: seriesID, + Change: change, + }, evt.PublishOptions{ + UserID: userID, + ProfileID: profileID, + }) +} diff --git a/internal/api/handlers/watch_providers.go b/internal/api/handlers/watch_providers.go new file mode 100644 index 00000000..d64c9e8a --- /dev/null +++ b/internal/api/handlers/watch_providers.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/watchsync" +) + +type WatchProviderService interface { + ListProviders() []watchsync.ProviderSummary + StartDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string) (watchsync.DeviceAuthSession, error) + PollDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string, sessionID string) (watchsync.Connection, error) + ConnectAPIKey(ctx context.Context, userID int, profileID string, providerKey string, apiKey string) (watchsync.Connection, error) + GetConnectionStatus(ctx context.Context, userID int, profileID string, provider string) (watchsync.ConnectionStatus, error) + UpdateConnection(ctx context.Context, userID int, profileID string, provider string, update watchsync.ConnectionUpdate) (watchsync.ConnectionStatus, error) + DeleteConnection(ctx context.Context, userID int, profileID string, provider string) error + RequestManualSync(ctx context.Context, userID int, profileID string, provider string) (watchsync.ManualSyncResult, error) + ListSyncRuns(ctx context.Context, userID int, profileID string, provider string, limit int) ([]watchsync.SyncRun, error) +} + +type WatchProviderHandler struct { + service WatchProviderService +} + +func NewWatchProviderHandler(service WatchProviderService) *WatchProviderHandler { + return &WatchProviderHandler{service: service} +} + +func (h *WatchProviderHandler) HandleListProviders(w http.ResponseWriter, r *http.Request) { + if h == nil || h.service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Watch provider service is not configured") + return + } + + providers := h.service.ListProviders() + if providers == nil { + providers = []watchsync.ProviderSummary{} + } + + writeJSON(w, http.StatusOK, struct { + Providers []watchsync.ProviderSummary `json:"providers"` + }{ + Providers: providers, + }) +} + +func (h *WatchProviderHandler) HandleGetConnection(w http.ResponseWriter, r *http.Request) { + status, ok := h.connectionStatus(w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, status) +} + +func (h *WatchProviderHandler) HandleUpdateConnection(w http.ResponseWriter, r *http.Request) { + userID, profileID, provider, ok := watchProviderRequestScope(w, r) + if !ok { + return + } + var update watchsync.ConnectionUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + status, err := h.service.UpdateConnection(r.Context(), userID, profileID, provider, update) + if err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (h *WatchProviderHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.Request) { + userID, profileID, provider, ok := watchProviderRequestScope(w, r) + if !ok { + return + } + if err := h.service.DeleteConnection(r.Context(), userID, profileID, provider); err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *WatchProviderHandler) HandleStartDeviceAuth(w http.ResponseWriter, r *http.Request) { + userID, profileID, provider, ok := watchProviderRequestScope(w, r) + if !ok { + return + } + session, err := h.service.StartDeviceAuth(r.Context(), userID, profileID, provider) + if err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, session) +} + +func (h *WatchProviderHandler) HandlePollDeviceAuth(w http.ResponseWriter, r *http.Request) { + userID, profileID, provider, ok := watchProviderRequestScope(w, r) + if !ok { + return + } + var req struct { + AuthSessionID string `json:"auth_session_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if _, err := h.service.PollDeviceAuth(r.Context(), userID, profileID, provider, req.AuthSessionID); err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + status, err := h.service.GetConnectionStatus(r.Context(), userID, profileID, provider) + if err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (h *WatchProviderHandler) HandleConnectAPIKey(w http.ResponseWriter, r *http.Request) { + userID, profileID, provider, ok := watchProviderRequestScope(w, r) + if !ok { + return + } + var req struct { + APIKey string `json:"api_key"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if _, err := h.service.ConnectAPIKey(r.Context(), userID, profileID, provider, req.APIKey); err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + status, err := h.service.GetConnectionStatus(r.Context(), userID, profileID, provider) + if err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (h *WatchProviderHandler) HandleManualSync(w http.ResponseWriter, r *http.Request) { + userID, profileID, provider, ok := watchProviderRequestScope(w, r) + if !ok { + return + } + result, err := h.service.RequestManualSync(r.Context(), userID, profileID, provider) + if err != nil { + var cooldown watchsync.SyncCooldownError + if errors.As(err, &cooldown) { + w.Header().Set("Retry-After", strconv.Itoa(cooldown.RetryAfterSeconds)) + writeJSON(w, http.StatusTooManyRequests, struct { + Error string `json:"error"` + Message string `json:"message"` + RetryAfterSeconds int `json:"retry_after_seconds"` + }{ + Error: "sync_cooldown", + Message: "Watch provider sync recently ran. Try again later.", + RetryAfterSeconds: cooldown.RetryAfterSeconds, + }) + return + } + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + writeJSON(w, http.StatusAccepted, result) +} + +func (h *WatchProviderHandler) HandleListSyncRuns(w http.ResponseWriter, r *http.Request) { + userID, profileID, provider, ok := watchProviderRequestScope(w, r) + if !ok { + return + } + limit := 10 + if raw := r.URL.Query().Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid limit") + return + } + limit = parsed + } + runs, err := h.service.ListSyncRuns(r.Context(), userID, profileID, provider, limit) + if err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return + } + if runs == nil { + runs = []watchsync.SyncRun{} + } + writeJSON(w, http.StatusOK, struct { + Runs []watchsync.SyncRun `json:"runs"` + }{Runs: runs}) +} + +func (h *WatchProviderHandler) connectionStatus(w http.ResponseWriter, r *http.Request) (watchsync.ConnectionStatus, bool) { + userID, profileID, provider, ok := watchProviderRequestScope(w, r) + if !ok { + return watchsync.ConnectionStatus{}, false + } + status, err := h.service.GetConnectionStatus(r.Context(), userID, profileID, provider) + if err != nil { + writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error()) + return watchsync.ConnectionStatus{}, false + } + return status, true +} + +func watchProviderRequestScope(w http.ResponseWriter, r *http.Request) (int, string, string, bool) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + provider := chi.URLParam(r, "provider") + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return 0, "", "", false + } + if profileID == "" { + writeError(w, http.StatusBadRequest, "profile_required", "Profile is required") + return 0, "", "", false + } + if provider == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Provider is required") + return 0, "", "", false + } + return userID, profileID, provider, true +} diff --git a/internal/api/handlers/watch_providers_test.go b/internal/api/handlers/watch_providers_test.go new file mode 100644 index 00000000..0a4e4e99 --- /dev/null +++ b/internal/api/handlers/watch_providers_test.go @@ -0,0 +1,277 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/watchsync" +) + +type stubWatchProviderService struct { + providers []watchsync.ProviderSummary + session watchsync.DeviceAuthSession + manual watchsync.ManualSyncResult + manualErr error + runs []watchsync.SyncRun +} + +func (s stubWatchProviderService) ListProviders() []watchsync.ProviderSummary { + return s.providers +} +func (s stubWatchProviderService) StartDeviceAuth(context.Context, int, string, string) (watchsync.DeviceAuthSession, error) { + return s.session, nil +} +func (s stubWatchProviderService) PollDeviceAuth(context.Context, int, string, string, string) (watchsync.Connection, error) { + return watchsync.Connection{}, nil +} +func (s stubWatchProviderService) ConnectAPIKey(context.Context, int, string, string, string) (watchsync.Connection, error) { + return watchsync.Connection{}, nil +} +func (s stubWatchProviderService) GetConnectionStatus(context.Context, int, string, string) (watchsync.ConnectionStatus, error) { + return watchsync.ConnectionStatus{}, nil +} +func (s stubWatchProviderService) UpdateConnection(context.Context, int, string, string, watchsync.ConnectionUpdate) (watchsync.ConnectionStatus, error) { + return watchsync.ConnectionStatus{}, nil +} +func (s stubWatchProviderService) DeleteConnection(context.Context, int, string, string) error { + return nil +} +func (s stubWatchProviderService) RequestManualSync(context.Context, int, string, string) (watchsync.ManualSyncResult, error) { + return s.manual, s.manualErr +} +func (s stubWatchProviderService) ListSyncRuns(context.Context, int, string, string, int) ([]watchsync.SyncRun, error) { + return s.runs, nil +} + +func TestWatchProviderHandlerListsProviders(t *testing.T) { + service := stubWatchProviderService{ + providers: []watchsync.ProviderSummary{ + { + Key: "trakt", + DisplayName: "Trakt", + Capabilities: watchsync.Capabilities{ + ImportWatched: true, + ImportProgress: true, + ExportWatched: true, + ScrobblePlayback: true, + }, + }, + }, + } + handler := NewWatchProviderHandler(service) + + req := httptest.NewRequest(http.MethodGet, "/watch-providers/", nil) + rec := httptest.NewRecorder() + + handler.HandleListProviders(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("content-type = %q, want application/json", got) + } + + var resp struct { + Providers []watchsync.ProviderSummary `json:"providers"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Providers) != 1 { + t.Fatalf("providers length = %d, want 1", len(resp.Providers)) + } + if resp.Providers[0] != service.providers[0] { + t.Fatalf("provider = %#v, want %#v", resp.Providers[0], service.providers[0]) + } +} + +func TestWatchProviderHandlerReturnsEmptyProviderList(t *testing.T) { + handler := NewWatchProviderHandler(stubWatchProviderService{}) + + req := httptest.NewRequest(http.MethodGet, "/watch-providers/", nil) + rec := httptest.NewRecorder() + + handler.HandleListProviders(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Providers []watchsync.ProviderSummary `json:"providers"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Providers == nil { + t.Fatal("providers = nil, want empty slice") + } + if len(resp.Providers) != 0 { + t.Fatalf("providers length = %d, want 0", len(resp.Providers)) + } +} + +func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T) { + expiresAt := time.Date(2026, 5, 4, 15, 57, 8, 0, time.UTC) + handler := NewWatchProviderHandler(stubWatchProviderService{ + session: watchsync.DeviceAuthSession{ + ID: "auth-1", + Provider: "trakt", + UserID: 7, + ProfileID: "profile-1", + DeviceCode: "device-code", + UserCode: "31B677B9", + VerificationURL: "https://trakt.tv/activate", + IntervalSeconds: 5, + ExpiresAt: expiresAt, + }, + }) + + req := httptest.NewRequest(http.MethodPost, "/watch-providers/trakt/auth/device-code", nil) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("provider", "trakt") + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx) + ctx = middleware.SetClaims(ctx, &auth.Claims{UserID: 7}) + ctx = middleware.SetProfileID(ctx, "profile-1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + handler.HandleStartDeviceAuth(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + for _, key := range []string{"ID", "UserCode", "VerificationURL", "IntervalSeconds", "ExpiresAt"} { + if _, ok := resp[key]; ok { + t.Fatalf("response contains Go-style key %q: %#v", key, resp) + } + } + if resp["id"] != "auth-1" { + t.Fatalf("id = %#v, want auth-1", resp["id"]) + } + if resp["user_code"] != "31B677B9" { + t.Fatalf("user_code = %#v, want 31B677B9", resp["user_code"]) + } + if resp["verification_url"] != "https://trakt.tv/activate" { + t.Fatalf("verification_url = %#v, want https://trakt.tv/activate", resp["verification_url"]) + } +} + +func TestWatchProviderHandlerManualSyncReturnsRun(t *testing.T) { + startedAt := time.Date(2026, 5, 4, 16, 0, 0, 0, time.UTC) + handler := NewWatchProviderHandler(stubWatchProviderService{ + manual: watchsync.ManualSyncResult{ + Run: watchsync.SyncRun{ + ID: "run-1", + ConnectionID: "conn-1", + Provider: "trakt", + Trigger: "manual", + Status: string(watchsync.SyncRunStatusRunning), + StartedAt: startedAt, + CreatedAt: startedAt, + }, + }, + }) + + req := authorizedWatchProviderRequest(http.MethodPost, "/watch-providers/trakt/sync") + rec := httptest.NewRecorder() + + handler.HandleManualSync(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String()) + } + var resp watchsync.ManualSyncResult + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Run.ID != "run-1" || resp.Run.Status != string(watchsync.SyncRunStatusRunning) { + t.Fatalf("run = %+v", resp.Run) + } +} + +func TestWatchProviderHandlerManualSyncCooldown(t *testing.T) { + handler := NewWatchProviderHandler(stubWatchProviderService{ + manualErr: watchsync.SyncCooldownError{RetryAfterSeconds: 2450}, + }) + + req := authorizedWatchProviderRequest(http.MethodPost, "/watch-providers/trakt/sync") + rec := httptest.NewRecorder() + + handler.HandleManualSync(rec, req) + + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String()) + } + if got := rec.Header().Get("Retry-After"); got != "2450" { + t.Fatalf("Retry-After = %q, want 2450", got) + } + var resp struct { + Error string `json:"error"` + RetryAfterSeconds int `json:"retry_after_seconds"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Error != "sync_cooldown" || resp.RetryAfterSeconds != 2450 { + t.Fatalf("response = %+v", resp) + } +} + +func TestWatchProviderHandlerListSyncRuns(t *testing.T) { + startedAt := time.Date(2026, 5, 4, 16, 0, 0, 0, time.UTC) + handler := NewWatchProviderHandler(stubWatchProviderService{ + runs: []watchsync.SyncRun{ + { + ID: "run-1", + ConnectionID: "conn-1", + Provider: "trakt", + Trigger: "manual", + Status: string(watchsync.SyncRunStatusSuccess), + StartedAt: startedAt, + CreatedAt: startedAt, + }, + }, + }) + + req := authorizedWatchProviderRequest(http.MethodGet, "/watch-providers/trakt/sync-runs") + rec := httptest.NewRecorder() + + handler.HandleListSyncRuns(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var resp struct { + Runs []watchsync.SyncRun `json:"runs"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Runs) != 1 || resp.Runs[0].ID != "run-1" { + t.Fatalf("runs = %+v", resp.Runs) + } +} + +func authorizedWatchProviderRequest(method string, path string) *http.Request { + req := httptest.NewRequest(method, path, nil) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("provider", "trakt") + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx) + ctx = middleware.SetClaims(ctx, &auth.Claims{UserID: 7}) + ctx = middleware.SetProfileID(ctx, "profile-1") + return req.WithContext(ctx) +} diff --git a/internal/api/handlers/watch_together.go b/internal/api/handlers/watch_together.go new file mode 100644 index 00000000..607d591d --- /dev/null +++ b/internal/api/handlers/watch_together.go @@ -0,0 +1,789 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "sync" + "time" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/watchtogether" + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" +) + +type WatchTogetherScopeResolver interface { + Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error) +} + +type WatchTogetherHandler struct { + Service *watchtogether.Service + ScopeResolver WatchTogetherScopeResolver + TokenService *watchtogether.RoomTokenService +} + +type createWatchTogetherRoomRequest struct { + SelectionMode string `json:"selection_mode,omitempty"` +} + +type joinWatchTogetherRoomRequest struct { + Code string `json:"code"` + JoinToken string `json:"join_token"` +} + +type updateWatchTogetherPolicyRequest struct { + GuestControlPolicy watchtogether.GuestControlPolicy `json:"guest_control_policy"` +} + +type selectWatchTogetherRoomItemRequest struct { + ContentID string `json:"content_id"` + FileID *int `json:"file_id"` + LibraryID *int `json:"library_id"` +} + +type watchTogetherRoomResponse struct { + Room watchtogether.Snapshot `json:"room"` + RoomAccessToken string `json:"room_access_token,omitempty"` +} + +type createWatchTogetherSuggestionRequest struct { + ContentID string `json:"content_id"` + ContentType string `json:"content_type"` + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + Note string `json:"note,omitempty"` +} + +type promoteSuggestionRequest struct { + SuggestionID string `json:"suggestion_id"` +} + +type watchTogetherSuggestionsResponse struct { + Suggestions []watchtogether.Suggestion `json:"suggestions"` +} + +type watchTogetherClientMessage struct { + Type string `json:"type"` +} + +type watchTogetherAttachMessage struct { + Type string `json:"type"` + SessionID string `json:"session_id"` +} + +type watchTogetherTransportRequestMessage struct { + Type string `json:"type"` + Action watchtogether.TransportAction `json:"action"` + PositionSeconds *float64 `json:"position_seconds,omitempty"` + IsPaused bool `json:"is_paused"` +} + +type watchTogetherStateReportMessage struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + PositionSeconds float64 `json:"position_seconds"` + IsPaused bool `json:"is_paused"` +} + +type watchTogetherReadyMessage struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + PositionSeconds float64 `json:"position_seconds"` + IsPaused bool `json:"is_paused"` +} + +type watchTogetherBufferingMessage struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + PositionSeconds float64 `json:"position_seconds"` + IsPaused bool `json:"is_paused"` +} + +type watchTogetherPingMessage struct { + Type string `json:"type"` + ClientSentAt string `json:"client_sent_at"` +} + +type watchTogetherRoomConn struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +func (c *watchTogetherRoomConn) WriteJSON(v any) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return writeWebSocketJSON(c.conn, v) +} + +func (c *watchTogetherRoomConn) Close() error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.Close() +} + +func (c *watchTogetherRoomConn) WritePing() error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return writeWebSocketControl(c.conn, websocket.PingMessage, nil) +} + +func NewWatchTogetherHandler( + service *watchtogether.Service, + scopeResolver WatchTogetherScopeResolver, + tokenService *watchtogether.RoomTokenService, +) *WatchTogetherHandler { + return &WatchTogetherHandler{ + Service: service, + ScopeResolver: scopeResolver, + TokenService: tokenService, + } +} + +func (h *WatchTogetherHandler) HandleCreateRoom(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + if userID == 0 || profileID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req createWatchTogetherRoomRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + room, err := h.Service.CreateRoom(r.Context(), watchtogether.CreateRoomInput{ + HostUserID: userID, + HostProfileID: profileID, + SelectionMode: watchtogether.RoomSelectionMode(req.SelectionMode), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create room") + return + } + + snapshot, err := h.Service.Snapshot(r.Context(), room.ID, userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room") + return + } + + response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") + return + } + writeJSON(w, http.StatusCreated, response) +} + +func (h *WatchTogetherHandler) HandleJoinRoom(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + if userID == 0 || profileID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var req joinWatchTogetherRoomRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + room, err := h.Service.JoinRoom(r.Context(), watchtogether.JoinInput{ + Code: req.Code, + JoinToken: req.JoinToken, + }) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrInvalidJoinRequest): + writeError(w, http.StatusBadRequest, "bad_request", "Room code or invite token is required") + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + case errors.Is(err, watchtogether.ErrRoomClosed): + writeError(w, http.StatusGone, "gone", "Room is no longer active") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to join room") + } + return + } + + snapshot, err := h.Service.Snapshot(r.Context(), room.ID, userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room") + return + } + + response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") + return + } + writeJSON(w, http.StatusOK, response) +} + +func (h *WatchTogetherHandler) HandleGetRoom(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + if userID == 0 || profileID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + roomID := chi.URLParam(r, "room_id") + if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { + writeError(w, http.StatusForbidden, "forbidden", "Room access token required") + return + } + snapshot, err := h.Service.Snapshot(r.Context(), roomID, userID, profileID) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + case errors.Is(err, watchtogether.ErrRoomClosed): + writeError(w, http.StatusGone, "gone", "Room is no longer active") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load room") + } + return + } + + response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") + return + } + writeJSON(w, http.StatusOK, response) +} + +func (h *WatchTogetherHandler) HandleUpdateRoomPolicy(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + + var req updateWatchTogetherPolicyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + snapshot, err := h.Service.UpdatePolicy(r.Context(), roomID, userID, profileID, req.GuestControlPolicy) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrRoomForbidden): + writeError(w, http.StatusForbidden, "forbidden", "Only the host can update room policy") + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update room policy") + } + return + } + + response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") + return + } + writeJSON(w, http.StatusOK, response) +} + +func (h *WatchTogetherHandler) HandleCloseRoom(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + + if err := h.Service.CloseRoom(r.Context(), roomID, userID, profileID); err != nil { + switch { + case errors.Is(err, watchtogether.ErrRoomForbidden): + writeError(w, http.StatusForbidden, "forbidden", "Only the host can close the room") + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to close room") + } + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *WatchTogetherHandler) HandleSelectRoomItem(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + + var req selectWatchTogetherRoomItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + snapshot, err := h.Service.SelectItem(r.Context(), roomID, userID, profileID, watchtogether.SelectItemInput{ + ContentID: req.ContentID, + FileID: req.FileID, + LibraryID: req.LibraryID, + }) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrRoomForbidden): + writeError(w, http.StatusForbidden, "forbidden", "Only the host can start or switch room playback") + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + case errors.Is(err, watchtogether.ErrRoomClosed): + writeError(w, http.StatusGone, "gone", "Room is no longer active") + case errors.Is(err, watchtogether.ErrInvalidSelection): + writeError(w, http.StatusBadRequest, "bad_request", "Content is not playable in this room") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update room selection") + } + return + } + + response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") + return + } + writeJSON(w, http.StatusOK, response) +} + +func (h *WatchTogetherHandler) HandleListSuggestions(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + + if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { + writeError(w, http.StatusForbidden, "forbidden", "Room access token required") + return + } + + suggestions, err := h.Service.ListSuggestions(r.Context(), roomID, profileID) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + case errors.Is(err, watchtogether.ErrRoomClosed): + writeError(w, http.StatusGone, "gone", "Room is no longer active") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list suggestions") + } + return + } + + writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions}) +} + +func (h *WatchTogetherHandler) HandleCreateSuggestion(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + + if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { + writeError(w, http.StatusForbidden, "forbidden", "Room access token required") + return + } + + var req createWatchTogetherSuggestionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.ContentID == "" || req.Title == "" { + writeError(w, http.StatusBadRequest, "bad_request", "content_id and title are required") + return + } + + suggestions, err := h.Service.CreateSuggestion(r.Context(), roomID, userID, profileID, watchtogether.CreateSuggestionInput{ + ContentID: req.ContentID, + ContentType: req.ContentType, + Title: req.Title, + Subtitle: req.Subtitle, + PosterURL: req.PosterURL, + Note: req.Note, + }) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + case errors.Is(err, watchtogether.ErrRoomClosed): + writeError(w, http.StatusGone, "gone", "Room is no longer active") + case errors.Is(err, watchtogether.ErrInvalidSelection): + writeError(w, http.StatusBadRequest, "bad_request", "Invalid content type") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create suggestion") + } + return + } + + writeJSON(w, http.StatusCreated, watchTogetherSuggestionsResponse{Suggestions: suggestions}) +} + +func (h *WatchTogetherHandler) HandleDeleteSuggestion(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + suggestionID := chi.URLParam(r, "suggestion_id") + + if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { + writeError(w, http.StatusForbidden, "forbidden", "Room access token required") + return + } + + suggestions, err := h.Service.DeleteSuggestion(r.Context(), roomID, suggestionID, userID, profileID) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrSuggestionNotFound): + writeError(w, http.StatusNotFound, "not_found", "Suggestion not found") + case errors.Is(err, watchtogether.ErrRoomForbidden): + writeError(w, http.StatusForbidden, "forbidden", "Only the host or suggester can delete a suggestion") + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete suggestion") + } + return + } + + writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions}) +} + +func (h *WatchTogetherHandler) HandleVote(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + suggestionID := chi.URLParam(r, "suggestion_id") + + if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { + writeError(w, http.StatusForbidden, "forbidden", "Room access token required") + return + } + + suggestions, err := h.Service.Vote(r.Context(), roomID, suggestionID, userID, profileID) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrDuplicateVote): + writeError(w, http.StatusConflict, "conflict", "Already voted") + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to vote") + } + return + } + + writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions}) +} + +func (h *WatchTogetherHandler) HandleUnvote(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + suggestionID := chi.URLParam(r, "suggestion_id") + + if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { + writeError(w, http.StatusForbidden, "forbidden", "Room access token required") + return + } + + suggestions, err := h.Service.Unvote(r.Context(), roomID, suggestionID, userID, profileID) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrNotVoted): + writeError(w, http.StatusConflict, "conflict", "Not voted") + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove vote") + } + return + } + + writeJSON(w, http.StatusOK, watchTogetherSuggestionsResponse{Suggestions: suggestions}) +} + +func (h *WatchTogetherHandler) HandlePromoteSuggestion(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + roomID := chi.URLParam(r, "room_id") + + if err := h.validateRoomAccessToken(r, roomID, userID, profileID); err != nil { + writeError(w, http.StatusForbidden, "forbidden", "Room access token required") + return + } + + var req promoteSuggestionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.SuggestionID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "suggestion_id is required") + return + } + + snapshot, err := h.Service.PromoteSuggestion(r.Context(), roomID, req.SuggestionID, userID, profileID) + if err != nil { + switch { + case errors.Is(err, watchtogether.ErrRoomForbidden): + writeError(w, http.StatusForbidden, "forbidden", "Only the host can promote a suggestion") + case errors.Is(err, watchtogether.ErrSuggestionNotFound): + writeError(w, http.StatusNotFound, "not_found", "Suggestion not found") + case errors.Is(err, watchtogether.ErrRoomNotFound): + writeError(w, http.StatusNotFound, "not_found", "Room not found") + case errors.Is(err, watchtogether.ErrInvalidSelection): + writeError(w, http.StatusBadRequest, "bad_request", "Suggested content is not playable") + default: + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to promote suggestion") + } + return + } + + response, err := h.buildRoomResponse(r.Context(), snapshot, userID, profileID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to issue room token") + return + } + writeJSON(w, http.StatusOK, response) +} + +func (h *WatchTogetherHandler) buildRoomResponse( + ctx context.Context, + snapshot watchtogether.Snapshot, + userID int, + profileID string, +) (watchTogetherRoomResponse, error) { + response := watchTogetherRoomResponse{Room: snapshot} + if h == nil || h.TokenService == nil { + return response, nil + } + + token, _, err := h.TokenService.Mint(watchtogether.RoomTokenClaims{ + RoomID: snapshot.RoomID, + UserID: userID, + ProfileID: profileID, + }) + if err != nil { + return watchTogetherRoomResponse{}, err + } + response.RoomAccessToken = token + return response, nil +} + +func (h *WatchTogetherHandler) validateRoomAccessToken( + r *http.Request, + roomID string, + userID int, + profileID string, +) error { + if h == nil || h.TokenService == nil { + return nil + } + + claims, err := h.TokenService.Validate(r.URL.Query().Get("room_token")) + if err != nil { + return err + } + if claims.RoomID != roomID || claims.UserID != userID || claims.ProfileID != profileID { + return watchtogether.ErrRoomForbidden + } + return nil +} + +func (h *WatchTogetherHandler) HandleRoomWebSocket(w http.ResponseWriter, r *http.Request) { + if h == nil || h.Service == nil || h.ScopeResolver == nil { + http.Error(w, "watch together unavailable", http.StatusServiceUnavailable) + return + } + + claims := apimw.GetClaims(r.Context()) + if claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + roomID := chi.URLParam(r, "room_id") + if roomID == "" { + http.Error(w, "room_id required", http.StatusBadRequest) + return + } + + profileID := r.URL.Query().Get("profile_id") + if profileID == "" { + http.Error(w, "profile_id required", http.StatusBadRequest) + return + } + if err := h.validateRoomAccessToken(r, roomID, claims.UserID, profileID); err != nil { + http.Error(w, "room access token required", http.StatusForbidden) + return + } + + _, err := h.ScopeResolver.Resolve(r.Context(), access.ResolveInput{ + UserID: claims.UserID, + SessionID: claims.SessionID, + ProfileID: profileID, + ProfileToken: r.URL.Query().Get("profile_token"), + SkipPINVerification: claims.TokenType == auth.TokenTypeAPIKey, + }) + if err != nil { + status := http.StatusForbidden + if errors.Is(err, access.ErrProfileNotFound) { + status = http.StatusNotFound + } + http.Error(w, "profile verification failed", status) + return + } + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + realtimeConn := &watchTogetherRoomConn{conn: conn} + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + reg, snapshot, err := h.Service.Connect(ctx, roomID, claims.UserID, profileID, realtimeConn) + if err != nil { + if errors.Is(err, watchtogether.ErrRoomNotFound) { + writeWebSocketError(conn, "not_found", "Room not found") + } else if errors.Is(err, watchtogether.ErrRoomClosed) { + writeWebSocketError(conn, "gone", "Room is no longer active") + } else { + writeWebSocketError(conn, "internal_error", "Failed to connect room socket") + } + return + } + defer h.Service.Disconnect(reg, false) + + configureWebSocket(conn) + startWebSocketPingLoop(ctx, realtimeConn.WritePing) + + if err := realtimeConn.WriteJSON(map[string]any{ + "type": "snapshot", + "room": snapshot, + }); err != nil { + return + } + + for { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + + if err := h.handleRoomClientMessage(ctx, conn, reg, claims.UserID, profileID, data); err != nil { + writeWebSocketError(conn, "bad_request", err.Error()) + } + } +} + +func (h *WatchTogetherHandler) handleRoomClientMessage( + ctx context.Context, + conn *websocket.Conn, + reg *watchtogether.Registration, + userID int, + profileID string, + data []byte, +) error { + var base watchTogetherClientMessage + if err := json.Unmarshal(data, &base); err != nil { + return err + } + + switch base.Type { + case "attach_session": + var msg watchTogetherAttachMessage + if err := json.Unmarshal(data, &msg); err != nil { + return err + } + if msg.SessionID == "" { + return errors.New("session_id is required") + } + _, err := h.Service.AttachSessionForConnection(ctx, reg, userID, profileID, msg.SessionID) + return err + case "transport_request": + var msg watchTogetherTransportRequestMessage + if err := json.Unmarshal(data, &msg); err != nil { + return err + } + _, err := h.Service.HandleTransportRequestForConnection(ctx, reg, userID, profileID, watchtogether.TransportRequest{ + Action: msg.Action, + PositionSeconds: msg.PositionSeconds, + IsPaused: msg.IsPaused, + }) + return err + case "state_report": + var msg watchTogetherStateReportMessage + if err := json.Unmarshal(data, &msg); err != nil { + return err + } + if msg.SessionID == "" { + return errors.New("session_id is required") + } + _, err := h.Service.HandleStateReportForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{ + SessionID: msg.SessionID, + PositionSeconds: msg.PositionSeconds, + IsPaused: msg.IsPaused, + }) + return err + case "ready": + var msg watchTogetherReadyMessage + if err := json.Unmarshal(data, &msg); err != nil { + return err + } + if msg.SessionID == "" { + return errors.New("session_id is required") + } + _, err := h.Service.HandleReadyForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{ + SessionID: msg.SessionID, + PositionSeconds: msg.PositionSeconds, + IsPaused: msg.IsPaused, + }) + return err + case "buffering": + var msg watchTogetherBufferingMessage + if err := json.Unmarshal(data, &msg); err != nil { + return err + } + if msg.SessionID == "" { + return errors.New("session_id is required") + } + _, err := h.Service.HandleBufferingForConnection(ctx, reg, userID, profileID, watchtogether.StateReport{ + SessionID: msg.SessionID, + PositionSeconds: msg.PositionSeconds, + IsPaused: msg.IsPaused, + }) + return err + case "ping": + var msg watchTogetherPingMessage + if err := json.Unmarshal(data, &msg); err != nil { + return err + } + clientSentAt, err := time.Parse(time.RFC3339Nano, msg.ClientSentAt) + if err != nil { + return errors.New("client_sent_at must be RFC3339Nano") + } + now := time.Now().UTC() + pingMS := now.Sub(clientSentAt.UTC()).Milliseconds() + if pingMS > 0 { + _ = h.Service.HandlePingForConnection(ctx, reg, userID, profileID, pingMS) + } + return writeWebSocketJSON(conn, map[string]string{ + "type": "pong", + "client_sent_at": msg.ClientSentAt, + "server_received_at": now.Format(time.RFC3339Nano), + "server_sent_at": time.Now().UTC().Format(time.RFC3339Nano), + }) + default: + return errors.New("unsupported room websocket message") + } +} diff --git a/internal/api/handlers/watch_tonight.go b/internal/api/handlers/watch_tonight.go new file mode 100644 index 00000000..2cb52821 --- /dev/null +++ b/internal/api/handlers/watch_tonight.go @@ -0,0 +1,409 @@ +package handlers + +import ( + "context" + "log/slog" + "net/http" + "sort" + "strconv" + "sync" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/sections" +) + +// watchTonightSectionFetcher provides access to live Continue Watching +// and Next Up data for the Watch Tonight handler. +type watchTonightSectionFetcher interface { + FetchNextUpItems(ctx context.Context, userID int, profileID string, libraryID *int, libraryIDs []int, filter catalog.AccessFilter, limit int) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error) +} + +type watchTonightItemResponse struct { + sectionItemResponse + WatchTonightSource string `json:"watch_tonight_source"` +} + +type watchTonightResponse struct { + Items []watchTonightItemResponse `json:"items"` + IsCold bool `json:"is_cold"` +} + +// Source boost multipliers for ranking. +const ( + boostCWInTaste = 1.40 // in-progress AND in taste pool + boostNextInTaste = 1.25 // next-up AND in taste pool + baseCWScore = 2.00 // in-progress items — always rank above pure recommendations (max ~1.2) + baseNextUpScore = 1.50 // next-up items — rank above recommendations but below in-progress + watchTonightLimit = 5 +) + +// HandleWatchTonight handles GET /recommendations/watch-tonight. +// It merges live Continue Watching / Next Up items with recommendation-engine +// candidates, applies taste-profile boosts, enriches, and returns a unified list. +func (h *RecommendationsHandler) HandleWatchTonight(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + limit := watchTonightLimit + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 20 { + limit = n + } + } + + // Fan out: recommendation candidates + live CW/Next-Up in parallel. + var ( + recResult recommendations.WatchTonightResult + recErr error + + cwItems []scoredCWItem + nextUpItems []scoredCWItem + liveErr error + ) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + if h.reader != nil { + recResult, recErr = h.reader.GetWatchTonight(r.Context(), userID, profileID, limit*3, filter) + } + }() + + go func() { + defer wg.Done() + cwItems, nextUpItems, liveErr = h.fetchLiveCWAndNextUp(r, userID, profileID, filter, limit) + }() + + wg.Wait() + + if recErr != nil { + slog.Error("WatchTonight: recommendations fetch failed", "user_id", userID, "error", recErr) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") + return + } + if liveErr != nil { + slog.Warn("WatchTonight: live CW/NextUp fetch failed, continuing with recs only", "user_id", userID, "error", liveErr) + } + + // Filter out already-watched and low-rated items from rec candidates. + recResult.Items = h.filterRecommendations(r, userID, profileID, recResult.Items) + + // Build taste set from recommendation items for boost lookups. + tasteSet := make(map[string]struct{}, len(recResult.Items)) + for _, item := range recResult.Items { + tasteSet[item.MediaItemID] = struct{}{} + } + + // Merge everything into a single scored list. + byID := make(map[string]mergedItem, len(recResult.Items)+len(cwItems)+len(nextUpItems)) + + // Insert recommendation items first. + for _, item := range recResult.Items { + byID[item.MediaItemID] = mergedItem{ + scored: item, + source: "recommendation", + } + } + + // Overlay CW items — these should always rank highly since the user + // actively started watching them. + for _, cw := range cwItems { + existing, exists := byID[cw.contentID] + if exists { + // Item is in both CW and recommendations — boost the rec score. + existing.scored.Score *= boostCWInTaste + existing.source = "continue_watching" + existing.meta = &cw.meta + byID[cw.contentID] = existing + } else { + // Item is in CW only — still high priority. + byID[cw.contentID] = mergedItem{ + scored: recommendations.ScoredItem{ + MediaItemID: cw.contentID, + Score: baseCWScore, + Reason: "continue_watching", + }, + source: "continue_watching", + meta: &cw.meta, + } + } + } + + // Overlay Next-Up items — next episode of something the user watched. + for _, nu := range nextUpItems { + existing, exists := byID[nu.contentID] + if exists { + if existing.source != "continue_watching" { + // Item is in both next-up and recommendations — boost. + existing.scored.Score *= boostNextInTaste + existing.source = "next_up" + existing.meta = &nu.meta + byID[nu.contentID] = existing + } + // CW wins over next-up for same item + } else { + // Item is in next-up only — still high priority. + byID[nu.contentID] = mergedItem{ + scored: recommendations.ScoredItem{ + MediaItemID: nu.contentID, + Score: baseNextUpScore, + Reason: "next_up", + }, + source: "next_up", + meta: &nu.meta, + } + } + } + + // Sort and trim. + merged := make([]mergedItem, 0, len(byID)) + for _, m := range byID { + merged = append(merged, m) + } + sort.Slice(merged, func(i, j int) bool { + return merged[i].scored.Score > merged[j].scored.Score + }) + if len(merged) > limit { + merged = merged[:limit] + } + + // Collect content IDs for enrichment. + contentIDs := make([]string, len(merged)) + for i, m := range merged { + contentIDs[i] = m.scored.MediaItemID + } + + // Enrich with full media item details. + resp := watchTonightResponse{ + Items: make([]watchTonightItemResponse, 0, len(merged)), + IsCold: recResult.IsCold && len(cwItems) == 0 && len(nextUpItems) == 0, + } + + if len(contentIDs) == 0 { + writeJSON(w, http.StatusOK, resp) + return + } + + itemMap, overlayMap, stateMap, enrichedEpMeta := h.enrichItems(r, userID, profileID, filter, contentIDs) + + for _, m := range merged { + mi, ok := itemMap[m.scored.MediaItemID] + if !ok || mi == nil { + continue + } + + item := h.buildSectionItem(r, mi, overlayMap, stateMap) + item.ItemSource = m.source + + // Apply CW/Next-Up metadata (position, duration, progress). + if m.meta != nil { + item.PositionSeconds = m.meta.PositionSeconds + item.DurationSeconds = m.meta.DurationSeconds + item.ProgressUpdatedAt = m.meta.ProgressUpdatedAt + if m.meta.SeriesID != nil { + item.SeriesID = *m.meta.SeriesID + } + if m.meta.SeriesTitle != "" { + item.SeriesTitle = m.meta.SeriesTitle + } + if m.meta.SeasonNumber != nil { + item.SeasonNumber = m.meta.SeasonNumber + } + if m.meta.EpisodeNumber != nil { + item.EpisodeNumber = m.meta.EpisodeNumber + } + } + + // Fill in episode metadata from the enrichment step for items that came + // through the episodes table (CW/Next-Up episodes). + if epMeta, ok := enrichedEpMeta[m.scored.MediaItemID]; ok { + if epMeta.SeriesID != nil && item.SeriesID == "" { + item.SeriesID = *epMeta.SeriesID + } + if epMeta.SeriesTitle != "" && item.SeriesTitle == "" { + item.SeriesTitle = epMeta.SeriesTitle + } + if epMeta.SeasonNumber != nil && item.SeasonNumber == nil { + item.SeasonNumber = epMeta.SeasonNumber + } + if epMeta.EpisodeNumber != nil && item.EpisodeNumber == nil { + item.EpisodeNumber = epMeta.EpisodeNumber + } + } + + resp.Items = append(resp.Items, watchTonightItemResponse{ + sectionItemResponse: item, + WatchTonightSource: m.source, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +type mergedItem struct { + scored recommendations.ScoredItem + source string // "continue_watching", "next_up", "recommendation" + meta *sections.SectionItemMeta +} + +type scoredCWItem struct { + contentID string + meta sections.SectionItemMeta +} + +// fetchLiveCWAndNextUp fetches live Continue Watching and Next Up items from the user store. +func (h *RecommendationsHandler) fetchLiveCWAndNextUp(r *http.Request, userID int, profileID string, filter catalog.AccessFilter, limit int) (cwItems, nextUpItems []scoredCWItem, err error) { + if h.storeProvider == nil || userID <= 0 || profileID == "" { + return nil, nil, nil + } + + ctx := r.Context() + + // Fetch in-progress items from user store. + store, err := h.storeProvider.ForUser(ctx, userID) + if err != nil { + return nil, nil, err + } + + progressEntries, err := store.ListProgress(ctx, profileID, "in_progress", limit, 0) + if err != nil { + return nil, nil, err + } + + for _, entry := range progressEntries { + position := entry.PositionSeconds + duration := entry.DurationSeconds + updatedAt := entry.UpdatedAt + cwItems = append(cwItems, scoredCWItem{ + contentID: entry.MediaItemID, + meta: sections.SectionItemMeta{ + PositionSeconds: &position, + DurationSeconds: &duration, + ProgressUpdatedAt: &updatedAt, + ItemSource: "in_progress", + }, + }) + } + + // Fetch next-up items. + if h.WatchTonightFetcher != nil { + nuItems, nuMeta, nuErr := h.WatchTonightFetcher.FetchNextUpItems(ctx, userID, profileID, nil, nil, filter, limit) + if nuErr != nil { + slog.Warn("WatchTonight: next-up fetch failed", "user_id", userID, "error", nuErr) + } else { + for _, item := range nuItems { + meta, ok := nuMeta[item.ContentID] + if !ok { + continue + } + nextUpItems = append(nextUpItems, scoredCWItem{ + contentID: item.ContentID, + meta: meta, + }) + } + } + } + + return cwItems, nextUpItems, nil +} + +// enrichItems fetches full MediaItem objects, overlay summaries, and user states +// for the given content IDs. Handles both movies/series (from media_items) and +// episodes (from episodes table) since CW/Next-Up items are episode content IDs. +func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profileID string, filter catalog.AccessFilter, contentIDs []string) ( + itemMap map[string]*models.MediaItem, + overlayMap map[string]*models.OverlaySummary, + stateMap map[string]*itemUserStateResponse, + episodeMeta map[string]sections.SectionItemMeta, +) { + if h.Fetcher == nil || len(contentIDs) == 0 { + return nil, nil, nil, nil + } + + ctx := r.Context() + + // Fetch movies/series from media_items table. + mediaItems, err := h.Fetcher.FetchItemsByContentIDs(ctx, contentIDs, filter) + if err != nil { + slog.Error("WatchTonight: fetch items failed", "error", err) + return nil, nil, nil, nil + } + + itemMap = make(map[string]*models.MediaItem, len(mediaItems)*2) + for _, mi := range mediaItems { + itemMap[mi.ContentID] = mi + } + + // Also fetch episodes — CW/Next-Up content IDs are typically episode IDs. + episodeItems, epMeta, epErr := h.Fetcher.FetchEpisodesByContentIDs(ctx, contentIDs, filter) + if epErr != nil { + slog.Error("WatchTonight: fetch episodes failed", "error", epErr) + } else { + for _, item := range episodeItems { + itemMap[item.ContentID] = item + } + episodeMeta = epMeta + mediaItems = append(mediaItems, episodeItems...) + } + + overlays, err := h.Fetcher.ListOverlaySummaries(ctx, contentIDs, filter) + if err != nil { + slog.Error("WatchTonight: overlay summaries failed", "error", err) + } else { + overlayMap = overlays + } + + if h.storeProvider != nil { + store, storeErr := h.storeProvider.ForUser(ctx, userID) + if storeErr == nil && store != nil { + states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems) + if stateErr == nil { + stateMap = states + } + } + } + + return itemMap, overlayMap, stateMap, episodeMeta +} + +// buildSectionItem constructs a sectionItemResponse from a MediaItem, +// applying presigning and overlay/state lookups. +func (h *RecommendationsHandler) buildSectionItem(r *http.Request, mi *models.MediaItem, overlayMap map[string]*models.OverlaySummary, stateMap map[string]*itemUserStateResponse) sectionItemResponse { + item := sectionItemResponse{ + ContentID: mi.ContentID, + Type: mi.Type, + Title: mi.Title, + Year: mi.Year, + Genres: mi.Genres, + Status: mi.Status, + RatingIMDB: mi.RatingIMDB, + RatingTMDB: mi.RatingTMDB, + RatingRTCritic: mi.RatingRTCritic, + RatingRTAudience: mi.RatingRTAudience, + OriginalLanguage: mi.OriginalLanguage, + Overview: mi.Overview, + PosterThumbhash: mi.PosterThumbhash, + BackdropThumbhash: mi.BackdropThumbhash, + } + if item.Genres == nil { + item.Genres = []string{} + } + if h.DetailSvc != nil { + item.PosterURL = h.DetailSvc.PresignURL(r.Context(), featuredPosterPath(mi.PosterPath), "featured") + item.BackdropURL = h.DetailSvc.PresignURL(r.Context(), featuredBackdropPath(mi.BackdropPath), "featured") + item.LogoURL = h.DetailSvc.PresignURL(r.Context(), mi.LogoPath, "featured") + } + if overlayMap != nil { + item.OverlaySummary = overlayMap[mi.ContentID] + } + if stateMap != nil { + item.UserState = stateMap[mi.ContentID] + } + return item +} diff --git a/internal/api/handlers/watch_tonight_cards.go b/internal/api/handlers/watch_tonight_cards.go new file mode 100644 index 00000000..27d07b9b --- /dev/null +++ b/internal/api/handlers/watch_tonight_cards.go @@ -0,0 +1,488 @@ +package handlers + +import ( + "context" + "log/slog" + "net/http" + "sort" + "strconv" + "strings" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/recommendations" +) + +// cardsCastFetcher fetches cast credits for a batch of media items. +type cardsCastFetcher interface { + ListForItems(ctx context.Context, contentIDs []string) (map[string][]models.ItemPerson, error) +} + +// swipeCardCastMember is a single cast member on a swipe card. +type swipeCardCastMember struct { + Name string `json:"name"` + Character string `json:"character,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` +} + +// swipeCardResponse extends sectionItemResponse with cast and runtime for the card back. +type swipeCardResponse struct { + sectionItemResponse + WatchTonightSource string `json:"watch_tonight_source"` + Runtime int `json:"runtime,omitempty"` + Cast []swipeCardCastMember `json:"cast"` +} + +// swipeCardsPageResponse is the response for GET /recommendations/watch-tonight/cards. +type swipeCardsPageResponse struct { + Cards []swipeCardResponse `json:"cards"` + HasMore bool `json:"has_more"` + IsCold bool `json:"is_cold"` +} + +const ( + swipeDefaultLimit = 12 + swipeMaxLimit = 20 + maxExcludeIDs = 200 + maxCastPerCard = 4 + discoverMinPoolSize = 120 + discoverMaxPoolSize = 500 +) + +// knownGenres is the validated set of genre names accepted by the cards endpoint. +var knownGenres = map[string]struct{}{ + "Action": {}, "Adventure": {}, "Animation": {}, "Comedy": {}, + "Crime": {}, "Documentary": {}, "Drama": {}, "Family": {}, + "Fantasy": {}, "History": {}, "Horror": {}, "Music": {}, + "Mystery": {}, "Romance": {}, "Science Fiction": {}, "Thriller": {}, + "War": {}, "Western": {}, +} + +// HandleWatchTonightCards handles GET /recommendations/watch-tonight/cards. +// It returns paginated, genre-filterable swipe cards with cast data for the +// gamified Watch Tonight experience. +func (h *RecommendationsHandler) HandleWatchTonightCards(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + filter := requestAccessFilter(r) + + mode := r.URL.Query().Get("mode") + if mode != "continue" && mode != "discover" { + writeError(w, http.StatusBadRequest, "invalid_mode", `mode must be "continue" or "discover"`) + return + } + + genres := parseGenreParams(r.URL.Query()["genres[]"]) + excludeIDs := parseExcludeIDs(r.URL.Query()["exclude_ids[]"]) + limit := swipeDefaultLimit + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= swipeMaxLimit { + limit = n + } + } + + var merged []mergedItem + var isCold bool + + if mode == "continue" { + merged, isCold = h.buildContinueCards(r, userID, profileID, filter, excludeIDs, limit) + } else { + merged, isCold = h.buildDiscoverCards(r, userID, profileID, genres, excludeIDs, limit) + } + + resp := swipeCardsPageResponse{ + Cards: make([]swipeCardResponse, 0, limit), + IsCold: isCold, + } + + if len(merged) == 0 { + writeJSON(w, http.StatusOK, resp) + return + } + + // Over-fetch for enrichment so we can still fill a full page after item + // lookups drop inaccessible or missing entries. Trim to limit+buffer to + // control enrichment cost. + enrichLimit := limit + 10 + if len(merged) > enrichLimit { + merged = merged[:enrichLimit] + } + + // Collect content IDs for enrichment. + contentIDs := make([]string, len(merged)) + for i, m := range merged { + contentIDs[i] = m.scored.MediaItemID + } + + itemMap, overlayMap, stateMap, enrichedEpMeta := h.enrichItems(r, userID, profileID, filter, contentIDs) + + // Determine if there are more items beyond what we return. + resp.HasMore = len(merged) > limit + if len(merged) > limit { + merged = merged[:limit] + } + + // First pass: build enriched items and collect cast lookup IDs. + type enrichedCard struct { + item sectionItemResponse + source string + runtime int + } + enrichedCards := make([]enrichedCard, 0, len(merged)) + castLookupIDs := make([]string, 0, len(merged)) + castLookupSeen := make(map[string]struct{}, len(merged)) + // Map content ID → cast lookup ID for the second pass. + contentToCastLookup := make(map[string]string, len(merged)) + + for _, m := range merged { + mi, ok := itemMap[m.scored.MediaItemID] + if !ok || mi == nil { + continue + } + + item := h.buildSectionItem(r, mi, overlayMap, stateMap) + item.ItemSource = m.source + + // Apply CW/Next-Up metadata. + if m.meta != nil { + item.PositionSeconds = m.meta.PositionSeconds + item.DurationSeconds = m.meta.DurationSeconds + item.ProgressUpdatedAt = m.meta.ProgressUpdatedAt + if m.meta.SeriesID != nil { + item.SeriesID = *m.meta.SeriesID + } + if m.meta.SeriesTitle != "" { + item.SeriesTitle = m.meta.SeriesTitle + } + if m.meta.SeasonNumber != nil { + item.SeasonNumber = m.meta.SeasonNumber + } + if m.meta.EpisodeNumber != nil { + item.EpisodeNumber = m.meta.EpisodeNumber + } + } + + if epMeta, ok := enrichedEpMeta[m.scored.MediaItemID]; ok { + if epMeta.SeriesID != nil && item.SeriesID == "" { + item.SeriesID = *epMeta.SeriesID + } + if epMeta.SeriesTitle != "" && item.SeriesTitle == "" { + item.SeriesTitle = epMeta.SeriesTitle + } + if epMeta.SeasonNumber != nil && item.SeasonNumber == nil { + item.SeasonNumber = epMeta.SeasonNumber + } + if epMeta.EpisodeNumber != nil && item.EpisodeNumber == nil { + item.EpisodeNumber = epMeta.EpisodeNumber + } + } + + // Resolve cast lookup ID: use series ID for episodes. + castLookupID := m.scored.MediaItemID + if item.SeriesID != "" { + castLookupID = item.SeriesID + } + contentToCastLookup[m.scored.MediaItemID] = castLookupID + if _, seen := castLookupSeen[castLookupID]; !seen { + castLookupSeen[castLookupID] = struct{}{} + castLookupIDs = append(castLookupIDs, castLookupID) + } + + enrichedCards = append(enrichedCards, enrichedCard{ + item: item, + source: m.source, + runtime: mi.Runtime, + }) + } + + // Fetch cast using the properly resolved lookup IDs. + castMap := h.fetchCastByIDs(r.Context(), castLookupIDs) + + // Second pass: assemble response cards with cast. + for i, ec := range enrichedCards { + lookupID := contentToCastLookup[merged[i].scored.MediaItemID] + cast := buildCastSlice(castMap, lookupID) + + resp.Cards = append(resp.Cards, swipeCardResponse{ + sectionItemResponse: ec.item, + WatchTonightSource: ec.source, + Runtime: ec.runtime, + Cast: cast, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// buildContinueCards returns CW + Next Up items as swipe cards. +func (h *RecommendationsHandler) buildContinueCards(r *http.Request, userID int, profileID string, filter catalog.AccessFilter, excludeIDs map[string]struct{}, limit int) ([]mergedItem, bool) { + cwItems, nextUpItems, err := h.fetchLiveCWAndNextUp(r, userID, profileID, filter, limit+len(excludeIDs)) + if err != nil { + slog.Warn("WatchTonightCards: CW/NextUp fetch failed", "user_id", userID, "error", err) + return nil, false + } + + merged := make([]mergedItem, 0, len(cwItems)+len(nextUpItems)) + + for _, cw := range cwItems { + if _, excluded := excludeIDs[cw.contentID]; excluded { + continue + } + merged = append(merged, mergedItem{ + scored: recommendations.ScoredItem{ + MediaItemID: cw.contentID, + Score: baseCWScore, + Reason: "continue_watching", + }, + source: "continue_watching", + meta: &cw.meta, + }) + } + + for _, nu := range nextUpItems { + if _, excluded := excludeIDs[nu.contentID]; excluded { + continue + } + merged = append(merged, mergedItem{ + scored: recommendations.ScoredItem{ + MediaItemID: nu.contentID, + Score: baseNextUpScore, + Reason: "next_up", + }, + source: "next_up", + meta: &nu.meta, + }) + } + + sort.Slice(merged, func(i, j int) bool { + return merged[i].scored.Score > merged[j].scored.Score + }) + + isCold := len(merged) == 0 + return merged, isCold +} + +// buildDiscoverCards returns recommendation candidates for discover mode. +// It searches the user's taste profile against the full embedded library, +// optionally requiring at least one selected genre match, and falls back to +// cold-start caches when no taste profile is available. +func (h *RecommendationsHandler) buildDiscoverCards(r *http.Request, userID int, profileID string, genres []string, excludeIDs map[string]struct{}, limit int) ([]mergedItem, bool) { + if h.recsRepo == nil { + return nil, true + } + + ctx := r.Context() + filter := requestAccessFilter(r) + poolSize := discoverCandidatePoolSize(limit, len(genres)) + embedding, err := h.recsRepo.GetTasteProfile(ctx, userID, profileID) + if err != nil { + slog.Warn("WatchTonightCards: taste profile fetch failed", "user_id", userID, "profile_id", profileID, "error", err) + } + + candidates := []recommendations.ScoredItem{} + genreMap := map[string][]string{} + isCold := embedding == nil + + if embedding != nil { + candidateItems, candidateGenres, err := h.recsRepo.FindTasteProfileCandidates( + ctx, + embedding, + mapKeys(excludeIDs), + genres, + poolSize, + filter, + ) + if err != nil { + slog.Warn("WatchTonightCards: taste candidate query failed", "user_id", userID, "profile_id", profileID, "error", err) + } else { + candidates = candidateItems + genreMap = candidateGenres + } + } + + if len(candidates) == 0 { + isCold = true + candidates = h.buildColdStartDiscoverCandidates(ctx) + if len(candidates) > 0 { + candidateIDs := make([]string, len(candidates)) + for i, item := range candidates { + candidateIDs[i] = item.MediaItemID + } + genreMap, _ = h.recsRepo.GetItemAllGenres(ctx, candidateIDs) + } + } + + candidates = h.filterRecommendations(r, userID, profileID, candidates) + candidates = recommendations.FilterAndRankGenreMatches(candidates, genres, genreMap) + + return recommendationCardItems(candidates, excludeIDs), isCold +} + +func recommendationCardItems(candidates []recommendations.ScoredItem, excludeIDs map[string]struct{}) []mergedItem { + merged := make([]mergedItem, 0, len(candidates)) + for _, item := range candidates { + if _, excluded := excludeIDs[item.MediaItemID]; excluded { + continue + } + merged = append(merged, mergedItem{ + scored: item, + source: "recommendation", + }) + } + return merged +} + +func (h *RecommendationsHandler) buildColdStartDiscoverCandidates(ctx context.Context) []recommendations.ScoredItem { + byID := make(map[string]recommendations.ScoredItem) + + popular, err := h.recsRepo.GetRecommendationCache( + ctx, + recommendations.GlobalCacheUserID, + recommendations.GlobalCacheProfileID, + recommendations.RecTypePopular, + "", + ) + if err != nil { + slog.Warn("WatchTonightCards: popular cache unavailable", "error", err) + } + for _, item := range popular { + byID[item.MediaItemID] = item + } + + recentlyAdded, err := h.recsRepo.GetRecommendationCache( + ctx, + recommendations.GlobalCacheUserID, + recommendations.GlobalCacheProfileID, + recommendations.RecTypeRecentlyAdded, + "", + ) + if err != nil { + slog.Warn("WatchTonightCards: recently added cache unavailable", "error", err) + } + for _, item := range recentlyAdded { + if existing, exists := byID[item.MediaItemID]; !exists || item.Score > existing.Score { + byID[item.MediaItemID] = item + } + } + + candidates := make([]recommendations.ScoredItem, 0, len(byID)) + for _, item := range byID { + candidates = append(candidates, item) + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].Score != candidates[j].Score { + return candidates[i].Score > candidates[j].Score + } + return candidates[i].MediaItemID < candidates[j].MediaItemID + }) + + return candidates +} + +// fetchCastByIDs fetches cast members for the given pre-resolved lookup IDs. +func (h *RecommendationsHandler) fetchCastByIDs(ctx context.Context, lookupIDs []string) map[string][]models.ItemPerson { + if h.CastFetcher == nil || len(lookupIDs) == 0 { + return nil + } + castMap, err := h.CastFetcher.ListForItems(ctx, lookupIDs) + if err != nil { + slog.Error("WatchTonightCards: cast fetch failed", "error", err) + return nil + } + return castMap +} + +// buildCastSlice builds a capped list of actor cast members for a card. +func buildCastSlice(castMap map[string][]models.ItemPerson, lookupID string) []swipeCardCastMember { + if castMap == nil { + return []swipeCardCastMember{} + } + people, ok := castMap[lookupID] + if !ok || len(people) == 0 { + return []swipeCardCastMember{} + } + + cast := make([]swipeCardCastMember, 0, maxCastPerCard) + for _, p := range people { + if p.Kind != models.PersonKindActor { + continue + } + cast = append(cast, swipeCardCastMember{ + Name: p.Name, + Character: p.Character, + }) + if len(cast) >= maxCastPerCard { + break + } + } + return cast +} + +// parseGenreParams validates and deduplicates genre query params. +func parseGenreParams(raw []string) []string { + if len(raw) == 0 { + return nil + } + seen := make(map[string]struct{}, len(raw)) + genres := make([]string, 0, len(raw)) + for _, g := range raw { + g = strings.TrimSpace(g) + if _, valid := knownGenres[g]; !valid { + continue + } + if _, dup := seen[g]; dup { + continue + } + seen[g] = struct{}{} + genres = append(genres, g) + } + return genres +} + +func discoverCandidatePoolSize(limit int, genreCount int) int { + multiplier := 15 + if genreCount > 0 { + multiplier = 20 + } + + size := limit * multiplier + if size < discoverMinPoolSize { + size = discoverMinPoolSize + } + if size > discoverMaxPoolSize { + size = discoverMaxPoolSize + } + return size +} + +func mapKeys(values map[string]struct{}) []string { + if len(values) == 0 { + return nil + } + + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + +// parseExcludeIDs extracts and caps the exclude_ids query params. +func parseExcludeIDs(raw []string) map[string]struct{} { + if len(raw) == 0 { + return nil + } + ids := make(map[string]struct{}, len(raw)) + for _, id := range raw { + id = strings.TrimSpace(id) + if id == "" { + continue + } + ids[id] = struct{}{} + if len(ids) >= maxExcludeIDs { + break + } + } + return ids +} diff --git a/internal/api/handlers/watch_tonight_cards_test.go b/internal/api/handlers/watch_tonight_cards_test.go new file mode 100644 index 00000000..c226a791 --- /dev/null +++ b/internal/api/handlers/watch_tonight_cards_test.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/recommendations" +) + +func TestRecommendationCardItemsPreservesRankedOrder(t *testing.T) { + candidates := []recommendations.ScoredItem{ + {MediaItemID: "multi-genre-match", Score: 0.4}, + {MediaItemID: "excluded", Score: 0.99}, + {MediaItemID: "single-genre-high-score", Score: 0.8}, + {MediaItemID: "single-genre-low-score", Score: 0.2}, + } + excluded := map[string]struct{}{"excluded": {}} + + cards := recommendationCardItems(candidates, excluded) + + got := make([]string, len(cards)) + for i, card := range cards { + got[i] = card.scored.MediaItemID + } + want := []string{"multi-genre-match", "single-genre-high-score", "single-genre-low-score"} + if len(got) != len(want) { + t.Fatalf("got %d cards, want %d: %#v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("card order = %#v, want %#v", got, want) + } + } +} diff --git a/internal/api/handlers/webhook_sync.go b/internal/api/handlers/webhook_sync.go new file mode 100644 index 00000000..bbcf6223 --- /dev/null +++ b/internal/api/handlers/webhook_sync.go @@ -0,0 +1,665 @@ +package handlers + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/historyimport" + "github.com/Silo-Server/silo-server/internal/webhooksync" +) + +type WebhookSyncHandler struct { + service *webhooksync.Service +} + +type legacyPlexSyncConnection struct { + ID string `json:"id"` + PlexServerID string `json:"plex_server_id"` + PlexServerName string `json:"plex_server_name"` + WebhookURL string `json:"webhook_url,omitempty"` + BindingsReady bool `json:"bindings_ready"` + ActorCount int `json:"actor_count"` + AccountDiscoveryAvailable bool `json:"account_discovery_available"` + LastWebhookReceivedAt *time.Time `json:"last_webhook_received_at,omitempty"` + LastWebhookErrorAt *time.Time `json:"last_webhook_error_at,omitempty"` + LastWebhookErrorMessage string `json:"last_webhook_error_message,omitempty"` + LastWritebackAt *time.Time `json:"last_writeback_at,omitempty"` + LastWritebackErrorAt *time.Time `json:"last_writeback_error_at,omitempty"` + LastWritebackErrorMessage string `json:"last_writeback_error_message,omitempty"` + LastBindingRefreshAt *time.Time `json:"last_binding_refresh_at,omitempty"` + LastBindingRefreshErrorAt *time.Time `json:"last_binding_refresh_error_at,omitempty"` + LastBindingRefreshErrorMessage string `json:"last_binding_refresh_error_message,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type legacyPlexSyncActorMapping struct { + ID int `json:"id"` + ConnectionID string `json:"connection_id"` + PlexAccountID int64 `json:"plex_account_id"` + PlexAccountTitle string `json:"plex_account_title"` + SiloProfileID string `json:"silo_profile_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type legacyPlexSyncDiscoveredActor struct { + PlexAccountID int64 `json:"plex_account_id"` + PlexAccountTitle string `json:"plex_account_title"` +} + +type legacyCreatePlexSyncConnectionRequest struct { + PlexServerID string `json:"plex_server_id"` + PlexServerName string `json:"plex_server_name"` + PlexBaseURL string `json:"plex_base_url"` + PlexToken string `json:"plex_token"` + DefaultProfileID string `json:"default_profile_id"` +} + +type legacyCreatePlexSyncConnectionResponse struct { + Connection legacyPlexSyncConnection `json:"connection"` + DefaultActorMapping legacyPlexSyncActorMapping `json:"default_actor_mapping"` + WebhookURL string `json:"webhook_url"` +} + +type legacyRotatePlexSyncWebhookResponse struct { + WebhookURL string `json:"webhook_url"` +} + +type legacyPlexSyncActorsResponse struct { + Mappings []legacyPlexSyncActorMapping `json:"mappings"` + DiscoveredActors []legacyPlexSyncDiscoveredActor `json:"discovered_actors"` + AccountDiscoveryAvailable bool `json:"account_discovery_available"` +} + +type legacyUpdatePlexSyncActorsRequest struct { + Mappings []struct { + PlexAccountID int64 `json:"plex_account_id"` + PlexAccountTitle string `json:"plex_account_title"` + SiloProfileID string `json:"silo_profile_id"` + } `json:"mappings"` +} + +func NewWebhookSyncHandler(service *webhooksync.Service) *WebhookSyncHandler { + return &WebhookSyncHandler{service: service} +} + +func (h *WebhookSyncHandler) HandleListConnections(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + connections, err := h.service.ListConnections(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list webhook sync connections") + return + } + baseURL := requestBaseURL(r) + for i := range connections { + connections[i].WebhookURL = requestWebhookURL(baseURL, connections[i].WebhookSecret) + } + writeJSON(w, http.StatusOK, connections) +} + +func (h *WebhookSyncHandler) HandleLegacyListConnections(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + connections, err := h.service.ListConnections(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list Plex sync connections") + return + } + baseURL := requestBaseURL(r) + legacyConnections := make([]legacyPlexSyncConnection, 0, len(connections)) + for _, connection := range connections { + if connection.Provider != webhooksync.ProviderPlex { + continue + } + legacyConnections = append(legacyConnections, toLegacyPlexConnection(connection, requestWebhookURLWithPrefix(baseURL, legacyPlexSyncPathPrefix, connection.WebhookSecret))) + } + writeJSON(w, http.StatusOK, legacyConnections) +} + +func (h *WebhookSyncHandler) HandleCreateConnection(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + var req webhooksync.CreateConnectionInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + resp, err := h.service.CreateConnection(r.Context(), userID, req, requestBaseURL(r)) + if err != nil { + h.writeError(w, err) + return + } + resp.Connection.WebhookURL = resp.WebhookURL + writeJSON(w, http.StatusCreated, resp) +} + +func (h *WebhookSyncHandler) HandleLegacyCreateConnection(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + if userID == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + var req legacyCreatePlexSyncConnectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + resp, err := h.service.CreateConnection(r.Context(), userID, webhooksync.CreateConnectionInput{ + Provider: webhooksync.ProviderPlex, + ServerID: req.PlexServerID, + ServerName: req.PlexServerName, + BaseURL: req.PlexBaseURL, + AccessToken: req.PlexToken, + DefaultProfileID: req.DefaultProfileID, + }, requestBaseURL(r)) + if err != nil { + h.writeError(w, err) + return + } + actors, err := h.service.GetActors(r.Context(), userID, resp.Connection.ID) + if err != nil { + h.writeError(w, err) + return + } + webhookURL := requestWebhookURLWithPrefix(requestBaseURL(r), legacyPlexSyncPathPrefix, resp.Connection.WebhookSecret) + writeJSON(w, http.StatusCreated, legacyCreatePlexSyncConnectionResponse{ + Connection: toLegacyPlexConnection(resp.Connection, webhookURL), + DefaultActorMapping: firstLegacyMappedActor(actors.Mappings), + WebhookURL: webhookURL, + }) +} + +func (h *WebhookSyncHandler) HandleUpdateConnection(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + var req webhooksync.UpdateConnectionInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + conn, err := h.service.UpdateConnection(r.Context(), userID, id, req) + if err != nil { + h.writeError(w, err) + return + } + conn.WebhookURL = requestWebhookURL(requestBaseURL(r), conn.WebhookSecret) + writeJSON(w, http.StatusOK, conn) +} + +func (h *WebhookSyncHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + if err := h.service.DeleteConnection(r.Context(), userID, id); err != nil { + h.writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *WebhookSyncHandler) HandleLegacyDeleteConnection(w http.ResponseWriter, r *http.Request) { + h.HandleDeleteConnection(w, r) +} + +func (h *WebhookSyncHandler) HandleRotateWebhook(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + resp, err := h.service.RotateWebhook(r.Context(), userID, id, requestBaseURL(r)) + if err != nil { + h.writeError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *WebhookSyncHandler) HandleLegacyRotateWebhook(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + resp, err := h.service.RotateWebhook(r.Context(), userID, id, requestBaseURL(r)) + if err != nil { + h.writeError(w, err) + return + } + writeJSON(w, http.StatusOK, legacyRotatePlexSyncWebhookResponse{ + WebhookURL: strings.TrimRight(requestBaseURL(r), "/") + legacyPlexSyncPathPrefix + secretFromWebhookURL(resp.WebhookURL), + }) +} + +func (h *WebhookSyncHandler) HandleGetActors(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + resp, err := h.service.GetActors(r.Context(), userID, id) + if err != nil { + h.writeError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *WebhookSyncHandler) HandleListEvents(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + limit := parseLimit(r, 50) + if limit > 200 { + limit = 200 + } + resp, err := h.service.ListEventLogs(r.Context(), userID, id, limit) + if err != nil { + h.writeError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *WebhookSyncHandler) HandleLegacyGetActors(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + resp, err := h.service.GetActors(r.Context(), userID, id) + if err != nil { + h.writeError(w, err) + return + } + writeJSON(w, http.StatusOK, toLegacyPlexActorsResponse(resp)) +} + +func (h *WebhookSyncHandler) HandleUpdateActors(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + var req webhooksync.UpdateActorMappingsInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + resp, err := h.service.UpdateActors(r.Context(), userID, id, req) + if err != nil { + h.writeError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *WebhookSyncHandler) HandleLegacyUpdateActors(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") + return + } + var req legacyUpdatePlexSyncActorsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + input := webhooksync.UpdateActorMappingsInput{ + Mappings: make([]webhooksync.UpdateActorMapping, 0, len(req.Mappings)), + } + for _, mapping := range req.Mappings { + profileID := mapping.SiloProfileID + input.Mappings = append(input.Mappings, webhooksync.UpdateActorMapping{ + ExternalActorID: strconv.FormatInt(mapping.PlexAccountID, 10), + ExternalActorName: mapping.PlexAccountTitle, + SiloProfileID: &profileID, + }) + } + resp, err := h.service.UpdateActors(r.Context(), userID, id, input) + if err != nil { + h.writeError(w, err) + return + } + writeJSON(w, http.StatusOK, toLegacyPlexActorMappings(resp)) +} + +func (h *WebhookSyncHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { + secret := chi.URLParam(r, "secret") + if secret == "" { + http.NotFound(w, r) + return + } + capture := webhooksync.NewBodyCaptureReadCloser(r.Body, webhooksync.WebhookBodyCaptureLimit) + r.Body = capture + + result, err := h.service.ProcessWebhook(r.Context(), secret, r) + logContext := webhooksync.BuildWebhookRequestLogContext( + r, + webhooksync.SanitizeWebhookBodyExcerpt(r.Header.Get("Content-Type"), capture.Captured()), + ) + statusCode := http.StatusNoContent + if err != nil { + statusCode = webhookErrorStatus(err) + } + + if result != nil { + if _, logErr := h.service.CreateEventLog(r.Context(), webhooksync.WebhookEventLog{ + ConnectionID: result.ConnectionID, + RequestID: logContext.RequestID, + HTTPStatus: statusCode, + Outcome: result.Outcome, + Summary: result.Summary, + ErrorMessage: result.ErrorMessage, + BodyExcerpt: logContext.BodyExcerpt, + Attrs: webhookEventAttrs(logContext, result), + }); logErr != nil { + slog.Warn("webhook sync: failed to persist webhook event log", "connection_id", result.ConnectionID, "error", logErr) + } + } + logWebhookDelivery(result, logContext, statusCode, err) + + if err != nil { + h.writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *WebhookSyncHandler) writeError(w http.ResponseWriter, err error) { + statusCode := webhookErrorStatus(err) + switch { + case errors.Is(err, webhooksync.ErrConnectionNotFound), errors.Is(err, historyimport.ErrProfileNotFound): + writeError(w, statusCode, "not_found", err.Error()) + default: + message := err.Error() + switch { + case message == "invalid multipart request", + message == "missing payload", + message == "invalid Plex payload", + message == "invalid Plex webhook payload", + message == "invalid Emby payload", + message == "invalid Emby webhook payload", + message == "invalid Jellyfin payload", + message == "invalid Jellyfin webhook payload", + strings.Contains(message, "are required"), + strings.Contains(message, "cannot be empty"), + strings.Contains(message, "unsupported provider"): + writeError(w, statusCode, "bad_request", err.Error()) + default: + writeError(w, statusCode, "internal_error", err.Error()) + } + } +} + +func webhookErrorStatus(err error) int { + switch { + case errors.Is(err, webhooksync.ErrConnectionNotFound), errors.Is(err, historyimport.ErrProfileNotFound): + return http.StatusNotFound + default: + message := err.Error() + switch { + case message == "invalid multipart request", + message == "missing payload", + message == "invalid Plex payload", + message == "invalid Plex webhook payload", + message == "invalid Emby payload", + message == "invalid Emby webhook payload", + message == "invalid Jellyfin payload", + message == "invalid Jellyfin webhook payload", + strings.Contains(message, "are required"), + strings.Contains(message, "cannot be empty"), + strings.Contains(message, "unsupported provider"): + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } + } +} + +func webhookEventAttrs(meta webhooksync.WebhookRequestLogContext, result *webhooksync.ProcessWebhookResult) map[string]any { + attrs := map[string]any{ + "client_ip": meta.ClientIP, + "content_type": meta.ContentType, + "user_agent": meta.UserAgent, + "path_pattern": meta.PathPattern, + } + if result == nil { + return attrs + } + if result.EventKind != "" { + attrs["event_kind"] = result.EventKind + } + if result.Action != "" { + attrs["action"] = result.Action + } + if result.ActorID != "" { + attrs["actor_id"] = result.ActorID + } + if result.ActorName != "" { + attrs["actor_name"] = result.ActorName + } + if result.ExternalItemID != "" { + attrs["external_item_id"] = result.ExternalItemID + } + if result.MediaKind != "" { + attrs["media_kind"] = result.MediaKind + } + if result.MatchedMediaItemID != "" { + attrs["matched_media_item_id"] = result.MatchedMediaItemID + } + if result.MatchedMediaItemTitle != "" { + attrs["matched_media_item_title"] = result.MatchedMediaItemTitle + } + if result.ProfileID != "" { + attrs["profile_id"] = result.ProfileID + } + return attrs +} + +func logWebhookDelivery(result *webhooksync.ProcessWebhookResult, meta webhooksync.WebhookRequestLogContext, statusCode int, err error) { + args := []any{ + "component", "webhook_sync", + "request_id", meta.RequestID, + "http_status", statusCode, + "path_pattern", meta.PathPattern, + "client_ip", meta.ClientIP, + "content_type", meta.ContentType, + } + if result != nil { + args = append(args, + "connection_id", result.ConnectionID, + "provider", result.Provider, + "outcome", result.Outcome, + "summary", result.Summary, + ) + if result.EventKind != "" { + args = append(args, "event_kind", result.EventKind) + } + if result.Action != "" { + args = append(args, "action", result.Action) + } + if result.ActorID != "" { + args = append(args, "actor_id", result.ActorID) + } + if result.ExternalItemID != "" { + args = append(args, "external_item_id", result.ExternalItemID) + } + if result.MatchedMediaItemID != "" { + args = append(args, "matched_media_item_id", result.MatchedMediaItemID) + } + if result.ErrorMessage != "" { + args = append(args, "error", result.ErrorMessage) + } + } else { + args = append(args, + "outcome", webhooksync.OutcomeRejected, + "summary", "Rejected webhook request because the secret was not found", + ) + if err != nil { + args = append(args, "error", err.Error()) + } + } + + switch { + case statusCode >= 500: + slog.Error("webhook delivery", args...) + case statusCode >= 400: + slog.Warn("webhook delivery", args...) + default: + slog.Info("webhook delivery", args...) + } +} + +func requestBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + scheme = forwarded + } + host := r.Host + if forwardedHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" { + host = forwardedHost + } + return scheme + "://" + host +} + +func requestWebhookURL(baseURL, secret string) string { + return requestWebhookURLWithPrefix(baseURL, webhookSyncPathPrefix, secret) +} + +func requestWebhookURLWithPrefix(baseURL, prefix, secret string) string { + if secret == "" { + return "" + } + return strings.TrimRight(baseURL, "/") + prefix + secret +} + +const ( + webhookSyncPathPrefix = "/api/v1/webhook-sync/webhooks/" + legacyPlexSyncPathPrefix = "/api/v1/plex-sync/webhooks/" +) + +func toLegacyPlexConnection(connection webhooksync.Connection, webhookURL string) legacyPlexSyncConnection { + return legacyPlexSyncConnection{ + ID: connection.ID, + PlexServerID: connection.ServerID, + PlexServerName: connection.ServerName, + WebhookURL: webhookURL, + BindingsReady: false, + ActorCount: connection.ActorCount, + AccountDiscoveryAvailable: connection.AccountDiscoveryAvailable, + LastWebhookReceivedAt: connection.LastWebhookReceivedAt, + LastWebhookErrorAt: connection.LastWebhookErrorAt, + LastWebhookErrorMessage: connection.LastWebhookErrorMessage, + CreatedAt: connection.CreatedAt, + UpdatedAt: connection.UpdatedAt, + } +} + +func toLegacyPlexActorsResponse(resp *webhooksync.ActorMappingsResponse) legacyPlexSyncActorsResponse { + if resp == nil { + return legacyPlexSyncActorsResponse{} + } + return legacyPlexSyncActorsResponse{ + Mappings: toLegacyPlexActorMappings(resp.Mappings), + DiscoveredActors: toLegacyPlexDiscoveredActors(resp.DiscoveredActors), + AccountDiscoveryAvailable: resp.AccountDiscoveryAvailable, + } +} + +func toLegacyPlexActorMappings(mappings []webhooksync.ActorMapping) []legacyPlexSyncActorMapping { + if len(mappings) == 0 { + return nil + } + out := make([]legacyPlexSyncActorMapping, 0, len(mappings)) + for _, mapping := range mappings { + accountID, err := strconv.ParseInt(mapping.ExternalActorID, 10, 64) + if err != nil { + continue + } + out = append(out, legacyPlexSyncActorMapping{ + ID: mapping.ID, + ConnectionID: mapping.ConnectionID, + PlexAccountID: accountID, + PlexAccountTitle: mapping.ExternalActorName, + SiloProfileID: valueOrEmpty(mapping.SiloProfileID), + CreatedAt: mapping.CreatedAt, + UpdatedAt: mapping.UpdatedAt, + }) + } + return out +} + +func toLegacyPlexDiscoveredActors(actors []webhooksync.DiscoveredActor) []legacyPlexSyncDiscoveredActor { + if len(actors) == 0 { + return nil + } + out := make([]legacyPlexSyncDiscoveredActor, 0, len(actors)) + for _, actor := range actors { + accountID, err := strconv.ParseInt(actor.ExternalActorID, 10, 64) + if err != nil { + continue + } + out = append(out, legacyPlexSyncDiscoveredActor{ + PlexAccountID: accountID, + PlexAccountTitle: actor.ExternalActorName, + }) + } + return out +} + +func firstLegacyMappedActor(mappings []webhooksync.ActorMapping) legacyPlexSyncActorMapping { + legacyMappings := toLegacyPlexActorMappings(mappings) + if len(legacyMappings) == 0 { + return legacyPlexSyncActorMapping{} + } + return legacyMappings[0] +} + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} + +func secretFromWebhookURL(raw string) string { + if raw == "" { + return "" + } + idx := strings.LastIndex(raw, "/") + if idx < 0 || idx == len(raw)-1 { + return "" + } + return raw[idx+1:] +} diff --git a/internal/api/handlers/webhook_sync_test.go b/internal/api/handlers/webhook_sync_test.go new file mode 100644 index 00000000..53fe0ff4 --- /dev/null +++ b/internal/api/handlers/webhook_sync_test.go @@ -0,0 +1,54 @@ +package handlers + +import ( + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/webhooksync" +) + +func TestRequestWebhookURLWithPrefix(t *testing.T) { + t.Parallel() + + if got := requestWebhookURLWithPrefix("https://example.com/", legacyPlexSyncPathPrefix, "secret"); got != "https://example.com/api/v1/plex-sync/webhooks/secret" { + t.Fatalf("unexpected legacy webhook URL: %q", got) + } + if got := requestWebhookURLWithPrefix("https://example.com/", webhookSyncPathPrefix, "secret"); got != "https://example.com/api/v1/webhook-sync/webhooks/secret" { + t.Fatalf("unexpected generic webhook URL: %q", got) + } +} + +func TestToLegacyPlexActorsResponse(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 7, 12, 0, 0, 0, time.UTC) + profileID := "profile-1" + resp := toLegacyPlexActorsResponse(&webhooksync.ActorMappingsResponse{ + Mappings: []webhooksync.ActorMapping{ + { + ID: 11, + ConnectionID: "conn-1", + ExternalActorID: "42", + ExternalActorName: "Alice", + SiloProfileID: &profileID, + CreatedAt: now, + UpdatedAt: now, + }, + }, + DiscoveredActors: []webhooksync.DiscoveredActor{ + {ExternalActorID: "42", ExternalActorName: "Alice"}, + {ExternalActorID: "77", ExternalActorName: "Bob"}, + }, + AccountDiscoveryAvailable: true, + }) + + if !resp.AccountDiscoveryAvailable { + t.Fatalf("expected discovery to be available") + } + if len(resp.Mappings) != 1 || resp.Mappings[0].PlexAccountID != 42 || resp.Mappings[0].SiloProfileID != "profile-1" { + t.Fatalf("unexpected legacy mappings: %#v", resp.Mappings) + } + if len(resp.DiscoveredActors) != 2 || resp.DiscoveredActors[1].PlexAccountID != 77 { + t.Fatalf("unexpected legacy discovered actors: %#v", resp.DiscoveredActors) + } +} diff --git a/internal/api/handlers/ws_shared.go b/internal/api/handlers/ws_shared.go new file mode 100644 index 00000000..046e0d69 --- /dev/null +++ b/internal/api/handlers/ws_shared.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +const ( + wsPingInterval = 20 * time.Second + wsPongTimeout = 10 * time.Second + wsWriteTimeout = 5 * time.Second +) + +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: checkWebSocketOrigin, +} + +func checkWebSocketOrigin(r *http.Request) bool { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + + originURL, err := url.Parse(origin) + if err != nil { + return false + } + + return strings.EqualFold(originURL.Host, r.Host) +} + +func configureWebSocket(conn *websocket.Conn) { + if conn == nil { + return + } + + _ = conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout)) + }) +} + +func startWebSocketPingLoop(ctx context.Context, writePing func() error) { + if writePing == nil { + return + } + + go func() { + ticker := time.NewTicker(wsPingInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := writePing(); err != nil { + return + } + } + } + }() +} + +func writeWebSocketError(conn *websocket.Conn, code, message string) { + if conn == nil { + return + } + _ = conn.WriteJSON(map[string]string{ + "type": "error", + "code": code, + "message": message, + }) +} + +func writeWebSocketJSON(conn *websocket.Conn, value any) error { + if conn == nil { + return websocket.ErrCloseSent + } + if err := conn.SetWriteDeadline(time.Now().Add(wsWriteTimeout)); err != nil { + return err + } + defer conn.SetWriteDeadline(time.Time{}) + return conn.WriteJSON(value) +} + +func writeWebSocketControl(conn *websocket.Conn, messageType int, data []byte) error { + if conn == nil { + return websocket.ErrCloseSent + } + return conn.WriteControl(messageType, data, time.Now().Add(wsWriteTimeout)) +} + +func readWebSocketJSON[T any](data []byte, target *T) error { + return json.Unmarshal(data, target) +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go new file mode 100644 index 00000000..58a86f22 --- /dev/null +++ b/internal/api/middleware/auth.go @@ -0,0 +1,246 @@ +// Package middleware provides HTTP middleware for the Silo API, +// including authentication and authorization. +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/Silo-Server/silo-server/internal/activitylog" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +// contextKey is an unexported type for context keys in this package. +type contextKey string + +// claimsKey is the context key for storing JWT claims. +const claimsKey contextKey = "claims" + +// SessionValidator checks whether a session is still valid (not revoked/expired). +type SessionValidator interface { + IsValid(ctx context.Context, sessionID string) (bool, error) +} + +// TokenValidator validates a JWT token string and returns the parsed claims. +type TokenValidator interface { + ValidateToken(tokenStr string) (*auth.Claims, error) +} + +// APIKeyValidator looks up an API key by its full key string. +type APIKeyValidator interface { + GetByKey(ctx context.Context, key string) (*models.APIKey, error) + UpdateLastUsed(ctx context.Context, id int64) error +} + +// APIKeyUserLoader loads a user by ID for API key authentication. +type APIKeyUserLoader interface { + GetByID(ctx context.Context, id int) (*models.User, error) +} + +// AuthMiddleware provides HTTP middleware for JWT-based authentication with +// session validity caching. +type AuthMiddleware struct { + tokenValidator TokenValidator + sessionValidator SessionValidator + apiKeyValidator APIKeyValidator // nil if API keys not configured + apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured +} + +// NewAuthMiddleware creates a new AuthMiddleware with the given token validator +// and session validator. +func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware { + return &AuthMiddleware{ + tokenValidator: tv, + sessionValidator: sv, + apiKeyValidator: akv, + apiKeyUserLoader: akul, + } +} + +// RequireAuth is an HTTP middleware that enforces JWT authentication. +// It extracts the Bearer token from the Authorization header, validates the +// JWT, checks session validity (with an in-memory cache), and sets the +// parsed claims in the request context for downstream handlers. +func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := extractBearerToken(r) + if !ok { + writeUnauthorized(w, "Missing or malformed authorization header") + return + } + + var claims *auth.Claims + + if strings.HasPrefix(token, "sa_") { + // API key authentication. + if am.apiKeyValidator == nil { + writeUnauthorized(w, "API key authentication not available") + return + } + + apiKey, err := am.apiKeyValidator.GetByKey(r.Context(), token) + if err != nil { + writeUnauthorized(w, "Invalid API key") + return + } + + user, err := am.apiKeyUserLoader.GetByID(r.Context(), apiKey.UserID) + if err != nil { + writeUnauthorized(w, "Invalid API key") + return + } + + if !user.Enabled { + writeUnauthorized(w, "User account is disabled") + return + } + + // Update last_used_at asynchronously. + go func(id int64) { + _ = am.apiKeyValidator.UpdateLastUsed(context.Background(), id) + }(apiKey.ID) + + claims = &auth.Claims{ + UserID: user.ID, + Role: user.Role, + SessionID: "", + TokenType: auth.TokenTypeAPIKey, + APIKeyID: apiKey.ID, + RateTier: apiKey.RateTier, + } + } else { + // JWT authentication (existing flow). + var err error + claims, err = am.tokenValidator.ValidateToken(token) + if err != nil { + writeUnauthorized(w, "Invalid or expired token") + return + } + if claims.TokenType != auth.TokenTypeAccess { + writeUnauthorized(w, "Invalid or expired token") + return + } + + valid, err := am.checkSession(r.Context(), claims.SessionID) + if err != nil || !valid { + writeUnauthorized(w, "Session is no longer valid") + return + } + } + + // Populate activity log context if present (set by activitylog middleware upstream) + if lc := activitylog.GetLogContext(r.Context()); lc != nil { + uid := claims.UserID + lc.UserID = &uid + lc.ImpersonatorUserID = claims.ImpersonatorUserID + lc.SessionID = claims.SessionID + } + + ctx := context.WithValue(r.Context(), claimsKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireAdmin is a standalone HTTP middleware that checks if the authenticated +// user has the "admin" role. It expects RequireAuth to have already placed +// claims in the request context. +func RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r.Context()) + if claims == nil { + writeUnauthorized(w, "Authentication required") + return + } + + if claims.Role != "admin" { + writeForbidden(w, "Admin access required") + return + } + + next.ServeHTTP(w, r) + }) +} + +// SetClaims stores JWT claims in the context. This is useful for testing +// handlers that depend on authentication without going through the full +// middleware chain. +func SetClaims(ctx context.Context, claims *auth.Claims) context.Context { + return context.WithValue(ctx, claimsKey, claims) +} + +// GetClaims retrieves the JWT claims from the context. Returns nil if no +// claims are present (caller should handle this case). +func GetClaims(ctx context.Context) *auth.Claims { + claims, ok := ctx.Value(claimsKey).(*auth.Claims) + if !ok { + return nil + } + return claims +} + +// GetUserID retrieves the user ID from the JWT claims in the context. +// Returns 0 if no claims are present. +func GetUserID(ctx context.Context) int { + claims := GetClaims(ctx) + if claims == nil { + return 0 + } + return claims.UserID +} + +// checkSession checks whether the session is valid, using the in-memory cache +// first and falling back to the session validator on cache miss. +func (am *AuthMiddleware) checkSession(ctx context.Context, sessionID string) (bool, error) { + return am.sessionValidator.IsValid(ctx, sessionID) +} + +// extractBearerToken extracts a JWT from the request. It checks (in order): +// 1. Authorization: Bearer header +// 2. ?token= query parameter (for native media elements that can't set headers) +func extractBearerToken(r *http.Request) (string, bool) { + // Try Authorization header first. + if header := r.Header.Get("Authorization"); header != "" { + parts := strings.SplitN(header, " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") { + if token := strings.TrimSpace(parts[1]); token != "" { + return token, true + } + } + } + + // Fall back to query parameter (used by