Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe934b59d3 |
@@ -3,7 +3,6 @@ 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 ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
@@ -23,7 +22,6 @@ 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 ./
|
||||
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
@@ -20,7 +19,6 @@ 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 ./
|
||||
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
+6
-21
@@ -47,7 +47,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/database"
|
||||
"github.com/Silo-Server/silo-server/internal/ebooks"
|
||||
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"
|
||||
@@ -73,6 +72,8 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/radarr"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
|
||||
"github.com/Silo-Server/silo-server/internal/s3client"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
@@ -670,7 +671,6 @@ func main() {
|
||||
|
||||
deps.ProxyPool = proxyPool
|
||||
deps.TranscodePool = transcodePool
|
||||
deps.NodePlanner = nodepool.NewPlanner(proxyPool, transcodePool)
|
||||
|
||||
healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo)
|
||||
healthChecker.Start(appCtx)
|
||||
@@ -943,7 +943,6 @@ func main() {
|
||||
var seasonRepo *catalog.SeasonRepository
|
||||
var episodeRepo *catalog.EpisodeRepository
|
||||
var audiobookEnricher *audiobooks.Enricher
|
||||
var ebookEnricher *ebooks.Enricher
|
||||
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
|
||||
chainRepo := metadata.NewChainRepository(deps.DB)
|
||||
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
|
||||
@@ -1025,14 +1024,6 @@ func main() {
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
ebookEnricher = ebooks.NewEnricher(
|
||||
deps.DB,
|
||||
chainRepo,
|
||||
pluginResolver,
|
||||
itemRepo,
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
|
||||
// Always wire the image resolver so plugin-prefixed URLs (e.g.
|
||||
// metadb://) can be resolved to presigned HTTP URLs in API responses.
|
||||
@@ -1055,9 +1046,6 @@ func main() {
|
||||
audiobookEnricher.SetImageCacher(imageCacher)
|
||||
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
ebookEnricher.SetImageCacher(imageCacher)
|
||||
}
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
@@ -1553,8 +1541,7 @@ func main() {
|
||||
catalog.NewProviderIDRepository(deps.DB),
|
||||
),
|
||||
)
|
||||
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
api.AttachRequestRouter(requestReconcileSvc, pluginService)
|
||||
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
if userStoreProvider != nil {
|
||||
reconcileResolver := access.NewResolver(
|
||||
auth.NewUserRepository(deps.DB),
|
||||
@@ -1600,9 +1587,6 @@ func main() {
|
||||
if audiobookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
|
||||
if err != nil {
|
||||
@@ -1898,7 +1882,8 @@ func main() {
|
||||
DB: deps.DB,
|
||||
SecretCipher: dataCipher,
|
||||
ClientIPResolver: ipResolver,
|
||||
NodePlanner: deps.NodePlanner,
|
||||
ProxyPool: deps.ProxyPool,
|
||||
TranscodePool: deps.TranscodePool,
|
||||
JWTSecret: cfg.Auth.JWTSecret,
|
||||
RecWorker: recWorker,
|
||||
}
|
||||
@@ -1954,7 +1939,7 @@ func main() {
|
||||
// Construct auth service for jellycompat login.
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
|
||||
compatDeps.UserLoader = userRepo
|
||||
compatDeps.APIKeyUserLoader = userRepo
|
||||
compatDeps.ScanQueue = deps.LibraryScanQueue
|
||||
sessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
jwtService := auth.NewJWTService(
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# Wholphin → jellycompat Endpoint Coverage Report
|
||||
|
||||
Date: 2026-06-09
|
||||
Scope: every Jellyfin server endpoint the Wholphin Android TV client (jellyfin-sdk-kotlin based) can call, cross-referenced against the routes registered in `internal/jellycompat/router.go`. Wholphin source analyzed from a local clone of `damontecres/Wholphin` (`app/src/main`). Commands assume the repository root is the cwd.
|
||||
|
||||
Wholphin's Seerr integration (`/api/v1/...` discover/request endpoints) targets a separate Seerr server, not Jellyfin, and is out of scope here.
|
||||
|
||||
## Methodology
|
||||
|
||||
1. Grepped Wholphin for every `*Api.method()` SDK call (`itemsApi`, `playStateApi`, etc.), websocket subscriptions, and raw URL construction; mapped each to its HTTP endpoint per the Jellyfin OpenAPI spec.
|
||||
2. Inventoried every route registered in `internal/jellycompat/router.go`, including path normalization (`path_normalization.go` handles casing and `/emby`//`jellyfin` prefixes).
|
||||
3. For each endpoint Wholphin uses but silo does not serve, checked whether a server-side gate (user policy flag, DTO field) prevents the client from ever calling it.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Covered (full):** 22 endpoint families — everything on the core path: auth, browse, item detail, episodes/next-up, resume, playback info, direct/HLS streaming, subtitles, progress reporting, favorites/played, images, genres, persons, suggestions, media segments (skip intro), display preferences.
|
||||
- **Covered (partial/stub):** 4 — websocket (keep-alive only, no remote control), `/Studios`, `/Users/Public`, `/QuickConnect/Enabled` (deliberate `false`).
|
||||
- **Missing but safely gated:** 8 — the client never calls them because we don't advertise the capability (delete, subtitle download, trickplay, local trailers, additional parts, quick connect auth, live TV, playlists).
|
||||
- **Missing and reachable:** 4 — theme songs, universal audio stream, item download, client log upload. These return 404 when the client calls them. None block video playback.
|
||||
|
||||
**Bottom line: the entire core video path is covered.** The reachable gaps are quality-of-life features (theme songs, photo screensaver, debug log upload) plus one advertised-but-broken inconsistency (`CanDownload=true` with no `/Items/{id}/Download` route).
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
### Fully covered
|
||||
|
||||
| Wholphin call | Endpoint | silo handler |
|
||||
|---|---|---|
|
||||
| `userApi.authenticateUserByName` | `POST /Users/AuthenticateByName` | `handlers_auth.go` |
|
||||
| `userApi.getCurrentUser` | `GET /Users/Me` | `handlers_auth.go` |
|
||||
| `userApi.getUserById` | `GET /Users/{id}` | `handlers_auth.go` |
|
||||
| `systemApi.getPublicSystemInfo` | `GET /System/Info/Public` | `handlers_system.go` |
|
||||
| `sessionApi.postCapabilities` | `POST /Sessions/Capabilities` | `handlers_playback.go` |
|
||||
| `itemsApi.getItems` | `GET /Items` | `handlers_items.go` |
|
||||
| `itemsApi.getResumeItems` | `GET /UserItems/Resume` (+ legacy `/Users/{id}/Items/Resume`) | `handlers_items.go` |
|
||||
| `userLibraryApi.getItem` | `GET /Items/{id}` (+ legacy user-scoped form) | `handlers_items.go` |
|
||||
| `tvShowsApi.getEpisodes` | `GET /Shows/{id}/Episodes` | `handlers_items.go` |
|
||||
| `tvShowsApi.getNextUp` | `GET /Shows/NextUp` | `handlers_items.go` |
|
||||
| `suggestionsApi.getSuggestions` | `GET /Items/Suggestions` | `handlers_items.go` |
|
||||
| `genresApi.getGenres` | `GET /Genres` | `handlers_items.go` |
|
||||
| `personsApi.getPersons` | `GET /Persons` | `handlers_persons.go` (full when PersonRepo is wired; empty stub otherwise) |
|
||||
| `searchApi.getSearchHints` | `GET /Search/Hints` | `handlers_items.go` (Wholphin's call is currently commented out upstream) |
|
||||
| `mediaInfoApi.getPostedPlaybackInfo` | `POST /Items/{id}/PlaybackInfo` | `handlers_playback.go` |
|
||||
| `videosApi.getVideoStreamUrl` | `GET/HEAD /Videos/{id}/stream(.{container})` | `streams.go` |
|
||||
| (transcode path from PlaybackInfo) | `GET /Videos/{id}/master.m3u8` + HLS playlist/segments | `streams.go` |
|
||||
| (subtitle delivery URLs) | `GET /Videos/{id}/{source}/Subtitles/{idx}/stream.{fmt}` | `streams.go` |
|
||||
| `playStateApi.reportPlaybackStart/Progress/Stopped` | `POST /Sessions/Playing[/Progress|/Stopped]` | `streams.go` |
|
||||
| `playStateApi.markPlayedItem/markUnplayedItem` | `POST/DELETE /UserPlayedItems/{id}` (+ legacy) | `handlers_userdata.go` |
|
||||
| `userLibraryApi.markFavoriteItem/unmarkFavoriteItem` | `POST/DELETE /UserFavoriteItems/{id}` (+ legacy) | `handlers_userdata.go` |
|
||||
| `mediaSegmentsApi.getItemSegments` | `GET /MediaSegments/{id}` | `handlers_items.go` (skip intro/credits) |
|
||||
| `displayPreferencesApi.updateDisplayPreferences` | `GET/POST /DisplayPreferences/{id}` | `handlers_displayprefs.go` |
|
||||
| `imageApi.getItemImageUrl` | `GET /Items/{id}/Images/{type}[/{index}]` | `handlers_images.go` |
|
||||
| `imageApi.getUserImageUrl` | `GET /Users/{id}/Images/Primary` | `handlers_images.go` (placeholder avatar) |
|
||||
|
||||
### Partial / stub (graceful degradation)
|
||||
|
||||
| Endpoint | Status | Client impact |
|
||||
|---|---|---|
|
||||
| `GET /socket` (websocket) | Accepts upgrade, answers KeepAlive only (`handlers_websocket.go`) | Wholphin subscribes to `GeneralCommandMessage` and `PlaystateMessage` for server-initiated remote control. Connection stays healthy; remote-control commands are simply never delivered. No errors, feature inert. |
|
||||
| `GET /Studios` | Empty-array stub | Studio browse rows render empty. |
|
||||
| `GET /Users/Public` | Empty-array stub | No user tiles on the login screen; manual login works. |
|
||||
| `GET /QuickConnect/Enabled` | Hardcoded `false` | Deliberate: Wholphin hides Quick Connect UI, so the unimplemented `POST /QuickConnect/Authorize` and `POST /Users/AuthenticateWithQuickConnect` are never called. |
|
||||
|
||||
### Missing but safely gated (client never calls them)
|
||||
|
||||
| Endpoint | Why it's unreachable |
|
||||
|---|---|
|
||||
| `DELETE /Items/{id}` | We return `CanDelete=false` per item (`mapping.go`) and `EnableContentDeletion=false` in user policy (`handlers_auth.go`), so Wholphin hides delete UI. |
|
||||
| `POST /Items/{id}/RemoteSearch/Subtitles/{subtitleId}` | `EnableSubtitleManagement=false` in user policy hides subtitle search/download. |
|
||||
| `GET /Videos/{id}/Trickplay/...` | We never emit trickplay metadata on item DTOs, so the scrubber never requests tiles. |
|
||||
| `GET /Items/{id}/LocalTrailers` | Wholphin gates on `localTrailerCount > 0` (`TrailerService.kt`); we never set `LocalTrailerCount`. |
|
||||
| `GET /Videos/{id}/AdditionalParts` | Gated on `partCount > 1` (`PlaylistCreator.kt`); we never set `PartCount`. |
|
||||
| Quick Connect auth endpoints | Gated by `/QuickConnect/Enabled` = false. |
|
||||
| Live TV suite (`/LiveTv/Channels`, `/Programs`, `/Recordings`, timers) | We expose no live-TV views or channels, so the feature never activates. |
|
||||
| Playlists (`GET/POST/DELETE /Playlists/{id}/Items`) | We expose no playlist items in the catalog, so playlist screens are unreachable. Becomes a real gap the day silo grows playlists. |
|
||||
|
||||
### Missing and reachable (return 404 today)
|
||||
|
||||
| Endpoint | Wholphin caller | Impact | Severity |
|
||||
|---|---|---|---|
|
||||
| `GET /Items/{id}/ThemeSongs` | `ThemeSongPlayer.kt` | ~~404 → feature silently dead.~~ **Resolved 2026-06-09:** now stubbed with an empty `ThemeMediaResult` (including the `OwnerId` field jellyfin-sdk-kotlin requires). | Resolved |
|
||||
| `GET /Audio/{id}/universal` | `ThemeSongPlayer.kt`, `MusicService.kt` | Audio streaming for theme songs and music playback. No music libraries are exposed today, but this is the second half of the theme-song path and the blocker for any future audio support in jellycompat. | Low (today) |
|
||||
| `GET /Items/{id}/Download` | `SlideshowViewModel.kt`, `ScreensaverService.kt` | ~~Inconsistency: `CanDownload=true` while the route 404s.~~ **Resolved 2026-06-09:** a real `/Items/{id}/Download` route now serves the original file (`streams.go`), and `CanDownload=true` again. The earlier `CanDownload=false` workaround broke Infuse, which requires the flag for Direct Play. | Resolved |
|
||||
| `POST /ClientLog/Document` | `MediaReportService.kt`, `DebugPage.kt` | "Upload logs to server" debug action fails. | Low |
|
||||
| `GET /Audio/{id}/Lyrics` | `NowPlayingViewModel.kt` | Music-only; unreachable until audio libraries exist. | Low |
|
||||
|
||||
## Recommendations (priority order)
|
||||
|
||||
1. **Merge the repeated-`Fields` fix** (PR #110). `parseItemsQuery` still reads `q.Get("Fields")` (`internal/jellycompat/query.go`), which truncates jellyfin-sdk-kotlin's repeated query params and breaks Wholphin episode auto-advance. This is the only *covered* endpoint with a known correctness bug for this client. Follow up by sweeping the remaining single-value reads (`Ids`, `GenreIds`, `PersonIds`, `Filters`, `SortBy`, `SortOrder`) to `q.Values`, matching how `IncludeItemTypes`/`MediaTypes`/`ImageTypes` are already handled.
|
||||
2. ~~Resolve the `CanDownload` inconsistency~~ — **done 2026-06-09**: real `/Items/{id}/Download` route added; `CanDownload` stays `true` (Infuse requires it for Direct Play).
|
||||
3. ~~Stub `GET /Items/{id}/ThemeSongs`~~ — **done 2026-06-09**: empty `ThemeMediaResult` stub registered.
|
||||
4. **Websocket server-push** (remote control: server-initiated pause/stop, display messages) — tracked in issue #122.
|
||||
5. **Optional, later:** `POST /ClientLog/Document` accept-and-discard stub and real `/Studios` data. Playlists, lyrics, and universal audio only matter once silo exposes those content types through jellycompat.
|
||||
|
||||
## Notes
|
||||
|
||||
- Path normalization (`path_normalization.go`) already handles Wholphin's casing and any `/emby`//`jellyfin` prefixes, so none of the covered routes are at risk from URL-shape differences.
|
||||
- Unmatched jellycompat paths fall through to chi's default 404 with no body; jellyfin-sdk-kotlin surfaces these as `InvalidStatusException`, which Wholphin generally catches per-feature.
|
||||
@@ -0,0 +1,145 @@
|
||||
# First-Party Audio API
|
||||
|
||||
This API is the Silo-native contract for audiobook playback. Clients should use
|
||||
it for Silo first-party audio experiences instead of the Audiobookshelf
|
||||
compatibility API. Music can reuse the same session, track, and progress
|
||||
concepts when the music UX lands.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require normal Silo user authentication. Playback start, sync,
|
||||
close, and bookmark writes also require the active profile headers used by the
|
||||
first-party clients.
|
||||
|
||||
Track stream URLs are returned from the start response and are scoped to the
|
||||
created audio session. The server validates that the session still belongs to
|
||||
the authenticated user, and to the active profile when a profile is attached.
|
||||
|
||||
## Start Playback
|
||||
|
||||
`POST /api/v1/audio/playback/start`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"content_id": "128814341867700228",
|
||||
"start_position": 120,
|
||||
"restart": false
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"content_id": "128814341867700228",
|
||||
"type": "audiobook",
|
||||
"title": "Book Title",
|
||||
"subtitle": "Author or series subtitle",
|
||||
"poster_url": "https://server/api/v1/images/...",
|
||||
"backdrop_url": "https://server/api/v1/images/...",
|
||||
"total_duration_seconds": 8746,
|
||||
"resume_position_seconds": 120,
|
||||
"tracks": [
|
||||
{
|
||||
"index": 0,
|
||||
"file_id": 123,
|
||||
"file_name": "Part 1.mp3",
|
||||
"duration_seconds": 3600,
|
||||
"start_offset_seconds": 0,
|
||||
"stream_url": "/api/v1/audio/playback/{session_id}/tracks/0",
|
||||
"stream_type": "progressive",
|
||||
"play_method": "direct",
|
||||
"codec": "mp3",
|
||||
"container": "mp3",
|
||||
"bitrate": 128000,
|
||||
"chapters": []
|
||||
}
|
||||
],
|
||||
"chapters": [
|
||||
{
|
||||
"index": 0,
|
||||
"title": "Chapter 1",
|
||||
"start_seconds": 0,
|
||||
"end_seconds": 1800,
|
||||
"track_index": 0
|
||||
}
|
||||
],
|
||||
"bookmarks": []
|
||||
}
|
||||
```
|
||||
|
||||
`total_duration_seconds`, `resume_position_seconds`, global chapter times, and
|
||||
sync positions are cumulative book seconds, not per-file seconds. Clients map
|
||||
global time to the active track by finding the last track whose
|
||||
`start_offset_seconds` is less than or equal to the target time.
|
||||
|
||||
Tracks are ordered by presentation part index when available, then by path and
|
||||
file id. The server direct-plays AVFoundation-safe formats and returns
|
||||
`play_method: "aac"` for fallback streams that are remuxed/transcoded to an
|
||||
Apple-safe AAC container.
|
||||
|
||||
## Stream Track
|
||||
|
||||
`GET /api/v1/audio/playback/{session_id}/tracks/{track_index}`
|
||||
|
||||
The response is the media stream for the selected track. Clients should request
|
||||
the track for the current global time and seek within that track using:
|
||||
|
||||
`local_seconds = global_seconds - track.start_offset_seconds`
|
||||
|
||||
## Sync Progress
|
||||
|
||||
`PATCH /api/v1/audio/playback/{session_id}/sync`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 1234,
|
||||
"duration": 8746,
|
||||
"is_paused": false
|
||||
}
|
||||
```
|
||||
|
||||
The server persists `position` as the audiobook progress position. The client
|
||||
should send cumulative book seconds on a throttle while playing, on manual
|
||||
seeks, and once more before closing the session.
|
||||
|
||||
## Close Playback
|
||||
|
||||
`POST /api/v1/audio/playback/{session_id}/close`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 1234,
|
||||
"duration": 8746,
|
||||
"is_paused": true
|
||||
}
|
||||
```
|
||||
|
||||
Close performs a final progress sync and expires the in-memory audio session.
|
||||
|
||||
## Bookmarks
|
||||
|
||||
`GET /api/v1/audio/items/{content_id}/bookmarks`
|
||||
|
||||
`POST /api/v1/audio/items/{content_id}/bookmarks`
|
||||
|
||||
`DELETE /api/v1/audio/items/{content_id}/bookmarks/{time_seconds}`
|
||||
|
||||
Create request:
|
||||
|
||||
```json
|
||||
{
|
||||
"time_seconds": 1234,
|
||||
"title": "Chapter note"
|
||||
}
|
||||
```
|
||||
|
||||
Bookmark positions are cumulative book seconds so they remain stable across
|
||||
multi-file audiobooks and client platforms.
|
||||
@@ -1,82 +0,0 @@
|
||||
# PR #124 (ebooks) — Review & Hardening Pass
|
||||
|
||||
Date: 2026-06-10
|
||||
Scope: full review of the ebook feature branch (`work/ebooks-reader-base`) followed by
|
||||
fixes for every finding. Two review rounds were performed (a full-PR review, then an
|
||||
adversarial edge-case/security pass over both the original code and the first round of
|
||||
fixes), with all changes verified against the full Go test suite, the full web test
|
||||
suite, production frontend build, ESLint/Prettier, and `make verify-local-paths`.
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Security fixes
|
||||
|
||||
- **Stored XSS via book content (high):** book sections render in same-origin blob
|
||||
iframes with `allow-scripts` (WebKit requirement). A Content-Security-Policy is now
|
||||
served with all SPA HTML responses (`internal/server/frontend.go`) — blob/srcdoc
|
||||
documents inherit it, so `script-src 'self' 'wasm-unsafe-eval'` blocks script
|
||||
execution from book content on all browsers. Threat model documented on the policy
|
||||
constant. `X-Content-Type-Options: nosniff` added to frontend, jellycompat, and
|
||||
ebook file responses.
|
||||
- Ebook file serving can no longer fall through to `application/octet-stream` for an
|
||||
admitted file (extension/container whitelist and MIME resolution share one resolver),
|
||||
closing a browser-sniff escape combined with the `?token=` query-auth fallback.
|
||||
- External links in books are intercepted app-side: http(s) only, opened with
|
||||
`noopener,noreferrer` (blocks reverse tabnabbing and `javascript:` URLs).
|
||||
- Request size caps (`http.MaxBytesReader`, 413) on progress/config/annotation writes;
|
||||
`Content-Disposition` built with `mime.FormatMediaType` (RFC 5987, no header
|
||||
injection); annotation PATCH is atomic (`SELECT ... FOR UPDATE`) with
|
||||
presence-aware field semantics and invariant re-validation.
|
||||
- Verified safe under adversarial review: no IDOR (all reader-state SQL scoped to
|
||||
user+profile+content), no path traversal, range serving via stdlib, progress input
|
||||
validation. Accepted residual: CSS-only loads inside book iframes can leak the
|
||||
book's own content but cannot reach tokens, DOM, or navigation.
|
||||
|
||||
## Reliability fixes
|
||||
|
||||
- **Scanner missing-file reconciliation (critical class):** ebook scans now mark
|
||||
missing files like video/audio, with real walk-failure tracking (failed roots are
|
||||
excluded from deletion), symlinked-root support via the shared logical walker, and
|
||||
the empty-root cleanup allowance — an unmounted share can no longer wipe a library.
|
||||
- **Enrichment:** provider errors record failures (dedicated `ebook_enrichment_state`
|
||||
backoff table, capped retries) instead of permanently stamping items done;
|
||||
unconfigured chains and scan-window races skip without stamping. No longer shares
|
||||
`media_items.refresh_failures` with the metadata refresh-debt system.
|
||||
- Ebook items are created `pending` and promoted to `matched` on enrichment (backfill
|
||||
migration included), making curated-metadata protection real: matched items keep
|
||||
provider titles/people/series on re-scan (fill-empty only).
|
||||
- PDF metadata: head+tail window scan (non-linearized PDFs keep the Info dict at the
|
||||
end), delimiter-aware key matching, head-wins merge. `.md` dropped as an ebook
|
||||
format; plain `.fb2` reads capped like `.fbz`.
|
||||
- FK-cascade indexes added for the reader-state tables.
|
||||
|
||||
## Consistency & correctness fixes
|
||||
|
||||
- Hidden-history (`user_history_hidden_items`) gating applied to every ebook progress
|
||||
surface (watched/in-progress filters, all sort plans, Continue Reading, sort
|
||||
metrics, `Played` state, recommendation signals) with the exact video semantics.
|
||||
- `GetItemWatchers` counts distinct watchers (no binge inflation) and the
|
||||
`minWatchers` floor counts distinct accounts, not profiles; ebook reading now feeds
|
||||
implicit taste signals and taste-seed candidates; Continue Reading pages past
|
||||
dismissals and dedupes across pages.
|
||||
- The 0.9 finished threshold is centralized (`models.EbookFinishedProgressThreshold`).
|
||||
- Reader frontend: open-flow race teardown (no wrong-file progress saves), ordered
|
||||
progress writes with `pagehide`/`visibilitychange` flush, settings no longer
|
||||
clobbered by late server config, TTS stop actually stops, Media Session cleanup,
|
||||
512 MiB download guard, fraction bookmarks navigable.
|
||||
|
||||
## Feature completion
|
||||
|
||||
- Native read-state endpoints: `POST/DELETE /watched/{id}` and `/history/remove`
|
||||
accept ebook content IDs (mark read = progress 1.0 preserving location; mark unread
|
||||
mirrors video unwatch and clears position; history removal hides without losing
|
||||
position). Web UI exposes Mark Read/Unread on the item page and card menus, with
|
||||
Continue Reading dismiss copy and dismissal-path ID encoding fixed.
|
||||
|
||||
## Client follow-ups (Android / Apple)
|
||||
|
||||
- Ebook leaf user data encodes the reading ratio as `position_seconds` (0..1) with
|
||||
`duration_seconds = 1`; do not render absolute times for ebooks.
|
||||
- `/watched/{id}` returns `{type: "ebook", affected_count: 1, played: bool}` and the
|
||||
existing watched SSE event fires.
|
||||
- Reading progress is deliberately per-book (cross-format): one position per content
|
||||
ID.
|
||||
@@ -1,301 +0,0 @@
|
||||
# Ebook Reader Full Parity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Bring the core ebook reader up to the personal reader feature set with server-persisted config, annotations/bookmarks, selection tools, reading aids, and advanced settings.
|
||||
|
||||
**Architecture:** Keep ebook reading in core, matching audiobooks: core owns the ebook media type and reader behavior; metadata remains plugin-provided. Extend the existing `/ebooks/{content_id}` reader API and `EbookReaderHandler` rather than creating a separate plugin-style reader subsystem. Persist per-user/per-profile/per-book state in Postgres so settings, annotations, and bookmarks roam across devices.
|
||||
|
||||
**Tech Stack:** Go/Chi handlers, pgx/Postgres migrations, React 19, TanStack Query, Foliate/readest reader wrapper, Vitest, Go unit tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Creates `ebook_reader_config` and `ebook_reader_annotations`.
|
||||
- `internal/api/handlers/ebook_reader.go`
|
||||
- Adds reader config and annotation types, store interfaces, handlers, validation, and Postgres store methods.
|
||||
- `internal/api/handlers/ebook_reader_test.go`
|
||||
- Adds handler tests for config, annotation CRUD, access scoping, and validation.
|
||||
- `internal/api/router.go`
|
||||
- Wires config/annotation stores and routes under `/ebooks/{content_id}`.
|
||||
- `web/src/reader/FoliateBookReader.tsx`
|
||||
- Adds selection/annotation/readable-text/content-popup reader handles and applies saved config.
|
||||
- `web/src/pages/EbookReader.tsx`
|
||||
- Adds server-backed settings, annotation/bookmark UI, selection tools, TTS controls, wake-lock/e-ink controls, and advanced reader controls.
|
||||
- `web/src/hooks/useTTS.ts`, `web/src/hooks/useScreenWakeLock.ts`, `web/src/hooks/useEinkMode.ts`
|
||||
- Port focused personal-reader hooks into core web.
|
||||
- `web/src/reader/ebookReaderApi.ts`
|
||||
- Centralizes reader config and annotation API calls for tests and page usage.
|
||||
- `web/src/pages/EbookReader.test.tsx`, `web/src/reader/FoliateBookReader.test.ts`
|
||||
- Adds UI and wrapper tests.
|
||||
|
||||
## Task 1: Server-Persisted Reader Config
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Modify: `internal/api/handlers/ebook_reader.go`
|
||||
- Modify: `internal/api/handlers/ebook_reader_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Create: `web/src/reader/ebookReaderApi.ts`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing Go handler tests**
|
||||
|
||||
Add tests that:
|
||||
- `GET /ebooks/{content_id}/reader-config` returns `{ "config": {} }` when no row exists.
|
||||
- `PUT /ebooks/{content_id}/reader-config` saves JSON config for the authenticated user/profile/content.
|
||||
- inaccessible content returns 404 through the existing `ItemAccess` path.
|
||||
- invalid non-object config returns 400.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because config store/handlers do not exist.
|
||||
|
||||
- [ ] **Step 2: Add migration**
|
||||
|
||||
Create `ebook_reader_config`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ebook_reader_config (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, profile_id, content_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_config_profile_updated
|
||||
ON ebook_reader_config (user_id, profile_id, updated_at DESC);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement config store and handlers**
|
||||
|
||||
Add `EbookReaderConfig`, `EbookReaderConfigStore`, `HandleGetConfig`, `HandleSaveConfig`, and `PGEbookReaderConfigStore` methods in `ebook_reader.go`.
|
||||
|
||||
Validation:
|
||||
- authenticated user required
|
||||
- `content_id` required
|
||||
- `config` must decode to a JSON object
|
||||
- content access checked with `FileAuthorizer.ItemAccess.EnsureAccessible`
|
||||
|
||||
- [ ] **Step 4: Wire routes and store**
|
||||
|
||||
In `internal/api/router.go`, create the store when `deps.DB != nil`, assign it to `ebookReaderHandler`, and add:
|
||||
|
||||
```go
|
||||
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
|
||||
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add web API wrapper**
|
||||
|
||||
Create `web/src/reader/ebookReaderApi.ts` with:
|
||||
|
||||
```ts
|
||||
export function ebookReaderConfigPath(contentID: string): string
|
||||
export async function fetchEbookReaderConfig(contentID: string): Promise<Record<string, unknown>>
|
||||
export async function saveEbookReaderConfig(contentID: string, config: Record<string, unknown>): Promise<Record<string, unknown>>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Move settings persistence from localStorage to server**
|
||||
|
||||
Update `EbookReader.tsx` to fetch reader config on load and save settings through the config endpoint. Keep a local fallback only until the server value arrives.
|
||||
|
||||
- [ ] **Step 7: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
|
||||
cd web && pnpm test src/pages/EbookReader.test.tsx src/reader/FoliateBookReader.test.ts --run
|
||||
cd web && pnpm build
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add migrations/sql/20260608000300_ebook_reader_state.sql internal/api/handlers/ebook_reader.go internal/api/handlers/ebook_reader_test.go internal/api/router.go web/src/reader/ebookReaderApi.ts web/src/pages/EbookReader.tsx web/src/pages/EbookReader.test.tsx
|
||||
git commit -m "feat: persist ebook reader config"
|
||||
```
|
||||
|
||||
## Task 2: Server-Persisted Annotations And Bookmarks
|
||||
|
||||
**Files:**
|
||||
- Modify: `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Modify: `internal/api/handlers/ebook_reader.go`
|
||||
- Modify: `internal/api/handlers/ebook_reader_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `web/src/reader/ebookReaderApi.ts`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing annotation handler tests**
|
||||
|
||||
Cover list, create, update, delete. Test annotation fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "highlight",
|
||||
"cfi_range": "epubcfi(/6/4,/1:0,/1:12)",
|
||||
"selected_text": "sample text",
|
||||
"note": "note text",
|
||||
"style": "highlight",
|
||||
"color": "#facc15"
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Annotation|TestEbookReader.*Bookmark' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because annotation APIs do not exist.
|
||||
|
||||
- [ ] **Step 2: Add annotation table**
|
||||
|
||||
Extend migration with:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ebook_reader_annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('highlight', 'note', 'bookmark')),
|
||||
cfi_range TEXT,
|
||||
location TEXT,
|
||||
selected_text TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
style TEXT NOT NULL DEFAULT 'highlight',
|
||||
color TEXT NOT NULL DEFAULT '#facc15',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK ((kind = 'bookmark' AND location IS NOT NULL) OR (kind <> 'bookmark' AND cfi_range IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_annotations_book
|
||||
ON ebook_reader_annotations (user_id, profile_id, content_id, updated_at DESC);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement annotation store and handlers**
|
||||
|
||||
Add handlers:
|
||||
- `GET /ebooks/{content_id}/annotations`
|
||||
- `POST /ebooks/{content_id}/annotations`
|
||||
- `PATCH /ebooks/{content_id}/annotations/{annotation_id}`
|
||||
- `DELETE /ebooks/{content_id}/annotations/{annotation_id}`
|
||||
|
||||
- [ ] **Step 4: Wire Foliate annotation drawing and selection**
|
||||
|
||||
Extend `FoliateBookReaderHandle` with:
|
||||
- `createSelectionAnnotation()`
|
||||
- `clearSelection()`
|
||||
|
||||
Draw stored annotations using Foliate `addAnnotation`.
|
||||
|
||||
- [ ] **Step 5: Add reader UI**
|
||||
|
||||
Add:
|
||||
- selection popover
|
||||
- highlight button
|
||||
- note button
|
||||
- bookmark button
|
||||
- annotation list in the side panel
|
||||
- delete/update note controls
|
||||
|
||||
- [ ] **Step 6: Verify and commit**
|
||||
|
||||
Run handler tests, web tests, and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook annotations and bookmarks"
|
||||
```
|
||||
|
||||
## Task 3: Reader Tools, TTS, And Reading Aids
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/useTTS.ts`
|
||||
- Create: `web/src/hooks/useScreenWakeLock.ts`
|
||||
- Create: `web/src/hooks/useEinkMode.ts`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Port focused hooks from personal reader**
|
||||
|
||||
Bring over:
|
||||
- Web Speech TTS controller
|
||||
- screen wake lock hook
|
||||
- e-ink body-class hook
|
||||
|
||||
- [ ] **Step 2: Add readable text handle**
|
||||
|
||||
Expose `getReadableText()` from `FoliateBookReader` for TTS.
|
||||
|
||||
- [ ] **Step 3: Add UI controls**
|
||||
|
||||
Add side-panel controls for:
|
||||
- Speak current text
|
||||
- Pause/resume/stop
|
||||
- voice/rate/pitch
|
||||
- wake lock
|
||||
- e-ink mode
|
||||
|
||||
- [ ] **Step 4: Add content popups and helpers**
|
||||
|
||||
Add selection popover actions:
|
||||
- define
|
||||
- translate
|
||||
|
||||
Add reader content popup handling for footnotes/images/tables when Foliate emits them.
|
||||
|
||||
- [ ] **Step 5: Verify and commit**
|
||||
|
||||
Run focused web tests and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook reader tools and aids"
|
||||
```
|
||||
|
||||
## Task 4: Advanced Reader Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add advanced reader settings**
|
||||
|
||||
Add server-persisted settings for:
|
||||
- RTL
|
||||
- writing mode
|
||||
- zoom/scale
|
||||
- brightness
|
||||
- hyphenation toggle
|
||||
- custom font selection/upload if API support is added in a later task
|
||||
|
||||
- [ ] **Step 2: Verify and commit**
|
||||
|
||||
Run focused tests and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook advanced reader settings"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
- The plan covers server persistence first, matching the user's explicit preference.
|
||||
- The plan keeps ebook features in core and metadata in plugins, matching the audiobook pattern.
|
||||
- The plan avoids extra ebook catalog tables; added tables are reader-user-state only.
|
||||
- The biggest implementation risk is annotation rendering against Foliate selection APIs; Task 2 isolates it after backend persistence exists.
|
||||
- The migration filename is intentionally after existing ebook migrations and can be adjusted if another migration is added before implementation.
|
||||
@@ -1,68 +0,0 @@
|
||||
# Ebook Reader Shell Parity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Bring the core ebook reader up to the first useful parity slice from `personal/silo-plugin-ebooks`: TOC, search, progress scrub, reader settings, and keyboard navigation.
|
||||
|
||||
**Architecture:** Keep the current core reader API and persistence model. Extend `FoliateBookReader` with imperative methods for TOC, search, href/fraction navigation, and runtime style updates; keep `EbookReader` responsible for the chrome/panels/settings state. Store reader preferences in localStorage for this first local slice so no backend schema is added.
|
||||
|
||||
**Tech Stack:** React 19, React Router, TanStack Query, foliate-js custom element, existing Silo UI components, Vitest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Reader Handle Capabilities
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/reader/FoliateBookReader.test.ts`
|
||||
|
||||
- [ ] Add exported types `ReaderSettings`, `ReaderSearchOptions`, `ReaderSearchResult`, and `ReaderReadyState`.
|
||||
- [ ] Extend `FoliateBookReaderHandle` with `goToFraction`, `goTo`, `search`, `clearSearch`, and `applySettings`.
|
||||
- [ ] Add a `settings?: ReaderSettings` prop and convert `readerStyles()` to accept settings.
|
||||
- [ ] Preserve existing progress save/restore behavior.
|
||||
- [ ] Verify with `pnpm vitest run src/reader/FoliateBookReader.test.ts`.
|
||||
|
||||
### Task 2: TOC and Search Panel
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add panel state for `toc`, `searchResults`, `searchTerm`, and current tab.
|
||||
- [ ] Render side panel tabs for Contents, Search, and Settings.
|
||||
- [ ] Wire TOC items to `readerRef.current.goTo(href)`.
|
||||
- [ ] Wire search form to `readerRef.current.search(term)` and result clicks to `goTo(cfi)`.
|
||||
- [ ] Verify with `pnpm vitest run src/pages/EbookReader.test.tsx`.
|
||||
|
||||
### Task 3: Reader Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add localStorage-backed settings for theme, font size, font family, line height, margin, max width, spread, and flow.
|
||||
- [ ] Render compact controls in the Settings tab.
|
||||
- [ ] Apply settings live through `FoliateBookReader`.
|
||||
- [ ] Verify settings persist across remount in tests.
|
||||
|
||||
### Task 4: Progress Scrub and Keyboard Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add a progress range input in the header.
|
||||
- [ ] Call `goToFraction` on commit/change and update display from relocate events.
|
||||
- [ ] Add ArrowLeft/ArrowRight keyboard navigation on the reader page.
|
||||
- [ ] Verify with page-level tests.
|
||||
|
||||
### Task 5: Local Verification
|
||||
|
||||
**Files:**
|
||||
- No source changes.
|
||||
|
||||
- [ ] Run `cd web && pnpm run build`.
|
||||
- [ ] Run `GOWORK=off go test ./internal/api/handlers ./internal/scanner`.
|
||||
- [ ] Keep changes local; do not push.
|
||||
@@ -1,518 +0,0 @@
|
||||
# Encrypt integration credentials at rest (issue #45) — implementation plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Do not edit this plan file while implementing.
|
||||
|
||||
**Issue:** [#45 — Encrypt arr integration credentials at rest (Requests + Autoscan)](https://github.com/Silo-Server/silo-server/issues/45)
|
||||
|
||||
> **Revision note (post-review):** This plan was revised after a code review. Key corrections baked in:
|
||||
> the arr backfill now **resolves the legacy `server_settings` reference before encrypting** (otherwise
|
||||
> rows holding a setting-name like `requests.radarr.api_key` would be encrypted as the *name*, not the
|
||||
> credential); the sensitive-settings allowlist is **audited from the config loader's real inputs**
|
||||
> (incl. `redis.sentinel_password` and legacy `s3.operational_*` aliases) rather than copied from the
|
||||
> admin redaction map; AAD binds to **row identity** (`table:column:pk`); read paths **pass through
|
||||
> legacy plaintext** and hard-fail only on corrupt ciphertext; `settingsRepo` is **re-wrapped after the
|
||||
> max-connections pool recreation**; history-import session tokens are **included**; and
|
||||
> `plugin_runtime_configs.config_value` is an **explicit, documented gap** (see Out of scope).
|
||||
|
||||
**Goal:** Stop storing third-party integration credentials in plaintext at rest. Today Sonarr/Radarr
|
||||
API keys, S3 keys, and a dozen other secrets sit in the database as naked `text` — `request_integrations.api_key_ref`
|
||||
and `autoscan_connections.api_key_ref` are resolved through a plaintext `server_settings` key/value
|
||||
store, and both resolvers contain a **ref-vs-literal fallback**: when the secret lookup returns empty,
|
||||
the stored string is used verbatim as the API key. This plan introduces a real symmetric-encryption
|
||||
layer (AES-256-GCM, `SECRET_KEY`-derived via HKDF), encrypts every recoverable server-owned credential
|
||||
**inline** in its owning row, deletes the ambiguous resolver fallback, and backfills existing rows
|
||||
automatically on startup.
|
||||
|
||||
**Decisions (locked with the requester):**
|
||||
- **Master key:** a **required** `SECRET_KEY` environment variable. The server fatals at startup if it
|
||||
is missing. The key lives outside Postgres, so encrypted secrets survive a full DB compromise/dump.
|
||||
- **Scope:** **all silo-server-owned credential storage** (was framed "full sweep"). Every recoverable
|
||||
server-owned secret — arr keys, S3 keys, all sensitive `server_settings`, and the per-table token
|
||||
columns — is covered. Two categories are deliberately *not* in this PR because they are a different
|
||||
*shape* of problem, not because they are unimportant: equality-looked-up secrets (need **hashing**,
|
||||
not encryption) and plugin runtime config JSONB (cross-repo + manifest-driven). Both are called out in
|
||||
Out of scope with follow-ups.
|
||||
- **Data model (best practice):** **inline-encrypted**. Store the ciphertext directly in the owning
|
||||
column and decrypt on read. Drop the `server_settings` "ref" indirection and the `SecretResolver`
|
||||
literal fallback entirely — this kills the ambiguity at its root and matches the repo's one existing
|
||||
good example (`oauth_completion.token_ciphertext` lives inline in its own row).
|
||||
- **Rollout:** idempotent Go startup **backfill** encrypts any plaintext (no-envelope-prefix) value in
|
||||
place on first boot after deploy. No manual steps.
|
||||
|
||||
**Architecture:** A new `internal/secret` package owns the one cryptographic primitive everything reuses:
|
||||
AES-256-GCM with a random 12-byte nonce, `base64.RawURLEncoding`, and a versioned `enc:v1:` envelope
|
||||
prefix; the data key is HKDF-SHA256–derived from `SECRET_KEY` with a domain label. Each ciphertext is
|
||||
GCM-bound to its logical **row** via additional-authenticated-data (`"table:column:<pk>"`, or
|
||||
`"server_settings:<key>"`) so a DB-write attacker cannot transplant a credential blob into another row
|
||||
or column. Three wiring seams consume the cipher: (1) an `EncryptedSettingsRepo` **decorator** around
|
||||
`catalog.ServerSettingsRepo` that transparently encrypts/decrypts the sensitive `server_settings` keys,
|
||||
so `config.LoadFromDB` and every downstream consumer keep seeing plaintext; (2) the two `api_key_ref`
|
||||
columns become inline ciphertext and the entire `SecretResolver` indirection is deleted from
|
||||
`internal/requests` and `internal/autoscan`; (3) a handful of per-table repos gain a `*secret.Cipher`
|
||||
and encrypt-on-write / decrypt-on-read. A startup backfill sweeps all groups idempotently — with a
|
||||
**special resolve-then-encrypt path for the two arr columns** so legacy `server_settings` references are
|
||||
collapsed to the real credential before encryption.
|
||||
|
||||
**Out of scope (each a deliberate, documented decision — not an oversight):**
|
||||
- **Equality-looked-up secrets → need hashing, not encryption.** `api_keys.api_key`
|
||||
(`WHERE api_key = $1`) and `webhook_sync_connections.webhook_secret` (`WHERE webhook_secret = $1`,
|
||||
[repo.go:101](internal/webhooksync/repo.go:101)) are matched by exact value. AES-GCM uses a fresh
|
||||
random nonce, so the same plaintext encrypts differently each time — encrypting these would silently
|
||||
break API-key auth and inbound-webhook routing. The correct fix is a deterministic **hash** column +
|
||||
lookup-by-hash, a distinct lookup-semantics + migration change. **Follow-up issue.**
|
||||
- **`plugin_runtime_configs.config_value` → cross-repo + manifest-driven.** This is opaque plugin-defined
|
||||
JSONB ([runtime_config.go:55](internal/plugins/runtime_config.go:55)); some plugins store secrets in it
|
||||
(e.g. the legacy one-time copy of `introdb.api_key` at [main.go:2341](cmd/silo/main.go:2341)). It is
|
||||
read/written only through `RuntimeConfigStore` *in this repo*, so whole-blob encrypt-on-write /
|
||||
decrypt-on-read at that store is technically feasible — **but** host-side plugin runtime lives in the
|
||||
separate **`Silo`** repo (per CLAUDE.md) and may read this table directly, and which fields are secret
|
||||
is declared in each plugin's manifest. Doing this correctly needs a coordinated, manifest-aware design
|
||||
across repos. **Explicitly deferred — known gap.** Note: while deferred, the legacy `introdb.api_key`
|
||||
copy means that secret still exists in plaintext inside plugin config even after the `server_settings`
|
||||
copy is encrypted; the follow-up must re-encrypt or purge it.
|
||||
- **`plex_sync_connections`** — dead table, zero Go references (superseded by `webhook_sync_connections`
|
||||
in migration `060`). Not touched.
|
||||
- **Already-protected / non-secret:** `users.password_hash` (bcrypt), `oauth_completion.token_ciphertext`
|
||||
(already AES-GCM), `abs_sessions.token_hash` / `oauth_completion.code_hash` (hashed), `oauth_session.state`
|
||||
(equality CSRF), `subtitle_provider_config.username` (a username, not a secret),
|
||||
`history_import_plex_sessions.pin_id`/`pin_code` (transient OAuth pairing handshake, not a stored
|
||||
access credential).
|
||||
- `internal/auth/oauth_store.go` is left as-is (its key is jwt-derived and its data is ephemeral).
|
||||
Optionally a shared low-level seal/open helper can be extracted later; not required here.
|
||||
|
||||
**Tech Stack:** Go 1.26.3 (module declares `go 1.26.3`; stdlib `crypto/hkdf` is available — no new
|
||||
dependency). PostgreSQL via `pgxpool`. **No schema migration** — every target column is already `text`
|
||||
(or `jsonb` left untouched) and now simply holds ciphertext; the backfill is pure Go. No frontend or
|
||||
client (silo-android / silo-apple) changes — API responses already mask these values to `has_api_key` /
|
||||
`has_credentials` booleans, so no contract changes.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
The `internal/secret` package is pure Go (no libvips/CGO). If a Go toolchain is on the host:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/secret/... -v
|
||||
```
|
||||
|
||||
`GOWORK=off` is required when running from a `.claude/worktrees/` checkout — the parent `go.work`
|
||||
otherwise pins the module to `main` and the build fails with "main module does not contain package".
|
||||
|
||||
If the host has no Go toolchain, run in a throwaway container (a named volume caches module downloads):
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/secret/... -v
|
||||
```
|
||||
|
||||
Full backend build/vet before opening the MR: `GOWORK=off go build ./...` then `make lint` and
|
||||
`make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**New files:**
|
||||
- `internal/secret/cipher.go` — **create**. `Cipher` type; `New`, `Encrypt`, `Decrypt`, `IsEncrypted`,
|
||||
`EncryptIfPlaintext`; envelope + HKDF + version dispatch. Row-bound AAD passed by callers.
|
||||
- `internal/secret/cipher_test.go` — **create**. Round-trip, tamper/AAD-mismatch rejection, wrong-key,
|
||||
`IsEncrypted` edge cases, version dispatch, idempotency.
|
||||
- `internal/secret/backfill.go` — **create**. Generic in-place backfill for literal-credential columns +
|
||||
the special resolve-then-encrypt backfill for the two arr columns.
|
||||
- `internal/secret/backfill_test.go` — **create**. Idempotency, mixed plaintext/ciphertext rows, empty
|
||||
values skipped, failure isolation, arr resolve-then-encrypt.
|
||||
|
||||
**Modified files:**
|
||||
- `internal/config/bootstrap.go` — add `SecretKey []byte` to `BootstrapConfig`; read `SECRET_KEY`; fatal
|
||||
if missing or `< 32` chars.
|
||||
- `cmd/silo/main.go` — construct `*secret.Cipher` after bootstrap; build the `EncryptedSettingsRepo`;
|
||||
run the settings + column backfills after migrations and before settings are read; **re-wrap
|
||||
`settingsRepo` after the max-connections pool recreation (line ~399)**; thread the cipher into the
|
||||
affected repo constructors; delete the `SetSecretResolver` wiring.
|
||||
- `internal/catalog/encrypted_settings_repo.go` — **create**. `EncryptedSettingsRepo` decorator +
|
||||
exported, **audited** `SensitiveSettingKeys` (single source of truth) + `BackfillSensitiveSettings`.
|
||||
- `internal/catalog/server_settings_repo.go` — unchanged behavior; remains the raw inner store.
|
||||
- `internal/api/handlers/admin.go` — replace the local `sensitiveSettingKeys` map with
|
||||
`catalog.SensitiveSettingKeys` (redaction now shares the audited allowlist; closes the prior gap where
|
||||
`redis.sentinel_password` and `s3.operational_*` were returned in plaintext).
|
||||
- `internal/requests/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
|
||||
insert/update (AAD bound to integration id); decrypt (pass-through legacy plaintext) in `scanIntegration`.
|
||||
- `internal/requests/service.go` — delete `SecretResolver` interface, `secrets` field,
|
||||
`SetSecretResolver`, and `resolveAPIKey`; call sites use the already-decrypted `Integration.APIKeyRef`.
|
||||
- `internal/autoscan/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
|
||||
create/update (AAD bound to connection id); decrypt (pass-through legacy) in `scanConnection`.
|
||||
- `internal/autoscan/connection.go` — delete `SecretResolver` interface, `secrets` field, and the
|
||||
secret-resolution branch in `Resolve`; linked-integration path uses the requests repo's decrypted key.
|
||||
- `internal/api/autoscan_wiring.go` — drop the `AutoscanSecretResolver` param from `BuildAutoscanService`.
|
||||
- `internal/api/router.go` — drop the `SetSecretResolver(settingsRepo)` calls; pass the
|
||||
`EncryptedSettingsRepo` where `settingsRepo` is used; thread the cipher into repo constructors.
|
||||
- `internal/subtitles/pgrepo.go` — `*secret.Cipher`; encrypt `api_key` + `password` on upsert; decrypt on
|
||||
read. PK for AAD = `provider_name`.
|
||||
- `internal/watchsync/repository.go` — `*secret.Cipher`; encrypt `access_token` + `refresh_token` on
|
||||
upsert; decrypt in a `decryptConnection` helper called after every scan. PK for AAD = connection `id`.
|
||||
- `internal/webhooksync/repo.go` — `*secret.Cipher`; encrypt `access_token` on create; decrypt on scan.
|
||||
**`webhook_secret` is NOT encrypted** (equality lookup — carved out). PK for AAD = connection `id`.
|
||||
- `internal/historyimport/repo_admin.go` — `*secret.Cipher`; encrypt in `SetSourceAdminToken`; decrypt in
|
||||
`GetSourceWithAdminToken`. PK for AAD = source `id`.
|
||||
- `internal/historyimport/repo.go` — `*secret.Cipher`; encrypt `connect_access_token`
|
||||
(`history_import_connect_sessions`) + `auth_token` (`history_import_plex_sessions`) on insert/update;
|
||||
decrypt on scan. PK for AAD = session `id`.
|
||||
- `docs/architecture/secret-encryption.md` — **create**. Operator runbook (SECRET_KEY generation, backup,
|
||||
key-loss, rollback/downgrade).
|
||||
|
||||
**No migration files.** All target columns are already `text`; the backfill is Go-side.
|
||||
|
||||
---
|
||||
|
||||
## Reference tables
|
||||
|
||||
### What is encrypted vs hashed vs excluded
|
||||
|
||||
AAD column shows the GCM additional-authenticated-data string; `<pk>` is the row's stable primary key.
|
||||
|
||||
| Group | Table.column | AAD binding | Action |
|
||||
| --- | --- | --- | --- |
|
||||
| Arr keys | `request_integrations.api_key_ref` | `request_integrations:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
|
||||
| Arr keys | `autoscan_connections.api_key_ref` | `autoscan_connections:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
|
||||
| Settings | every key in `SensitiveSettingKeys` (see audited list below) | `server_settings:<key>` | **Encrypt via decorator** |
|
||||
| Subtitles | `subtitle_provider_config.api_key`, `.password` | `subtitle_provider_config:<col>:<provider_name>` | **Encrypt inline** |
|
||||
| Watch sync | `watch_provider_connections.access_token`, `.refresh_token` | `watch_provider_connections:<col>:<id>` | **Encrypt inline** |
|
||||
| Webhook sync | `webhook_sync_connections.access_token` | `webhook_sync_connections:access_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_sources.admin_token` | `history_import_sources:admin_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_connect_sessions.connect_access_token` | `history_import_connect_sessions:connect_access_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_plex_sessions.auth_token` | `history_import_plex_sessions:auth_token:<id>` | **Encrypt inline** |
|
||||
| — | `api_keys.api_key` | — | **Carved out → hash** (follow-up) |
|
||||
| — | `webhook_sync_connections.webhook_secret` | — | **Carved out → hash** (follow-up) |
|
||||
| — | `plugin_runtime_configs.config_value` | — | **Deferred → cross-repo/manifest design** (follow-up) |
|
||||
| — | `plex_sync_connections.*` | — | **Excluded** (dead table) |
|
||||
|
||||
### Audited `SensitiveSettingKeys` (single source of truth)
|
||||
|
||||
Built from the config loader's actual secret inputs ([internal/config/db_loader.go](internal/config/db_loader.go))
|
||||
plus the admin redaction map plus other known consumers — **not** just the current redaction map. The
|
||||
union is intentional and also fixes a pre-existing redaction leak. Implementers must re-audit
|
||||
`db_loader.go` and `grep` for any `*secret*`/`*token*`/`*password*`/`*api_key*` settings keys before
|
||||
finalizing.
|
||||
|
||||
```
|
||||
auth.jwt_secret
|
||||
s3.public_access_key s3.public_secret_key s3.public_token_secret
|
||||
s3.private_access_key s3.private_secret_key
|
||||
s3.user_db_access_key s3.user_db_secret_key
|
||||
s3.operational_access_key s3.operational_secret_key s3.operational_token_secret # legacy aliases still read as fallbacks (db_loader L165-192); migration 086 copied but did not delete them
|
||||
redis.url # may embed credentials (redis://:pass@host)
|
||||
redis.sentinel_password # read at db_loader L334 — MISSING from the old redaction map
|
||||
tmdb.api_key
|
||||
mdblist.api_key
|
||||
introdb.api_key # read in main.go (legacy → plugin copy)
|
||||
subtitle_ai.api_key
|
||||
recommendations.embedding_auth_token recommendations.openai_api_key # legacy alias (db_loader L409)
|
||||
watchsync.trakt.client_id watchsync.trakt.client_secret
|
||||
watchsync.simkl.client_id watchsync.simkl.client_secret
|
||||
requests.radarr.api_key requests.sonarr.api_key # legacy single-instance arr keys
|
||||
```
|
||||
|
||||
`auth.jwt_secret` IS encrypted at rest under `SECRET_KEY` (it is no longer the encryption root — `SECRET_KEY`
|
||||
is). `redis.url`/client-id entries are not high-value secrets but are kept in the set as a harmless safe
|
||||
superset and to match existing redaction.
|
||||
|
||||
### Envelope format
|
||||
|
||||
```
|
||||
enc:v1:<base64.RawURLEncoding( nonce[12] ‖ gcm.Seal(...) )>
|
||||
```
|
||||
|
||||
- `enc:v1:` — exactly 7 ASCII chars; not valid base64url, not a UUID/JWT/URL/integer prefix. Collision-proof
|
||||
against any real plaintext credential in practice.
|
||||
- `IsEncrypted(s)` ≡ `strings.HasPrefix(s, "enc:v1:")`.
|
||||
- Decrypt parses the version (`strings.SplitN(s, ":", 3)`), dispatches to the v1 opener; an unknown
|
||||
version returns an explicit `ErrUnknownVersion`. This is the forward-compat hook for a future `enc:v2:`
|
||||
key rotation — implement the dispatch now even though only `v1` exists.
|
||||
|
||||
### Read-path contract (applies to every decrypt site — decorator and repos)
|
||||
|
||||
1. `value == ""` → return `""`.
|
||||
2. `!IsEncrypted(value)` → **return the value unchanged** (legacy plaintext pass-through during the
|
||||
backfill window; the value is no worse than today). This is unambiguous — there is no longer any
|
||||
"ref vs literal" question because the `_ref` indirection is gone.
|
||||
3. `IsEncrypted(value)` → `Decrypt`; **any error (wrong key, tamper, truncation) is propagated, never
|
||||
swallowed and never returned as the ciphertext string.** A corrupt ciphertext fails the operation.
|
||||
|
||||
This reconciles the best-effort backfill (a skipped/failed row stays readable as plaintext) with the
|
||||
hard guarantee that a real `enc:v1:` value never silently degrades to using ciphertext as a credential.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: The `internal/secret` cipher package
|
||||
|
||||
**Files:** Create `internal/secret/cipher.go`, `internal/secret/cipher_test.go`.
|
||||
|
||||
This is the foundation; it has no dependencies on `catalog`/`config` and is independently testable.
|
||||
|
||||
- [ ] **Step 1: `Cipher` type + `New`.** Derive a 32-byte AES key with stdlib
|
||||
`hkdf.Key(sha256.New, masterKey, nil /*salt*/, "silo/data-encryption/v1", 32)`. Return an error if
|
||||
`len(masterKey) < 32`. Store the derived key as `[32]byte`. The type is safe for concurrent use.
|
||||
- [ ] **Step 2: `Encrypt(plaintext, aad string) (string, error)`.** Empty plaintext returns `"", nil`
|
||||
(do not produce an `enc:v1:` blob for an empty secret). Otherwise: `aes.NewCipher` → `cipher.NewGCM` →
|
||||
12-byte `rand` nonce → `gcm.Seal(nonce, nonce, []byte(plaintext), []byte(aad))` →
|
||||
`envelopePrefix + base64.RawURLEncoding.EncodeToString(sealed)`. `aad` is the row-bound context string.
|
||||
- [ ] **Step 3: `Decrypt(ciphertext, aad string) (string, error)`.** Strip + validate the version prefix;
|
||||
unknown version → `ErrUnknownVersion`. base64-decode; reject if shorter than the nonce size; split
|
||||
nonce/body; `gcm.Open(nil, nonce, body, []byte(aad))`. **Any failure returns an error — never fall back
|
||||
to returning the ciphertext or an empty string.** (Pass-through of *non-prefixed* plaintext is the
|
||||
caller's job per the read-path contract, not `Decrypt`'s.)
|
||||
- [ ] **Step 4: `IsEncrypted(s string) bool`** and `EncryptIfPlaintext(s, aad string) (string, bool, error)`.
|
||||
`EncryptIfPlaintext` returns `(s, false, nil)` when already encrypted or empty; otherwise encrypts and
|
||||
returns `(ct, true, nil)`. This is the idempotent primitive the backfill uses.
|
||||
- [ ] **Step 5: Tests** (`cipher_test.go`):
|
||||
- round-trip with AAD recovers plaintext; ciphertext has the `enc:v1:` prefix.
|
||||
- two encryptions of the same plaintext+AAD differ (random nonce works).
|
||||
- tamper: flip one byte of the decoded body → `Decrypt` errors.
|
||||
- AAD mismatch: encrypt with `"t:c:1"`, decrypt with `"t:c:2"` → errors (proves row binding).
|
||||
- wrong key: encrypt with key A, decrypt with key B → errors.
|
||||
- `New` with a `< 32`-byte key → error.
|
||||
- `IsEncrypted`: `"enc:v1:x"`→true, `"sa_abc"`→false, `""`→false, `"ENC:V1:x"`→false.
|
||||
- unknown version `"enc:v99:..."` → `ErrUnknownVersion`.
|
||||
- `EncryptIfPlaintext` is idempotent (second call is a no-op, `changed=false`).
|
||||
- [ ] **Step 6:** `GOWORK=off go test ./internal/secret/... -v` passes.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SECRET_KEY` bootstrap + cipher construction
|
||||
|
||||
**Files:** Modify `internal/config/bootstrap.go`, `cmd/silo/main.go`.
|
||||
|
||||
- [ ] **Step 1:** Add `SecretKey []byte` to `BootstrapConfig`. In `LoadBootstrap`, after the
|
||||
`DATABASE_URL` check, read `os.Getenv("SECRET_KEY")`; return an error (which the existing
|
||||
`log.Fatalf` path surfaces) if it is empty or `< 32` characters. Message must be actionable, e.g.
|
||||
`SECRET_KEY is required (>=32 chars); generate one with: openssl rand -base64 48`. `godotenv` already
|
||||
loads `.env`, so dev sets it there.
|
||||
- [ ] **Step 2:** In `cmd/silo/main.go`, immediately after `LoadBootstrap` returns, construct the cipher:
|
||||
`dataCipher, err := secret.New(bc.SecretKey)` and `log.Fatalf` on error. The cipher is threaded
|
||||
explicitly as a dependency — **never** a package-level global.
|
||||
- [ ] **Step 3:** Add `SECRET_KEY=...` to `.env.example` (or the documented dev env) and note it in the
|
||||
operator runbook (Task 7).
|
||||
- [ ] **Step 4:** `GOWORK=off go build ./cmd/silo` compiles; starting without `SECRET_KEY` fatals with the
|
||||
actionable message.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `server_settings` encryption decorator
|
||||
|
||||
**Files:** Create `internal/catalog/encrypted_settings_repo.go`; modify `internal/api/handlers/admin.go`,
|
||||
`cmd/silo/main.go`, `internal/api/router.go`.
|
||||
|
||||
- [ ] **Step 1: Build the audited allowlist.** Create exported `var SensitiveSettingKeys map[string]bool`
|
||||
in `internal/catalog` populated from the "Audited `SensitiveSettingKeys`" list above. **Do not** simply
|
||||
move the admin redaction map — re-audit `internal/config/db_loader.go` (and other `server_settings`
|
||||
consumers) for every secret-bearing key, and include the legacy aliases (`s3.operational_*`,
|
||||
`recommendations.openai_api_key`) and `redis.sentinel_password`. Update `admin.go`
|
||||
(`HandleGetSettings` redaction, `HandleGetSensitiveStatus`, the `PUT` response branch) to reference
|
||||
`catalog.SensitiveSettingKeys`. One allowlist now drives **both** redaction and encryption.
|
||||
- [ ] **Step 2:** Implement `EncryptedSettingsRepo` wrapping `*ServerSettingsRepo` + `*secret.Cipher`:
|
||||
- `Set(key, value)`: if `SensitiveSettingKeys[key]` and `value != ""`, encrypt with AAD
|
||||
`"server_settings:"+key` before delegating; else delegate raw.
|
||||
- `Get(key)` / `GetAll()`: delegate, then apply the **read-path contract** to each sensitive key
|
||||
(pass through legacy plaintext; decrypt prefixed values; propagate decrypt errors).
|
||||
- [ ] **Step 3:** Add `BackfillSensitiveSettings(ctx)` on the decorator: for each key in
|
||||
`SensitiveSettingKeys`, read the raw inner value; if non-empty and not `IsEncrypted`, `inner.Set` the
|
||||
encrypted value (AAD `"server_settings:"+key`). Idempotent. Returns a count + aggregated errors.
|
||||
- [ ] **Step 4:** In `cmd/silo/main.go`, build `settingsRepo` as the `EncryptedSettingsRepo` wrapping the
|
||||
raw `catalog.NewServerSettingsRepo(pool)`. Confirm the auto-generate-`jwt_secret` step still works: the
|
||||
`GetAll` map carries plaintext, the `== ""` check is unchanged, and `Set("auth.jwt_secret", encoded)`
|
||||
now writes **encrypted** while the in-memory map keeps the plaintext for `LoadFromDB`. Any function
|
||||
currently typed to the concrete `*catalog.ServerSettingsRepo` changes to the `ServerSettingsStore`
|
||||
interface that both already satisfy.
|
||||
- [ ] **Step 5 (re-wrap after pool recreation):** `cmd/silo/main.go` rebuilds a **raw**
|
||||
`catalog.NewServerSettingsRepo(pool)` after recreating the pool for `max_connections` (line ~399).
|
||||
Re-wrap it there too (`settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)`)
|
||||
so every later consumer gets the encrypting repo, not a raw one. Audit for any other raw construction
|
||||
of the settings repo and wrap each.
|
||||
- [ ] **Step 6:** Tests: decorator round-trip (raw store holds `enc:v1:`, decorator `Get` returns
|
||||
plaintext); non-sensitive keys pass through unencrypted; legacy-plaintext pass-through on read;
|
||||
`BackfillSensitiveSettings` idempotency.
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...` compiles; admin settings GET still redacts (now including
|
||||
`redis.sentinel_password`); a sensitive key written via the admin API lands as `enc:v1:` in the DB.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Inline-encrypt arr keys + delete the `SecretResolver` indirection
|
||||
|
||||
**Files:** Modify `internal/requests/repository.go`, `internal/requests/service.go`,
|
||||
`internal/autoscan/repository.go`, `internal/autoscan/connection.go`,
|
||||
`internal/api/autoscan_wiring.go`, `internal/api/router.go`, `cmd/silo/main.go`.
|
||||
|
||||
> The legacy-data resolution for these two columns is handled by the **resolve-then-encrypt backfill** in
|
||||
> Task 6 (a row may currently hold a `server_settings` key name, not a literal key). The repo write path
|
||||
> below only ever encrypts the literal credential an admin submits going forward.
|
||||
|
||||
- [ ] **Step 1:** `requests.NewRepository` gains a `*secret.Cipher` parameter. In `insertIntegration` and
|
||||
`updateIntegration`, encrypt `i.APIKeyRef` (AAD `"request_integrations:api_key_ref:"+i.ID`) before
|
||||
binding — preserve the existing `CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END` keep-existing
|
||||
semantics (encrypt only the non-empty incoming value). In `scanIntegration`, apply the read-path
|
||||
contract to the scanned `api_key_ref`.
|
||||
- [ ] **Step 2:** Delete `requests.SecretResolver` (interface), the `Service.secrets` field,
|
||||
`Service.SetSecretResolver`, and `Service.resolveAPIKey`. Every former `resolveAPIKey(ctx, integration)`
|
||||
call site uses `strings.TrimSpace(integration.APIKeyRef)` directly (the repo already decrypted it).
|
||||
- [ ] **Step 3:** `autoscan.NewRepository` gains a `*secret.Cipher`. Encrypt `api_key_ref` in
|
||||
`CreateConnection` / `UpdateConnection` (AAD `"autoscan_connections:api_key_ref:"+id`, same
|
||||
keep-existing CASE semantics); apply the read-path contract in `scanConnection`.
|
||||
- [ ] **Step 4:** In `internal/autoscan/connection.go`, delete the `SecretResolver` interface, the
|
||||
`ConnectionResolver.secrets` field, and the secret-resolution branch in `Resolve`. For a linked Requests
|
||||
integration, `RequestIntegrationLookup.Get` returns the requests repo's **already-decrypted** key — use
|
||||
it directly. For a standalone connection, the autoscan repo already decrypted it. `Resolve` now only
|
||||
resolves base URL + linkage and returns plaintext.
|
||||
- [ ] **Step 5:** Remove the `AutoscanSecretResolver` parameter from `BuildAutoscanService`
|
||||
(`internal/api/autoscan_wiring.go`) and drop the `SetSecretResolver(settingsRepo)` calls in
|
||||
`internal/api/router.go` and `cmd/silo/main.go` (including the reconcile-worker service). Thread
|
||||
`dataCipher` into the requests/autoscan repo constructors at all wiring sites.
|
||||
- [ ] **Step 6:** Verify no API response serializes the raw key: the integration/connection response DTOs
|
||||
still emit only `has_api_key`. Add/extend a test asserting the create→store→read round-trip yields the
|
||||
original key, and that the stored column value is `enc:v1:`-prefixed.
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...`; `GOWORK=off go test ./internal/requests/... ./internal/autoscan/...`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Per-table credential columns
|
||||
|
||||
**Files:** Modify `internal/subtitles/pgrepo.go`, `internal/watchsync/repository.go`,
|
||||
`internal/webhooksync/repo.go`, `internal/historyimport/repo_admin.go`, `internal/historyimport/repo.go`,
|
||||
plus their constructor call sites.
|
||||
|
||||
Each repo gains a `*secret.Cipher` constructor arg and follows encrypt-on-write / decrypt-on-read with a
|
||||
row-bound AAD. Preserve existing keep-existing CASE/COALESCE semantics — encrypt only non-empty incoming
|
||||
values. All read paths use the read-path contract (pass through legacy plaintext, hard-fail corrupt
|
||||
ciphertext). These columns **never** had the resolver indirection — they always stored literal
|
||||
credentials — so no resolve-then-encrypt is needed (unlike the arr columns).
|
||||
|
||||
- [ ] **Step 1: subtitles** — encrypt `api_key` + `password` in `UpsertProviderConfig`; decrypt in
|
||||
`ListProviderConfigs` and `GetProviderConfig`. Keep the existing `HasAPIKey`/`HasCredentials` flags
|
||||
(set them from the decrypted values). AAD PK = `provider_name`.
|
||||
- [ ] **Step 2: watchsync** — encrypt `access_token` + `refresh_token` in `UpsertConnection`. Add a
|
||||
`decryptConnection(*Connection)` helper and call it after **every** scan path (list/get/by-provider/etc.
|
||||
— there are several). AAD PK = connection `id`.
|
||||
- [ ] **Step 3: webhooksync** — encrypt `access_token` in `CreateConnection`; decrypt in `scanConnection`.
|
||||
**Do NOT touch `webhook_secret`** — it is equality-looked-up in `GetConnectionBySecret`
|
||||
(`WHERE webhook_secret = $1`) and is carved out for the hashing follow-up. AAD PK = connection `id`.
|
||||
- [ ] **Step 4: historyimport admin token** — encrypt in `SetSourceAdminToken`; decrypt in
|
||||
`GetSourceWithAdminToken`; `ClearSourceAdminToken` (writes NULL) is unchanged. AAD PK = source `id`.
|
||||
- [ ] **Step 5: historyimport session tokens** — in `internal/historyimport/repo.go`, encrypt
|
||||
`connect_access_token` (`history_import_connect_sessions`) and `auth_token`
|
||||
(`history_import_plex_sessions`) on insert/update; decrypt on scan (note the existing
|
||||
`COALESCE(auth_token, '')` read). These are read by session `id`, not by token value. `pin_id`/`pin_code`
|
||||
are transient pairing artifacts — leave them. AAD PK = session `id`.
|
||||
- [ ] **Step 6:** Thread `dataCipher` into each constructor at its wiring site (`router.go` / `main.go`).
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...`; package tests for each modified repo pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Idempotent startup backfill
|
||||
|
||||
**Files:** Create `internal/secret/backfill.go`, `internal/secret/backfill_test.go`; modify `cmd/silo/main.go`.
|
||||
|
||||
- [ ] **Step 1: Generic in-place backfill** for the literal-credential columns (subtitle ×2, watchsync ×2,
|
||||
webhook `access_token`, historyimport `admin_token`, historyimport session `connect_access_token` +
|
||||
`auth_token`). Per `BackfillTarget {table, column, idColumn, aadPrefix}`: select rows where the column
|
||||
is non-null, non-empty, and `NOT LIKE 'enc:v1:%'`; for each, compute AAD = `aadPrefix + ":" + id`,
|
||||
`EncryptIfPlaintext`, and `UPDATE ... SET col = $1 WHERE id = $2 AND col = $3` (the `AND col = $3` guard
|
||||
makes concurrent multi-node boots converge without double-encrypting). Empty values are skipped (never
|
||||
encrypt `""`).
|
||||
- [ ] **Step 2: Resolve-then-encrypt backfill for the two arr columns** (`request_integrations.api_key_ref`,
|
||||
`autoscan_connections.api_key_ref`). This is **separate** from Step 1 because legacy rows may hold a
|
||||
`server_settings` key name rather than a literal key. For each row whose value is not already
|
||||
`enc:v1:`-prefixed, replicate the old `resolveAPIKey` semantics **once**:
|
||||
1. `resolved := encryptedSettingsRepo.Get(ctx, value)` (the decrypting decorator — handles both a
|
||||
sensitive-and-encrypted target like `requests.radarr.api_key` and a plaintext custom key).
|
||||
2. If `resolved` is non-empty, the row held a *reference* → encrypt `resolved` inline.
|
||||
Else the row held a *literal* key → encrypt `value` inline.
|
||||
3. `UPDATE ... SET api_key_ref = <enc> WHERE id = $2 AND api_key_ref = <original>`, AAD bound to the row id.
|
||||
Run this **after** `BackfillSensitiveSettings` so the referenced settings are consistent (the decorator
|
||||
decrypts regardless, but ordering keeps it deterministic). Optionally log/flag now-orphaned
|
||||
`requests.{radarr,sonarr}.api_key` settings rows for a later cleanup (do not delete in this PR).
|
||||
- [ ] **Step 3: Failure posture (decided):** best-effort. Collect per-row/per-target errors, log each at
|
||||
ERROR, and emit a single startup summary line (`secret backfill: encrypted N, failed M`). A failed
|
||||
encrypt leaves the pre-existing plaintext (no *new* exposure vs. today) and reads still succeed via the
|
||||
read-path pass-through, so it must **not** block boot — reliability over a hard stop. (Read-path decrypt
|
||||
failures on an `enc:v1:` value still hard-fail the individual operation.)
|
||||
- [ ] **Step 4: Ordering in `cmd/silo/main.go`** — gate on migration-running modes only (mirror the
|
||||
existing migration guard; proxy/transcode nodes skip the backfill). Sequence:
|
||||
`LoadBootstrap (read SECRET_KEY) → open pool → RunMigrations → construct dataCipher → wrap settingsRepo →
|
||||
BackfillSensitiveSettings → generic column backfill → arr resolve-then-encrypt backfill → GetAll(decrypts)
|
||||
→ auto-gen jwt_secret (writes encrypted) → [pool recreate? re-wrap settingsRepo] → LoadFromDB(plaintext) → validate`.
|
||||
The settings backfill + `GetAll` must precede `LoadFromDB` so config consumers read decrypted values.
|
||||
- [ ] **Step 5: Concurrency note** — the `AND col = $3` idempotency guard is sufficient for the common
|
||||
single-primary deploy. For multi-primary races, optionally wrap the backfill in a dedicated Postgres
|
||||
advisory lock with a **fresh** lock id (do **not** reuse the migration `legacyBootstrapLocker` id — that
|
||||
would extend the migration lock's held duration unpredictably).
|
||||
- [ ] **Step 6: Tests** (`backfill_test.go`, against a test DB): seed plaintext rows → backfill →
|
||||
all become `enc:v1:` and decrypt to the originals; second run is a no-op (no double-encrypt); mixed
|
||||
plaintext/ciphertext rows handled; empty values skipped; one failing row does not abort the rest;
|
||||
**arr resolve-then-encrypt:** a row holding `requests.radarr.api_key` (with that settings key present)
|
||||
ends up encrypting the *resolved* credential, and a row holding a literal key encrypts the literal.
|
||||
- [ ] **Step 7:** Boot against a DB pre-seeded with plaintext credentials **and** a legacy arr row that
|
||||
references a settings key; confirm the summary log, that every target column is `enc:v1:` afterward, that
|
||||
the arr row decrypts to the real Radarr key (not the setting name), and that a second boot reports
|
||||
`encrypted 0`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Verification, docs, follow-up
|
||||
|
||||
- [ ] **Step 1: Regression guard tests:**
|
||||
- API key auth still works — `api_keys.api_key` is stored as the literal `sa_`-prefixed token and
|
||||
`GetByKey` (`WHERE api_key = $1`) still resolves (proves it was *not* routed through encryption).
|
||||
- `auth.jwt_secret` is consistent across two simulated boots (encrypted at rest, plaintext to
|
||||
`LoadFromDB`), and the stored `server_settings` row is `enc:v1:`-prefixed.
|
||||
- Inbound webhook routing still works (`webhook_secret` untouched).
|
||||
- A legacy arr integration that referenced `requests.sonarr.api_key` still fulfills after backfill.
|
||||
- [ ] **Step 2:** `GOWORK=off go build ./...`, `make lint`, `make verify-local-paths`,
|
||||
`cd web && pnpm run lint && pnpm run format:check` (frontend unaffected — confirm green).
|
||||
- [ ] **Step 3:** Write `docs/architecture/secret-encryption.md`: how `SECRET_KEY` is generated, that it
|
||||
must be backed up **separately** from DB dumps (treat like a CA private key), what happens on key loss
|
||||
(encrypted secrets unrecoverable → re-enter integrations), the rollback/downgrade hazard (an old binary
|
||||
reads `enc:v1:auth.jwt_secret` as a literal JWT secret → all sessions invalid; document that downgrade
|
||||
requires decrypting settings back to plaintext or clearing `auth.jwt_secret`), and the envelope/version
|
||||
scheme for future rotation.
|
||||
- [ ] **Step 4: File the follow-up issues:**
|
||||
1. **Hash equality-lookup secrets** (`api_keys.api_key`, `webhook_sync_connections.webhook_secret`) —
|
||||
store a deterministic hash, look up by hash.
|
||||
2. **Encrypt plugin runtime config** (`plugin_runtime_configs.config_value`) — coordinated with the
|
||||
`Silo` host repo and plugin-manifest secret-field metadata; must also re-encrypt/purge the legacy
|
||||
plaintext `introdb.api_key` copy. Note the dead `plex_sync_connections` table as a drop candidate.
|
||||
- [ ] **Step 5:** Update the issue #45 acceptance check: no plaintext arr keys at rest (Requests +
|
||||
Autoscan); `api_key_ref` is unambiguous (inline ciphertext, no literal fallback); existing rows
|
||||
encrypted by the backfill (arr rows resolved-then-encrypted); S3 + the broader server-owned credential
|
||||
set covered; remaining gaps (equality-lookup hashing, plugin JSONB) tracked as follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## Safety checklist (the implementer must not skip)
|
||||
|
||||
1. `SECRET_KEY` is read in `LoadBootstrap` and **fatals** if absent/short — never defaults to a zero key
|
||||
or a hash of `DATABASE_URL`.
|
||||
2. The cipher is constructed once, right after bootstrap, before any `settingsRepo` call — threaded as a
|
||||
dependency, never a global.
|
||||
3. The settings backfill + decryption happen **before** `config.LoadFromDB` consumes the map.
|
||||
4. The auto-generated `auth.jwt_secret` is written **encrypted**, while the in-memory map keeps plaintext
|
||||
for `LoadFromDB`.
|
||||
5. **`settingsRepo` is re-wrapped as the `EncryptedSettingsRepo` after the max-connections pool recreation
|
||||
(main.go ~399)** and at every other construction site — no raw settings repo escapes into later wiring.
|
||||
6. The two **arr columns** use the **resolve-then-encrypt** backfill (not in-place) so legacy
|
||||
`server_settings` references become the real credential before encryption; the other columns use the
|
||||
plain in-place backfill.
|
||||
7. `SensitiveSettingKeys` is **audited from the config loader's real inputs** (incl.
|
||||
`redis.sentinel_password` and the legacy `s3.operational_*` / `recommendations.openai_api_key`
|
||||
aliases), not copied from the old redaction map.
|
||||
8. `api_keys.api_key` and `webhook_sync_connections.webhook_secret` are **never** routed through
|
||||
encryption — they are equality-looked-up and would break auth/routing (separate hashing task).
|
||||
9. Every decrypt site obeys the **read-path contract**: pass through non-prefixed plaintext; **error
|
||||
(never fall back)** on a corrupt `enc:v1:` value. Audit every old `if resolved == ""` site removed in Task 4.
|
||||
10. AAD binds to **row identity** (`table:column:<pk>`); PKs in scope (integration/connection ids, uuids,
|
||||
`provider_name`) are stable. server_settings binds to its key.
|
||||
11. The backfill is prefix-gated and idempotent (`AND col = $3` guard); proxy/transcode nodes skip it.
|
||||
12. `plex_sync_connections` is not a target (dead table); `plugin_runtime_configs.config_value` is a
|
||||
documented deferred gap, not silently ignored; `subtitle_provider_config.username` stays plaintext.
|
||||
@@ -1,377 +0,0 @@
|
||||
# AI Services Core, Metadata Translation & Whisper ASR — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Design:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md` (read it first; this plan does not repeat the rationale).
|
||||
|
||||
**Goal:** Extract the subtitle-AI LLM plumbing into shared packages (`internal/ai/llm`, `internal/ai/translate`, `internal/ai/jobrunner`), add AI translation of overviews/taglines into the existing localization tables with field provenance, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). Single PR.
|
||||
|
||||
**Architecture:** New `internal/ai/*` packages consumed by a refactored `internal/subtitles/ai` and a new `internal/metadata/translation` service. One new Goose migration (jobs table, provenance columns, library auto-translate flag). New `ai.*` settings keys with loader fallback to legacy `subtitle_ai.*` rows (encrypted values are GCM-bound to their key — never rename rows in SQL). Frontend: AI Services settings page, metadata-editor translate action, library toggle, player "generate from audio" mode.
|
||||
|
||||
**Tech stack:** Go (chi, pgx), PostgreSQL via Goose SQL migrations, React + TypeScript (TanStack Query), Vitest.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Ordering and verification
|
||||
|
||||
Tasks 1–4 are a pure refactor and must leave subtitle translation behaviorally unchanged — run `go build ./... && go test ./internal/...` after each. Tasks 5–11 are backend features; 12–15 frontend; 16 final verification.
|
||||
|
||||
Before opening the MR: `make lint`, `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Create**
|
||||
- `internal/ai/llm/client.go`, `client_test.go`, `config.go` — shared OpenAI-compatible client (chat + transcription)
|
||||
- `internal/ai/llm/transcribe.go` — multipart `/v1/audio/transcriptions`, `verbose_json` types
|
||||
- `internal/ai/translate/translate.go`, `translate_test.go` — generic segment batch translator (package `aitranslate`)
|
||||
- `internal/ai/jobrunner/runner.go`, `runner_test.go` — dispatch/semaphore/heartbeat/reaper/cancel registry
|
||||
- `internal/metadata/translation/{job.go,pgrepo.go,service.go,prompt.go,service_test.go}`
|
||||
- `internal/subtitles/ai/transcriber.go`, `transcriber_test.go` — ASR pipeline
|
||||
- `internal/playback/audio_extract.go` — ffmpeg chunked audio extraction helper
|
||||
- `internal/api/handlers/metadata_ai.go`
|
||||
- `migrations/sql/<timestamp>_ai_metadata_translation_and_asr.sql` (via `make migrate-create`)
|
||||
- `web/src/pages/admin-settings/AIServicesSettings.tsx`
|
||||
|
||||
**Modify**
|
||||
- `internal/subtitles/ai/{client.go→deleted,translator.go,service.go,engine.go,job.go,config.go,pgrepo.go}`
|
||||
- `internal/config/{config.go,db_loader.go}`; `internal/catalog/encrypted_settings_repo.go`
|
||||
- `internal/catalog/{localization_repo.go,detail.go}`; `internal/models/media.go`
|
||||
- `internal/metadata/service.go` (provider provenance + auto-enqueue hook)
|
||||
- `internal/api/handlers/subtitle_ai.go`; router registration; `cmd/silo/main.go` wiring
|
||||
- `web/src/pages/admin-settings/SubtitlesSettings.tsx`, `web/src/components/EditMetadataDialog.tsx`, `web/src/player/components/SubtitleTranslateModal.tsx`, library settings form, `web/src/api/` types/client, admin settings nav/route registration
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `internal/ai/llm` — shared client
|
||||
|
||||
- [ ] **Step 1: Move the chat client.** Create `internal/ai/llm` (package `llm`). Move `Client`, `chat` (export as `Chat`), `chatMessage` (export as `Message`), retry helpers (`sleepCtx`, `rateLimitBackoff`, `truncate`) from `internal/subtitles/ai/client.go`. New `llm.Config`:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
BaseURL string // chat endpoint, no trailing /v1
|
||||
APIKey string
|
||||
ChatModel string
|
||||
ASRBaseURL string // empty = BaseURL
|
||||
ASRAPIKey string // empty = APIKey
|
||||
ASRModel string
|
||||
MaxConcurrentJobs int
|
||||
}
|
||||
```
|
||||
|
||||
Log lines lose the "subtitle" wording (they are shared now).
|
||||
|
||||
- [ ] **Step 2: Add `Transcribe`.** In `transcribe.go`:
|
||||
|
||||
```go
|
||||
type TranscribeRequest struct {
|
||||
Filename string
|
||||
Audio io.Reader
|
||||
Language string // optional ISO-639-1 hint
|
||||
Timeout time.Duration // per-request; sized to chunk length by the caller
|
||||
}
|
||||
type TranscriptionSegment struct{ Start, End float64; Text string }
|
||||
type Transcription struct{ Language string; Segments []TranscriptionSegment }
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error)
|
||||
```
|
||||
|
||||
Multipart fields: `file`, `model` (=`ASRModel`), `response_format=verbose_json`, `temperature=0`, optional `language`. Reuse the same 429/5xx/transport retry-with-backoff loop as `Chat` (extract a shared `doWithRetry` rather than duplicating it). Empty `segments` in the response is an error (`transcription returned no segments`), not a silent fallback.
|
||||
|
||||
- [ ] **Step 3: Tests** (`client_test.go`, httptest): chat retries on 429 (honors `Retry-After`), 5xx, 200-with-error-object, empty choices; transcribe happy path (multipart fields present, segments parsed), missing-segments error, ASR base-url/key override falling back to chat values.
|
||||
|
||||
- [ ] **Step 4: Commit.** `refactor(ai): extract shared OpenAI-compatible LLM client with transcription support`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `internal/ai/translate` — generic segment translator
|
||||
|
||||
- [ ] **Step 1: Move the batch logic.** Package `aitranslate`. Move `buildIndexedJSON`, `extractJSONObject`, and the batch loop from `internal/subtitles/ai/translator.go`, generalized:
|
||||
|
||||
```go
|
||||
type Segment struct{ ID, Text string }
|
||||
type Request struct {
|
||||
Segments []Segment
|
||||
SystemPrompt string // caller-supplied; domain-specific
|
||||
BatchSize int
|
||||
ContextNeighbors int // preceding source segments sent untranslated
|
||||
}
|
||||
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
||||
func Translate(ctx context.Context, chat ChatFn, req Request,
|
||||
onBatch func(batch []Segment, done, total int)) ([]Segment, error)
|
||||
```
|
||||
|
||||
Wire protocol unchanged: 1-based indexed JSON per batch, same-keys response, malformed-response retries (`maxRetries = 2`), completeness check. `ChatFn` keeps the package free of an `llm` dependency and trivially testable.
|
||||
|
||||
- [ ] **Step 2: Tests:** batch splitting/boundaries, context neighbors, code-fence tolerance, omitted-key retry then failure, ctx cancellation between batches.
|
||||
|
||||
- [ ] **Step 3: Commit.** `refactor(ai): extract generic batched segment translator`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `internal/ai/jobrunner` — shared job lifecycle
|
||||
|
||||
- [ ] **Step 1: Extract the runner.** Move dispatch/heartbeat/reaper/cancel mechanics from `internal/subtitles/ai/service.go` (lines ~136–319) behind:
|
||||
|
||||
```go
|
||||
type Store interface {
|
||||
Heartbeat(ctx context.Context, id int64) error
|
||||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||||
MarkCancelled(ctx context.Context, id int64, message string) error
|
||||
}
|
||||
type Runner struct{ /* baseCtx, sem (shared), store, logger, cancels, wg */ }
|
||||
func New(appCtx context.Context, sem chan struct{}, store Store, logger *slog.Logger) *Runner
|
||||
func (r *Runner) Recover() // reap + background reaper loop
|
||||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context)) // semaphore + heartbeat + cancel registry
|
||||
func (r *Runner) Cancel(id int64) bool // true if an in-flight goroutine was cancelled
|
||||
```
|
||||
|
||||
Heartbeat-while-queued behavior (the comment block in `dispatch`) must be preserved verbatim — it is load-bearing for multi-instance safety. Constants (30 s heartbeat, 2 min stale, 1 min reaper) move here.
|
||||
|
||||
- [ ] **Step 2: Shared semaphore.** The semaphore is **constructed by the caller** (`cmd/silo`) at size `ai.max_concurrent_jobs` and passed to every `Runner`, so subtitle + metadata + ASR jobs share one bound.
|
||||
|
||||
- [ ] **Step 3: Tests:** dispatch bounded by a size-1 shared semaphore across two runners; cancel of queued job marks cancelled without running; reaper resets a stale heartbeat row (fake store).
|
||||
|
||||
- [ ] **Step 4: Commit.** `refactor(ai): extract shared job lifecycle runner`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Refactor `internal/subtitles/ai` onto the shared core
|
||||
|
||||
- [ ] **Step 1:** Delete `client.go`; `LLMTranslator` becomes an adapter: cues → `aitranslate.Segment` (ID = 1-based index, Text = joined lines), subtitle system prompt stays here, `splitCueLines` maps back. `Service` keeps job semantics but delegates lifecycle to `jobrunner.Runner`; `pgrepo` satisfies `jobrunner.Store`.
|
||||
- [ ] **Step 2:** Update `cmd/silo/main.go` wiring (build `llm.Client` + shared semaphore once; pass into the subtitle service).
|
||||
- [ ] **Step 3:** `go build ./... && go test ./internal/...` — subtitle translation behavior unchanged (same prompts, same batching, same job rows).
|
||||
- [ ] **Step 4: Commit.** `refactor(subtitles): consume shared AI core`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migration
|
||||
|
||||
- [ ] **Step 1:** `make migrate-create NAME=ai_metadata_translation_and_asr`, then fill the generated file:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE public.metadata_translation_jobs (
|
||||
id bigserial PRIMARY KEY,
|
||||
target_kind text NOT NULL, -- 'item' | 'season' | 'episode'
|
||||
content_id text NOT NULL,
|
||||
include_children boolean NOT NULL DEFAULT true,
|
||||
source_language text NOT NULL DEFAULT '',
|
||||
target_language text NOT NULL,
|
||||
engine text NOT NULL DEFAULT 'openai',
|
||||
model text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
|
||||
progress double precision NOT NULL DEFAULT 0,
|
||||
progress_message text NOT NULL DEFAULT '',
|
||||
fields_done integer NOT NULL DEFAULT 0,
|
||||
fields_total integer NOT NULL DEFAULT 0,
|
||||
force boolean NOT NULL DEFAULT false,
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
idempotency_key text NOT NULL,
|
||||
requested_by integer,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
heartbeat_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX metadata_translation_jobs_active_idempotency_idx
|
||||
ON public.metadata_translation_jobs (idempotency_key)
|
||||
WHERE status IN ('pending', 'running');
|
||||
CREATE INDEX metadata_translation_jobs_content_idx
|
||||
ON public.metadata_translation_jobs (content_id, created_at DESC);
|
||||
CREATE INDEX metadata_translation_jobs_status_idx
|
||||
ON public.metadata_translation_jobs (status) WHERE status IN ('pending', 'running');
|
||||
|
||||
-- Field provenance: 'provider' | 'ai' | 'manual'. manual > provider > ai.
|
||||
ALTER TABLE media_item_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual')),
|
||||
ADD COLUMN tagline_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (tagline_source IN ('provider', 'ai', 'manual'));
|
||||
ALTER TABLE season_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual'));
|
||||
ALTER TABLE episode_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual'));
|
||||
|
||||
ALTER TABLE media_folders
|
||||
ADD COLUMN auto_translate_metadata boolean NOT NULL DEFAULT false;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE media_folders DROP COLUMN auto_translate_metadata;
|
||||
ALTER TABLE episode_localizations DROP COLUMN overview_source;
|
||||
ALTER TABLE season_localizations DROP COLUMN overview_source;
|
||||
ALTER TABLE media_item_localizations DROP COLUMN tagline_source, DROP COLUMN overview_source;
|
||||
DROP TABLE IF EXISTS public.metadata_translation_jobs;
|
||||
-- +goose StatementEnd
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `make migrate-up` against the local stack (`docker compose up -d postgres redis`), then `make migrate-status`.
|
||||
- [ ] **Step 3: Commit.** `feat(metadata): migration for translation jobs, localization provenance, library auto-translate`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Settings & config
|
||||
|
||||
- [ ] **Step 1: Config structs** (`internal/config/config.go`): add `AIConfig` (fields mirroring `llm.Config`); slim `SubtitleAIConfig` to `Enabled`, `TranscribeEnabled`, `BatchSize`, `ContextNeighbors`; add `MetadataAIConfig{Enabled bool}`.
|
||||
- [ ] **Step 2: Loader** (`internal/config/db_loader.go`): load `ai.*` with legacy fallback, following the `recommendations.embedding_auth_token` precedent at line ~409:
|
||||
|
||||
```go
|
||||
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
|
||||
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
|
||||
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
|
||||
// ai.max_concurrent_jobs ← subtitle_ai.max_concurrent_jobs ← 2
|
||||
// ai.asr_model (default "whisper-1"), ai.asr_base_url, ai.asr_api_key (default "")
|
||||
// subtitle_ai.transcribe_enabled, metadata_ai.enabled (default false)
|
||||
```
|
||||
|
||||
**Never** rename the legacy rows in SQL — encrypted values are GCM-bound to their setting key (`internal/catalog/encrypted_settings_repo.go`).
|
||||
|
||||
- [ ] **Step 3:** Add `ai.api_key`, `ai.asr_api_key` to `sensitiveSettingKeys`.
|
||||
- [ ] **Step 4:** Readiness helpers: subtitle translate = `SubtitleAI.Enabled && AI chat ready`; ASR = `SubtitleAI.TranscribeEnabled && AI.ASRModel != "" && (asr or chat base URL set)`; metadata = `MetadataAI.Enabled && AI chat ready`.
|
||||
- [ ] **Step 5: Commit.** `feat(config): shared ai.* settings with legacy subtitle_ai fallback`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Provenance-aware localization writes
|
||||
|
||||
- [ ] **Step 1: Models** (`internal/models/media.go`): add `OverviewSource`/`TaglineSource` to `MediaItemLocalization`, `OverviewSource` to `SeasonLocalization`/`EpisodeLocalization`.
|
||||
- [ ] **Step 2: Provider upsert rules** (`internal/catalog/localization_repo.go`): rewrite the three `Upsert` statements so, per AI-writable field:
|
||||
- existing source `manual` → keep existing value and source;
|
||||
- incoming value empty → keep existing value and source;
|
||||
- otherwise → take incoming value, set source `provider`.
|
||||
|
||||
Single-statement `ON CONFLICT … DO UPDATE` with `CASE` (no read-modify-write). Pattern for one field:
|
||||
|
||||
```sql
|
||||
overview = CASE
|
||||
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview
|
||||
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview
|
||||
ELSE EXCLUDED.overview END,
|
||||
overview_source = CASE
|
||||
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview_source
|
||||
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview_source
|
||||
ELSE 'provider' END
|
||||
```
|
||||
|
||||
- [ ] **Step 3: AI upsert methods**: `UpsertAITranslation(ctx, contentID, language string, overview, tagline *string, force bool)` per repo (nil pointer = field not part of this write). Insert path populates only the translated fields (other text columns empty). Update path per field: write when existing source is `ai`, existing value is empty, or `force` — never when `manual`; `force` may overwrite `provider` (the admin asked). Sets source `ai`.
|
||||
- [ ] **Step 4: Serving hardening** (`internal/catalog/detail.go`): verify `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel` only override base fields with **non-empty** localization values (AI rows carry empty titles). Fix any field that clobbers; add a regression test.
|
||||
- [ ] **Step 5: Tests** for the upsert matrix (provider-over-ai, ai-skips-provider-unless-force, manual-untouchable, empty-never-blanks), following the existing repo/service test patterns in `internal/catalog`.
|
||||
- [ ] **Step 6: Commit.** `feat(catalog): field provenance for localizations with provider/ai/manual precedence`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `internal/metadata/translation` service
|
||||
|
||||
- [ ] **Step 1: Job model + repo** (`job.go`, `pgrepo.go`): mirror `internal/subtitles/ai/{job.go,pgrepo.go}` shapes against `metadata_translation_jobs`; satisfy `jobrunner.Store`. Idempotency key = SHA-256 of `content_id|target_kind|target_language|model`.
|
||||
- [ ] **Step 2: Service** (`service.go`): `Enqueue`, `GetJob`, `ListJobs(contentID)`, `Cancel`, `Recover`, using `jobrunner.Runner` + the shared semaphore. The run loop:
|
||||
1. Expand targets: `item` → item overview+tagline; series item with `include_children` → + every season overview + every episode overview; `season` (+children → its episodes); `episode` → its overview. Source text comes from the **base** rows (default metadata language); `source_language` recorded from the item's `default_metadata_language`.
|
||||
2. Skip-if-filled: drop any field whose target-language localization value is already non-empty, unless `force`. All skipped → complete immediately ("Nothing to translate").
|
||||
3. Build `aitranslate.Segment`s (IDs `item:overview`, `item:tagline`, `season:<content_id>:overview`, `episode:<content_id>:overview`), translate with `prompt.go`'s system prompt (names the title/year, "translate media catalog descriptions", preserve proper nouns/character names/tone, no added information), `BatchSize` = package constant `metadataBatchSize = 10`, `ContextNeighbors = 0`.
|
||||
4. Per batch: provenance-aware AI upserts (Task 7) + `fields_done`/progress update. Persisting per batch means a cancelled job keeps completed fields.
|
||||
- [ ] **Step 3: Tests** (fake repos + fake `ChatFn`): series expansion counts, skip-if-filled short-circuit (zero chat calls), force overwrites `ai`+`provider` but never `manual`, per-batch persistence on cancellation mid-job.
|
||||
- [ ] **Step 4: Commit.** `feat(metadata): AI translation service for overviews and taglines`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Ingestion hook + library flag
|
||||
|
||||
- [ ] **Step 1:** Switch the provider localization writes in `internal/metadata/service.go` (item ~line 1476, season ~2921, episode ~3080) to the provenance-aware upserts — no other behavior change.
|
||||
- [ ] **Step 2:** Thread `auto_translate_metadata` through the `MediaFolder` model, folder repo scan/update, and the folder settings API payload.
|
||||
- [ ] **Step 3: Auto-enqueue hook.** After an item's refresh persists (single point at the end of the item flow — not per season/episode), when: folder flag set, folder `metadata_language` non-empty and ≠ item `default_metadata_language`, metadata AI ready, and the item's target-language localization is missing overview (or tagline) → `Enqueue` a non-force `item` job with `include_children=true`. Fire-and-forget (log on error); the active-idempotency index plus skip-if-filled make repeat refreshes free.
|
||||
- [ ] **Step 4: Commit.** `feat(metadata): per-library auto-translate fallback on refresh`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Metadata AI API
|
||||
|
||||
- [ ] **Step 1:** `internal/api/handlers/metadata_ai.go`, mirroring `subtitle_ai.go` shapes:
|
||||
- `GET /api/v1/metadata/ai/status` → `{enabled}`
|
||||
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}` → `{job}` (joins an in-flight duplicate, same as subtitles)
|
||||
- `GET /api/v1/metadata/ai/jobs/{job_id}`, `POST /api/v1/metadata/ai/jobs/{job_id}/cancel`, `GET /api/v1/metadata/ai/jobs?content_id=…`
|
||||
- [ ] **Step 2:** Gate with the same permission as metadata editing (metadata curation permission; see `internal/catalog/update.go` callers). Register routes; wire the service in `cmd/silo/main.go`.
|
||||
- [ ] **Step 3:** Handler tests following `internal/api/handlers` conventions (validation: bad kind, missing language, disabled engine → 400/503 mapping).
|
||||
- [ ] **Step 4: Commit.** `feat(api): metadata AI translation endpoints`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Whisper ASR
|
||||
|
||||
- [ ] **Step 1: Audio extraction** (`internal/playback/audio_extract.go`), alongside the existing subtitle extraction helpers:
|
||||
|
||||
```go
|
||||
// ExtractAudioChunks extracts one audio track to 16 kHz mono WAV chunks in dir.
|
||||
// Returns ordered chunk paths; chunkSeconds = 600 in production.
|
||||
func ExtractAudioChunks(ctx context.Context, filePath string, audioTrackIndex int,
|
||||
dir, ffmpegPath string, chunkSeconds int) ([]string, error)
|
||||
```
|
||||
|
||||
Single ffmpeg pass: `-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time <n> <dir>/chunk%05d.wav`.
|
||||
|
||||
- [ ] **Step 2: Transcriber** (`internal/subtitles/ai/transcriber.go`):
|
||||
|
||||
```go
|
||||
type Transcriber interface {
|
||||
Transcribe(ctx context.Context, req TranscribeJobRequest,
|
||||
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string /*detected lang*/, error)
|
||||
}
|
||||
```
|
||||
|
||||
`WhisperTranscriber` implementation: temp dir (`os.MkdirTemp`, removed on all exit paths) → `ExtractAudioChunks` → per chunk `llm.Client.Transcribe` (per-request timeout ∝ chunk duration; pass the job's language hint) → offset segment times by `chunkIndex*chunkSeconds` → cues (one per segment, wrapped to ≤2 lines ×~42 chars via a `wrapCueText` helper; whitespace-only segments dropped). Chunk processing order starts at the chunk containing `StartPosition`, then wraps (mirrors `reorderFromPosition` semantics).
|
||||
|
||||
- [ ] **Step 3: Wire kinds into the service.** `Service.run` branches on `job.Kind`:
|
||||
- `translate`: existing path.
|
||||
- `transcribe`: resolve audio track (`source_index` = audio index, `-1` → default/first; reject files without audio), transcribe (progress 10–70 %, live cue streaming via the existing notifier callbacks), store SRT as provider `transcribed`, language = hint or detected, release name `"<Language> (AI transcribed)"`, notify `SubtitleReady`.
|
||||
- `transcribe_translate`: `transcribe`, store the transcript track, then run the existing `Translator` on the cues (70–95 %), store provider `translated`, `result_subtitle_id` = translated track.
|
||||
|
||||
Enqueue validation per kind (ASR requires `TranscribeEnabled`; idempotency model component = `asr_model` or `asr_model+chat_model` for the chained kind).
|
||||
|
||||
- [ ] **Step 4:** API surface: `kind` field (optional, default `translate`) on the existing enqueue request in `subtitle_ai.go`; `transcribe_enabled` on the status response.
|
||||
- [ ] **Step 5: Tests** (`transcriber_test.go`, fake client): timestamp stitching across chunks, playhead-first chunk ordering, cue wrapping, empty-segments chunk → job error, temp-dir cleanup on failure (assert via `t.TempDir` layout).
|
||||
- [ ] **Step 6: Commit.** `feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Frontend — AI Services settings page
|
||||
|
||||
- [ ] **Step 1:** `web/src/pages/admin-settings/AIServicesSettings.tsx`: connection card (`ai.base_url`, `ai.api_key` via the sensitive-key pattern already used in `SubtitlesSettings.tsx:264`, `ai.chat_model`, `ai.asr_model`, optional `ai.asr_base_url`/`ai.asr_api_key`, `ai.max_concurrent_jobs`) + features card (`subtitle_ai.enabled`, `subtitle_ai.transcribe_enabled`, `metadata_ai.enabled`, `subtitle_ai.batch_size`, `subtitle_ai.context_neighbors`). Reads show effective values (new key, falling back to the legacy key when unset — same fallback order as the loader); writes always target the new `ai.*` keys.
|
||||
- [ ] **Step 2:** Remove the AI card from `SubtitlesSettings.tsx` (leave a link/hint to the new page); register the page in the admin settings nav + route following the existing page registrations.
|
||||
- [ ] **Step 3: Commit.** `feat(web): AI services settings page`
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Frontend — metadata translate action
|
||||
|
||||
- [ ] **Step 1:** API client functions + types for the Task 10 endpoints.
|
||||
- [ ] **Step 2:** `EditMetadataDialog.tsx`: "Translate with AI" action (visible when `metadata_ai` status is enabled): target language (default = library `metadata_language`), `include_children` (series only), `force` checkbox; enqueue, poll the job every ~1.5 s, show `progress_message`/`fields_done`, invalidate the item detail query on completion.
|
||||
- [ ] **Step 3: Commit.** `feat(web): translate descriptions from the metadata editor`
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Frontend — library auto-translate toggle
|
||||
|
||||
- [ ] **Step 1:** Add the `auto_translate_metadata` switch to the library settings form next to the metadata-language field (helper text: "When metadata providers have no translation for this library's language, translate descriptions with AI"). Disabled state with hint when `metadata_ai` is off.
|
||||
- [ ] **Step 2: Commit.** `feat(web): library auto-translate toggle`
|
||||
|
||||
---
|
||||
|
||||
## Task 15: Frontend — player "Generate from audio"
|
||||
|
||||
- [ ] **Step 1:** `SubtitleTranslateModal.tsx`: when `transcribe_enabled`, add a source option "Generate from audio (AI)" listing the file's audio tracks; submit with `kind: "transcribe"` (or `transcribe_translate` when the chosen output language differs from the audio language). Surface it prominently when no text subtitle source exists (today's dead end for bitmap-only files).
|
||||
- [ ] **Step 2: Commit.** `feat(web): generate subtitles from audio in the player`
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Verification
|
||||
|
||||
- [ ] `go build ./... && go test ./...`
|
||||
- [ ] `make lint`
|
||||
- [ ] `cd web && pnpm run lint && pnpm run format:check`
|
||||
- [ ] `make verify-local-paths`
|
||||
- [ ] Manual smoke against the local stack: enqueue a metadata translation for a series (verify localization rows + provenance values), re-run a refresh (verify provider values overwrite `ai` rows and `manual` simulation survives), run a `transcribe` job on a short file against a local Whisper-compatible server.
|
||||
- [ ] MR description: problem, approach, link to the design doc, risks (Whisper endpoint variance, chunk-boundary artifacts), AI-use disclosure, screenshots of the new settings page / editor action / player mode.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,224 +0,0 @@
|
||||
# Audiobook Player QoL Improvements
|
||||
|
||||
Commands assume the repository root is the cwd. All paths are repository-relative.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the web audiobook player to feature parity with best-in-class audiobook apps
|
||||
(Audiobookshelf, Audible, BookPlayer, Prologue) on the quality-of-life axis. The current
|
||||
surface architecture (persistent mini bar + expandable Now Listening view, playback
|
||||
survives navigation) is already the de-facto standard and stays as-is.
|
||||
|
||||
Explicitly **out of scope**: Media Session / lock-screen integration, bookmarks (needs a
|
||||
server endpoint — follow-up), offline/download, queue.
|
||||
|
||||
## Features
|
||||
|
||||
1. **Keyboard shortcuts** — space/K play-pause, arrow skips, volume, chapter nav, speed step.
|
||||
2. **Configurable skip intervals** — asymmetric defaults (back 10s, forward 30s), user-tunable.
|
||||
3. **Expanded speed control** — 0.5×–3.0× with fine stepping, presets, and per-book memory.
|
||||
4. **Chapter prev/next buttons** — alongside the seconds-skip buttons.
|
||||
5. **Volume control** — reuse the shared `VolumeControl`, persisted like the video player.
|
||||
6. **Smart rewind** — auto-rewind on resume, scaled by how long playback was paused.
|
||||
7. **Time remaining at current speed** — real-clock remaining display in Now Listening.
|
||||
|
||||
No server/API changes are required; everything is web-frontend only, so no Android/Apple
|
||||
client coordination is needed. (Per-book speed memory is device-local in v1; syncing it
|
||||
server-side via profile settings is a noted follow-up.)
|
||||
|
||||
## Existing seams (read these first)
|
||||
|
||||
| File | Relevance |
|
||||
| --- | --- |
|
||||
| `web/src/pages/audiobooks/player/useAudiobookPlayback.ts` | Playback engine. `togglePlay` (~line 499), `skip` (~534), `setRate` (~541), returned API (~690+). All engine work lands here. |
|
||||
| `web/src/pages/audiobooks/player/MiniBar.tsx` | Bottom bar. Hardcoded `SKIP_*_SECONDS = 30` and `PLAYBACK_RATES` at lines 10–12. |
|
||||
| `web/src/pages/audiobooks/player/NowListening.tsx` | Full-screen view. Same hardcoded constants; dead "More" button (lines 50–56) becomes the settings menu trigger; remaining-time toggle at lines 33–37. |
|
||||
| `web/src/pages/audiobooks/player/AudiobookPlayer.tsx` | Wrapper that owns mini/now-listening mode — mount point for the keyboard shortcuts hook. |
|
||||
| `web/src/player/components/VolumeControl.tsx` | Existing slider + `getPersistedVolume`/`persistVolume` helpers (shared `player-volume`/`player-muted` storage keys). Styled for dark video overlay — needs a surface-toned variant. |
|
||||
| `web/src/player/components/SpeedMenu.tsx` | Current preset-only menu (shared with video player). Audiobook gets a richer `SpeedControl`; video keeps `SpeedMenu` untouched. |
|
||||
| `web/src/player/hooks/useKeyboardShortcuts.ts` | Video player's shortcut hook — pattern to mirror (input-field guard, single document listener). |
|
||||
| `web/src/utils/storage.ts` | Typed localStorage wrapper — add new keys here, never call `localStorage` directly. |
|
||||
| `web/src/player/components/SeekBar.tsx`, `CircleButton.tsx`, `SleepTimerMenu.tsx`, `ChaptersMenu.tsx` | Established player design language; reuse, don't fork. |
|
||||
|
||||
## Design language
|
||||
|
||||
These are additions to an existing, refined system — extend it, don't restyle it:
|
||||
|
||||
- **Surfaces**: mini bar is a themed surface (`bg-background`, `border-t`, token colors).
|
||||
Popovers anchored to it use the established dark-glass treatment
|
||||
(`bg-black/90 backdrop-blur-sm rounded-lg shadow-xl`, white/75 text, `data-active` rows)
|
||||
exactly as `SpeedMenu`/`SleepTimerMenu` do today.
|
||||
- **Type**: time and rate values are always `font-mono`/`tabular-nums` so nothing shifts as
|
||||
digits tick. The new speed readout (`1.45×`) must reserve width for two decimals.
|
||||
- **Buttons**: all transport controls are `CircleButton` (`sm` secondary flanks, `md`/`lg`
|
||||
primary center). Chapter prev/next use `SkipBack`/`SkipForward` lucide glyphs at the same
|
||||
stroke weight (1.6) as the rotate icons, so the cluster reads as one family.
|
||||
- **Micro-interactions**, small and purposeful:
|
||||
- Skip buttons: a quick ~18° icon flick in the skip direction on press
|
||||
(CSS transform transition, ~150ms ease-out) — confirms the action without a toast.
|
||||
- Speed stepper: value crossfades (opacity 120ms) on change; the active preset chip gets
|
||||
the same `bg-white/5 text-white` treatment as current menu rows.
|
||||
- Smart rewind: when it fires, the seek bar playhead animates the small jump back rather
|
||||
than teleporting (SeekBar already animates position; verify it holds for ≤30s deltas).
|
||||
- **Accessibility**: every new control keeps the existing patterns — `aria-label`,
|
||||
`aria-expanded`/`aria-haspopup` on triggers, roving focus + Escape in menus (copy from
|
||||
`SleepTimerMenu`), `role="slider"` semantics from `VolumeControl`/`SeekBar`.
|
||||
|
||||
## Phase 1 — Engine + preferences model
|
||||
|
||||
### 1a. Preferences hook
|
||||
|
||||
New `web/src/pages/audiobooks/player/useAudiobookPrefs.ts`:
|
||||
|
||||
- Add storage keys: `AUDIOBOOK_SKIP_BACK`, `AUDIOBOOK_SKIP_FORWARD`,
|
||||
`AUDIOBOOK_SMART_REWIND` (bool), `AUDIOBOOK_RATES` (JSON map) in `web/src/utils/storage.ts`.
|
||||
- Exposes `{ skipBack, skipForward, smartRewind, setSkipBack, setSkipForward, setSmartRewind }`.
|
||||
- Defaults: back **10s**, forward **30s** (the asymmetric convention — small "what did she
|
||||
say?" hops back, bigger hops forward), smart rewind **on**.
|
||||
- Allowed skip values: 5, 10, 15, 30, 45, 60, 90 (two digits max keeps `SkipIcon`'s inset
|
||||
number legible).
|
||||
- Per-book rate map: single JSON object `{ [contentId]: rate }` under `AUDIOBOOK_RATES`,
|
||||
LRU-capped at 50 entries (store `{ rate, at }` and evict oldest on insert).
|
||||
Helpers `getBookRate(contentId)` / `setBookRate(contentId, rate)` live here too.
|
||||
|
||||
### 1b. Engine extensions (`useAudiobookPlayback.ts`)
|
||||
|
||||
- **Volume/mute**: initialize from `getPersistedVolume()`; apply to the `<audio>` element in
|
||||
an effect; expose `volume`, `muted`, `setVolume`, `setMuted`; persist via `persistVolume`.
|
||||
Sharing the video player's keys is intentional — one volume preference per device.
|
||||
- **Per-book rate**: on mount, initialize `rate` from `getBookRate(contentId)` (fallback 1);
|
||||
in `setRate`, also `setBookRate`. The hook needs `contentId` passed in (it already
|
||||
receives the files/options object — extend that input).
|
||||
- **Chapter navigation**: expose `nextChapter()` and `prevChapter()`.
|
||||
- `next`: seek to start of the chapter after `currentChapter`; no-op at the last chapter.
|
||||
- `prev`: if more than 3s into the current chapter, seek to its start; otherwise seek to
|
||||
the previous chapter's start (the universal music/audiobook convention).
|
||||
- **Smart rewind**: record `pausedAtRef = performance.now()` whenever playback pauses (in
|
||||
the `pause` event handler so it also catches OS-initiated pauses). On resume, before
|
||||
`audio.play()`, compute rewind from pause duration and `seekTo(currentTime - rewind)`:
|
||||
- < 10s paused → 0s; < 1min → 3s; < 10min → 10s; < 1h → 20s; ≥ 1h → 30s.
|
||||
- Clamp at 0; skip entirely when the smart-rewind pref is off or the resume immediately
|
||||
follows a user seek (seeking sets a short suppress flag so explicit jumps aren't undone).
|
||||
- Also apply on **cold resume**: when starting with `initialPositionSeconds > 0` from the
|
||||
detail page, rewind by the same schedule using the age of the saved progress if the
|
||||
API exposes an updated-at timestamp; otherwise apply a flat 10s. Keep this logic in one
|
||||
pure function `smartRewindSeconds(pauseMs)` so it's unit-testable.
|
||||
- **Skip intervals**: no engine change — `skip(delta)` already takes a signed delta; the UI
|
||||
passes the configured values.
|
||||
|
||||
### 1c. Tests
|
||||
|
||||
Vitest, colocated like existing `*.test.tsx`:
|
||||
|
||||
- `smartRewindSeconds` schedule boundaries.
|
||||
- Per-book rate LRU (cap, eviction, malformed-JSON tolerance).
|
||||
- `prevChapter` 3s threshold behavior and first/last chapter edges
|
||||
(chapter math is pure — test against `buildChapterList` fixtures from
|
||||
`web/src/lib/audiobooks/chapters.ts`).
|
||||
|
||||
## Phase 2 — Controls UI
|
||||
|
||||
### 2a. SpeedControl (new, audiobook-specific)
|
||||
|
||||
New `web/src/pages/audiobooks/player/SpeedControl.tsx`, replacing `SpeedMenu` usage in both
|
||||
audiobook views (video player keeps `SpeedMenu`):
|
||||
|
||||
- Trigger: same `player-utility-btn` showing the current rate (`1.45×`, tabular-nums).
|
||||
- Popover (dark-glass, bottom-anchored like today):
|
||||
- A stepper row: `−` / big mono readout / `+`, stepping **0.05** per tap, clamped to
|
||||
**0.5–3.0**. Press-and-hold repeats (250ms initial, 80ms repeat).
|
||||
- A preset chip row: 1× · 1.25× · 1.5× · 1.75× · 2× · 2.5× · 3×.
|
||||
- Footnote line: "Remembered for this book" (quiet `text-white/40`, 11px) — makes the
|
||||
per-book memory discoverable instead of magical.
|
||||
- Keyboard: ArrowUp/Down step inside the popover; Escape closes (copy menu plumbing from
|
||||
`SleepTimerMenu`).
|
||||
|
||||
### 2b. Chapter prev/next buttons
|
||||
|
||||
- Add `SkipBack`/`SkipForward` `CircleButton size="sm" variant="secondary"` on the outside
|
||||
of the transport cluster in both `MiniBar` and `NowListening`:
|
||||
`⏮ ↺10 ▶ ↻30 ⏭`. Wire to `prevChapter`/`nextChapter`; render only when
|
||||
`playback.chapters.length > 0`; disable at the ends (prev stays enabled mid-chapter).
|
||||
- In `MiniBar`, hide the chapter buttons below `sm:` — the 3-column grid is already tight
|
||||
on phones and the chapters menu remains available.
|
||||
|
||||
### 2c. Volume
|
||||
|
||||
- Add a `tone` prop to `web/src/player/components/VolumeControl.tsx`
|
||||
(`"overlay"` = current white-on-dark, `"surface"` = theme tokens: track `bg-muted`,
|
||||
fill `bg-foreground`, focus ring `ring-ring`). Default `"overlay"` so the video player is
|
||||
untouched.
|
||||
- Mount in `MiniBar`'s right cluster (before the sleep timer) at `md:` and up, and in
|
||||
`NowListening`'s utility row. Wire to the new engine `volume`/`muted` state.
|
||||
|
||||
### 2d. Player settings menu
|
||||
|
||||
New `web/src/pages/audiobooks/player/PlayerSettingsMenu.tsx` (dark-glass popover):
|
||||
|
||||
- **Skip back** / **Skip forward**: rows of small value chips (5/10/15/30/45/60/90s).
|
||||
- **Smart rewind**: toggle row with a one-line description ("Backs up a little after a pause").
|
||||
- Triggers: the currently-dead "More" (`MoreHorizontal`) button in `NowListening`
|
||||
(lines 50–56) and a matching trigger in `MiniBar`'s right cluster.
|
||||
- `MiniBar`/`NowListening` read `useAudiobookPrefs()` and pass the configured seconds to
|
||||
the skip buttons and `SkipIcon` (which already renders a dynamic number) — delete the
|
||||
`SKIP_*_SECONDS` constants.
|
||||
|
||||
## Phase 3 — Shortcuts + time display + polish
|
||||
|
||||
### 3a. Keyboard shortcuts
|
||||
|
||||
New `web/src/pages/audiobooks/player/useAudiobookKeyboardShortcuts.ts`, mounted in
|
||||
`AudiobookPlayer.tsx` (so it's active in both mini and expanded modes, on every route):
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| Space / K | play-pause |
|
||||
| ← / → | skip by configured back/forward seconds |
|
||||
| ↑ / ↓ | volume ±5% |
|
||||
| M | mute toggle |
|
||||
| N / P | next / previous chapter |
|
||||
| Shift+. / Shift+, | speed +0.05 / −0.05 (YouTube convention) |
|
||||
| E | expand / collapse Now Listening |
|
||||
| Esc | collapse Now Listening (expanded mode only) |
|
||||
|
||||
- Mirror `useKeyboardShortcuts.ts` guards: ignore when target is input/textarea/
|
||||
contentEditable, single document listener, cleanup on unmount.
|
||||
- Additional guard: **do nothing while a video session is active** — the video player binds
|
||||
the same keys. Check `WatchPlaybackProvider` state (or simply whether a `<video>` watch
|
||||
route is mounted) and bail; the audiobook bar is backgrounded in that case anyway. Space
|
||||
must also not fire when a button has focus (let the focused control handle Enter/Space).
|
||||
- Add `title` tooltips with the shortcut hint to transport buttons
|
||||
(e.g. `Back 10 seconds (←)`) for discoverability.
|
||||
|
||||
### 3b. Time remaining at current speed
|
||||
|
||||
In `NowListening`, the right time label currently toggles total ↔ remaining. Make it cycle
|
||||
three states (persist last choice in component state only):
|
||||
|
||||
1. total (`12:04:00`)
|
||||
2. remaining (`−3:21:09`)
|
||||
3. remaining at speed (`−2:14:06 at 1.5×`) — `remaining / rate`, hidden (skipped in the
|
||||
cycle) when `rate === 1`.
|
||||
|
||||
Keep the `data-testid="now-listening-right-time"` hook and update its test.
|
||||
|
||||
### 3c. Polish pass
|
||||
|
||||
- Skip-flick and speed-crossfade micro-interactions from the design section.
|
||||
- Verify mini bar layout on 360px-wide viewports with all new controls (chapter buttons and
|
||||
volume hidden, settings menu accessible).
|
||||
- `cd web && pnpm run lint && pnpm run format:check`; run the player test suites.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- `useAudiobookPlayback.ts` is 707 lines and growing — extract smart rewind + prefs-coupled
|
||||
logic into small pure modules (`smartRewind.ts`) rather than inflating the hook; consider
|
||||
splitting volume/rate concerns into a `useAudioElementSettings` helper if the hook passes
|
||||
~800 lines.
|
||||
- Smart rewind must not fight the 10s progress reporter: rewinding on resume changes
|
||||
`currentTime`, which triggers a progress write — that's correct (the rewound position is
|
||||
the truth), just confirm no oscillation with the suppress-after-seek flag.
|
||||
- Per-book rate uses `contentId`; switching narrators navigates to a different `contentId`,
|
||||
so each narration remembers its own speed — acceptable, arguably desirable.
|
||||
- Cross-device sync of prefs/per-book speed is a candidate follow-up once a profile
|
||||
settings endpoint exists; the `useAudiobookPrefs` API is the seam to swap storage behind.
|
||||
@@ -1,311 +0,0 @@
|
||||
# AI services: shared core, metadata translation, Whisper ASR — design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Draft, pending review
|
||||
**Scope:** Extract the AI subtitle translation engine into a shared AI core, add AI translation of movie/series/season/episode descriptions into the existing localization tables, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). One PR.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
`internal/subtitles/ai` ships an on-demand LLM subtitle translator. The LLM plumbing inside
|
||||
it (OpenAI-compatible chat client with retry/backoff, batched indexed-JSON translation,
|
||||
job lifecycle with idempotency/heartbeat/reaper/bounded concurrency) is generic, but it is
|
||||
buried in a subtitle-specific package, so nothing else can use it.
|
||||
|
||||
Two features want exactly that plumbing:
|
||||
|
||||
1. **Metadata translation.** Libraries whose `metadata_language` the providers cannot fully
|
||||
serve end up with overviews/taglines in the item's default language (usually English).
|
||||
The localization tables (`media_item_localizations`, `season_localizations`,
|
||||
`episode_localizations`) and the serving path (`LocalizeItemModel` and friends in
|
||||
`internal/catalog/detail.go`) already exist — the gap is producing rows for languages the
|
||||
providers don't have.
|
||||
2. **Whisper ASR.** `subtitle_ai_jobs.kind` already reserves `transcribe` and
|
||||
`transcribe_translate`; media with no usable text subtitle track (foreign audio with only
|
||||
bitmap subs, or no subs at all) currently has no AI path to a subtitle track.
|
||||
|
||||
## Goals
|
||||
|
||||
- One shared AI core: a single OpenAI-compatible endpoint configuration (chat + audio
|
||||
transcription), one retry/backoff implementation, one generic batched text translator,
|
||||
one job-lifecycle runner — consumed by subtitle translation, metadata translation, and ASR.
|
||||
- AI-translated overviews and taglines stored in the **existing** localization tables, so
|
||||
web, Android, Apple, and jellycompat clients get them through the current serving path
|
||||
with **zero client changes**.
|
||||
- Provenance on AI-writable localization fields so refreshes never regress data quality:
|
||||
`manual` beats `provider` beats `ai`. A later provider localization overwrites an AI
|
||||
translation; the reverse never happens.
|
||||
- Two metadata-translation triggers: a manual admin action, and an opt-in per-library
|
||||
fallback ("provider had no localization for the library language → AI-translate") wired
|
||||
into metadata ingestion.
|
||||
- ASR jobs that produce ordinary `downloaded_subtitles` rows (provider `transcribed`),
|
||||
with `transcribe_translate` chaining the existing LLM translator for a target-language
|
||||
track; live cue streaming to the requesting playback session, same as translation today.
|
||||
- Total AI load on the configured endpoint bounded by **one** shared semaphore.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Episode/movie/series title translation.** Titles often have official localized forms
|
||||
that are not literal translations; provider data only.
|
||||
- **Collection names or descriptions.** Deferred: collections have no localization table
|
||||
yet. The metadata translation service is written against content-ID + field-list inputs,
|
||||
so a `collection_localizations` table can be added in a follow-up without reshaping it.
|
||||
- **Genre translation.** If ever wanted, that is a static mapping, not an LLM call.
|
||||
- **Per-profile metadata language.** Presentation language stays library-scoped
|
||||
(`media_folders.metadata_language`); changing that is an orthogonal feature.
|
||||
- **A localization editor UI.** The `manual` provenance value is honored by the write
|
||||
rules but no UI sets it yet (the existing metadata editor edits base fields, not
|
||||
localizations).
|
||||
- **Whisper word-level timing, diarization, VAD-aligned chunking.** v1 uses fixed-length
|
||||
chunks; boundary-word artifacts are an accepted limitation noted for follow-up.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package layout
|
||||
|
||||
```
|
||||
internal/ai/llm Client: OpenAI-compatible /v1/chat/completions (moved from
|
||||
internal/subtitles/ai/client.go) + new /v1/audio/transcriptions.
|
||||
Shared retry/backoff/429 handling. Config for the endpoint(s).
|
||||
internal/ai/translate Generic batched text translation: []Segment{ID, Text} in,
|
||||
translated segments out. Indexed-JSON protocol, context
|
||||
neighbors, malformed-response retries — moved from
|
||||
internal/subtitles/ai/translator.go and de-subtitled.
|
||||
internal/ai/jobrunner Generic job lifecycle: bounded dispatch off a shared semaphore,
|
||||
heartbeat loop, stale-job reaper, cancel registry — extracted from
|
||||
internal/subtitles/ai/service.go behind a small repo interface.
|
||||
internal/subtitles/ai Stays: subtitle job semantics. Translate path becomes a thin
|
||||
adapter (cues ↔ segments); gains the transcribe paths.
|
||||
internal/metadata/translation
|
||||
New: metadata translation job service + repo + provenance-aware
|
||||
writes into the localization repos.
|
||||
```
|
||||
|
||||
Package names: `llm`, `aitranslate`, `jobrunner` (import paths above; `aitranslate` avoids
|
||||
a name collision when imported from package `ai`).
|
||||
|
||||
### Shared semaphore
|
||||
|
||||
`cmd/silo` builds one `chan struct{}` of size `ai.max_concurrent_jobs` and hands it to both
|
||||
job services via `jobrunner`. Subtitle, metadata, and ASR jobs all draw from it, so the
|
||||
operator's endpoint sees a bounded number of concurrent jobs regardless of job mix.
|
||||
(An ASR job holds one slot for its whole pipeline: extract → transcribe → translate.)
|
||||
|
||||
## Settings
|
||||
|
||||
Connection settings move to a shared `ai.*` namespace. Because encrypted values are
|
||||
GCM-bound to their setting key (see `internal/catalog/encrypted_settings_repo.go`), the
|
||||
legacy rows are **not renamed**; the loader falls back to them, following the precedent of
|
||||
`recommendations.embedding_auth_token` → `recommendations.openai_api_key` in
|
||||
`internal/config/db_loader.go`.
|
||||
|
||||
| Key | Default | Fallback | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `ai.base_url` | `https://api.openai.com` | `subtitle_ai.base_url` | |
|
||||
| `ai.api_key` | `""` | `subtitle_ai.api_key` | sensitive |
|
||||
| `ai.chat_model` | `gpt-4o-mini` | `subtitle_ai.chat_model` | |
|
||||
| `ai.max_concurrent_jobs` | `2` | `subtitle_ai.max_concurrent_jobs` | shared semaphore size |
|
||||
| `ai.asr_model` | `whisper-1` | — | |
|
||||
| `ai.asr_base_url` | `""` (= `ai.base_url`) | — | separate local Whisper server |
|
||||
| `ai.asr_api_key` | `""` (= `ai.api_key`) | — | sensitive |
|
||||
| `subtitle_ai.enabled` | `false` | — | existing; subtitle translation toggle |
|
||||
| `subtitle_ai.transcribe_enabled` | `false` | — | new; ASR toggle |
|
||||
| `subtitle_ai.batch_size` | `40` | — | existing; cues per chat request |
|
||||
| `subtitle_ai.context_neighbors` | `2` | — | existing |
|
||||
| `metadata_ai.enabled` | `false` | — | new; metadata translation toggle |
|
||||
|
||||
`ai.api_key` and `ai.asr_api_key` join `sensitiveSettingKeys`. The admin UI reads through
|
||||
the same fallback (effective values come from the loaded config) and writes the new keys.
|
||||
|
||||
Config structs: `AIConfig` (connection + concurrency), `SubtitleAIConfig` (slims down to
|
||||
toggles + batch tuning), new `MetadataAIConfig`. Readiness: subtitle translate requires
|
||||
`subtitle_ai.enabled` + chat config; ASR requires `subtitle_ai.transcribe_enabled` + ASR
|
||||
config; metadata requires `metadata_ai.enabled` + chat config.
|
||||
|
||||
## Metadata translation
|
||||
|
||||
### What gets translated
|
||||
|
||||
| Target | Fields |
|
||||
| --- | --- |
|
||||
| movie / series (`media_item_localizations`) | `overview`, `tagline` |
|
||||
| season (`season_localizations`) | `overview` |
|
||||
| episode (`episode_localizations`) | `overview` |
|
||||
|
||||
Titles, sort titles, and artwork columns in those rows are never written by AI.
|
||||
|
||||
### Provenance
|
||||
|
||||
New columns (default `'provider'`, check-constrained to `provider|ai|manual`):
|
||||
|
||||
- `media_item_localizations.overview_source`, `media_item_localizations.tagline_source`
|
||||
- `season_localizations.overview_source`
|
||||
- `episode_localizations.overview_source`
|
||||
|
||||
Write rules, enforced in the localization repos (single-statement upserts with CASE):
|
||||
|
||||
| Writer | May overwrite | Sets source | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| provider ingestion | `provider`, `ai` | `provider` | a non-empty provider value always replaces an AI value; an **empty** provider value never blanks an existing one |
|
||||
| AI translation | `ai`, empty | `ai` | with `force`, may also overwrite `provider` (admin explicitly re-translating) — never `manual` |
|
||||
| manual (future editor) | anything | `manual` | |
|
||||
|
||||
Serving hardening: `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel`
|
||||
must only override base fields with **non-empty** localization values, since AI-created
|
||||
rows legitimately carry empty titles. (Verify current behavior; fix if it clobbers.)
|
||||
|
||||
### Job model
|
||||
|
||||
New table `metadata_translation_jobs`, mirroring `subtitle_ai_jobs` conventions
|
||||
(status enum, progress, idempotency partial-unique index, heartbeat):
|
||||
|
||||
- `target_kind` (`item` | `season` | `episode`), `content_id`, `include_children`
|
||||
- `target_language`, `source_language` (default language of the base row)
|
||||
- `engine`, `model`, `status`, `progress`, `progress_message`, `fields_done`, `fields_total`
|
||||
- `error_message`, `idempotency_key`, `requested_by`, `force`, timestamps, `heartbeat_at`
|
||||
|
||||
A job for a series item with `include_children` expands to: item overview + tagline, every
|
||||
season overview, every episode overview. All fields become segments
|
||||
(`item:overview`, `season:<content_id>:overview`, `episode:<content_id>:overview`, …) fed
|
||||
to the generic translator in batches (metadata batch size constant, smaller than the
|
||||
subtitle one — paragraphs, not cue lines), so episode descriptions share terminology in one
|
||||
prompt context. The system prompt names the series/movie and year for grounding.
|
||||
|
||||
Skip logic in the run loop (not at enqueue): a field whose localization value is already
|
||||
non-empty is skipped unless `force` — so the auto trigger costs zero LLM calls on repeat
|
||||
refreshes. If every field is skipped the job completes immediately.
|
||||
|
||||
Idempotency key: `content_id | target_kind | target_language | model`, active-only partial
|
||||
unique index (completed/failed rows never block a retry).
|
||||
|
||||
### Triggers
|
||||
|
||||
1. **Manual.** Admin action on the item detail metadata editor: pick target language
|
||||
(defaults to the library's `metadata_language`), `include_children`, `force`. Gated by
|
||||
the same permission as metadata editing.
|
||||
2. **Auto fallback.** New `media_folders.auto_translate_metadata boolean NOT NULL DEFAULT
|
||||
false`. At the end of a metadata refresh for an item (after provider localizations are
|
||||
persisted in `internal/metadata/service.go`), if the folder has the flag, has a
|
||||
`metadata_language`, that language differs from the item's default metadata language,
|
||||
and the localization row for it is missing a translated field — enqueue a non-force job
|
||||
for the item (children included for series). Enqueue is fire-and-forget; refresh latency
|
||||
is unaffected.
|
||||
|
||||
### API
|
||||
|
||||
Admin-gated, mirroring the subtitle AI handler shapes:
|
||||
|
||||
- `GET /api/v1/metadata/ai/status`
|
||||
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}`
|
||||
- `GET /api/v1/metadata/ai/jobs/{job_id}` / `POST …/cancel`
|
||||
- `GET /api/v1/metadata/ai/jobs?content_id=…`
|
||||
|
||||
Progress UI polls the job endpoint (jobs are short; no websocket events in v1).
|
||||
|
||||
## Whisper ASR
|
||||
|
||||
### Kinds
|
||||
|
||||
`subtitle_ai_jobs.kind` gains its reserved values:
|
||||
|
||||
- `transcribe` — audio track → subtitle track in the spoken language.
|
||||
- `transcribe_translate` — `transcribe`, then the existing LLM translation chained to
|
||||
`target_language`. Both the transcript track and the translated track are stored (the
|
||||
transcript is a cache: a second target language skips ASR entirely because the transcript
|
||||
is now a selectable text source for a plain `translate` job). `result_subtitle_id`
|
||||
points at the translated track.
|
||||
|
||||
For ASR kinds, `source_index` holds the **audio** track index (`-1` = default/first audio
|
||||
track); `source_language` is an optional hint passed to Whisper, else taken from the
|
||||
detected `verbose_json.language`.
|
||||
|
||||
### Pipeline
|
||||
|
||||
1. Resolve the audio track from `media_files`; reject files with no audio.
|
||||
2. Extract once with ffmpeg into fixed chunks in a job temp dir
|
||||
(`-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time 600`):
|
||||
16 kHz mono WAV, 10-minute chunks (~19 MB, under typical 25 MB API limits). Temp dir
|
||||
removed on every exit path.
|
||||
3. Per chunk: `POST /v1/audio/transcriptions` (multipart: file, model,
|
||||
`response_format=verbose_json`, optional `language`, `temperature=0`) via the shared
|
||||
`llm.Client` with the shared retry/backoff. Segment timestamps are offset by the chunk
|
||||
start and merged.
|
||||
4. Cue building: one cue per Whisper segment; text wrapped to at most two lines
|
||||
(~42 chars/line). Empty/whitespace segments dropped.
|
||||
5. Store as `downloaded_subtitles` (provider `transcribed`, SRT, release name
|
||||
`"<Language> (AI transcribed)"`), notify `SubtitleReady`.
|
||||
6. `transcribe_translate`: feed the cues to the existing `Translator`; store the translated
|
||||
track (provider `translated`) and complete.
|
||||
|
||||
Chunks are processed starting from the chunk containing the viewer's playhead, then
|
||||
wrapping — same UX as translation — and cues stream live to the requesting session through
|
||||
the existing notifier. Progress bands: extract 0–10 %, transcribe 10–70 % (per chunk),
|
||||
translate 70–95 %.
|
||||
|
||||
Known v1 limitation: fixed chunk boundaries can clip a word at the seam; acceptable, noted
|
||||
for a silence-aligned follow-up.
|
||||
|
||||
### API / UI
|
||||
|
||||
The existing enqueue endpoint `POST /api/v1/subtitles/ai/translate` gains an optional
|
||||
`kind` (default `translate`; backward compatible). `GET /api/v1/subtitles/ai/status` gains
|
||||
`transcribe_enabled`. The player's translate modal gains a "Generate from audio" mode that
|
||||
lists audio tracks and a target language — the natural path when the only subtitle tracks
|
||||
are bitmap (today's hard error) or none exist.
|
||||
|
||||
## Frontend
|
||||
|
||||
- **AI Services settings page** (`web/src/pages/admin-settings/`): connection card
|
||||
(base URL, API key, chat model, ASR model, optional ASR base URL/key), features card
|
||||
(subtitle translation, ASR, metadata translation toggles; concurrency; batch tuning).
|
||||
The AI card currently inside `SubtitlesSettings.tsx` moves here; subtitle settings keep
|
||||
the download-provider config.
|
||||
- **Metadata editor**: "Translate with AI" action in `EditMetadataDialog.tsx` (language,
|
||||
include children, force), with job-poll progress and query invalidation on completion.
|
||||
- **Library settings**: `auto_translate_metadata` toggle next to the metadata-language
|
||||
field.
|
||||
- **Player**: "Generate from audio" mode in `SubtitleTranslateModal.tsx`.
|
||||
|
||||
## Client (Android / Apple) impact
|
||||
|
||||
None required. Translated overviews/taglines arrive through the existing localized detail
|
||||
responses; ASR/translated tracks arrive as ordinary downloaded subtitles. Optional
|
||||
follow-ups in the client repos: surface "Generate from audio" in their subtitle menus
|
||||
(the jobs API is shared) and the library auto-translate toggle in their admin surfaces.
|
||||
|
||||
## Reliability
|
||||
|
||||
- All jobs: idempotency dedup of in-flight work, heartbeat + stale reaper (crash-safe,
|
||||
multi-instance safe), terminal-state handling on cancel/shutdown — one implementation in
|
||||
`jobrunner`, no copy-paste between the two services.
|
||||
- Provider-wins/manual-wins provenance is enforced in SQL (single-statement upserts), not
|
||||
in racy read-modify-write Go code.
|
||||
- The shared semaphore bounds total endpoint load; ASR temp space is cleaned on success,
|
||||
failure, cancel, and shutdown.
|
||||
- Auto-translate re-runs are free: skip-if-filled happens before any LLM call.
|
||||
|
||||
## Migrations
|
||||
|
||||
One Goose migration (`make migrate-create NAME=ai_metadata_translation_and_asr`):
|
||||
|
||||
1. `metadata_translation_jobs` table + active-idempotency / content / status indexes.
|
||||
2. Provenance columns on the three localization tables (+ check constraints).
|
||||
3. `media_folders.auto_translate_metadata`.
|
||||
|
||||
Down: drop the table, columns, and constraint.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Whisper-endpoint compatibility.** OpenAI, Groq, faster-whisper-server/speaches all
|
||||
speak `verbose_json`, but segment quality varies; the cue builder must tolerate missing
|
||||
segment arrays (fall back to one cue per chunk worth of `text` is *not* acceptable —
|
||||
fail the job with a clear message instead).
|
||||
- **`LocalizeItemModel` empty-field semantics** must be verified before AI rows (empty
|
||||
titles) ship; this is called out as an explicit plan task.
|
||||
- **Long-running ASR on slow local Whisper servers** can exceed the 10-minute HTTP client
|
||||
timeout per chunk; the transcription call uses a per-request timeout sized for chunk
|
||||
duration rather than the client default.
|
||||
- **Settings sprawl.** The `ai.*`/legacy fallback leaves old `subtitle_ai.*` connection
|
||||
rows in place indefinitely; harmless, but a future settings-GC could prune them once the
|
||||
new keys are written.
|
||||
@@ -1,78 +0,0 @@
|
||||
# On-view description translation + per-profile metadata language — design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Approved direction (per-profile preference; auto with button fallback; any user behind an opt-in setting)
|
||||
**Builds on:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md`
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
Metadata translation currently triggers from the admin editor or the per-library
|
||||
refresh fallback, always targeting the library's metadata language. A viewer whose
|
||||
language differs from the library's (or whose library was never auto-translated) has
|
||||
no way to get descriptions in their language, and there is no per-person language
|
||||
preference at all — presentation language is library-scoped.
|
||||
|
||||
## Design
|
||||
|
||||
### Per-profile preferred metadata language
|
||||
|
||||
- New column `user_profiles.preferred_metadata_language text NOT NULL DEFAULT ''`
|
||||
(empty = inherit the library's metadata language). Threaded through the profile
|
||||
model/repo/API exactly like the existing `language` (UI language) field, plus a
|
||||
picker in the profile settings UI with an "Inherit library default" option.
|
||||
- `access.Scope` gains `PreferredMetadataLanguage`, populated when the access
|
||||
resolver loads the profile, so every request already carries it.
|
||||
- `catalog.AccessFilter` gains `ProfilePreferredLanguage`, and presentation language
|
||||
resolution becomes: explicit `PresentationLanguage` (request param) →
|
||||
**profile preference** → library `metadata_language`. Applied wherever the scope
|
||||
is available (native API and jellycompat), so all clients get profile-localized
|
||||
metadata server-side.
|
||||
|
||||
### "Not in your language" signal
|
||||
|
||||
`ItemDetail` gains `pending_translation_language` (omitted when empty): set when the
|
||||
resolved presentation language differs from the item's default metadata language,
|
||||
the base overview is non-empty, and no localization overview exists for that
|
||||
language. It is pure data — clients combine it with the AI feature status to decide
|
||||
what to render.
|
||||
|
||||
### On-view trigger
|
||||
|
||||
- New setting `metadata_ai.on_view`: `off` (default) | `button` | `auto`, surfaced
|
||||
on `GET /api/v1/metadata/ai/status` as `on_view`.
|
||||
- New authenticated endpoint `POST /api/v1/items/{id}/translate-description`
|
||||
`{target_language}` — any profile, gated by `metadata_ai` readiness +
|
||||
`on_view != off`, item access enforced with the same `EnsureAccessible` check the
|
||||
watched-state endpoints use. The client echoes back the language the server
|
||||
reported in `pending_translation_language`.
|
||||
- The endpoint goes through a new `Service.RequestOnView`, which adds one guard on
|
||||
top of the existing pipeline: if the latest job for (content, language) failed
|
||||
within a cooldown window, return it instead of enqueuing — a broken endpoint must
|
||||
not be retried on every page view. Existing guarantees do the rest: in-flight
|
||||
idempotency collapses concurrent viewers onto one job, skip-if-filled makes
|
||||
repeats free, and the shared semaphore caps endpoint load.
|
||||
|
||||
### Web UX
|
||||
|
||||
On the detail page, when `pending_translation_language` is set:
|
||||
|
||||
- `on_view=auto`: fire the request on view (once per item), pulse-animate the
|
||||
description while waiting, and poll the detail query until the flag clears (the
|
||||
job's first batch translates the item's own overview, so this lands in seconds
|
||||
even when a series job continues episodes in the background). Time out after ~45 s
|
||||
and show the original text — no error states in the viewer's face.
|
||||
- `on_view=button`: a small "Translate" chip near the description triggers the same
|
||||
flow on click.
|
||||
|
||||
No job-status endpoint for viewers: completion is observed as the flag clearing on
|
||||
refetch; failures surface only as the shimmer timing out (the cooldown stops
|
||||
re-triggering).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-profile language in the Android/Apple UIs (server-side serving means they
|
||||
already *receive* localized metadata; surfacing the trigger there is a client
|
||||
follow-up using the same endpoint).
|
||||
- Translating titles, or any admin-side behavior changes.
|
||||
@@ -1,170 +0,0 @@
|
||||
# Tiered Security Model: Group-Based Permissions — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: Approved design, pending implementation plan
|
||||
|
||||
Commands and paths in this document assume the repository root is the cwd.
|
||||
|
||||
## Overview
|
||||
|
||||
Replace Silo's per-user authorization fields with a group-based model:
|
||||
|
||||
- Admins create **groups**; each group carries a full policy (feature permissions, library access, playback/transcode limits, download rights).
|
||||
- Users are assigned to one or more groups; effective policy is the most-permissive union of their groups.
|
||||
- Groups are the **only** source of policy. Per-user policy columns are removed.
|
||||
- Two protected built-in groups ship by default: `administrators` and `users`. New accounts join the group(s) named by a server setting (initially `users`).
|
||||
- The `users.role` column and JWT role claim are removed; "admin" becomes a permission granted by group membership.
|
||||
|
||||
Deployments may have **thousands of users**. All fan-out paths (revision bumps, member listings, migration artifacts) are designed set-based and paginated accordingly.
|
||||
|
||||
## Current state (what this replaces)
|
||||
|
||||
- `users.role` text (`"admin"`/`"user"`), checked by `RequireAdmin` middleware via JWT `Claims.Role` (`internal/api/middleware/auth.go`).
|
||||
- `users.permissions text[]` with two assignable permissions (`marker_edit`, `metadata_curation`) defined in `internal/auth/permissions.go`; admins implicitly hold all.
|
||||
- Per-user resource policy columns on `users`: `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
|
||||
- `users.access_policy_revision` invalidates profile tokens via `internal/access/resolver.go`.
|
||||
- Household profiles (`user_profiles`) carry a separate restriction layer (child flag, content rating, allowed libraries). **Profiles are out of scope** — that layer is unchanged and continues to apply on top of the user's effective policy.
|
||||
|
||||
## Data model
|
||||
|
||||
### `groups`
|
||||
|
||||
```sql
|
||||
CREATE TABLE groups (
|
||||
id serial PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE, -- stable identifier: 'administrators', 'users', ...
|
||||
name text NOT NULL, -- display name, renamable
|
||||
description text NOT NULL DEFAULT '',
|
||||
built_in boolean NOT NULL DEFAULT false,
|
||||
permissions text[] NOT NULL DEFAULT '{}',
|
||||
library_ids integer[], -- NULL = all libraries
|
||||
max_streams integer NOT NULL DEFAULT 6,
|
||||
max_transcodes integer NOT NULL DEFAULT 2,
|
||||
max_profiles integer NOT NULL DEFAULT 5,
|
||||
max_playback_quality text NOT NULL DEFAULT '', -- '' = unrestricted
|
||||
download_allowed boolean NOT NULL DEFAULT true,
|
||||
download_transcode_allowed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### `user_groups`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_groups (
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
CREATE INDEX user_groups_group_id_idx ON user_groups (group_id);
|
||||
```
|
||||
|
||||
### Admin as a permission
|
||||
|
||||
`'admin'` is a permission string in `groups.permissions`, not a column. The resolver treats it as implying every permission and unrestricted policy, mirroring the current `HasEffectivePermission` short-circuit for `role == "admin"`. One mechanism covers everything.
|
||||
|
||||
Permission catalog at launch: `admin`, `marker_edit`, `metadata_curation`. The catalog stays a Go-level allowlist in `internal/auth/permissions.go`; adding a permission is a code change, as today.
|
||||
|
||||
### Built-in groups
|
||||
|
||||
| Slug | Permissions | Policy | Protections |
|
||||
|---|---|---|---|
|
||||
| `administrators` | `['admin']` | irrelevant (admin implies unrestricted) | not deletable; `admin` permission not removable; last enabled member not removable |
|
||||
| `users` | `['marker_edit']` | all libraries, 6 streams, 2 transcodes, 5 profiles, downloads on, transcode downloads off | not deletable |
|
||||
|
||||
Protections are enforced at the service layer (consistent with existing last-admin guards), not by triggers.
|
||||
|
||||
### `users` table changes
|
||||
|
||||
Dropped: `role`, `permissions`, `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
|
||||
|
||||
Kept: `enabled` (account kill-switch stays user-level), `access_policy_revision` (still the invalidation counter).
|
||||
|
||||
### Default group setting
|
||||
|
||||
A `server_settings` entry `users.default_group_slugs` (initially `["users"]`), consumed by all account-provisioning paths (signup, invite, OAuth) in `internal/auth/account_provisioner.go`. Invite codes may later override it; out of scope now.
|
||||
|
||||
## Resolution semantics
|
||||
|
||||
A user's **effective policy** is the most-permissive union of their groups:
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `permissions` | set union; `admin` present → every permission granted |
|
||||
| `library_ids` | union; any group with `NULL` (all) or `admin` → unrestricted |
|
||||
| `max_streams`, `max_transcodes`, `max_profiles` | 0 means unlimited at enforcement, so any group with 0 wins; otherwise max across groups |
|
||||
| `max_playback_quality` | highest wins; `''` (unrestricted) beats everything (ordering as in `internal/access/quality.go`) |
|
||||
| `download_allowed`, `download_transcode_allowed` | OR |
|
||||
|
||||
**Zero groups → empty policy**: no permissions, no libraries (lockout is enforced by the empty library list, so stream/transcode limits are irrelevant; `max_profiles` is floored at 1 since 0 would mean unlimited profile creation). The user can authenticate but sees an empty server. This is deliberate ("groups only grant") and gives admins a soft-disable distinct from `enabled = false`.
|
||||
|
||||
**Where computed:** the user repository (`internal/auth/repository.go`) aggregates group policy in the same load path as the user row (a second indexed query over `user_groups`/`groups`, batched for list views; cost is O(memberships per user), independent of total user count). `models.User` keeps its existing policy fields (`LibraryIDs`, `MaxStreams`, `Permissions`, …) but they become **effective** values populated from the aggregate. The ~25 consumer packages (playback, catalog access filters, jellycompat, sections, audiobooks, requests, …) keep working unchanged. `Role` is replaced by `IsAdmin bool` derived from effective permissions. Assigned memberships are exposed separately (`GroupIDs`) for the admin API.
|
||||
|
||||
**Invalidation:** unchanged mechanism, new triggers:
|
||||
|
||||
- Group policy edit → bump `access_policy_revision` for all members in **one set-based `UPDATE ... FROM user_groups`** (never a per-user loop; the `users` group may have thousands of members).
|
||||
- Membership add/remove → bump the affected user's revision.
|
||||
|
||||
The profile-token revision check in `internal/access/resolver.go` then invalidates stale scopes automatically. The post-bump re-resolution stampede is bounded: each active session re-resolves once with a single indexed query on its next request. No new caching layer until profiling shows a need.
|
||||
|
||||
## Enforcement changes
|
||||
|
||||
- **JWT claims:** `Claims.Role` removed. Claims carry identity only (`user_id`, session, profile, revision) — never policy. Fixes the existing staleness gap where a role change waited for token re-issuance; after this, revoking admin takes effect on the next request.
|
||||
- **`RequireAdmin`** (`internal/api/middleware/auth.go`): loads the user (effective policy included) and checks `IsAdmin`; stashes the loaded user in request context so handlers don't re-fetch. One indexed query on low-traffic admin routes.
|
||||
- **Permission middleware** (`internal/api/middleware/permissions.go`): logic unchanged — it already operates on `models.User`. Only `user.Role == "admin"` comparisons become `user.IsAdmin`.
|
||||
- **Jellyfin compat** (`internal/jellycompat/handlers_auth.go`): `UserPolicy` maps from effective policy — `IsAdministrator` ← `IsAdmin`, `EnableContentDownloading` ← `DownloadAllowed`, `EnableAllFolders`/`EnabledFolders` ← `LibraryIDs`. Replaces the currently hardcoded policy values.
|
||||
- **Requests service** (`internal/requests`): role-string comparisons → `IsAdmin`; otherwise unchanged.
|
||||
|
||||
## API surface
|
||||
|
||||
All new endpoints behind `RequireAdmin`, under the existing admin API namespace:
|
||||
|
||||
- `GET /api/admin/groups` — list groups with member counts (single aggregated count query)
|
||||
- `POST /api/admin/groups` — create; slug auto-derived from name
|
||||
- `GET /api/admin/groups/{id}` — group detail: policy + member **count** (members are paginated separately)
|
||||
- `GET /api/admin/groups/{id}/members?offset=&limit=` — paginated member list
|
||||
- `PATCH /api/admin/groups/{id}` — update name/description/policy; rejects removing `admin` from `administrators`
|
||||
- `DELETE /api/admin/groups/{id}` — rejects built-ins; memberships cascade
|
||||
- `PUT /api/admin/groups/{id}/members/{userID}` — add member
|
||||
- `DELETE /api/admin/groups/{id}/members/{userID}` — remove member; rejects removing the last enabled member of `administrators`
|
||||
|
||||
Changed endpoints:
|
||||
|
||||
- `POST`/`PATCH` admin user endpoints: per-user policy fields removed from request bodies, replaced by `group_ids`. Responses gain `groups: [{id, slug, name}]`; the existing top-level policy fields (`library_ids`, `max_streams`, …) become read-only effective values derived from group membership.
|
||||
- User-facing account/session responses keep a **derived** `role` field (`"admin"`/`"user"` from `IsAdmin`) so `silo-android` and `silo-apple` need no immediate changes; group info is added alongside.
|
||||
|
||||
Default-group setting rides the existing server-settings endpoints.
|
||||
|
||||
## Admin UI (`web/src/pages/admin`)
|
||||
|
||||
- New **Groups** page: list (name, member count, key-policy summary) and an editor with three sections — permissions (checkboxes), library access (all / picker, reusing the user editor's component), limits (streams, transcodes, quality, downloads, profiles). Member management with a paginated member list and user search to add.
|
||||
- **Users** page: per-user policy controls replaced by a group multi-select plus a read-only "effective policy" summary showing the resolved union — the admin's answer to "why can this user do X".
|
||||
- Built-in groups render with a badge; delete and protected fields disabled with explanatory tooltips.
|
||||
|
||||
## Migration
|
||||
|
||||
One transactional Goose migration created with `make migrate-create NAME=group_based_permissions`:
|
||||
|
||||
1. Create `groups` and `user_groups`; seed `administrators` and `users` (policy from current column defaults).
|
||||
2. Map members: `role = 'admin'` → `administrators` only. Non-admin users whose policy columns match the defaults → `users`.
|
||||
3. Preserve outliers **bucketed by distinct policy tuple**: non-admin users whose policy columns deviate from the defaults are grouped by their unique `(permissions, library_ids, max_streams, max_transcodes, max_profiles, max_playback_quality, download_allowed, download_transcode_allowed)` combination; one group per distinct tuple (`migrated-policy-1`, `-2`, …) is created carrying those values, and matching users are added **to that group only — not to `users`**. (Adding outliers to `users` would erase their restrictions via the most-permissive union: `users` grants all libraries, so a user previously limited to specific libraries would become unrestricted.) Group count is bounded by distinct policies in use, not user count. Post-migration effective policy is exactly each user's prior policy; nothing is silently lost or gained. (Admins were already unrestricted, so admin outliers need no preservation.)
|
||||
4. Drop the old `users` columns; bump all `access_policy_revision` values set-based.
|
||||
|
||||
Down-migration recreates the columns and writes back each user's flattened effective policy (best-effort; acceptable at WIP stage).
|
||||
|
||||
## Testing
|
||||
|
||||
- Union resolver unit tests: multi-group merges, `admin` short-circuit, zero-group empty policy, NULL-libraries propagation, quality ordering (extending `internal/access/quality_test.go` coverage).
|
||||
- Service-layer guard tests: delete built-in, strip `admin` from `administrators`, remove last enabled administrator.
|
||||
- Migration test against a seeded DB asserting before/after effective-policy equivalence, including outlier users sharing a policy tuple.
|
||||
- Handler tests for group endpoints incl. member pagination; updated user-endpoint tests for changed payloads.
|
||||
- Jellycompat test asserting `UserPolicy` reflects group-derived policy.
|
||||
|
||||
## Risks & follow-ups
|
||||
|
||||
- **Client coordination:** `silo-android` / `silo-apple` keep working via the derived `role` field; follow-up tickets to surface group membership in account UI.
|
||||
- **Revision-bump stampede:** bounded and acceptable (one re-resolve per active session); revisit with a cache only if profiling shows pressure.
|
||||
- **Invite-code group overrides** and an expanded permission catalog (requests, watch-together, etc.) are natural follow-ups, out of scope here.
|
||||
- **Plugin/API-key auth paths** (`internal/auth/api_key_repository.go`, jellycompat API keys) must be audited during implementation for any `role` reads not covered above.
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Silo-Server/silo-server
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
@@ -14,8 +14,8 @@ require (
|
||||
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.52.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sys v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -32,8 +32,7 @@ require (
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/zishang520/socket.io/v2 v2.5.0
|
||||
go.n16f.net/thumbhash v1.1.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/image v0.39.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
@@ -42,13 +41,13 @@ require (
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
@@ -56,9 +55,8 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.53.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
@@ -70,11 +68,15 @@ require (
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/webtransport-go v0.9.1 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.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
|
||||
@@ -99,10 +101,8 @@ require (
|
||||
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.37.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
replace github.com/zishang520/webtransport-go => ./internal/compat/zishang520-webtransport-go
|
||||
|
||||
@@ -2,8 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
@@ -44,6 +44,7 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
|
||||
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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=
|
||||
@@ -51,12 +52,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
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/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
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=
|
||||
@@ -165,14 +166,10 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
|
||||
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -225,6 +222,8 @@ github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+q
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0/go.mod h1:GK9GIIs/KQbBKfnxgZJMBYSlsFemB2rL7EFUn0kmTfM=
|
||||
github.com/zishang520/socket.io/v2 v2.5.0 h1:+KdZLbl4wWVzyI84RG+h21t8OY4ifz/Oo6qwgt9zHl8=
|
||||
github.com/zishang520/socket.io/v2 v2.5.0/go.mod h1:+GyoPyakXDS6KsW81RAQpDA9+mJBXbcYcQ+Itx2D+rU=
|
||||
github.com/zishang520/webtransport-go v0.9.1 h1:Y3gqPM8cIDvQILsTyXJ5G9fp2PYqGqLI2z+QXpgboQc=
|
||||
github.com/zishang520/webtransport-go v0.9.1/go.mod h1:IgNAD6qLe3oWu7MSSkjusRNftpvjYxWjI4LmoH4VEyY=
|
||||
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=
|
||||
@@ -243,19 +242,21 @@ 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.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
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/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
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=
|
||||
@@ -265,15 +266,17 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||
|
||||
@@ -5,9 +5,4 @@ import "errors"
|
||||
var (
|
||||
ErrProfileNotFound = errors.New("profile not found")
|
||||
ErrProfileUnverified = errors.New("profile unverified")
|
||||
// ErrUserDisabled is returned when the viewer's account is disabled.
|
||||
// Scope resolution re-loads the user on every request, so disabling an
|
||||
// account denies content access on the next request for every surface
|
||||
// that resolves viewer scope (native API and Jellyfin compat alike).
|
||||
ErrUserDisabled = errors.New("user account disabled")
|
||||
)
|
||||
|
||||
@@ -79,17 +79,3 @@ func MinQuality(a, b string) string {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
// MaxQuality returns the more permissive quality ceiling. An empty string
|
||||
// means unrestricted and beats any concrete ceiling.
|
||||
func MaxQuality(a, b string) string {
|
||||
a = NormalizePlaybackQuality(a)
|
||||
b = NormalizePlaybackQuality(b)
|
||||
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
|
||||
return ""
|
||||
}
|
||||
if CompareQuality(a, b) >= 0 {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -54,22 +54,3 @@ func TestParsePlaybackQualityPreset(t *testing.T) {
|
||||
t.Fatal("expected invalid preset to return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxQuality(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b, want string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"", "1080p", ""}, // unrestricted beats any ceiling
|
||||
{"2160p", "", ""},
|
||||
{"1080p", "2160p", "2160p"},
|
||||
{"2160p", "1080p", "2160p"},
|
||||
{"1080p", "1080p", "1080p"},
|
||||
{"720p", "480p", "1080p"}, // presets normalize: 720p/480p both -> 1080p (standard)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MaxQuality(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("MaxQuality(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,6 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
if err != nil {
|
||||
return Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err)
|
||||
}
|
||||
if !user.Enabled {
|
||||
return Scope{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
scope := Scope{
|
||||
UserID: user.ID,
|
||||
@@ -76,7 +73,6 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
|
||||
scope.MaxContentRating = profile.MaxContentRating
|
||||
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
|
||||
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile)
|
||||
scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int)
|
||||
|
||||
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -285,7 +285,7 @@ func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
|
||||
|
||||
func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
|
||||
nil,
|
||||
)
|
||||
@@ -304,7 +304,7 @@ func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
|
||||
|
||||
func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -324,7 +324,7 @@ func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
|
||||
|
||||
func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -344,7 +344,7 @@ func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
|
||||
|
||||
func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
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]"},
|
||||
@@ -367,7 +367,7 @@ func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
|
||||
|
||||
func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
|
||||
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]"},
|
||||
@@ -388,22 +388,9 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledUserIsDenied(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: false, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if !errors.Is(err, ErrUserDisabled) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrUserDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
settings: map[string]string{"disabled_library_ids": "[7]"},
|
||||
}},
|
||||
|
||||
@@ -9,11 +9,8 @@ type Scope struct {
|
||||
LibrariesRestricted bool
|
||||
MaxContentRating string
|
||||
MaxPlaybackQuality string
|
||||
// PreferredMetadataLanguage is the profile's metadata (presentation)
|
||||
// language; "" inherits the library's metadata language.
|
||||
PreferredMetadataLanguage string
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
}
|
||||
|
||||
// ResolveInput is the request input for resolving a viewer access scope.
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
// Package jobrunner owns the lifecycle mechanics shared by Silo's AI job
|
||||
// services (subtitle translation/ASR, metadata translation): bounded dispatch
|
||||
// off a semaphore shared across services, a heartbeat loop that keeps a row
|
||||
// alive even while the job is queued, a stale-job reaper for rows orphaned by
|
||||
// a crashed worker, and a per-job cancel registry. The services keep their own
|
||||
// job tables, validation, and run logic; this package keeps them honest about
|
||||
// concurrency and crash recovery without copy-pasting the trickiest code.
|
||||
package jobrunner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// A running job refreshes its heartbeat every HeartbeatInterval; one whose
|
||||
// heartbeat has not advanced for StaleJobThreshold is treated as orphaned
|
||||
// by a crashed worker and reaped. The margin over HeartbeatInterval avoids
|
||||
// reaping a job that is merely mid–LLM-call.
|
||||
HeartbeatInterval = 30 * time.Second
|
||||
StaleJobThreshold = 2 * time.Minute
|
||||
// How often the background reaper scans for orphaned jobs.
|
||||
ReaperInterval = time.Minute
|
||||
)
|
||||
|
||||
// Store is the minimal persistence surface the runner needs. Both AI job
|
||||
// repositories satisfy it.
|
||||
type Store interface {
|
||||
Heartbeat(ctx context.Context, id int64) error
|
||||
// ResetStaleJobs marks pending/running jobs whose heartbeat predates
|
||||
// `before` as failed with the given message. Returns rows reset.
|
||||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||||
}
|
||||
|
||||
// NewSemaphore builds the dispatch semaphore shared across runners, so the
|
||||
// configured endpoint sees one global bound regardless of job mix. size <= 0
|
||||
// falls back to 2.
|
||||
func NewSemaphore(size int) chan struct{} {
|
||||
if size <= 0 {
|
||||
size = 2
|
||||
}
|
||||
return make(chan struct{}, size)
|
||||
}
|
||||
|
||||
// Runner executes jobs with bounded concurrency, heartbeats, cancellation,
|
||||
// and crash recovery. One Runner per job table; the semaphore may be shared
|
||||
// across Runners.
|
||||
type Runner struct {
|
||||
// baseCtx is the application context; dispatched jobs and the reaper
|
||||
// derive from it so they stop when the server shuts down.
|
||||
baseCtx context.Context
|
||||
sem chan struct{}
|
||||
store Store
|
||||
// label names the job family in log lines ("subtitle ai", "metadata translation").
|
||||
label string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cancels map[int64]context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New wires a runner. A nil appCtx falls back to context.Background(); a nil
|
||||
// sem gets a private default-size semaphore (used by tests; production passes
|
||||
// the shared one).
|
||||
func New(appCtx context.Context, sem chan struct{}, store Store, label string, logger *slog.Logger) *Runner {
|
||||
if appCtx == nil {
|
||||
appCtx = context.Background()
|
||||
}
|
||||
if sem == nil {
|
||||
sem = NewSemaphore(0)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Runner{
|
||||
baseCtx: appCtx,
|
||||
sem: sem,
|
||||
store: store,
|
||||
label: label,
|
||||
logger: logger,
|
||||
cancels: make(map[int64]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Recover clears jobs orphaned by a crashed worker and starts a background
|
||||
// reaper that keeps doing so. Reaping is heartbeat-based (not "every active
|
||||
// job"), so it is safe when multiple instances share one database: a job
|
||||
// still being heartbeat-updated by a live worker is never reset. Call once at
|
||||
// startup; jobs and the reaper derive from the application context, so they
|
||||
// stop on shutdown.
|
||||
func (r *Runner) Recover() {
|
||||
r.reapStaleJobs()
|
||||
go r.reaperLoop()
|
||||
}
|
||||
|
||||
func (r *Runner) reaperLoop() {
|
||||
ticker := time.NewTicker(ReaperInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.baseCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.reapStaleJobs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) reapStaleJobs() {
|
||||
before := time.Now().Add(-StaleJobThreshold)
|
||||
n, err := r.store.ResetStaleJobs(context.WithoutCancel(r.baseCtx), before, "interrupted by server restart")
|
||||
if err != nil {
|
||||
r.logger.Warn("failed to reset stale jobs", "jobs", r.label, "error", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
r.logger.Info("reset stale jobs", "jobs", r.label, "count", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch launches a bounded background goroutine for job id. run executes
|
||||
// once a semaphore slot is acquired; onAbort runs instead if the job is
|
||||
// cancelled (user cancel or shutdown) while still waiting for a slot. Both
|
||||
// receive a context derived from the application context that is cancelled by
|
||||
// Cancel(id) or server shutdown.
|
||||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context), onAbort func(ctx context.Context)) {
|
||||
runCtx, cancel := context.WithCancel(r.baseCtx)
|
||||
r.mu.Lock()
|
||||
r.cancels[id] = cancel
|
||||
r.mu.Unlock()
|
||||
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.cancels, id)
|
||||
r.mu.Unlock()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Heartbeat for the whole lifetime — crucially including while queued
|
||||
// behind the semaphore — so the stale-job reaper never reaps a job that
|
||||
// is alive but merely waiting for a slot (which would otherwise mark it
|
||||
// failed and let it resurrect itself on acquire, or admit a duplicate).
|
||||
stopHeartbeat := make(chan struct{})
|
||||
defer close(stopHeartbeat)
|
||||
go r.heartbeatLoop(runCtx, id, stopHeartbeat)
|
||||
|
||||
select {
|
||||
case r.sem <- struct{}{}:
|
||||
case <-runCtx.Done():
|
||||
if onAbort != nil {
|
||||
onAbort(context.WithoutCancel(runCtx))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer func() { <-r.sem }()
|
||||
|
||||
run(runCtx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Cancel cancels the in-flight goroutine for id, returning false when none is
|
||||
// registered (job on another node, or never dispatched) so the caller can
|
||||
// fall back to a best-effort terminal transition in the database.
|
||||
func (r *Runner) Cancel(id int64) bool {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancels[id]
|
||||
r.mu.Unlock()
|
||||
if cancel == nil {
|
||||
return false
|
||||
}
|
||||
cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
// heartbeatLoop keeps a job's heartbeat_at fresh until the job ends or the
|
||||
// context is cancelled, so the stale-job reaper only ever reaps jobs orphaned
|
||||
// by a crashed worker.
|
||||
func (r *Runner) heartbeatLoop(ctx context.Context, jobID int64, stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = r.store.Heartbeat(context.WithoutCancel(ctx), jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package jobrunner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
heartbeats int
|
||||
resets int
|
||||
lastBefore time.Time
|
||||
}
|
||||
|
||||
func (s *fakeStore) Heartbeat(context.Context, int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.heartbeats++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) ResetStaleJobs(_ context.Context, before time.Time, _ string) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.resets++
|
||||
s.lastBefore = before
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) snapshot() (int, time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.resets, s.lastBefore
|
||||
}
|
||||
|
||||
func TestRecoverReapsImmediatelyWithStaleCutoff(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
approxNow := time.Now()
|
||||
New(ctx, nil, store, "test", nil).Recover()
|
||||
|
||||
resets, before := store.snapshot()
|
||||
if resets < 1 {
|
||||
t.Fatalf("Recover did not reap immediately: resets=%d", resets)
|
||||
}
|
||||
want := approxNow.Add(-StaleJobThreshold)
|
||||
if diff := before.Sub(want); diff > 2*time.Second || diff < -2*time.Second {
|
||||
t.Errorf("stale cutoff = %v, want ~%v", before, want)
|
||||
}
|
||||
}
|
||||
|
||||
// One shared semaphore bounds jobs across BOTH runners: with size 1, the
|
||||
// second runner's job must not start until the first runner's job finishes.
|
||||
func TestSharedSemaphoreBoundsAcrossRunners(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sem := NewSemaphore(1)
|
||||
r1 := New(ctx, sem, &fakeStore{}, "one", nil)
|
||||
r2 := New(ctx, sem, &fakeStore{}, "two", nil)
|
||||
|
||||
release := make(chan struct{})
|
||||
aStarted := make(chan struct{})
|
||||
var bStarted atomic.Bool
|
||||
bDone := make(chan struct{})
|
||||
|
||||
r1.Dispatch(1, func(context.Context) {
|
||||
close(aStarted)
|
||||
<-release
|
||||
}, nil)
|
||||
<-aStarted
|
||||
|
||||
r2.Dispatch(2, func(context.Context) {
|
||||
bStarted.Store(true)
|
||||
close(bDone)
|
||||
}, nil)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if bStarted.Load() {
|
||||
t.Fatal("job B ran while job A held the only slot")
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-bDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("job B never ran after the slot freed")
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling a job that is queued behind the semaphore aborts it without
|
||||
// running it, via the onAbort callback.
|
||||
func TestCancelWhileQueuedCallsOnAbort(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sem := NewSemaphore(1)
|
||||
r := New(ctx, sem, &fakeStore{}, "test", nil)
|
||||
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
started := make(chan struct{})
|
||||
r.Dispatch(1, func(context.Context) {
|
||||
close(started)
|
||||
<-release
|
||||
}, nil)
|
||||
<-started
|
||||
|
||||
var ran atomic.Bool
|
||||
aborted := make(chan struct{})
|
||||
r.Dispatch(2, func(context.Context) {
|
||||
ran.Store(true)
|
||||
}, func(context.Context) {
|
||||
close(aborted)
|
||||
})
|
||||
|
||||
if !r.Cancel(2) {
|
||||
t.Fatal("Cancel(2) found no in-flight goroutine")
|
||||
}
|
||||
select {
|
||||
case <-aborted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("onAbort never ran")
|
||||
}
|
||||
if ran.Load() {
|
||||
t.Fatal("cancelled queued job still ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelUnknownJobReturnsFalse(t *testing.T) {
|
||||
r := New(context.Background(), nil, &fakeStore{}, "test", nil)
|
||||
if r.Cancel(99) {
|
||||
t.Fatal("Cancel(99) = true for unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
// The run context is cancelled by Cancel(id) so an in-flight job can stop.
|
||||
func TestCancelRunningJobCancelsContext(t *testing.T) {
|
||||
r := New(context.Background(), NewSemaphore(1), &fakeStore{}, "test", nil)
|
||||
started := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
r.Dispatch(7, func(ctx context.Context) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
close(stopped)
|
||||
}, nil)
|
||||
<-started
|
||||
if !r.Cancel(7) {
|
||||
t.Fatal("Cancel(7) found no in-flight goroutine")
|
||||
}
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("running job did not observe cancellation")
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package jobrunner
|
||||
|
||||
// Status is the lifecycle state shared by every AI job table.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// Terminal reports whether a status is final.
|
||||
func (s Status) Terminal() bool {
|
||||
switch s {
|
||||
case StatusCompleted, StatusFailed, StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
// Package llm provides the shared OpenAI-compatible API client used by every
|
||||
// AI feature in Silo (subtitle translation, metadata translation, Whisper ASR).
|
||||
// One endpoint configuration, one retry/backoff implementation; the operator
|
||||
// can point it at OpenAI, Groq, a local Ollama/llama.cpp/faster-whisper
|
||||
// server, etc.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the connection settings for the shared OpenAI-compatible
|
||||
// endpoints. The ASR fields are optional overrides for operators who run a
|
||||
// separate Whisper-compatible server next to their chat endpoint; when empty,
|
||||
// transcription uses the chat endpoint's base URL and key.
|
||||
type Config struct {
|
||||
BaseURL string // e.g. "https://api.openai.com" (no trailing /v1)
|
||||
APIKey string // empty for keyless local servers
|
||||
ChatModel string // chat-completions model used for translation
|
||||
|
||||
ASRBaseURL string // optional; empty = BaseURL
|
||||
ASRAPIKey string // optional; empty = APIKey
|
||||
ASRModel string // audio-transcription model, e.g. "whisper-1"
|
||||
}
|
||||
|
||||
func (c Config) asrBaseURL() string {
|
||||
if c.ASRBaseURL != "" {
|
||||
return c.ASRBaseURL
|
||||
}
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
func (c Config) asrAPIKey() string {
|
||||
if c.ASRAPIKey != "" {
|
||||
return c.ASRAPIKey
|
||||
}
|
||||
return c.APIKey
|
||||
}
|
||||
|
||||
// ChatConfigured reports whether chat completions are minimally configured.
|
||||
func (c Config) ChatConfigured() bool { return c.BaseURL != "" && c.ChatModel != "" }
|
||||
|
||||
// ASRConfigured reports whether audio transcription is minimally configured.
|
||||
func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel != "" }
|
||||
|
||||
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
|
||||
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
|
||||
// with embedded error object" responses are retried; other 4xx fail fast).
|
||||
type Client struct {
|
||||
cfg Config
|
||||
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
|
||||
// client-level timeout: a transcription upload's deadline is set per request
|
||||
// (sized to the chunk duration), which a client timeout would silently cap.
|
||||
chatHTTP *http.Client
|
||||
asrHTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a client from the endpoint config.
|
||||
func NewClient(cfg Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
|
||||
asrHTTP: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// Message is one chat-completions message.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature float32 `json:"temperature"`
|
||||
ResponseFormat *chatResponseFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type chatCompletionResponse struct {
|
||||
Choices []struct {
|
||||
Message Message `json:"message"`
|
||||
} `json:"choices"`
|
||||
// Some OpenAI-compatible gateways (e.g. OpenRouter) return a 200 with an
|
||||
// error object instead of an HTTP error status when an upstream provider
|
||||
// fails. We surface and retry on it.
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Chat performs one chat completion and returns the first choice's content.
|
||||
// When jsonObject is true it requests response_format=json_object; providers
|
||||
// that ignore the field still work because the prompt itself demands JSON.
|
||||
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
|
||||
reqBody := chatCompletionRequest{
|
||||
Model: c.cfg.ChatModel,
|
||||
Messages: messages,
|
||||
Temperature: 0.2,
|
||||
}
|
||||
if jsonObject {
|
||||
reqBody.ResponseFormat = &chatResponseFormat{Type: "json_object"}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal chat request: %w", err)
|
||||
}
|
||||
|
||||
url := endpointURL(c.cfg.BaseURL, "chat/completions")
|
||||
|
||||
var content string
|
||||
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
|
||||
func() (*http.Request, error) {
|
||||
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if reqErr != nil {
|
||||
return nil, fmt.Errorf("create request: %w", reqErr)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if c.cfg.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
func(respBody []byte) error {
|
||||
var parsed chatCompletionResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("decode chat response: %w", err)
|
||||
}
|
||||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||||
// 200 with an upstream error object — transient on gateways.
|
||||
return fmt.Errorf("chat API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
|
||||
slog.Warn("AI chat returned no choices, retrying",
|
||||
"model", c.cfg.ChatModel, "response_bytes", len(respBody))
|
||||
return fmt.Errorf("chat API returned no choices")
|
||||
}
|
||||
content = parsed.Choices[0].Message.Content
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// SystemUserChat performs one chat completion from a system + user prompt
|
||||
// pair, requesting a JSON object response. Its signature matches
|
||||
// aitranslate.ChatFn so a client method reference wires straight in.
|
||||
func (c *Client) SystemUserChat(ctx context.Context, system, user string) (string, error) {
|
||||
return c.Chat(ctx, []Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
}, true)
|
||||
}
|
||||
|
||||
// permanentError marks a parse failure that retrying cannot fix (e.g. an
|
||||
// endpoint that structurally does not support the requested response format).
|
||||
type permanentError struct{ err error }
|
||||
|
||||
func (e *permanentError) Error() string { return e.err.Error() }
|
||||
func (e *permanentError) Unwrap() error { return e.err }
|
||||
|
||||
// doWithRetry runs the shared request/retry loop: build constructs a fresh
|
||||
// request per attempt, parse consumes a 200 body. Transport errors, read
|
||||
// errors, 429 (honoring Retry-After), 5xx, and retryable parse errors back
|
||||
// off and retry; other 4xx and permanentError parse failures return at once.
|
||||
func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label string,
|
||||
build func() (*http.Request, error), parse func(body []byte) error) error {
|
||||
const maxAttempts = 6
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
httpReq, err := build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, doErr := httpClient.Do(httpReq)
|
||||
if doErr != nil {
|
||||
lastErr = fmt.Errorf("%s request failed: %w", label, doErr)
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
// A truncated/failed read could otherwise be misparsed as a valid
|
||||
// (empty) response; treat it as a retryable transport error.
|
||||
lastErr = fmt.Errorf("read %s response: %w", label, readErr)
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
wait := rateLimitBackoff(resp, attempt)
|
||||
slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait)
|
||||
lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
case resp.StatusCode >= 500:
|
||||
lastErr = fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
// 4xx other than 429: not retryable.
|
||||
return fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
|
||||
}
|
||||
|
||||
parseErr := parse(respBody)
|
||||
if parseErr == nil {
|
||||
return nil
|
||||
}
|
||||
var perm *permanentError
|
||||
if errors.As(parseErr, &perm) {
|
||||
return perm.err
|
||||
}
|
||||
lastErr = parseErr
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("%s: retries exhausted", label)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// endpointURL joins a configured base URL with an OpenAI API path,
|
||||
// tolerating bases that already include the version segment (e.g.
|
||||
// DeepInfra's https://api.deepinfra.com/v1/openai) alongside bare hosts
|
||||
// (https://api.openai.com) and prefixed hosts (https://api.groq.com/openai).
|
||||
func endpointURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.Contains(base, "/v1") {
|
||||
return base + "/" + path
|
||||
}
|
||||
return base + "/v1/" + path
|
||||
}
|
||||
|
||||
// Truncate caps a string for inclusion in an error or log line.
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sleepCtx waits for d or until ctx is cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(d):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimitBackoff returns how long to wait after a 429, honoring Retry-After
|
||||
// and otherwise backing off exponentially (capped at 60s).
|
||||
func rateLimitBackoff(resp *http.Response, attempt int) time.Duration {
|
||||
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
wait := 10 * time.Second * (1 << attempt)
|
||||
if wait > 60*time.Second {
|
||||
wait = 60 * time.Second
|
||||
}
|
||||
return wait
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func chatConfig(baseURL string) Config {
|
||||
return Config{BaseURL: baseURL, APIKey: "test-key", ChatModel: "test-model"}
|
||||
}
|
||||
|
||||
const chatOK = `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
|
||||
|
||||
func TestChatSuccessSendsAuthAndModel(t *testing.T) {
|
||||
var gotAuth, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
gotBody = string(buf[:n])
|
||||
w.Write([]byte(chatOK))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out != "hello" {
|
||||
t.Errorf("content = %q, want hello", out)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("auth = %q", gotAuth)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"model":"test-model"`) || !strings.Contains(gotBody, `"json_object"`) {
|
||||
t.Errorf("request body missing model/response_format: %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesOn429HonoringRetryAfter(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(chatOK))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
start := time.Now()
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("calls = %d, want 2", calls.Load())
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < time.Second {
|
||||
t.Errorf("did not honor Retry-After: elapsed %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesOn5xxAndEmbeddedErrorAndEmptyChoices(t *testing.T) {
|
||||
responses := []func(w http.ResponseWriter){
|
||||
func(w http.ResponseWriter) { w.WriteHeader(http.StatusBadGateway) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(`{"error":{"message":"upstream sad"}}`)) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(`{"choices":[]}`)) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(chatOK)) },
|
||||
}
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
responses[calls.Add(1)-1](w)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out != "hello" || calls.Load() != 4 {
|
||||
t.Errorf("out=%q calls=%d, want hello/4", out, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFailsFastOnNon429ClientError(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("calls = %d, want 1 (no retry on 401)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
const verboseJSON = `{"language":"english","text":"hi there","segments":[{"start":0.0,"end":1.5,"text":" hi"},{"start":1.5,"end":3.0,"text":" there"}]}`
|
||||
|
||||
func TestTranscribeParsesSegmentsAndMultipart(t *testing.T) {
|
||||
var gotModel, gotFormat, gotLang, gotAuth, gotVAD string
|
||||
var gotGranularities []string
|
||||
var gotFile []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
t.Errorf("parse multipart: %v", err)
|
||||
}
|
||||
gotModel = r.FormValue("model")
|
||||
gotFormat = r.FormValue("response_format")
|
||||
gotLang = r.FormValue("language")
|
||||
gotVAD = r.FormValue("vad_filter")
|
||||
gotGranularities = r.MultipartForm.Value["timestamp_granularities[]"]
|
||||
f, _, err := r.FormFile("file")
|
||||
if err == nil {
|
||||
buf := make([]byte, 64)
|
||||
n, _ := f.Read(buf)
|
||||
gotFile = buf[:n]
|
||||
f.Close()
|
||||
}
|
||||
w.Write([]byte(verboseJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
c := NewClient(cfg)
|
||||
tr, err := c.Transcribe(context.Background(), TranscribeRequest{
|
||||
Filename: "chunk.wav", Audio: []byte("RIFFfake"), Language: "ja",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotModel != "whisper-test" || gotFormat != "verbose_json" || gotLang != "ja" {
|
||||
t.Errorf("fields model=%q format=%q lang=%q", gotModel, gotFormat, gotLang)
|
||||
}
|
||||
// Local (non-hosted) endpoint: faster-whisper VAD must be requested or
|
||||
// segment timestamps stretch wall-to-wall across silence.
|
||||
if gotVAD != "true" {
|
||||
t.Errorf("vad_filter = %q, want true for a self-hosted endpoint", gotVAD)
|
||||
}
|
||||
if fmt.Sprint(gotGranularities) != "[segment word]" {
|
||||
t.Errorf("timestamp_granularities[] = %v, want [segment word]", gotGranularities)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("auth = %q (should fall back to chat key)", gotAuth)
|
||||
}
|
||||
if string(gotFile) != "RIFFfake" {
|
||||
t.Errorf("file payload = %q", gotFile)
|
||||
}
|
||||
if tr.Language != "english" || len(tr.Segments) != 2 || tr.Segments[1].Text != " there" || tr.Segments[1].End != 3.0 {
|
||||
t.Errorf("unexpected transcription: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeOmitsVADFilterForStrictHostedEndpoints(t *testing.T) {
|
||||
for url, wantVAD := range map[string]bool{
|
||||
"https://api.openai.com": false,
|
||||
"https://myorg.openai.azure.com": false,
|
||||
"https://api.groq.com/openai": false,
|
||||
"http://192.168.1.10:8000": true,
|
||||
"https://whisper.example.com": true,
|
||||
"https://api.deepinfra.com/v1/openai": true,
|
||||
} {
|
||||
got := !hostMatchesAny(url, strictHostedASRHosts)
|
||||
if got != wantVAD {
|
||||
t.Errorf("vad_filter for %s = %v, want %v", url, got, wantVAD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeParsesPerSegmentWords(t *testing.T) {
|
||||
// speaches/faster-whisper shape: words nested inside each segment.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"en","text":"hi there","segments":[
|
||||
{"start":0.0,"end":3.0,"text":" hi there","words":[
|
||||
{"start":0.2,"end":0.5,"word":" hi"},{"start":0.6,"end":1.0,"word":" there"}]}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
words := tr.Segments[0].Words
|
||||
if len(words) != 2 || words[1].Text != " there" || words[1].Start != 0.6 || words[1].End != 1.0 {
|
||||
t.Errorf("segment words = %+v", words)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeAttachesTopLevelWordsBySegmentTime(t *testing.T) {
|
||||
// OpenAI shape: words in a top-level array, segments without words.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"en","text":"hi there friend","segments":[
|
||||
{"start":0.0,"end":1.5,"text":" hi there"},{"start":1.5,"end":3.0,"text":" friend"}],
|
||||
"words":[{"start":0.2,"end":0.5,"word":"hi"},{"start":0.6,"end":1.0,"word":"there"},
|
||||
{"start":1.8,"end":2.2,"word":"friend"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if len(tr.Segments[0].Words) != 2 || len(tr.Segments[1].Words) != 1 {
|
||||
t.Errorf("word distribution = %d/%d, want 2/1", len(tr.Segments[0].Words), len(tr.Segments[1].Words))
|
||||
}
|
||||
if tr.Segments[1].Words[0].Text != "friend" {
|
||||
t.Errorf("segment 1 word = %+v", tr.Segments[1].Words[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeEmptySegmentsIsNotAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"english","text":"","segments":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if len(tr.Segments) != 0 {
|
||||
t.Errorf("segments = %v, want empty", tr.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeMissingSegmentsFieldFailsFast(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Write([]byte(`{"text":"plain response without segments"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "verbose_json") {
|
||||
t.Fatalf("err = %v, want verbose_json complaint", err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("calls = %d, want 1 (permanent error must not retry)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeUsesASROverrides(t *testing.T) {
|
||||
var gotAuth atomic.Value
|
||||
asrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth.Store(r.Header.Get("Authorization"))
|
||||
w.Write([]byte(verboseJSON))
|
||||
}))
|
||||
defer asrSrv.Close()
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: "http://chat.invalid", APIKey: "chat-key", ChatModel: "m",
|
||||
ASRBaseURL: asrSrv.URL, ASRAPIKey: "asr-key", ASRModel: "whisper-test",
|
||||
}
|
||||
if _, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotAuth.Load() != "Bearer asr-key" {
|
||||
t.Errorf("auth = %q, want asr-key", gotAuth.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRequiresConfig(t *testing.T) {
|
||||
c := NewClient(Config{BaseURL: "http://x", ChatModel: "m"}) // no ASR model
|
||||
if _, err := c.Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err == nil {
|
||||
t.Fatal("expected not-configured error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeChatOnlyGatewayGetsConfigHint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":{"message":"invalid content-type: multipart/form-data","code":400}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "Whisper-compatible Transcription base URL") {
|
||||
t.Fatalf("err = %v, want configuration hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsChatOnlyGateway(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://openrouter.ai/api": true,
|
||||
"https://openrouter.ai": true,
|
||||
"openrouter.ai/api/v1": true,
|
||||
"https://gateway.openrouter.ai": true,
|
||||
"https://api.groq.com/openai": false,
|
||||
"https://api.openai.com": false,
|
||||
"http://localhost:8000": false,
|
||||
"https://my-openrouter.ai.example.io": false,
|
||||
"": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsChatOnlyGateway(in); got != want {
|
||||
t.Errorf("IsChatOnlyGateway(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRefusesChatOnlyGateway(t *testing.T) {
|
||||
cfg := Config{BaseURL: "https://openrouter.ai/api", ChatModel: "m", ASRModel: "whisper-1"}
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot produce timestamped transcriptions") {
|
||||
t.Fatalf("err = %v, want chat-only refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointURLToleratesVersionedBases(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://api.openai.com": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.groq.com/openai": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.deepinfra.com/v1/openai": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"http://localhost:8969": "http://localhost:8969/v1/chat/completions",
|
||||
"http://localhost:8969/v1": "http://localhost:8969/v1/chat/completions",
|
||||
"https://api.openai.com/": "https://api.openai.com/v1/chat/completions",
|
||||
}
|
||||
for base, want := range cases {
|
||||
if got := endpointURL(base, "chat/completions"); got != want {
|
||||
t.Errorf("endpointURL(%q) = %q, want %q", base, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultTranscribeTimeout bounds a single transcription request when the
|
||||
// caller does not size one to its chunk duration. Generous on purpose: local
|
||||
// Whisper servers on modest hardware can run well below realtime.
|
||||
const defaultTranscribeTimeout = 20 * time.Minute
|
||||
|
||||
// TranscribeRequest is one audio-transcription call. Audio is held in memory
|
||||
// so the request can be rebuilt across retries; callers chunk long files
|
||||
// (a 10-minute 16 kHz mono WAV is ~19 MB).
|
||||
type TranscribeRequest struct {
|
||||
Filename string // e.g. "chunk00001.wav"; the extension hints the container
|
||||
Audio []byte
|
||||
Language string // optional ISO-639-1 hint; empty lets the model detect
|
||||
Timeout time.Duration // per-request deadline; 0 uses defaultTranscribeTimeout
|
||||
}
|
||||
|
||||
// TranscriptionSegment is one timed segment of recognized speech, in seconds
|
||||
// relative to the start of the submitted audio.
|
||||
type TranscriptionSegment struct {
|
||||
Start float64
|
||||
End float64
|
||||
Text string
|
||||
// Words carries per-word timings when the endpoint honors
|
||||
// timestamp_granularities[]=word; empty otherwise. Word-level times let
|
||||
// cue building split paragraph-length segments and end cues when speech
|
||||
// actually stops instead of when the next segment begins.
|
||||
Words []TranscriptionWord
|
||||
}
|
||||
|
||||
// TranscriptionWord is one recognized word with timing, in seconds relative
|
||||
// to the start of the submitted audio.
|
||||
type TranscriptionWord struct {
|
||||
Start float64
|
||||
End float64
|
||||
Text string
|
||||
}
|
||||
|
||||
// Transcription is the parsed verbose_json transcription result.
|
||||
type Transcription struct {
|
||||
// Language is the detected (or hinted) language as reported by the
|
||||
// endpoint. OpenAI returns an English language name ("english"); other
|
||||
// servers return ISO codes. Callers must normalize.
|
||||
Language string
|
||||
// Segments may legitimately be empty for speech-free audio (silence,
|
||||
// music-only chunks).
|
||||
Segments []TranscriptionSegment
|
||||
}
|
||||
|
||||
type transcriptionWordJSON struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Word string `json:"word"`
|
||||
}
|
||||
|
||||
type transcriptionResponse struct {
|
||||
Language string `json:"language"`
|
||||
Text string `json:"text"`
|
||||
// Segments distinguishes "verbose_json honored, no speech" (empty array)
|
||||
// from "endpoint ignored verbose_json" (field absent) — the latter cannot
|
||||
// produce timed cues and must fail rather than silently emit nothing.
|
||||
Segments *[]struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Words []transcriptionWordJSON `json:"words"`
|
||||
} `json:"segments"`
|
||||
// Words is where OpenAI puts word timings; faster-whisper servers
|
||||
// (speaches) nest them inside each segment instead.
|
||||
Words []transcriptionWordJSON `json:"words"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// chatOnlyGatewayHosts lists OpenAI-compatible gateways that cannot serve
|
||||
// Silo's transcription needs: they either lack /v1/audio/transcriptions
|
||||
// entirely or (OpenRouter) return plain text with no segment timestamps,
|
||||
// which subtitle cues require. Matched by host suffix.
|
||||
var chatOnlyGatewayHosts = []string{
|
||||
"openrouter.ai",
|
||||
}
|
||||
|
||||
// strictHostedASRHosts lists hosted transcription providers that reject
|
||||
// multipart fields outside the OpenAI spec, so the faster-whisper-only
|
||||
// vad_filter field must be omitted for them. Nothing is lost: hosted Whisper
|
||||
// runs voice-activity detection server-side. Self-hosted servers (speaches,
|
||||
// faster-whisper) need the explicit field — without it segment timestamps
|
||||
// stretch wall-to-wall across silence. Matched by host suffix.
|
||||
var strictHostedASRHosts = []string{
|
||||
"api.openai.com",
|
||||
"openai.azure.com",
|
||||
"api.groq.com",
|
||||
"api.mistral.ai",
|
||||
}
|
||||
|
||||
// IsChatOnlyGateway reports whether baseURL points at a known chat-only
|
||||
// gateway that cannot produce timestamped transcriptions. Used to validate
|
||||
// the transcription settings and to disable ASR rather than fail jobs at
|
||||
// runtime.
|
||||
func IsChatOnlyGateway(baseURL string) bool {
|
||||
return hostMatchesAny(baseURL, chatOnlyGatewayHosts)
|
||||
}
|
||||
|
||||
// hostMatchesAny reports whether baseURL's hostname equals or is a subdomain
|
||||
// of any of the given host suffixes.
|
||||
func hostMatchesAny(baseURL string, hosts []string) bool {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(baseURL, "://") {
|
||||
baseURL = "https://" + baseURL
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
for _, h := range hosts {
|
||||
if host == h || strings.HasSuffix(host, "."+h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Transcribe performs one audio transcription against the ASR endpoint
|
||||
// (falling back to the chat endpoint's base URL/key when no ASR override is
|
||||
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
|
||||
// response_format=verbose_json for segment timestamps.
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
|
||||
if !c.cfg.ASRConfigured() {
|
||||
return nil, fmt.Errorf("transcription endpoint is not configured")
|
||||
}
|
||||
if IsChatOnlyGateway(c.cfg.asrBaseURL()) {
|
||||
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
|
||||
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", c.cfg.asrBaseURL())
|
||||
}
|
||||
if len(req.Audio) == 0 {
|
||||
return nil, fmt.Errorf("no audio data to transcribe")
|
||||
}
|
||||
|
||||
timeout := req.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTranscribeTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
url := endpointURL(c.cfg.asrBaseURL(), "audio/transcriptions")
|
||||
|
||||
var result *Transcription
|
||||
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
|
||||
func() (*http.Request, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", req.Filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create multipart file: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(req.Audio); err != nil {
|
||||
return nil, fmt.Errorf("write multipart audio: %w", err)
|
||||
}
|
||||
fields := [][2]string{
|
||||
{"model", c.cfg.ASRModel},
|
||||
{"response_format", "verbose_json"},
|
||||
{"temperature", "0"},
|
||||
// Word timings let cue building split paragraph-length
|
||||
// segments and end cues when speech actually stops; segment
|
||||
// granularity must be requested alongside or OpenAI omits it.
|
||||
{"timestamp_granularities[]", "segment"},
|
||||
{"timestamp_granularities[]", "word"},
|
||||
}
|
||||
if req.Language != "" {
|
||||
fields = append(fields, [2]string{"language", req.Language})
|
||||
}
|
||||
if !hostMatchesAny(c.cfg.asrBaseURL(), strictHostedASRHosts) {
|
||||
fields = append(fields, [2]string{"vad_filter", "true"})
|
||||
}
|
||||
for _, f := range fields {
|
||||
if err := w.WriteField(f[0], f[1]); err != nil {
|
||||
return nil, fmt.Errorf("write multipart field %s: %w", f[0], err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalize multipart body: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if key := c.cfg.asrAPIKey(); key != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
func(respBody []byte) error {
|
||||
var parsed transcriptionResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("decode transcription response: %w", err)
|
||||
}
|
||||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||||
return fmt.Errorf("transcription API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if parsed.Segments == nil {
|
||||
return &permanentError{err: fmt.Errorf(
|
||||
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", c.cfg.ASRModel)}
|
||||
}
|
||||
out := &Transcription{Language: parsed.Language}
|
||||
for _, s := range *parsed.Segments {
|
||||
out.Segments = append(out.Segments, TranscriptionSegment{
|
||||
Start: s.Start, End: s.End, Text: s.Text, Words: wordsFromJSON(s.Words),
|
||||
})
|
||||
}
|
||||
attachTopLevelWords(out.Segments, wordsFromJSON(parsed.Words))
|
||||
result = out
|
||||
return nil
|
||||
})
|
||||
if doErr != nil {
|
||||
return nil, decorateTranscribeError(doErr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func wordsFromJSON(words []transcriptionWordJSON) []TranscriptionWord {
|
||||
out := make([]TranscriptionWord, 0, len(words))
|
||||
for _, w := range words {
|
||||
out = append(out, TranscriptionWord{Start: w.Start, End: w.End, Text: w.Word})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attachTopLevelWords distributes an OpenAI-style top-level word list onto
|
||||
// segments by word midpoint, for endpoints that report words separately from
|
||||
// segments. Segments that already carry their own words are left untouched.
|
||||
func attachTopLevelWords(segments []TranscriptionSegment, words []TranscriptionWord) {
|
||||
if len(segments) == 0 || len(words) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range segments {
|
||||
if len(s.Words) > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
si := 0
|
||||
for _, w := range words {
|
||||
mid := (w.Start + w.End) / 2
|
||||
for si < len(segments)-1 && mid >= segments[si].End {
|
||||
si++
|
||||
}
|
||||
segments[si].Words = append(segments[si].Words, w)
|
||||
}
|
||||
}
|
||||
|
||||
// decorateTranscribeError appends a configuration hint to the errors a
|
||||
// chat-only gateway produces when it receives a transcription upload (no
|
||||
// /v1/audio/transcriptions route, or multipart rejected). Operators routinely
|
||||
// point the shared base URL at a chat-only provider; without the hint the raw
|
||||
// 400/404 reads like a pipeline bug instead of "set a Whisper endpoint".
|
||||
func decorateTranscribeError(err error) error {
|
||||
msg := err.Error()
|
||||
likelyUnsupported := strings.Contains(msg, "returned 400") ||
|
||||
strings.Contains(msg, "returned 404") ||
|
||||
strings.Contains(msg, "returned 405")
|
||||
if !likelyUnsupported {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w — the configured endpoint likely does not support audio transcription; "+
|
||||
"set a Whisper-compatible Transcription base URL (and model) under Admin Settings → AI Services "+
|
||||
"(e.g. a self-hosted faster-whisper/speaches server, api.groq.com/openai with whisper-large-v3-turbo, or api.openai.com with whisper-1)", err)
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
// Package aitranslate implements generic batched text translation over an
|
||||
// OpenAI-compatible chat model. Callers hand it ordered text segments and a
|
||||
// domain-specific system prompt; it owns the wire protocol (indexed-JSON
|
||||
// batches the model must echo back key-for-key), context carryover between
|
||||
// batches, and retries on malformed model output. Subtitle cues and metadata
|
||||
// descriptions both translate through this one implementation.
|
||||
package aitranslate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Segment is one unit of translatable text. ID is the caller's identifier and
|
||||
// is never sent to the model (batches are keyed by position), so any stable
|
||||
// string works.
|
||||
type Segment struct {
|
||||
ID string
|
||||
Text string
|
||||
}
|
||||
|
||||
// ChatFn performs one chat completion. Wiring this as a function keeps the
|
||||
// package independent of the client and trivially testable.
|
||||
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
||||
|
||||
// Request describes one translation run.
|
||||
type Request struct {
|
||||
Segments []Segment
|
||||
SystemPrompt string // domain-specific; must demand same-keys JSON output
|
||||
// TargetName is the human-readable target language name used in the
|
||||
// per-batch user message.
|
||||
TargetName string
|
||||
// EntryNoun names the units in the user message ("cues", "descriptions");
|
||||
// empty defaults to "entries".
|
||||
EntryNoun string
|
||||
// BatchSize is the number of segments per chat request (required, > 0).
|
||||
BatchSize int
|
||||
// ContextNeighbors is how many preceding source segments are included,
|
||||
// untranslated, for continuity across batch boundaries.
|
||||
ContextNeighbors int
|
||||
}
|
||||
|
||||
// maxFormatRetries is how many times a batch is re-asked after the model
|
||||
// returns unparsable or incomplete JSON. Transport/API errors are retried
|
||||
// inside the chat client and are not part of this count.
|
||||
const maxFormatRetries = 2
|
||||
|
||||
// Translate translates req.Segments in order, preserving count and IDs and
|
||||
// only rewriting Text. onBatch, when non-nil, is called after each batch with
|
||||
// that batch's translated segments and overall progress (done/total
|
||||
// segments). Implementations must honor ctx cancellation between batches.
|
||||
func Translate(ctx context.Context, chat ChatFn, req Request, onBatch func(batch []Segment, done, total int)) ([]Segment, error) {
|
||||
if chat == nil {
|
||||
return nil, fmt.Errorf("chat function is nil")
|
||||
}
|
||||
if req.BatchSize <= 0 {
|
||||
return nil, fmt.Errorf("invalid batch size: %d", req.BatchSize)
|
||||
}
|
||||
total := len(req.Segments)
|
||||
if total == 0 {
|
||||
return nil, fmt.Errorf("no segments to translate")
|
||||
}
|
||||
noun := req.EntryNoun
|
||||
if noun == "" {
|
||||
noun = "entries"
|
||||
}
|
||||
|
||||
// Preserve IDs by copying input segments and only replacing Text.
|
||||
out := make([]Segment, total)
|
||||
copy(out, req.Segments)
|
||||
|
||||
for start := 0; start < total; start += req.BatchSize {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := min(start+req.BatchSize, total)
|
||||
|
||||
contextStart := max(0, start-req.ContextNeighbors)
|
||||
translated, err := translateBatch(ctx, chat, req, noun, req.Segments[contextStart:start], req.Segments[start:end])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("translate %s %d-%d: %w", noun, start+1, end, err)
|
||||
}
|
||||
for i, text := range translated {
|
||||
out[start+i].Text = text
|
||||
}
|
||||
if onBatch != nil {
|
||||
onBatch(out[start:end], end, total)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func translateBatch(ctx context.Context, chat ChatFn, req Request, noun string, contextSegs, batch []Segment) ([]string, error) {
|
||||
texts := make([]string, len(batch))
|
||||
for i, s := range batch {
|
||||
texts[i] = s.Text
|
||||
}
|
||||
payload, err := buildIndexedJSON(texts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user strings.Builder
|
||||
if len(contextSegs) > 0 {
|
||||
user.WriteString("Preceding lines for context only — do not translate or include them in your output:\n")
|
||||
for _, s := range contextSegs {
|
||||
user.WriteString(strings.ReplaceAll(s.Text, "\n", " "))
|
||||
user.WriteByte('\n')
|
||||
}
|
||||
user.WriteByte('\n')
|
||||
}
|
||||
target := req.TargetName
|
||||
if target == "" {
|
||||
target = "the target language"
|
||||
}
|
||||
fmt.Fprintf(&user, "Translate these %d %s into %s. Respond with only the JSON object:\n%s", len(batch), noun, target, payload)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxFormatRetries; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := chat(ctx, req.SystemPrompt, user.String())
|
||||
if err != nil {
|
||||
return nil, err // transport/API errors are already retried inside the client
|
||||
}
|
||||
|
||||
obj, err := extractJSONObject(content)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
||||
lastErr = fmt.Errorf("decode translation JSON: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
out := make([]string, len(batch))
|
||||
complete := true
|
||||
for i := range batch {
|
||||
v, ok := m[strconv.Itoa(i+1)]
|
||||
if !ok {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
if !complete {
|
||||
lastErr = fmt.Errorf("model omitted one or more %s", noun)
|
||||
continue
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid model response after %d attempts: %w", maxFormatRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// buildIndexedJSON renders texts as a JSON object {"1":..., "2":...} keyed by
|
||||
// 1-based position, escaping each value safely. It is built by hand rather
|
||||
// than json.Marshal'ing a map so the keys stay in numeric order — that reads
|
||||
// more naturally for the model than the lexicographic order Go emits for maps
|
||||
// ("1","10","11",...,"2"). Correctness doesn't depend on order (results are
|
||||
// mapped back by key), but ordered input gives the model better context.
|
||||
func buildIndexedJSON(texts []string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteByte('{')
|
||||
for i, text := range texts {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(strconv.Itoa(i + 1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
val, err := json.Marshal(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.Write(key)
|
||||
b.WriteByte(':')
|
||||
b.Write(val)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// extractJSONObject pulls the first balanced-looking JSON object out of a
|
||||
// model reply, tolerating ``` code fences and surrounding prose.
|
||||
func extractJSONObject(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "```") {
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if nl := strings.IndexByte(s, '\n'); nl >= 0 {
|
||||
s = s[nl+1:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
start := strings.IndexByte(s, '{')
|
||||
end := strings.LastIndexByte(s, '}')
|
||||
if start < 0 || end < 0 || end < start {
|
||||
return "", fmt.Errorf("no JSON object found in model response")
|
||||
}
|
||||
return s[start : end+1], nil
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package aitranslate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// echoChat translates by upper-casing every value of the indexed JSON payload
|
||||
// in the user message, mimicking a well-behaved model.
|
||||
func echoChat(t *testing.T) ChatFn {
|
||||
t.Helper()
|
||||
return func(_ context.Context, _ string, user string) (string, error) {
|
||||
obj, err := extractJSONObject(user)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("test chat: %w", err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
||||
return "", fmt.Errorf("test chat decode: %w", err)
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = strings.ToUpper(v)
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func segments(n int) []Segment {
|
||||
out := make([]Segment, n)
|
||||
for i := range out {
|
||||
out[i] = Segment{ID: strconv.Itoa(i + 1), Text: fmt.Sprintf("line %d", i+1)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTranslateBatchesAndPreservesIDs(t *testing.T) {
|
||||
var batches []int
|
||||
out, err := Translate(context.Background(), echoChat(t), Request{
|
||||
Segments: segments(7),
|
||||
BatchSize: 3,
|
||||
}, func(batch []Segment, done, total int) {
|
||||
batches = append(batches, len(batch))
|
||||
if total != 7 {
|
||||
t.Errorf("total = %d, want 7", total)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if len(out) != 7 {
|
||||
t.Fatalf("len(out) = %d", len(out))
|
||||
}
|
||||
for i, s := range out {
|
||||
if s.ID != strconv.Itoa(i+1) {
|
||||
t.Errorf("ID[%d] = %q, want %d", i, s.ID, i+1)
|
||||
}
|
||||
if want := strings.ToUpper(fmt.Sprintf("line %d", i+1)); s.Text != want {
|
||||
t.Errorf("Text[%d] = %q, want %q", i, s.Text, want)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(batches) != "[3 3 1]" {
|
||||
t.Errorf("batch sizes = %v, want [3 3 1]", batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateSendsContextNeighborsUntranslated(t *testing.T) {
|
||||
var secondBatchMsg string
|
||||
call := 0
|
||||
chat := func(ctx context.Context, system, user string) (string, error) {
|
||||
call++
|
||||
if call == 2 {
|
||||
secondBatchMsg = user
|
||||
}
|
||||
return echoChat(t)(ctx, system, user)
|
||||
}
|
||||
_, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(4),
|
||||
BatchSize: 2,
|
||||
ContextNeighbors: 2,
|
||||
TargetName: "French",
|
||||
EntryNoun: "cues",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if !strings.Contains(secondBatchMsg, "context only") ||
|
||||
!strings.Contains(secondBatchMsg, "line 1\nline 2") {
|
||||
t.Errorf("second batch missing context block:\n%s", secondBatchMsg)
|
||||
}
|
||||
if !strings.Contains(secondBatchMsg, "Translate these 2 cues into French.") {
|
||||
t.Errorf("second batch missing instruction:\n%s", secondBatchMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateToleratesCodeFences(t *testing.T) {
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
return "Sure! Here you go:\n```json\n{\"1\":\"ok\"}\n```", nil
|
||||
}
|
||||
out, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(1),
|
||||
BatchSize: 5,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if out[0].Text != "ok" {
|
||||
t.Errorf("Text = %q", out[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateRetriesOmittedKeysThenFails(t *testing.T) {
|
||||
calls := 0
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
calls++
|
||||
return `{"1":"only one"}`, nil // always omits key "2"
|
||||
}
|
||||
_, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(2),
|
||||
BatchSize: 5,
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "omitted") {
|
||||
t.Fatalf("err = %v, want omission failure", err)
|
||||
}
|
||||
if calls != maxFormatRetries+1 {
|
||||
t.Errorf("calls = %d, want %d", calls, maxFormatRetries+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStopsOnCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
calls++
|
||||
cancel() // cancel after the first batch completes
|
||||
return `{"1":"a"}`, nil
|
||||
}
|
||||
_, err := Translate(ctx, chat, Request{
|
||||
Segments: segments(3),
|
||||
BatchSize: 1,
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("err = %v, want context cancellation", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
|
||||
// connection is rejected when the integration is disabled or has no base_url, so
|
||||
@@ -26,31 +22,3 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
|
||||
// locks the autoscan→requests credential-reuse behavior after the
|
||||
// request_integrations table was generalized into the two-tier connection
|
||||
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
|
||||
// rows via the same enabled + base_url gate, regardless of the added
|
||||
// capability/installation columns.
|
||||
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
|
||||
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
|
||||
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
|
||||
}
|
||||
// Disabled -> error (autoscan must not poll a disabled connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
|
||||
t.Fatal("disabled integration must be rejected")
|
||||
}
|
||||
// Blank base_url -> error.
|
||||
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
|
||||
t.Fatal("blank base_url must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
|
||||
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
|
||||
// fail the build here rather than silently at runtime.
|
||||
var _ = func(in mediarequests.Integration) (string, string, bool) {
|
||||
return in.BaseURL, in.APIKeyRef, in.Enabled
|
||||
}
|
||||
|
||||
+206
-134
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -20,7 +21,6 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
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"
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -102,17 +101,11 @@ func NewAdminHandler(
|
||||
pool *pgxpool.Pool,
|
||||
storeProv userstore.UserStoreProvider,
|
||||
) *AdminHandler {
|
||||
var groupResolver auth.GroupResolver
|
||||
if pool != nil {
|
||||
groupResolver = auth.NewGroupRepository(pool)
|
||||
}
|
||||
return &AdminHandler{
|
||||
userRepo: userRepo,
|
||||
pool: pool,
|
||||
storeProv: storeProv,
|
||||
// No settings dependency at construction time: nil settings makes the
|
||||
// provisioner fall back to the built-in users group for defaults.
|
||||
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv, nil, groupResolver),
|
||||
userRepo: userRepo,
|
||||
pool: pool,
|
||||
storeProv: storeProv,
|
||||
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,53 +113,115 @@ func NewAdminHandler(
|
||||
|
||||
// createUserRequest represents the JSON body for POST /admin/users.
|
||||
type createUserRequest 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"`
|
||||
// GroupIDs are the group memberships to assign. Omitted (nil) means the
|
||||
// configured default groups are used.
|
||||
GroupIDs []int `json:"group_ids"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Permissions createStringSliceField `json:"permissions"`
|
||||
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 createStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *createStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type updateStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
func (f updateStringSliceField) Ptr() *[]string {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := append([]string(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"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// GroupIDs replaces the user's group memberships. Omitted = unchanged.
|
||||
GroupIDs *[]int `json:"group_ids,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Permissions updateStringSliceField `json:"permissions,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"`
|
||||
}
|
||||
|
||||
// adminGroupRef is a lightweight group reference in admin user responses.
|
||||
type adminGroupRef struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// adminUserResponse represents a user in admin JSON responses. The policy
|
||||
// fields are the effective values derived from the user's groups.
|
||||
// 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"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Groups []adminGroupRef `json:"groups"`
|
||||
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"`
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
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 {
|
||||
@@ -212,21 +267,15 @@ func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// toAdminUserResponse converts a User model (with its group memberships
|
||||
// loaded into u.Groups at load time) to an admin API response.
|
||||
// toAdminUserResponse converts a User model to an admin API response.
|
||||
func toAdminUserResponse(u *models.User) adminUserResponse {
|
||||
refs := make([]adminGroupRef, 0, len(u.Groups))
|
||||
for _, g := range u.Groups {
|
||||
refs = append(refs, adminGroupRef{ID: g.ID, Slug: g.Slug, Name: g.Name})
|
||||
}
|
||||
return adminUserResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Role: auth.RoleForUser(u),
|
||||
Role: u.Role,
|
||||
Permissions: append([]string{}, u.Permissions...),
|
||||
Enabled: u.Enabled,
|
||||
Groups: refs,
|
||||
LibraryIDs: append([]int(nil), u.LibraryIDs...),
|
||||
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
|
||||
MaxStreams: u.MaxStreams,
|
||||
@@ -289,13 +338,10 @@ func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]adminUserResponse, 0, len(users))
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
|
||||
resp := make([]adminUserResponse, 0, len(users))
|
||||
for _, u := range users {
|
||||
resp = append(resp, toAdminUserResponse(u))
|
||||
}
|
||||
lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs)
|
||||
@@ -345,17 +391,44 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
req.Username = auth.NormalizeUsername(req.Username)
|
||||
req.Email = auth.NormalizeEmail(req.Email)
|
||||
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
|
||||
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
|
||||
}
|
||||
permissions := auth.DefaultUserPermissions()
|
||||
if req.Permissions.Set {
|
||||
permissions = req.Permissions.Value
|
||||
}
|
||||
permissions, err := auth.NormalizePermissions(permissions)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
|
||||
User: models.CreateUserInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
Permissions: permissions,
|
||||
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,
|
||||
@@ -363,10 +436,6 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUnknownGroup) {
|
||||
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
|
||||
return
|
||||
}
|
||||
@@ -390,12 +459,43 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
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
|
||||
}
|
||||
var permissions *[]string
|
||||
if req.Permissions.Set {
|
||||
normalized, err := auth.NormalizePermissions(req.Permissions.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
permissions = &normalized
|
||||
}
|
||||
|
||||
updateInput := models.UpdateUserInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Enabled: req.Enabled,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
Permissions: permissions,
|
||||
Enabled: req.Enabled,
|
||||
LibraryIDs: req.LibraryIDs.Ptr(),
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
}
|
||||
|
||||
var currentUser *models.User
|
||||
@@ -417,14 +517,6 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrLastAdministrator) {
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrUnknownGroup) {
|
||||
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -455,10 +547,6 @@ func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
err = h.userRepo.Delete(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrLastAdministrator) {
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete user")
|
||||
return
|
||||
}
|
||||
@@ -702,8 +790,10 @@ func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool {
|
||||
return input.Password != nil ||
|
||||
input.Role != nil ||
|
||||
input.Enabled != nil ||
|
||||
input.GroupIDs != nil
|
||||
input.Permissions != nil ||
|
||||
input.MaxPlaybackQuality != nil
|
||||
}
|
||||
|
||||
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
|
||||
@@ -713,28 +803,22 @@ func updateRequiresSessionRevocation(current *models.User, input models.UpdateUs
|
||||
if current == nil {
|
||||
return updateMayRequireSessionRevocation(input)
|
||||
}
|
||||
if input.Role != nil && *input.Role != current.Role {
|
||||
return true
|
||||
}
|
||||
if input.Enabled != nil && *input.Enabled != current.Enabled {
|
||||
return true
|
||||
}
|
||||
// Group membership changes alter the effective policy (permissions,
|
||||
// quality ceilings, ...), so a real change revokes sessions.
|
||||
if input.GroupIDs != nil && !equalIntSets(*input.GroupIDs, current.GroupIDs) {
|
||||
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
|
||||
return true
|
||||
}
|
||||
if input.MaxPlaybackQuality != nil &&
|
||||
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// equalIntSets reports whether two int slices contain the same set of values,
|
||||
// ignoring order and duplicates.
|
||||
func equalIntSets(a, b []int) bool {
|
||||
normalize := func(values []int) []int {
|
||||
out := append([]int(nil), values...)
|
||||
sort.Ints(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
return slices.Equal(normalize(a), normalize(b))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error {
|
||||
if h.pool == nil {
|
||||
return nil
|
||||
@@ -935,7 +1019,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
|
||||
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponseForViewer(r, job, nil, isAdminRequest(r, h.userRepo)))
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
|
||||
}
|
||||
|
||||
// UpdateItemMetadataRequest contains the fields that can be updated via
|
||||
@@ -1994,6 +2078,21 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
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 {
|
||||
@@ -2002,33 +2101,6 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
} else {
|
||||
req.Value = normalized
|
||||
}
|
||||
case "ai.asr_base_url":
|
||||
if llm.IsChatOnlyGateway(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
|
||||
"Use a self-hosted Whisper server (faster-whisper/speaches), api.groq.com/openai, or api.openai.com.")
|
||||
return
|
||||
}
|
||||
case "metadata_ai.on_view":
|
||||
switch req.Value {
|
||||
case "off", "button", "auto":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"metadata_ai.on_view must be off, button, or auto")
|
||||
return
|
||||
}
|
||||
case "subtitle_ai.transcribe_quota_jobs":
|
||||
if n, err := strconv.Atoi(req.Value); err != nil || n < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"subtitle_ai.transcribe_quota_jobs must be an integer >= 0 (0 = unlimited)")
|
||||
return
|
||||
}
|
||||
case "subtitle_ai.transcribe_quota_period":
|
||||
if !subtitleai.ValidQuotaPeriod(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"subtitle_ai.transcribe_quota_period must be day, week, or month")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
// isAdminRequest reports whether the request's authenticated user currently
|
||||
// holds the admin permission. Admin authority is always derived from the
|
||||
// freshly loaded user (group-derived policy), never from token contents.
|
||||
// It first consults the user stashed by the RequireAdmin middleware and
|
||||
// falls back to loading via the given loader.
|
||||
func isAdminRequest(r *http.Request, loader auth.UserLoader) bool {
|
||||
ctx := r.Context()
|
||||
if user := apimw.GetUser(ctx); user != nil {
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
claims := apimw.GetClaims(ctx)
|
||||
if claims == nil || loader == nil {
|
||||
return false
|
||||
}
|
||||
user, err := loader.GetByID(ctx, claims.UserID)
|
||||
if err != nil || user == nil {
|
||||
return false
|
||||
}
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// GroupStore defines the group repository operations the AdminGroupsHandler
|
||||
// needs, so tests can substitute a fake.
|
||||
type GroupStore interface {
|
||||
List(ctx context.Context) ([]auth.GroupWithMemberCount, error)
|
||||
GetByID(ctx context.Context, id int) (*models.Group, error)
|
||||
MemberCount(ctx context.Context, groupID int) (int, error)
|
||||
Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error)
|
||||
Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
ListMembers(ctx context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error)
|
||||
AddMember(ctx context.Context, groupID, userID int) error
|
||||
RemoveMember(ctx context.Context, groupID, userID int) error
|
||||
}
|
||||
|
||||
// AdminGroupsHandler handles admin-only group management endpoints.
|
||||
type AdminGroupsHandler struct {
|
||||
store GroupStore
|
||||
}
|
||||
|
||||
// NewAdminGroupsHandler creates an AdminGroupsHandler backed by the given
|
||||
// group store.
|
||||
func NewAdminGroupsHandler(store GroupStore) *AdminGroupsHandler {
|
||||
return &AdminGroupsHandler{store: store}
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
// createAdminGroupRequest represents the JSON body for POST /admin/groups.
|
||||
type createAdminGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
LibraryIDs []int `json:"library_ids"` // null/absent = all libraries
|
||||
MaxStreams *int `json:"max_streams"`
|
||||
MaxTranscodes *int `json:"max_transcodes"`
|
||||
MaxProfiles *int `json:"max_profiles"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality"`
|
||||
DownloadAllowed *bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed"`
|
||||
}
|
||||
|
||||
// updateAdminGroupRequest represents the JSON body for PATCH /admin/groups/{id}.
|
||||
// Absent fields are left unchanged.
|
||||
type updateAdminGroupRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Permissions optionalStringSliceField `json:"permissions,omitempty"`
|
||||
LibraryIDs optionalIntSliceField `json:"library_ids,omitempty"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// adminGroupResponse represents a group in admin JSON responses.
|
||||
type adminGroupResponse struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Permissions []string `json:"permissions"`
|
||||
LibraryIDs []int `json:"library_ids"` // null = all libraries
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
MaxProfiles int `json:"max_profiles"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
MemberCount int `json:"member_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type adminGroupListResponse struct {
|
||||
Groups []adminGroupResponse `json:"groups"`
|
||||
}
|
||||
|
||||
type adminGroupMemberRow struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type adminGroupMembersResponse struct {
|
||||
Members []adminGroupMemberRow `json:"members"`
|
||||
Total int `json:"total"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func toAdminGroupResponse(g *models.Group, memberCount int) adminGroupResponse {
|
||||
return adminGroupResponse{
|
||||
ID: g.ID,
|
||||
Slug: g.Slug,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
BuiltIn: g.BuiltIn,
|
||||
Permissions: append([]string{}, g.Permissions...),
|
||||
LibraryIDs: g.LibraryIDs,
|
||||
MaxStreams: g.MaxStreams,
|
||||
MaxTranscodes: g.MaxTranscodes,
|
||||
MaxProfiles: g.MaxProfiles,
|
||||
MaxPlaybackQuality: g.MaxPlaybackQuality,
|
||||
DownloadAllowed: g.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: g.DownloadTranscodeAllowed,
|
||||
MemberCount: memberCount,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// writeGroupError maps group repository sentinel errors to API responses.
|
||||
func writeGroupError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrGroupNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Group not found")
|
||||
case errors.Is(err, auth.ErrUnknownUser):
|
||||
writeError(w, http.StatusNotFound, "unknown_user", "User not found")
|
||||
case errors.Is(err, auth.ErrBuiltInGroup):
|
||||
writeError(w, http.StatusConflict, "built_in_group", "Built-in groups cannot be deleted")
|
||||
case errors.Is(err, auth.ErrAdminPermRequired):
|
||||
writeError(w, http.StatusConflict, "admin_permission_required", "The administrators group must keep the admin permission")
|
||||
case errors.Is(err, auth.ErrLastAdministrator):
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
case errors.Is(err, auth.ErrDuplicate):
|
||||
writeError(w, http.StatusConflict, "duplicate", "A group with that name already exists")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Group operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func parseGroupIDParam(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, name))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+name)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// --- Handler methods ---
|
||||
|
||||
// HandleList handles GET /admin/groups.
|
||||
func (h *AdminGroupsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := h.store.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list groups")
|
||||
return
|
||||
}
|
||||
resp := adminGroupListResponse{Groups: make([]adminGroupResponse, 0, len(groups))}
|
||||
for i := range groups {
|
||||
resp.Groups = append(resp.Groups, toAdminGroupResponse(&groups[i].Group, groups[i].MemberCount))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleCreate handles POST /admin/groups.
|
||||
func (h *AdminGroupsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req createAdminGroupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Group name is required")
|
||||
return
|
||||
}
|
||||
permissions, err := auth.NormalizePermissions(req.Permissions)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.store.Create(r.Context(), models.CreateGroupInput{
|
||||
Name: name,
|
||||
Description: req.Description,
|
||||
Permissions: permissions,
|
||||
LibraryIDs: req.LibraryIDs,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
MaxPlaybackQuality: req.MaxPlaybackQuality,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
})
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAdminGroupResponse(group, 0))
|
||||
}
|
||||
|
||||
// HandleGet handles GET /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
group, err := h.store.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
memberCount, err := h.store.MemberCount(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
|
||||
}
|
||||
|
||||
// HandleUpdate handles PATCH /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateAdminGroupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Group name cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
input := models.UpdateGroupInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
LibraryIDs: req.LibraryIDs.Ptr(),
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
MaxPlaybackQuality: req.MaxPlaybackQuality,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
}
|
||||
if req.Permissions.Set {
|
||||
permissions, err := auth.NormalizePermissions(req.Permissions.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
input.Permissions = &permissions
|
||||
}
|
||||
|
||||
group, err := h.store.Update(r.Context(), id, input)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
memberCount, err := h.store.MemberCount(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
|
||||
}
|
||||
|
||||
// HandleDelete handles DELETE /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(r.Context(), id); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleListMembers handles GET /admin/groups/{id}/members.
|
||||
func (h *AdminGroupsHandler) HandleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.GetByID(r.Context(), id); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
offset := 0
|
||||
if v := q.Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
limit := 50
|
||||
if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
members, total, err := h.store.ListMembers(r.Context(), id, offset, limit)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
resp := adminGroupMembersResponse{
|
||||
Members: make([]adminGroupMemberRow, 0, len(members)),
|
||||
Total: total,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
}
|
||||
for _, m := range members {
|
||||
resp.Members = append(resp.Members, adminGroupMemberRow{
|
||||
UserID: m.UserID,
|
||||
Username: m.Username,
|
||||
Email: m.Email,
|
||||
Enabled: m.Enabled,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleAddMember handles PUT /admin/groups/{id}/members/{userID}.
|
||||
func (h *AdminGroupsHandler) HandleAddMember(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := parseGroupIDParam(w, r, "userID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.AddMember(r.Context(), groupID, userID); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleRemoveMember handles DELETE /admin/groups/{id}/members/{userID}.
|
||||
func (h *AdminGroupsHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := parseGroupIDParam(w, r, "userID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.RemoveMember(r.Context(), groupID, userID); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeGroupStore is a configurable in-memory GroupStore that records the
|
||||
// inputs handlers pass to it.
|
||||
type fakeGroupStore struct {
|
||||
listGroups []auth.GroupWithMemberCount
|
||||
listErr error
|
||||
getGroup *models.Group
|
||||
getErr error
|
||||
memberCount int
|
||||
countErr error
|
||||
|
||||
createInput *models.CreateGroupInput
|
||||
createGroup *models.Group
|
||||
createErr error
|
||||
|
||||
updateID int
|
||||
updateInput *models.UpdateGroupInput
|
||||
updateGroup *models.Group
|
||||
updateErr error
|
||||
|
||||
deleteID int
|
||||
deleteErr error
|
||||
|
||||
membersGroupID int
|
||||
membersOffset int
|
||||
membersLimit int
|
||||
members []auth.GroupMember
|
||||
membersTotal int
|
||||
membersErr error
|
||||
|
||||
addGroupID int
|
||||
addUserID int
|
||||
addErr error
|
||||
removeGroupID int
|
||||
removeUserID int
|
||||
removeErr error
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) List(context.Context) ([]auth.GroupWithMemberCount, error) {
|
||||
return f.listGroups, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) GetByID(_ context.Context, id int) (*models.Group, error) {
|
||||
return f.getGroup, f.getErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) MemberCount(_ context.Context, groupID int) (int, error) {
|
||||
return f.memberCount, f.countErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Create(_ context.Context, input models.CreateGroupInput) (*models.Group, error) {
|
||||
f.createInput = &input
|
||||
return f.createGroup, f.createErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Update(_ context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
|
||||
f.updateID = id
|
||||
f.updateInput = &input
|
||||
return f.updateGroup, f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Delete(_ context.Context, id int) error {
|
||||
f.deleteID = id
|
||||
return f.deleteErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) ListMembers(_ context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error) {
|
||||
f.membersGroupID = groupID
|
||||
f.membersOffset = offset
|
||||
f.membersLimit = limit
|
||||
return f.members, f.membersTotal, f.membersErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) AddMember(_ context.Context, groupID, userID int) error {
|
||||
f.addGroupID = groupID
|
||||
f.addUserID = userID
|
||||
return f.addErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) RemoveMember(_ context.Context, groupID, userID int) error {
|
||||
f.removeGroupID = groupID
|
||||
f.removeUserID = userID
|
||||
return f.removeErr
|
||||
}
|
||||
|
||||
// newGroupsTestRouter mounts the handler routes the same way the API router
|
||||
// does so tests exercise real chi URL params.
|
||||
func newGroupsTestRouter(store *fakeGroupStore) chi.Router {
|
||||
h := NewAdminGroupsHandler(store)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/groups", h.HandleList)
|
||||
r.Post("/groups", h.HandleCreate)
|
||||
r.Get("/groups/{id}", h.HandleGet)
|
||||
r.Patch("/groups/{id}", h.HandleUpdate)
|
||||
r.Delete("/groups/{id}", h.HandleDelete)
|
||||
r.Get("/groups/{id}/members", h.HandleListMembers)
|
||||
r.Put("/groups/{id}/members/{userID}", h.HandleAddMember)
|
||||
r.Delete("/groups/{id}/members/{userID}", h.HandleRemoveMember)
|
||||
return r
|
||||
}
|
||||
|
||||
func doGroupsRequest(t *testing.T, router chi.Router, method, target string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshaling request body: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(payload)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, reader)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeGroupsJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decoding response %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertGroupsError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("status = %d, want %d (body %q)", rec.Code, status, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["error"] != code {
|
||||
t.Fatalf("error code = %v, want %q (body %q)", body["error"], code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func testGroup() *models.Group {
|
||||
return &models.Group{
|
||||
ID: 7,
|
||||
Slug: "film-club",
|
||||
Name: "Film Club",
|
||||
Description: "Movie nights",
|
||||
Permissions: []string{"marker_edit"},
|
||||
LibraryIDs: nil,
|
||||
MaxStreams: 2,
|
||||
MaxTranscodes: 1,
|
||||
MaxProfiles: 4,
|
||||
MaxPlaybackQuality: "1080p",
|
||||
CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsList(t *testing.T) {
|
||||
store := &fakeGroupStore{
|
||||
listGroups: []auth.GroupWithMemberCount{
|
||||
{Group: models.Group{ID: 1, Slug: "administrators", Name: "Administrators", BuiltIn: true, Permissions: []string{"admin"}}, MemberCount: 1},
|
||||
{Group: models.Group{ID: 2, Slug: "users", Name: "Users", BuiltIn: true, LibraryIDs: []int{3}}, MemberCount: 12},
|
||||
},
|
||||
}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
groups, ok := body["groups"].([]any)
|
||||
if !ok || len(groups) != 2 {
|
||||
t.Fatalf("groups = %v, want 2 entries", body["groups"])
|
||||
}
|
||||
first := groups[0].(map[string]any)
|
||||
if first["member_count"] != float64(1) {
|
||||
t.Fatalf("member_count = %v, want 1", first["member_count"])
|
||||
}
|
||||
if first["library_ids"] != nil {
|
||||
t.Fatalf("library_ids = %v, want null for all-libraries", first["library_ids"])
|
||||
}
|
||||
second := groups[1].(map[string]any)
|
||||
if second["member_count"] != float64(12) {
|
||||
t.Fatalf("member_count = %v, want 12", second["member_count"])
|
||||
}
|
||||
if ids, ok := second["library_ids"].([]any); !ok || len(ids) != 1 || ids[0] != float64(3) {
|
||||
t.Fatalf("library_ids = %v, want [3]", second["library_ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreate(t *testing.T) {
|
||||
store := &fakeGroupStore{createGroup: testGroup()}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": " Film Club ",
|
||||
"description": "Movie nights",
|
||||
"permissions": []string{"marker_edit"},
|
||||
"library_ids": []int{1, 2},
|
||||
"max_streams": 2,
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.createInput == nil {
|
||||
t.Fatal("store.Create was not called")
|
||||
}
|
||||
if store.createInput.Name != "Film Club" {
|
||||
t.Fatalf("Name = %q, want trimmed name", store.createInput.Name)
|
||||
}
|
||||
if len(store.createInput.LibraryIDs) != 2 {
|
||||
t.Fatalf("LibraryIDs = %v, want [1 2]", store.createInput.LibraryIDs)
|
||||
}
|
||||
if store.createInput.MaxStreams == nil || *store.createInput.MaxStreams != 2 {
|
||||
t.Fatalf("MaxStreams = %v, want 2", store.createInput.MaxStreams)
|
||||
}
|
||||
if store.createInput.MaxTranscodes != nil {
|
||||
t.Fatalf("MaxTranscodes = %v, want nil (omitted)", store.createInput.MaxTranscodes)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["slug"] != "film-club" {
|
||||
t.Fatalf("slug = %v, want film-club", body["slug"])
|
||||
}
|
||||
if body["member_count"] != float64(0) {
|
||||
t.Fatalf("member_count = %v, want 0 for new group", body["member_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateEmptyName(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": " ",
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.createInput != nil {
|
||||
t.Fatal("store.Create should not be called for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateUnknownPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": "Film Club",
|
||||
"permissions": []string{"server_owner"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.createInput != nil {
|
||||
t.Fatal("store.Create should not be called for unknown permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateDuplicate(t *testing.T) {
|
||||
store := &fakeGroupStore{createErr: auth.ErrDuplicate}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": "Film Club",
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "duplicate")
|
||||
}
|
||||
|
||||
func TestAdminGroupsGet(t *testing.T) {
|
||||
store := &fakeGroupStore{getGroup: testGroup(), memberCount: 5}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["id"] != float64(7) {
|
||||
t.Fatalf("id = %v, want 7", body["id"])
|
||||
}
|
||||
if body["member_count"] != float64(5) {
|
||||
t.Fatalf("member_count = %v, want 5", body["member_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsGetNotFound(t *testing.T) {
|
||||
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
func TestAdminGroupsGetInvalidID(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/abc", nil)
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchStripAdminPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{updateErr: auth.ErrAdminPermRequired}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/1", map[string]any{
|
||||
"permissions": []string{"marker_edit"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "admin_permission_required")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchUnknownPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/7", map[string]any{
|
||||
"permissions": []string{"server_owner"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.updateInput != nil {
|
||||
t.Fatal("store.Update should not be called for unknown permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchLibraryIDsSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want func(t *testing.T, input *models.UpdateGroupInput)
|
||||
}{
|
||||
{
|
||||
name: "absent means no change",
|
||||
body: `{"name":"Renamed"}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs != nil {
|
||||
t.Fatalf("LibraryIDs = %v, want nil (no change)", input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null means all libraries",
|
||||
body: `{"library_ids":null}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to nil slice (all libraries)")
|
||||
}
|
||||
if *input.LibraryIDs != nil {
|
||||
t.Fatalf("*LibraryIDs = %v, want nil slice (all libraries)", *input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty array means no libraries",
|
||||
body: `{"library_ids":[]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to empty slice (no libraries)")
|
||||
}
|
||||
if *input.LibraryIDs == nil || len(*input.LibraryIDs) != 0 {
|
||||
t.Fatalf("*LibraryIDs = %v, want non-nil empty slice", *input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit ids",
|
||||
body: `{"library_ids":[1,2]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to [1 2]")
|
||||
}
|
||||
ids := *input.LibraryIDs
|
||||
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
|
||||
t.Fatalf("*LibraryIDs = %v, want [1 2]", ids)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := &fakeGroupStore{updateGroup: testGroup()}
|
||||
router := newGroupsTestRouter(store)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.updateInput == nil {
|
||||
t.Fatal("store.Update was not called")
|
||||
}
|
||||
tt.want(t, store.updateInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsDeleteBuiltIn(t *testing.T) {
|
||||
store := &fakeGroupStore{deleteErr: auth.ErrBuiltInGroup}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "built_in_group")
|
||||
}
|
||||
|
||||
func TestAdminGroupsDelete(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.deleteID != 7 {
|
||||
t.Fatalf("deleted id = %d, want 7", store.deleteID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersPagination(t *testing.T) {
|
||||
store := &fakeGroupStore{
|
||||
members: []auth.GroupMember{
|
||||
{UserID: 3, Username: "alice", Email: "alice@example.com", Enabled: true},
|
||||
},
|
||||
membersTotal: 42,
|
||||
}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members?offset=10&limit=5", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.membersGroupID != 7 || store.membersOffset != 10 || store.membersLimit != 5 {
|
||||
t.Fatalf("ListMembers(%d, %d, %d), want (7, 10, 5)",
|
||||
store.membersGroupID, store.membersOffset, store.membersLimit)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["total"] != float64(42) || body["offset"] != float64(10) || body["limit"] != float64(5) {
|
||||
t.Fatalf("pagination = total %v offset %v limit %v, want 42/10/5",
|
||||
body["total"], body["offset"], body["limit"])
|
||||
}
|
||||
members, ok := body["members"].([]any)
|
||||
if !ok || len(members) != 1 {
|
||||
t.Fatalf("members = %v, want 1 entry", body["members"])
|
||||
}
|
||||
member := members[0].(map[string]any)
|
||||
if member["user_id"] != float64(3) || member["username"] != "alice" ||
|
||||
member["email"] != "alice@example.com" || member["enabled"] != true {
|
||||
t.Fatalf("member = %v, want alice row", member)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersDefaults(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.membersOffset != 0 || store.membersLimit != 50 {
|
||||
t.Fatalf("ListMembers offset %d limit %d, want defaults 0/50",
|
||||
store.membersOffset, store.membersLimit)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if members, ok := body["members"].([]any); !ok || len(members) != 0 {
|
||||
t.Fatalf("members = %v, want empty array (not null)", body["members"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsAddMember(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/12", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.addGroupID != 7 || store.addUserID != 12 {
|
||||
t.Fatalf("AddMember(%d, %d), want (7, 12)", store.addGroupID, store.addUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsRemoveLastAdministrator(t *testing.T) {
|
||||
store := &fakeGroupStore{removeErr: auth.ErrLastAdministrator}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1/members/12", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestAdminGroupsRemoveMember(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7/members/12", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.removeGroupID != 7 || store.removeUserID != 12 {
|
||||
t.Fatalf("RemoveMember(%d, %d), want (7, 12)", store.removeGroupID, store.removeUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsAddMemberUnknownUser(t *testing.T) {
|
||||
store := &fakeGroupStore{addErr: auth.ErrUnknownUser}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/999", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "unknown_user")
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersNotFound(t *testing.T) {
|
||||
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999/members", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchPermissionsSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want func(t *testing.T, input *models.UpdateGroupInput)
|
||||
}{
|
||||
{
|
||||
name: "explicit permissions reach store as pointer to slice",
|
||||
body: `{"permissions":["marker_edit"]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions == nil {
|
||||
t.Fatal("Permissions = nil, want pointer to [\"marker_edit\"]")
|
||||
}
|
||||
perms := *input.Permissions
|
||||
if len(perms) != 1 || perms[0] != "marker_edit" {
|
||||
t.Fatalf("*Permissions = %v, want [\"marker_edit\"]", perms)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null permissions reaches store as pointer to empty slice",
|
||||
body: `{"permissions":null}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions == nil {
|
||||
t.Fatal("Permissions = nil, want pointer to empty slice")
|
||||
}
|
||||
if len(*input.Permissions) != 0 {
|
||||
t.Fatalf("*Permissions = %v, want empty slice", *input.Permissions)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "absent permissions reaches store as nil (no change)",
|
||||
body: `{"name":"Renamed"}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions != nil {
|
||||
t.Fatalf("Permissions = %v, want nil (no change)", input.Permissions)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := &fakeGroupStore{updateGroup: testGroup()}
|
||||
router := newGroupsTestRouter(store)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.updateInput == nil {
|
||||
t.Fatal("store.Update was not called")
|
||||
}
|
||||
tt.want(t, store.updateInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ type AdminJobsHandler struct {
|
||||
store AdminJobArtifactStore
|
||||
CancelRegistry *adminjob.CancelRegistry
|
||||
RealtimeHub *notifications.Hub
|
||||
Users auth.UserLoader // for server-side admin checks on non-admin-gated routes
|
||||
}
|
||||
|
||||
func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler {
|
||||
@@ -123,14 +122,13 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
viewerUserID := apimw.GetUserID(r.Context())
|
||||
viewerIsAdmin := isAdminRequest(r, h.Users)
|
||||
if !canReadAdminJob(viewerUserID, viewerIsAdmin, job) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if !canReadAdminJob(claims, job) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForViewer(r, job, h.store, viewerIsAdmin))
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
|
||||
}
|
||||
|
||||
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -266,19 +264,19 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
|
||||
return resp
|
||||
}
|
||||
|
||||
func adminJobToResponseForViewer(
|
||||
func adminJobToResponseForClaims(
|
||||
r *http.Request,
|
||||
job *models.AdminJob,
|
||||
store AdminJobArtifactStore,
|
||||
viewerIsAdmin bool,
|
||||
claims *auth.Claims,
|
||||
) adminJobResponse {
|
||||
response := adminJobToResponse(r, job, store)
|
||||
sanitizeAdminJobResponseForViewer(&response, viewerIsAdmin)
|
||||
sanitizeAdminJobResponseForClaims(&response, claims)
|
||||
return response
|
||||
}
|
||||
|
||||
func sanitizeAdminJobResponseForViewer(response *adminJobResponse, viewerIsAdmin bool) {
|
||||
if response == nil || viewerIsAdmin {
|
||||
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
|
||||
if response == nil || (claims != nil && claims.Role == "admin") {
|
||||
return
|
||||
}
|
||||
response.RequestPayload = json.RawMessage(`{}`)
|
||||
@@ -386,12 +384,12 @@ func currentAdminUserID(r *http.Request) int {
|
||||
return claims.UserID
|
||||
}
|
||||
|
||||
func canReadAdminJob(viewerUserID int, viewerIsAdmin bool, job *models.AdminJob) bool {
|
||||
if viewerUserID == 0 || job == nil {
|
||||
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
|
||||
if claims == nil || job == nil {
|
||||
return false
|
||||
}
|
||||
if viewerIsAdmin {
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == viewerUserID
|
||||
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
|
||||
}
|
||||
|
||||
@@ -13,40 +13,46 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 1, Role: "admin"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
|
||||
if !canReadAdminJob(1, true, job) {
|
||||
if !canReadAdminJob(claims, job) {
|
||||
t.Fatal("admin should be allowed to read any job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
|
||||
if !canReadAdminJob(2, false, job) {
|
||||
if !canReadAdminJob(claims, job) {
|
||||
t.Fatal("creator should be allowed to read own item refresh job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
|
||||
if canReadAdminJob(2, false, job) {
|
||||
if canReadAdminJob(claims, job) {
|
||||
t.Fatal("non-admin should not read non-item-refresh jobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 3, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
|
||||
if canReadAdminJob(3, false, job) {
|
||||
if canReadAdminJob(claims, job) {
|
||||
t.Fatal("non-admin should not read another user's item refresh job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
|
||||
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{
|
||||
JobType: adminjob.JobTypeItemRefresh,
|
||||
CreatedByUserID: 2,
|
||||
@@ -60,7 +66,7 @@ func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *tes
|
||||
PublicURL: "https://example.test/public",
|
||||
}
|
||||
|
||||
resp := adminJobToResponseForViewer(nil, job, nil, false)
|
||||
resp := adminJobToResponseForClaims(nil, job, nil, claims)
|
||||
|
||||
if string(resp.RequestPayload) != `{}` {
|
||||
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
)
|
||||
@@ -120,11 +122,7 @@ func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
existing, ok := h.Config.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Unknown marker provider")
|
||||
@@ -181,11 +179,7 @@ func (h *AdminMarkerProvidersHandler) HandleValidateProvider(w http.ResponseWrit
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
var submitter markers.Submitter
|
||||
for _, p := range h.Registry.Providers() {
|
||||
if p.ID() == provider {
|
||||
|
||||
@@ -49,34 +49,6 @@ func TestValidateMarkerProviderUsesSnakeCaseStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakePluginMarkerSubmitter struct{ fakeMarkerStatsSubmitter }
|
||||
|
||||
func (fakePluginMarkerSubmitter) ID() string { return "plugin:6:introdb" }
|
||||
|
||||
// Browsers send encodeURIComponent-escaped provider IDs (plugin%3A6%3Aintrodb),
|
||||
// and chi matches the raw path, so the handler must decode the route param.
|
||||
func TestValidateMarkerProviderDecodesEncodedID(t *testing.T) {
|
||||
reg := markers.NewRegistry(nil)
|
||||
if err := reg.Register(fakePluginMarkerSubmitter{}); err != nil {
|
||||
t.Fatalf("register provider: %v", err)
|
||||
}
|
||||
h := NewAdminMarkerProvidersHandler(reg, nil, nil, nil)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/admin/markers/providers/{provider}/validate", h.HandleValidateProvider)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/markers/providers/plugin%3A6%3Aintrodb/validate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"valid":true`) {
|
||||
t.Fatalf("unexpected response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerProviderResponseIncludesPluginMetadata(t *testing.T) {
|
||||
resp := toProviderConfigResponse(
|
||||
markers.ProviderConfig{Provider: "plugin:4:markers", FetchEnabled: true, FetchPriority: 25},
|
||||
|
||||
@@ -111,7 +111,7 @@ func (h *AdminPlaybackControlHandler) HandleMessageSession(w http.ResponseWriter
|
||||
return
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
|
||||
result := h.playback.CommandDispatcher.DispatchToSession(command, 0, nil)
|
||||
if result.DispatchErr != nil {
|
||||
@@ -173,7 +173,7 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
|
||||
return
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
deadline := boundedPlaybackControlDeadline(req.DeadlineMS)
|
||||
command.DeadlineMS = int(deadline / time.Millisecond)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -56,11 +57,7 @@ func (h *AdminSubtitleHandler) HandleListProviders(w http.ResponseWriter, r *htt
|
||||
|
||||
// HandleUpdateProvider handles PUT /api/v1/admin/subtitle-providers/{provider}
|
||||
func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerName, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return
|
||||
}
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
|
||||
var req updateSubtitleProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -86,11 +83,7 @@ func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *ht
|
||||
|
||||
// HandleTestProvider handles POST /api/v1/admin/subtitle-providers/{provider}/test
|
||||
func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerName, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return
|
||||
}
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
|
||||
cfg, err := h.repo.GetProviderConfig(r.Context(), providerName)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,82 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeAdminUserRepo is a configurable in-memory UserRepository for handler
|
||||
// tests.
|
||||
type fakeAdminUserRepo struct {
|
||||
user *models.User
|
||||
updateErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) List(context.Context) ([]*models.User, error) { return nil, nil }
|
||||
|
||||
func (f *fakeAdminUserRepo) Create(context.Context, models.CreateUserInput) (*models.User, error) {
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) Update(context.Context, int, models.UpdateUserInput) error {
|
||||
return f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) Delete(context.Context, int) error { return f.deleteErr }
|
||||
|
||||
func (f *fakeAdminUserRepo) GetByID(context.Context, int) (*models.User, error) {
|
||||
if f.user == nil {
|
||||
return nil, auth.ErrNotFound
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
// newAdminUsersTestRouter mounts the user-management routes the same way the
|
||||
// API router does so tests exercise real chi URL params.
|
||||
func newAdminUsersTestRouter(repo UserRepository) chi.Router {
|
||||
h := NewAdminHandler(repo, nil, nil)
|
||||
r := chi.NewRouter()
|
||||
r.Put("/admin/users/{id}", h.HandleUpdateUser)
|
||||
r.Delete("/admin/users/{id}", h.HandleDeleteUser)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminUpdateUserDisableLastAdministrator(t *testing.T) {
|
||||
repo := &fakeAdminUserRepo{
|
||||
user: &models.User{ID: 2, Enabled: true},
|
||||
updateErr: auth.ErrLastAdministrator,
|
||||
}
|
||||
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodPut,
|
||||
"/admin/users/2", map[string]any{"enabled": false})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestAdminDeleteUserLastAdministrator(t *testing.T) {
|
||||
repo := &fakeAdminUserRepo{deleteErr: auth.ErrLastAdministrator}
|
||||
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodDelete,
|
||||
"/admin/users/2", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
role := "admin"
|
||||
sameRole := "user"
|
||||
enabled := true
|
||||
disabled := false
|
||||
libraryIDs := []int{1, 2}
|
||||
sameLibraryIDs := []int{1}
|
||||
emptyLibraryIDs := []int{}
|
||||
var allLibraryIDs []int
|
||||
maxPlaybackQuality := "1080p"
|
||||
sameMaxPlaybackQuality := "original"
|
||||
password := "new-password"
|
||||
username := "renamed"
|
||||
newGroups := []int{1, 2}
|
||||
sameGroups := []int{2, 1, 1} // order and duplicates must not matter
|
||||
emptyGroups := []int{}
|
||||
maxStreams := 4
|
||||
permissions := []string{"metadata_curation"}
|
||||
samePermissions := []string{"download"}
|
||||
|
||||
current := &models.User{
|
||||
Enabled: false,
|
||||
GroupIDs: []int{1, 2},
|
||||
Role: "user",
|
||||
Permissions: []string{"download"},
|
||||
Enabled: false,
|
||||
LibraryIDs: []int{1},
|
||||
MaxPlaybackQuality: "original",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -84,6 +36,26 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
in models.UpdateUserInput
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "permissions set",
|
||||
in: models.UpdateUserInput{Permissions: &permissions},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "permissions unchanged",
|
||||
in: models.UpdateUserInput{Permissions: &samePermissions},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
in: models.UpdateUserInput{Role: &role},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "role unchanged",
|
||||
in: models.UpdateUserInput{Role: &sameRole},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "enabled",
|
||||
in: models.UpdateUserInput{Enabled: &enabled},
|
||||
@@ -95,15 +67,30 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "groups unchanged",
|
||||
in: models.UpdateUserInput{GroupIDs: &sameGroups},
|
||||
name: "library ids does not revoke session",
|
||||
in: models.UpdateUserInput{LibraryIDs: &libraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "groups cleared",
|
||||
in: models.UpdateUserInput{GroupIDs: &emptyGroups},
|
||||
name: "library ids unchanged",
|
||||
in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "library ids nil does not revoke session",
|
||||
in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "max playback quality",
|
||||
in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "max playback quality unchanged",
|
||||
in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
in: models.UpdateUserInput{Password: &password},
|
||||
@@ -111,7 +98,7 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "non access fields",
|
||||
in: models.UpdateUserInput{Username: &username},
|
||||
in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
@@ -129,18 +116,16 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
memberless := &models.User{Enabled: true, GroupIDs: nil}
|
||||
t.Run("groups assigned to memberless user", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(memberless, models.UpdateUserInput{GroupIDs: &newGroups}); !got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
|
||||
unrestrictedCurrent := *current
|
||||
unrestrictedCurrent.LibraryIDs = nil
|
||||
t.Run("library ids empty does not revoke session", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown current user falls back to may-require", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{GroupIDs: &sameGroups}); !got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
|
||||
}
|
||||
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{Username: &username}); got {
|
||||
t.Run("library ids nil unchanged", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
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/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
const audioPlaybackSessionTTL = 12 * time.Hour
|
||||
|
||||
type AudioDetailService interface {
|
||||
GetItemDetail(ctx context.Context, contentID string, filter catalog.AccessFilter) (*catalog.ItemDetail, error)
|
||||
}
|
||||
|
||||
type AudioFileResolver interface {
|
||||
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
|
||||
}
|
||||
|
||||
type AudioHandler struct {
|
||||
detailSvc AudioDetailService
|
||||
files AudioFileResolver
|
||||
storeProvider userstore.UserStoreProvider
|
||||
bookmarkStore *PGAudioBookmarkStore
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]audioPlaybackSession
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAudioHandler(
|
||||
detailSvc AudioDetailService,
|
||||
files AudioFileResolver,
|
||||
storeProvider userstore.UserStoreProvider,
|
||||
bookmarkStore *PGAudioBookmarkStore,
|
||||
) *AudioHandler {
|
||||
return &AudioHandler{
|
||||
detailSvc: detailSvc,
|
||||
files: files,
|
||||
storeProvider: storeProvider,
|
||||
bookmarkStore: bookmarkStore,
|
||||
sessions: make(map[string]audioPlaybackSession),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
type audioPlaybackSession struct {
|
||||
ID string
|
||||
UserID int
|
||||
ProfileID string
|
||||
ContentID string
|
||||
TotalSeconds float64
|
||||
Files []audioSessionFile
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type audioSessionFile struct {
|
||||
FileID int
|
||||
FilePath string
|
||||
Start float64
|
||||
Duration float64
|
||||
Mode string
|
||||
TrackIdx int
|
||||
}
|
||||
|
||||
type audioStartPlaybackRequest struct {
|
||||
ContentID string `json:"content_id"`
|
||||
StartPosition *float64 `json:"start_position,omitempty"`
|
||||
Restart bool `json:"restart,omitempty"`
|
||||
}
|
||||
|
||||
type audioProgressRequest struct {
|
||||
Position float64 `json:"position"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
}
|
||||
|
||||
type audioPlaybackResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ContentID string `json:"content_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
TotalDurationSeconds float64 `json:"total_duration_seconds"`
|
||||
ResumePositionSeconds float64 `json:"resume_position_seconds"`
|
||||
Tracks []audioTrackResponse `json:"tracks"`
|
||||
Chapters []audioChapter `json:"chapters"`
|
||||
Bookmarks []audioBookmark `json:"bookmarks"`
|
||||
}
|
||||
|
||||
type audioTrackResponse struct {
|
||||
Index int `json:"index"`
|
||||
FileID int `json:"file_id"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
StartOffsetSeconds float64 `json:"start_offset_seconds"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
StreamType string `json:"stream_type"`
|
||||
PlayMethod string `json:"play_method"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
Container string `json:"container,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
Chapters []audioChapter `json:"chapters"`
|
||||
}
|
||||
|
||||
type audioChapter struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title,omitempty"`
|
||||
StartSeconds float64 `json:"start_seconds"`
|
||||
EndSeconds float64 `json:"end_seconds,omitempty"`
|
||||
TrackIndex int `json:"track_index,omitempty"`
|
||||
}
|
||||
|
||||
type audioBookmark struct {
|
||||
ID string `json:"id"`
|
||||
ContentID string `json:"content_id"`
|
||||
TimeSeconds float64 `json:"time_seconds"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type audioBookmarkRequest struct {
|
||||
TimeSeconds *float64 `json:"time_seconds"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.detailSvc == nil || h.files == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Audio playback is not configured")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 || profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Profile is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req audioStartPlaybackRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
req.ContentID = strings.TrimSpace(req.ContentID)
|
||||
if req.ContentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
filter := requestAccessFilter(r)
|
||||
detail, err := h.detailSvc.GetItemDetail(r.Context(), req.ContentID, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item detail")
|
||||
return
|
||||
}
|
||||
if detail == nil || detail.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.files.GetByContentID(r.Context(), req.ContentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load audio files")
|
||||
return
|
||||
}
|
||||
files = catalog.FilterMediaFilesByAccess(files, filter)
|
||||
sortAudioFiles(files)
|
||||
if len(files) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No playable audio files found")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := ulid.Make().String()
|
||||
tracks, sessionFiles, chapters, totalDuration := buildAudioTrackResponses(sessionID, files)
|
||||
if detail.Audiobook != nil && detail.Audiobook.TotalDurationSeconds > 0 {
|
||||
totalDuration = float64(detail.Audiobook.TotalDurationSeconds)
|
||||
}
|
||||
|
||||
resume := resolveAudioResumePosition(r.Context(), h.storeProvider, userID, profileID, req.ContentID)
|
||||
if req.Restart {
|
||||
resume = 0
|
||||
} else if req.StartPosition != nil && isFiniteNonNegative(*req.StartPosition) {
|
||||
resume = clampFloat(*req.StartPosition, 0, totalDuration)
|
||||
}
|
||||
|
||||
bookmarks, _ := h.listBookmarks(r.Context(), userID, profileID, req.ContentID)
|
||||
session := audioPlaybackSession{
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: req.ContentID,
|
||||
TotalSeconds: totalDuration,
|
||||
Files: sessionFiles,
|
||||
CreatedAt: h.now(),
|
||||
LastSeenAt: h.now(),
|
||||
ExpiresAt: h.now().Add(audioPlaybackSessionTTL),
|
||||
}
|
||||
h.putSession(session)
|
||||
|
||||
writeJSON(w, http.StatusOK, audioPlaybackResponse{
|
||||
SessionID: sessionID,
|
||||
ContentID: detail.ContentID,
|
||||
Type: detail.Type,
|
||||
Title: detail.Title,
|
||||
Subtitle: audioSubtitle(detail),
|
||||
PosterURL: detail.PosterURL,
|
||||
BackdropURL: detail.BackdropURL,
|
||||
TotalDurationSeconds: totalDuration,
|
||||
ResumePositionSeconds: resume,
|
||||
Tracks: tracks,
|
||||
Chapters: chapters,
|
||||
Bookmarks: bookmarks,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleSyncPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req audioProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = session.TotalSeconds
|
||||
}
|
||||
position := clampFloat(req.Position, 0, duration)
|
||||
if err := h.persistProgress(r.Context(), session.UserID, session.ProfileID, session.ContentID, position, duration); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync audio progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"session_id": session.ID,
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
"is_paused": req.IsPaused,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleClosePlayback(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req audioProgressRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if isFiniteNonNegative(req.Position) {
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = session.TotalSeconds
|
||||
}
|
||||
position := clampFloat(req.Position, 0, duration)
|
||||
_ = h.persistProgress(r.Context(), session.UserID, session.ProfileID, session.ContentID, position, duration)
|
||||
}
|
||||
h.deleteSession(session.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleStreamTrack(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rawIndex := chi.URLParam(r, "track_index")
|
||||
index, err := strconv.Atoi(rawIndex)
|
||||
if err != nil || index < 0 || index >= len(session.Files) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid track index")
|
||||
return
|
||||
}
|
||||
file := session.Files[index]
|
||||
mode := r.URL.Query().Get("mode")
|
||||
if mode == "" {
|
||||
mode = file.Mode
|
||||
}
|
||||
if mode == "aac" {
|
||||
seek := 0.0
|
||||
if raw := r.URL.Query().Get("seek"); raw != "" {
|
||||
if parsed, err := strconv.ParseFloat(raw, 64); err == nil && parsed >= 0 {
|
||||
seek = parsed
|
||||
}
|
||||
}
|
||||
if err := playback.ServeRemux(w, r, file.FilePath, "mp4", seek, true, 0); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = playback.ServeDirectPlay(w, r, file.FilePath)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleListBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
bookmarks, err := h.listBookmarks(r.Context(), userID, profileID, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list bookmarks")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"bookmarks": bookmarks})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleCreateBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.bookmarkStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Bookmarks are not configured")
|
||||
return
|
||||
}
|
||||
var req audioBookmarkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TimeSeconds == nil || !isFiniteNonNegative(*req.TimeSeconds) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "time_seconds is required")
|
||||
return
|
||||
}
|
||||
filter := requestAccessFilter(r)
|
||||
detail, err := h.detailSvc.GetItemDetail(r.Context(), contentID, filter)
|
||||
if err != nil || detail == nil || detail.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audiobook not found")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
bookmark, err := h.bookmarkStore.Upsert(r.Context(), userID, profileID, contentID, *req.TimeSeconds, req.Title)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save bookmark")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bookmark)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleDeleteBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.bookmarkStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Bookmarks are not configured")
|
||||
return
|
||||
}
|
||||
rawTime := chi.URLParam(r, "time_seconds")
|
||||
timeSeconds, err := strconv.ParseFloat(rawTime, 64)
|
||||
if err != nil || !isFiniteNonNegative(timeSeconds) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid bookmark time")
|
||||
return
|
||||
}
|
||||
if err := h.bookmarkStore.Delete(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), contentID, timeSeconds); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete bookmark")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) contentIDParam(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return "", false
|
||||
}
|
||||
return contentID, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) sessionForRequest(w http.ResponseWriter, r *http.Request) (audioPlaybackSession, bool) {
|
||||
sessionID := chi.URLParam(r, "session_id")
|
||||
if sessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
session, ok := h.getSession(sessionID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audio playback session not found")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
if session.UserID != apimw.GetUserID(r.Context()) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID != "" && session.ProfileID != profileID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another profile")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) putSession(session audioPlaybackSession) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.pruneExpiredLocked(h.now())
|
||||
h.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (h *AudioHandler) getSession(sessionID string) (audioPlaybackSession, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := h.now()
|
||||
h.pruneExpiredLocked(now)
|
||||
session, ok := h.sessions[sessionID]
|
||||
if !ok {
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(h.sessions, sessionID)
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
session.LastSeenAt = now
|
||||
h.sessions[sessionID] = session
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) deleteSession(sessionID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) pruneExpiredLocked(now time.Time) {
|
||||
for id, session := range h.sessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(h.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AudioHandler) persistProgress(ctx context.Context, userID int, profileID, contentID string, position, duration float64) error {
|
||||
if h.storeProvider == nil {
|
||||
return nil
|
||||
}
|
||||
store, err := h.storeProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.SetProgress(ctx, profileID, contentID, position, duration, userstore.ProgressThresholds{})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) listBookmarks(ctx context.Context, userID int, profileID, contentID string) ([]audioBookmark, error) {
|
||||
if h.bookmarkStore == nil {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
return h.bookmarkStore.List(ctx, userID, profileID, contentID)
|
||||
}
|
||||
|
||||
func buildAudioTrackResponses(sessionID string, files []*models.MediaFile) ([]audioTrackResponse, []audioSessionFile, []audioChapter, float64) {
|
||||
tracks := make([]audioTrackResponse, 0, len(files))
|
||||
sessionFiles := make([]audioSessionFile, 0, len(files))
|
||||
allChapters := []audioChapter{}
|
||||
offset := 0.0
|
||||
for index, file := range files {
|
||||
duration := float64(file.Duration)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
mode := audioDeliveryMode(file)
|
||||
streamURL := fmt.Sprintf("/api/v1/audio/playback/%s/tracks/%d", sessionID, index)
|
||||
if mode == "aac" {
|
||||
streamURL += "?mode=aac"
|
||||
}
|
||||
chapters := audioChaptersForTrack(index, offset, file.Chapters)
|
||||
allChapters = append(allChapters, chapters...)
|
||||
tracks = append(tracks, audioTrackResponse{
|
||||
Index: index,
|
||||
FileID: file.ID,
|
||||
FileName: filepath.Base(file.FilePath),
|
||||
DurationSeconds: duration,
|
||||
StartOffsetSeconds: offset,
|
||||
StreamURL: streamURL,
|
||||
StreamType: "progressive",
|
||||
PlayMethod: mode,
|
||||
Codec: file.CodecAudio,
|
||||
Container: file.Container,
|
||||
Bitrate: file.Bitrate,
|
||||
Chapters: chapters,
|
||||
})
|
||||
sessionFiles = append(sessionFiles, audioSessionFile{
|
||||
FileID: file.ID,
|
||||
FilePath: file.FilePath,
|
||||
Start: offset,
|
||||
Duration: duration,
|
||||
Mode: mode,
|
||||
TrackIdx: index,
|
||||
})
|
||||
offset += duration
|
||||
}
|
||||
return tracks, sessionFiles, allChapters, offset
|
||||
}
|
||||
|
||||
func audioChaptersForTrack(trackIndex int, offset float64, chapters []models.MediaChapter) []audioChapter {
|
||||
out := make([]audioChapter, 0, len(chapters))
|
||||
for _, chapter := range chapters {
|
||||
start := offset + maxFloat(0, chapter.StartSeconds)
|
||||
end := 0.0
|
||||
if chapter.EndSeconds > 0 {
|
||||
end = offset + chapter.EndSeconds
|
||||
}
|
||||
out = append(out, audioChapter{
|
||||
Index: len(out),
|
||||
Title: chapter.Title,
|
||||
StartSeconds: start,
|
||||
EndSeconds: end,
|
||||
TrackIndex: trackIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortAudioFiles(files []*models.MediaFile) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
left, right := files[i], files[j]
|
||||
if left.PresentationPartIndex != right.PresentationPartIndex {
|
||||
if left.PresentationPartIndex == 0 {
|
||||
return false
|
||||
}
|
||||
if right.PresentationPartIndex == 0 {
|
||||
return true
|
||||
}
|
||||
return left.PresentationPartIndex < right.PresentationPartIndex
|
||||
}
|
||||
if left.FilePath != right.FilePath {
|
||||
return left.FilePath < right.FilePath
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
}
|
||||
|
||||
func audioDeliveryMode(file *models.MediaFile) string {
|
||||
if file == nil {
|
||||
return "direct"
|
||||
}
|
||||
codec := strings.ToLower(strings.TrimSpace(file.CodecAudio))
|
||||
container := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(file.Container), "."))
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.FilePath), "."))
|
||||
if container == "" {
|
||||
container = ext
|
||||
}
|
||||
switch codec {
|
||||
case "aac", "mp3", "alac", "flac":
|
||||
return "direct"
|
||||
case "":
|
||||
switch container {
|
||||
case "mp3", "m4a", "m4b", "mp4", "aac", "flac", "wav", "aiff", "aif":
|
||||
return "direct"
|
||||
}
|
||||
}
|
||||
return "aac"
|
||||
}
|
||||
|
||||
func audioSubtitle(detail *catalog.ItemDetail) string {
|
||||
if detail == nil || detail.Audiobook == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if len(detail.Audiobook.Authors) > 0 {
|
||||
names := make([]string, 0, len(detail.Audiobook.Authors))
|
||||
for _, author := range detail.Audiobook.Authors {
|
||||
if strings.TrimSpace(author.Name) != "" {
|
||||
names = append(names, author.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
if len(detail.Audiobook.Narrators) > 0 {
|
||||
names := make([]string, 0, len(detail.Audiobook.Narrators))
|
||||
for _, narrator := range detail.Audiobook.Narrators {
|
||||
if strings.TrimSpace(narrator.Name) != "" {
|
||||
names = append(names, narrator.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, "Narrated by "+strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " • ")
|
||||
}
|
||||
|
||||
func resolveAudioResumePosition(ctx context.Context, provider userstore.UserStoreProvider, userID int, profileID, contentID string) float64 {
|
||||
if provider == nil || userID == 0 || profileID == "" {
|
||||
return 0
|
||||
}
|
||||
store, err := provider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
progress, err := store.GetProgress(ctx, profileID, contentID)
|
||||
if err != nil || progress == nil || progress.Completed {
|
||||
return 0
|
||||
}
|
||||
if progress.PositionSeconds < 30 {
|
||||
return 0
|
||||
}
|
||||
if progress.DurationSeconds > 0 && progress.PositionSeconds >= progress.DurationSeconds-5 {
|
||||
return 0
|
||||
}
|
||||
return progress.PositionSeconds
|
||||
}
|
||||
|
||||
func isFiniteNonNegative(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0
|
||||
}
|
||||
|
||||
func clampFloat(value, lower, upper float64) float64 {
|
||||
if !isFiniteNonNegative(value) {
|
||||
return lower
|
||||
}
|
||||
if value < lower {
|
||||
return lower
|
||||
}
|
||||
if upper > lower && value > upper {
|
||||
return upper
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func maxFloat(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type PGAudioBookmarkStore struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGAudioBookmarkStore(pool *pgxpool.Pool) *PGAudioBookmarkStore {
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
return &PGAudioBookmarkStore{Pool: pool}
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) List(ctx context.Context, userID int, profileID, contentID string) ([]audioBookmark, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, library_item_id, time_seconds, title, created_at, updated_at
|
||||
FROM abs_bookmarks
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND library_item_id = $3
|
||||
ORDER BY time_seconds ASC`,
|
||||
userID, profileArg(profileID), contentID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
bookmarks := []audioBookmark{}
|
||||
for rows.Next() {
|
||||
var createdAt, updatedAt time.Time
|
||||
var bookmark audioBookmark
|
||||
if err := rows.Scan(&bookmark.ID, &bookmark.ContentID, &bookmark.TimeSeconds, &bookmark.Title, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bookmark.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
bookmark.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
||||
bookmarks = append(bookmarks, bookmark)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bookmarks, nil
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) Upsert(ctx context.Context, userID int, profileID, contentID string, timeSeconds float64, title string) (audioBookmark, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return audioBookmark{}, fmt.Errorf("bookmark store unavailable")
|
||||
}
|
||||
id := ulid.Make().String()
|
||||
var createdAt, updatedAt time.Time
|
||||
var bookmark audioBookmark
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO abs_bookmarks
|
||||
(id, user_id, profile_id, library_item_id, time_seconds, title)
|
||||
VALUES ($1, $2, $3::uuid, $4, $5, $6)
|
||||
ON CONFLICT (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
library_item_id,
|
||||
time_seconds
|
||||
) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
updated_at = now()
|
||||
RETURNING id, library_item_id, time_seconds, title, created_at, updated_at`,
|
||||
id, userID, profileArg(profileID), contentID, timeSeconds, strings.TrimSpace(title),
|
||||
).Scan(&bookmark.ID, &bookmark.ContentID, &bookmark.TimeSeconds, &bookmark.Title, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return audioBookmark{}, err
|
||||
}
|
||||
bookmark.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
bookmark.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) Delete(ctx context.Context, userID int, profileID, contentID string, timeSeconds float64) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return fmt.Errorf("bookmark store unavailable")
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM abs_bookmarks
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND library_item_id = $3
|
||||
AND time_seconds = $4`,
|
||||
userID, profileArg(profileID), contentID, timeSeconds,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func profileArg(profileID string) any {
|
||||
if strings.TrimSpace(profileID) == "" {
|
||||
return nil
|
||||
}
|
||||
return profileID
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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"
|
||||
)
|
||||
|
||||
type stubAudioDetailService struct {
|
||||
detail *catalog.ItemDetail
|
||||
}
|
||||
|
||||
func (s stubAudioDetailService) GetItemDetail(context.Context, string, catalog.AccessFilter) (*catalog.ItemDetail, error) {
|
||||
return s.detail, nil
|
||||
}
|
||||
|
||||
type stubAudioFiles struct {
|
||||
files []*models.MediaFile
|
||||
}
|
||||
|
||||
func (s stubAudioFiles) GetByContentID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return append([]*models.MediaFile(nil), s.files...), nil
|
||||
}
|
||||
|
||||
func (s stubAudioFiles) GetByID(_ context.Context, id int) (*models.MediaFile, error) {
|
||||
for _, file := range s.files {
|
||||
if file.ID == id {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestAudioStartPlaybackOrdersTracksAndBuildsGlobalChapters(t *testing.T) {
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{
|
||||
testAudioFile(3, "part-03.mp3", 30, 30),
|
||||
testAudioFile(1, "part-01.mp3", 10, 10),
|
||||
testAudioFile(2, "part-02.mp3", 20, 20),
|
||||
}},
|
||||
testUserStoreProvider{store: newPlaybackTestStore(t)},
|
||||
nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleStartPlayback(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp audioPlaybackResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Tracks) != 3 {
|
||||
t.Fatalf("tracks = %d, want 3", len(resp.Tracks))
|
||||
}
|
||||
if resp.Tracks[0].FileID != 1 || resp.Tracks[1].FileID != 2 || resp.Tracks[2].FileID != 3 {
|
||||
t.Fatalf("track order = [%d %d %d], want [1 2 3]", resp.Tracks[0].FileID, resp.Tracks[1].FileID, resp.Tracks[2].FileID)
|
||||
}
|
||||
if resp.Tracks[1].StartOffsetSeconds != 10 || resp.Tracks[2].StartOffsetSeconds != 30 {
|
||||
t.Fatalf("offsets = %.0f %.0f, want 10 30", resp.Tracks[1].StartOffsetSeconds, resp.Tracks[2].StartOffsetSeconds)
|
||||
}
|
||||
if len(resp.Chapters) != 3 || resp.Chapters[2].StartSeconds != 30 {
|
||||
t.Fatalf("global chapters = %+v, want third chapter at 30s", resp.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioSyncPersistsCumulativeProgressAndEnforcesOwner(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{
|
||||
testAudioFile(1, "part-01.mp3", 10, 10),
|
||||
testAudioFile(2, "part-02.mp3", 20, 20),
|
||||
}},
|
||||
testUserStoreProvider{store: store},
|
||||
nil,
|
||||
)
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
startRec := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRec, startReq)
|
||||
if startRec.Code != http.StatusOK {
|
||||
t.Fatalf("start status = %d body=%s", startRec.Code, startRec.Body.String())
|
||||
}
|
||||
var start audioPlaybackResponse
|
||||
if err := json.Unmarshal(startRec.Body.Bytes(), &start); err != nil {
|
||||
t.Fatalf("decode start: %v", err)
|
||||
}
|
||||
|
||||
otherCtx := apimw.SetClaims(context.Background(), &auth.Claims{UserID: 2, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
otherCtx = apimw.SetProfileID(otherCtx, "profile-1")
|
||||
denied := httptest.NewRequest(http.MethodPatch, "/api/v1/audio/playback/"+start.SessionID+"/sync", strings.NewReader(`{"position":12,"duration":30}`))
|
||||
denied = withAudioRouteParam(denied.WithContext(otherCtx), "session_id", start.SessionID)
|
||||
deniedRec := httptest.NewRecorder()
|
||||
handler.HandleSyncPlayback(deniedRec, denied)
|
||||
if deniedRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign sync status = %d, want 403", deniedRec.Code)
|
||||
}
|
||||
|
||||
syncReq := httptest.NewRequest(http.MethodPatch, "/api/v1/audio/playback/"+start.SessionID+"/sync", strings.NewReader(`{"position":18,"duration":30}`))
|
||||
syncReq = withAudioRouteParam(syncReq.WithContext(newAuthorizedPlaybackContext()), "session_id", start.SessionID)
|
||||
syncRec := httptest.NewRecorder()
|
||||
handler.HandleSyncPlayback(syncRec, syncReq)
|
||||
if syncRec.Code != http.StatusOK {
|
||||
t.Fatalf("sync status = %d body=%s", syncRec.Code, syncRec.Body.String())
|
||||
}
|
||||
|
||||
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get progress: %v", err)
|
||||
}
|
||||
if progress == nil || progress.PositionSeconds != 18 || progress.DurationSeconds != 30 {
|
||||
t.Fatalf("progress = %+v, want 18/30", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioSessionExpires(t *testing.T) {
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{testAudioFile(1, "part-01.mp3", 10, 10)}},
|
||||
testUserStoreProvider{store: newPlaybackTestStore(t)},
|
||||
nil,
|
||||
)
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler.now = func() time.Time { return now }
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
startRec := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRec, startReq)
|
||||
var start audioPlaybackResponse
|
||||
if err := json.Unmarshal(startRec.Body.Bytes(), &start); err != nil {
|
||||
t.Fatalf("decode start: %v", err)
|
||||
}
|
||||
|
||||
now = now.Add(audioPlaybackSessionTTL + time.Second)
|
||||
streamReq := httptest.NewRequest(http.MethodHead, "/api/v1/audio/playback/"+start.SessionID+"/tracks/0", nil)
|
||||
streamReq = withAudioRouteParam(streamReq.WithContext(newAuthorizedPlaybackContext()), "session_id", start.SessionID)
|
||||
streamReq = withAudioRouteParam(streamReq, "track_index", "0")
|
||||
streamRec := httptest.NewRecorder()
|
||||
handler.HandleStreamTrack(streamRec, streamReq)
|
||||
if streamRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expired stream status = %d, want 404", streamRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func testAudiobookDetail() *catalog.ItemDetail {
|
||||
return &catalog.ItemDetail{
|
||||
ContentID: "book-1",
|
||||
Type: "audiobook",
|
||||
Title: "Book One",
|
||||
Audiobook: &catalog.AudiobookDetailExtension{
|
||||
Authors: []catalog.AudiobookPerson{{Name: "Author One"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testAudioFile(id int, name string, partIndex int, duration int) *models.MediaFile {
|
||||
return &models.MediaFile{
|
||||
ID: id,
|
||||
ContentID: "book-1",
|
||||
FilePath: "/media/" + name,
|
||||
CodecAudio: "mp3",
|
||||
Container: "mp3",
|
||||
Duration: duration,
|
||||
PresentationPartIndex: partIndex,
|
||||
Chapters: []models.MediaChapter{{
|
||||
Index: 0,
|
||||
Title: name,
|
||||
StartSeconds: 0,
|
||||
EndSeconds: float64(duration),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func withAudioRouteParam(req *http.Request, key, value string) *http.Request {
|
||||
routeCtx := chi.RouteContext(req.Context())
|
||||
if routeCtx == nil {
|
||||
routeCtx = chi.NewRouteContext()
|
||||
}
|
||||
routeCtx.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
type audiobookGroupResponse struct {
|
||||
Name string `json:"name"`
|
||||
ItemCount int `json:"item_count"`
|
||||
TotalDurationSeconds int64 `json:"total_duration_seconds"`
|
||||
InProgressCount int `json:"in_progress_count"`
|
||||
FinishedCount int `json:"finished_count"`
|
||||
PosterURLs []string `json:"poster_urls"`
|
||||
}
|
||||
|
||||
type audiobookGroupsResponse struct {
|
||||
Total int `json:"total"`
|
||||
Groups []audiobookGroupResponse `json:"groups"`
|
||||
}
|
||||
|
||||
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
|
||||
//
|
||||
// Grouped browse for audiobook libraries: authors, narrators, or series with
|
||||
// aggregate stats (book count, total duration, per-profile progress counts)
|
||||
// and up to four poster URLs for cover stacks. Query parameters:
|
||||
// library_id=<id> (required), group_by=author|narrator|series (required),
|
||||
// sort=name|count|duration (default name), limit, offset.
|
||||
//
|
||||
// Group names round-trip into the corresponding catalog filter fields
|
||||
// (author / narrator / series), which match case-insensitively.
|
||||
func (h *CatalogHandler) HandleGetAudiobookGroups(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
|
||||
}
|
||||
|
||||
libraryID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("library_id")))
|
||||
if err != nil || libraryID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer")
|
||||
return
|
||||
}
|
||||
|
||||
groupBy, ok := catalog.ParseAudiobookGroupBy(r.URL.Query().Get("group_by"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "group_by must be one of author, narrator, series")
|
||||
return
|
||||
}
|
||||
|
||||
sort := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
|
||||
switch sort {
|
||||
case "", "name", "count", "duration":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "sort must be one of name, count, duration")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 200
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
n, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || n <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
|
||||
|
||||
groups, total, err := catalog.ListAudiobookGroups(
|
||||
r.Context(),
|
||||
h.itemsH.browseRepo.Pool(),
|
||||
catalog.AudiobookGroupsQuery{
|
||||
LibraryID: libraryID,
|
||||
GroupBy: groupBy,
|
||||
Sort: sort,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
},
|
||||
h.itemsH.accessFilter(r),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list audiobook groups")
|
||||
return
|
||||
}
|
||||
|
||||
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
|
||||
for _, g := range groups {
|
||||
posterURLs := make([]string, 0, len(g.PosterPaths))
|
||||
for _, path := range g.PosterPaths {
|
||||
if url := h.itemsH.presignURL(r, cardThumbnailPath(path), "card"); url != "" {
|
||||
posterURLs = append(posterURLs, url)
|
||||
}
|
||||
}
|
||||
resp.Groups = append(resp.Groups, audiobookGroupResponse{
|
||||
Name: g.Name,
|
||||
ItemCount: g.ItemCount,
|
||||
TotalDurationSeconds: g.TotalDurationSeconds,
|
||||
InProgressCount: g.InProgressCount,
|
||||
FinishedCount: g.FinishedCount,
|
||||
PosterURLs: posterURLs,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
const ttl = 5 * time.Minute
|
||||
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.SessionID, ttl)
|
||||
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
|
||||
@@ -527,7 +527,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: auth.RoleForUser(user),
|
||||
Role: user.Role,
|
||||
Permissions: auth.EffectivePermissions(user),
|
||||
DownloadAllowed: user.DownloadAllowed,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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"
|
||||
@@ -90,7 +89,6 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
h.itemsH.accessFilter(r),
|
||||
overlaySummaries,
|
||||
store,
|
||||
apimw.GetUserID(r.Context()),
|
||||
profileID,
|
||||
)
|
||||
items := make([]itemListResponse, 0, len(result.Items))
|
||||
|
||||
@@ -161,7 +161,35 @@ func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
|
||||
h.writeEpisodeResponses(w, r, 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) {
|
||||
@@ -411,7 +439,34 @@ func (h *CatalogResourceHandler) syntheticSeasonDetail(r *http.Request, seasonID
|
||||
}
|
||||
|
||||
func (h *CatalogResourceHandler) writeEpisodeResponses(w http.ResponseWriter, r *http.Request, episodes []*models.Episode) {
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: h.items.buildEpisodeResponses(r, episodes)})
|
||||
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) {
|
||||
@@ -442,8 +497,8 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
|
||||
}
|
||||
}
|
||||
case "movie", "episode", "audiobook", "ebook":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
case "movie", "episode", "audiobook":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// Ebook read state lives exclusively in ebook_reader_progress: every consumer
|
||||
// (user-state Played, catalog watched/in_progress filters, Continue Reading,
|
||||
// sort metrics, recommendations) keys off progress crossing
|
||||
// models.EbookFinishedProgressThreshold. The native watched endpoints therefore
|
||||
// write that table instead of user_watch_progress/user_watch_history. The
|
||||
// helpers live in this package — next to the ebook progress store and types —
|
||||
// because the handler layer is the only producer today: the webhook-import
|
||||
// matcher (internal/historyimport) only matches movie/series/episode kinds, so
|
||||
// imported mark-played events can never resolve to an ebook content ID.
|
||||
|
||||
// EbookReadStateStore is the ebook_reader_progress surface needed to set or
|
||||
// clear an ebook's read state from the watched endpoints.
|
||||
// *PGEbookReaderProgressStore implements it.
|
||||
type EbookReadStateStore interface {
|
||||
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
|
||||
Upsert(ctx context.Context, progress EbookReaderProgress) error
|
||||
Delete(ctx context.Context, userID int, profileID string, contentID string) error
|
||||
}
|
||||
|
||||
// EbookReaderProgressReadWriter is the full progress-store surface the items
|
||||
// handler needs: batch reads for list/user-state responses plus read-state
|
||||
// writes for the watched endpoints.
|
||||
type EbookReaderProgressReadWriter interface {
|
||||
EbookReaderProgressLister
|
||||
EbookReadStateStore
|
||||
}
|
||||
|
||||
// markEbookRead marks an ebook as finished by upserting its reader-progress
|
||||
// row with progress = 1.0 (>= models.EbookFinishedProgressThreshold). An
|
||||
// existing row keeps its file_id and location so the reader still reopens at
|
||||
// the user's last position even though the book is marked read; a missing row
|
||||
// references the item's preferred reader file (resolved lazily via
|
||||
// defaultFileID, since ebook_reader_progress.file_id is NOT NULL) with an
|
||||
// empty location meaning "start at the beginning". A fresh updated_at also
|
||||
// resurfaces a previously hidden book, mirroring how re-watching resurfaces
|
||||
// hidden video history.
|
||||
func markEbookRead(
|
||||
ctx context.Context,
|
||||
store EbookReadStateStore,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
now time.Time,
|
||||
defaultFileID func(ctx context.Context) (int, error),
|
||||
) error {
|
||||
existing, err := store.Get(ctx, userID, profileID, contentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress := EbookReaderProgress{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
Progress: 1.0,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if existing != nil {
|
||||
progress.FileID = existing.FileID
|
||||
progress.Location = existing.Location
|
||||
} else {
|
||||
fileID, err := defaultFileID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress.FileID = fileID
|
||||
}
|
||||
return store.Upsert(ctx, progress)
|
||||
}
|
||||
|
||||
// markEbookUnread clears an ebook's read state by deleting its reader-progress
|
||||
// row — the direct analog of the video unwatch path, where
|
||||
// userstore.ClearProgress DELETEs the user_watch_progress row (dropping the
|
||||
// resume position along with the watched flag).
|
||||
func markEbookUnread(
|
||||
ctx context.Context,
|
||||
store EbookReadStateStore,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
) error {
|
||||
return store.Delete(ctx, userID, profileID, contentID)
|
||||
}
|
||||
|
||||
// setEbookReadState applies the watched toggle for an ebook target resolved by
|
||||
// resolveWatchedTargets, which has already enforced the access filter on the
|
||||
// item. Scoping matches the rest of the ebook progress code: user_id +
|
||||
// profile_id.
|
||||
func (h *ItemsHandler) setEbookReadState(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
read bool,
|
||||
filter catalog.AccessFilter,
|
||||
) error {
|
||||
if h.ebookReadStateStore == nil {
|
||||
return fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if !read {
|
||||
return markEbookUnread(ctx, h.ebookReadStateStore, userID, profileID, contentID)
|
||||
}
|
||||
return markEbookRead(ctx, h.ebookReadStateStore, userID, profileID, contentID, time.Now().UTC(), func(ctx context.Context) (int, error) {
|
||||
return h.defaultEbookFileID(ctx, contentID, filter)
|
||||
})
|
||||
}
|
||||
|
||||
// defaultEbookFileID resolves the file a fresh mark-read progress row should
|
||||
// reference, respecting the caller's access filter.
|
||||
func (h *ItemsHandler) defaultEbookFileID(ctx context.Context, contentID string, filter catalog.AccessFilter) (int, error) {
|
||||
if h.fileRepo == nil {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
}
|
||||
files, err := h.fileRepo.GetByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file := preferredEbookReadFile(catalog.FilterMediaFilesByAccess(files, filter))
|
||||
if file == nil {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
}
|
||||
return file.ID, nil
|
||||
}
|
||||
|
||||
// preferredEbookReadFile mirrors the web reader's default file choice
|
||||
// (web/src/pages/ItemDetail/EbookContent.tsx preferredReadVersion): prefer an
|
||||
// EPUB version, otherwise the first reader-supported ebook file. Callers must
|
||||
// pass access-filtered files.
|
||||
func preferredEbookReadFile(files []*models.MediaFile) *models.MediaFile {
|
||||
var first *models.MediaFile
|
||||
for _, file := range files {
|
||||
if !isEbookFile(file) {
|
||||
continue
|
||||
}
|
||||
if ebookReaderFormat(file.FilePath, file.Container) == "epub" {
|
||||
return file
|
||||
}
|
||||
if first == nil {
|
||||
first = file
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeEbookReadStateStore is an in-memory EbookReaderProgressReadWriter keyed
|
||||
// by content ID for a single (user, profile) scope.
|
||||
type fakeEbookReadStateStore struct {
|
||||
rows map[string]EbookReaderProgress
|
||||
}
|
||||
|
||||
func newFakeEbookReadStateStore() *fakeEbookReadStateStore {
|
||||
return &fakeEbookReadStateStore{rows: map[string]EbookReaderProgress{}}
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Get(_ context.Context, _ int, _ string, contentID string) (*EbookReaderProgress, error) {
|
||||
row, ok := f.rows[contentID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
|
||||
f.rows[progress.ContentID] = progress
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Delete(_ context.Context, _ int, _ string, contentID string) error {
|
||||
delete(f.rows, contentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) ListByContentIDs(_ context.Context, _ int, _ string, contentIDs []string) (map[string]EbookReaderProgress, error) {
|
||||
result := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
if row, ok := f.rows[contentID]; ok {
|
||||
result[contentID] = row
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fakeEbookFileProvider implements EpisodeFileProvider for ebook file lookups.
|
||||
type fakeEbookFileProvider struct {
|
||||
files map[string][]*models.MediaFile
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) GetByContentID(_ context.Context, contentID string) ([]*models.MediaFile, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.files[contentID], nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) GetByEpisodeID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) ListByContentIDs(context.Context, []string) (map[string][]*models.MediaFile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestMarkEbookReadPreservesExistingFileAndLocation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
store.rows["ebook-1"] = EbookReaderProgress{
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
FileID: 7,
|
||||
Location: "epubcfi(/6/14!/4/2/14)",
|
||||
Progress: 0.42,
|
||||
UpdatedAt: time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC),
|
||||
}
|
||||
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
|
||||
t.Fatal("defaultFileID must not be called when a progress row exists")
|
||||
return 0, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("markEbookRead: %v", err)
|
||||
}
|
||||
|
||||
row := store.rows["ebook-1"]
|
||||
if row.Progress != 1.0 {
|
||||
t.Fatalf("progress = %v, want 1.0", row.Progress)
|
||||
}
|
||||
if row.FileID != 7 || row.Location != "epubcfi(/6/14!/4/2/14)" {
|
||||
t.Fatalf("file/location not preserved: %+v", row)
|
||||
}
|
||||
if !row.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("updated_at = %v, want %v", row.UpdatedAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookReadUsesDefaultFileWhenNoProgressExists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
|
||||
return 12, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("markEbookRead: %v", err)
|
||||
}
|
||||
|
||||
row, ok := store.rows["ebook-1"]
|
||||
if !ok {
|
||||
t.Fatal("expected a progress row to be created")
|
||||
}
|
||||
if row.Progress != 1.0 || row.FileID != 12 || row.Location != "" {
|
||||
t.Fatalf("row = %+v, want progress 1.0 with file 12 and empty location", row)
|
||||
}
|
||||
if row.UserID != 42 || row.ProfileID != "profile-1" {
|
||||
t.Fatalf("row scope = user %d profile %q, want user 42 profile-1", row.UserID, row.ProfileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookReadPropagatesDefaultFileError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", time.Now().UTC(), func(context.Context) (int, error) {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
})
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
|
||||
}
|
||||
if len(store.rows) != 0 {
|
||||
t.Fatalf("no row must be written on error, got %+v", store.rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookUnreadDeletesProgressRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
store.rows["ebook-1"] = EbookReaderProgress{ContentID: "ebook-1", FileID: 7, Progress: 1.0}
|
||||
|
||||
if err := markEbookUnread(ctx, store, 42, "profile-1", "ebook-1"); err != nil {
|
||||
t.Fatalf("markEbookUnread: %v", err)
|
||||
}
|
||||
if _, ok := store.rows["ebook-1"]; ok {
|
||||
t.Fatal("progress row must be deleted on mark unread")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredEbookReadFilePrefersEpub(t *testing.T) {
|
||||
pdf := &models.MediaFile{ID: 1, BaseType: "ebook", FilePath: "/books/a.pdf", Container: "pdf"}
|
||||
epub := &models.MediaFile{ID: 2, BaseType: "ebook", FilePath: "/books/a.epub", Container: "epub"}
|
||||
video := &models.MediaFile{ID: 3, BaseType: "movie", FilePath: "/movies/a.mkv", Container: "mkv"}
|
||||
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf, epub}); got == nil || got.ID != 2 {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want epub file 2", got)
|
||||
}
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf}); got == nil || got.ID != 1 {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want first reader-supported file 1", got)
|
||||
}
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video}); got != nil {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want nil without ebook files", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEbookReadStateMarkReadShowsPlayedUserState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: store,
|
||||
ebookReadStateStore: store,
|
||||
fileRepo: &fakeEbookFileProvider{files: map[string][]*models.MediaFile{
|
||||
"ebook-1": {{ID: 5, BaseType: "ebook", FilePath: "/books/b.epub", Container: "epub"}},
|
||||
}},
|
||||
}
|
||||
|
||||
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{}); err != nil {
|
||||
t.Fatalf("setEbookReadState(read): %v", err)
|
||||
}
|
||||
|
||||
userStore := newProfileTestStore(t)
|
||||
items := []*models.MediaItem{{ContentID: "ebook-1", Type: "ebook", Title: "Book"}}
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, userStore, "profile-1", nil, items, itemUserStateOptions{
|
||||
UserID: 42,
|
||||
EbookProgressStore: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
|
||||
}
|
||||
if states["ebook-1"] == nil || !states["ebook-1"].Played {
|
||||
t.Fatalf("user state = %+v, want played after mark read", states["ebook-1"])
|
||||
}
|
||||
|
||||
// A marked-read book is finished, not in progress: the reader-progress
|
||||
// response derived from the row must exclude it from Continue Reading.
|
||||
row := store.rows["ebook-1"]
|
||||
if row.Progress < models.EbookFinishedProgressThreshold {
|
||||
t.Fatalf("progress %v must cross the finished threshold", row.Progress)
|
||||
}
|
||||
|
||||
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", false, catalog.AccessFilter{}); err != nil {
|
||||
t.Fatalf("setEbookReadState(unread): %v", err)
|
||||
}
|
||||
if _, ok := store.rows["ebook-1"]; ok {
|
||||
t.Fatal("mark unread must delete the reader progress row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEbookReadStateFailsWithoutAccessibleFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: store,
|
||||
ebookReadStateStore: store,
|
||||
fileRepo: &fakeEbookFileProvider{},
|
||||
}
|
||||
|
||||
err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{})
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,897 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
func newEbookReaderAuthRequest(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withEbookReaderRouteParams(req *http.Request, contentID, fileID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
routeCtx.URLParams.Add("file_id", fileID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func withEbookReaderContentRouteParam(req *http.Request, contentID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func withEbookReaderAnnotationRouteParams(req *http.Request, contentID, annotationID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
routeCtx.URLParams.Add("annotation_id", annotationID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
type fakeEbookProgressStore struct {
|
||||
progress *EbookReaderProgress
|
||||
upserted *EbookReaderProgress
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeEbookProgressStore) Get(
|
||||
context.Context,
|
||||
int,
|
||||
string,
|
||||
string,
|
||||
) (*EbookReaderProgress, error) {
|
||||
return s.progress, s.err
|
||||
}
|
||||
|
||||
func (s *fakeEbookProgressStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.upserted = &progress
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEbookReaderConfigStore struct {
|
||||
config *EbookReaderConfig
|
||||
saved *EbookReaderConfig
|
||||
getErr error
|
||||
saveErr error
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderConfigStore) Get(
|
||||
context.Context,
|
||||
int,
|
||||
string,
|
||||
string,
|
||||
) (*EbookReaderConfig, error) {
|
||||
return s.config, s.getErr
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderConfigStore) Upsert(_ context.Context, config EbookReaderConfig) error {
|
||||
if s.saveErr != nil {
|
||||
return s.saveErr
|
||||
}
|
||||
s.saved = &config
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEbookReaderAnnotationStore struct {
|
||||
items []EbookReaderAnnotation
|
||||
existing *EbookReaderAnnotation
|
||||
created *EbookReaderAnnotation
|
||||
updated *EbookReaderAnnotation
|
||||
mergedFrom *EbookReaderAnnotation
|
||||
mergeCalls int
|
||||
deleted string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) List(context.Context, int, string, string) ([]EbookReaderAnnotation, error) {
|
||||
return s.items, s.err
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) Create(_ context.Context, annotation EbookReaderAnnotation) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.created = &annotation
|
||||
s.items = append(s.items, annotation)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update models the PG store contract: read the stored row, invoke merge with
|
||||
// it, and persist the merged result, all as one atomic operation.
|
||||
func (s *fakeEbookReaderAnnotationStore) Update(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
_ string,
|
||||
annotationID string,
|
||||
merge func(existing EbookReaderAnnotation) (EbookReaderAnnotation, error),
|
||||
) (*EbookReaderAnnotation, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.existing == nil || s.existing.ID != annotationID {
|
||||
return nil, nil
|
||||
}
|
||||
existing := *s.existing
|
||||
s.mergedFrom = &existing
|
||||
s.mergeCalls++
|
||||
merged, err := merge(existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.updated = &merged
|
||||
stored := merged
|
||||
s.existing = &stored
|
||||
return &merged, nil
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) Delete(_ context.Context, userID int, profileID, contentID, annotationID string) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.deleted = annotationID
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEbookReaderServesEbookInlineWithRangeSupport(t *testing.T) {
|
||||
filePath := writePlaybackTestMediaFile(t, "book.epub")
|
||||
if err := os.WriteFile(filePath, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatalf("write ebook: %v", err)
|
||||
}
|
||||
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: filePath,
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req.Header.Set("Range", "bytes=2-5")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
|
||||
t.Fatalf("content-type = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Disposition"); got != "inline; filename=book.epub" {
|
||||
t.Fatalf("content-disposition = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("x-content-type-options = %q, want nosniff", got)
|
||||
}
|
||||
if rr.Body.String() != "2345" {
|
||||
t.Fatalf("body = %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderServesContainerMimeForMismatchedExtension(t *testing.T) {
|
||||
filePath := writePlaybackTestMediaFile(t, "book.txt")
|
||||
if err := os.WriteFile(filePath, []byte("<html>not really</html>"), 0o644); err != nil {
|
||||
t.Fatalf("write ebook: %v", err)
|
||||
}
|
||||
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: filePath,
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// The file is admitted via its validated container, so the response must
|
||||
// carry that container's MIME type, never application/octet-stream.
|
||||
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
|
||||
t.Fatalf("content-type = %q, want application/epub+zip", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("x-content-type-options = %q, want nosniff", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookMimeTypeNeverReturnsOctetStreamForAdmittedFiles(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
container string
|
||||
wantMime string
|
||||
}{
|
||||
{"/library/book.txt", "epub", "application/epub+zip"},
|
||||
{"/library/book", "pdf", "application/pdf"},
|
||||
{"/library/book.epub", "pdf", "application/epub+zip"}, // known extension wins
|
||||
}
|
||||
for _, tc := range cases {
|
||||
file := &models.MediaFile{FilePath: tc.path, Container: tc.container, BaseType: "ebook"}
|
||||
if !isEbookFile(file) {
|
||||
t.Fatalf("isEbookFile(%q, %q) = false, want admitted", tc.path, tc.container)
|
||||
}
|
||||
if got := ebookMimeType(tc.path, tc.container); got != tc.wantMime {
|
||||
t.Fatalf("ebookMimeType(%q, %q) = %q, want %q", tc.path, tc.container, got, tc.wantMime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsNonEbookFile(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: "/tmp/movie.mkv",
|
||||
Container: "mkv",
|
||||
BaseType: "movie",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/movie-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "movie-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRecognizesReadestFormats(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"book.epub": "application/epub+zip",
|
||||
"book.pdf": "application/pdf",
|
||||
"book.mobi": "application/x-mobipocket-ebook",
|
||||
"book.azw": "application/vnd.amazon.ebook",
|
||||
"book.azw3": "application/vnd.amazon.mobi8-ebook",
|
||||
"book.cbz": "application/vnd.comicbook+zip",
|
||||
"book.cbr": "application/vnd.comicbook-rar",
|
||||
"book.fb2": "application/x-fictionbook+xml",
|
||||
"book.fbz": "application/x-zip-compressed-fb2",
|
||||
"book.fb2.zip": "application/x-zip-compressed-fb2",
|
||||
"book.md": "application/octet-stream",
|
||||
"book.unknown": "application/octet-stream",
|
||||
}
|
||||
rejected := map[string]bool{"book.md": true, "book.unknown": true}
|
||||
|
||||
for name, wantMime := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
container := name
|
||||
if name == "book.fb2.zip" {
|
||||
container = "fbz"
|
||||
}
|
||||
file := &models.MediaFile{FilePath: "/library/" + name, Container: container}
|
||||
if rejected[name] {
|
||||
file.BaseType = "ebook"
|
||||
if isEbookFile(file) {
|
||||
t.Fatalf("%s should not be treated as an ebook reader format", name)
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
|
||||
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
|
||||
}
|
||||
return
|
||||
}
|
||||
file.BaseType = "ebook"
|
||||
if !isEbookFile(file) {
|
||||
t.Fatal("expected ebook reader format")
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
|
||||
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsPlainText(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
FilePath: "/library/book.txt",
|
||||
BaseType: "ebook",
|
||||
}
|
||||
|
||||
if isEbookFile(file) {
|
||||
t.Fatal("plain text should not be treated as an ebook reader format")
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got == "text/plain; charset=utf-8" {
|
||||
t.Fatal("plain text should not have an ebook reader MIME type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRecognizesFBZFromCompoundFilenameWithoutContainer(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
FilePath: "/library/book.fb2.zip",
|
||||
BaseType: "ebook",
|
||||
}
|
||||
|
||||
if !isEbookFile(file) {
|
||||
t.Fatal("expected .fb2.zip path to be treated as an ebook reader format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderMapsAccessFailureToNotFound(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: "/tmp/book.epub",
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsInternalErrorForUnexpectedAuthorizeFailure(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: errors.New("db down")},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
|
||||
store := &fakeEbookProgressStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: "/tmp/book.epub",
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/progress",
|
||||
strings.NewReader(`{"file_id":42,"location":"epubcfi(/6/4)","progress":0.42}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.upserted == nil {
|
||||
t.Fatal("expected progress to be saved")
|
||||
}
|
||||
if store.upserted.UserID != 1 || store.upserted.ProfileID != "profile-9" {
|
||||
t.Fatalf("scope = user %d profile %q", store.upserted.UserID, store.upserted.ProfileID)
|
||||
}
|
||||
if store.upserted.ContentID != "ebook-1" || store.upserted.FileID != 42 {
|
||||
t.Fatalf("target = content %q file %d", store.upserted.ContentID, store.upserted.FileID)
|
||||
}
|
||||
if store.upserted.Location != "epubcfi(/6/4)" || store.upserted.Progress != 0.42 {
|
||||
t.Fatalf("progress = %+v", store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsSavedProgress(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = &fakeEbookProgressStore{
|
||||
progress: &EbookReaderProgress{
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
FileID: 42,
|
||||
Location: "epubcfi(/6/8)",
|
||||
Progress: 0.75,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/progress")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
`"content_id":"ebook-1"`,
|
||||
`"file_id":42`,
|
||||
`"location":"epubcfi(/6/8)"`,
|
||||
`"progress":0.75`,
|
||||
`"updated_at":"2026-06-08T12:00:00Z"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsEmptyConfigWhenNoneSaved(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := strings.TrimSpace(rr.Body.String()); got != `{"config":{}}` {
|
||||
t.Fatalf("body = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderSavesConfigForAccessibleEbook(t *testing.T) {
|
||||
store := &fakeEbookReaderConfigStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/reader-config",
|
||||
strings.NewReader(`{"config":{"theme":"dark","flow":"scrolled","fontSize":120}}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.saved == nil {
|
||||
t.Fatal("expected config to be saved")
|
||||
}
|
||||
if store.saved.UserID != 1 || store.saved.ProfileID != "profile-9" || store.saved.ContentID != "ebook-1" {
|
||||
t.Fatalf("scope = %+v", store.saved)
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(store.saved.Config, &config); err != nil {
|
||||
t.Fatalf("config json: %v", err)
|
||||
}
|
||||
if config["theme"] != "dark" || config["flow"] != "scrolled" || config["fontSize"].(float64) != 120 {
|
||||
t.Fatalf("config = %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsNonObjectConfig(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/reader-config")
|
||||
req.Body = http.NoBody
|
||||
req = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/reader-config",
|
||||
strings.NewReader(`{"config":["bad"]}`),
|
||||
).WithContext(req.Context())
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderConfigRequiresAccessibleContent(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderCreatesAnnotationForAccessibleEbook(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/ebooks/ebook-1/annotations",
|
||||
strings.NewReader(`{"kind":"highlight","cfi_range":"epubcfi(/6/4,/1:0,/1:12)","selected_text":"sample text","note":"note text","style":"highlight","color":"#facc15"}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleCreateAnnotation(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.created == nil {
|
||||
t.Fatal("expected annotation to be created")
|
||||
}
|
||||
if store.created.UserID != 1 || store.created.ProfileID != "profile-9" || store.created.ContentID != "ebook-1" {
|
||||
t.Fatalf("scope = %+v", store.created)
|
||||
}
|
||||
if store.created.Kind != "highlight" || store.created.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
|
||||
t.Fatalf("annotation = %+v", store.created)
|
||||
}
|
||||
if store.created.SelectedText != "sample text" || store.created.Note != "note text" {
|
||||
t.Fatalf("annotation text = %+v", store.created)
|
||||
}
|
||||
if store.created.ID == "" {
|
||||
t.Fatal("expected generated annotation id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsAnnotations(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = &fakeEbookReaderAnnotationStore{
|
||||
items: []EbookReaderAnnotation{
|
||||
{
|
||||
ID: "ann-1",
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
Kind: "bookmark",
|
||||
Location: "epubcfi(/6/8)",
|
||||
SelectedText: "",
|
||||
Note: "Saved spot",
|
||||
Style: "highlight",
|
||||
Color: "#facc15",
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
CreatedAt: updatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/annotations")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleListAnnotations(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`"items":[`, `"id":"ann-1"`, `"kind":"bookmark"`, `"location":"epubcfi(/6/8)"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func existingEbookReaderAnnotation() *EbookReaderAnnotation {
|
||||
createdAt := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
return &EbookReaderAnnotation{
|
||||
ID: "ann-1",
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
Kind: "highlight",
|
||||
CFIRange: "epubcfi(/6/4,/1:0,/1:12)",
|
||||
SelectedText: "sample text",
|
||||
Note: "original note",
|
||||
Style: "highlight",
|
||||
Color: "#facc15",
|
||||
Metadata: json.RawMessage(`{"chapter":"one"}`),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func patchEbookReaderAnnotation(t *testing.T, store *fakeEbookReaderAnnotationStore, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodPatch, "/ebooks/ebook-1/annotations/ann-1")
|
||||
req.Body = ioNopCloser{strings.NewReader(body)}
|
||||
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateAnnotation(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchKeepsAbsentFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated","color":"#bfdbfe"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.ID != "ann-1" || store.updated.Note != "updated" || store.updated.Color != "#bfdbfe" {
|
||||
t.Fatalf("updated = %+v", store.updated)
|
||||
}
|
||||
if string(store.updated.Metadata) != `{"chapter":"one"}` {
|
||||
t.Fatalf("metadata should be preserved when absent, got %s", store.updated.Metadata)
|
||||
}
|
||||
if store.updated.Kind != "highlight" || store.updated.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
|
||||
t.Fatalf("absent fields should keep stored values, got %+v", store.updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchClearsExplicitlyEmptyFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"","metadata":{}}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.Note != "" {
|
||||
t.Fatalf("note should be cleared, got %+v", store.updated)
|
||||
}
|
||||
if string(store.updated.Metadata) != `{}` {
|
||||
t.Fatalf("metadata should be cleared to {}, got %s", store.updated.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchValidatesResultingState(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"kind change without location": `{"kind":"bookmark"}`,
|
||||
"clearing required cfi_range": `{"cfi_range":""}`,
|
||||
"unknown kind": `{"kind":"doodle"}`,
|
||||
"non-object metadata": `{"metadata":["bad"]}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
rr := patchEbookReaderAnnotation(t, store, body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated != nil {
|
||||
t.Fatalf("invalid patch must not reach the store, got %+v", store.updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchAllowsKindChangeWithRequiredFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"kind":"bookmark","location":"epubcfi(/6/8)"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.Kind != "bookmark" || store.updated.Location != "epubcfi(/6/8)" {
|
||||
t.Fatalf("updated = %+v", store.updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchMergesOntoRowReadInsideStoreUpdate(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"merged note"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// The handler must not pre-read and merge outside the store: the merge
|
||||
// runs exactly once, on the row handed over by the (locked) store read.
|
||||
if store.mergeCalls != 1 {
|
||||
t.Fatalf("merge calls = %d, want 1", store.mergeCalls)
|
||||
}
|
||||
if store.mergedFrom == nil || store.mergedFrom.Note != "original note" {
|
||||
t.Fatalf("merge input = %+v, want the stored row", store.mergedFrom)
|
||||
}
|
||||
if store.updated == nil || store.updated.Note != "merged note" {
|
||||
t.Fatalf("written row = %+v, want merged note", store.updated)
|
||||
}
|
||||
if store.updated.CFIRange != store.mergedFrom.CFIRange || store.updated.Kind != store.mergedFrom.Kind {
|
||||
t.Fatalf("written row %+v must keep unpatched fields from merge input %+v", store.updated, store.mergedFrom)
|
||||
}
|
||||
if !store.updated.UpdatedAt.After(store.mergedFrom.UpdatedAt) {
|
||||
t.Fatalf("updated_at %v must advance past %v", store.updated.UpdatedAt, store.mergedFrom.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchReturnsNotFoundForMissingAnnotation(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated"}`)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsOversizedProgressBody(t *testing.T) {
|
||||
store := &fakeEbookProgressStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = store
|
||||
|
||||
body := `{"file_id":42,"location":"` + strings.Repeat("x", int(ebookReaderProgressMaxBodySize)) + `","progress":0.5}`
|
||||
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/progress")
|
||||
req.Body = ioNopCloser{strings.NewReader(body)}
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.upserted != nil {
|
||||
t.Fatalf("oversized body must not be saved, got %+v", store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineContentDispositionProducesParseableHeaders(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"book.epub": "book.epub",
|
||||
`bo"ok\.epub`: `bo"ok\.epub`,
|
||||
`trailing-slash\`: `trailing-slash\`,
|
||||
"börk \U0001F4DA.epub": "börk \U0001F4DA.epub",
|
||||
"": "ebook",
|
||||
".": "ebook",
|
||||
}
|
||||
for name, want := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := inlineContentDisposition(name)
|
||||
mediaType, params, err := mime.ParseMediaType(got)
|
||||
if err != nil {
|
||||
t.Fatalf("header %q is malformed: %v", got, err)
|
||||
}
|
||||
if mediaType != "inline" {
|
||||
t.Fatalf("media type = %q", mediaType)
|
||||
}
|
||||
if params["filename"] != want {
|
||||
t.Fatalf("filename = %q, want %q", params["filename"], want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderDeletesAnnotation(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodDelete, "/ebooks/ebook-1/annotations/ann-1")
|
||||
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDeleteAnnotation(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.deleted != "ann-1" {
|
||||
t.Fatalf("deleted = %q", store.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
type ioNopCloser struct {
|
||||
*strings.Reader
|
||||
}
|
||||
|
||||
func (c ioNopCloser) Close() error { return nil }
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"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/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -32,10 +31,6 @@ type activeScanLister interface {
|
||||
ListActive(ctx context.Context) ([]evt.ScanRun, error)
|
||||
}
|
||||
|
||||
// viewerRevalidateInterval is how often an open events WebSocket re-loads its
|
||||
// user to confirm the account is still enabled and admin status is unchanged.
|
||||
const viewerRevalidateInterval = 60 * time.Second
|
||||
|
||||
type EventsHandler struct {
|
||||
hub *evt.Hub
|
||||
jobs *AdminJobsHandler
|
||||
@@ -44,11 +39,6 @@ type EventsHandler struct {
|
||||
scans *evt.ScanRegistry
|
||||
persistedScans activeScanLister
|
||||
historyImports historyImportActiveLister
|
||||
users auth.UserLoader
|
||||
|
||||
// revalidateInterval overrides viewerRevalidateInterval in tests; zero
|
||||
// means the default.
|
||||
revalidateInterval time.Duration
|
||||
}
|
||||
|
||||
func NewEventsHandler(
|
||||
@@ -59,7 +49,6 @@ func NewEventsHandler(
|
||||
scans *evt.ScanRegistry,
|
||||
persistedScans *scanqueue.Service,
|
||||
historyImports historyImportActiveLister,
|
||||
users auth.UserLoader,
|
||||
) *EventsHandler {
|
||||
return &EventsHandler{
|
||||
hub: hub,
|
||||
@@ -69,7 +58,6 @@ func NewEventsHandler(
|
||||
scans: scans,
|
||||
persistedScans: persistedScans,
|
||||
historyImports: historyImports,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +73,6 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Admin status is checked server-side once per connection; it never comes
|
||||
// from token contents.
|
||||
viewerIsAdmin := isAdminRequest(r, h.users)
|
||||
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -105,7 +89,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return writeWebSocketControl(conn, websocket.PingMessage, nil)
|
||||
})
|
||||
|
||||
allowedChannels := allowedChannelsForViewer(viewerIsAdmin)
|
||||
allowedChannels := allowedChannelsForRole(claims.Role)
|
||||
connectionID := ulid.Make().String()
|
||||
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
|
||||
Type: "hello",
|
||||
@@ -137,17 +121,6 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
|
||||
// Channel authorization is decided once at upgrade, so a long-lived
|
||||
// connection must periodically re-load its user: a disabled account or a
|
||||
// changed admin role would otherwise keep its channel set until the
|
||||
// client disconnects on its own.
|
||||
revalidateEvery := h.revalidateInterval
|
||||
if revalidateEvery <= 0 {
|
||||
revalidateEvery = viewerRevalidateInterval
|
||||
}
|
||||
revalidate := time.NewTicker(revalidateEvery)
|
||||
defer revalidate.Stop()
|
||||
|
||||
subscriptions := make(map[evt.EventChannel]struct{})
|
||||
subscribedOnce := false
|
||||
|
||||
@@ -157,33 +130,6 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
case <-readDone:
|
||||
return
|
||||
case <-revalidate.C:
|
||||
if h.users == nil {
|
||||
// No loader wired: the viewer was never granted admin
|
||||
// channels (isAdminRequest fails closed), so there is no
|
||||
// stale privilege to revoke.
|
||||
continue
|
||||
}
|
||||
user, err := h.users.GetByID(ctx, claims.UserID)
|
||||
if err != nil && !auth.IsNotFound(err) {
|
||||
// Transient lookup failure: keep the connection and retry on
|
||||
// the next tick rather than dropping every viewer on a DB
|
||||
// blip. A definitive miss (ErrNotFound) leaves user nil and
|
||||
// closes below.
|
||||
slog.Warn("events: viewer revalidation failed; retrying next tick",
|
||||
"user_id", claims.UserID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if closeConn, reason := revalidateViewer(user, viewerIsAdmin); closeConn {
|
||||
_ = writeWebSocketControl(
|
||||
conn,
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason),
|
||||
)
|
||||
return
|
||||
}
|
||||
case <-deadline.C:
|
||||
if subscribedOnce {
|
||||
continue
|
||||
@@ -196,7 +142,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
)
|
||||
return
|
||||
case data := <-readMessages:
|
||||
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, viewerIsAdmin, data, allowedChannels)
|
||||
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -218,10 +164,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
if _, subscribed := subscriptions[env.Channel]; !subscribed {
|
||||
continue
|
||||
}
|
||||
if !allowsEventForViewer(claims.UserID, viewerIsAdmin, env) {
|
||||
if !allowsEventForClaims(claims, env) {
|
||||
continue
|
||||
}
|
||||
if err := h.writeEventFrame(conn, r, claims, viewerIsAdmin, env); err != nil {
|
||||
if err := h.writeEventFrame(conn, r, claims, env); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -232,7 +178,6 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
data []byte,
|
||||
allowed []evt.EventChannel,
|
||||
) (map[evt.EventChannel]struct{}, bool, bool) {
|
||||
@@ -317,7 +262,7 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
}
|
||||
|
||||
for _, channel := range accepted {
|
||||
if err := h.writeSnapshotFrame(conn, r, claims, viewerIsAdmin, channel); err != nil {
|
||||
if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
}
|
||||
@@ -325,32 +270,13 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
return nextSubs, true, true
|
||||
}
|
||||
|
||||
// revalidateViewer decides whether an open events WebSocket must be closed
|
||||
// after its user is re-loaded. The connection closes when the account is gone
|
||||
// or disabled, and when admin status changed in either direction — the
|
||||
// allowed channel set is fixed at upgrade, so the client must reconnect to
|
||||
// receive the correct set; closing on change is simpler and safer than
|
||||
// re-assigning channels mid-stream. Other policy changes (AccessPolicyRevision
|
||||
// bumps from group/library edits) deliberately keep the connection: event
|
||||
// authorization (allowedChannelsForViewer, allowsEventForViewer) depends only
|
||||
// on admin status and user ID, never on per-user library or rating policy.
|
||||
func revalidateViewer(user *models.User, wasAdmin bool) (closeConn bool, reason string) {
|
||||
if user == nil || !user.Enabled {
|
||||
return true, "account disabled"
|
||||
}
|
||||
if user.IsAdmin != wasAdmin {
|
||||
return true, "permissions changed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
|
||||
func allowedChannelsForRole(role string) []evt.EventChannel {
|
||||
channels := []evt.EventChannel{
|
||||
evt.ChannelCatalog,
|
||||
evt.ChannelHistoryImport,
|
||||
evt.ChannelUserState,
|
||||
}
|
||||
if viewerIsAdmin {
|
||||
if role == "admin" {
|
||||
channels = append(channels,
|
||||
evt.ChannelJobs,
|
||||
evt.ChannelSessions,
|
||||
@@ -361,14 +287,14 @@ func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
|
||||
return channels
|
||||
}
|
||||
|
||||
func allowsEventForViewer(viewerUserID int, viewerIsAdmin bool, env evt.Envelope) bool {
|
||||
if viewerUserID == 0 {
|
||||
func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
if env.AdminOnly && !viewerIsAdmin {
|
||||
if env.AdminOnly && claims.Role != "admin" {
|
||||
return false
|
||||
}
|
||||
if env.UserID > 0 && !viewerIsAdmin && env.UserID != viewerUserID {
|
||||
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -388,7 +314,6 @@ func marshalJSON(value any) json.RawMessage {
|
||||
func (h *EventsHandler) snapshotForChannel(
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
channel evt.EventChannel,
|
||||
) (json.RawMessage, error) {
|
||||
switch channel {
|
||||
@@ -441,7 +366,7 @@ func (h *EventsHandler) snapshotForChannel(
|
||||
if h == nil || h.historyImports == nil {
|
||||
return json.RawMessage("[]"), nil
|
||||
}
|
||||
if viewerIsAdmin {
|
||||
if claims != nil && claims.Role == "admin" {
|
||||
runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -462,10 +387,9 @@ func (h *EventsHandler) writeSnapshotFrame(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
channel evt.EventChannel,
|
||||
) error {
|
||||
data, err := h.snapshotForChannel(r, claims, viewerIsAdmin, channel)
|
||||
data, err := h.snapshotForChannel(r, claims, channel)
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"events: failed to build initial snapshot",
|
||||
@@ -491,12 +415,11 @@ func (h *EventsHandler) writeEventFrame(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
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, viewerIsAdmin, env.Channel)
|
||||
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
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
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/models"
|
||||
)
|
||||
|
||||
func TestRevalidateViewer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user *models.User
|
||||
wasAdmin bool
|
||||
wantClose bool
|
||||
}{
|
||||
{
|
||||
name: "user deleted",
|
||||
user: nil,
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "user disabled",
|
||||
user: &models.User{ID: 1, Enabled: false},
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "admin disabled keeps no access even with admin flag",
|
||||
user: &models.User{ID: 1, Enabled: false, IsAdmin: true},
|
||||
wasAdmin: true,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "admin demoted",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
|
||||
wasAdmin: true,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "viewer promoted to admin",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "enabled non-admin unchanged",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
|
||||
wasAdmin: false,
|
||||
wantClose: false,
|
||||
},
|
||||
{
|
||||
name: "enabled admin unchanged",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
|
||||
wasAdmin: true,
|
||||
wantClose: false,
|
||||
},
|
||||
{
|
||||
// Policy revision bumps (group/library edits) do not affect event
|
||||
// authorization, which depends only on admin status and user ID.
|
||||
name: "policy revision change without admin change",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false, AccessPolicyRevision: 42},
|
||||
wasAdmin: false,
|
||||
wantClose: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
closeConn, reason := revalidateViewer(tc.user, tc.wasAdmin)
|
||||
if closeConn != tc.wantClose {
|
||||
t.Fatalf("revalidateViewer() close = %v, want %v", closeConn, tc.wantClose)
|
||||
}
|
||||
if closeConn && reason == "" {
|
||||
t.Fatal("expected a close reason")
|
||||
}
|
||||
if !closeConn && reason != "" {
|
||||
t.Fatalf("expected empty reason for kept connection, got %q", reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mutableUserLoader is a swappable auth.UserLoader for revalidation tests.
|
||||
type mutableUserLoader struct {
|
||||
mu sync.Mutex
|
||||
user *models.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *mutableUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.err != nil {
|
||||
return nil, l.err
|
||||
}
|
||||
if l.user == nil || l.user.ID != id {
|
||||
return nil, auth.ErrNotFound
|
||||
}
|
||||
userCopy := *l.user
|
||||
return &userCopy, nil
|
||||
}
|
||||
|
||||
func (l *mutableUserLoader) set(user *models.User, err error) {
|
||||
l.mu.Lock()
|
||||
l.user = user
|
||||
l.err = err
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection covers the
|
||||
// revalidation ticker end to end: an open connection is closed by the server
|
||||
// once the periodic user re-load reports the account disabled.
|
||||
func TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection(t *testing.T) {
|
||||
loader := &mutableUserLoader{user: &models.User{ID: 1, Enabled: true}}
|
||||
handler := NewEventsHandler(evt.NewHub("test", nil), nil, nil, nil, nil, nil, nil, loader)
|
||||
handler.revalidateInterval = 20 * time.Millisecond
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
|
||||
handler.HandleWebSocket(w, r.WithContext(ctx))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial websocket: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Complete the handshake: hello, subscribe, subscribed + snapshot.
|
||||
var hello evt.EventsHelloMessage
|
||||
if err := conn.ReadJSON(&hello); err != nil {
|
||||
t.Fatalf("read hello: %v", err)
|
||||
}
|
||||
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
|
||||
Type: "subscribe",
|
||||
Channels: []evt.EventChannel{evt.ChannelCatalog},
|
||||
}); err != nil {
|
||||
t.Fatalf("write subscribe: %v", err)
|
||||
}
|
||||
|
||||
// The account is disabled while the connection is open; the next
|
||||
// revalidation tick must close it with a normal close frame.
|
||||
loader.set(&models.User{ID: 1, Enabled: false}, nil)
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
for {
|
||||
var frame json.RawMessage
|
||||
if err := conn.ReadJSON(&frame); err != nil {
|
||||
var closeErr *websocket.CloseError
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||
return // server closed the connection as required
|
||||
}
|
||||
t.Fatalf("expected normal close, got %v (%T %v)", err, err, closeErr)
|
||||
}
|
||||
// Drain subscribed/snapshot frames until the close arrives.
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ type PersonalDataHandler struct {
|
||||
localFavoriteDispatcher LocalFavoriteEventDispatcher
|
||||
profileStaler ProfileStaler
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
}
|
||||
|
||||
// NewPersonalDataHandler creates a new PersonalDataHandler.
|
||||
@@ -56,10 +55,6 @@ func (h *PersonalDataHandler) SetDetailService(svc *catalog.DetailService) {
|
||||
h.detailSvc = svc
|
||||
}
|
||||
|
||||
func (h *PersonalDataHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
|
||||
h.ebookProgressStore = store
|
||||
}
|
||||
|
||||
// SetProfileStaler configures an optional staleness trigger for taste profiles.
|
||||
func (h *PersonalDataHandler) SetProfileStaler(ps ProfileStaler) {
|
||||
h.profileStaler = ps
|
||||
@@ -557,10 +552,7 @@ func resolveItemsByIDs(h *PersonalDataHandler, r *http.Request, ids []string) ([
|
||||
|
||||
userStates := map[string]*itemUserStateResponse{}
|
||||
if store, profileID, ok := h.userStoreForRequest(r); ok {
|
||||
if resolvedStates, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, accessibleItems, itemUserStateOptions{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
EbookProgressStore: h.ebookProgressStore,
|
||||
}); err == nil {
|
||||
if resolvedStates, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, accessibleItems); err == nil {
|
||||
userStates = resolvedStates
|
||||
}
|
||||
}
|
||||
@@ -677,11 +669,7 @@ func (h *PersonalDataHandler) resolveHistoryRemovalMediaItemIDs(
|
||||
return nil, err
|
||||
}
|
||||
switch item.Type {
|
||||
case "movie", "ebook":
|
||||
// Hiding an ebook gates ebook_reader_progress reads via
|
||||
// user_history_hidden_items (updated_at <= hidden_before)
|
||||
// without deleting the progress row, so the reader position
|
||||
// survives: hidden is not the same as unread.
|
||||
case "movie":
|
||||
return []string{item.ContentID}, nil
|
||||
case "series":
|
||||
return h.seriesEpisodeIDs(ctx, item.ContentID)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/userstore"
|
||||
)
|
||||
|
||||
type fakeHistoryItemRepo struct {
|
||||
items map[string]*models.MediaItem
|
||||
inaccessible map[string]bool
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
|
||||
item, ok := r.items[contentID]
|
||||
if !ok {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.MediaItem, error) {
|
||||
result := make([]*models.MediaItem, 0, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
if item, ok := r.items[contentID]; ok {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
|
||||
if r.inaccessible[contentID] {
|
||||
return catalog.ErrItemNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleRemoveHistoryAcceptsEbookTargets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
|
||||
items: map[string]*models.MediaItem{
|
||||
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
|
||||
},
|
||||
})
|
||||
|
||||
// Seed a history entry keyed by the ebook content ID so the removal has
|
||||
// something to hide. RemoveHistoryItems only touches watch history,
|
||||
// watch progress, and the hidden-items gate — PersonalDataHandler has no
|
||||
// write access to ebook_reader_progress (its store interface is read-only),
|
||||
// so the reader position survives: hidden is not the same as unread.
|
||||
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
||||
ProfileID: "profile-1",
|
||||
MediaItemID: "ebook-1",
|
||||
WatchedAt: "2026-06-01T10:00:00Z",
|
||||
Completed: true,
|
||||
Source: userstore.WatchHistorySourceManual,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddHistory: %v", err)
|
||||
}
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
http.MethodPost,
|
||||
"/history/remove",
|
||||
`{"targets":[{"content_id":"ebook-1"}]}`,
|
||||
"user",
|
||||
"profile-1",
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleRemoveHistory(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
entries, err := store.ListHistory(ctx, "profile-1", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListHistory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("history entries = %+v, want hidden after removal", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRemoveHistoryRejectsInaccessibleEbook(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
|
||||
items: map[string]*models.MediaItem{
|
||||
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
|
||||
},
|
||||
inaccessible: map[string]bool{"ebook-1": true},
|
||||
})
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
http.MethodPost,
|
||||
"/history/remove",
|
||||
`{"targets":[{"content_id":"ebook-1"}]}`,
|
||||
"user",
|
||||
"profile-1",
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleRemoveHistory(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
+30
-332
@@ -46,10 +46,6 @@ type LocalWatchEventDispatcher interface {
|
||||
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
|
||||
}
|
||||
|
||||
type EbookReaderProgressLister interface {
|
||||
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
|
||||
}
|
||||
|
||||
// ItemsHandler handles browse, search, item detail, and series endpoints.
|
||||
type ItemsHandler struct {
|
||||
browseRepo *catalog.BrowseRepository
|
||||
@@ -66,8 +62,6 @@ type ItemsHandler struct {
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
metadataRefreshRequester MetadataRefreshRequester
|
||||
localWatchDispatcher LocalWatchEventDispatcher
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
ebookReadStateStore EbookReadStateStore
|
||||
EventsHub *evt.Hub
|
||||
UserRepo *auth.UserRepository
|
||||
}
|
||||
@@ -120,11 +114,6 @@ func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDi
|
||||
h.localWatchDispatcher = dispatcher
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressReadWriter) {
|
||||
h.ebookProgressStore = store
|
||||
h.ebookReadStateStore = store
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
|
||||
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
|
||||
return
|
||||
@@ -236,9 +225,6 @@ type sortMetricsResponse struct {
|
||||
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
|
||||
ViewedAt string `json:"viewed_at,omitempty"`
|
||||
PlayCount *int `json:"play_count,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
SeriesName string `json:"series_name,omitempty"`
|
||||
}
|
||||
|
||||
// browseResponse is the paginated response for the /items endpoint.
|
||||
@@ -387,8 +373,8 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" {
|
||||
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
if detail.Type == "movie" || detail.Type == "episode" {
|
||||
detail.UserData = h.getLeafUserData(r, detail.ContentID)
|
||||
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
@@ -443,8 +429,7 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
filter := h.accessFilter(r)
|
||||
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, filter)
|
||||
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, h.accessFilter(r))
|
||||
if err != nil {
|
||||
switch {
|
||||
case isNotFound(err):
|
||||
@@ -455,16 +440,12 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case targetType == "ebook":
|
||||
// Ebook read state lives in ebook_reader_progress, not in
|
||||
// user_watch_progress/user_watch_history; watch providers do not sync
|
||||
// books, so no local watch event is dispatched.
|
||||
err = h.setEbookReadState(r.Context(), userID, profileID, id, played, filter)
|
||||
case h.watchState == nil:
|
||||
if h.watchState == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
case played:
|
||||
}
|
||||
|
||||
if played {
|
||||
leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
leafTargets = append(leafTargets, watchstate.LeafWatchTarget{
|
||||
@@ -478,7 +459,7 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
if err == nil {
|
||||
h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result)
|
||||
}
|
||||
default:
|
||||
} else {
|
||||
targetIDs := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
targetIDs = append(targetIDs, target.ContentID)
|
||||
@@ -490,10 +471,6 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state")
|
||||
return
|
||||
}
|
||||
@@ -762,10 +739,7 @@ func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.Media
|
||||
if !ok {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
EbookProgressStore: h.ebookProgressStore,
|
||||
})
|
||||
states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items)
|
||||
if err != nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
@@ -783,14 +757,6 @@ func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models
|
||||
ep = localized
|
||||
}
|
||||
}
|
||||
resp, stillPath := episodeResponseShell(ep, fallback)
|
||||
resp.StillURL = h.presignURL(r, stillPath, "card")
|
||||
return resp
|
||||
}
|
||||
|
||||
// episodeResponseShell maps an already-localized episode onto the response
|
||||
// shape, returning the card-variant still path for the caller to presign.
|
||||
func episodeResponseShell(ep *models.Episode, fallback episodeImageFallback) (episodeResponse, string) {
|
||||
stillPath := ep.StillPath
|
||||
stillThumbhash := ep.StillThumbhash
|
||||
if strings.TrimSpace(stillPath) == "" && strings.TrimSpace(fallback.Path) != "" {
|
||||
@@ -814,94 +780,8 @@ func episodeResponseShell(ep *models.Episode, fallback episodeImageFallback) (ep
|
||||
resp.AirDate = ep.AirDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
return resp, cardThumbnailPath(stillPath)
|
||||
}
|
||||
resp.StillURL = h.presignURL(r, cardThumbnailPath(stillPath), "card")
|
||||
|
||||
// buildEpisodeResponses converts episodes to API responses using batched
|
||||
// lookups — localization, media files, watch progress, and image presigning
|
||||
// each resolve in one round-trip for the whole list instead of per episode.
|
||||
func (h *ItemsHandler) buildEpisodeResponses(r *http.Request, episodes []*models.Episode) []episodeResponse {
|
||||
ctx := r.Context()
|
||||
filter := h.accessFilter(r)
|
||||
|
||||
if h.detailSvc != nil {
|
||||
if localized, err := h.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); err == nil && len(localized) == len(episodes) {
|
||||
episodes = localized
|
||||
}
|
||||
}
|
||||
|
||||
fallbacks := h.episodeImageFallbacks(ctx, episodes)
|
||||
|
||||
episodeIDs := make([]string, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep != nil && ep.ContentID != "" {
|
||||
episodeIDs = append(episodeIDs, ep.ContentID)
|
||||
}
|
||||
}
|
||||
filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs)
|
||||
userData := h.listLeafUserData(r, episodeIDs)
|
||||
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
stillPaths := make([]string, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep == nil {
|
||||
continue
|
||||
}
|
||||
shell, stillPath := episodeResponseShell(ep, fallbacks[ep.SeriesID])
|
||||
resp = append(resp, shell)
|
||||
stillPaths = append(stillPaths, stillPath)
|
||||
}
|
||||
|
||||
stillURLs := map[string]catalog.ResolvedImageURL{}
|
||||
if h.detailSvc != nil {
|
||||
stillURLs = h.detailSvc.PresignURLsWithExpiry(ctx, stillPaths, "card")
|
||||
}
|
||||
for i := range resp {
|
||||
resp[i].StillURL = stillURLs[stillPaths[i]].URL
|
||||
resp[i].Files = episodeFileResponses(filesByEpisode[resp[i].ContentID], filter)
|
||||
resp[i].UserData = userData[resp[i].ContentID]
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// listEpisodeFiles batch-fetches media files keyed by episode ID, falling back
|
||||
// to per-episode lookups when the repository lacks batch support.
|
||||
func (h *ItemsHandler) listEpisodeFiles(ctx context.Context, episodeIDs []string) map[string][]*models.MediaFile {
|
||||
if h.fileRepo == nil || len(episodeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok {
|
||||
files, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return files
|
||||
}
|
||||
files := make(map[string][]*models.MediaFile, len(episodeIDs))
|
||||
for _, id := range episodeIDs {
|
||||
if list, err := h.fileRepo.GetByEpisodeID(ctx, id); err == nil {
|
||||
files[id] = list
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func episodeFileResponses(files []*models.MediaFile, filter catalog.AccessFilter) []episodeFileResponse {
|
||||
var resp []episodeFileResponse
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -970,7 +850,6 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
filter catalog.AccessFilter,
|
||||
overlaySummaries map[string]*models.OverlaySummary,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
) map[string]*sortMetricsResponse {
|
||||
metrics := make(map[string]*sortMetricsResponse, len(items))
|
||||
@@ -1011,97 +890,16 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
}
|
||||
}
|
||||
case "progress", "date_viewed", "plays":
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
|
||||
case "author", "narrator", "series":
|
||||
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listAudiobookSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
contentIDs := uniqueItemContentIDs(items)
|
||||
if len(contentIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var query string
|
||||
switch sortField {
|
||||
case "author":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
|
||||
case "narrator":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
|
||||
case "series":
|
||||
query = `
|
||||
SELECT target.content_id, BTRIM(s.series_name) AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN audiobook_series s ON s.content_id = target.content_id
|
||||
WHERE BTRIM(s.series_name) <> ''`
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var contentID, value string
|
||||
if err := rows.Scan(&contentID, &value); err != nil {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if contentID == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
resp := metrics[contentID]
|
||||
if resp == nil {
|
||||
resp = &sortMetricsResponse{}
|
||||
metrics[contentID] = resp
|
||||
}
|
||||
switch sortField {
|
||||
case "author":
|
||||
resp.Author = value
|
||||
case "narrator":
|
||||
resp.Narrator = value
|
||||
case "series":
|
||||
resp.SeriesName = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audiobookPersonSortMetricQuery(kind int) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT target.content_id, person.name AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN LATERAL (
|
||||
SELECT BTRIM(p.name) AS name
|
||||
FROM item_people ip
|
||||
JOIN people p ON p.id = ip.person_id
|
||||
WHERE ip.content_id = target.content_id
|
||||
AND ip.kind = %d
|
||||
AND p.name IS NOT NULL
|
||||
AND BTRIM(p.name) <> ''
|
||||
ORDER BY p.name ASC
|
||||
LIMIT 1
|
||||
) person ON TRUE`, kind)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listUserSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
@@ -1117,10 +915,6 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch sortField {
|
||||
case "progress":
|
||||
@@ -1129,16 +923,10 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
continue
|
||||
}
|
||||
progress, ok := progressMap[item.ContentID]
|
||||
if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 {
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
ebookProgress, ok := ebookProgressMap[item.ContentID]
|
||||
if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= models.EbookFinishedProgressThreshold {
|
||||
continue
|
||||
}
|
||||
ratio := ebookProgress.Progress
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
}
|
||||
case "date_viewed", "plays":
|
||||
@@ -1167,12 +955,6 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
|
||||
viewedAt = progress.UpdatedAt
|
||||
}
|
||||
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold {
|
||||
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
if ebookViewedAt > viewedAt {
|
||||
viewedAt = ebookViewedAt
|
||||
}
|
||||
}
|
||||
if viewedAt == "" {
|
||||
continue
|
||||
}
|
||||
@@ -1182,9 +964,6 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if playCount <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -1195,18 +974,6 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listEbookReaderProgressByContentIDs(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs)
|
||||
}
|
||||
|
||||
func uniqueItemContentIDs(items []*models.MediaItem) []string {
|
||||
contentIDs := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
@@ -1343,11 +1110,7 @@ func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *mod
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData {
|
||||
if len(itemType) > 0 && itemType[0] == "ebook" {
|
||||
return h.getEbookLeafUserData(r, contentID)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
||||
store, profileID, ok := h.userStoreForRequest(r)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -1358,34 +1121,10 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemTy
|
||||
return nil
|
||||
}
|
||||
|
||||
return leafUserDataFromProgress(*progress)
|
||||
}
|
||||
|
||||
// listLeafUserData batch-fetches watch progress for the given content IDs in a
|
||||
// single query; the result only holds entries for items with progress rows.
|
||||
func (h *ItemsHandler) listLeafUserData(r *http.Request, contentIDs []string) map[string]*catalog.SeasonUserData {
|
||||
store, profileID, ok := h.userStoreForRequest(r)
|
||||
if !ok || len(contentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
progressMap, err := store.ListProgressByMediaItems(r.Context(), profileID, contentIDs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]*catalog.SeasonUserData, len(progressMap))
|
||||
for contentID, progress := range progressMap {
|
||||
result[contentID] = leafUserDataFromProgress(progress)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func leafUserDataFromProgress(progress userstore.WatchProgress) *catalog.SeasonUserData {
|
||||
return &catalog.SeasonUserData{
|
||||
PositionSeconds: progress.PositionSeconds,
|
||||
DurationSeconds: progress.DurationSeconds,
|
||||
IsInProgress: progress.PositionSeconds > 0,
|
||||
IsInProgress: !progress.Completed && progress.PositionSeconds > 0,
|
||||
Played: progress.Completed,
|
||||
LastFileID: progress.LastFileID,
|
||||
LastResolution: progress.LastResolution,
|
||||
@@ -1395,38 +1134,6 @@ func leafUserDataFromProgress(progress userstore.WatchProgress) *catalog.SeasonU
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
||||
if h == nil || h.ebookProgressStore == nil {
|
||||
return nil
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := requestProfileID(r)
|
||||
if userID <= 0 || profileID == "" || contentID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
row, ok := progress[contentID]
|
||||
if !ok || row.Progress <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ebooks have no playback duration, so PositionSeconds/DurationSeconds
|
||||
// encode the 0..1 reading ratio (position=ratio, duration=1). Clients can
|
||||
// derive percent-complete from the pair as usual, but rendering them as
|
||||
// absolute times is meaningless for ebooks. Changing the wire shape needs
|
||||
// coordinated Android/Apple client updates.
|
||||
return &catalog.SeasonUserData{
|
||||
PositionSeconds: row.Progress,
|
||||
DurationSeconds: 1,
|
||||
IsInProgress: row.Progress < models.EbookFinishedProgressThreshold,
|
||||
Played: row.Progress >= models.EbookFinishedProgressThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
|
||||
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
|
||||
return
|
||||
@@ -1471,16 +1178,6 @@ func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.
|
||||
}
|
||||
}
|
||||
|
||||
// requestProfileID resolves the active profile for a request, falling back to
|
||||
// the X-Profile-Id header when the auth context does not carry one.
|
||||
func requestProfileID(r *http.Request) string {
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
return profileID
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) {
|
||||
if h.storeProvider == nil {
|
||||
return nil, "", false
|
||||
@@ -1491,7 +1188,10 @@ func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
profileID := requestProfileID(r)
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if profileID == "" {
|
||||
return nil, "", false
|
||||
}
|
||||
@@ -1615,15 +1315,14 @@ func (h *ItemsHandler) accessFilter(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,
|
||||
PresentationLibraryID: presentationLibraryID,
|
||||
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1686,10 +1385,6 @@ func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID stri
|
||||
ContentID: item.ContentID,
|
||||
DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime),
|
||||
}}, nil
|
||||
case "ebook":
|
||||
// Ebooks have no playback duration; read state is keyed off
|
||||
// ebook_reader_progress, so the leaf target only carries the ID.
|
||||
return "ebook", []watchedLeafTarget{{ContentID: item.ContentID}}, nil
|
||||
case "series":
|
||||
if h.episodeRepo == nil {
|
||||
return "", nil, catalog.ErrItemNotFound
|
||||
@@ -1800,6 +1495,9 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if h == nil || h.UserRepo == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -40,145 +40,20 @@ func TestListSortMetricsIncludesUserStateSorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
progress := progressMetrics["movie-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
plays := playsMetrics["movie-plays"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSortMetricsIncludesEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-progress": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.37,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
"ebook-complete": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-progress", Type: "ebook", Title: "Progress"},
|
||||
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete"},
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
progress := progressMetrics["ebook-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.37 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := viewedMetrics["ebook-complete"]; got == nil || got.ViewedAt != "2026-06-03T10:15:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
plays := playsMetrics["ebook-complete"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 1 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSortMetricsExcludesHiddenEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-hidden": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-hidden",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
"ebook-reread": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-reread",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt.Add(2 * time.Hour),
|
||||
},
|
||||
},
|
||||
hiddenBefore: map[string]time.Time{
|
||||
"ebook-hidden": updatedAt.Add(time.Hour),
|
||||
"ebook-reread": updatedAt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden"},
|
||||
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread"},
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := viewedMetrics["ebook-hidden"]; got != nil {
|
||||
t.Fatalf("hidden ebook must have no viewed metrics, got %#v", got)
|
||||
}
|
||||
if got := viewedMetrics["ebook-reread"]; got == nil || got.ViewedAt != "2026-06-03T11:15:00Z" {
|
||||
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := playsMetrics["ebook-hidden"]; got != nil {
|
||||
t.Fatalf("hidden ebook must have no play count, got %#v", got)
|
||||
}
|
||||
if got := playsMetrics["ebook-reread"]; got == nil || got.PlayCount == nil || *got.PlayCount != 1 {
|
||||
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEbookReaderProgressLister struct {
|
||||
progress map[string]EbookReaderProgress
|
||||
// hiddenBefore models user_history_hidden_items: like the PG store, a
|
||||
// row is excluded while progress.UpdatedAt <= hidden_before and counts
|
||||
// again once the progress row is updated after the hide.
|
||||
hiddenBefore map[string]time.Time
|
||||
}
|
||||
|
||||
func (f *fakeEbookReaderProgressLister) ListByContentIDs(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
result := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
progress, ok := f.progress[contentID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if hiddenBefore, hidden := f.hiddenBefore[contentID]; hidden && !progress.UpdatedAt.After(hiddenBefore) {
|
||||
continue
|
||||
}
|
||||
result[contentID] = progress
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-progress": {
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.42,
|
||||
UpdatedAt: time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"ebook-complete": {
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.93,
|
||||
UpdatedAt: time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/items/ebook-progress", nil)
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
progress := handler.getLeafUserData(req, "ebook-progress", "ebook")
|
||||
if progress == nil || progress.Played || !progress.IsInProgress || progress.PositionSeconds != 0.42 || progress.DurationSeconds != 1 {
|
||||
t.Fatalf("partial ebook user data = %#v", progress)
|
||||
}
|
||||
|
||||
complete := handler.getLeafUserData(req, "ebook-complete", "ebook")
|
||||
if complete == nil || !complete.Played || complete.IsInProgress {
|
||||
t.Fatalf("completed ebook user data = %#v", complete)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// This file holds tri-state JSON field wrappers for PATCH-style request
|
||||
// bodies. Plain Go pointers can only distinguish two states (absent/nil vs.
|
||||
// value); these wrappers track three:
|
||||
//
|
||||
// - absent: the key was not present in the JSON body (Set = false) —
|
||||
// the caller should leave the current value unchanged
|
||||
// - null: the key was present with an explicit JSON null (Set = true)
|
||||
// - value: the key was present with a concrete value (Set = true)
|
||||
//
|
||||
// How null is decoded depends on the wrapper: optionalIntSliceField keeps a
|
||||
// nil slice (e.g. "all libraries"), optionalStringSliceField normalizes null
|
||||
// to an empty slice.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// optionalIntSliceField distinguishes an absent JSON field from an explicit
|
||||
// null or array value. Absent = don't update; null = nil slice ("all
|
||||
// libraries"); [] = empty slice ("none").
|
||||
type optionalIntSliceField struct {
|
||||
Set bool
|
||||
Value []int
|
||||
}
|
||||
|
||||
func (f *optionalIntSliceField) 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)
|
||||
}
|
||||
|
||||
// Ptr returns nil when the field was absent, otherwise a pointer to the
|
||||
// decoded slice (which may itself be nil for JSON null).
|
||||
func (f optionalIntSliceField) Ptr() *[]int {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := f.Value
|
||||
return &value
|
||||
}
|
||||
|
||||
// optionalStringSliceField distinguishes an absent JSON field from an
|
||||
// explicit null or array value. JSON null decodes to an empty slice.
|
||||
type optionalStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *optionalStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
@@ -192,7 +192,6 @@ type updateLibraryRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
MetadataLanguage *string `json:"metadata_language,omitempty"`
|
||||
AutoTranslateMetadata *bool `json:"auto_translate_metadata,omitempty"`
|
||||
ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"`
|
||||
IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"`
|
||||
}
|
||||
@@ -227,7 +226,6 @@ type libraryResponse struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MetadataLanguage string `json:"metadata_language"`
|
||||
AutoTranslateMetadata bool `json:"auto_translate_metadata"`
|
||||
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"`
|
||||
ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"`
|
||||
IntroDetectionEnabled bool `json:"intro_detection_enabled"`
|
||||
@@ -350,7 +348,6 @@ func toLibraryResponse(f *models.MediaFolder) libraryResponse {
|
||||
Name: f.Name,
|
||||
Enabled: f.Enabled,
|
||||
MetadataLanguage: f.MetadataLanguage,
|
||||
AutoTranslateMetadata: f.AutoTranslateMetadata,
|
||||
ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled,
|
||||
ChapterThumbnailsSupported: false,
|
||||
IntroDetectionEnabled: f.IntroDetectionEnabled,
|
||||
@@ -577,11 +574,6 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
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 sections.IsAudiobookLibraryType(folder.Type) {
|
||||
if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil {
|
||||
slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
}
|
||||
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
|
||||
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
@@ -655,7 +647,6 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
Name: req.Name,
|
||||
Enabled: req.Enabled,
|
||||
MetadataLanguage: req.MetadataLanguage,
|
||||
AutoTranslateMetadata: req.AutoTranslateMetadata,
|
||||
ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled,
|
||||
IntroDetectionEnabled: req.IntroDetectionEnabled,
|
||||
})
|
||||
@@ -2038,8 +2029,18 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
return nil
|
||||
}
|
||||
|
||||
levels := metadataContentLevelsForLibraryType(libraryType)
|
||||
if len(levels) == 0 {
|
||||
// Determine which content levels apply.
|
||||
var levels []string
|
||||
switch libraryType {
|
||||
case "series":
|
||||
levels = []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
levels = []string{"movie"}
|
||||
case "audiobooks", "audiobook":
|
||||
levels = []string{"audiobook"}
|
||||
case "mixed":
|
||||
levels = []string{"movie", "series", "season", "episode", "audiobook"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2054,7 +2055,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
var candidates []candidate
|
||||
|
||||
for _, c := range caps {
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level)
|
||||
if defaultPriority > 0 {
|
||||
candidates = append(candidates, candidate{
|
||||
installationID: c.PluginInstallationID,
|
||||
@@ -2092,23 +2093,6 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
return entries
|
||||
}
|
||||
|
||||
func metadataContentLevelsForLibraryType(libraryType string) []string {
|
||||
switch libraryType {
|
||||
case "series":
|
||||
return []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
return []string{"movie"}
|
||||
case "audiobooks", "audiobook":
|
||||
return []string{"audiobook"}
|
||||
case "ebooks", "ebook":
|
||||
return []string{"ebook"}
|
||||
case "mixed":
|
||||
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListStaleIDs handles GET /libraries/stale-ids.
|
||||
func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) {
|
||||
if h.StaleIDRepo == nil {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
libraryType string
|
||||
want []string
|
||||
}{
|
||||
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
|
||||
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
|
||||
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := metadataContentLevelsForLibraryType(tc.libraryType); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("metadataContentLevelsForLibraryType(%q) = %#v, want %#v", tc.libraryType, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,9 @@ func (h *MarkersHandler) requireMarkerEdit(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if h == nil || h.Users == nil {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
|
||||
return false
|
||||
|
||||
@@ -90,6 +90,7 @@ func markerItemPutRequest(body string) *http.Request {
|
||||
func withMarkerAdminClaims(req *http.Request) *http.Request {
|
||||
return req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
SessionID: "session-1",
|
||||
}))
|
||||
@@ -97,12 +98,7 @@ func withMarkerAdminClaims(req *http.Request) *http.Request {
|
||||
|
||||
func newMarkersHandler(writer ManualMarkerWriter) *MarkersHandler {
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{
|
||||
1: {ID: 1, Enabled: true, IsAdmin: true},
|
||||
7: {ID: 7, Enabled: true, IsAdmin: true},
|
||||
}
|
||||
return h
|
||||
return NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
func TestSetFileMarkersWritesManual(t *testing.T) {
|
||||
@@ -135,7 +131,6 @@ func TestSetItemMarkersWritesPrimaryEpisodeFile(t *testing.T) {
|
||||
episodeFiles: []*models.MediaFile{file},
|
||||
}
|
||||
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetItemMarkers(rec, markerItemPutRequest(`{"recap":{"start":0,"end":45}}`))
|
||||
@@ -214,13 +209,13 @@ func TestGetItemMarkersUsesFirstAuthorizedFile(t *testing.T) {
|
||||
func TestGetFileMarkersDoesNotRequireMarkerEditPermission(t *testing.T) {
|
||||
h := newMarkersHandler(nil)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/markers/files/5", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetFileMarkers(rec, req)
|
||||
@@ -234,10 +229,10 @@ func TestSetFileMarkersRejectsUserWithoutMarkerEditPermission(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
|
||||
}
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
@@ -254,10 +249,10 @@ func TestSetFileMarkersAllowsUserWithMarkerEditPermission(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: []string{"marker_edit"}},
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{"marker_edit"}},
|
||||
}
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
@@ -279,6 +274,7 @@ func TestSetFileMarkersPassesAuditContextToWriter(t *testing.T) {
|
||||
ctx := clientip.SetContext(req.Context(), "203.0.113.10")
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{
|
||||
UserID: 7,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAPIKey,
|
||||
APIKeyID: apiKeyID,
|
||||
})
|
||||
@@ -386,7 +382,6 @@ func TestSetFileMarkersTriggersBackgroundContribution(t *testing.T) {
|
||||
called := make(chan markers.ContributeOptions, 1)
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"intro":{"start":0,"end":60}}`))
|
||||
@@ -408,7 +403,6 @@ func TestSetFileMarkersClearOnlyDoesNotContribute(t *testing.T) {
|
||||
called := make(chan markers.ContributeOptions, 1)
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"credits":null}`))
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata/translation"
|
||||
)
|
||||
|
||||
// MetadataAIHandler exposes AI translation of catalog descriptions into the
|
||||
// localization tables. The admin routes are mounted under the per-item
|
||||
// metadata curation guard; the on-view route is viewer-facing and enforces
|
||||
// item access itself.
|
||||
type MetadataAIHandler struct {
|
||||
service *translation.Service
|
||||
// ItemAccess authorizes the viewer-facing on-view route; nil disables it.
|
||||
ItemAccess *catalog.ItemRepository
|
||||
}
|
||||
|
||||
// NewMetadataAIHandler creates a handler backed by the given service.
|
||||
func NewMetadataAIHandler(service *translation.Service) *MetadataAIHandler {
|
||||
return &MetadataAIHandler{service: service}
|
||||
}
|
||||
|
||||
// HandleStatus reports whether metadata AI translation is available and the
|
||||
// viewer-facing on-view mode, so the metadata editor and detail pages can
|
||||
// show or hide their entry points.
|
||||
// GET /api/v1/metadata/ai/status
|
||||
func (h *MetadataAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": h.service.Enabled(),
|
||||
"on_view": h.service.OnViewMode(),
|
||||
})
|
||||
}
|
||||
|
||||
// WriteMetadataAIDisabledStatus answers the status probe with a clean negative
|
||||
// when no metadata AI handler is wired.
|
||||
func WriteMetadataAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "on_view": "off"})
|
||||
}
|
||||
|
||||
type translateDescriptionRequest struct {
|
||||
// TargetLanguage echoes the detail response's pending_translation_language.
|
||||
TargetLanguage string `json:"target_language"`
|
||||
}
|
||||
|
||||
// HandleTranslateOnView is the viewer-facing on-demand description
|
||||
// translation: any profile that can access the item may request its
|
||||
// descriptions in the language the detail response reported missing. Gated by
|
||||
// metadata_ai.on_view; duplicate viewers collapse onto one job and recently
|
||||
// failed targets are not retried (cooldown in the service).
|
||||
// POST /api/v1/items/{id}/translate-description
|
||||
func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
var req translateDescriptionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
|
||||
scope, ok := access.GetScope(r.Context())
|
||||
if !ok || h.ItemAccess == nil {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Viewer access is required")
|
||||
return
|
||||
}
|
||||
filter := catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
UserID: scope.UserID,
|
||||
ProfileID: scope.ProfileID,
|
||||
}
|
||||
if err := h.ItemAccess.EnsureAccessible(r.Context(), contentID, filter); err != nil {
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
|
||||
return
|
||||
}
|
||||
|
||||
var requestedBy *int
|
||||
if userID := apimw.GetUserID(r.Context()); userID != 0 {
|
||||
requestedBy = &userID
|
||||
}
|
||||
|
||||
job, err := h.service.RequestOnView(r.Context(), contentID, req.TargetLanguage, requestedBy)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, translation.ErrNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"On-view translation is not enabled on this server")
|
||||
case errors.Is(err, translation.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to request on-view translation",
|
||||
"content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
type translateMetadataRequest struct {
|
||||
TargetLanguage string `json:"target_language"`
|
||||
IncludeChildren *bool `json:"include_children"` // default true
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// HandleTranslate enqueues a translation job for an item.
|
||||
// POST /api/v1/admin/items/{id}/metadata-translation
|
||||
func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
var req translateMetadataRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
includeChildren := true
|
||||
if req.IncludeChildren != nil {
|
||||
includeChildren = *req.IncludeChildren
|
||||
}
|
||||
|
||||
var requestedBy *int
|
||||
if userID := apimw.GetUserID(r.Context()); userID != 0 {
|
||||
requestedBy = &userID
|
||||
}
|
||||
|
||||
job, err := h.service.Enqueue(r.Context(), translation.JobRequest{
|
||||
TargetKind: translation.TargetItem,
|
||||
ContentID: contentID,
|
||||
TargetLanguage: req.TargetLanguage,
|
||||
IncludeChildren: includeChildren,
|
||||
Force: req.Force,
|
||||
RequestedBy: requestedBy,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, translation.ErrNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"Metadata AI translation is not configured on this server")
|
||||
case errors.Is(err, translation.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to enqueue metadata translation",
|
||||
"content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
// HandleListJobs lists recent translation jobs for an item; the metadata
|
||||
// editor polls this for progress.
|
||||
// GET /api/v1/admin/items/{id}/metadata-translation/jobs
|
||||
func (h *MetadataAIHandler) HandleListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
jobs, err := h.service.ListJobs(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list jobs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
|
||||
}
|
||||
|
||||
// HandleCancelJob cancels a job belonging to the item in the URL.
|
||||
// POST /api/v1/admin/items/{id}/metadata-translation/jobs/{job_id}/cancel
|
||||
func (h *MetadataAIHandler) HandleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
jobID, err := strconv.ParseInt(chi.URLParam(r, "job_id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid job ID")
|
||||
return
|
||||
}
|
||||
job, err := h.service.GetJob(r.Context(), jobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, translation.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load job")
|
||||
return
|
||||
}
|
||||
// The curation guard authorized {id}; the job must belong to it.
|
||||
if job.ContentID != chi.URLParam(r, "id") {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
if err := h.service.Cancel(r.Context(), jobID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type NodeRepository interface {
|
||||
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, egressKbps int) error
|
||||
UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs int) error
|
||||
}
|
||||
|
||||
// NodeListEnabled queries enabled nodes by type for pool reload.
|
||||
@@ -70,7 +70,6 @@ type ForceReloadResult struct {
|
||||
type checkNodeResult struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
ActiveJobs int `json:"active_jobs"`
|
||||
EgressKbps int `json:"egress_kbps"`
|
||||
}
|
||||
|
||||
// HandleListNodes handles GET /admin/nodes.
|
||||
@@ -182,16 +181,15 @@ func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
healthy, activeJobs, egressKbps := nodepool.CheckNode(r.Context(), node)
|
||||
healthy, activeJobs := nodepool.CheckNode(r.Context(), node)
|
||||
|
||||
if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs, egressKbps); err != nil {
|
||||
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,
|
||||
EgressKbps: egressKbps,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,6 @@ type SessionManagerInterface interface {
|
||||
SetEffectiveMediaFileID(sessionID string, fileID int) error
|
||||
SetTranscodeNodeURL(sessionID, url string) error
|
||||
SetWebSocket(sessionID string, connected bool) error
|
||||
SetRealtimeConnection(sessionID string, connected bool) error
|
||||
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
|
||||
StopSession(sessionID string) error
|
||||
GetSession(sessionID string) (*playback.Session, error)
|
||||
}
|
||||
@@ -59,6 +57,17 @@ 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
|
||||
}
|
||||
@@ -108,7 +117,8 @@ type PlaybackHandler struct {
|
||||
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
|
||||
EventsHub *evt.Hub
|
||||
MissingMarker MissingFileMarker
|
||||
NodePlanner nodepool.SessionPlanner // optional; enables proxy/transcode node selection
|
||||
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
|
||||
@@ -247,7 +257,6 @@ type startPlaybackRequest struct {
|
||||
StartPosition *float64 `json:"start_position,omitempty"`
|
||||
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
|
||||
PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"`
|
||||
DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"`
|
||||
CodecsVideo []string `json:"codecs_video"`
|
||||
CodecsAudio []string `json:"codecs_audio"`
|
||||
Containers []string `json:"containers"`
|
||||
@@ -433,13 +442,6 @@ func playbackStreamURL(s *playback.Session) string {
|
||||
return fmt.Sprintf("/stream/%s", s.ID)
|
||||
}
|
||||
|
||||
func fileBitrateKbps(file *models.MediaFile) int {
|
||||
if file == nil || file.Bitrate <= 0 {
|
||||
return 0
|
||||
}
|
||||
return file.Bitrate
|
||||
}
|
||||
|
||||
func buildPlaybackInfo(session *playback.Session, file *models.MediaFile) *playbackInfoResult {
|
||||
if session == nil {
|
||||
return nil
|
||||
@@ -759,9 +761,6 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return
|
||||
}
|
||||
if session == nil || session.DisableProgressPersistence {
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||||
targetID := playbackProgressTarget(file)
|
||||
@@ -800,7 +799,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
|
||||
if session == nil || session.Position <= 0 {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
|
||||
@@ -1282,13 +1281,6 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
setPlaybackSessionLogContext(r, session.ID)
|
||||
if req.DisableProgressPersistence {
|
||||
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
|
||||
slog.Error("failed to disable progress persistence", "session", session.ID, "error", err)
|
||||
} else {
|
||||
session.DisableProgressPersistence = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
|
||||
slog.Error("failed to set audio track", "session", session.ID, "error", err)
|
||||
@@ -1328,7 +1320,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
} else {
|
||||
h.restoreSessionProgress(r.Context(), session, file)
|
||||
}
|
||||
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
if h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
targetID := playbackProgressTarget(effectiveFile)
|
||||
if targetID != "" {
|
||||
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
|
||||
@@ -1367,14 +1359,10 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
resp.SubtitleURLs = buildSubtitleURLs(session.ID, effectiveFile, downloadedSubs)
|
||||
|
||||
// If stream nodes are available, generate proxy-based stream URLs.
|
||||
// Remux and transcode both use HLS via a transcode node, so the planner
|
||||
// picks the transcode node and its group's proxy together.
|
||||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||||
needsTranscode := session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux
|
||||
plan := h.NodePlanner.PlanSession(session.ID, "", needsTranscode, fileBitrateKbps(effectiveFile))
|
||||
proxyNode := plan.ProxyNode
|
||||
if proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||||
// 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),
|
||||
@@ -1388,9 +1376,12 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
tokenClaims.TranscodeAudio = session.TranscodeAudio
|
||||
tokenClaims.AudioTrackIndex = session.AudioTrackIndex
|
||||
|
||||
if plan.TranscodeNode != nil {
|
||||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, plan.TranscodeNode.URL)
|
||||
// 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)
|
||||
@@ -1558,7 +1549,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
|
||||
// Persist progress to UserStore (best-effort).
|
||||
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
|
||||
h.persistProgress(r.Context(), sess)
|
||||
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
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 != "" {
|
||||
@@ -1767,14 +1758,8 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
PlaybackInfo: buildPlaybackInfo(&updatedSession, file),
|
||||
}
|
||||
|
||||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||||
needsTranscode := updatedSession.PlayMethod == playback.PlayTranscode
|
||||
estKbps := updatedSession.TargetBitrateKbps
|
||||
if estKbps <= 0 {
|
||||
estKbps = fileBitrateKbps(file)
|
||||
}
|
||||
plan := h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, needsTranscode, estKbps)
|
||||
if proxyNode := plan.ProxyNode; proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||||
if h.ProxyPool != nil && h.JWTSecret != "" {
|
||||
if proxyNode := h.ProxyPool.Pick(); proxyNode != nil {
|
||||
tokenClaims := streamtoken.Claims{
|
||||
SessionID: sessionID,
|
||||
PlayMethod: string(updatedSession.PlayMethod),
|
||||
@@ -1782,9 +1767,11 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
TranscodeAudio: updatedSession.TranscodeAudio,
|
||||
AudioTrackIndex: req.AudioTrackIndex,
|
||||
}
|
||||
if plan.TranscodeNode != nil {
|
||||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(sessionID, plan.TranscodeNode.URL)
|
||||
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 {
|
||||
@@ -2037,15 +2024,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Determine whether to run locally or forward to a remote transcode node.
|
||||
var plan nodepool.Plan
|
||||
if h.NodePlanner != nil {
|
||||
estKbps := req.TargetBitrateKbps
|
||||
if estKbps <= 0 {
|
||||
estKbps = fileBitrateKbps(file)
|
||||
}
|
||||
plan = h.NodePlanner.PlanSession(req.SessionID, session.TranscodeNodeURL, true, estKbps)
|
||||
}
|
||||
tcNode := plan.TranscodeNode
|
||||
tcNode := h.PickTranscodeNode(session.TranscodeNodeURL)
|
||||
|
||||
if tcNode != nil {
|
||||
// Remote transcode: forward to the assigned node.
|
||||
@@ -2110,7 +2089,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel)
|
||||
}
|
||||
|
||||
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL, plan.ProxyNode)
|
||||
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL)
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{
|
||||
req: req,
|
||||
file: file,
|
||||
@@ -2123,13 +2102,6 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Local transcode (integrated mode — no transcode nodes available).
|
||||
// In distributed mode admins can disable this fallback so the API server
|
||||
// never transcodes when no eligible node exists.
|
||||
if h.NodePlanner != nil && !nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_transcode_node",
|
||||
"No transcode node is available and local transcode fallback is disabled")
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
|
||||
return
|
||||
@@ -2471,11 +2443,40 @@ func (h *PlaybackHandler) closeTranscodeSession(sessionID, transcodeNodeURL stri
|
||||
}
|
||||
}
|
||||
|
||||
// buildProxyManifestURL signs a stream token and builds the proxy-based
|
||||
// manifest URL. proxyNode is the planner's pick for this session; when nil
|
||||
// the URL falls back to the API-local path.
|
||||
func (h *PlaybackHandler) buildProxyManifestURL(sessionID string, session *playback.Session, transcodeNodeURL string, proxyNode *nodepool.Node) string {
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -129,10 +129,6 @@ func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return
|
||||
|
||||
func (failingSessionManager) SetWebSocket(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) SetRealtimeConnection(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) SetProgressPersistenceDisabled(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) StopSession(string) error { return nil }
|
||||
|
||||
func (failingSessionManager) GetSession(string) (*playback.Session, error) { return nil, nil }
|
||||
@@ -162,7 +158,7 @@ func newPlaybackTestStore(t *testing.T) userstore.UserStore {
|
||||
|
||||
func newAuthorizedPlaybackContext() context.Context {
|
||||
ctx := context.Background()
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
return apimw.SetProfileID(ctx, "profile-1")
|
||||
}
|
||||
|
||||
@@ -287,64 +283,6 @@ func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume(
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionProgressPersistenceCanBeDisabled(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
if err := store.SetProgress(context.Background(), "profile-1", "book-1", 500, 1200, userstore.ProgressThresholds{}); err != nil {
|
||||
t.Fatalf("seed progress: %v", err)
|
||||
}
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "book-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "book.m4b"),
|
||||
Duration: 600,
|
||||
}
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.StoreProvider = testUserStoreProvider{store: store}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","start_position":120,"disable_progress_persistence":true}`))
|
||||
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 resp playbackSessionResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
progressReq := httptest.NewRequest("POST", "/api/v1/playback/"+resp.SessionID+"/progress", strings.NewReader(`{"position":240,"is_paused":false}`))
|
||||
progressReq = progressReq.WithContext(newAuthorizedPlaybackContext())
|
||||
progressReq = withPlaybackRouteParam(progressReq, "session_id", resp.SessionID)
|
||||
progressRR := httptest.NewRecorder()
|
||||
handler.HandleUpdateProgress(progressRR, progressReq)
|
||||
if progressRR.Code != 204 {
|
||||
t.Fatalf("progress status = %d, body = %s", progressRR.Code, progressRR.Body.String())
|
||||
}
|
||||
|
||||
stopReq := httptest.NewRequest("DELETE", "/api/v1/playback/"+resp.SessionID, nil)
|
||||
stopReq = stopReq.WithContext(newAuthorizedPlaybackContext())
|
||||
stopReq = withPlaybackRouteParam(stopReq, "session_id", resp.SessionID)
|
||||
stopRR := httptest.NewRecorder()
|
||||
handler.HandleStopPlayback(stopRR, stopReq)
|
||||
if stopRR.Code != 204 {
|
||||
t.Fatalf("stop status = %d, body = %s", stopRR.Code, stopRR.Body.String())
|
||||
}
|
||||
|
||||
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get progress: %v", err)
|
||||
}
|
||||
if progress == nil || progress.PositionSeconds != 500 || progress.DurationSeconds != 1200 {
|
||||
t.Fatalf("progress after disabled session = %+v, want 500/1200", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
|
||||
file := &models.MediaFile{Duration: 3600}
|
||||
|
||||
@@ -1117,7 +1055,7 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
|
||||
Healthy: true,
|
||||
ActiveJobs: 0,
|
||||
}})
|
||||
handler.NodePlanner = nodepool.NewPlanner(nodepool.NewProxyPool(), pool)
|
||||
handler.TranscodePool = pool
|
||||
|
||||
transcodeReq := httptest.NewRequest(
|
||||
"POST",
|
||||
|
||||
@@ -169,27 +169,22 @@ type pluginConfigSchemaJSON struct {
|
||||
}
|
||||
|
||||
type pluginAdminFormJSON struct {
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
|
||||
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"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
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 {
|
||||
@@ -198,31 +193,6 @@ type pluginAdminFormOptionJSON struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormConditionJSON struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type pluginAdminFormValidationJSON struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormSectionJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
}
|
||||
|
||||
type pluginCapabilityJSON struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
@@ -690,7 +660,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
|
||||
continue
|
||||
}
|
||||
if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int {
|
||||
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
|
||||
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,
|
||||
@@ -1372,78 +1342,26 @@ func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *pluginAdminFormValidationJSON
|
||||
if v := field.GetValidation(); v != nil {
|
||||
validation = &pluginAdminFormValidationJSON{
|
||||
HasMin: v.GetHasMin(),
|
||||
Min: v.GetMin(),
|
||||
HasMax: v.GetHasMax(),
|
||||
Max: v.GetMax(),
|
||||
Pattern: v.GetPattern(),
|
||||
MinLength: v.GetMinLength(),
|
||||
MaxLength: v.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
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(),
|
||||
DynamicOptions: field.GetDynamicOptions(),
|
||||
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
|
||||
Validation: validation,
|
||||
ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, pluginAdminFormSectionJSON{
|
||||
Key: section.GetKey(),
|
||||
Title: section.GetTitle(),
|
||||
Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(),
|
||||
CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
|
||||
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
|
||||
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(),
|
||||
Sections: sections,
|
||||
}
|
||||
}
|
||||
|
||||
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
|
||||
if len(conditions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, pluginAdminFormConditionJSON{
|
||||
Field: condition.GetField(),
|
||||
Equals: append([]string(nil), condition.GetEquals()...),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
|
||||
response := make([]pluginCapabilityJSON, 0, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// TestAdminFormToJSONSchemaFields locks the bridge between the SDK proto
|
||||
// AdminForm descriptor and the JSON the frontend SchemaForm consumes. Without
|
||||
// this, the schema-driven request-connection form silently loses its
|
||||
// dynamic dropdowns (dynamic_options), conditional visibility (show_when),
|
||||
// validation, sections, and the MULTI_SELECT control mapping, which makes the
|
||||
// new form non-functional (required fields can never be set, save stays
|
||||
// disabled).
|
||||
func TestAdminFormToJSONSchemaFields(t *testing.T) {
|
||||
descriptor := &pluginv1.AdminFormDescriptor{
|
||||
SubmitLabel: "Connect",
|
||||
Fields: []*pluginv1.AdminFormField{
|
||||
{
|
||||
Key: "quality_profile_ids",
|
||||
Label: "Quality Profiles",
|
||||
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT,
|
||||
DynamicOptions: true,
|
||||
ExclusiveGroupField: "service_kind",
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{
|
||||
{Field: "service_type", Equals: []string{"radarr", "sonarr"}},
|
||||
},
|
||||
Validation: &pluginv1.AdminFormValidation{
|
||||
HasMin: true,
|
||||
Min: 1,
|
||||
HasMax: true,
|
||||
Max: 10,
|
||||
Pattern: "^[0-9]+$",
|
||||
MinLength: 1,
|
||||
MaxLength: 64,
|
||||
},
|
||||
},
|
||||
},
|
||||
Sections: []*pluginv1.AdminFormSection{
|
||||
{
|
||||
Key: "advanced",
|
||||
Title: "Advanced",
|
||||
Description: "Optional tuning",
|
||||
Collapsible: true,
|
||||
CollapsedDefault: true,
|
||||
FieldKeys: []string{"quality_profile_ids"},
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{
|
||||
{Field: "service_type", Equals: []string{"radarr"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
form := adminFormToJSON(descriptor)
|
||||
if form == nil {
|
||||
t.Fatal("adminFormToJSON returned nil")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(form)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal admin form: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Fields []struct {
|
||||
Control string `json:"control"`
|
||||
DynamicOptions bool `json:"dynamic_options"`
|
||||
ShowWhen []struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
} `json:"show_when"`
|
||||
Validation *struct {
|
||||
HasMin bool `json:"has_min"`
|
||||
Min float64 `json:"min"`
|
||||
HasMax bool `json:"has_max"`
|
||||
Max float64 `json:"max"`
|
||||
Pattern string `json:"pattern"`
|
||||
MinLength int32 `json:"min_length"`
|
||||
MaxLength int32 `json:"max_length"`
|
||||
} `json:"validation"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field"`
|
||||
} `json:"fields"`
|
||||
Sections []struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
} `json:"show_when"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal admin form: %v\njson: %s", err, raw)
|
||||
}
|
||||
|
||||
if len(decoded.Fields) != 1 {
|
||||
t.Fatalf("expected 1 field, got %d", len(decoded.Fields))
|
||||
}
|
||||
field := decoded.Fields[0]
|
||||
|
||||
// Control must serialize to the SHORT form the frontend switches on.
|
||||
if field.Control != "MULTI_SELECT" {
|
||||
t.Errorf("control = %q, want %q", field.Control, "MULTI_SELECT")
|
||||
}
|
||||
if !field.DynamicOptions {
|
||||
t.Error("dynamic_options = false, want true")
|
||||
}
|
||||
if field.ExclusiveGroupField != "service_kind" {
|
||||
t.Errorf("exclusive_group_field = %q, want %q", field.ExclusiveGroupField, "service_kind")
|
||||
}
|
||||
if len(field.ShowWhen) != 1 || field.ShowWhen[0].Field != "service_type" ||
|
||||
len(field.ShowWhen[0].Equals) != 2 {
|
||||
t.Errorf("field show_when not serialized correctly: %+v", field.ShowWhen)
|
||||
}
|
||||
if field.Validation == nil {
|
||||
t.Fatal("validation not serialized")
|
||||
}
|
||||
if !field.Validation.HasMin || field.Validation.Min != 1 ||
|
||||
!field.Validation.HasMax || field.Validation.Max != 10 ||
|
||||
field.Validation.Pattern != "^[0-9]+$" ||
|
||||
field.Validation.MinLength != 1 || field.Validation.MaxLength != 64 {
|
||||
t.Errorf("validation not serialized correctly: %+v", field.Validation)
|
||||
}
|
||||
|
||||
if len(decoded.Sections) != 1 {
|
||||
t.Fatalf("expected 1 section, got %d", len(decoded.Sections))
|
||||
}
|
||||
section := decoded.Sections[0]
|
||||
if section.Key != "advanced" || section.Title != "Advanced" ||
|
||||
section.Description != "Optional tuning" || !section.Collapsible ||
|
||||
!section.CollapsedDefault {
|
||||
t.Errorf("section scalar fields not serialized correctly: %+v", section)
|
||||
}
|
||||
if len(section.FieldKeys) != 1 || section.FieldKeys[0] != "quality_profile_ids" {
|
||||
t.Errorf("section field_keys not serialized correctly: %+v", section.FieldKeys)
|
||||
}
|
||||
if len(section.ShowWhen) != 1 || section.ShowWhen[0].Field != "service_type" ||
|
||||
len(section.ShowWhen[0].Equals) != 1 {
|
||||
t.Errorf("section show_when not serialized correctly: %+v", section.ShowWhen)
|
||||
}
|
||||
}
|
||||
|
||||
// adminFormDTOMappingRenames records intentional proto-field -> DTO-field name
|
||||
// renames. The DTO field names currently mirror the proto field names 1:1, so
|
||||
// this is empty; add an entry only when a DTO field is deliberately named
|
||||
// differently from its proto source (the test then accepts the renamed target).
|
||||
var adminFormDTOMappingRenames = map[string]string{}
|
||||
|
||||
// protoInternalFields are the protobuf-runtime bookkeeping fields every
|
||||
// generated message struct carries. They are never serialized to the client
|
||||
// DTO, so the completeness guard skips them.
|
||||
var protoInternalFields = map[string]bool{
|
||||
"state": true,
|
||||
"sizeCache": true,
|
||||
"unknownFields": true,
|
||||
}
|
||||
|
||||
// TestAdminFormDTOCompleteness is a guard against the C1-class bug: the
|
||||
// hand-written adminFormToJSON allowlist (pluginAdminFormFieldJSON /
|
||||
// pluginAdminFormSectionJSON / pluginAdminFormJSON) silently dropping a NEW
|
||||
// proto field added to AdminFormField / AdminFormDescriptor / AdminFormSection
|
||||
// later. It reflects over each proto message's exported fields and asserts the
|
||||
// corresponding JSON DTO struct has a field mapping to it (by identical Go name,
|
||||
// or via an explicit rename in adminFormDTOMappingRenames). When the next proto
|
||||
// field lands, this FAILS naming the un-mapped field, forcing the serializer +
|
||||
// DTO to be updated in lockstep. (We deliberately do not switch the serializer
|
||||
// to protojson — the short-form control-string MULTI_SELECT mapping makes that
|
||||
// risky; this guard is the proportionate fix.)
|
||||
func TestAdminFormDTOCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
proto reflect.Type
|
||||
dto reflect.Type
|
||||
}{
|
||||
{"AdminFormField", reflect.TypeOf(pluginv1.AdminFormField{}), reflect.TypeOf(pluginAdminFormFieldJSON{})},
|
||||
{"AdminFormDescriptor", reflect.TypeOf(pluginv1.AdminFormDescriptor{}), reflect.TypeOf(pluginAdminFormJSON{})},
|
||||
{"AdminFormSection", reflect.TypeOf(pluginv1.AdminFormSection{}), reflect.TypeOf(pluginAdminFormSectionJSON{})},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dtoFields := map[string]bool{}
|
||||
for i := 0; i < tc.dto.NumField(); i++ {
|
||||
dtoFields[tc.dto.Field(i).Name] = true
|
||||
}
|
||||
|
||||
for i := 0; i < tc.proto.NumField(); i++ {
|
||||
pf := tc.proto.Field(i)
|
||||
if !pf.IsExported() {
|
||||
continue // unexported protobuf-internal state
|
||||
}
|
||||
if protoInternalFields[pf.Name] || strings.HasPrefix(pf.Name, "XXX_") {
|
||||
continue
|
||||
}
|
||||
want := pf.Name
|
||||
if mapped, ok := adminFormDTOMappingRenames[pf.Name]; ok {
|
||||
want = mapped
|
||||
}
|
||||
if !dtoFields[want] {
|
||||
t.Errorf("proto field %s.%s has no corresponding field %q in the JSON DTO %s; "+
|
||||
"adminFormToJSON would silently drop it. Add the field to the DTO + serializer "+
|
||||
"(or record an intentional rename in adminFormDTOMappingRenames).",
|
||||
tc.name, pf.Name, want, tc.dto.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ type createProfileRequest struct {
|
||||
MaxContentRating string `json:"max_content_rating,omitempty"`
|
||||
QualityPreference string `json:"quality_preference,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
|
||||
SubtitleLanguage string `json:"subtitle_language,omitempty"`
|
||||
SubtitleMode string `json:"subtitle_mode,omitempty"`
|
||||
AutoSkipIntro bool `json:"auto_skip_intro"`
|
||||
@@ -69,7 +68,6 @@ type updateProfileRequest struct {
|
||||
MaxContentRating *string `json:"max_content_rating,omitempty"`
|
||||
QualityPreference *string `json:"quality_preference,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
PreferredMetadataLanguage *string `json:"preferred_metadata_language,omitempty"`
|
||||
SubtitleLanguage *string `json:"subtitle_language,omitempty"`
|
||||
SubtitleMode *string `json:"subtitle_mode,omitempty"`
|
||||
AutoSkipIntro *bool `json:"auto_skip_intro,omitempty"`
|
||||
@@ -98,7 +96,6 @@ type profileResponse struct {
|
||||
MaxContentRating string `json:"max_content_rating,omitempty"`
|
||||
QualityPreference string `json:"quality_preference,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
|
||||
SubtitleLanguage string `json:"subtitle_language,omitempty"`
|
||||
SubtitleMode string `json:"subtitle_mode,omitempty"`
|
||||
AutoSkipIntro bool `json:"auto_skip_intro"`
|
||||
@@ -124,10 +121,9 @@ type verifyPINResponse struct {
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// isAdminProfileRequest reports whether the request user currently holds the
|
||||
// admin permission, checked server-side against the loaded user.
|
||||
func (h *ProfileHandler) isAdminProfileRequest(r *http.Request) bool {
|
||||
return isAdminRequest(r, h.UserRepo)
|
||||
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
|
||||
@@ -139,7 +135,7 @@ func (h *ProfileHandler) isAdminProfileRequest(r *http.Request) bool {
|
||||
// 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 h.isAdminProfileRequest(r) {
|
||||
if isAdminProfileRequest(ctx) {
|
||||
return true, nil
|
||||
}
|
||||
activeProfileID := apimw.GetProfileID(ctx)
|
||||
@@ -317,7 +313,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
|
||||
// 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 && !h.isAdminProfileRequest(r) &&
|
||||
if isBootstrap && !isAdminProfileRequest(r.Context()) &&
|
||||
(req.IsChild || req.MaxContentRating != "" ||
|
||||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
|
||||
req.MaxPlaybackQuality != "") {
|
||||
@@ -360,7 +356,6 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
|
||||
MaxContentRating: req.MaxContentRating,
|
||||
QualityPreference: req.QualityPreference,
|
||||
Language: req.Language,
|
||||
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
|
||||
SubtitleLanguage: req.SubtitleLanguage,
|
||||
SubtitleMode: req.SubtitleMode,
|
||||
AutoSkipIntro: req.AutoSkipIntro,
|
||||
@@ -510,7 +505,6 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
|
||||
MaxContentRating: req.MaxContentRating,
|
||||
QualityPreference: req.QualityPreference,
|
||||
Language: req.Language,
|
||||
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
|
||||
SubtitleLanguage: req.SubtitleLanguage,
|
||||
SubtitleMode: req.SubtitleMode,
|
||||
AutoSkipIntro: req.AutoSkipIntro,
|
||||
@@ -690,7 +684,6 @@ func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Prof
|
||||
MaxContentRating: p.MaxContentRating,
|
||||
QualityPreference: p.QualityPreference,
|
||||
Language: p.Language,
|
||||
PreferredMetadataLanguage: p.PreferredMetadataLanguage,
|
||||
SubtitleLanguage: p.SubtitleLanguage,
|
||||
SubtitleMode: p.SubtitleMode,
|
||||
AutoSkipIntro: p.AutoSkipIntro,
|
||||
|
||||
@@ -45,6 +45,7 @@ func newAuthorizedProfileRequestWithSession(
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: role,
|
||||
SessionID: sessionID,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
@@ -98,7 +99,7 @@ func TestHandleCreateProfile_EnforcesUserProfileLimit(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
||||
handler.UserRepo = testProfileUserRepo{
|
||||
user: &models.User{ID: 1, MaxProfiles: 1, Enabled: true, IsAdmin: true},
|
||||
user: &models.User{ID: 1, MaxProfiles: 1},
|
||||
}
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
|
||||
@@ -29,13 +29,6 @@ type rateLimitConfigResponse struct {
|
||||
IPReqPerMinute float64 `json:"ip_requests_per_minute"`
|
||||
IPBurst int `json:"ip_burst"`
|
||||
AuthEndpoints map[string]authEndpointConfigResponse `json:"auth_endpoints"`
|
||||
// Active reports whether a limiter is running in this process. The
|
||||
// limiter is constructed at startup, so config saved while it is absent
|
||||
// (or a backend change while it is running) needs a restart to apply.
|
||||
Active bool `json:"active"`
|
||||
// ActiveBackend is the backend the running limiter actually uses, which
|
||||
// can differ from Backend until the server restarts.
|
||||
ActiveBackend string `json:"active_backend,omitempty"`
|
||||
}
|
||||
|
||||
type tierConfigResponse struct {
|
||||
@@ -71,10 +64,6 @@ func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Reques
|
||||
IPReqPerMinute: cfg.IPReqPerMinute,
|
||||
IPBurst: cfg.IPBurst,
|
||||
AuthEndpoints: make(map[string]authEndpointConfigResponse),
|
||||
Active: h.mw != nil,
|
||||
}
|
||||
if h.mw != nil {
|
||||
resp.ActiveBackend = h.mw.ActiveBackend()
|
||||
}
|
||||
for name, tier := range cfg.Tiers {
|
||||
resp.Tiers[name] = tierConfigResponse{
|
||||
@@ -162,21 +151,10 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
// The limiter is constructed at startup, so enabling while it is absent
|
||||
// or switching backend while it runs only takes effect after a restart.
|
||||
// Everything else hot-reloads below.
|
||||
restartRequired := false
|
||||
if h.mw == nil {
|
||||
restartRequired = req.Enabled
|
||||
} else {
|
||||
if (req.Backend == "memory" || req.Backend == "redis") && req.Backend != h.mw.ActiveBackend() {
|
||||
restartRequired = true
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// 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)
|
||||
@@ -187,5 +165,5 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "restart_required": restartRequired})
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
)
|
||||
|
||||
type fakeRateLimitStore struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func newFakeRateLimitStore() *fakeRateLimitStore {
|
||||
return &fakeRateLimitStore{values: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) Get(_ context.Context, key string) (string, error) {
|
||||
return s.values[key], nil
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) Set(_ context.Context, key, value string) error {
|
||||
s.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error) {
|
||||
out := make(map[string]string, len(s.values))
|
||||
for k, v := range s.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newRunningRateLimitMiddleware(t *testing.T, store ratelimit.SettingsStore) *ratelimit.Middleware {
|
||||
t.Helper()
|
||||
perKey := ratelimit.NewMemoryLimiter()
|
||||
global := ratelimit.NewMemoryLimiter()
|
||||
t.Cleanup(func() {
|
||||
perKey.Close()
|
||||
global.Close()
|
||||
})
|
||||
mw := ratelimit.NewMiddleware(perKey, global, store, true)
|
||||
if err := mw.Init(context.Background()); err != nil {
|
||||
t.Fatalf("init middleware: %v", err)
|
||||
}
|
||||
return mw
|
||||
}
|
||||
|
||||
func putRateLimitConfig(t *testing.T, h *RateLimitHandler, body string) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleUpdateConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode PUT response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func getRateLimitConfig(t *testing.T, h *RateLimitHandler) rateLimitConfigResponse {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/rate-limits/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp rateLimitConfigResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode GET response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
|
||||
// The limiter is nil when rate limiting was disabled at boot. The config
|
||||
// endpoints must still work so an admin can re-enable it from the UI.
|
||||
store := newFakeRateLimitStore()
|
||||
store.values["ratelimit.enabled"] = "false"
|
||||
h := NewRateLimitHandler(store, nil, nil)
|
||||
|
||||
got := getRateLimitConfig(t, h)
|
||||
if got.Enabled {
|
||||
t.Error("GET enabled = true, want false")
|
||||
}
|
||||
if got.Active {
|
||||
t.Error("GET active = true, want false when no limiter is running")
|
||||
}
|
||||
|
||||
resp := putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
|
||||
if resp["restart_required"] != true {
|
||||
t.Errorf("enabling without a running limiter: restart_required = %v, want true", resp["restart_required"])
|
||||
}
|
||||
if store.values["ratelimit.enabled"] != "true" {
|
||||
t.Errorf("ratelimit.enabled = %q, want \"true\"", store.values["ratelimit.enabled"])
|
||||
}
|
||||
if store.values["ratelimit.backend"] != "redis" {
|
||||
t.Errorf("ratelimit.backend = %q, want \"redis\"", store.values["ratelimit.backend"])
|
||||
}
|
||||
|
||||
resp = putRateLimitConfig(t, h, `{"enabled":false,"backend":"redis"}`)
|
||||
if resp["restart_required"] != false {
|
||||
t.Errorf("saving disabled config without a running limiter: restart_required = %v, want false", resp["restart_required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitHandlerWithRunningLimiter(t *testing.T) {
|
||||
store := newFakeRateLimitStore()
|
||||
mw := newRunningRateLimitMiddleware(t, store)
|
||||
h := NewRateLimitHandler(store, mw, nil)
|
||||
|
||||
got := getRateLimitConfig(t, h)
|
||||
if !got.Active {
|
||||
t.Error("GET active = false, want true with a running limiter")
|
||||
}
|
||||
if got.ActiveBackend != "memory" {
|
||||
t.Errorf("GET active_backend = %q, want \"memory\"", got.ActiveBackend)
|
||||
}
|
||||
|
||||
// Toggling enabled hot-reloads; no restart needed when the backend matches.
|
||||
resp := putRateLimitConfig(t, h, `{"enabled":false,"backend":"memory"}`)
|
||||
if resp["restart_required"] != false {
|
||||
t.Errorf("toggle on running limiter: restart_required = %v, want false", resp["restart_required"])
|
||||
}
|
||||
|
||||
// Switching backend only takes effect at boot.
|
||||
resp = putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
|
||||
if resp["restart_required"] != true {
|
||||
t.Errorf("backend change: restart_required = %v, want true", resp["restart_required"])
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ type RecommendationsHandler struct {
|
||||
EpisodeRepo *catalog.EpisodeRepository
|
||||
WatchTonightFetcher watchTonightSectionFetcher
|
||||
CastFetcher cardsCastFetcher
|
||||
EbookProgress EbookReaderProgressLister
|
||||
// RecWorker enqueues asynchronous taste-profile refreshes after writes
|
||||
// (taste seeding). Optional — when nil, refresh is simply skipped.
|
||||
RecWorker ProfileRefreshRequester
|
||||
@@ -644,10 +643,7 @@ func (h *RecommendationsHandler) loadItemEnrichment(
|
||||
if h.storeProvider != nil {
|
||||
store, storeErr := h.storeProvider.ForUser(ctx, userID)
|
||||
if storeErr == nil && store != nil {
|
||||
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
|
||||
if stateErr == nil {
|
||||
out.states = states
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
@@ -39,7 +38,7 @@ type RequestService interface {
|
||||
CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (map[string][]mediarequests.RouterOption, error)
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
|
||||
|
||||
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
@@ -51,7 +50,6 @@ type RequestService interface {
|
||||
|
||||
type RequestsHandler struct {
|
||||
service RequestService
|
||||
Users auth.UserLoader // for server-side admin checks
|
||||
}
|
||||
|
||||
func NewRequestsHandler(service RequestService) *RequestsHandler {
|
||||
@@ -59,7 +57,7 @@ func NewRequestsHandler(service RequestService) *RequestsHandler {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -82,7 +80,7 @@ func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -97,7 +95,7 @@ func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -114,7 +112,7 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -129,7 +127,7 @@ func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -144,7 +142,7 @@ func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +157,7 @@ func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -178,7 +176,7 @@ func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -197,7 +195,7 @@ func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -217,7 +215,7 @@ func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -236,7 +234,7 @@ func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -254,7 +252,7 @@ func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -269,7 +267,7 @@ func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -282,7 +280,7 @@ func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -297,7 +295,7 @@ func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -310,7 +308,7 @@ func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -332,7 +330,7 @@ func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -354,7 +352,7 @@ func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -367,7 +365,7 @@ func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -380,7 +378,7 @@ func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -393,7 +391,7 @@ func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -411,7 +409,7 @@ func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -426,7 +424,7 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -444,7 +442,7 @@ func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -463,7 +461,7 @@ func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -475,7 +473,7 @@ func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -498,7 +496,7 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -515,7 +513,7 @@ func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -537,7 +535,7 @@ func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.R
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
|
||||
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if claims == nil || claims.UserID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
@@ -551,7 +549,7 @@ func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request,
|
||||
return mediarequests.Viewer{
|
||||
UserID: claims.UserID,
|
||||
ProfileID: profileID,
|
||||
IsAdmin: isAdminRequest(r, h.Users),
|
||||
IsAdmin: claims.Role == "admin",
|
||||
}, true
|
||||
}
|
||||
|
||||
@@ -590,36 +588,62 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i
|
||||
}
|
||||
|
||||
type requestIntegrationResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
SupportedMediaTypes []string `json:"supported_media_types"`
|
||||
PluginConfig map[string]any `json:"plugin_config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Is4K bool `json:"is_4k"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsDefault4K bool `json:"is_default_4k"`
|
||||
AnimeEnabled bool `json:"anime_enabled"`
|
||||
AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"`
|
||||
AnimeRootFolder string `json:"anime_root_folder,omitempty"`
|
||||
AnimeTags []int `json:"anime_tags"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
RootFolder string `json:"root_folder"`
|
||||
QualityProfileID *int `json:"quality_profile_id,omitempty"`
|
||||
Tags []int `json:"tags"`
|
||||
Options map[string]any `json:"options"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse {
|
||||
// Normalize nil slices so they serialize as [] (not null); the frontend
|
||||
// types them as number[] and indexes/maps over them.
|
||||
tags := integration.Tags
|
||||
if tags == nil {
|
||||
tags = []int{}
|
||||
}
|
||||
animeTags := integration.AnimeTags
|
||||
if animeTags == nil {
|
||||
animeTags = []int{}
|
||||
}
|
||||
return requestIntegrationResponse{
|
||||
ID: integration.ID,
|
||||
Name: integration.Name,
|
||||
CapabilityID: integration.CapabilityID,
|
||||
InstallationID: integration.InstallationID,
|
||||
SupportedMediaTypes: integration.SupportedMediaTypes,
|
||||
PluginConfig: integration.PluginConfig,
|
||||
Enabled: integration.Enabled,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
ID: integration.ID,
|
||||
Name: integration.Name,
|
||||
Kind: integration.Kind,
|
||||
Enabled: integration.Enabled,
|
||||
Is4K: integration.Is4K,
|
||||
IsDefault: integration.IsDefault,
|
||||
IsDefault4K: integration.IsDefault4K,
|
||||
AnimeEnabled: integration.AnimeEnabled,
|
||||
AnimeQualityProfileID: integration.AnimeQualityProfileID,
|
||||
AnimeRootFolder: integration.AnimeRootFolder,
|
||||
AnimeTags: animeTags,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
RootFolder: integration.RootFolder,
|
||||
QualityProfileID: integration.QualityProfileID,
|
||||
Tags: tags,
|
||||
Options: integration.Options,
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,18 +656,6 @@ func toIntegrationResponses(integrations []mediarequests.Integration) []requestI
|
||||
}
|
||||
|
||||
func writeRequestServiceError(w http.ResponseWriter, err error) {
|
||||
// Plugin/instance validation failures carry inline field/form errors; surface
|
||||
// them as a structured 400 so any handler routing through here renders them
|
||||
// inline. Checked first because *ValidationError does not wrap a sentinel.
|
||||
var verr *mediarequests.ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"error": "validation_failed",
|
||||
"field_errors": verr.FieldErrors,
|
||||
"form_error": verr.FormError,
|
||||
})
|
||||
return
|
||||
}
|
||||
var quota mediarequests.QuotaError
|
||||
switch {
|
||||
case errors.As(err, "a):
|
||||
|
||||
@@ -139,7 +139,7 @@ func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Vi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) {
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ func authedRequest(method, target string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
|
||||
@@ -34,7 +34,6 @@ type SectionHandler struct {
|
||||
DetailSvc *catalog.DetailService
|
||||
Settings catalog.SettingsStore
|
||||
CollectionRepo *catalog.LibraryCollectionRepository
|
||||
EbookProgress EbookReaderProgressLister
|
||||
}
|
||||
|
||||
// NewSectionHandler creates a new SectionHandler.
|
||||
@@ -158,11 +157,8 @@ func validateSectionConfig(sectionType sections.SectionType, config json.RawMess
|
||||
}
|
||||
|
||||
cfg := sections.ParseConfigFilters(config)
|
||||
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" && cfg.FilterType != "audiobook" {
|
||||
return "filter_type must be 'movie', 'series', or 'audiobook'", false
|
||||
}
|
||||
if _, err := sections.ParseContinueType(config); err != nil {
|
||||
return err.Error(), false
|
||||
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" {
|
||||
return "filter_type must be 'movie' or 'series'", false
|
||||
}
|
||||
|
||||
var rawLibraryConfig struct {
|
||||
@@ -891,8 +887,8 @@ func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *ht
|
||||
allowCustom = v == "true"
|
||||
}
|
||||
isAdmin := false
|
||||
if h.UserRepo != nil {
|
||||
isAdmin = isAdminRequest(r, h.UserRepo)
|
||||
if claims := apimw.GetClaims(r.Context()); claims != nil {
|
||||
isAdmin = claims.Role == "admin"
|
||||
}
|
||||
|
||||
for _, o := range req.Overrides {
|
||||
@@ -1381,10 +1377,7 @@ func (h *SectionHandler) listSectionItemUserStates(r *http.Request, items []*mod
|
||||
if err != nil || store == nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.EpisodeRepo, items, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
states, err := resolveItemUserStates(r.Context(), store, profileID, h.EpisodeRepo, items)
|
||||
if err != nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
@@ -1415,8 +1408,8 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti
|
||||
return resolved
|
||||
}
|
||||
|
||||
// injectNextUpSection inserts a synthetic SectionNextUp entry after the
|
||||
// contiguous continue rows that start with the video Continue Watching row.
|
||||
// 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",
|
||||
@@ -1425,20 +1418,6 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
|
||||
ItemLimit: 20,
|
||||
}
|
||||
|
||||
for i, s := range resolved {
|
||||
if s.SectionType == sections.SectionContinueWatching && sections.ContinueTypeFromConfig(s.Config) == sections.ContinueTypeWatching {
|
||||
insertAt := i + 1
|
||||
for insertAt < len(resolved) && resolved[insertAt].SectionType == sections.SectionContinueWatching {
|
||||
insertAt++
|
||||
}
|
||||
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
|
||||
result = append(result, resolved[:insertAt]...)
|
||||
result = append(result, nextUp)
|
||||
result = append(result, resolved[insertAt:]...)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range resolved {
|
||||
if s.SectionType == sections.SectionContinueWatching {
|
||||
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
|
||||
@@ -1449,7 +1428,7 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
|
||||
}
|
||||
}
|
||||
|
||||
// If no resume row is found, prepend.
|
||||
// If no continue_watching found, prepend
|
||||
return append([]sections.ResolvedSection{nextUp}, resolved...)
|
||||
}
|
||||
|
||||
|
||||
@@ -282,66 +282,6 @@ func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSectionConfigAcceptsContinueTypes(t *testing.T) {
|
||||
tests := []string{
|
||||
`{"continue_type":"watching"}`,
|
||||
`{"continue_type":"listening"}`,
|
||||
`{"continue_type":"reading"}`,
|
||||
`{"filter_type":"audiobook"}`,
|
||||
}
|
||||
|
||||
for _, config := range tests {
|
||||
t.Run(config, func(t *testing.T) {
|
||||
if msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(config)); !ok {
|
||||
t.Fatalf("validateSectionConfig(%s) rejected config: %s", config, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSectionConfigRejectsUnknownContinueType(t *testing.T) {
|
||||
msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(`{"continue_type":"scrolling"}`))
|
||||
if ok {
|
||||
t.Fatal("validateSectionConfig accepted unknown continue_type")
|
||||
}
|
||||
if msg != "continue_type must be 'watching', 'listening', or 'reading'" {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNextUpAfterContiguousContinueRows(t *testing.T) {
|
||||
in := []sections.ResolvedSection{
|
||||
{
|
||||
ID: "cw",
|
||||
SectionType: sections.SectionContinueWatching,
|
||||
Title: "Continue Watching",
|
||||
Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching),
|
||||
},
|
||||
{
|
||||
ID: "cl",
|
||||
SectionType: sections.SectionContinueWatching,
|
||||
Title: "Continue Listening",
|
||||
Config: sections.ContinueTypeConfig(sections.ContinueTypeListening),
|
||||
},
|
||||
{ID: "recent", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added"},
|
||||
}
|
||||
|
||||
got := injectNextUpSection(in)
|
||||
gotIDs := make([]string, 0, len(got))
|
||||
for _, section := range got {
|
||||
gotIDs = append(gotIDs, section.ID)
|
||||
}
|
||||
wantIDs := []string{"cw", "cl", "system-next-up", "recent"}
|
||||
if len(gotIDs) != len(wantIDs) {
|
||||
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
for i := range wantIDs {
|
||||
if gotIDs[i] != wantIDs[i] {
|
||||
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) {
|
||||
in := []sections.SectionWithItems{
|
||||
// empty seasonal — drop
|
||||
@@ -452,7 +392,7 @@ func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testin
|
||||
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{UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -471,7 +411,7 @@ func TestSaveProfileOverridesUnknownRecipeReturnsBadRequest(t *testing.T) {
|
||||
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{UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -495,7 +435,7 @@ func TestSaveProfileOverridesLegacyShapeCannotBypassGate(t *testing.T) {
|
||||
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{UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -515,7 +455,7 @@ func TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected(t *testing.T) {
|
||||
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{UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -118,7 +118,7 @@ func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest)
|
||||
if command.Reason == "" {
|
||||
command.Reason = "server_restart_requested"
|
||||
}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
return command, 0, nil, nil
|
||||
})
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestHandleSessionWebSocket_RequiresHelloBeforeRealtimeReady(t *testing.T) {
|
||||
|
||||
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, TokenType: auth.TokenTypeAccess})
|
||||
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
handler.HandleSessionWebSocket(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ const subtitleAppearanceSettingKey = "subtitle_appearance"
|
||||
const (
|
||||
libraryPageStateSettingKey = "ui.library_page_state"
|
||||
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
|
||||
searchMediaScopeSettingKey = "search.media_scope"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -172,14 +171,6 @@ var settingsRegistry = map[string]settingSpec{
|
||||
DefaultValue: "true",
|
||||
Validate: validateBoolSetting(rememberLibraryPageStateSettingKey),
|
||||
},
|
||||
// Preferred default scope for global/catalog search. "video" keeps
|
||||
// results to movies and series; "all" mixes audiobooks in.
|
||||
searchMediaScopeSettingKey: {
|
||||
Scope: scopeUser,
|
||||
DefaultValue: "video",
|
||||
Validate: validateEnumSetting(searchMediaScopeSettingKey,
|
||||
"all", "video", "audiobook"),
|
||||
},
|
||||
"player.hdr_enabled": {
|
||||
Scope: scopeDevice,
|
||||
DefaultValue: "true",
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -11,10 +10,8 @@ import (
|
||||
"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/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// SubtitleAIHandler exposes on-demand AI subtitle translation backed by the
|
||||
@@ -24,12 +21,6 @@ import (
|
||||
type SubtitleAIHandler struct {
|
||||
service *ai.Service
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
// StoreProvider resolves household profiles for the transcription quota
|
||||
// exemption check; when nil the whole admin account is exempt.
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
// Users loads the account server-side for the quota exemption's admin
|
||||
// check; admin status is never read from token contents.
|
||||
Users auth.UserLoader
|
||||
}
|
||||
|
||||
// NewSubtitleAIHandler creates a handler backed by the given service.
|
||||
@@ -57,12 +48,7 @@ func authorizeMediaFileAccess(w http.ResponseWriter, r *http.Request, authorizer
|
||||
}
|
||||
|
||||
type translateSubtitleRequest struct {
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
// Kind selects the job: "translate" (default), "transcribe", or
|
||||
// "transcribe_translate". For ASR kinds, source_index is the audio track
|
||||
// index (-1 = default track) and target_language is optional for plain
|
||||
// transcribe (acts as a language hint).
|
||||
Kind string `json:"kind"`
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
SourceIndex int `json:"source_index"`
|
||||
SourceLanguage string `json:"source_language"`
|
||||
TargetLanguage string `json:"target_language"`
|
||||
@@ -70,14 +56,10 @@ type translateSubtitleRequest struct {
|
||||
StartPosition float64 `json:"start_position"`
|
||||
}
|
||||
|
||||
// HandleStatus reports whether AI subtitle translation / ASR generation are
|
||||
// available, so the player can show or hide the entry points.
|
||||
// GET /api/v1/subtitles/ai/status
|
||||
// HandleStatus reports whether AI subtitle translation is available, so the
|
||||
// player can show or hide the entry point. GET /api/v1/subtitles/ai/status
|
||||
func (h *SubtitleAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": h.service.Enabled(),
|
||||
"transcribe_enabled": h.service.TranscribeEnabled(),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": h.service.Enabled()})
|
||||
}
|
||||
|
||||
// WriteSubtitleAIDisabledStatus answers the AI status capability probe with a
|
||||
@@ -85,7 +67,7 @@ func (h *SubtitleAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request)
|
||||
// negative instead of a 404 (the 2-segment /ai/status path is not shadowed by the
|
||||
// 1-segment /{media_file_id} route — they never compete in chi's router).
|
||||
func WriteSubtitleAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "transcribe_enabled": false})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
}
|
||||
|
||||
// HandleTranslate enqueues a translation job. POST /api/v1/subtitles/ai/translate
|
||||
@@ -99,13 +81,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "media_file_id is required")
|
||||
return
|
||||
}
|
||||
kind := ai.JobKind(req.Kind)
|
||||
if kind == "" {
|
||||
kind = ai.JobKindTranslate
|
||||
}
|
||||
// A plain transcribe produces a track in the spoken language, so the
|
||||
// target is optional; every other kind needs one.
|
||||
if req.TargetLanguage == "" && kind != ai.JobKindTranscribe {
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
@@ -121,12 +97,11 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
job, err := h.service.Enqueue(r.Context(), ai.JobRequest{
|
||||
MediaFileID: req.MediaFileID,
|
||||
Kind: kind,
|
||||
Kind: ai.JobKindTranslate,
|
||||
SourceIndex: req.SourceIndex,
|
||||
SourceLanguage: req.SourceLanguage,
|
||||
TargetLanguage: req.TargetLanguage,
|
||||
RequestedBy: requestedBy,
|
||||
QuotaExempt: h.quotaExempt(r),
|
||||
SessionID: req.SessionID,
|
||||
StartPosition: req.StartPosition,
|
||||
})
|
||||
@@ -135,8 +110,6 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
case errors.Is(err, ai.ErrEngineNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"AI subtitle translation is not configured on this server")
|
||||
case errors.Is(err, ai.ErrQuotaExceeded):
|
||||
writeError(w, http.StatusTooManyRequests, "quota_exceeded", quotaExceededMessage(err))
|
||||
case errors.Is(err, ai.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
@@ -150,60 +123,6 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
// quotaExempt reports whether the caller is exempt from the transcription
|
||||
// quota: an admin account acting as itself (no profile context) or through
|
||||
// its primary (household parent) profile. Other profiles on an admin account
|
||||
// — e.g. a kid's profile — stay subject to the quota even though the account
|
||||
// holds the admin role. X-Profile-Id is client-asserted, so this is a
|
||||
// budgeting gate, not a security boundary; the quota itself is account-scoped.
|
||||
func (h *SubtitleAIHandler) quotaExempt(r *http.Request) bool {
|
||||
ctx := r.Context()
|
||||
if !isAdminRequest(r, h.Users) {
|
||||
return false
|
||||
}
|
||||
profileID := apimw.GetProfileID(ctx)
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if profileID == "" || h.StoreProvider == nil {
|
||||
return true
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(ctx, apimw.GetUserID(ctx))
|
||||
if err != nil {
|
||||
return false // fail closed: the quota still applies
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil || profile == nil {
|
||||
return false
|
||||
}
|
||||
return profile.IsPrimary
|
||||
}
|
||||
|
||||
// quotaExceededMessage turns a quota error into a user-facing message with the
|
||||
// limit and window when available.
|
||||
func quotaExceededMessage(err error) string {
|
||||
var qe *ai.QuotaExceededError
|
||||
if errors.As(err, &qe) {
|
||||
return fmt.Sprintf(
|
||||
"You've reached your transcription limit (%d per %s). Try again later or ask an admin to raise it.",
|
||||
qe.Limit, qe.Period)
|
||||
}
|
||||
return "You've reached your transcription limit. Try again later or ask an admin to raise it."
|
||||
}
|
||||
|
||||
// HandleQuota reports the calling user's transcription quota usage, so the
|
||||
// player can show how many jobs they have left before they start one.
|
||||
// GET /api/v1/subtitles/ai/quota
|
||||
func (h *SubtitleAIHandler) HandleQuota(w http.ResponseWriter, r *http.Request) {
|
||||
quota, err := h.service.TranscribeQuota(r.Context(), apimw.GetUserID(r.Context()), h.quotaExempt(r))
|
||||
if err != nil {
|
||||
slog.Error("failed to load transcription quota", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load transcription quota")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, quota)
|
||||
}
|
||||
|
||||
// HandleGetJob returns a job's current state. GET /api/v1/subtitles/ai/jobs/{job_id}
|
||||
func (h *SubtitleAIHandler) HandleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||
job, ok := h.loadAuthorizedJob(w, r)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
|
||||
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/subtitles"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -64,7 +63,6 @@ type SubtitleSearchHandler struct {
|
||||
repo subtitles.Repository
|
||||
mediaResolver SubtitleMediaResolver
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
Users auth.UserLoader // for server-side admin checks
|
||||
}
|
||||
|
||||
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
|
||||
@@ -336,8 +334,8 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
isAdmin := claims != nil && claims.Role == "admin"
|
||||
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
|
||||
isAdmin := !isOwner && isAdminRequest(r, h.Users)
|
||||
if !isAdmin && !isOwner {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Not authorized to delete this subtitle")
|
||||
return
|
||||
|
||||
@@ -56,6 +56,7 @@ func newSubtitleAuthRequest(method, path string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
|
||||
@@ -181,10 +181,7 @@ func (h *RecommendationsHandler) resolveTasteSeedUserStates(ctx context.Context,
|
||||
if err != nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
states, err := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// decodedURLParam returns the named chi route parameter with percent-encoding
|
||||
// removed. chi matches routes against the raw (escaped) request path, so
|
||||
// parameters arrive still encoded when clients escape reserved characters —
|
||||
// e.g. encodeURIComponent("plugin:6:introdb") arrives as
|
||||
// "plugin%3A6%3Aintrodb". Returns an error for malformed escape sequences.
|
||||
func decodedURLParam(r *http.Request, name string) (string, error) {
|
||||
return url.PathUnescape(chi.URLParam(r, name))
|
||||
}
|
||||
@@ -14,28 +14,12 @@ type itemUserStateResponse struct {
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
type itemUserStateOptions struct {
|
||||
UserID int
|
||||
EbookProgressStore EbookReaderProgressLister
|
||||
}
|
||||
|
||||
func resolveItemUserStates(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
items []*models.MediaItem,
|
||||
) (map[string]*itemUserStateResponse, error) {
|
||||
return resolveItemUserStatesWithOptions(ctx, store, profileID, episodeRepo, items, itemUserStateOptions{})
|
||||
}
|
||||
|
||||
func resolveItemUserStatesWithOptions(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
items []*models.MediaItem,
|
||||
options itemUserStateOptions,
|
||||
) (map[string]*itemUserStateResponse, error) {
|
||||
result := make(map[string]*itemUserStateResponse, len(items))
|
||||
if store == nil || profileID == "" || len(items) == 0 {
|
||||
@@ -127,10 +111,6 @@ func resolveItemUserStatesWithOptions(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ebookProgressMap, err := resolveEbookProgressForUserStates(ctx, options, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item == nil || item.ContentID == "" {
|
||||
@@ -145,8 +125,6 @@ func resolveItemUserStatesWithOptions(
|
||||
state.Played = allEpisodesCompleted(seriesEpisodes[item.ContentID], progressMap)
|
||||
case "season":
|
||||
state.Played = allEpisodesCompleted(seasonEpisodes[item.ContentID], progressMap)
|
||||
case "ebook":
|
||||
state.Played = ebookProgressMap[item.ContentID].Progress >= models.EbookFinishedProgressThreshold
|
||||
default:
|
||||
state.Played = progressMap[item.ContentID].Completed
|
||||
}
|
||||
@@ -156,18 +134,6 @@ func resolveItemUserStatesWithOptions(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveEbookProgressForUserStates(
|
||||
ctx context.Context,
|
||||
options itemUserStateOptions,
|
||||
profileID string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
if options.EbookProgressStore == nil || options.UserID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return options.EbookProgressStore.ListByContentIDs(ctx, options.UserID, profileID, contentIDs)
|
||||
}
|
||||
|
||||
func allEpisodesCompleted(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) bool {
|
||||
if len(episodes) == 0 {
|
||||
return false
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestResolveItemUserStatesIncludesCompletedEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete Ebook"},
|
||||
{ContentID: "ebook-progress", Type: "ebook", Title: "Partial Ebook"},
|
||||
}
|
||||
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
|
||||
UserID: 42,
|
||||
EbookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-complete": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"ebook-progress": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.45,
|
||||
UpdatedAt: time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
|
||||
}
|
||||
|
||||
if states["ebook-complete"] == nil || !states["ebook-complete"].Played {
|
||||
t.Fatalf("completed ebook state = %+v, want played", states["ebook-complete"])
|
||||
}
|
||||
if states["ebook-progress"] == nil || states["ebook-progress"].Played {
|
||||
t.Fatalf("partial ebook state = %+v, want not played", states["ebook-progress"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveItemUserStatesExcludesHiddenEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC)
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden Ebook"},
|
||||
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread Ebook"},
|
||||
}
|
||||
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
|
||||
UserID: 42,
|
||||
EbookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-hidden": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-hidden",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
"ebook-reread": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-reread",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt.Add(2 * time.Hour),
|
||||
},
|
||||
},
|
||||
hiddenBefore: map[string]time.Time{
|
||||
"ebook-hidden": updatedAt.Add(time.Hour),
|
||||
"ebook-reread": updatedAt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
|
||||
}
|
||||
|
||||
if states["ebook-hidden"] == nil || states["ebook-hidden"].Played {
|
||||
t.Fatalf("hidden ebook state = %+v, want not played", states["ebook-hidden"])
|
||||
}
|
||||
if states["ebook-reread"] == nil || !states["ebook-reread"].Played {
|
||||
t.Fatalf("ebook updated after hidden_before = %+v, want played again", states["ebook-reread"])
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -218,11 +220,7 @@ func (h *WatchProviderHandler) connectionStatus(w http.ResponseWriter, r *http.R
|
||||
func watchProviderRequestScope(w http.ResponseWriter, r *http.Request) (int, string, string, bool) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return 0, "", "", false
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return 0, "", "", false
|
||||
|
||||
@@ -362,10 +362,7 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
|
||||
if h.storeProvider != nil {
|
||||
store, storeErr := h.storeProvider.ForUser(ctx, userID)
|
||||
if storeErr == nil && store != nil {
|
||||
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
|
||||
if stateErr == nil {
|
||||
stateMap = states
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ type contextKey string
|
||||
// claimsKey is the context key for storing JWT claims.
|
||||
const claimsKey contextKey = "claims"
|
||||
|
||||
// userKey is the context key for the loaded authenticated user.
|
||||
const userKey contextKey = "user"
|
||||
|
||||
// SessionValidator checks whether a session is still valid (not revoked/expired).
|
||||
type SessionValidator interface {
|
||||
IsValid(ctx context.Context, sessionID string) (bool, error)
|
||||
@@ -38,25 +35,28 @@ type APIKeyValidator interface {
|
||||
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 auth.UserLoader // nil if API keys not configured
|
||||
userLoader auth.UserLoader // nil if no user store; RequireAdmin then denies
|
||||
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 auth.UserLoader, ul auth.UserLoader) *AuthMiddleware {
|
||||
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
tokenValidator: tv,
|
||||
sessionValidator: sv,
|
||||
apiKeyValidator: akv,
|
||||
apiKeyUserLoader: akul,
|
||||
userLoader: ul,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
||||
|
||||
claims = &auth.Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
SessionID: "",
|
||||
TokenType: auth.TokenTypeAPIKey,
|
||||
APIKeyID: apiKey.ID,
|
||||
@@ -143,28 +144,23 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAdmin enforces that the authenticated user currently holds the
|
||||
// admin permission. The check is server-side against group-derived policy —
|
||||
// never against token contents — so revoking admin takes effect on the next
|
||||
// request. The loaded user is stashed in the context for handlers.
|
||||
func (am *AuthMiddleware) RequireAdmin(next http.Handler) http.Handler {
|
||||
// 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 am.userLoader == nil {
|
||||
|
||||
if claims.Role != "admin" {
|
||||
writeForbidden(w, "Admin access required")
|
||||
return
|
||||
}
|
||||
user, err := am.userLoader.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !user.Enabled || !user.IsAdmin {
|
||||
writeForbidden(w, "Admin access required")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,15 +181,6 @@ func GetClaims(ctx context.Context) *auth.Claims {
|
||||
return claims
|
||||
}
|
||||
|
||||
// GetUser returns the user loaded by RequireAdmin, or nil.
|
||||
func GetUser(ctx context.Context) *models.User {
|
||||
user, ok := ctx.Value(userKey).(*models.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeAdminUserLoader implements auth.UserLoader for unit tests.
|
||||
type fakeAdminUserLoader struct {
|
||||
user *models.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserLoader) GetByID(_ context.Context, _ int) (*models.User, error) {
|
||||
return f.user, f.err
|
||||
}
|
||||
|
||||
// newTestAuthMiddleware returns an AuthMiddleware wired only with a userLoader;
|
||||
// the JWT/session fields are unused by RequireAdmin.
|
||||
func newTestAuthMiddleware(ul auth.UserLoader) *AuthMiddleware {
|
||||
return NewAuthMiddleware(nil, nil, nil, nil, ul)
|
||||
}
|
||||
|
||||
// adminClaims injects a minimal Claims into the given request context.
|
||||
func adminClaims(r *http.Request, userID int) *http.Request {
|
||||
ctx := SetClaims(r.Context(), &auth.Claims{UserID: userID, TokenType: auth.TokenTypeAccess})
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupReq func(*http.Request) *http.Request
|
||||
loader auth.UserLoader
|
||||
wantStatus int
|
||||
checkUser bool // if true, assert GetUser is non-nil inside the next handler
|
||||
}{
|
||||
{
|
||||
name: "no claims in context returns 401",
|
||||
setupReq: func(r *http.Request) *http.Request { return r }, // no claims injected
|
||||
loader: &fakeAdminUserLoader{user: &models.User{ID: 1, Enabled: true, IsAdmin: true}},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "nil user loader returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
|
||||
loader: nil,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "loader returns error returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
|
||||
loader: &fakeAdminUserLoader{err: errors.New("db error")},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "loader returns nil user returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
|
||||
loader: &fakeAdminUserLoader{user: nil, err: nil},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "disabled admin user returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 2) },
|
||||
loader: &fakeAdminUserLoader{
|
||||
user: &models.User{ID: 2, Enabled: false, IsAdmin: true},
|
||||
},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "enabled non-admin user returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 3) },
|
||||
loader: &fakeAdminUserLoader{
|
||||
user: &models.User{ID: 3, Enabled: true, IsAdmin: false},
|
||||
},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "enabled admin reaches next handler and user is stashed in context",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 4) },
|
||||
loader: &fakeAdminUserLoader{
|
||||
user: &models.User{ID: 4, Enabled: true, IsAdmin: true},
|
||||
},
|
||||
wantStatus: http.StatusNoContent,
|
||||
checkUser: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
am := newTestAuthMiddleware(tc.loader)
|
||||
|
||||
var gotUser *models.User
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUser = GetUser(r.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/something", nil)
|
||||
req = tc.setupReq(req)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
am.RequireAdmin(next).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
|
||||
if tc.checkUser {
|
||||
if gotUser == nil {
|
||||
t.Fatal("GetUser returned nil inside next handler, want a non-nil user")
|
||||
}
|
||||
if gotUser.ID != tc.loader.(*fakeAdminUserLoader).user.ID {
|
||||
t.Fatalf("stashed user ID = %d, want %d", gotUser.ID, tc.loader.(*fakeAdminUserLoader).user.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
// DemoSettingsReader is the subset of ServerSettingsStore needed by DemoGuard.
|
||||
@@ -22,12 +20,11 @@ type DemoSettingsReader interface {
|
||||
// Blocked: API key management, downloads, history imports, subtitle downloads.
|
||||
type DemoGuard struct {
|
||||
settings DemoSettingsReader
|
||||
users auth.UserLoader // nil means no admin bypass
|
||||
}
|
||||
|
||||
// NewDemoGuard creates a new DemoGuard.
|
||||
func NewDemoGuard(settings DemoSettingsReader, users auth.UserLoader) *DemoGuard {
|
||||
return &DemoGuard{settings: settings, users: users}
|
||||
func NewDemoGuard(settings DemoSettingsReader) *DemoGuard {
|
||||
return &DemoGuard{settings: settings}
|
||||
}
|
||||
|
||||
// blockedRoute defines a method + path prefix combination that is blocked in demo mode.
|
||||
@@ -62,8 +59,14 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Admins bypass all demo restrictions.
|
||||
claims := GetClaims(r.Context())
|
||||
if claims != nil && claims.Role == "admin" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this request matches a blocked route.
|
||||
blocked := false
|
||||
path := r.URL.Path
|
||||
for _, br := range demoBlockedRoutes {
|
||||
if !strings.HasPrefix(path, br.prefix) {
|
||||
@@ -71,51 +74,16 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
|
||||
}
|
||||
for _, m := range br.methods {
|
||||
if r.Method == m {
|
||||
blocked = true
|
||||
break
|
||||
writeDemoBlocked(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
if blocked {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !blocked {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Admins bypass all demo restrictions. Admin status is checked against
|
||||
// the loaded user (group-derived), never against token contents.
|
||||
if dg.isAdmin(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
writeDemoBlocked(w)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// isAdmin reports whether the request's authenticated user currently holds
|
||||
// the admin permission. It first consults the user stashed in the context by
|
||||
// RequireAdmin and falls back to a fresh load.
|
||||
func (dg *DemoGuard) isAdmin(r *http.Request) bool {
|
||||
if user := GetUser(r.Context()); user != nil {
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
if dg.users == nil {
|
||||
return false
|
||||
}
|
||||
claims := GetClaims(r.Context())
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
user, err := dg.users.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil {
|
||||
return false
|
||||
}
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
|
||||
func writeDemoBlocked(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
@@ -40,28 +40,26 @@ func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler)
|
||||
writeUnauthorized(w, "Authentication required")
|
||||
return
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if m == nil || m.users == nil || m.libraries == nil {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.users.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
// Admins bypass the per-library restriction checks below.
|
||||
if user.IsAdmin {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.users.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
|
||||
@@ -30,16 +30,16 @@ func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Conte
|
||||
return f.ids, f.err
|
||||
}
|
||||
|
||||
func requestWithItemID() *http.Request {
|
||||
func requestWithItemID(role string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil)
|
||||
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess})
|
||||
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, TokenType: auth.TokenTypeAccess})
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", "item-1")
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int) int {
|
||||
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int {
|
||||
mw := NewPermissionMiddleware(
|
||||
fakePermissionUserLoader{user: user},
|
||||
fakeTargetLibraryResolver{ids: libraryIDs},
|
||||
@@ -48,52 +48,52 @@ func runMetadataCurationMiddleware(user *models.User, libraryIDs []int) int {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
next.ServeHTTP(rec, requestWithItemID())
|
||||
next.ServeHTTP(rec, requestWithItemID(role))
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) {
|
||||
code := runMetadataCurationMiddleware(&models.User{ID: 7, Enabled: true, IsAdmin: true}, nil)
|
||||
code := runMetadataCurationMiddleware(nil, nil, "admin")
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) {
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
|
||||
code := runMetadataCurationMiddleware(user, []int{1})
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
|
||||
code := runMetadataCurationMiddleware(user, []int{1}, "user")
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) {
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2})
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) {
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 3})
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 3}, "user")
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) {
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2})
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) {
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, nil)
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, nil, "user")
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -46,12 +46,6 @@ func (m *ViewerAccessMiddleware) RequireViewerAccess(next http.Handler) http.Han
|
||||
scope, err := m.resolver.Resolve(r.Context(), input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, access.ErrUserDisabled):
|
||||
// Disabling a user also revokes their sessions, so RequireAuth
|
||||
// normally rejects first; this covers any session that is
|
||||
// still live (e.g. the account was disabled out of band).
|
||||
writeUnauthorized(w, "User account is disabled")
|
||||
return
|
||||
case errors.Is(err, access.ErrProfileUnverified):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
// PluginRequestRouterAdapter adapts plugins.Service to
|
||||
// mediarequests.RouterClientResolver. The concrete *pluginhost.RequestRouterClient
|
||||
// satisfies mediarequests.RouterClient, so the adapter names the interface as its
|
||||
// return type (Go has no return-type covariance).
|
||||
type PluginRequestRouterAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginRequestRouterAdapter) RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (mediarequests.RouterClient, error) {
|
||||
if a.Svc == nil {
|
||||
return nil, errors.New("request router plugin service is not configured")
|
||||
}
|
||||
return a.Svc.RequestRouterClient(ctx, installationID, capabilityID)
|
||||
}
|
||||
|
||||
// AttachRequestRouter wires the plugin-backed router provider onto a requests
|
||||
// service. Both the HTTP handler and the reconcile task call this so the wiring
|
||||
// lives in one place. With either dependency absent (e.g. a build without the
|
||||
// plugin service) it leaves the requests service router-less, so fulfillment
|
||||
// degrades to the existing "no backend configured" failure instead of panicking.
|
||||
func AttachRequestRouter(svc *mediarequests.Service, pluginService *plugins.Service) {
|
||||
if svc == nil || pluginService == nil {
|
||||
return
|
||||
}
|
||||
svc.SetRouterProvider(mediarequests.NewPluginRouterProvider(PluginRequestRouterAdapter{pluginService}))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user