Merge remote-tracking branch 'origin/main' into feat/audiobooks

# Conflicts:
#	go.sum
This commit is contained in:
RXWatcher
2026-05-27 16:27:25 +02:00
146 changed files with 15999 additions and 1363 deletions
+24 -10
View File
@@ -866,23 +866,34 @@ func main() {
if err != nil {
slog.Warn("failed to seed metadata queues", "error", err)
} else {
seedMovieQueue := func(folderID int) {
if movieQueueRepo == nil {
return
}
if err := movieQueueRepo.SyncForFolder(appCtx, folderID); err != nil {
slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err)
}
}
seedSeriesQueue := func(folderID int) {
if seriesQueueRepo == nil {
return
}
if err := seriesQueueRepo.SyncForFolder(appCtx, folderID); err != nil {
slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err)
}
}
for _, folder := range enabledFolders {
if folder == nil {
continue
}
switch strings.ToLower(strings.TrimSpace(folder.Type)) {
case "movie", "movies":
if movieQueueRepo != nil {
if err := movieQueueRepo.SyncForFolder(appCtx, folder.ID); err != nil {
slog.Warn("failed to seed movie match queue", "folder_id", folder.ID, "error", err)
}
}
seedMovieQueue(folder.ID)
case "series", "tv", "show", "tvshows":
if seriesQueueRepo != nil {
if err := seriesQueueRepo.SyncForFolder(appCtx, folder.ID); err != nil {
slog.Warn("failed to seed series root queue", "folder_id", folder.ID, "error", err)
}
}
seedSeriesQueue(folder.ID)
case "mixed":
seedSeriesQueue(folder.ID)
seedMovieQueue(folder.ID)
}
}
}
@@ -1665,6 +1676,9 @@ func main() {
// Construct auth service for jellycompat login.
userRepo := auth.NewUserRepository(deps.DB)
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
compatDeps.APIKeyUserLoader = userRepo
compatDeps.ScanQueue = deps.LibraryScanQueue
sessionRepo := auth.NewSessionRepository(deps.DB)
jwtService := auth.NewJWTService(
cfg.Auth.JWTSecret,
+15 -2
View File
@@ -185,9 +185,22 @@ curl -X POST http://your-server:8090/api/v1/scan \
## Integration with Autoscan
[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and other sources for new downloads, then relays scan requests to media servers. To use Autoscan with Silo, configure a **manual/generic target** using a custom script or webhook that calls the Silo scan API.
[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and
other sources for new downloads, then relays scan requests to media servers.
Silo supports Autoscan's stock Jellyfin target through the Jellyfin compatibility
server.
### Autoscan Custom Script Target
Use:
- URL: Silo's Jellyfin compatibility URL, usually `http://your-server:8096`
- Token: a Silo admin API key beginning with `sa_`
- Target type: Autoscan `jellyfin`
Autoscan discovers library roots from `GET /Library/VirtualFolders` and sends
changed paths to `POST /Library/Media/Updated`. The paths must be server-side
paths as Silo sees them.
### Alternative: Autoscan Custom Script Target
Create a script (e.g., `silo-scan.sh`) that Autoscan calls with the changed path:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,976 @@
# TMDB Duplicate Tie-Breaker 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:** Auto-match duplicate same-title/year provider candidates when one candidate has clearly richer metadata, while still refusing uncertain matches.
**Architecture:** Keep the existing title/year/ID scorer as the primary gate. Add a secondary detail-score path that runs only for near-tied duplicate candidates, enriches those candidates through the configured metadata provider chain, and accepts a winner only when the richness gap is strong. Manual match search can keep showing all candidates unchanged.
**Tech Stack:** Go, PostgreSQL-backed metadata service, existing metadata provider interfaces, `go test`.
---
## File Structure
- Modify `internal/metadata/match_candidates.go`
- Add detail-score fields to `MatchCandidate`.
- Add pure helper functions for duplicate-tie detection and metadata richness scoring.
- Update `selectInitialMatchCandidate` to use detail score only when the normal score gap rejects an otherwise duplicate tie.
- Modify `internal/metadata/match_candidates_test.go`
- Add focused unit tests for TMDB duplicate tie resolution.
- Add tests proving detail score does not override non-duplicate near matches.
- Modify `internal/metadata/service.go`
- Enrich candidate detail scores before selecting an initial match.
- Use the existing configured provider chain and `MetadataProvider.GetMetadata`, so the behavior works with installed TMDB/TVDB plugins instead of hard-coding a TMDB client.
- Modify `internal/metadata/service_test.go` or the nearest existing service-level test file if `service_test.go` already contains `MetadataService.Process` fakes
- Add one integration-style unit test proving two identical TMDB candidates can be disambiguated after detail enrichment.
Do not add migrations. Do not add frontend changes. Do not persist the detail score; it is a transient matching decision signal.
---
### Task 1: Add Detail-Score Tie-Breaker Unit Tests
**Files:**
- Modify: `internal/metadata/match_candidates_test.go`
- [ ] **Step 1: Add failing tests for duplicate tie selection**
Append these tests after `TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear`:
```go
func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "movie",
},
[]MatchCandidate{
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1558410"},
Sources: []string{"tmdb"},
DetailScore: 18,
},
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
Sources: []string{"tmdb"},
DetailScore: 46,
},
},
)
if !ok || winner == nil {
t.Fatal("expected richer duplicate TMDB candidate to be accepted")
}
if got := winner.ProviderIDs["tmdb"]; got != "17508" {
t.Fatalf("winner tmdb = %q, want 17508", got)
}
}
func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "movie",
},
[]MatchCandidate{
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1558410"},
Sources: []string{"tmdb"},
DetailScore: 28,
},
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "17508"},
Sources: []string{"tmdb"},
DetailScore: 34,
},
},
)
if ok || winner != nil {
t.Fatal("expected duplicate tie without clear detail gap to remain unmatched")
}
}
func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "movie",
},
[]MatchCandidate{
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "17508"},
Sources: []string{"tmdb"},
DetailScore: 22,
},
{
Title: "UFC 4: The Alternate Fights",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "999999"},
Sources: []string{"tmdb"},
DetailScore: 80,
},
},
)
if ok || winner != nil {
t.Fatal("expected richer different-title candidate to be rejected")
}
}
```
- [ ] **Step 2: Run tests and verify they fail**
Run:
```bash
go test ./internal/metadata -run 'TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie|TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap|TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie' -count=1
```
Expected: fail because `MatchCandidate.DetailScore` does not exist.
- [ ] **Step 3: Commit the failing tests**
```bash
git add internal/metadata/match_candidates_test.go
git commit -m "test(metadata): cover duplicate candidate tie breaking"
```
---
### Task 2: Implement Pure Detail-Score Selection
**Files:**
- Modify: `internal/metadata/match_candidates.go`
- [ ] **Step 1: Add transient detail fields to `MatchCandidate`**
Update the struct near the top of `internal/metadata/match_candidates.go`:
```go
type MatchCandidate struct {
Title string `json:"title"`
Year int `json:"year"`
ContentType string `json:"content_type"`
ProviderIDs map[string]string `json:"provider_ids"`
ImageURL string `json:"image_url,omitempty"`
Overview string `json:"overview,omitempty"`
Sources []string `json:"sources"`
AgreementHints []string `json:"agreement_hints"`
DetailScore int `json:"-"`
}
```
- [ ] **Step 2: Add duplicate-tie helper constants and functions**
Add these helpers after `providerIDRichness`:
```go
const (
minimumDetailTieBreakScore = 20
minimumDetailTieBreakGap = 12
)
func duplicateTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
if hints == nil || len(scoredCandidates) < 2 {
return nil, false
}
best := scoredCandidates[0]
if best.candidate.DetailScore < minimumDetailTieBreakScore {
return nil, false
}
contenders := []scoredMatchCandidate{best}
for i := 1; i < len(scoredCandidates); i++ {
next := scoredCandidates[i]
if best.score-next.score >= 15 {
break
}
if duplicateTieBreakComparable(hints, best.candidate, next.candidate) {
contenders = append(contenders, next)
}
}
if len(contenders) < 2 {
return nil, false
}
sort.SliceStable(contenders, func(i, j int) bool {
return contenders[i].candidate.DetailScore > contenders[j].candidate.DetailScore
})
if contenders[0].candidate.DetailScore-contenders[1].candidate.DetailScore < minimumDetailTieBreakGap {
return nil, false
}
return &contenders[0].candidate, true
}
func duplicateTieBreakComparable(hints *MatchHints, left, right MatchCandidate) bool {
if left.Year != 0 && right.Year != 0 && left.Year != right.Year {
return false
}
if hints.Year != 0 {
if left.Year != 0 && left.Year != hints.Year {
return false
}
if right.Year != 0 && right.Year != hints.Year {
return false
}
}
if strings.TrimSpace(left.ContentType) != "" &&
strings.TrimSpace(right.ContentType) != "" &&
!strings.EqualFold(left.ContentType, right.ContentType) {
return false
}
if inferTitleSimilarity(left.Title, right.Title, hints.Year) != 1 {
return false
}
if inferTitleSimilarity(hints.Title, left.Title, hints.Year) != 1 {
return false
}
if inferTitleSimilarity(hints.Title, right.Title, hints.Year) != 1 {
return false
}
return samePrimaryProvider(left.ProviderIDs, right.ProviderIDs)
}
func samePrimaryProvider(left, right map[string]string) bool {
for _, key := range canonicalCandidateIDKeys {
leftValue := strings.TrimSpace(left[key])
rightValue := strings.TrimSpace(right[key])
if leftValue != "" && rightValue != "" {
return true
}
}
return false
}
```
- [ ] **Step 3: Promote the local scored type so helpers can use it**
Move the `scored` type out of `selectInitialMatchCandidate` and rename it:
```go
type scoredMatchCandidate struct {
candidate MatchCandidate
score float64
}
```
Place it immediately above `selectInitialMatchCandidate`.
- [ ] **Step 4: Update `selectInitialMatchCandidate` to use the helper**
Replace the first half of `selectInitialMatchCandidate` with:
```go
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) {
if len(candidates) == 0 {
return nil, false
}
scoredCandidates := make([]scoredMatchCandidate, 0, len(candidates))
for _, candidate := range candidates {
scoredCandidates = append(scoredCandidates, scoredMatchCandidate{
candidate: candidate,
score: scoreMatchCandidate(hints, candidate),
})
}
sort.SliceStable(scoredCandidates, func(i, j int) bool {
return scoredCandidates[i].score > scoredCandidates[j].score
})
best := scoredCandidates[0]
if trustedHintIDsPresent(hints) {
if candidateMatchesTrustedIDs(hints, best.candidate) {
return &best.candidate, true
}
return nil, false
}
if best.score < 55 {
return nil, false
}
if len(scoredCandidates) == 1 {
if best.score < 70 {
return nil, false
}
return &best.candidate, true
}
if best.score-scoredCandidates[1].score < 15 {
return duplicateTieBreakWinner(hints, scoredCandidates)
}
return &best.candidate, true
}
```
- [ ] **Step 5: Run focused tests and verify they pass**
Run:
```bash
go test ./internal/metadata -run 'TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie|TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap|TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie' -count=1
```
Expected: pass.
- [ ] **Step 6: Run nearby candidate tests**
Run:
```bash
go test ./internal/metadata -run 'TestSelectInitialMatchCandidate|TestSelectRefreshMatchCandidate' -count=1
```
Expected: pass.
- [ ] **Step 7: Commit pure selector change**
```bash
git add internal/metadata/match_candidates.go internal/metadata/match_candidates_test.go
git commit -m "fix(metadata): resolve rich duplicate candidate ties"
```
---
### Task 3: Add Metadata Completeness Scoring
**Files:**
- Modify: `internal/metadata/match_candidates.go`
- Modify: `internal/metadata/match_candidates_test.go`
- [ ] **Step 1: Add failing tests for metadata completeness**
Append these tests near the other scoring tests in `internal/metadata/match_candidates_test.go`:
```go
func TestMetadataCompletenessScorePrefersExternalIDsAndRichFields(t *testing.T) {
rich := &MetadataResult{
HasMetadata: true,
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
Title: "UFC 4: Revenge of the Warriors",
Overview: "UFC 4 was a mixed martial arts event.",
Year: 1994,
Runtime: 99,
PosterPath: "tmdb://poster/17508.jpg",
BackdropPath: "tmdb://backdrop/17508.jpg",
Tagline: "Revenge of the Warriors",
OriginalTitle: "UFC 4: Revenge of the Warriors",
Studios: []string{"Ultimate Fighting Championship"},
Keywords: []string{"mixed martial arts"},
Ratings: Ratings{TMDB: 7.4},
People: []models.ItemPerson{
{Name: "Royce Gracie", Role: "Self", Type: "actor", OrderIndex: 0},
{Name: "Dan Severn", Role: "Self", Type: "actor", OrderIndex: 1},
},
}
thin := &MetadataResult{
HasMetadata: true,
ProviderIDs: map[string]string{"tmdb": "1558410"},
Title: "UFC 4: Revenge of the Warriors",
Overview: "UFC 4 used an eight-man tournament format.",
Year: 1994,
Runtime: 90,
PosterPath: "tmdb://poster/1558410.jpg",
People: []models.ItemPerson{
{Name: "Marcus Bossett", Type: "actor", OrderIndex: 0},
},
}
richScore := metadataCompletenessScore(rich)
thinScore := metadataCompletenessScore(thin)
if richScore-thinScore < minimumDetailTieBreakGap {
t.Fatalf("richScore - thinScore = %d, want at least %d; rich=%d thin=%d",
richScore-thinScore, minimumDetailTieBreakGap, richScore, thinScore)
}
}
func TestMetadataCompletenessScoreHandlesNilAndEmptyMetadata(t *testing.T) {
if got := metadataCompletenessScore(nil); got != 0 {
t.Fatalf("nil score = %d, want 0", got)
}
if got := metadataCompletenessScore(&MetadataResult{}); got != 0 {
t.Fatalf("empty score = %d, want 0", got)
}
}
```
- [ ] **Step 2: Run tests and verify they fail**
Run:
```bash
go test ./internal/metadata -run 'TestMetadataCompletenessScore' -count=1
```
Expected: fail because `metadataCompletenessScore` is undefined.
- [ ] **Step 3: Add completeness scoring helper**
Add this helper after `providerIDRichness` in `internal/metadata/match_candidates.go`:
```go
func metadataCompletenessScore(result *MetadataResult) int {
if result == nil || !result.HasMetadata {
return 0
}
score := 0
if strings.TrimSpace(result.ProviderIDs["imdb"]) != "" {
score += 18
}
if strings.TrimSpace(result.ProviderIDs["tvdb"]) != "" {
score += 18
}
if strings.TrimSpace(result.ProviderIDs["tmdb"]) != "" {
score += 4
}
if strings.TrimSpace(result.Title) != "" {
score += 4
}
if strings.TrimSpace(result.OriginalTitle) != "" {
score += 2
}
if strings.TrimSpace(result.Overview) != "" {
score += 6
}
if result.Year != 0 {
score += 4
}
if result.Runtime > 0 {
score += 3
}
if strings.TrimSpace(result.PosterPath) != "" {
score += 4
}
if strings.TrimSpace(result.BackdropPath) != "" {
score += 5
}
if strings.TrimSpace(result.Homepage) != "" {
score += 3
}
if len(result.Studios) > 0 {
score += 3
}
if len(result.Networks) > 0 {
score += 2
}
if len(result.Countries) > 0 {
score += 2
}
if len(result.Keywords) > 0 {
score += 2
}
if result.Ratings.TMDB > 0 {
score += 2
}
if strings.TrimSpace(result.ContentRating) != "" {
score += 2
}
score += boundedCountScore(len(result.People), 10)
return score
}
func boundedCountScore(count, max int) int {
if count <= 0 {
return 0
}
if count > max {
return max
}
return count
}
```
- [ ] **Step 4: Run completeness tests**
Run:
```bash
go test ./internal/metadata -run 'TestMetadataCompletenessScore' -count=1
```
Expected: pass.
- [ ] **Step 5: Run all candidate tests**
Run:
```bash
go test ./internal/metadata -run 'TestSelectInitialMatchCandidate|TestSelectRefreshMatchCandidate|TestMetadataCompletenessScore' -count=1
```
Expected: pass.
- [ ] **Step 6: Commit completeness scoring**
```bash
git add internal/metadata/match_candidates.go internal/metadata/match_candidates_test.go
git commit -m "fix(metadata): score candidate metadata completeness"
```
---
### Task 4: Enrich Near-Duplicate Candidates Before Initial Selection
**Files:**
- Modify: `internal/metadata/service.go`
- [ ] **Step 1: Add candidate enrichment call in initial match flow**
In `internal/metadata/service.go`, inside the `ModeInitialMatch` case, find:
```go
candidates := NormalizeCandidates(allResults, contentType)
if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil {
for k, v := range winner.ProviderIDs {
if v != "" {
accumulatedIDs[k] = v
}
}
}
```
Replace it with:
```go
candidates := NormalizeCandidates(allResults, contentType)
s.enrichInitialMatchDuplicateCandidates(ctx, req, itemChain, candidates)
if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil {
for k, v := range winner.ProviderIDs {
if v != "" {
accumulatedIDs[k] = v
}
}
}
```
- [ ] **Step 2: Add enrichment helpers**
Add these helpers near `processInternal` helper functions in `internal/metadata/service.go`:
```go
func (s *MetadataService) enrichInitialMatchDuplicateCandidates(
ctx context.Context,
req ProcessRequest,
itemChain []Provider,
candidates []MatchCandidate,
) {
if req.Hints == nil || len(candidates) < 2 {
return
}
indexes := candidateIndexesNeedingDetailScores(req.Hints, candidates)
if len(indexes) < 2 {
return
}
for _, index := range indexes {
candidates[index].DetailScore = s.detailScoreForCandidate(ctx, req, itemChain, candidates[index])
}
}
func candidateIndexesNeedingDetailScores(hints *MatchHints, candidates []MatchCandidate) []int {
if hints == nil || len(candidates) < 2 {
return nil
}
scoredCandidates := make([]scoredMatchCandidate, 0, len(candidates))
for _, candidate := range candidates {
scoredCandidates = append(scoredCandidates, scoredMatchCandidate{
candidate: candidate,
score: scoreMatchCandidate(hints, candidate),
})
}
sort.SliceStable(scoredCandidates, func(i, j int) bool {
return scoredCandidates[i].score > scoredCandidates[j].score
})
if scoredCandidates[0].score < 55 {
return nil
}
if len(scoredCandidates) < 2 || scoredCandidates[0].score-scoredCandidates[1].score >= 15 {
return nil
}
indexes := make([]int, 0, len(candidates))
for index, candidate := range candidates {
if duplicateTieBreakComparable(hints, scoredCandidates[0].candidate, candidate) {
indexes = append(indexes, index)
}
}
return indexes
}
func (s *MetadataService) detailScoreForCandidate(
ctx context.Context,
req ProcessRequest,
itemChain []Provider,
candidate MatchCandidate,
) int {
accumulator := &MetadataResult{
ProviderIDs: copyMap(candidate.ProviderIDs),
}
for _, provider := range itemChain {
metadataProvider, ok := provider.(MetadataProvider)
if !ok {
continue
}
result, err := metadataProvider.GetMetadata(ctx, MetadataRequest{
ProviderIDs: copyMap(accumulator.ProviderIDs),
ContentType: candidate.ContentType,
Language: req.Language,
FilePath: req.Hints.FilePath,
RepresentativeFilePath: req.Hints.RepresentativeFilePath,
ObservedRootPath: req.Hints.ObservedRootPath,
AllGroupFilePaths: append([]string(nil), req.Hints.AllGroupFilePaths...),
PrimarySidecarSearchPaths: append([]string(nil), req.Hints.PrimarySidecarSearchPaths...),
GroupTitle: req.Hints.Title,
GroupYear: req.Hints.Year,
})
if err != nil || result == nil || !result.HasMetadata {
continue
}
mergeProviderIDs(accumulator, result)
mergeMetadataResult(accumulator, result)
}
return metadataCompletenessScore(accumulator)
}
```
- [ ] **Step 3: Add `sort` import if needed**
If `internal/metadata/service.go` does not already import `sort`, add it to the existing import block:
```go
import (
"sort"
)
```
Do not create a second import block.
- [ ] **Step 4: Run compile-focused metadata tests**
Run:
```bash
go test ./internal/metadata -run 'TestSelectInitialMatchCandidate|TestMetadataCompletenessScore' -count=1
```
Expected: pass.
- [ ] **Step 5: Run package tests**
Run:
```bash
go test ./internal/metadata -count=1
```
Expected: pass.
- [ ] **Step 6: Commit service enrichment**
```bash
git add internal/metadata/service.go internal/metadata/match_candidates.go internal/metadata/match_candidates_test.go
git commit -m "fix(metadata): enrich duplicate candidates before auto match"
```
---
### Task 5: Add Service-Level Regression Test
**Files:**
- Modify: `internal/metadata/service_test.go` if it exists
- Otherwise modify the existing metadata service test file that already defines fake metadata providers
- [ ] **Step 1: Locate existing service fake providers**
Run:
```bash
rg -n "type .*Provider|GetMetadata\\(|Search\\(" internal/metadata/*test.go
```
Expected: output includes existing fake provider definitions. Use the file that already tests `MetadataService.Process`.
- [ ] **Step 2: Add a fake provider if the selected test file does not already have one**
Add this fake to the selected test file:
```go
type duplicateSearchAndMetadataProvider struct {
searchResults []SearchResult
metadataByID map[string]*MetadataResult
}
func (p *duplicateSearchAndMetadataProvider) Slug() string { return "tmdb" }
func (p *duplicateSearchAndMetadataProvider) Name() string { return "TMDB" }
func (p *duplicateSearchAndMetadataProvider) ForTypes() []string {
return []string{"movie"}
}
func (p *duplicateSearchAndMetadataProvider) Search(context.Context, SearchQuery) ([]SearchResult, error) {
return append([]SearchResult(nil), p.searchResults...), nil
}
func (p *duplicateSearchAndMetadataProvider) GetMetadata(_ context.Context, req MetadataRequest) (*MetadataResult, error) {
tmdbID := req.ProviderIDs["tmdb"]
if result, ok := p.metadataByID[tmdbID]; ok {
clone := *result
clone.ProviderIDs = copyMap(result.ProviderIDs)
clone.People = append([]models.ItemPerson(nil), result.People...)
return &clone, nil
}
return nil, ErrMetadataNotFound
}
```
- [ ] **Step 3: Add regression test for UFC 4 duplicate selection**
Add this test to the selected file, adapting only the existing service-construction helper name if the file already has one:
```go
func TestProcessInitialMatchSelectsRicherDuplicateTMDBCandidate(t *testing.T) {
ctx := context.Background()
provider := &duplicateSearchAndMetadataProvider{
searchResults: []SearchResult{
{
Name: "UFC 4: Revenge of the Warriors",
Year: 1994,
Provider: "tmdb",
ProviderIDs: map[string]string{"tmdb": "1558410"},
ImageURL: "tmdb://poster/1558410.jpg",
Overview: "UFC 4 used an eight-man tournament format.",
},
{
Name: "UFC 4: Revenge of the Warriors",
Year: 1994,
Provider: "tmdb",
ProviderIDs: map[string]string{"tmdb": "17508"},
ImageURL: "tmdb://poster/17508.jpg",
Overview: "UFC 4 was a mixed martial arts event.",
},
},
metadataByID: map[string]*MetadataResult{
"1558410": {
HasMetadata: true,
ProviderIDs: map[string]string{"tmdb": "1558410"},
Title: "UFC 4: Revenge of the Warriors",
Overview: "UFC 4 used an eight-man tournament format.",
Year: 1994,
Runtime: 90,
PosterPath: "tmdb://poster/1558410.jpg",
},
"17508": {
HasMetadata: true,
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
Title: "UFC 4: Revenge of the Warriors",
Overview: "UFC 4 was a mixed martial arts event.",
Year: 1994,
Runtime: 99,
PosterPath: "tmdb://poster/17508.jpg",
BackdropPath: "tmdb://backdrop/17508.jpg",
Homepage: "http://www.ufc.com/index.cfm?fa=eventdetail.fightCard&eid=5",
People: []models.ItemPerson{
{Name: "Royce Gracie", Role: "Self", Type: "actor", OrderIndex: 0},
{Name: "Dan Severn", Role: "Self", Type: "actor", OrderIndex: 1},
{Name: "Keith Hackney", Role: "Self", Type: "actor", OrderIndex: 2},
},
},
},
}
service := newTestMetadataService(t, []Provider{provider})
result, err := service.Process(ctx, ProcessRequest{
ContentID: "local-ufc-4",
FolderID: "7",
Mode: ModeInitialMatch,
Hints: &MatchHints{
ContentID: "local-ufc-4",
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "movie",
FilePath: "/sports/movies/UFC/UFC 4 Revenge of the Warriors (1994)/UFC 4 Revenge of the Warriors (1994) SDTV.avi",
},
})
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("Process result = %#v, want updated result", result)
}
item := mustGetTestMediaItem(t, service, "local-ufc-4")
if item.TmdbID != "17508" {
t.Fatalf("item.TmdbID = %q, want 17508", item.TmdbID)
}
if item.ImdbID != "tt0487980" {
t.Fatalf("item.ImdbID = %q, want tt0487980", item.ImdbID)
}
}
```
If the repository uses differently named helpers, keep the same assertions and wire the provider into the existing helper. The test must assert the persisted item has TMDB `17508` and IMDb `tt0487980`.
- [ ] **Step 4: Run the new service test and verify it fails before helper wiring is complete**
Run:
```bash
go test ./internal/metadata -run 'TestProcessInitialMatchSelectsRicherDuplicateTMDBCandidate' -count=1
```
Expected: fail if helper names are not wired yet, or pass if the selected test harness already supports fake chains.
- [ ] **Step 5: Wire the test to existing metadata service test helpers**
Use the selected files existing constructors and repositories. The final test must use a real `MetadataService.Process` call, not a direct call to `selectInitialMatchCandidate`.
- [ ] **Step 6: Run the service regression test**
Run:
```bash
go test ./internal/metadata -run 'TestProcessInitialMatchSelectsRicherDuplicateTMDBCandidate' -count=1
```
Expected: pass.
- [ ] **Step 7: Commit regression coverage**
```bash
git add internal/metadata/*test.go
git commit -m "test(metadata): verify rich TMDB duplicate auto match"
```
---
### Task 6: Verify on the Dev Server
**Files:**
- No code files
- [ ] **Step 1: Run targeted local verification**
Run:
```bash
go test ./internal/metadata -run 'TestSelectInitialMatchCandidate|TestSelectRefreshMatchCandidate|TestMetadataCompletenessScore|TestProcessInitialMatchSelectsRicherDuplicateTMDBCandidate' -count=1
```
Expected: pass.
- [ ] **Step 2: Run broader affected package verification**
Run:
```bash
go test ./internal/metadata ./internal/scanner ./internal/libraryingest ./internal/taskmanager -count=1
```
Expected: pass.
- [ ] **Step 3: Deploy to dev**
Run:
```bash
make dev-deploy
```
Expected: build succeeds and Docker Compose restarts the dev server.
- [ ] **Step 4: Confirm dev readiness**
Run:
```bash
ssh root@100.86.116.20 'curl -s http://localhost:8090/api/v1/ready'
```
Expected:
```json
{"status":"ok"}
```
- [ ] **Step 5: Requeue the UFC 4 movie row**
Run:
```bash
ssh root@100.86.116.20 "docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c \"UPDATE movie_match_queue SET available_at = now() - interval '1 hour', last_attempted_at = NULL, updated_at = now() WHERE media_file_id = 2425791;\""
```
Expected:
```text
UPDATE 1
```
- [ ] **Step 6: Trigger or wait for metadata matching**
Run:
```bash
ssh root@100.86.116.20 "docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c \"SELECT media_file_id, available_at, last_attempted_at, attempt_count, last_error FROM movie_match_queue WHERE media_file_id = 2425791;\""
```
Expected after the worker claims the row: `last_attempted_at` is non-null and newer than the requeue time.
- [ ] **Step 7: Verify the item matched to TMDB 17508**
Run:
```bash
ssh root@100.86.116.20 "docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c \"SELECT mf.id AS file_id, mi.content_id, mi.title, mi.year, mi.status, mi.tmdb_id, mi.imdb_id FROM media_files mf JOIN media_items mi ON mi.content_id = mf.content_id WHERE mf.id = 2425791;\""
```
Expected row:
```text
file_id | content_id | title | year | status | tmdb_id | imdb_id
---------+--------------------+-------------------------------+------+---------+---------+-----------
2425791 | 126715023410790404 | UFC 4: Revenge of the Warriors | 1994 | matched | 17508 | tt0487980
```
- [ ] **Step 8: Commit any deployment-only notes are not needed**
No commit for dev verification output. Keep the repository clean except for code/test changes.
---
## Self-Review
Spec coverage:
- Auto-match still refuses uncertain duplicate ties: Task 2.
- Correct TMDB duplicate can be selected when metadata richness is clearly better: Tasks 2, 3, 4, 5.
- No hard dependency on TMDB-only client code: Task 4 uses `MetadataProvider`.
- Runtime is weak and does not override richer metadata: Task 3 weights runtime at `3`, external IDs and rich fields higher.
- Manual search remains unchanged: no frontend/API candidate response change is planned.
Placeholder scan:
- No `TBD`, `TODO`, `implement later`, or "write tests for the above" placeholders remain.
- The one service-test helper adaptation step is constrained to existing test harness names and includes exact required assertions.
Type consistency:
- `MatchCandidate.DetailScore` is defined before selector tests use it.
- `scoredMatchCandidate` is used by both `selectInitialMatchCandidate` and service enrichment.
- `metadataCompletenessScore` accepts `*MetadataResult`, matching provider `GetMetadata` results.
@@ -0,0 +1,166 @@
# Jellyfin Autoscan Scan Compatibility Design
## Goal
Make Silo work with Autoscan's stock Jellyfin target. Autoscan should be able to
point at Silo's Jellyfin compatibility URL, use a Silo admin API key as the
Jellyfin token, discover Silo library roots, and notify Silo about changed media
paths without a custom script.
This design is intentionally scoped to Jellyfin compatibility only. Emby routes
and aliases are out of scope.
## Current State
Silo already has a native admin scan API at `POST /api/v1/scan`. It accepts
either `library_id`, `path`, or both, resolves the target to a full-library,
subtree, or single-file scan, and dispatches through the existing scan queue or
scanner path.
Silo's Jellyfin compatibility server currently supports enough read/playback
routes for Jellyfin clients, including `GET /System/Info` and
`GET /Library/VirtualFolders`, but it does not expose Jellyfin's scan notification
endpoint. `GET /Library/VirtualFolders` also returns empty `Locations`, which
prevents Autoscan from matching incoming paths to Jellyfin libraries.
Autoscan's Jellyfin target uses this flow:
1. `GET /System/Info` with `X-Emby-Token`.
2. `GET /Library/VirtualFolders` with `X-Emby-Token`.
3. `POST /Library/Media/Updated` with `X-Emby-Token` and a body shaped like:
```json
{
"Updates": [
{
"path": "/media/tv/Show/Season 01/Episode.mkv",
"updateType": "Modified"
}
]
}
```
## Compatibility Surface
Add a small Jellyfin scan compatibility adapter under `internal/jellycompat`.
The adapter should own Jellyfin scan/discovery semantics and translate them into
Silo's existing scan behavior.
The first supported route set is:
- `GET /System/Info`: already present, but should accept Silo admin API keys on
the Autoscan path.
- `GET /Library/VirtualFolders`: return enabled Silo libraries with real
configured root paths in `Locations`.
- `POST /Library/Media/Updated`: accept Autoscan update payloads and enqueue
equivalent Silo scans.
Do not add Emby-specific routes such as `/emby/Library/SelectableMediaFolders`
or `/emby/Library/Media/Updated` in this pass.
## Authentication
For the Jellyfin scan/discovery routes needed by Autoscan, allow a Silo admin API
key (`sa_...`) in the token locations Autoscan uses:
- `X-Emby-Token`
- `X-Mediabrowser-Token`
- `Authorization: Bearer`
- `api_key` query parameter
The API key must resolve to an enabled Silo admin user. Non-admin API keys must
receive a non-2xx authorization error. Existing Jellyfin compatibility session
tokens should continue to work for normal Jellyfin client routes; this change
should not broadly weaken playback or browse authorization.
## Library Discovery
`GET /Library/VirtualFolders` should include `Locations` using the exact
server-side paths configured on each enabled Silo library. Autoscan appends a
trailing slash internally and compares incoming paths against these roots, so the
paths must be real filesystem paths as Silo sees them.
Disabled libraries should be omitted from the Autoscan discovery response because
they are not valid scan targets.
## Scan Notification Behavior
`POST /Library/Media/Updated` should parse every `Updates[]` entry with a
non-empty `path`. The first pass ignores `updateType`; Autoscan sends
`Modified`, and Silo's existing path resolver determines the correct scan mode.
Each update path should use the same effective target resolution as
`POST /api/v1/scan`:
- A path equal to a configured library root becomes a full-library scan.
- A directory under a configured root becomes a subtree scan.
- A supported media file under a configured root becomes a file scan.
- Paths outside all libraries, missing paths, permission failures, special files,
disabled libraries, and unsupported file extensions are rejected.
For requests containing multiple updates, resolution should be all-or-fail:
validate every update first, enqueue nothing if any update is invalid, and return
a non-2xx error. This avoids Autoscan seeing success while Silo silently drops
part of the request.
When all updates are valid, enqueue each resolved scan independently and let the
existing scan queue deduplicate or serialize overlapping work. The compatibility
adapter should not implement a separate deduplication policy.
The successful response can be `204 No Content`; Autoscan only requires a 2xx.
## Component Boundaries
Keep the compatibility layer small and explicit:
- Add a Jellyfin scan handler in `internal/jellycompat` for
`Library/Media/Updated` and Autoscan-facing `VirtualFolders`.
- Share scan target resolution with the native scan API by extracting the
resolver/enqueue logic behind a small interface or helper. Avoid duplicating
path classification rules in two packages.
- Reuse the existing API key repository and user lookup logic for admin API key
validation rather than creating a Jellyfin-specific API key store.
- Continue routing normal playback, browse, and user-data Jellyfin endpoints
through the existing compat session authenticator.
## Error Handling
Return non-2xx responses for invalid scan notifications so Autoscan can treat the
target as failed:
- `401 Unauthorized` for missing or invalid tokens.
- `403 Forbidden` for valid non-admin keys.
- `400 Bad Request` for malformed JSON, empty update lists, empty paths, paths
outside libraries, missing paths, unsupported files, and other validation
failures.
- `409 Conflict` for paths that map only to a disabled library.
- `503 Service Unavailable` if the scanner or scan queue is unavailable.
- `500 Internal Server Error` for unexpected repository or enqueue failures.
The response body may use Silo's existing JSON error shape where practical.
## Testing
Add focused backend tests for this compatibility surface:
- Admin API key auth is accepted by Autoscan routes.
- Non-admin or invalid keys are rejected.
- `GET /Library/VirtualFolders` includes enabled library `Locations`.
- `POST /Library/Media/Updated` maps a valid file or directory path into an
enqueued Silo scan.
- Multi-update requests are all-or-fail and do not enqueue partial scans when
one path is invalid.
No frontend tests are needed.
## Documentation
Update `docs/scan-api.md` to explain that Autoscan can use its stock Jellyfin
target:
- URL: Silo's Jellyfin compatibility URL, usually `http://host:8096`.
- Token: a Silo admin API key beginning with `sa_`.
- Paths: server-side paths as seen by Silo.
Keep the custom script/webhook example as an alternative for users who do not
want to expose the Jellyfin compatibility endpoint.
@@ -0,0 +1,105 @@
# Search Request Section Design
## Goal
Surface TMDB-backed "requestable" results inside the main catalog search so users can discover and request items that aren't in their library without leaving the search flow. The library remains the primary surface; requestable results are an additive, clearly delimited section that never blocks or displaces library results.
## Behavior
### Layout (both surfaces)
- The library section renders first using existing FTS results. No changes to library ranking, pagination, or row layout.
- A "Request to Add" section renders below the library results when:
- admin `RequestsEnabled = true`, AND
- the viewer has a profile, AND
- the TMDB query returns at least one result that is not already in the library.
- The Cmd+K search dialog (`GlobalSearch`) shows up to 4 TMDB rows beneath a single "Not in your library?" CTA strip.
- The full search results page (`Catalog`) shows a section divider, a section header, then a grid of up to 20 TMDB cards on initial render.
- Clicking any TMDB row or card navigates to the existing `/requests/{media_type}/{tmdb_id}` detail page. The detail page is responsible for the actual request action and confirmation.
### Section header copy
- When library has ≥1 hit: header reads "Request to Add".
- When library has 0 hits and TMDB has ≥1 hit: header is replaced by a soft framing — "Not in your library, but you can request" — and there is no separate empty state for library.
- When both sources return 0 results: the existing "No matches" / "No items found" empty state is unchanged; no requestable section renders.
### Quota / blocked viewers
Discovery eligibility (whether the TMDB query fires) is separate from submission eligibility (whether the row's request CTA is active):
- **Discovery eligibility** is gated only by global/identity preconditions: admin `RequestsEnabled = true`, the viewer is authenticated, and has a profile. If any of these is false, the TMDB query does not fire and the section is not rendered.
- **Submission eligibility** is per-viewer policy: quota-exhausted, individually blocked (`UserLimit.LimitMode = "blocked"`), or otherwise restricted. When discovery is allowed but submission is not, the section still renders, each row's request affordance is disabled, and a tooltip surfaces the reason. Rows remain clickable and still navigate to the detail page, which is responsible for displaying the full policy state.
This keeps search-side UX consistent with what the detail page would show for the same viewer.
### Performance
- Library results never wait on TMDB. The two queries fire concurrently from the client; the library section paints as soon as FTS returns.
- TMDB query is debounced at 400ms; library query stays at the current 200ms.
- TMDB query is cancelled in-flight when the query string changes. This requires extending `useRequestSearch` to accept and forward `{ signal }` to `api` (it does not today); see Architecture.
- TMDB error or timeout silently omits the section; no error banner.
- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). The 5-minute window is only safe because the cache key includes viewer identity (see Architecture); cross-viewer reuse is impossible.
## Architecture
- No backend changes to existing endpoints. The frontend coordinates two parallel queries.
- Library on the results page: existing `useCatalogWindow` against `/api/v1/catalog?source=query`.
- Library in the Cmd+K dialog: existing `previewQuery` pattern using `fetchCatalogPage` against the same endpoint.
- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces — see required extensions below.
- Deduplication is handled server-side by the existing `enrichPage()``presence.Lookup()` flow on `/requests/search`. Client filters TMDB results where `availability == "available"` so they don't shadow library rows.
### Gating hook (`useCanRequest`)
The new hook splits its return into two independent signals:
- `discoveryEnabled: boolean` — true when admin `RequestsEnabled = true` AND the viewer is authenticated with a profile. This is the only signal that controls whether the TMDB query fires.
- `submitDisabledReason: string | null` — null when the viewer can submit; otherwise one of `"blocked"`, `"quota_exhausted"`, or a future reason key. Passed through `RequestToAddSection` to per-row UI to disable the request CTA and populate its tooltip.
Per-viewer policy state (`EffectivePolicy.LimitMode`, quota counters) feeds `submitDisabledReason` and is never used to suppress the query.
### `useRequestSearch` extensions
The existing hook is reused but must be extended before it can back this feature safely:
- **Pass through `{ signal }`**: the query function currently does not accept the react-query `signal`. Update it to accept the signal and forward it to `api` so in-flight TMDB requests are cancelled on query change, unmount, or viewer change.
- **Key by viewer identity**: extend `requestKeys.search(...)` to include the active `profile_id` (and `user_id` if profile alone is insufficient to identify the policy holder). This prevents cached results from being served across viewer changes and makes the 5-minute `staleTime` safe.
- **Invalidate on policy or identity change**: invalidate `requestKeys.search()` queries when any of the following occurs in the SPA: login/logout, profile switch, admin `RequestsEnabled` toggle, `UserLimit` mutation affecting the current viewer, or quota reset/refresh. The invalidation hooks live alongside the existing auth/profile/settings stores.
## Components
- `web/src/hooks/useCanRequest.ts` (new): exposes `{ discoveryEnabled, submitDisabledReason }` derived from settings + viewer identity + policy as described in Architecture.
- `web/src/hooks/queries/useRequests.ts` (modified): extend `useRequestSearch` and `requestKeys.search(...)` to accept/forward `{ signal }`, include viewer identity in the query key, and expose invalidation helpers used by the auth/profile/settings stores.
- `web/src/components/RequestToAddSection.tsx` (new): renders the section in two variants:
- `variant="dialog"` — compact row layout for `GlobalSearch`.
- `variant="grid"` — poster grid using existing `RequestPosterCard` for `Catalog`.
Accepts `submitDisabledReason` and propagates it to per-row CTAs.
- `web/src/components/GlobalSearch.tsx` (modified): wires the second query, passes results into `RequestToAddSection` with `variant="dialog"`.
- `web/src/pages/Catalog.tsx` (modified): renders `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when the source is `query`.
## Edge cases
- TMDB returns only items already available in the library: section is omitted (after client filter).
- Library has hits but TMDB is still loading: library renders immediately; section shows a compact skeleton in its slot, then either renders or vanishes.
- Library has 0 hits and TMDB is still pending: the page suppresses the "No matches" empty state and shows a single loading indicator until TMDB resolves. Only after TMDB returns 0 (or errors) does the empty state render.
- TMDB query never fires (discovery gated off): library follows its existing behavior including the standard empty state.
- Viewer logs out, switches profile, or admin disables `RequestsEnabled` mid-query: `useCanRequest()` re-evaluates and `discoveryEnabled` flips to false; the in-flight TMDB request is cancelled via its forwarded `signal`, and cached entries under the previous viewer identity are invalidated so they cannot be re-served.
- Admin updates `UserLimit` for the current viewer while results are cached: the settings/limit mutation triggers a `requestKeys.search()` invalidation; the next paint re-fetches with the new `submitDisabledReason`.
- Source is not `query` (e.g., `favorites`, `watchlist`, `history`, `section`): section never renders.
## Out of scope
- Backend changes to `/api/v1/catalog` or any merged endpoint.
- Inline request submission from search results (the detail page continues to own request creation).
- Surfacing requestable results in any non-search context (home, library browse, etc.).
- Person / cast results from TMDB. Only movie and series results are shown.
## Verification
Commands assume the repository root is the cwd.
- `cd web && pnpm run lint`
- `cd web && pnpm run format:check`
- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, blocked viewer (section renders, CTAs disabled), quota-exhausted viewer (section renders, CTAs disabled), requests-globally-off (no TMDB query fired, no section).
- Hook tests for `useCanRequest` across the matrix of `RequestsEnabled`, auth state, profile presence, and policy states, asserting that `discoveryEnabled` and `submitDisabledReason` are independent.
- Hook/integration tests for the extended `useRequestSearch`: confirm `signal` forwarding cancels in-flight requests on query change, confirm cache entries are not shared across `profile_id` keys, and confirm the relevant store mutations invalidate `requestKeys.search()`.
- Manual smoke in the dev frontend: confirm library results are not delayed when TMDB is slow or errors; confirm the dialog and full-page surfaces both show the section under matching conditions.
@@ -0,0 +1,110 @@
# PageBack Component Design
## Goal
Replace the inconsistent collection of inline back affordances across user-facing pages with a single shared `PageBack` component, placed in the top-left of every non-root page at a stable pixel offset that does not drift with title length, hero content, or page layout.
Today, back navigation is implemented eight different ways across the app (`DetailBreadcrumb` chevron inside the hero, outline `<Button>` at the page top, ghost `<Link>` with "Back to X" text, `surface-panel-subtle` pill in the page header, plain text link with `←`, etc.), at three different positions (overlaid on hero info column, top-left of page-shell, top-right of page header). A user reported the affordance is hard to find precisely because it moves around as titles and hero content shift. This spec consolidates all of them.
## Behavior
### The component
`<PageBack />` renders a small circular chevron pill in the top-left of its containing element. It is visually identical to the existing `DetailBreadcrumb` chevron (`glass-subtle` background, `ChevronLeft size-5`, muted-foreground with hover) so users already familiar with the season/episode back arrow see no change in that affordance — only a more consistent location.
- Takes no `to` or `onClick` props. A single, fixed behavior is the point.
- Calls `navigate(-1)` on click.
- `aria-label` defaults to "Go back" and is overridable via an optional `label` prop for screen reader context.
- Uses `position: absolute; top: 1rem; left: 1rem; z-index: 20` with `sm:top-1.5rem sm:left-1.5rem`. Pages place it inside a `position: relative` ancestor — typically the existing hero `<section>` (which is already `relative isolate overflow-hidden`) or a wrapping `<div className="relative">` at the top of the page-shell.
### Placement strategy
Two cases:
1. **Hero pages** (ItemDetail variants, PersonDetail). `<PageBack />` is a direct child of the hero `<section>`. The hero is already `relative isolate overflow-hidden`, so the chevron overlays the backdrop in the top-left. No layout change to the hero itself.
2. **Non-hero pages** (Settings, Request detail, Request browse, Collection editors, Smart collection wizard, Recommendations section, Profile customize home). `<PageBack />` is rendered at the top of the page-shell inside a `<div className="relative">` wrapper, replacing the page's current bespoke back link or button.
In both cases the chevron lands in roughly the same screen pixel range — top-left, just inside the page-shell padding. The title and surrounding content shift around the chevron instead of the other way around.
### Behavior on history-less loads
When a user lands on a non-root page via direct URL (deep link, refresh, new tab) and there is no prior history entry within the SPA, `navigate(-1)` does nothing useful. The chevron is still rendered (we cannot detect this state reliably with React Router v6's history API). This matches browser mouse-back behavior and is acceptable because (a) the failure mode is silent — clicking the button does nothing visible — and (b) users who reach a page via deep link can navigate via the main app shell. We do not gate rendering on history length.
### What `DetailBreadcrumb` becomes
`DetailBreadcrumb` keeps its textual breadcrumb path on Season and Episode pages — the path (e.g., "Severance Season 1") communicates hierarchy and is independent of back navigation. The leading `ChevronLeft` is removed from `DetailBreadcrumb`; PageBack now owns that affordance. The breadcrumb segments themselves remain individually clickable links to their hierarchy targets.
## Architecture
`PageBack` is a presentational client component with one dependency: React Router's `useNavigate`. No new state, no new context, no new routes. The component lives in `web/src/components/PageBack.tsx` alongside other shared UI primitives.
Each consuming page imports `PageBack` and renders it inside an already-`relative` container. No central placement registry, no per-route configuration — each page is responsible for opting in. This keeps the change localized and reviewable per page.
## Pages affected
### Hero pages — add `<PageBack />` (new affordance)
- `web/src/pages/ItemDetail/MovieContent.tsx`
- `web/src/pages/ItemDetail/SeriesContent.tsx`
- `web/src/pages/PersonDetail.tsx`
### Hero pages — add `<PageBack />`, simplify `DetailBreadcrumb`
- `web/src/pages/ItemDetail/SeasonContent.tsx`
- `web/src/pages/ItemDetail/EpisodeContent.tsx`
In both, the inline `DetailBreadcrumb` retains the textual hierarchy path inside the hero info column; PageBack overlays the hero at top-left.
### Non-hero pages — replace existing back affordance
- `web/src/pages/SettingsLayout.tsx` — remove the `surface-panel-subtle` `ArrowLeft` pill from the page header.
- `web/src/pages/RequestDetail.tsx` — remove both the outline `<Button>` at the top and the duplicate `<Button>` near the bottom of the page.
- `web/src/pages/RequestBrowse.tsx` — remove the "Back to Requests" text link.
- `web/src/pages/CollectionEditor.tsx` — remove the ghost "Back to Collections" `<Link>` (currently rendered in three branches).
- `web/src/pages/ImportedCollectionEditor.tsx` — same.
- `web/src/pages/SmartCollectionWizard.tsx` — remove the ghost "Back to Collections" link at the top (the inline "Back to Filters" / floating wizard back buttons stay; they are step controls, not page navigation).
- `web/src/pages/RecommendationsSection.tsx` — remove the "Recommendations" `<Link>` with `ArrowLeft` near the section header.
### Non-hero pages — add `<PageBack />` (new affordance)
- `web/src/pages/ProfileCustomizeHome.tsx` — currently has no page-level back affordance (the existing `onBackToGallery` is an intra-page state callback, not a page-level navigation control). Add `<PageBack />` at the top.
### Component changes
- `web/src/components/PageBack.tsx` (new) — described above.
- `web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx` (modified) — drop the leading `ChevronLeft` button. Component becomes a pure path renderer. Update the component's tests if they cover the chevron rendering.
### Pages explicitly not changed
- Top-level user-facing pages (no back affordance is appropriate, they are reached as navigation roots): Home, Catalog, LibraryBrowse, LibraryRecommended, LibraryCollections, LibraryPage, Calendar, Collections, Recommendations (list view), Requests (list view), Profiles.
- Auth and onboarding flows (state-machine controlled, not history controlled): Login, Signup, OAuthComplete, ActivateDevice, SetupWizard, TasteSeed.
- Playback chrome (`web/src/playback/WatchPlaybackChrome.tsx`) — already has its own chevron in the player overlay; the watch UX is intentionally separate from the page-shell.
- Watch Together flows (`WatchTogetherJoin`, `WatchTogetherRoomPage`) — the in-flyout "Back to results" is a contextual subnav inside a flyout, not page-level navigation.
- Admin pages — the original feedback was user-facing only; admin can adopt `PageBack` in a follow-up pass if desired.
## Edge cases
- **No history entry on initial load.** Clicking `PageBack` calls `navigate(-1)`, which is a no-op. Acceptable; matches browser mouse-back behavior. Documented but not gated.
- **Backdrop image is mostly white in the top-left.** The `glass-subtle` background provides enough contrast against bright backdrops thanks to its translucent dark fill; this is the same treatment `DetailBreadcrumb` already uses on Season/Episode pages, so behavior is unchanged.
- **Mobile viewport.** Component uses `top-4 left-4` on small screens and `sm:top-6 sm:left-6` on `sm:` and up. The chevron is `size-5` (20px) inside `p-1.5` padding — a 32px tap target, which is below the 44px Apple HIG recommendation but matches the existing `DetailBreadcrumb` chevron. If mobile reach is a concern, bump padding to `p-2` (40px target). Default to `p-1.5` for visual parity with current code.
- **RTL layout.** The component pins to `left`. If the app later supports RTL, this becomes `start`-relative; out of scope for this change.
- **Keyboard focus.** Standard `<button>` element, focusable by default, gets the existing global focus ring from Tailwind base styles. No custom focus handling.
## Out of scope
- Replacing back affordances on admin pages.
- Persisting or reconstructing app-internal history when users land via deep link (would require a custom history wrapper).
- Adding a keyboard shortcut (e.g., `Esc` or `Backspace`) for back navigation. Worth considering in a follow-up but not part of this change.
- Replacing the in-player back chevron in `WatchPlaybackChrome`.
- Touching Watch Together's contextual subnav.
## Verification
Commands assume the repository root is the cwd.
- `cd web && pnpm run lint`
- `cd web && pnpm run format:check`
- Frontend component test for `PageBack`: renders a button with `aria-label`, calls `navigate(-1)` on click, applies the documented Tailwind class string (snapshot or class assertion).
- Update existing `DetailBreadcrumb` test (if present) to confirm the leading chevron is no longer rendered and the path segments still render and link correctly.
- Smoke test in the dev frontend: navigate from Home → a movie detail page → confirm the chevron is in the top-left and clicking it returns to Home. Repeat for Series, Season, Episode, Person, Request detail, Request browse, Collection editor, Smart collection wizard, Settings, and Recommendations Section. Confirm the chevron stays in the same screen position across all of them.
- Visual check on a Season detail page: confirm the textual breadcrumb path ("Series Title Season N") still renders inside the hero info column and segment links still navigate to the series page.
+2 -1
View File
@@ -20,11 +20,13 @@ require (
)
require (
github.com/abadojack/whatlanggo v1.0.1
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
github.com/h2non/bimg v1.1.9
github.com/hashicorp/go-hclog v1.6.3
github.com/joho/godotenv v1.5.1
github.com/mmcdole/gofeed v1.3.0
github.com/oklog/ulid/v2 v2.1.0
github.com/pgvector/pgvector-go v0.3.0
github.com/zishang520/socket.io/v2 v2.5.0
@@ -45,7 +47,6 @@ require (
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mmcdole/gofeed v1.3.0 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
+2
View File
@@ -4,6 +4,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/Silo-Server/silo-plugin-sdk v0.4.0 h1:DJkRROQfr/kfwnF5dUdkhmbCso1KDLZc7uK7YfJnaO0=
github.com/Silo-Server/silo-plugin-sdk v0.4.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/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.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
+69 -26
View File
@@ -112,19 +112,20 @@ 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"`
Role string `json:"role"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Permissions []string `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 updateLibraryIDsField struct {
@@ -149,20 +150,43 @@ func (f updateLibraryIDsField) Ptr() *[]int {
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"`
Role *string `json:"role,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
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"`
}
// adminUserResponse represents a user in admin JSON responses.
@@ -171,6 +195,7 @@ type adminUserResponse struct {
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"`
@@ -234,6 +259,7 @@ func toAdminUserResponse(u *models.User) adminUserResponse {
Username: u.Username,
Email: u.Email,
Role: u.Role,
Permissions: append([]string{}, u.Permissions...),
Enabled: u.Enabled,
LibraryIDs: append([]int(nil), u.LibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
@@ -361,6 +387,11 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
permissions, err := auth.NormalizePermissions(req.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{
@@ -368,6 +399,7 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
@@ -418,12 +450,22 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
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
}
err = h.userRepo.Update(r.Context(), id, models.UpdateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
Enabled: req.Enabled,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxPlaybackQuality: maxPlaybackQuality,
@@ -710,6 +752,7 @@ func updateRequiresSessionRevocation(req updateUserRequest) bool {
req.Role != nil ||
req.Enabled != nil ||
req.LibraryIDs.Set ||
req.Permissions.Set ||
req.MaxPlaybackQuality != nil
}
@@ -901,7 +944,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
}
writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil))
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
}
// UpdateItemMetadataRequest contains the fields that can be updated via
@@ -0,0 +1,326 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
// AdminDownloadedSubtitle is the admin-facing view of a stored subtitle record.
type AdminDownloadedSubtitle struct {
ID int `json:"id"`
MediaFileID int `json:"media_file_id"`
MediaContentID string `json:"media_content_id,omitempty"`
Provider string `json:"provider"`
Language string `json:"language"`
Format string `json:"format"`
ReleaseName string `json:"release_name"`
Score float64 `json:"score"`
HearingImpaired bool `json:"hearing_impaired"`
CreatedAt time.Time `json:"created_at"`
DownloadedBy *int `json:"downloaded_by,omitempty"`
UploaderUsername string `json:"uploader_username"`
MediaTitle string `json:"media_title"`
MediaType string `json:"media_type"`
FilePath string `json:"file_path"`
}
type adminDownloadedSubtitlesResponse struct {
Subtitles []AdminDownloadedSubtitle `json:"subtitles"`
Total int `json:"total"`
Uploads int `json:"uploads"`
Provider int `json:"provider_downloads"`
}
type patchDownloadedSubtitleRequest struct {
Language *string `json:"language"`
ReleaseName *string `json:"release_name"`
HearingImpaired *bool `json:"hearing_impaired"`
}
// SetDownloadedSubtitleDeps wires optional dependencies for downloaded subtitle admin routes.
func (h *AdminSubtitleHandler) SetDownloadedSubtitleDeps(pool *pgxpool.Pool, manager *subtitles.Manager) {
h.pool = pool
h.manager = manager
}
// HandleListDownloadedSubtitles handles GET /api/v1/admin/subtitles.
func (h *AdminSubtitleHandler) HandleListDownloadedSubtitles(w http.ResponseWriter, r *http.Request) {
if h.pool == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured")
return
}
limit, offset := parsePagination(r)
q := r.URL.Query()
var (
args []any
conditions []string
argIndex = 1
)
if provider := strings.TrimSpace(q.Get("provider")); provider != "" {
conditions = append(conditions, "ds.provider = $"+strconv.Itoa(argIndex))
args = append(args, provider)
argIndex++
}
if language := strings.TrimSpace(q.Get("language")); language != "" {
conditions = append(conditions, "ds.language = $"+strconv.Itoa(argIndex))
args = append(args, language)
argIndex++
}
if userIDStr := strings.TrimSpace(q.Get("user_id")); userIDStr != "" {
userID, err := strconv.Atoi(userIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user_id")
return
}
conditions = append(conditions, "ds.downloaded_by = $"+strconv.Itoa(argIndex))
args = append(args, userID)
argIndex++
}
if mediaFileIDStr := strings.TrimSpace(q.Get("media_file_id")); mediaFileIDStr != "" {
mediaFileID, err := strconv.Atoi(mediaFileIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid media_file_id")
return
}
conditions = append(conditions, "ds.media_file_id = $"+strconv.Itoa(argIndex))
args = append(args, mediaFileID)
argIndex++
}
if search := strings.TrimSpace(q.Get("q")); search != "" {
conditions = append(conditions, "ds.release_name ILIKE $"+strconv.Itoa(argIndex))
args = append(args, "%"+search+"%")
argIndex++
}
whereClause := ""
if len(conditions) > 0 {
whereClause = " WHERE " + strings.Join(conditions, " AND ")
}
countQuery := `SELECT COUNT(*) FROM downloaded_subtitles ds` + whereClause
var total int
if err := h.pool.QueryRow(r.Context(), countQuery, args...).Scan(&total); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count subtitles")
return
}
statsQuery := `
SELECT
COUNT(*) FILTER (WHERE ds.provider = 'upload'),
COUNT(*) FILTER (WHERE ds.provider <> 'upload')
FROM downloaded_subtitles ds` + whereClause
var uploads, providerDownloads int
if err := h.pool.QueryRow(r.Context(), statsQuery, args...).Scan(&uploads, &providerDownloads); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count subtitle stats")
return
}
listQuery := `
SELECT
ds.id,
ds.media_file_id,
COALESCE(mf.content_id, ''),
ds.provider,
ds.language,
ds.format,
ds.release_name,
ds.score,
ds.hearing_impaired,
ds.created_at,
ds.downloaded_by,
COALESCE(u.username, ''),
COALESCE(ep.title, mi.title, ''),
COALESCE(CASE WHEN ep.content_id IS NOT NULL THEN 'episode' ELSE mi.type END, ''),
COALESCE(mf.file_path, '')
FROM downloaded_subtitles ds
LEFT JOIN users u ON u.id = ds.downloaded_by
LEFT JOIN media_files mf ON mf.id = ds.media_file_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
LEFT JOIN episodes ep ON ep.content_id = mf.content_id` + whereClause + `
ORDER BY ds.created_at DESC
LIMIT $` + strconv.Itoa(argIndex) + ` OFFSET $` + strconv.Itoa(argIndex+1)
listArgs := append(append([]any{}, args...), limit, offset)
rows, err := h.pool.Query(r.Context(), listQuery, listArgs...)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list subtitles")
return
}
defer rows.Close()
subtitlesList := make([]AdminDownloadedSubtitle, 0)
for rows.Next() {
var row AdminDownloadedSubtitle
if err := rows.Scan(
&row.ID,
&row.MediaFileID,
&row.MediaContentID,
&row.Provider,
&row.Language,
&row.Format,
&row.ReleaseName,
&row.Score,
&row.HearingImpaired,
&row.CreatedAt,
&row.DownloadedBy,
&row.UploaderUsername,
&row.MediaTitle,
&row.MediaType,
&row.FilePath,
); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan subtitle row")
return
}
subtitlesList = append(subtitlesList, row)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate subtitles")
return
}
writeJSON(w, http.StatusOK, adminDownloadedSubtitlesResponse{
Subtitles: subtitlesList,
Total: total,
Uploads: uploads,
Provider: providerDownloads,
})
}
// HandlePatchDownloadedSubtitle handles PATCH /api/v1/admin/subtitles/{id}.
func (h *AdminSubtitleHandler) HandlePatchDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
if h.manager == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
return
}
var req patchDownloadedSubtitleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
return
}
if req.Language == nil && req.ReleaseName == nil && req.HearingImpaired == nil {
writeError(w, http.StatusBadRequest, "invalid_request", "No fields to update")
return
}
updated, err := h.manager.UpdateDownloadedSubtitle(r.Context(), id, subtitles.SubtitleMetadataPatch{
Language: req.Language,
ReleaseName: req.ReleaseName,
HearingImpaired: req.HearingImpaired,
})
if err != nil {
switch {
case errors.Is(err, subtitles.ErrSubtitleNotFound):
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
case errors.Is(err, subtitles.ErrSubtitleLanguageConflict):
writeError(w, http.StatusConflict, "conflict", "Subtitle with this language already exists for this file")
default:
if strings.Contains(err.Error(), "invalid subtitle language") {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "update_error", "Failed to update subtitle")
}
return
}
writeJSON(w, http.StatusOK, map[string]any{"subtitle": updated})
}
// HandleDownloadDownloadedSubtitle handles GET /api/v1/admin/subtitles/{id}/download.
func (h *AdminSubtitleHandler) HandleDownloadDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
if h.manager == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
return
}
sub, data, err := h.manager.GetSubtitleContent(r.Context(), id)
if err != nil {
if errors.Is(err, subtitles.ErrSubtitleNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
return
}
writeError(w, http.StatusInternalServerError, "download_error", "Failed to download subtitle")
return
}
filename := subtitleDownloadFilename(sub)
w.Header().Set("Content-Type", subtitles.SubtitleContentType(sub.Format))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
// HandleDeleteDownloadedSubtitle handles DELETE /api/v1/admin/subtitles/{id}.
func (h *AdminSubtitleHandler) HandleDeleteDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
if h.manager == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
return
}
sub, err := h.repo.GetDownloadedSubtitle(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "lookup_error", "Failed to look up subtitle")
return
}
if sub == nil {
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
return
}
if err := h.manager.DeleteSubtitle(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "delete_error", "Failed to delete subtitle")
return
}
w.WriteHeader(http.StatusNoContent)
}
func subtitleDownloadFilename(sub *subtitles.DownloadedSubtitle) string {
base := strings.TrimSpace(sub.ReleaseName)
if base == "" {
base = fmt.Sprintf("subtitle-%d", sub.ID)
}
base = path.Base(base)
base = strings.TrimSuffix(base, path.Ext(base))
if base == "" || base == "." {
base = fmt.Sprintf("subtitle-%d", sub.ID)
}
return fmt.Sprintf("%s.%s", base, sub.Format)
}
@@ -0,0 +1,232 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"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/subtitles"
)
func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
var reader *bytes.Reader
if body != nil {
reader = bytes.NewReader(body)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "admin",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
}
func withSubtitleRouteParam(req *http.Request, key, value string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
func TestHandlePatchDownloadedSubtitleUpdatesMetadata(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
repo.subtitles[7] = &subtitles.DownloadedSubtitle{
ID: 7,
MediaFileID: 42,
Provider: subtitles.ProviderUpload,
Language: "en",
Format: subtitles.FormatSRT,
ReleaseName: "movie.en.srt",
S3Key: "subtitles/42/en_upload_abcd1234.srt",
}
repo.byKey["subtitles/42/en_upload_abcd1234.srt"] = repo.subtitles[7]
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
"subtitles/42/en_upload_abcd1234.srt": []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"),
}}
manager := subtitles.NewManager(repo, s3, "test-bucket")
handler := NewAdminSubtitleHandler(repo)
handler.SetDownloadedSubtitleDeps(nil, manager)
body, err := json.Marshal(map[string]any{
"language": "es",
"release_name": "movie.es.srt",
"hearing_impaired": true,
})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := newAdminSubtitleRequest(http.MethodPatch, "/admin/subtitles/7", body)
req = withSubtitleRouteParam(req, "id", "7")
rr := httptest.NewRecorder()
handler.HandlePatchDownloadedSubtitle(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
updated := repo.subtitles[7]
if updated.Language != "es" {
t.Fatalf("language = %q, want es", updated.Language)
}
if updated.ReleaseName != "movie.es.srt" {
t.Fatalf("release_name = %q, want movie.es.srt", updated.ReleaseName)
}
if !updated.HearingImpaired {
t.Fatal("expected hearing_impaired=true")
}
if updated.S3Key == "subtitles/42/en_upload_abcd1234.srt" {
t.Fatalf("expected migrated s3 key, got %q", updated.S3Key)
}
if len(s3.putKeys) != 1 {
t.Fatalf("putKeys = %d, want 1", len(s3.putKeys))
}
if len(s3.deletedKeys) != 1 {
t.Fatalf("deletedKeys = %d, want 1", len(s3.deletedKeys))
}
}
func TestHandlePatchDownloadedSubtitleNotFound(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewAdminSubtitleHandler(repo)
handler.SetDownloadedSubtitleDeps(nil, manager)
body := []byte(`{"language":"fr"}`)
req := newAdminSubtitleRequest(http.MethodPatch, "/admin/subtitles/404", body)
req = withSubtitleRouteParam(req, "id", "404")
rr := httptest.NewRecorder()
handler.HandlePatchDownloadedSubtitle(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
func TestHandleDownloadDownloadedSubtitle(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
content := []byte("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n")
repo.subtitles[3] = &subtitles.DownloadedSubtitle{
ID: 3,
MediaFileID: 10,
Provider: subtitles.ProviderUpload,
Language: "en",
Format: subtitles.FormatVTT,
ReleaseName: "sample.vtt",
S3Key: "subtitles/10/en_upload_deadbeef.vtt",
CreatedAt: time.Now(),
}
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
"subtitles/10/en_upload_deadbeef.vtt": content,
}}
manager := subtitles.NewManager(repo, s3, "test-bucket")
handler := NewAdminSubtitleHandler(repo)
handler.SetDownloadedSubtitleDeps(nil, manager)
req := newAdminSubtitleRequest(http.MethodGet, "/admin/subtitles/3/download", nil)
req = withSubtitleRouteParam(req, "id", "3")
rr := httptest.NewRecorder()
handler.HandleDownloadDownloadedSubtitle(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Header().Get("Content-Type"); got != "text/vtt; charset=utf-8" {
t.Fatalf("content-type = %q", got)
}
if !bytes.Contains([]byte(rr.Header().Get("Content-Disposition")), []byte("sample.vtt")) {
t.Fatalf("content-disposition = %q", rr.Header().Get("Content-Disposition"))
}
if !bytes.Equal(rr.Body.Bytes(), content) {
t.Fatalf("body mismatch")
}
}
func TestHandleDeleteDownloadedSubtitle(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
repo.subtitles[5] = &subtitles.DownloadedSubtitle{
ID: 5,
MediaFileID: 11,
Provider: "opensubtitles",
Language: "en",
Format: subtitles.FormatSRT,
S3Key: "subtitles/11/en_opensubtitles_abcd1234.srt",
}
repo.byKey[repo.subtitles[5].S3Key] = repo.subtitles[5]
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
repo.subtitles[5].S3Key: []byte("subtitle"),
}}
manager := subtitles.NewManager(repo, s3, "test-bucket")
handler := NewAdminSubtitleHandler(repo)
handler.SetDownloadedSubtitleDeps(nil, manager)
req := newAdminSubtitleRequest(http.MethodDelete, "/admin/subtitles/5", nil)
req = withSubtitleRouteParam(req, "id", "5")
rr := httptest.NewRecorder()
handler.HandleDeleteDownloadedSubtitle(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rr.Code)
}
if _, ok := repo.subtitles[5]; ok {
t.Fatal("expected subtitle record deleted")
}
if len(s3.deletedKeys) != 1 {
t.Fatalf("deletedKeys = %d, want 1", len(s3.deletedKeys))
}
}
type trackingHandlerS3Client struct {
objects map[string][]byte
putKeys []string
deletedKeys []string
}
func (c *trackingHandlerS3Client) PutObject(_ context.Context, _, key string, data []byte) error {
if c.objects == nil {
c.objects = make(map[string][]byte)
}
c.objects[key] = append([]byte(nil), data...)
c.putKeys = append(c.putKeys, key)
return nil
}
func (c *trackingHandlerS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
if data, ok := c.objects[key]; ok {
return append([]byte(nil), data...), nil
}
return nil, context.Canceled
}
func (c *trackingHandlerS3Client) DeleteObject(_ context.Context, _, key string) error {
delete(c.objects, key)
c.deletedKeys = append(c.deletedKeys, key)
return nil
}
func TestSubtitleDownloadFilename(t *testing.T) {
sub := &subtitles.DownloadedSubtitle{
ID: 9,
ReleaseName: "../unsafe/path/movie.en.srt",
Format: subtitles.FormatSRT,
}
got := subtitleDownloadFilename(sub)
if got != "movie.en.srt" {
t.Fatalf("filename = %q, want movie.en.srt", got)
}
if strconv.Itoa(sub.ID) == "" {
t.Fatal("unexpected")
}
}
+108 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/Silo-Server/silo-server/internal/adminjob"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -110,7 +111,13 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, adminJobToResponse(r, job, h.store))
claims := apimw.GetClaims(r.Context())
if !canReadAdminJob(claims, job) {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
}
func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArtifactStore) adminJobResponse {
@@ -147,6 +154,96 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
return resp
}
func adminJobToResponseForClaims(
r *http.Request,
job *models.AdminJob,
store AdminJobArtifactStore,
claims *auth.Claims,
) adminJobResponse {
response := adminJobToResponse(r, job, store)
sanitizeAdminJobResponseForClaims(&response, claims)
return response
}
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
if response == nil || (claims != nil && claims.Role == "admin") {
return
}
response.RequestPayload = json.RawMessage(`{}`)
response.ResultPayload = sanitizeNonAdminAdminJobResultPayload(response.JobType, response.ResultPayload)
response.ErrorMessage = ""
response.PublicURL = ""
response.DownloadURL = ""
response.DownloadExpiresAt = nil
}
func sanitizeNonAdminAdminJobResultPayload(jobType string, payload json.RawMessage) json.RawMessage {
if jobType != adminjob.JobTypeItemRefresh {
return json.RawMessage(`{}`)
}
var raw map[string]json.RawMessage
if len(payload) == 0 || json.Unmarshal(payload, &raw) != nil {
return json.RawMessage(`{}`)
}
safe := make(map[string]json.RawMessage)
copyJSONFields(safe, raw,
"requested_content_id",
"refresh_content_id",
"detail_content_id",
"matched_files",
)
if scanPayload, ok := raw["scan_result"]; ok {
if scanSummary := sanitizeScanResultPayload(scanPayload); len(scanSummary) > 0 {
safe["scan_result"] = scanSummary
}
}
if len(safe) == 0 {
return json.RawMessage(`{}`)
}
data, err := json.Marshal(safe)
if err != nil {
return json.RawMessage(`{}`)
}
return data
}
func sanitizeScanResultPayload(payload json.RawMessage) json.RawMessage {
var raw map[string]json.RawMessage
if len(payload) == 0 || json.Unmarshal(payload, &raw) != nil {
return nil
}
safe := make(map[string]json.RawMessage)
copyJSONFields(safe, raw,
"New",
"Updated",
"Unchanged",
"Missing",
"FilesDeleted",
"MembershipsRemoved",
"ItemsDeleted",
"Errors",
"EmptyRootGuarded",
)
if len(safe) == 0 {
return nil
}
data, err := json.Marshal(safe)
if err != nil {
return nil
}
return data
}
func copyJSONFields(dst, src map[string]json.RawMessage, keys ...string) {
for _, key := range keys {
if value, ok := src[key]; ok && len(value) > 0 {
dst[key] = value
}
}
}
func writeAdminJobConflict(w http.ResponseWriter, message string, job *models.AdminJob, handler *AdminJobsHandler, r *http.Request) {
resp := adminJobConflictResponse{
Error: "conflict",
@@ -176,3 +273,13 @@ func currentAdminUserID(r *http.Request) int {
}
return claims.UserID
}
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
if claims == nil || job == nil {
return false
}
if claims.Role == "admin" {
return true
}
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
}
+81
View File
@@ -0,0 +1,81 @@
package handlers
import (
"bytes"
"encoding/json"
"testing"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
claims := &auth.Claims{UserID: 1, Role: "admin"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
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(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(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(claims, job) {
t.Fatal("non-admin should not read another user's item refresh job")
}
}
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
job := &models.AdminJob{
JobType: adminjob.JobTypeItemRefresh,
CreatedByUserID: 2,
RequestPayload: json.RawMessage(
`{"requested_content_id":"item-1","scan_path":"/srv/media/private/movie"}`,
),
ResultPayload: json.RawMessage(
`{"requested_content_id":"item-1","detail_content_id":"item-2","scan_path":"/srv/media/private/movie","scan_result":{"New":1,"RootObservations":[{"RootPath":"/srv/media/private","SampleFilePath":"/srv/media/private/movie.mkv"}]}}`,
),
ErrorMessage: "scan scope: stat /srv/media/private/movie: permission denied",
PublicURL: "https://example.test/public",
}
resp := adminJobToResponseForClaims(nil, job, nil, claims)
if string(resp.RequestPayload) != `{}` {
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
}
if resp.PublicURL != "" || resp.DownloadURL != "" || resp.DownloadExpiresAt != nil {
t.Fatalf("expected non-admin URLs to be stripped, got public=%q download=%q", resp.PublicURL, resp.DownloadURL)
}
if bytes.Contains(resp.ResultPayload, []byte("/srv/media")) ||
bytes.Contains(resp.ResultPayload, []byte("scan_path")) ||
bytes.Contains(resp.ResultPayload, []byte("RootObservations")) ||
bytes.Contains(resp.ResultPayload, []byte("SampleFilePath")) {
t.Fatalf("ResultPayload leaked sensitive data: %s", resp.ResultPayload)
}
if !bytes.Contains(resp.ResultPayload, []byte("requested_content_id")) ||
!bytes.Contains(resp.ResultPayload, []byte("detail_content_id")) {
t.Fatalf("ResultPayload = %s, want safe item refresh summary fields", resp.ResultPayload)
}
if resp.ErrorMessage != "" {
t.Fatalf("ErrorMessage = %q, want stripped for non-admin", resp.ErrorMessage)
}
}
+3
View File
@@ -13,6 +13,7 @@ import (
"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"
)
// SubtitleProviderFactory creates a Provider from a config. Allows testing without real providers.
@@ -21,6 +22,8 @@ type SubtitleProviderFactory func(cfg *subtitles.ProviderConfig) (subtitles.Prov
// AdminSubtitleHandler handles admin operations for subtitle provider management.
type AdminSubtitleHandler struct {
repo subtitles.Repository
manager *subtitles.Manager
pool *pgxpool.Pool
providerFactory SubtitleProviderFactory
}
+73
View File
@@ -0,0 +1,73 @@
package handlers
import "testing"
func TestUpdateRequiresSessionRevocation(t *testing.T) {
role := "admin"
enabled := true
libraryIDs := []int{1, 2}
maxPlaybackQuality := "1080p"
password := "new-password"
username := "renamed"
maxStreams := 4
tests := []struct {
name string
req updateUserRequest
want bool
}{
{
name: "permissions set",
req: updateUserRequest{Permissions: updateStringSliceField{Set: true, Value: []string{"metadata_curation"}}},
want: true,
},
{
name: "permissions unset",
req: updateUserRequest{Permissions: updateStringSliceField{Set: false, Value: []string{"metadata_curation"}}},
want: false,
},
{
name: "role",
req: updateUserRequest{Role: &role},
want: true,
},
{
name: "enabled",
req: updateUserRequest{Enabled: &enabled},
want: true,
},
{
name: "library ids",
req: updateUserRequest{LibraryIDs: updateLibraryIDsField{Set: true, Value: libraryIDs}},
want: true,
},
{
name: "max playback quality",
req: updateUserRequest{MaxPlaybackQuality: &maxPlaybackQuality},
want: true,
},
{
name: "password",
req: updateUserRequest{Password: &password},
want: true,
},
{
name: "non access fields",
req: updateUserRequest{Username: &username, MaxStreams: &maxStreams},
want: false,
},
{
name: "empty update",
req: updateUserRequest{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := updateRequiresSessionRevocation(tt.req); got != tt.want {
t.Fatalf("updateRequiresSessionRevocation() = %v, want %v", got, tt.want)
}
})
}
}
+2
View File
@@ -106,6 +106,7 @@ type userResponse struct {
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
DownloadAllowed bool `json:"download_allowed"`
Impersonation *impersonationResponse `json:"impersonation,omitempty"`
}
@@ -510,6 +511,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
Username: user.Username,
Email: user.Email,
Role: user.Role,
Permissions: auth.EffectivePermissions(user),
DownloadAllowed: user.DownloadAllowed,
}
if impersonatorUserID != nil {
+2 -2
View File
@@ -78,7 +78,7 @@ func (h *CatalogResourceHandler) HandleGetItemVersions(w http.ResponseWriter, r
return
}
if !requestIsAdmin(r) {
if !h.items.requestCanViewFilePaths(r) {
for i := range detail.Versions {
detail.Versions[i].FilePath = ""
}
@@ -502,7 +502,7 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
}
if !requestIsAdmin(r) {
if !h.items.requestCanViewFilePaths(r) {
for i := range detail.Versions {
detail.Versions[i].FilePath = ""
}
+16 -2
View File
@@ -1258,7 +1258,21 @@ func isNotFound(err error) bool {
errors.Is(err, catalog.ErrSeasonNotFound)
}
func requestIsAdmin(r *http.Request) bool {
func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
claims := apimw.GetClaims(r.Context())
return claims != nil && claims.Role == "admin"
if claims == nil {
return false
}
if claims.Role == "admin" {
return true
}
if h == nil || h.UserRepo == nil {
return false
}
user, err := h.UserRepo.GetByID(r.Context(), claims.UserID)
if err != nil {
slog.WarnContext(r.Context(), "checking file path visibility permissions", "user_id", claims.UserID, "error", err)
return false
}
return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration)
}
+31 -269
View File
@@ -32,6 +32,7 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scantrigger"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -570,10 +571,10 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
}
} else {
initialScanID := ulid.Make().String()
h.recordAcceptedScan(initialScanID, &resolvedScanTarget{
folder: folder,
mode: scanModeLibrary,
trigger: "library_created",
h.recordAcceptedScan(initialScanID, &scantrigger.Target{
Folder: folder,
Mode: scantrigger.ModeLibrary,
Trigger: "library_created",
})
h.runFolderScanAsync(initialScanID, folder, "library_created")
}
@@ -660,10 +661,10 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
}
} else {
updateScanID := ulid.Make().String()
h.recordAcceptedScan(updateScanID, &resolvedScanTarget{
folder: folder,
mode: scanModeLibrary,
trigger: "library_paths_changed",
h.recordAcceptedScan(updateScanID, &scantrigger.Target{
Folder: folder,
Mode: scantrigger.ModeLibrary,
Trigger: "library_paths_changed",
})
h.runFolderScanAsync(updateScanID, folder, "library_paths_changed")
}
@@ -791,29 +792,6 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.
writeJSON(w, http.StatusOK, resp)
}
type scanMode string
const (
scanModeLibrary scanMode = "library"
scanModeSubtree scanMode = "subtree"
scanModeFile scanMode = "file"
)
type resolvedScanTarget struct {
folder *models.MediaFolder
mode scanMode
path string
trigger string
}
type scanRequestError struct {
status int
code string
message string
}
func (e *scanRequestError) Error() string { return e.message }
// HandleScan handles POST /scan. It accepts either a library_id, a path, or both
// and dispatches to full-library, subtree, or single-file scanning.
func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
@@ -823,11 +801,14 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
return
}
target, err := h.resolveScanTarget(r.Context(), req)
target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{
LibraryID: req.LibraryID,
Path: req.Path,
})
if err != nil {
var reqErr *scanRequestError
var reqErr *scantrigger.RequestError
if errors.As(err, &reqErr) {
writeError(w, reqErr.status, reqErr.code, reqErr.message)
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
return
}
slog.Error("resolving scan target", "error", err)
@@ -836,21 +817,21 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
}
if h.ScanQueue != nil {
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.folder.ID, string(target.mode), target.path, target.trigger); err != nil {
slog.Error("queueing library scan", "library_id", target.folder.ID, "error", err)
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil {
slog.Error("queueing library scan", "library_id", target.Folder.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan")
return
}
} else if h.ingester != nil {
scanID := ulid.Make().String()
h.recordAcceptedScan(scanID, target)
switch target.mode {
case scanModeFile:
h.runFileScanAsync(scanID, target.folder, target.path, target.trigger)
case scanModeSubtree:
h.runSubtreeScanAsync(scanID, target.folder, target.path, target.trigger)
switch target.Mode {
case scantrigger.ModeFile:
h.runFileScanAsync(scanID, target.Folder, target.Path, target.Trigger)
case scantrigger.ModeSubtree:
h.runSubtreeScanAsync(scanID, target.Folder, target.Path, target.Trigger)
default:
h.runFolderScanAsync(scanID, target.folder, target.trigger)
h.runFolderScanAsync(scanID, target.Folder, target.Trigger)
}
} else {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available")
@@ -859,8 +840,8 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, scanResponse{
Status: "accepted",
Mode: string(target.mode),
LibraryID: target.folder.ID,
Mode: target.Mode,
LibraryID: target.Folder.ID,
})
}
@@ -908,225 +889,6 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request
})
}
func (h *LibraryHandler) resolveScanTarget(ctx context.Context, req scanRequest) (*resolvedScanTarget, error) {
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
return nil, &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Either library_id or path is required",
}
}
var (
folder *models.MediaFolder
err error
)
if req.LibraryID != nil {
folder, err = h.folderRepo.GetByID(ctx, *req.LibraryID)
if err != nil {
if errors.Is(err, catalog.ErrFolderNotFound) {
return nil, &scanRequestError{
status: http.StatusNotFound,
code: "not_found",
message: "Library not found",
}
}
return nil, fmt.Errorf("fetching library for scan: %w", err)
}
}
if strings.TrimSpace(req.Path) == "" {
if folder != nil && !folder.Enabled {
return nil, &scanRequestError{
status: http.StatusConflict,
code: "conflict",
message: "Library is disabled",
}
}
return &resolvedScanTarget{
folder: folder,
mode: scanModeLibrary,
trigger: "manual",
}, nil
}
cleanPath := filepath.Clean(req.Path)
var matchedRoot string
if folder != nil {
matchedRoot, err = longestMatchingRoot(cleanPath, folder.Paths)
if err != nil {
return nil, err
}
if matchedRoot == "" {
return nil, &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path does not belong to the specified library",
}
}
} else {
folders, err := h.folderRepo.List(ctx)
if err != nil {
return nil, fmt.Errorf("listing libraries for scan: %w", err)
}
folder, matchedRoot, err = matchFolderForPath(cleanPath, folders)
if err != nil {
return nil, err
}
}
if folder != nil && !folder.Enabled {
return nil, &scanRequestError{
status: http.StatusConflict,
code: "conflict",
message: "Library is disabled",
}
}
mode, err := classifyScanPath(cleanPath, matchedRoot)
if err != nil {
return nil, err
}
trigger := "path"
if req.LibraryID != nil {
trigger = "library_id_path"
}
return &resolvedScanTarget{
folder: folder,
mode: mode,
path: cleanPath,
trigger: trigger,
}, nil
}
func longestMatchingRoot(targetPath string, roots []string) (string, error) {
bestRoot := ""
bestLen := -1
for _, root := range roots {
if !pathWithinRoot(targetPath, root) {
continue
}
cleanRoot := filepath.Clean(root)
rootLen := len(cleanRoot)
if rootLen > bestLen {
bestRoot = cleanRoot
bestLen = rootLen
}
}
return bestRoot, nil
}
func matchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) {
var (
bestFolder *models.MediaFolder
bestRoot string
bestLen = -1
ambiguous bool
)
for _, folder := range folders {
if folder == nil {
continue
}
root, err := longestMatchingRoot(targetPath, folder.Paths)
if err != nil {
return nil, "", err
}
if root == "" {
continue
}
rootLen := len(root)
if rootLen > bestLen {
bestFolder = folder
bestRoot = root
bestLen = rootLen
ambiguous = false
continue
}
if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID {
ambiguous = true
}
}
if ambiguous {
return nil, "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path matches multiple libraries",
}
}
if bestFolder == nil {
return nil, "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "No library matches the given path",
}
}
return bestFolder, bestRoot, nil
}
func classifyScanPath(targetPath, matchedRoot string) (scanMode, error) {
if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) {
return scanModeLibrary, nil
}
info, err := os.Stat(targetPath)
if err != nil {
switch {
case errors.Is(err, os.ErrNotExist):
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path does not exist",
}
case errors.Is(err, os.ErrPermission):
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Permission denied for path",
}
default:
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path could not be inspected",
}
}
}
if info.IsDir() {
return scanModeSubtree, nil
}
if !info.Mode().IsRegular() {
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path must be a file or directory",
}
}
if !scanner.SupportsVideoFile(targetPath) {
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Unsupported media file extension",
}
}
return scanModeFile, nil
}
func pathWithinRoot(targetPath, rootPath string) bool {
cleanTarget := filepath.Clean(targetPath)
cleanRoot := filepath.Clean(rootPath)
rel, err := filepath.Rel(cleanRoot, cleanTarget)
if err != nil {
return false
}
if rel == "." || rel == "" {
return true
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func (h *LibraryHandler) runFolderScanAsync(scanID string, folder *models.MediaFolder, trigger string) {
go func() {
h.markScanRunning(scanID)
@@ -1287,16 +1049,16 @@ func (h *LibraryHandler) runFileScanAsync(scanID string, folder *models.MediaFol
}()
}
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *resolvedScanTarget) {
if h == nil || h.ScanRegistry == nil || target == nil || target.folder == nil {
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) {
if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil {
return
}
h.ScanRegistry.Upsert(evt.ScanRun{
ID: scanID,
LibraryID: target.folder.ID,
Mode: string(target.mode),
Path: target.path,
Trigger: target.trigger,
LibraryID: target.Folder.ID,
Mode: target.Mode,
Path: target.Path,
Trigger: target.Trigger,
Status: "accepted",
})
}
+70
View File
@@ -0,0 +1,70 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
// MediaFileAuthorizer validates that the authenticated user can access a media file.
type MediaFileAuthorizer struct {
FileResolver FilePathResolver
ItemAccess PlaybackItemAccessChecker
EpisodeLookup PlaybackEpisodeLookup
}
// Authorize returns the media file when the caller may access it, or catalog.ErrItemNotFound.
func (a *MediaFileAuthorizer) Authorize(r *http.Request, fileID int) (*models.MediaFile, error) {
if a == nil || a.FileResolver == nil || a.ItemAccess == nil {
return nil, fmt.Errorf("media file authorization dependencies not configured")
}
file, err := a.FileResolver.GetByID(r.Context(), fileID)
if err != nil {
return nil, mapMediaFileLookupError(err)
}
if file == nil || file.MissingSince != nil {
return nil, catalog.ErrItemNotFound
}
filter := requestAccessFilter(r)
switch {
case file.EpisodeID != "":
if a.EpisodeLookup == nil {
return nil, fmt.Errorf("episode lookup not configured")
}
episode, err := a.EpisodeLookup.GetByID(r.Context(), file.EpisodeID)
if err != nil {
return nil, err
}
if episode == nil {
return nil, catalog.ErrEpisodeNotFound
}
if err := a.ItemAccess.EnsureAccessible(r.Context(), episode.SeriesID, filter); err != nil {
return nil, err
}
case file.ContentID != "":
if err := a.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil {
return nil, err
}
default:
return nil, catalog.ErrItemNotFound
}
if !catalog.FileAllowedByAccess(file, filter) {
return nil, catalog.ErrItemNotFound
}
return file, nil
}
func mapMediaFileLookupError(err error) error {
if errors.Is(err, scanner.ErrFileNotFound) {
return catalog.ErrItemNotFound
}
return err
}
@@ -0,0 +1,102 @@
package handlers
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
func TestMediaFileAuthorizerMapsMissingFileToNotFound(t *testing.T) {
authorizer := &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
TokenType: auth.TokenTypeAccess,
}))
_, err := authorizer.Authorize(req, 99)
if !errors.Is(err, catalog.ErrItemNotFound) {
t.Fatalf("Authorize() error = %v, want ErrItemNotFound", err)
}
}
func TestHandleUploadMissingMediaFileReturns404(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(t, 99, "en", "custom.srt", []byte("1\n00:00:01,000 --> 00:00:02,000\nHi\n"))
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404, body = %s", rr.Code, rr.Body.String())
}
}
func TestHandleListMissingMediaFileReturns404(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleAuthRequest(http.MethodGet, "/subtitles/99", nil)
req = withProfileRouteParam(req, "media_file_id", "99")
rr := httptest.NewRecorder()
handler.HandleList(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404, body = %s", rr.Code, rr.Body.String())
}
}
func TestMediaFileAuthorizerAllowsAccessibleFile(t *testing.T) {
authorizer := &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
},
ItemAccess: stubItemAccessChecker{},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
TokenType: auth.TokenTypeAccess,
}))
file, err := authorizer.Authorize(req, 42)
if err != nil {
t.Fatalf("Authorize() error = %v", err)
}
if file == nil || file.ID != 42 {
t.Fatalf("file = %#v, want id 42", file)
}
}
func TestMapMediaFileLookupError(t *testing.T) {
if !errors.Is(mapMediaFileLookupError(scanner.ErrFileNotFound), catalog.ErrItemNotFound) {
t.Fatal("expected scanner.ErrFileNotFound to map to catalog.ErrItemNotFound")
}
if mapMediaFileLookupError(errors.New("db down")) == nil {
t.Fatal("expected unrelated error to pass through")
}
}
+1 -1
View File
@@ -1780,7 +1780,7 @@ func (h *PlaybackHandler) loadAuthorizedFile(r *http.Request, fileID int) (*mode
}
file, err := h.fileResolver.GetByID(r.Context(), fileID)
if err != nil {
return nil, err
return nil, mapMediaFileLookupError(err)
}
if file == nil || file.MissingSince != nil {
return nil, catalog.ErrItemNotFound
+179 -3
View File
@@ -3,16 +3,40 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"path/filepath"
"strconv"
"strings"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/go-chi/chi/v5"
)
const (
subtitleUploadMaxSize = subtitles.MaxUploadSize
// Allow multipart framing and small form fields above the file size cap.
subtitleUploadMaxBodySize = subtitleUploadMaxSize + (256 << 10)
)
func parseSubtitleMultipartForm(w http.ResponseWriter, r *http.Request) bool {
r.Body = http.MaxBytesReader(w, r.Body, subtitleUploadMaxBodySize)
if err := r.ParseMultipartForm(subtitleUploadMaxSize); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
} else {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form")
}
return false
}
return true
}
// SubtitleMediaResolver looks up media metadata for subtitle search.
type SubtitleMediaResolver interface {
GetMediaFileWithMetadata(ctx context.Context, fileID int) (*MediaFileMetadata, error)
@@ -36,9 +60,10 @@ type MediaFileMetadata struct {
// SubtitleSearchHandler handles user-facing subtitle search operations.
type SubtitleSearchHandler struct {
manager *subtitles.Manager
repo subtitles.Repository
mediaResolver SubtitleMediaResolver
manager *subtitles.Manager
repo subtitles.Repository
mediaResolver SubtitleMediaResolver
FileAuthorizer *MediaFileAuthorizer
}
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
@@ -70,6 +95,23 @@ type downloadSubtitleRequest struct {
HearingImpaired bool `json:"hearing_impaired"`
}
func (h *SubtitleSearchHandler) authorizeMediaFile(w http.ResponseWriter, r *http.Request, fileID int) bool {
if h.FileAuthorizer == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Media file authorization is not configured")
return false
}
if _, err := h.FileAuthorizer.Authorize(r, fileID); err != nil {
switch {
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
}
return false
}
return true
}
// HandleSearch handles POST /api/v1/subtitles/search
func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
var req searchSubtitlesRequest
@@ -78,6 +120,10 @@ func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Requ
return
}
if !h.authorizeMediaFile(w, r, req.MediaFileID) {
return
}
meta, err := h.mediaResolver.GetMediaFileWithMetadata(r.Context(), req.MediaFileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "metadata_error", "Failed to look up media metadata")
@@ -124,6 +170,10 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
return
}
if !h.authorizeMediaFile(w, r, req.MediaFileID) {
return
}
userID := apimw.GetUserID(r.Context())
sub, err := h.manager.Download(r.Context(), subtitles.DownloadRequest{
@@ -145,6 +195,115 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub})
}
// HandleUpload handles POST /api/v1/subtitles/upload
func (h *SubtitleSearchHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
if !parseSubtitleMultipartForm(w, r) {
return
}
mediaFileID, err := strconv.Atoi(strings.TrimSpace(r.FormValue("media_file_id")))
if err != nil || mediaFileID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid media_file_id")
return
}
if !h.authorizeMediaFile(w, r, mediaFileID) {
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Missing subtitle file")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, subtitleUploadMaxSize+1))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
return
}
if len(data) > subtitleUploadMaxSize {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
return
}
releaseName := strings.TrimSpace(r.FormValue("release_name"))
hearingImpaired := parseBoolFormValue(r.FormValue("hearing_impaired"))
userID := apimw.GetUserID(r.Context())
userLanguage := strings.TrimSpace(r.FormValue("language"))
preferUserLanguage := parseBoolFormValue(r.FormValue("language_override"))
sub, err := h.manager.Upload(r.Context(), subtitles.UploadRequest{
MediaFileID: mediaFileID,
UserID: &userID,
Language: userLanguage,
PreferUserLanguage: preferUserLanguage,
Filename: header.Filename,
ReleaseName: releaseName,
HearingImpaired: hearingImpaired,
Data: data,
})
if err != nil {
switch {
case strings.Contains(err.Error(), "unsupported subtitle format"),
strings.Contains(err.Error(), "missing file extension"),
strings.Contains(err.Error(), "empty subtitle file"),
strings.Contains(err.Error(), "could not detect subtitle language"),
strings.Contains(err.Error(), "invalid subtitle language"):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
case strings.Contains(err.Error(), "exceeds maximum size"):
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
default:
slog.Error("subtitle upload failed", "media_file_id", mediaFileID, "error", err)
writeError(w, http.StatusInternalServerError, "upload_error", "Failed to upload subtitle")
}
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub})
}
// HandleDetectLanguage handles POST /api/v1/subtitles/detect-language
func (h *SubtitleSearchHandler) HandleDetectLanguage(w http.ResponseWriter, r *http.Request) {
if !parseSubtitleMultipartForm(w, r) {
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Missing subtitle file")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, subtitleUploadMaxSize+1))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
return
}
if len(data) > subtitleUploadMaxSize {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
return
}
format, err := subtitles.FormatFromFilename(header.Filename)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
userLanguage := strings.TrimSpace(r.FormValue("language"))
detected, err := subtitles.ResolveUploadLanguage(header.Filename, format, data, userLanguage, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusOK, detected)
}
// HandleList handles GET /api/v1/subtitles/{media_file_id}
func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Request) {
mediaFileID, err := strconv.Atoi(chi.URLParam(r, "media_file_id"))
@@ -153,6 +312,10 @@ func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Reques
return
}
if !h.authorizeMediaFile(w, r, mediaFileID) {
return
}
subs, err := h.repo.ListDownloadedSubtitles(r.Context(), mediaFileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list subtitles")
@@ -180,6 +343,10 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
return
}
if !h.authorizeMediaFile(w, r, sub.MediaFileID) {
return
}
claims := apimw.GetClaims(r.Context())
isAdmin := claims != nil && claims.Role == "admin"
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
@@ -204,3 +371,12 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
func parseBoolFormValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
@@ -0,0 +1,330 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"testing"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
type stubSubtitleMediaResolver struct {
meta *MediaFileMetadata
}
func (s stubSubtitleMediaResolver) GetMediaFileWithMetadata(context.Context, int) (*MediaFileMetadata, error) {
return s.meta, nil
}
type stubMediaFileResolver struct {
file *models.MediaFile
err error
}
func (s stubMediaFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) {
return s.file, s.err
}
type stubItemAccessChecker struct {
err error
}
func (s stubItemAccessChecker) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
return s.err
}
type stubEpisodeLookup struct {
episode *models.Episode
}
func (s stubEpisodeLookup) GetByID(context.Context, string) (*models.Episode, error) {
return s.episode, nil
}
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)
}
func newSubtitleUploadRequest(t *testing.T, mediaFileID int, language, filename string, content []byte) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("media_file_id", strconv.Itoa(mediaFileID)); err != nil {
t.Fatalf("write media_file_id: %v", err)
}
if err := writer.WriteField("language", language); err != nil {
t.Fatalf("write language: %v", err)
}
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := newSubtitleAuthRequest(http.MethodPost, "/subtitles/upload", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestHandleUploadSuccess(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(t, 42, "en", "custom.srt", []byte("1\n00:00:01,000 --> 00:00:02,000\nHi\n"))
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp struct {
Subtitle subtitles.DownloadedSubtitle `json:"subtitle"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Subtitle.Provider != subtitles.ProviderUpload {
t.Fatalf("provider = %q, want upload", resp.Subtitle.Provider)
}
}
func TestHandleUploadUnauthorizedMediaFile(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(t, 99, "en", "custom.srt", []byte("hello"))
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
func TestHandleUploadRejectsBadExtension(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(t, 42, "en", "notes.txt", []byte("hello"))
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}
func TestHandleUploadRejectsOversizedBody(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(
t,
42,
"en",
"huge.srt",
make([]byte, subtitleUploadMaxBodySize+1),
)
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413", rr.Code)
}
}
func TestHandleDetectLanguageRejectsOversizedBody(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "huge.srt")
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(make([]byte, subtitleUploadMaxBodySize+1)); err != nil {
t.Fatalf("write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := newSubtitleAuthRequest(http.MethodPost, "/subtitles/detect-language", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rr := httptest.NewRecorder()
handler.HandleDetectLanguage(rr, req)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413", rr.Code)
}
}
func TestHandleDeleteRequiresAccessToMediaFile(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
repo.subtitles[1] = &subtitles.DownloadedSubtitle{
ID: 1,
MediaFileID: 42,
Provider: subtitles.ProviderUpload,
}
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleAuthRequest(http.MethodDelete, "/subtitles/1", nil)
req = withProfileRouteParam(req, "id", "1")
rr := httptest.NewRecorder()
handler.HandleDelete(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
type handlerMockSubtitleRepo struct {
subtitles map[int]*subtitles.DownloadedSubtitle
nextID int
byKey map[string]*subtitles.DownloadedSubtitle
}
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
return &handlerMockSubtitleRepo{
subtitles: make(map[int]*subtitles.DownloadedSubtitle),
byKey: make(map[string]*subtitles.DownloadedSubtitle),
}
}
func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, sub *subtitles.DownloadedSubtitle) error {
m.nextID++
sub.ID = m.nextID
m.subtitles[sub.ID] = sub
m.byKey[sub.S3Key] = sub
return nil
}
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
if sub, ok := m.subtitles[id]; ok {
copy := *sub
return &copy, nil
}
return nil, nil
}
func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
return nil, nil
}
func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
sub := m.subtitles[id]
delete(m.subtitles, id)
return sub, nil
}
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitleByS3Key(_ context.Context, s3Key string) (*subtitles.DownloadedSubtitle, error) {
if sub, ok := m.byKey[s3Key]; ok {
copy := *sub
return &copy, nil
}
return nil, nil
}
func (m *handlerMockSubtitleRepo) UpdateDownloadedSubtitle(_ context.Context, id int, update subtitles.SubtitleMetadataUpdate) (*subtitles.DownloadedSubtitle, error) {
sub, ok := m.subtitles[id]
if !ok {
return nil, nil
}
sub.Language = update.Language
sub.ReleaseName = update.ReleaseName
sub.HearingImpaired = update.HearingImpaired
if sub.S3Key != update.S3Key {
delete(m.byKey, sub.S3Key)
sub.S3Key = update.S3Key
m.byKey[sub.S3Key] = sub
}
copy := *sub
return &copy, nil
}
func (m *handlerMockSubtitleRepo) ListProviderConfigs(context.Context) ([]subtitles.ProviderConfig, error) {
return nil, nil
}
func (m *handlerMockSubtitleRepo) GetProviderConfig(context.Context, string) (*subtitles.ProviderConfig, error) {
return nil, nil
}
func (m *handlerMockSubtitleRepo) UpsertProviderConfig(context.Context, *subtitles.ProviderConfig) error {
return nil
}
type handlerMockS3Client struct{}
func newMockS3ClientForHandler() *handlerMockS3Client {
return &handlerMockS3Client{}
}
func (handlerMockS3Client) PutObject(context.Context, string, string, []byte) error {
return nil
}
func (handlerMockS3Client) GetObject(context.Context, string, string) ([]byte, error) {
return nil, nil
}
func (handlerMockS3Client) DeleteObject(context.Context, string, string) error {
return nil
}
+1
View File
@@ -39,6 +39,7 @@ var demoBlockedRoutes = []blockedRoute{
{methods: []string{"POST", "DELETE"}, prefix: "/api/v1/downloads"},
{methods: []string{"POST"}, prefix: "/api/v1/history-imports"},
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/download"},
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/upload"},
{methods: []string{"DELETE"}, prefix: "/api/v1/subtitles/"},
}
+155
View File
@@ -0,0 +1,155 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
type PermissionUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
type MetadataTargetLibraryResolver interface {
ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error)
}
type PermissionMiddleware struct {
users PermissionUserLoader
libraries MetadataTargetLibraryResolver
}
func NewPermissionMiddleware(users PermissionUserLoader, libraries MetadataTargetLibraryResolver) *PermissionMiddleware {
return &PermissionMiddleware{users: users, libraries: libraries}
}
// RequireMetadataCurationForItem allows admins or users with metadata_curation
// permission when every library containing the target item is within the user's
// assigned libraries. A nil user library list means unrestricted library access.
func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role == "admin" {
next.ServeHTTP(w, r)
return
}
if m == nil || m.users == nil || m.libraries == nil {
writeForbidden(w, "Metadata curation permission required")
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
}
targetLibraries, err := m.libraries.ResolveMetadataTargetLibraryIDs(r.Context(), contentID)
if err != nil {
writePermissionError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item libraries")
return
}
if len(targetLibraries) == 0 {
writePermissionError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !metadataTargetWithinUserLibraries(user.LibraryIDs, targetLibraries) {
writeForbidden(w, "Item is outside your assigned libraries")
return
}
next.ServeHTTP(w, r)
})
}
func metadataTargetWithinUserLibraries(allowed []int, target []int) bool {
if allowed == nil {
return true
}
if len(target) == 0 {
return false
}
allowedSet := make(map[int]struct{}, len(allowed))
for _, id := range allowed {
allowedSet[id] = struct{}{}
}
for _, id := range target {
if _, ok := allowedSet[id]; !ok {
return false
}
}
return true
}
type PGMetadataTargetLibraryResolver struct {
Pool *pgxpool.Pool
}
func NewPGMetadataTargetLibraryResolver(pool *pgxpool.Pool) *PGMetadataTargetLibraryResolver {
return &PGMetadataTargetLibraryResolver{Pool: pool}
}
func (r *PGMetadataTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) {
if r == nil || r.Pool == nil {
return nil, fmt.Errorf("database not configured")
}
rows, err := r.Pool.Query(ctx, `
WITH target_root AS (
SELECT mi.content_id
FROM media_items mi
WHERE mi.content_id = $1
UNION
SELECT s.series_id
FROM seasons s
WHERE s.content_id = $1
UNION
SELECT e.series_id
FROM episodes e
WHERE e.content_id = $1
)
SELECT DISTINCT mil.media_folder_id
FROM target_root tr
JOIN media_item_libraries mil ON mil.content_id = tr.content_id
ORDER BY mil.media_folder_id`, contentID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func writePermissionError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: code, Message: message})
}
+100
View File
@@ -0,0 +1,100 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakePermissionUserLoader struct {
user *models.User
err error
}
func (f fakePermissionUserLoader) GetByID(context.Context, int) (*models.User, error) {
return f.user, f.err
}
type fakeTargetLibraryResolver struct {
ids []int
err error
}
func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Context, string) ([]int, error) {
return f.ids, f.err
}
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, 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, role string) int {
mw := NewPermissionMiddleware(
fakePermissionUserLoader{user: user},
fakeTargetLibraryResolver{ids: libraryIDs},
)
next := mw.RequireMetadataCurationForItem(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
next.ServeHTTP(rec, requestWithItemID(role))
return rec.Code
}
func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) {
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, 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, 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, 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, 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, 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)
}
}
+342 -298
View File
@@ -222,6 +222,7 @@ func NewRouter(deps Dependencies) chi.Router {
var authHandler *handlers.AuthHandler
var authMiddleware *apimw.AuthMiddleware
var viewerAccessMiddleware *apimw.ViewerAccessMiddleware
var permissionMiddleware *apimw.PermissionMiddleware
var viewerResolver *access.Resolver
var profileTokenService *access.ProfileTokenService
var jwtService *auth.JWTService
@@ -258,6 +259,12 @@ func NewRouter(deps Dependencies) chi.Router {
viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService)
viewerAccessMiddleware = apimw.NewViewerAccessMiddleware(viewerResolver)
}
if deps.DB != nil {
permissionMiddleware = apimw.NewPermissionMiddleware(
userRepo,
apimw.NewPGMetadataTargetLibraryResolver(deps.DB),
)
}
}
if deps.SessionMgr != nil && userRepo != nil {
deps.SessionMgr.SetLimitProvider(func(ctx context.Context, userID int) (playback.SessionLimits, error) {
@@ -727,6 +734,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Admin subtitle config handler only needs the DB repo — no S3 required.
var adminSubtitleHandler *handlers.AdminSubtitleHandler
var subtitleManager *subtitles.Manager
if subtitleRepo != nil {
adminSubtitleHandler = handlers.NewAdminSubtitleHandler(subtitleRepo)
}
@@ -734,7 +742,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Build subtitle search handler if we have DB and S3.
var subtitleSearchHandler *handlers.SubtitleSearchHandler
if deps.DB != nil && deps.S3Public != nil && subtitleRepo != nil {
subtitleManager := subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
subtitleManager = subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
// Load provider configs from DB and register enabled providers.
providerConfigs, _ := subtitleRepo.ListProviderConfigs(deps.AppContext)
@@ -768,6 +776,10 @@ func NewRouter(deps Dependencies) chi.Router {
subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver)
}
if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil {
adminSubtitleHandler.SetDownloadedSubtitleDeps(deps.DB, subtitleManager)
}
// Build section handler if DB is available.
var sectionHandler *handlers.SectionHandler
var sectionSettingsHandler *handlers.SectionSettingsHandler
@@ -1530,9 +1542,18 @@ func NewRouter(deps Dependencies) chi.Router {
// Subtitle search routes.
if subtitleSearchHandler != nil {
if deps.FileRepo != nil && itemRepo != nil {
subtitleSearchHandler.FileAuthorizer = &handlers.MediaFileAuthorizer{
FileResolver: deps.FileRepo,
ItemAccess: itemRepo,
EpisodeLookup: episodeRepo,
}
}
r.Route("/subtitles", func(r chi.Router) {
r.Post("/search", subtitleSearchHandler.HandleSearch)
r.Post("/download", subtitleSearchHandler.HandleDownload)
r.Post("/upload", subtitleSearchHandler.HandleUpload)
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
r.Get("/{media_file_id}", subtitleSearchHandler.HandleList)
r.Delete("/{id}", subtitleSearchHandler.HandleDelete)
})
@@ -1670,324 +1691,347 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
// Admin routes (admin-only).
// Admin routes.
if adminHandler != nil {
r.Route("/admin", func(r chi.Router) {
r.Use(apimw.RequireAdmin)
r.Get("/users", adminHandler.HandleListUsers)
r.Post("/users", adminHandler.HandleCreateUser)
r.Get("/users/{id}", adminHandler.HandleGetUser)
r.Put("/users/{id}", adminHandler.HandleUpdateUser)
r.Delete("/users/{id}", adminHandler.HandleDeleteUser)
r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser)
r.Get("/users/{id}/profiles", adminHandler.HandleListUserProfiles)
r.Get("/users/{id}/settings", adminHandler.HandleListUserSettings)
r.Get("/users/{id}/settings/{key}", adminHandler.HandleGetUserSetting)
r.Put("/users/{id}/settings/{key}", adminHandler.HandleUpdateUserSetting)
r.Delete("/users/{id}/settings/{key}", adminHandler.HandleDeleteUserSetting)
r.Get("/users/{id}/device-settings", adminHandler.HandleListUserDeviceSettings)
r.Get("/users/{id}/device-settings/{key}", adminHandler.HandleListUserDeviceSettingsByKey)
r.Put("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleUpdateUserDeviceSetting)
r.Delete("/users/{id}/device-settings/{key}", adminHandler.HandleDeleteUserDeviceSettingsByKey)
r.Delete("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleDeleteUserDeviceSetting)
r.Delete("/users/{id}/profiles/{profile_id}/devices/{device_id}/settings", adminHandler.HandleDeleteAllUserDeviceSettings)
r.Get("/devices", adminHandler.HandleListDevices)
r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice)
r.Get("/sessions", adminHandler.HandleListSessions)
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
r.Get("/stats", adminHandler.HandleGetStats)
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
if sectionSettingsHandler != nil {
r.Get("/settings/sections", sectionSettingsHandler.HandleGet)
r.Put("/settings/sections", sectionSettingsHandler.HandlePut)
}
r.Get("/settings/{key}", adminHandler.HandleGetSetting)
r.Get("/settings", adminHandler.HandleGetSettings)
r.Put("/settings/{key}", adminHandler.HandleUpdateSetting)
r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata)
r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata)
if adminIntroHandler != nil {
r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers)
r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro)
}
if peopleHandler != nil {
r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson)
r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson)
metadataItemAccess := apimw.RequireAdmin
if permissionMiddleware != nil {
metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem
}
if adminMatchHandler != nil {
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
}
if adminImageHandler != nil {
r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages)
r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage)
}
filesystemHandler := handlers.NewFilesystemHandler()
r.Get("/filesystem/browse", filesystemHandler.HandleBrowse)
if catalogSeedHandler != nil {
r.Route("/catalog", func(r chi.Router) {
r.Post("/export", catalogSeedHandler.HandleExport)
r.Post("/export-jobs", catalogSeedHandler.HandleCreateExportJob)
r.Post("/export-jobs/{id}/publish", catalogSeedHandler.HandlePublishExportJob)
r.Post("/import-jobs", catalogSeedHandler.HandleCreateImportJob)
r.Get("/import-sources", catalogSeedHandler.HandleListImportSources)
r.Get("/local-import-sources", catalogSeedHandler.HandleListLocalImportSources)
r.Post("/import", catalogSeedHandler.HandleImport)
})
}
r.Group(func(r chi.Router) {
r.Use(metadataItemAccess)
r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata)
r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata)
if adminMatchHandler != nil {
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
}
})
if adminJobsHandler != nil {
r.Route("/jobs", func(r chi.Router) {
r.Get("/", adminJobsHandler.HandleList)
r.Get("/{id}", adminJobsHandler.HandleGet)
})
// Curators must poll their own item-refresh jobs, so this stays outside
// the admin-only group. HandleGet enforces per-job authorization.
r.Get("/jobs/{id}", adminJobsHandler.HandleGet)
}
if deps.PluginService != nil && deps.PluginUserConfig != nil {
pluginHandler := handlers.NewPluginHandler(
plugins.NewRepositoryStore(deps.DB),
plugins.NewInstallationStore(deps.DB),
plugins.NewRuntimeConfigStore(deps.DB),
deps.PluginService,
deps.PluginUserConfig,
deps.PluginHTTPProxy,
metadata.NewChainRepository(deps.DB),
deps.PluginImageResolver,
)
r.Route("/plugins", func(r chi.Router) {
r.Get("/repositories", pluginHandler.HandleListRepositories)
r.Post("/repositories", pluginHandler.HandleCreateRepository)
r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository)
r.Delete("/repositories/{id}", pluginHandler.HandleDeleteRepository)
r.Get("/catalog", pluginHandler.HandleCatalog)
r.Get("/installations", pluginHandler.HandleListInstallations)
r.Post("/installations", pluginHandler.HandleCreateInstallation)
r.Post("/uploads", pluginHandler.HandleUploadInstallation)
r.Put("/installations/{id}", pluginHandler.HandleUpdateInstallation)
r.Post("/installations/{id}/update", pluginHandler.HandleApplyUpdate)
r.Post("/installations/{id}/config/test", pluginHandler.HandleTestInstallationConfig)
r.Put("/installations/{id}/config", pluginHandler.HandlePutInstallationConfig)
r.Put("/installations/{id}/auth-binding", pluginHandler.HandlePutAuthBinding)
r.Put("/installations/{id}/task-bindings/{capability_id}", pluginHandler.HandlePutTaskBinding)
r.Delete("/installations/{id}", pluginHandler.HandleDeleteInstallation)
})
}
r.Group(func(r chi.Router) {
r.Use(apimw.RequireAdmin)
if historyImportHandler != nil {
r.Route("/history-import-sources", func(r chi.Router) {
r.Get("/", historyImportHandler.HandleAdminListSources)
r.Post("/", historyImportHandler.HandleAdminCreateSource)
r.Put("/{id}", historyImportHandler.HandleAdminUpdateSource)
r.Delete("/{id}", historyImportHandler.HandleAdminDeleteSource)
})
r.Get("/users", adminHandler.HandleListUsers)
r.Post("/users", adminHandler.HandleCreateUser)
r.Get("/users/{id}", adminHandler.HandleGetUser)
r.Put("/users/{id}", adminHandler.HandleUpdateUser)
r.Delete("/users/{id}", adminHandler.HandleDeleteUser)
r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser)
r.Get("/users/{id}/profiles", adminHandler.HandleListUserProfiles)
r.Get("/users/{id}/settings", adminHandler.HandleListUserSettings)
r.Get("/users/{id}/settings/{key}", adminHandler.HandleGetUserSetting)
r.Put("/users/{id}/settings/{key}", adminHandler.HandleUpdateUserSetting)
r.Delete("/users/{id}/settings/{key}", adminHandler.HandleDeleteUserSetting)
r.Get("/users/{id}/device-settings", adminHandler.HandleListUserDeviceSettings)
r.Get("/users/{id}/device-settings/{key}", adminHandler.HandleListUserDeviceSettingsByKey)
r.Put("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleUpdateUserDeviceSetting)
r.Delete("/users/{id}/device-settings/{key}", adminHandler.HandleDeleteUserDeviceSettingsByKey)
r.Delete("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleDeleteUserDeviceSetting)
r.Delete("/users/{id}/profiles/{profile_id}/devices/{device_id}/settings", adminHandler.HandleDeleteAllUserDeviceSettings)
r.Get("/devices", adminHandler.HandleListDevices)
r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice)
r.Route("/history-imports", func(r chi.Router) {
r.Post("/plex/login", historyImportHandler.HandleAdminPlexLogin)
r.Put("/sources/{id}/token", historyImportHandler.HandleAdminSetSourceToken)
r.Delete("/sources/{id}/token", historyImportHandler.HandleAdminClearSourceToken)
r.Get("/sources/{id}/users", historyImportHandler.HandleAdminDiscoverUsers)
r.Post("/sources/{id}/bulk-run", historyImportHandler.HandleAdminBulkRun)
r.Get("/mappings", historyImportHandler.HandleAdminListMappings)
r.Post("/mappings", historyImportHandler.HandleAdminCreateMapping)
r.Put("/mappings/{id}", historyImportHandler.HandleAdminUpdateMapping)
r.Delete("/mappings/{id}", historyImportHandler.HandleAdminDeleteMapping)
r.Post("/mappings/{id}/run", historyImportHandler.HandleAdminCreateRun)
r.Get("/runs", historyImportHandler.HandleAdminListRuns)
r.Get("/runs/{id}", historyImportHandler.HandleAdminGetRun)
r.Post("/runs/{id}/cancel", historyImportHandler.HandleAdminCancelRun)
})
}
if sectionHandler != nil {
r.Route("/sections", func(r chi.Router) {
r.Get("/", sectionHandler.HandleListSections)
r.Post("/", sectionHandler.HandleCreateSection)
r.Post("/preview", sectionHandler.HandlePreview)
r.Put("/reorder", sectionHandler.HandleReorderSections)
r.Post("/restore-defaults", sectionHandler.HandleRestoreDefaults)
r.Put("/{id}", sectionHandler.HandleUpdateSection)
r.Delete("/{id}", sectionHandler.HandleDeleteSection)
if sectionBulkHandler != nil {
r.Post("/bulk-create", sectionBulkHandler.HandleBulkCreate)
}
})
}
if libraryCollectionHandler != nil {
collectionTemplateHandler := handlers.NewCollectionTemplateHandler(nil)
r.Route("/collections", func(r chi.Router) {
r.Get("/", libraryCollectionHandler.HandleListAdminCollections)
r.Get("/templates", collectionTemplateHandler.HandleListTemplates)
r.Get("/template-bundles", libraryCollectionHandler.HandleListTemplateBundles)
r.Post("/template-bundles/{bundleID}/apply", libraryCollectionHandler.HandleApplyTemplateBundle)
r.Post("/template-bundles/{bundleID}/apply-job", libraryCollectionHandler.HandleApplyTemplateBundleJob)
r.Post("/", libraryCollectionHandler.HandleCreateAdminCollection)
r.Post("/preview", libraryCollectionHandler.HandlePreviewAdminCollection)
r.Put("/order", libraryCollectionHandler.HandleReorderAdminCollections)
r.Put("/{id}", libraryCollectionHandler.HandleUpdateAdminCollection)
r.Delete("/{id}", libraryCollectionHandler.HandleDeleteAdminCollection)
r.Post("/{id}/sync", libraryCollectionHandler.HandleSyncAdminCollection)
r.Delete("/{id}/image", libraryCollectionHandler.HandleDeleteCollectionImage)
r.Put("/{id}/items/order", libraryCollectionHandler.HandleReorderAdminCollectionItems)
r.Put("/{id}/items/{item_id}", libraryCollectionHandler.HandleAddAdminCollectionItem)
r.Delete("/{id}/items/{item_id}", libraryCollectionHandler.HandleRemoveAdminCollectionItem)
r.Post("/import/mdblist", libraryCollectionHandler.HandleImportMDBList)
r.Post("/import/tmdb", libraryCollectionHandler.HandleImportTMDBCollection)
r.Post("/import/trakt", libraryCollectionHandler.HandleImportTraktCollection)
})
}
if libraryCollectionGroupHandler != nil {
r.Route("/libraries/{libraryID}/collection-groups", func(r chi.Router) {
r.Get("/", libraryCollectionGroupHandler.HandleListGroups)
r.Post("/", libraryCollectionGroupHandler.HandleCreateGroup)
r.Put("/reorder", libraryCollectionGroupHandler.HandleReorderGroups)
})
r.Route("/collection-groups", func(r chi.Router) {
r.Put("/{id}", libraryCollectionGroupHandler.HandleUpdateGroup)
r.Delete("/{id}", libraryCollectionGroupHandler.HandleDeleteGroup)
r.Put("/{groupID}/collections/reorder", libraryCollectionGroupHandler.HandleReorderCollectionsInGroup)
})
}
if deps.NodeRepo != nil {
jwtSecret := ""
if deps.Config != nil {
jwtSecret = deps.Config.Auth.JWTSecret
r.Get("/sessions", adminHandler.HandleListSessions)
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
r.Get("/stats", adminHandler.HandleGetStats)
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
if sectionSettingsHandler != nil {
r.Get("/settings/sections", sectionSettingsHandler.HandleGet)
r.Put("/settings/sections", sectionSettingsHandler.HandlePut)
}
nodeHandler := handlers.NewNodeHandler(deps.NodeRepo, deps.ProxyPool, deps.TranscodePool, deps.NodeRepo, deps.EventBus, deps.RedisClient, jwtSecret)
r.Route("/nodes", func(r chi.Router) {
r.Get("/", nodeHandler.HandleListNodes)
r.Post("/", nodeHandler.HandleCreateNode)
r.Put("/{id}", nodeHandler.HandleUpdateNode)
r.Delete("/{id}", nodeHandler.HandleDeleteNode)
r.Post("/{id}/check", nodeHandler.HandleCheckNode)
r.Post("/force-reload", nodeHandler.HandleForceReloadNodes)
r.Post("/{id}/force-reload", nodeHandler.HandleForceReloadNode)
})
// Live node sessions (reads from Redis)
// Note: /admin/sessions is already used for playback sessions from PostgreSQL.
r.Get("/node-sessions", nodeHandler.HandleListSessions)
}
// System inspection.
{
sysJWTSecret := ""
if deps.Config != nil {
sysJWTSecret = deps.Config.Auth.JWTSecret
r.Get("/settings/{key}", adminHandler.HandleGetSetting)
r.Get("/settings", adminHandler.HandleGetSettings)
r.Put("/settings/{key}", adminHandler.HandleUpdateSetting)
if adminIntroHandler != nil {
r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers)
r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro)
}
if peopleHandler != nil {
r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson)
r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson)
}
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
r.Route("/system", func(r chi.Router) {
r.Get("/build", systemHandler.HandleBuildInfo)
r.Get("/hw-accel", systemHandler.HandleHWAccel)
})
}
if deps.RecWorker != nil {
adminRecsHandler := handlers.NewAdminRecommendationsHandler(deps.RecWorker)
r.Route("/recommendations", func(r chi.Router) {
r.Get("/status", adminRecsHandler.HandleStatus)
r.Post("/trigger/embeddings", adminRecsHandler.HandleTriggerEmbeddings)
r.Post("/trigger/taste-profiles", adminRecsHandler.HandleTriggerTasteProfiles)
r.Post("/trigger/cowatch", adminRecsHandler.HandleTriggerCowatch)
r.Post("/trigger/recommendations", adminRecsHandler.HandleTriggerRecommendations)
})
}
if adminImageHandler != nil {
r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages)
r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage)
}
if inviteCodeRepo != nil {
inviteCodeHandler := handlers.NewInviteCodeHandler(inviteCodeRepo)
r.Route("/invite-codes", func(r chi.Router) {
r.Get("/", inviteCodeHandler.HandleListInviteCodes)
r.Post("/", inviteCodeHandler.HandleCreateInviteCode)
r.Put("/{id}", inviteCodeHandler.HandleUpdateInviteCode)
r.Post("/{id}/top-up", inviteCodeHandler.HandleTopUpInviteCode)
r.Delete("/{id}", inviteCodeHandler.HandleDeleteInviteCode)
})
}
filesystemHandler := handlers.NewFilesystemHandler()
r.Get("/filesystem/browse", filesystemHandler.HandleBrowse)
if adminSubtitleHandler != nil {
r.Route("/subtitle-providers", func(r chi.Router) {
r.Get("/", adminSubtitleHandler.HandleListProviders)
r.Route("/{provider}", func(r chi.Router) {
r.Put("/", adminSubtitleHandler.HandleUpdateProvider)
r.Post("/test", adminSubtitleHandler.HandleTestProvider)
if catalogSeedHandler != nil {
r.Route("/catalog", func(r chi.Router) {
r.Post("/export", catalogSeedHandler.HandleExport)
r.Post("/export-jobs", catalogSeedHandler.HandleCreateExportJob)
r.Post("/export-jobs/{id}/publish", catalogSeedHandler.HandlePublishExportJob)
r.Post("/import-jobs", catalogSeedHandler.HandleCreateImportJob)
r.Get("/import-sources", catalogSeedHandler.HandleListImportSources)
r.Get("/local-import-sources", catalogSeedHandler.HandleListLocalImportSources)
r.Post("/import", catalogSeedHandler.HandleImport)
})
})
}
}
// Rate limit admin routes
if deps.RateLimitMW != nil && settingsRepo != nil {
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
r.Route("/rate-limits", func(r chi.Router) {
r.Get("/config", rateLimitHandler.HandleGetConfig)
r.Put("/config", rateLimitHandler.HandleUpdateConfig)
})
}
if adminJobsHandler != nil {
r.Route("/jobs", func(r chi.Router) {
r.Get("/", adminJobsHandler.HandleList)
})
}
if apiKeyRepo != nil {
apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo)
r.Get("/users/{userId}/api-keys", apiKeyHandler.HandleAdminListUserAPIKeys)
r.Get("/api-keys", apiKeyHandler.HandleAdminListAllAPIKeys)
r.Post("/api-keys", apiKeyHandler.HandleAdminCreateAPIKey)
r.Delete("/api-keys/{id}", apiKeyHandler.HandleAdminDeleteAPIKey)
r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier)
}
if deps.PluginService != nil && deps.PluginUserConfig != nil {
pluginHandler := handlers.NewPluginHandler(
plugins.NewRepositoryStore(deps.DB),
plugins.NewInstallationStore(deps.DB),
plugins.NewRuntimeConfigStore(deps.DB),
deps.PluginService,
deps.PluginUserConfig,
deps.PluginHTTPProxy,
metadata.NewChainRepository(deps.DB),
deps.PluginImageResolver,
)
r.Route("/plugins", func(r chi.Router) {
r.Get("/repositories", pluginHandler.HandleListRepositories)
r.Post("/repositories", pluginHandler.HandleCreateRepository)
r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository)
r.Delete("/repositories/{id}", pluginHandler.HandleDeleteRepository)
r.Get("/catalog", pluginHandler.HandleCatalog)
r.Get("/installations", pluginHandler.HandleListInstallations)
r.Post("/installations", pluginHandler.HandleCreateInstallation)
r.Post("/uploads", pluginHandler.HandleUploadInstallation)
r.Put("/installations/{id}", pluginHandler.HandleUpdateInstallation)
r.Post("/installations/{id}/update", pluginHandler.HandleApplyUpdate)
r.Post("/installations/{id}/config/test", pluginHandler.HandleTestInstallationConfig)
r.Put("/installations/{id}/config", pluginHandler.HandlePutInstallationConfig)
r.Put("/installations/{id}/auth-binding", pluginHandler.HandlePutAuthBinding)
r.Put("/installations/{id}/task-bindings/{capability_id}", pluginHandler.HandlePutTaskBinding)
r.Delete("/installations/{id}", pluginHandler.HandleDeleteInstallation)
})
}
if requestHandler != nil {
r.Get("/requests", requestHandler.HandleAdminList)
r.Post("/requests/{id}/approve", requestHandler.HandleApprove)
r.Post("/requests/{id}/decline", requestHandler.HandleDecline)
r.Post("/requests/{id}/cancel", requestHandler.HandleCancel)
r.Post("/requests/{id}/retry", requestHandler.HandleRetry)
r.Get("/request-settings", requestHandler.HandleGetSettings)
r.Put("/request-settings", requestHandler.HandleUpdateSettings)
r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit)
r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit)
r.Get("/request-integrations", requestHandler.HandleListIntegrations)
r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations)
r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions)
}
if historyImportHandler != nil {
r.Route("/history-import-sources", func(r chi.Router) {
r.Get("/", historyImportHandler.HandleAdminListSources)
r.Post("/", historyImportHandler.HandleAdminCreateSource)
r.Put("/{id}", historyImportHandler.HandleAdminUpdateSource)
r.Delete("/{id}", historyImportHandler.HandleAdminDeleteSource)
})
if deps.ActivityLogRepo != nil {
adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo)
r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs)
r.Get("/ips", adminIPHandler.HandleGetIPUsers)
}
if deps.OpsLogRepo != nil && deps.ActivityLogRepo != nil {
adminLogsHandler := handlers.NewAdminLogsHandler(deps.OpsLogRepo, deps.ActivityLogRepo, deps.LogStreamHub)
r.Get("/logs/app", adminLogsHandler.HandleListOperationalLogs)
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
}
if adminPlaybackControlHandler != nil {
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
r.Post("/sessions/{session_id}/stop", adminPlaybackControlHandler.HandleStopSession)
r.Post("/sessions/{session_id}/terminate", adminPlaybackControlHandler.HandleTerminateSession)
r.Post("/sessions/{session_id}/message", adminPlaybackControlHandler.HandleMessageSession)
}
r.Route("/history-imports", func(r chi.Router) {
r.Post("/plex/login", historyImportHandler.HandleAdminPlexLogin)
r.Put("/sources/{id}/token", historyImportHandler.HandleAdminSetSourceToken)
r.Delete("/sources/{id}/token", historyImportHandler.HandleAdminClearSourceToken)
r.Get("/sources/{id}/users", historyImportHandler.HandleAdminDiscoverUsers)
r.Post("/sources/{id}/bulk-run", historyImportHandler.HandleAdminBulkRun)
r.Get("/mappings", historyImportHandler.HandleAdminListMappings)
r.Post("/mappings", historyImportHandler.HandleAdminCreateMapping)
r.Put("/mappings/{id}", historyImportHandler.HandleAdminUpdateMapping)
r.Delete("/mappings/{id}", historyImportHandler.HandleAdminDeleteMapping)
r.Post("/mappings/{id}/run", historyImportHandler.HandleAdminCreateRun)
r.Get("/runs", historyImportHandler.HandleAdminListRuns)
r.Get("/runs/{id}", historyImportHandler.HandleAdminGetRun)
r.Post("/runs/{id}/cancel", historyImportHandler.HandleAdminCancelRun)
})
}
if deps.TaskManager != nil {
taskHistoryRepo := repository.NewPgExecutionRepository(deps.DB)
taskMetrics := handlers.NewTaskMetricsService(metadata.NewRefreshDebtRepository(deps.DB))
taskHandler := handlers.NewTaskHandler(deps.TaskManager, taskHistoryRepo, taskMetrics)
r.Route("/tasks", func(r chi.Router) {
r.Get("/", taskHandler.HandleListTasks)
r.Get("/{key}", taskHandler.HandleGetTask)
r.Get("/{key}/metrics", taskHandler.HandleGetMetrics)
r.Post("/{key}/run", taskHandler.HandleRunTask)
r.Post("/{key}/cancel", taskHandler.HandleCancelTask)
r.Put("/{key}/triggers", taskHandler.HandleUpdateTriggers)
r.Get("/{key}/history", taskHandler.HandleGetHistory)
})
}
if sectionHandler != nil {
r.Route("/sections", func(r chi.Router) {
r.Get("/", sectionHandler.HandleListSections)
r.Post("/", sectionHandler.HandleCreateSection)
r.Post("/preview", sectionHandler.HandlePreview)
r.Put("/reorder", sectionHandler.HandleReorderSections)
r.Post("/restore-defaults", sectionHandler.HandleRestoreDefaults)
r.Put("/{id}", sectionHandler.HandleUpdateSection)
r.Delete("/{id}", sectionHandler.HandleDeleteSection)
if sectionBulkHandler != nil {
r.Post("/bulk-create", sectionBulkHandler.HandleBulkCreate)
}
})
}
if libraryCollectionHandler != nil {
collectionTemplateHandler := handlers.NewCollectionTemplateHandler(nil)
r.Route("/collections", func(r chi.Router) {
r.Get("/", libraryCollectionHandler.HandleListAdminCollections)
r.Get("/templates", collectionTemplateHandler.HandleListTemplates)
r.Get("/template-bundles", libraryCollectionHandler.HandleListTemplateBundles)
r.Post("/template-bundles/{bundleID}/apply", libraryCollectionHandler.HandleApplyTemplateBundle)
r.Post("/template-bundles/{bundleID}/apply-job", libraryCollectionHandler.HandleApplyTemplateBundleJob)
r.Post("/", libraryCollectionHandler.HandleCreateAdminCollection)
r.Post("/preview", libraryCollectionHandler.HandlePreviewAdminCollection)
r.Put("/order", libraryCollectionHandler.HandleReorderAdminCollections)
r.Put("/{id}", libraryCollectionHandler.HandleUpdateAdminCollection)
r.Delete("/{id}", libraryCollectionHandler.HandleDeleteAdminCollection)
r.Post("/{id}/sync", libraryCollectionHandler.HandleSyncAdminCollection)
r.Delete("/{id}/image", libraryCollectionHandler.HandleDeleteCollectionImage)
r.Put("/{id}/items/order", libraryCollectionHandler.HandleReorderAdminCollectionItems)
r.Put("/{id}/items/{item_id}", libraryCollectionHandler.HandleAddAdminCollectionItem)
r.Delete("/{id}/items/{item_id}", libraryCollectionHandler.HandleRemoveAdminCollectionItem)
r.Post("/import/mdblist", libraryCollectionHandler.HandleImportMDBList)
r.Post("/import/tmdb", libraryCollectionHandler.HandleImportTMDBCollection)
r.Post("/import/trakt", libraryCollectionHandler.HandleImportTraktCollection)
})
}
if libraryCollectionGroupHandler != nil {
r.Route("/libraries/{libraryID}/collection-groups", func(r chi.Router) {
r.Get("/", libraryCollectionGroupHandler.HandleListGroups)
r.Post("/", libraryCollectionGroupHandler.HandleCreateGroup)
r.Put("/reorder", libraryCollectionGroupHandler.HandleReorderGroups)
})
r.Route("/collection-groups", func(r chi.Router) {
r.Put("/{id}", libraryCollectionGroupHandler.HandleUpdateGroup)
r.Delete("/{id}", libraryCollectionGroupHandler.HandleDeleteGroup)
r.Put("/{groupID}/collections/reorder", libraryCollectionGroupHandler.HandleReorderCollectionsInGroup)
})
}
if deps.NodeRepo != nil {
jwtSecret := ""
if deps.Config != nil {
jwtSecret = deps.Config.Auth.JWTSecret
}
nodeHandler := handlers.NewNodeHandler(deps.NodeRepo, deps.ProxyPool, deps.TranscodePool, deps.NodeRepo, deps.EventBus, deps.RedisClient, jwtSecret)
r.Route("/nodes", func(r chi.Router) {
r.Get("/", nodeHandler.HandleListNodes)
r.Post("/", nodeHandler.HandleCreateNode)
r.Put("/{id}", nodeHandler.HandleUpdateNode)
r.Delete("/{id}", nodeHandler.HandleDeleteNode)
r.Post("/{id}/check", nodeHandler.HandleCheckNode)
r.Post("/force-reload", nodeHandler.HandleForceReloadNodes)
r.Post("/{id}/force-reload", nodeHandler.HandleForceReloadNode)
})
// Live node sessions (reads from Redis)
// Note: /admin/sessions is already used for playback sessions from PostgreSQL.
r.Get("/node-sessions", nodeHandler.HandleListSessions)
}
// System inspection.
{
sysJWTSecret := ""
if deps.Config != nil {
sysJWTSecret = deps.Config.Auth.JWTSecret
}
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
r.Route("/system", func(r chi.Router) {
r.Get("/build", systemHandler.HandleBuildInfo)
r.Get("/hw-accel", systemHandler.HandleHWAccel)
})
}
if deps.RecWorker != nil {
adminRecsHandler := handlers.NewAdminRecommendationsHandler(deps.RecWorker)
r.Route("/recommendations", func(r chi.Router) {
r.Get("/status", adminRecsHandler.HandleStatus)
r.Post("/trigger/embeddings", adminRecsHandler.HandleTriggerEmbeddings)
r.Post("/trigger/taste-profiles", adminRecsHandler.HandleTriggerTasteProfiles)
r.Post("/trigger/cowatch", adminRecsHandler.HandleTriggerCowatch)
r.Post("/trigger/recommendations", adminRecsHandler.HandleTriggerRecommendations)
})
}
if inviteCodeRepo != nil {
inviteCodeHandler := handlers.NewInviteCodeHandler(inviteCodeRepo)
r.Route("/invite-codes", func(r chi.Router) {
r.Get("/", inviteCodeHandler.HandleListInviteCodes)
r.Post("/", inviteCodeHandler.HandleCreateInviteCode)
r.Put("/{id}", inviteCodeHandler.HandleUpdateInviteCode)
r.Post("/{id}/top-up", inviteCodeHandler.HandleTopUpInviteCode)
r.Delete("/{id}", inviteCodeHandler.HandleDeleteInviteCode)
})
}
if adminSubtitleHandler != nil {
r.Route("/subtitle-providers", func(r chi.Router) {
r.Get("/", adminSubtitleHandler.HandleListProviders)
r.Route("/{provider}", func(r chi.Router) {
r.Put("/", adminSubtitleHandler.HandleUpdateProvider)
r.Post("/test", adminSubtitleHandler.HandleTestProvider)
})
})
r.Route("/subtitles", func(r chi.Router) {
r.Get("/", adminSubtitleHandler.HandleListDownloadedSubtitles)
r.Route("/{id}", func(r chi.Router) {
r.Patch("/", adminSubtitleHandler.HandlePatchDownloadedSubtitle)
r.Get("/download", adminSubtitleHandler.HandleDownloadDownloadedSubtitle)
r.Delete("/", adminSubtitleHandler.HandleDeleteDownloadedSubtitle)
})
})
}
// Rate limit admin routes
if deps.RateLimitMW != nil && settingsRepo != nil {
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
r.Route("/rate-limits", func(r chi.Router) {
r.Get("/config", rateLimitHandler.HandleGetConfig)
r.Put("/config", rateLimitHandler.HandleUpdateConfig)
})
}
if apiKeyRepo != nil {
apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo)
r.Get("/users/{userId}/api-keys", apiKeyHandler.HandleAdminListUserAPIKeys)
r.Get("/api-keys", apiKeyHandler.HandleAdminListAllAPIKeys)
r.Post("/api-keys", apiKeyHandler.HandleAdminCreateAPIKey)
r.Delete("/api-keys/{id}", apiKeyHandler.HandleAdminDeleteAPIKey)
r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier)
}
if requestHandler != nil {
r.Get("/requests", requestHandler.HandleAdminList)
r.Post("/requests/{id}/approve", requestHandler.HandleApprove)
r.Post("/requests/{id}/decline", requestHandler.HandleDecline)
r.Post("/requests/{id}/cancel", requestHandler.HandleCancel)
r.Post("/requests/{id}/retry", requestHandler.HandleRetry)
r.Get("/request-settings", requestHandler.HandleGetSettings)
r.Put("/request-settings", requestHandler.HandleUpdateSettings)
r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit)
r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit)
r.Get("/request-integrations", requestHandler.HandleListIntegrations)
r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations)
r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions)
}
if deps.ActivityLogRepo != nil {
adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo)
r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs)
r.Get("/ips", adminIPHandler.HandleGetIPUsers)
}
if deps.OpsLogRepo != nil && deps.ActivityLogRepo != nil {
adminLogsHandler := handlers.NewAdminLogsHandler(deps.OpsLogRepo, deps.ActivityLogRepo, deps.LogStreamHub)
r.Get("/logs/app", adminLogsHandler.HandleListOperationalLogs)
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
}
if adminPlaybackControlHandler != nil {
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
r.Post("/sessions/{session_id}/stop", adminPlaybackControlHandler.HandleStopSession)
r.Post("/sessions/{session_id}/terminate", adminPlaybackControlHandler.HandleTerminateSession)
r.Post("/sessions/{session_id}/message", adminPlaybackControlHandler.HandleMessageSession)
}
if deps.TaskManager != nil {
taskHistoryRepo := repository.NewPgExecutionRepository(deps.DB)
taskMetrics := handlers.NewTaskMetricsService(metadata.NewRefreshDebtRepository(deps.DB))
taskHandler := handlers.NewTaskHandler(deps.TaskManager, taskHistoryRepo, taskMetrics)
r.Route("/tasks", func(r chi.Router) {
r.Get("/", taskHandler.HandleListTasks)
r.Get("/{key}", taskHandler.HandleGetTask)
r.Get("/{key}/metrics", taskHandler.HandleGetMetrics)
r.Post("/{key}/run", taskHandler.HandleRunTask)
r.Post("/{key}/cancel", taskHandler.HandleCancelTask)
r.Put("/{key}/triggers", taskHandler.HandleUpdateTriggers)
r.Get("/{key}/history", taskHandler.HandleGetHistory)
})
}
})
})
}
})
+93
View File
@@ -0,0 +1,93 @@
package auth
import (
"fmt"
"sort"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
type Permission string
const PermissionMetadataCuration Permission = "metadata_curation"
var assignablePermissions = map[Permission]struct{}{
PermissionMetadataCuration: {},
}
func assignablePermissionList() []string {
out := make([]string, 0, len(assignablePermissions))
for permission := range assignablePermissions {
out = append(out, string(permission))
}
sort.Strings(out)
return out
}
func isAssignablePermission(permission Permission) bool {
_, ok := assignablePermissions[permission]
return ok
}
func NormalizePermissions(values []string) ([]string, error) {
if len(values) == 0 {
return []string{}, nil
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, raw := range values {
key := strings.TrimSpace(raw)
if key == "" {
continue
}
permission := Permission(key)
if !isAssignablePermission(permission) {
return nil, fmt.Errorf("unknown permission %q", key)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
sort.Strings(out)
return out, nil
}
func HasAssignedPermission(user *models.User, permission Permission) bool {
if user == nil {
return false
}
for _, value := range user.Permissions {
if value == string(permission) {
return true
}
}
return false
}
func HasEffectivePermission(user *models.User, permission Permission) bool {
if user == nil || !user.Enabled {
return false
}
if user.Role == "admin" {
return isAssignablePermission(permission)
}
return HasAssignedPermission(user, permission)
}
func EffectivePermissions(user *models.User) []string {
if user == nil || !user.Enabled {
return []string{}
}
if user.Role == "admin" {
return assignablePermissionList()
}
permissions, err := NormalizePermissions(user.Permissions)
if err != nil {
return []string{}
}
return permissions
}
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestNormalizePermissions_DeduplicatesAndSorts(t *testing.T) {
got, err := NormalizePermissions([]string{
" metadata_curation ",
"metadata_curation",
"",
})
if err != nil {
t.Fatalf("NormalizePermissions returned error: %v", err)
}
want := []string{"metadata_curation"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("permissions = %#v, want %#v", got, want)
}
}
func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) {
if _, err := NormalizePermissions([]string{"server_owner"}); err == nil {
t.Fatal("expected unknown permission error")
}
}
func TestHasEffectivePermission_AdminImpliesMetadataCuration(t *testing.T) {
user := &models.User{Role: "admin", Enabled: true}
if !HasEffectivePermission(user, PermissionMetadataCuration) {
t.Fatal("admin should have metadata curation")
}
}
func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
user := &models.User{Role: "user", Enabled: true}
if HasEffectivePermission(user, PermissionMetadataCuration) {
t.Fatal("plain user should not have metadata curation")
}
user.Permissions = []string{"metadata_curation"}
if !HasEffectivePermission(user, PermissionMetadataCuration) {
t.Fatal("assigned user should have metadata curation")
}
}
+27 -2
View File
@@ -49,7 +49,7 @@ func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
// allColumns is the list of columns returned by all SELECT queries.
// Kept in one place so scanUser stays in sync.
const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, enabled,
const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled,
library_ids, max_playback_quality, access_policy_revision,
max_streams, max_transcodes, max_profiles, download_allowed,
download_transcode_allowed, created_at, updated_at`
@@ -64,6 +64,7 @@ func scanUser(row pgx.Row) (*models.User, error) {
&u.PasswordHash,
&u.LocalPasswordLoginEnabled,
&u.Role,
&u.Permissions,
&u.Enabled,
&u.LibraryIDs,
&u.MaxPlaybackQuality,
@@ -97,6 +98,7 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) {
&u.PasswordHash,
&u.LocalPasswordLoginEnabled,
&u.Role,
&u.Permissions,
&u.Enabled,
&u.LibraryIDs,
&u.MaxPlaybackQuality,
@@ -133,13 +135,19 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu
localPasswordLoginEnabled = *input.LocalPasswordLoginEnabled
}
cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "library_ids", "max_playback_quality"}
permissions, err := NormalizePermissions(input.Permissions)
if err != nil {
return nil, err
}
cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"}
args := []any{
input.Email,
input.Username,
string(hash),
localPasswordLoginEnabled,
input.Role,
permissions,
input.LibraryIDs,
input.MaxPlaybackQuality,
}
@@ -245,6 +253,15 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update
args = append(args, *input.Role)
argIndex++
}
if input.Permissions != nil {
permissions, err := NormalizePermissions(*input.Permissions)
if err != nil {
return err
}
setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex))
args = append(args, permissions)
argIndex++
}
if input.Enabled != nil {
setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", argIndex))
args = append(args, *input.Enabled)
@@ -292,6 +309,14 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update
return err
}
if input.Role != nil ||
input.Enabled != nil ||
input.LibraryIDs != nil ||
input.MaxPlaybackQuality != nil ||
input.Permissions != nil {
setClauses = append(setClauses, "access_policy_revision = access_policy_revision + 1")
}
// Always bump updated_at.
setClauses = append(setClauses, "updated_at = NOW()")
+15
View File
@@ -51,9 +51,24 @@ func FileAllowedByAccess(file *models.MediaFile, filter AccessFilter) bool {
if file == nil {
return false
}
if filter.AllowedLibraryIDs != nil && !intInSlice(file.MediaFolderID, filter.AllowedLibraryIDs) {
return false
}
if len(filter.DisabledLibraryIDs) > 0 && intInSlice(file.MediaFolderID, filter.DisabledLibraryIDs) {
return false
}
return access.QualityAllowed(file.Resolution, filter.MaxPlaybackQuality)
}
func intInSlice(value int, values []int) bool {
for _, candidate := range values {
if candidate == value {
return true
}
}
return false
}
// FilterMediaFilesByAccess drops file versions that exceed the viewer's
// effective quality ceiling.
func FilterMediaFilesByAccess(files []*models.MediaFile, filter AccessFilter) []*models.MediaFile {
@@ -1,5 +1,7 @@
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
role TEXT,
permissions TEXT[] DEFAULT '{}'::TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+122
View File
@@ -0,0 +1,122 @@
package jellycompat
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type adminAPIKeyContextKey string
const adminAPIKeyKey adminAPIKeyContextKey = "jellycompat_admin_api_key"
type apiKeyValidator interface {
GetByKey(ctx context.Context, key string) (*models.APIKey, error)
UpdateLastUsed(ctx context.Context, id int64) error
}
type apiKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
type AdminAPIKeyAuthenticator struct {
keys apiKeyValidator
users apiKeyUserLoader
}
type adminAPIKeyAuthResult struct {
ctx context.Context
status int
code string
message string
ok bool
}
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator {
if keys == nil || users == nil {
return nil
}
return &AdminAPIKeyAuthenticator{keys: keys, users: users}
}
func AdminAPIKeyFromContext(ctx context.Context) bool {
ok, _ := ctx.Value(adminAPIKeyKey).(bool)
return ok
}
func (a *AdminAPIKeyAuthenticator) RequireAdminAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
result := a.authenticate(r)
if !result.ok {
writeError(w, result.status, result.code, result.message)
return
}
next.ServeHTTP(w, r.WithContext(result.ctx))
})
}
func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKeyAuthenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := ExtractToken(r)
if ok && strings.HasPrefix(token, "sa_") {
result := keyAuth.authenticate(r)
if !result.ok {
writeError(w, result.status, result.code, result.message)
return
}
next.ServeHTTP(w, r.WithContext(result.ctx))
return
}
sessionAuth.RequireSession(next).ServeHTTP(w, r)
})
}
}
func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult {
unauthorized := adminAPIKeyAuthResult{
ctx: r.Context(),
status: http.StatusUnauthorized,
code: "Unauthorized",
message: "Invalid API key",
}
if a == nil || a.keys == nil || a.users == nil {
return unauthorized
}
token, ok := ExtractToken(r)
if !ok || !strings.HasPrefix(token, "sa_") {
return unauthorized
}
apiKey, err := a.keys.GetByKey(r.Context(), token)
if err != nil || apiKey == nil {
return unauthorized
}
user, err := a.users.GetByID(r.Context(), apiKey.UserID)
if err != nil || user == nil || !user.Enabled {
return unauthorized
}
if user.Role != "admin" {
return adminAPIKeyAuthResult{
ctx: r.Context(),
status: http.StatusForbidden,
code: "Forbidden",
message: "Admin access required",
}
}
go func(id int64) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := a.keys.UpdateLastUsed(ctx, id); err != nil {
slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err)
}
}(apiKey.ID)
return adminAPIKeyAuthResult{
ctx: context.WithValue(r.Context(), adminAPIKeyKey, true),
status: http.StatusOK,
ok: true,
}
}
+129
View File
@@ -1,10 +1,14 @@
package jellycompat
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) {
@@ -114,3 +118,128 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
t.Errorf("expected 200 (no authService = skip refresh), got %d", rec.Code)
}
}
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
authn := NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
rec := httptest.NewRecorder()
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !AdminAPIKeyFromContext(r.Context()) {
t.Fatal("expected admin API key marker in context")
}
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
authn := NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}},
)
req := httptest.NewRequest("POST", "/Library/Media/Updated", nil)
req.Header.Set("X-Emby-Token", "sa_test")
rec := httptest.NewRecorder()
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not run")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) {
authn := NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{returnNilWithoutError: true},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
rec := httptest.NewRecorder()
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not run")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
called := make(chan bool, 1)
authn := NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{
key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"},
update: func(ctx context.Context, _ int64) error {
_, ok := ctx.Deadline()
called <- ok
return nil
},
},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
rec := httptest.NewRecorder()
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
select {
case ok := <-called:
if !ok {
t.Fatal("expected last-used update context to have a deadline")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for last-used update")
}
}
type fakeAPIKeyValidator struct {
key *models.APIKey
returnNilWithoutError bool
update func(context.Context, int64) error
}
func (f *fakeAPIKeyValidator) GetByKey(_ context.Context, key string) (*models.APIKey, error) {
if f.returnNilWithoutError {
return nil, nil
}
if f.key != nil && f.key.Key == key {
return f.key, nil
}
return nil, auth.ErrAPIKeyNotFound
}
func (f *fakeAPIKeyValidator) UpdateLastUsed(ctx context.Context, id int64) error {
if f.update != nil {
return f.update(ctx, id)
}
return nil
}
type fakeAPIKeyUserLoader struct {
user *models.User
}
func (f *fakeAPIKeyUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
if f.user != nil && f.user.ID == id {
return f.user, nil
}
return nil, auth.ErrNotFound
}
+66 -32
View File
@@ -14,9 +14,8 @@ import (
)
type compatEpisodeTarget struct {
Item upstreamListItem
SeriesPosterURL string
SeriesBackdropURL string
Item upstreamListItem
SeriesImages seriesImageSet
}
type libraryMembershipChecker interface {
@@ -204,6 +203,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
e.rating_tmdb,
e.air_date,
e.still_path,
COALESCE(e.still_thumbhash, ''),
e.updated_at,
e.season_number,
e.episode_number,
si.content_id,
@@ -211,9 +212,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
si.genres,
si.content_rating,
si.poster_path,
COALESCE(si.poster_thumbhash, ''),
si.backdrop_path,
COALESCE(si.backdrop_thumbhash, ''),
si.logo_path,
si.status
si.status,
si.updated_at
FROM %s
WHERE %s
ORDER BY e.content_id
@@ -236,6 +240,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
ratingTMDB *float64
airDate *time.Time
stillPath string
stillThumbhash string
updatedAt time.Time
seasonNumber int
episodeNumber int
seriesID string
@@ -243,9 +249,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
genres []string
contentRating string
seriesPosterPath string
seriesPosterTH string
seriesBackdrop string
seriesBackdropTH string
seriesLogoPath string
status string
seriesUpdatedAt time.Time
)
if err := rows.Scan(
&contentID,
@@ -256,6 +265,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
&ratingTMDB,
&airDate,
&stillPath,
&stillThumbhash,
&updatedAt,
&seasonNumber,
&episodeNumber,
&seriesID,
@@ -263,45 +274,59 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
&genres,
&contentRating,
&seriesPosterPath,
&seriesPosterTH,
&seriesBackdrop,
&seriesBackdropTH,
&seriesLogoPath,
&status,
&seriesUpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning compat episode target: %w", err)
}
listItem := upstreamListItem{
ContentID: contentID,
Type: "episode",
Title: title,
Genres: genres,
ContentRating: contentRating,
Status: status,
RatingIMDB: ratingIMDB,
RatingTMDB: ratingTMDB,
Overview: overview,
PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"),
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"),
StillURL: h.presignCompatImagePath(ctx, stillPath, "still"),
PosterPath: stillPath,
BackdropPath: seriesBackdrop,
LogoPath: seriesLogoPath,
StillPath: stillPath,
SeriesID: seriesID,
SeriesTitle: seriesTitle,
SeasonNumber: intPtr(seasonNumber),
EpisodeNumber: intPtr(episodeNumber),
Runtime: runtime,
ContentID: contentID,
Type: "episode",
Title: title,
Genres: genres,
ContentRating: contentRating,
Status: status,
RatingIMDB: ratingIMDB,
RatingTMDB: ratingTMDB,
Overview: overview,
PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"),
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"),
StillURL: h.presignCompatImagePath(ctx, stillPath, "still"),
PosterPath: stillPath,
BackdropPath: seriesBackdrop,
BackdropThumbhash: seriesBackdropTH,
LogoPath: seriesLogoPath,
StillPath: stillPath,
StillThumbhash: stillThumbhash,
UpdatedAt: updatedAt,
SeriesID: seriesID,
SeriesTitle: seriesTitle,
SeasonNumber: intPtr(seasonNumber),
EpisodeNumber: intPtr(episodeNumber),
Runtime: runtime,
}
if airDate != nil {
listItem.AirDate = airDate.Format(time.DateOnly)
}
result[contentID] = compatEpisodeTarget{
Item: listItem,
SeriesPosterURL: h.presignCompatImagePath(ctx, seriesPosterPath, "poster"),
SeriesBackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
Item: listItem,
SeriesImages: seriesImageSet{
ContentID: seriesID,
PosterURL: h.presignCompatImagePath(ctx, seriesPosterPath, "poster"),
PosterPath: seriesPosterPath,
PosterThumbhash: seriesPosterTH,
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
BackdropPath: seriesBackdrop,
BackdropThumbhash: seriesBackdropTH,
UpdatedAt: seriesUpdatedAt,
},
}
}
if err := rows.Err(); err != nil {
@@ -385,6 +410,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
BackdropThumbhash: series.BackdropThumbhash,
LogoPath: series.LogoPath,
StillPath: episode.StillPath,
StillThumbhash: episode.StillThumbhash,
UpdatedAt: episode.UpdatedAt,
SeriesID: episode.SeriesID,
SeriesTitle: series.Title,
@@ -396,9 +422,17 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
listItem.AirDate = episode.AirDate.Format(time.DateOnly)
}
result[episode.ContentID] = compatEpisodeTarget{
Item: listItem,
SeriesPosterURL: h.presignCompatImagePath(ctx, series.PosterPath, "poster"),
SeriesBackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"),
Item: listItem,
SeriesImages: seriesImageSet{
ContentID: series.ContentID,
PosterURL: h.presignCompatImagePath(ctx, series.PosterPath, "poster"),
PosterPath: series.PosterPath,
PosterThumbhash: series.PosterThumbhash,
BackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"),
BackdropPath: series.BackdropPath,
BackdropThumbhash: series.BackdropThumbhash,
UpdatedAt: series.UpdatedAt,
},
}
}
+4 -3
View File
@@ -142,9 +142,10 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
libraries := make([]upstreamUserLibrary, 0, len(folders))
for _, f := range folders {
lib := upstreamUserLibrary{
ID: f.ID,
Name: f.Name,
Type: f.Type,
ID: f.ID,
Name: f.Name,
Type: f.Type,
PosterPath: f.PosterPath,
}
if f.PosterPath != "" && s.posterPresigner != nil {
ttl := s.presignTTL
+316
View File
@@ -0,0 +1,316 @@
package jellycompat
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scantrigger"
)
const autoscanTrigger = "jellyfin_autoscan"
type autoscanFolderRepository interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
List(ctx context.Context) ([]*models.MediaFolder, error)
}
type autoscanVirtualFolderFallback interface {
HandleVirtualFolders(w http.ResponseWriter, r *http.Request)
}
type AutoscanHandler struct {
folders autoscanFolderRepository
queue scantrigger.Queuer
codec *ResourceIDCodec
fallback autoscanVirtualFolderFallback
}
func NewAutoscanHandler(
folders autoscanFolderRepository,
queue scantrigger.Queuer,
codec *ResourceIDCodec,
fallback autoscanVirtualFolderFallback,
) *AutoscanHandler {
if codec == nil {
codec = NewResourceIDCodec()
}
return &AutoscanHandler{folders: folders, queue: queue, codec: codec, fallback: fallback}
}
func (h *AutoscanHandler) HandleVirtualFolders(w http.ResponseWriter, r *http.Request) {
if h == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available")
return
}
if !AdminAPIKeyFromContext(r.Context()) {
if h.fallback != nil {
h.fallback.HandleVirtualFolders(w, r)
return
}
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if h.folders == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available")
return
}
folders, err := h.folders.List(r.Context())
if err != nil {
slog.Error("jellycompat autoscan: listing libraries", "error", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to list libraries")
return
}
resp := make([]virtualFolderDTO, 0, len(folders))
for _, folder := range folders {
if folder == nil || !folder.Enabled {
continue
}
resp = append(resp, virtualFolderDTO{
Name: folder.Name,
Locations: folder.Paths,
CollectionType: libraryCollectionType(folder.Type),
ItemID: h.codec.EncodeIntID(EncodedIDLibrary, int64(folder.ID)),
LibraryOptions: virtualLibraryOptDTO{
Enabled: true,
EnableRealtimeMonitor: true,
EnableInternetProviders: true,
SeasonZeroDisplayName: "Specials",
TypeOptions: []string{},
},
})
}
writeJSON(w, http.StatusOK, resp)
}
type mediaUpdatedRequest struct {
Updates []mediaUpdatedEntry `json:"Updates"`
}
type mediaUpdatedEntry struct {
Path string `json:"path"`
UpdateType string `json:"updateType"`
}
func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Request) {
if h == nil || h.folders == nil || h.queue == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available")
return
}
var req mediaUpdatedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid request body")
return
}
if len(req.Updates) == 0 {
writeError(w, http.StatusBadRequest, "BadRequest", "Updates is required")
return
}
resolver := scantrigger.NewResolver(h.folders)
targets := make([]scantrigger.Target, 0, len(req.Updates))
seenTargets := make(map[autoscanTargetKey]struct{}, len(req.Updates))
for _, update := range req.Updates {
path := strings.TrimSpace(update.Path)
if path == "" {
writeError(w, http.StatusBadRequest, "BadRequest", "Update path is required")
return
}
target, err := resolver.Resolve(r.Context(), scantrigger.Request{
Path: path,
Trigger: autoscanTrigger,
})
if err != nil {
if parentTarget, handled, fallbackErr := resolveAutoscanParentTarget(r.Context(), resolver, path, err); handled {
if fallbackErr != nil {
slog.Warn("jellycompat autoscan: media update parent path rejected",
"path", path,
"parent_path", filepath.Dir(filepath.Clean(path)),
"update_type", update.UpdateType,
"error", fallbackErr,
)
writeScanTriggerError(w, fallbackErr)
return
}
if parentTarget != nil {
slog.Debug("jellycompat autoscan: media update falling back to parent scan",
"path", path,
"parent_path", parentTarget.Path,
"parent_mode", parentTarget.Mode,
"update_type", update.UpdateType,
)
targets = appendAutoscanTarget(targets, seenTargets, parentTarget)
}
continue
}
if softAutoscanUpdateError(err) {
slog.Debug("jellycompat autoscan: media update ignored",
"path", path,
"update_type", update.UpdateType,
"error", err,
)
continue
}
slog.Warn("jellycompat autoscan: media update path rejected",
"path", path,
"update_type", update.UpdateType,
"error", err,
)
writeScanTriggerError(w, err)
return
}
targets = appendAutoscanTarget(targets, seenTargets, target)
}
targets = compactAutoscanTargets(targets)
if len(targets) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil {
writeScanTriggerError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type autoscanTargetKey struct {
folderID int
mode string
path string
trigger string
}
func appendAutoscanTarget(
targets []scantrigger.Target,
seen map[autoscanTargetKey]struct{},
target *scantrigger.Target,
) []scantrigger.Target {
if target == nil {
return targets
}
folderID := 0
if target.Folder != nil {
folderID = target.Folder.ID
}
key := autoscanTargetKey{
folderID: folderID,
mode: target.Mode,
path: target.Path,
trigger: target.Trigger,
}
if _, ok := seen[key]; ok {
return targets
}
seen[key] = struct{}{}
return append(targets, *target)
}
func compactAutoscanTargets(targets []scantrigger.Target) []scantrigger.Target {
if len(targets) < 2 {
return targets
}
compacted := make([]scantrigger.Target, 0, len(targets))
for i, target := range targets {
if autoscanTargetCoveredByOther(target, targets, i) {
continue
}
compacted = append(compacted, target)
}
return compacted
}
func autoscanTargetCoveredByOther(target scantrigger.Target, targets []scantrigger.Target, index int) bool {
if target.Folder == nil || target.Mode == scantrigger.ModeLibrary {
return false
}
for i, other := range targets {
if i == index || other.Folder == nil || other.Folder.ID != target.Folder.ID || other.Trigger != target.Trigger {
continue
}
switch other.Mode {
case scantrigger.ModeLibrary:
return true
case scantrigger.ModeSubtree:
if target.Path != "" && scantrigger.PathWithinRoot(target.Path, other.Path) {
return true
}
}
}
return false
}
func resolveAutoscanParentTarget(
ctx context.Context,
resolver *scantrigger.Resolver,
path string,
err error,
) (*scantrigger.Target, bool, error) {
if !parentFallbackAutoscanUpdateError(err) {
return nil, false, nil
}
cleanPath := filepath.Clean(path)
parentPath := filepath.Dir(cleanPath)
if parentPath == "." || parentPath == cleanPath {
return nil, true, nil
}
target, parentErr := resolver.Resolve(ctx, scantrigger.Request{
Path: parentPath,
Trigger: autoscanTrigger,
})
if parentErr == nil {
if target.Mode == scantrigger.ModeLibrary {
return nil, true, nil
}
return target, true, nil
}
if softAutoscanUpdateError(parentErr) {
return nil, true, nil
}
return nil, true, parentErr
}
func parentFallbackAutoscanUpdateError(err error) bool {
var reqErr *scantrigger.RequestError
if !errors.As(err, &reqErr) || reqErr.Status != http.StatusBadRequest {
return false
}
switch reqErr.Message {
case "Path does not exist",
"Path must be a file or directory",
"Unsupported media file extension":
return true
default:
return false
}
}
func softAutoscanUpdateError(err error) bool {
var reqErr *scantrigger.RequestError
if !errors.As(err, &reqErr) || reqErr.Status != http.StatusBadRequest {
return false
}
switch reqErr.Message {
case "No library matches the given path",
"Path does not exist",
"Path must be a file or directory",
"Unsupported media file extension":
return true
default:
return false
}
}
func writeScanTriggerError(w http.ResponseWriter, err error) {
var reqErr *scantrigger.RequestError
if errors.As(err, &reqErr) {
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
return
}
slog.Error("jellycompat autoscan: scan update failed", "error", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to process scan update")
}
@@ -0,0 +1,442 @@
package jellycompat
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scantrigger"
)
type fakeAutoscanFolders struct {
folders []*models.MediaFolder
}
func (f *fakeAutoscanFolders) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
for _, folder := range f.folders {
if folder.ID == id {
return folder, nil
}
}
return nil, catalog.ErrFolderNotFound
}
func (f *fakeAutoscanFolders) List(context.Context) ([]*models.MediaFolder, error) {
return f.folders, nil
}
type fakeAutoscanQueue struct {
calls []queuedScan
batches [][]scantrigger.Target
batchErr error
}
type queuedScan struct {
libraryID int
mode string
path string
trigger string
}
func (q *fakeAutoscanQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) {
q.calls = append(q.calls, queuedScan{libraryID: folderID, mode: mode, path: path, trigger: trigger})
return true, nil
}
func (q *fakeAutoscanQueue) EnqueueScans(_ context.Context, targets []scantrigger.Target) error {
copied := append([]scantrigger.Target(nil), targets...)
q.batches = append(q.batches, copied)
if q.batchErr != nil {
return q.batchErr
}
for _, target := range targets {
folderID := 0
if target.Folder != nil {
folderID = target.Folder.ID
}
q.calls = append(q.calls, queuedScan{
libraryID: folderID,
mode: target.Mode,
path: target.Path,
trigger: target.Trigger,
})
}
return nil
}
func TestAutoscanVirtualFoldersIncludesEnabledLocationsForAdminKey(t *testing.T) {
enabledRoot := t.TempDir()
disabledRoot := t.TempDir()
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{
{ID: 1, Name: "Movies", Type: "movie", Enabled: true, Paths: []string{enabledRoot}},
{ID: 2, Name: "Disabled", Type: "movie", Enabled: false, Paths: []string{disabledRoot}},
}}, nil, NewResourceIDCodec(), nil)
req := httptest.NewRequest(http.MethodGet, "/Library/VirtualFolders", nil)
req = req.WithContext(context.WithValue(req.Context(), adminAPIKeyKey, true))
rec := httptest.NewRecorder()
handler.HandleVirtualFolders(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var got []virtualFolderDTO
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected one enabled library, got %d", len(got))
}
if got[0].Name != "Movies" || len(got[0].Locations) != 1 || got[0].Locations[0] != enabledRoot {
t.Fatalf("unexpected folder response: %#v", got[0])
}
}
func TestAutoscanMediaUpdatedEnqueuesResolvedPath(t *testing.T) {
root := t.TempDir()
filePath := filepath.Join(root, "Movie.mkv")
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
queue := &fakeAutoscanQueue{}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
ID: 3,
Name: "Movies",
Type: "movie",
Enabled: true,
Paths: []string{root},
}}}, queue, NewResourceIDCodec(), nil)
body := []byte(`{"Updates":[{"path":` + strconv.Quote(filePath) + `,"updateType":"Modified"}]}`)
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleMediaUpdated(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
if len(queue.calls) != 1 {
t.Fatalf("expected one queued scan, got %d", len(queue.calls))
}
if len(queue.batches) != 1 {
t.Fatalf("expected one batch enqueue, got %d", len(queue.batches))
}
if queue.calls[0].libraryID != 3 || queue.calls[0].mode != "file" || queue.calls[0].path != filePath || queue.calls[0].trigger != "jellyfin_autoscan" {
t.Fatalf("unexpected queued scan: %#v", queue.calls[0])
}
}
func TestAutoscanMediaUpdatedRejectsAmbiguousLibraryWithoutPartialEnqueue(t *testing.T) {
root := t.TempDir()
ambiguousRoot := t.TempDir()
filePath := filepath.Join(root, "Movie.mkv")
ambiguousPath := filepath.Join(ambiguousRoot, "Other.mkv")
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(ambiguousPath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
queue := &fakeAutoscanQueue{}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{
{
ID: 4,
Name: "Movies",
Type: "movie",
Enabled: true,
Paths: []string{root},
},
{
ID: 5,
Name: "Movies A",
Type: "movie",
Enabled: true,
Paths: []string{ambiguousRoot},
},
{
ID: 6,
Name: "Movies B",
Type: "movie",
Enabled: true,
Paths: []string{ambiguousRoot},
},
}}, queue, NewResourceIDCodec(), nil)
payload := map[string]any{"Updates": []map[string]string{
{"path": filePath, "updateType": "Modified"},
{"path": ambiguousPath, "updateType": "Modified"},
}}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
rec := httptest.NewRecorder()
handler.HandleMediaUpdated(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
if len(queue.calls) != 0 {
t.Fatalf("expected no partial enqueue, got %#v", queue.calls)
}
}
func TestAutoscanMediaUpdatedIgnoresUnsupportedSidecars(t *testing.T) {
root := t.TempDir()
movieDir := filepath.Join(root, "Movie (2024)")
if err := os.Mkdir(movieDir, 0o755); err != nil {
t.Fatal(err)
}
filePath := filepath.Join(movieDir, "Movie.mkv")
nfoPath := filepath.Join(movieDir, "Movie.nfo")
posterPath := filepath.Join(movieDir, "poster.jpg")
for _, path := range []string{filePath, nfoPath, posterPath} {
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
}
queue := &fakeAutoscanQueue{}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
ID: 5,
Name: "Movies",
Type: "movie",
Enabled: true,
Paths: []string{root},
}}}, queue, NewResourceIDCodec(), nil)
payload := map[string]any{"Updates": []map[string]string{
{"path": nfoPath, "updateType": "Modified"},
{"path": filePath, "updateType": "Modified"},
{"path": posterPath, "updateType": "Modified"},
}}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
rec := httptest.NewRecorder()
handler.HandleMediaUpdated(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
if len(queue.calls) != 1 {
t.Fatalf("expected one parent scan, got %#v", queue.calls)
}
if queue.calls[0].libraryID != 5 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
t.Fatalf("unexpected parent scan: %#v", queue.calls[0])
}
}
func TestAutoscanMediaUpdatedSidecarsScanParent(t *testing.T) {
root := t.TempDir()
movieDir := filepath.Join(root, "Movie (2024)")
if err := os.Mkdir(movieDir, 0o755); err != nil {
t.Fatal(err)
}
nfoPath := filepath.Join(movieDir, "Movie.nfo")
posterPath := filepath.Join(movieDir, "poster.jpg")
for _, path := range []string{nfoPath, posterPath} {
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
}
queue := &fakeAutoscanQueue{}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
ID: 6,
Name: "Movies",
Type: "movie",
Enabled: true,
Paths: []string{root},
}}}, queue, NewResourceIDCodec(), nil)
payload := map[string]any{"Updates": []map[string]string{
{"path": nfoPath, "updateType": "Modified"},
{"path": posterPath, "updateType": "Modified"},
}}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
rec := httptest.NewRecorder()
handler.HandleMediaUpdated(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
if len(queue.calls) != 1 {
t.Fatalf("expected one parent scan, got %#v", queue.calls)
}
if queue.calls[0].libraryID != 6 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
t.Fatalf("unexpected parent scan: %#v", queue.calls[0])
}
}
func TestAutoscanMediaUpdatedRootSidecarDoesNotScanLibrary(t *testing.T) {
root := t.TempDir()
anchorPath := filepath.Join(root, ".plexignore")
if err := os.WriteFile(anchorPath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
queue := &fakeAutoscanQueue{}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
ID: 7,
Name: "Movies",
Type: "movie",
Enabled: true,
Paths: []string{root},
}}}, queue, NewResourceIDCodec(), nil)
payload := map[string]any{"Updates": []map[string]string{
{"path": anchorPath, "updateType": "Modified"},
}}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
rec := httptest.NewRecorder()
handler.HandleMediaUpdated(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
if len(queue.calls) != 0 {
t.Fatalf("expected no queued scans, got %#v", queue.calls)
}
if len(queue.batches) != 0 {
t.Fatalf("expected no batch enqueue, got %#v", queue.batches)
}
}
func TestAutoscanMediaUpdatedFallsBackToParentForMissingFiles(t *testing.T) {
root := t.TempDir()
movieDir := filepath.Join(root, "Movie (2024)")
if err := os.Mkdir(movieDir, 0o755); err != nil {
t.Fatal(err)
}
filePath := filepath.Join(movieDir, "Movie.mkv")
missingPath := filepath.Join(movieDir, "pollermovie.mkv")
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
queue := &fakeAutoscanQueue{}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
ID: 8,
Name: "Movies",
Type: "movie",
Enabled: true,
Paths: []string{root},
}}}, queue, NewResourceIDCodec(), nil)
payload := map[string]any{"Updates": []map[string]string{
{"path": missingPath, "updateType": "Modified"},
}}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
rec := httptest.NewRecorder()
handler.HandleMediaUpdated(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
if len(queue.calls) != 1 {
t.Fatalf("expected one queued scan, got %d", len(queue.calls))
}
if queue.calls[0].libraryID != 8 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
t.Fatalf("unexpected queued scan: %#v", queue.calls[0])
}
}
func TestAutoscanMediaUpdatedIgnoresUnmatchedLibraryPaths(t *testing.T) {
root := t.TempDir()
unmatchedRoot := t.TempDir()
filePath := filepath.Join(root, "Movie.mkv")
unmatchedPath := filepath.Join(unmatchedRoot, "Other.mkv")
for _, path := range []string{filePath, unmatchedPath} {
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
}
queue := &fakeAutoscanQueue{}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
ID: 9,
Name: "Movies",
Type: "movie",
Enabled: true,
Paths: []string{root},
}}}, queue, NewResourceIDCodec(), nil)
payload := map[string]any{"Updates": []map[string]string{
{"path": unmatchedPath, "updateType": "Modified"},
{"path": filePath, "updateType": "Modified"},
}}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
rec := httptest.NewRecorder()
handler.HandleMediaUpdated(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
if len(queue.calls) != 1 {
t.Fatalf("expected one queued scan, got %d", len(queue.calls))
}
if queue.calls[0].libraryID != 9 || queue.calls[0].mode != "file" || queue.calls[0].path != filePath || queue.calls[0].trigger != "jellyfin_autoscan" {
t.Fatalf("unexpected queued scan: %#v", queue.calls[0])
}
}
func TestAutoscanMediaUpdatedHidesInternalQueueError(t *testing.T) {
root := t.TempDir()
filePath := filepath.Join(root, "Movie.mkv")
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
queue := &fakeAutoscanQueue{batchErr: errors.New("database password leaked")}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
ID: 10,
Name: "Movies",
Type: "movie",
Enabled: true,
Paths: []string{root},
}}}, queue, NewResourceIDCodec(), nil)
body := []byte(`{"Updates":[{"path":` + strconv.Quote(filePath) + `,"updateType":"Modified"}]}`)
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleMediaUpdated(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", rec.Code, rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte("database password leaked")) {
t.Fatalf("response leaked internal error: %s", rec.Body.String())
}
}
+191 -5
View File
@@ -5,8 +5,11 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// ImagesHandler serves Jellyfin-compatible image routes.
@@ -18,14 +21,35 @@ type ImagesHandler struct {
images *ImageCache
personRepo *catalog.PersonRepository
detailSvc *catalog.DetailService
itemRepo *catalog.ItemRepository
seasonRepo *catalog.SeasonRepository
episodeRepo *catalog.EpisodeRepository
itemRepo imageItemRepository
folderRepo imageFolderRepository
seasonRepo imageSeasonRepository
episodeRepo imageEpisodeRepository
accessFilter AccessFilterResolver
posterSigner LibraryPosterPresigner
presignTTL time.Duration
imageTags *imageTagSigner
}
type imageItemRepository interface {
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
}
type imageSeasonRepository interface {
GetByID(ctx context.Context, contentID string) (*models.Season, error)
}
type imageEpisodeRepository interface {
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
}
type imageFolderRepository interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}
// NewImagesHandler creates an image proxy handler.
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver) *ImagesHandler {
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string) *ImagesHandler {
if httpClient == nil {
httpClient = http.DefaultClient
}
@@ -38,9 +62,13 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient
personRepo: personRepo,
detailSvc: detailSvc,
itemRepo: itemRepo,
folderRepo: folderRepo,
seasonRepo: seasonRepo,
episodeRepo: episodeRepo,
accessFilter: accessFilter,
posterSigner: posterSigner,
presignTTL: presignTTL,
imageTags: newImageTagSigner(imageTagSecret),
}
}
@@ -51,7 +79,23 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
routeID := chiURLParam(r, "id")
imageType := chiURLParam(r, "imageType")
imageSize := compatRequestImageSize(r, imageType)
if imageURL, ok := h.images.LookupSized(routeID, imageType, r.URL.Query().Get("tag"), imageSize); ok {
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
if tag != "" {
imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if ok {
h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt)
h.proxyImageURL(w, r, imageURL.URL)
return
}
if imageURL, ok := h.images.LookupTag(tag); ok {
h.proxyImageURL(w, r, imageURL)
return
}
} else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok {
h.proxyImageURL(w, r, imageURL)
return
}
@@ -202,6 +246,148 @@ func (h *ImagesHandler) resolveItemImageURLFromRepos(ctx context.Context, sessio
return catalog.ResolvedImageURL{}, false, nil
}
func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) {
if h.imageTags == nil || tag == "" {
return catalog.ResolvedImageURL{}, false, nil
}
if libraryID, err := h.codec.DecodeIntID(EncodedIDLibrary, routeID); err == nil {
return h.resolveLibraryImageURLFromTag(ctx, routeID, int(libraryID), imageType, imageSize, tag)
}
contentID, err := decodeContentID(h.codec, routeID)
if err != nil {
return catalog.ResolvedImageURL{}, false, nil
}
return h.resolveItemImageURLFromReposWithoutSession(ctx, routeID, contentID, imageType, imageSize, tag)
}
func (h *ImagesHandler) resolveLibraryImageURLFromTag(ctx context.Context, routeID string, libraryID int, imageType, _ string, tag string) (catalog.ResolvedImageURL, bool, error) {
if imageType != "Primary" || h.folderRepo == nil || h.posterSigner == nil {
return catalog.ResolvedImageURL{}, false, nil
}
folder, err := h.folderRepo.GetByID(ctx, libraryID)
if err != nil {
return catalog.ResolvedImageURL{}, false, nil
}
if folder.PosterPath == "" || !h.imageTags.Equal(
imageTagSeed(routeID, "Primary", compatCardImageSize, folder.PosterPath, "", time.Time{}),
"",
tag,
) {
return catalog.ResolvedImageURL{}, false, nil
}
imageURL := h.presignLibraryPosterURL(ctx, folder.PosterPath)
if imageURL == "" {
return catalog.ResolvedImageURL{}, false, nil
}
return catalog.ResolvedImageURL{URL: imageURL}, true, nil
}
func (h *ImagesHandler) presignLibraryPosterURL(ctx context.Context, posterPath string) string {
if posterPath == "" || h.posterSigner == nil {
return ""
}
ttl := h.presignTTL
if ttl <= 0 {
ttl = 4 * time.Hour
}
imageURL, err := h.posterSigner.PresignGetURL(ctx, h.posterSigner.Bucket(), posterPath, ttl)
if err != nil {
return ""
}
return imageURL
}
func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, routeID, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) {
if h.itemRepo != nil {
if item, err := h.itemRepo.GetByID(ctx, contentID); err == nil {
if imageURL := h.imageURLForItem(ctx, item.PosterPath, "poster", item.BackdropPath, item.LogoPath, imageType, imageSize); imageURL.URL != "" {
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.UpdatedAt, imageURL.URL) {
return catalog.ResolvedImageURL{}, false, nil
}
return imageURL, true, nil
}
} else if !errors.Is(err, catalog.ErrItemNotFound) {
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
}
}
if h.episodeRepo != nil && h.itemRepo != nil {
if episode, err := h.episodeRepo.GetByID(ctx, contentID); err == nil {
series, seriesErr := h.itemRepo.GetByID(ctx, episode.SeriesID)
if seriesErr != nil {
if !errors.Is(seriesErr, catalog.ErrItemNotFound) {
return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr)
}
} else {
if imageURL := h.imageURLForItem(ctx, episode.StillPath, "still", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" {
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, episode.StillPath, episode.StillThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, episode.UpdatedAt, imageURL.URL) {
return catalog.ResolvedImageURL{}, false, nil
}
return imageURL, true, nil
}
}
} else if !errors.Is(err, catalog.ErrEpisodeNotFound) {
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
}
}
if h.seasonRepo != nil && h.itemRepo != nil {
if season, err := h.seasonRepo.GetByID(ctx, contentID); err == nil {
series, seriesErr := h.itemRepo.GetByID(ctx, season.SeriesID)
if seriesErr != nil {
if errors.Is(seriesErr, catalog.ErrItemNotFound) {
return catalog.ResolvedImageURL{}, false, nil
}
return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr)
}
if imageURL := h.imageURLForItem(ctx, season.PosterPath, "poster", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" {
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, season.PosterPath, season.PosterThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, season.UpdatedAt, imageURL.URL) {
return catalog.ResolvedImageURL{}, false, nil
}
return imageURL, true, nil
}
} else if !errors.Is(err, catalog.ErrSeasonNotFound) {
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
}
}
return catalog.ResolvedImageURL{}, false, nil
}
func (h *ImagesHandler) signedImageTagMatches(routeID, contentID, imageType, tag, primaryPath, primaryThumbhash, backdropPath, backdropThumbhash, logoPath string, updatedAt time.Time, resolvedURL string) bool {
var path, thumbhash, tagImageType string
switch imageType {
case "Primary":
path = primaryPath
thumbhash = primaryThumbhash
tagImageType = "Primary"
case "Backdrop", "Thumb":
path = backdropPath
thumbhash = backdropThumbhash
tagImageType = "Backdrop"
case "Logo":
path = logoPath
tagImageType = "Logo"
default:
return false
}
if path != "" && h.imageTags.Equal(
imageTagSeed(contentID, tagImageType, compatCardImageSize, path, thumbhash, updatedAt),
path,
tag,
) {
return true
}
if resolvedURL == "" {
return false
}
return h.imageTags.Equal(
imageTagSeed(routeID, tagImageType, compatCardImageSize, resolvedURL, "", time.Time{}),
resolvedURL,
tag,
)
}
func (h *ImagesHandler) imageURLForItem(ctx context.Context, primaryPath, primaryImageType, backdropPath, logoPath, imageType, size string) catalog.ResolvedImageURL {
primaryURL := compatPresignImageWithExpiry(h.detailSvc, ctx, primaryPath, primaryImageType, size)
backdropURL := compatPresignImageWithExpiry(h.detailSvc, ctx, backdropPath, "backdrop", size)
+23 -39
View File
@@ -212,26 +212,8 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
}
}
if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" {
seriesRouteID := h.codec.EncodeStringID(EncodedIDItem, detail.SeriesID)
cachedPoster, _ := h.images.LookupSized(seriesRouteID, "Primary", "", compatCardImageSize)
cachedBackdrop, _ := h.images.LookupSized(seriesRouteID, "Backdrop", "", compatCardImageSize)
if cachedPoster != "" && cachedBackdrop != "" {
// Both poster and backdrop hit — populate from cache and skip the
// second GetItemDetail call against the parent series. Cache is
// populated by browse/list/recommendation responses for the series.
// Audit 2026-05-01 §3.4. We require BOTH because a partial hit
// (only one URL cached) would silently degrade the response — the
// fallback fetch can populate both.
h.mapper.applySeriesImages(&dto, cachedPoster, cachedBackdrop)
if h.images != nil {
h.images.RememberSized(dto.SeriesID, "Thumb", cachedBackdrop, compatCardImageSize)
}
} else {
// Cache miss or partial — fall back to original series-detail fetch.
seriesImgCache := make(map[string]seriesImageURLs)
h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache)
}
seriesImgCache := make(map[string]seriesImageSet)
h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache)
if detail.SeasonNumber != nil {
season, seasonErr := h.content.GetSeason(r.Context(), session, detail.SeriesID, *detail.SeasonNumber, nil)
if seasonErr == nil && season != nil {
@@ -1910,22 +1892,22 @@ func (h *ItemsHandler) presignCompatImagePath(ctx context.Context, path, imageTy
return compatPresignImage(h.detailSvc, ctx, path, imageType, compatCardImageSize)
}
func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL, seriesPosterURL, seriesBackdropURL string) {
func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL string, series seriesImageSet) {
if h.images == nil {
return
}
h.images.RememberSized(dto.ID, "Primary", stillURL, compatCardImageSize)
h.images.RememberSized(dto.ID, "Backdrop", seriesBackdropURL, compatCardImageSize)
h.images.RememberSized(dto.ID, "Backdrop", series.BackdropURL, compatCardImageSize)
if dto.SeriesID != "" {
h.images.RememberSized(dto.SeriesID, "Primary", seriesPosterURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Backdrop", seriesBackdropURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Thumb", seriesBackdropURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Primary", series.PosterURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Backdrop", series.BackdropURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Thumb", series.BackdropURL, compatCardImageSize)
}
}
func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) {
h.mapper.applySeriesImages(dto, target.SeriesPosterURL, target.SeriesBackdropURL)
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesPosterURL, target.SeriesBackdropURL)
h.mapper.applySeriesImages(dto, target.SeriesImages)
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages)
}
func (h *ItemsHandler) listSeriesEpisodes(ctx context.Context, session *Session, seriesID string, seasons []upstreamSeason, requestedSeasonID string) ([]*models.Episode, error) {
@@ -2164,17 +2146,10 @@ func (h *ItemsHandler) rememberEpisodeImages(episodes []upstreamEpisode) {
}
}
// seriesImageURLs holds poster/backdrop URLs for a series, used to populate
// series image tags on episode DTOs for clients like Infuse.
type seriesImageURLs struct {
posterURL string
backdropURL string
}
// enrichEpisodeSeriesImages looks up the parent series poster/backdrop and
// applies them to an episode DTO. The cache avoids repeated lookups when
// multiple episodes belong to the same series.
func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageURLs) {
func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageSet) {
if seriesContentID == "" || dto.SeriesID == "" {
return
}
@@ -2182,14 +2157,23 @@ func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *S
if !ok {
detail, err := h.content.GetItemDetail(ctx, session, seriesContentID, nil)
if err == nil {
imgs = seriesImageURLs{posterURL: detail.PosterURL, backdropURL: detail.BackdropURL}
imgs = seriesImageSet{
ContentID: detail.ContentID,
PosterURL: detail.PosterURL,
PosterPath: detail.PosterPath,
PosterThumbhash: detail.PosterThumbhash,
BackdropURL: detail.BackdropURL,
BackdropPath: detail.BackdropPath,
BackdropThumbhash: detail.BackdropThumbhash,
UpdatedAt: detail.UpdatedAt,
}
h.rememberDetailImages(*detail)
}
cache[seriesContentID] = imgs
}
h.mapper.applySeriesImages(dto, imgs.posterURL, imgs.backdropURL)
if imgs.backdropURL != "" && h.images != nil {
h.images.RememberSized(dto.SeriesID, "Thumb", imgs.backdropURL, compatCardImageSize)
h.mapper.applySeriesImages(dto, imgs)
if imgs.BackdropURL != "" && h.images != nil {
h.images.RememberSized(dto.SeriesID, "Thumb", imgs.BackdropURL, compatCardImageSize)
}
}
+7 -7
View File
@@ -70,11 +70,11 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
panic("unused")
}
// TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail verifies that when an
// episode detail is requested and the series's poster/backdrop are already in
// the ImageCache (e.g. from a prior browse response), the handler does NOT
// fetch the parent series detail a second time. Audit 2026-05-01 §3.4.
func TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail(t *testing.T) {
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
// that episode detail responses fetch parent series image metadata even when
// image URLs are already cached. Cached URLs are not enough to build stable
// signed tags after Jellycompat restarts.
func TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags(t *testing.T) {
codec := NewResourceIDCodec()
episodeContentID := "ep1"
seriesContentID := "series-1"
@@ -123,8 +123,8 @@ func TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if contentSvc.getItemDetailCalls != 1 {
t.Errorf("expected exactly 1 GetItemDetail (episode only); got %d",
if contentSvc.getItemDetailCalls != 2 {
t.Errorf("expected episode and series GetItemDetail calls for stable parent image tags; got %d",
contentSvc.getItemDetailCalls)
}
}
+9 -3
View File
@@ -94,9 +94,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
}
if tag = strings.TrimSpace(tag); tag != "" {
if url, ok := c.lookupTag(tag); ok {
return url, true
}
return c.LookupTag(tag)
}
if routeID == "" || imageType == "" {
@@ -105,6 +103,14 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
return c.lookupRoute(routeImageKey(routeID, imageType, size))
}
// LookupTag resolves a cached image URL only by its legacy URL-derived tag.
func (c *ImageCache) LookupTag(tag string) (string, bool) {
if c == nil {
return "", false
}
return c.lookupTag(strings.TrimSpace(tag))
}
// lookupTag resolves a tag without size partitioning. Tags are sha1 of the
// presigned URL: for S3-cached paths the size variant is embedded in the URL
// (so different sizes produce different tags), and for HTTP-passthrough URLs
+49
View File
@@ -0,0 +1,49 @@
package jellycompat
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"strings"
)
const imageTagSignatureDomain = "silo:jellycompat:image-tag:v1"
type imageTagSigner struct {
secret []byte
}
func newImageTagSigner(secret string) *imageTagSigner {
if strings.TrimSpace(secret) == "" {
return nil
}
return &imageTagSigner{secret: []byte(secret)}
}
func (s *imageTagSigner) Tag(seed, fallbackURL string) string {
if strings.TrimSpace(seed) == "" {
return tagValue(fallbackURL)
}
if s == nil {
return tagValue(seed)
}
mac := hmac.New(sha256.New, s.secret)
_, _ = mac.Write([]byte(imageTagSignatureDomain))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(seed))
sum := mac.Sum(nil)
return hex.EncodeToString(sum[:8])
}
func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool {
if s == nil {
return false
}
actual = strings.TrimSpace(actual)
expected := s.Tag(seed, fallbackURL)
if expected == "" || actual == "" || len(expected) != len(actual) {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
+307
View File
@@ -1,11 +1,19 @@
package jellycompat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) {
@@ -86,3 +94,302 @@ func TestProxyImageURLForwardsConditionalHeaders(t *testing.T) {
t.Fatalf("status = %d, want 304", rec.Code)
}
}
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("image-bytes"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}}
tag := newMapper(codec, cfg).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "image-bytes" {
t.Fatalf("body = %q, want image bytes", got)
}
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
t.Fatal("signed-tag image URL was not cached after resolution")
}
}
func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: "https://cdn.example.test/poster.jpg",
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
tag := newMapper(codec, &config.Config{}).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
httpClient: http.DefaultClient,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(""),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String())
}
}
func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("backdrop-bytes"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "series-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
secret := "image-secret"
tag := newImageTagSigner(secret).Tag(
imageTagSeed(contentID, "Backdrop", compatCardImageSize, upstream.URL, "", time.Time{}),
upstream.URL,
)
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: NewImageCache(time.Hour, time.Now),
itemRepo: fakeImageItemRepo{item: &models.MediaItem{
ContentID: contentID,
BackdropPath: upstream.URL,
}},
imageTags: newImageTagSigner(secret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Thumb?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Thumb")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "backdrop-bytes" {
t.Fatalf("body = %q, want backdrop bytes", got)
}
}
func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("library-poster"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
libraryID := 1
routeID := codec.EncodeIntID(EncodedIDLibrary, int64(libraryID))
posterPath := "library-posters/1/original.jpg"
secret := "image-secret"
tag := newImageTagSigner(secret).Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, posterPath, "", time.Time{}),
"",
)
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: NewImageCache(time.Hour, time.Now),
folderRepo: fakeImageFolderRepo{folder: &models.MediaFolder{ID: libraryID, PosterPath: posterPath}},
posterSigner: fakeLibraryPosterPresigner{url: upstream.URL},
imageTags: newImageTagSigner(secret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "library-poster" {
t.Fatalf("body = %q, want library poster", got)
}
}
func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("cached-image"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
routeID := codec.EncodeStringID(EncodedIDItem, "movie-1")
cache := NewImageCache(time.Hour, time.Now)
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: cache,
imageTags: newImageTagSigner("image-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "cached-image" {
t.Fatalf("body = %q, want cached image", got)
}
}
func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte("stale-image"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cache := NewImageCache(time.Hour, func() time.Time { return updatedAt })
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
tag := newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: "old-secret"},
}).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: cache,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner("new-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String())
}
if called {
t.Fatal("served cached image before validating the signed tag")
}
}
type fakeImageItemRepo struct {
item *models.MediaItem
}
func (r fakeImageItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
if r.item != nil && r.item.ContentID == contentID {
return r.item, nil
}
return nil, catalog.ErrItemNotFound
}
func (r fakeImageItemRepo) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
return nil
}
type fakeImageFolderRepo struct {
folder *models.MediaFolder
}
func (r fakeImageFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
if r.folder != nil && r.folder.ID == id {
return r.folder, nil
}
return nil, catalog.ErrFolderNotFound
}
type fakeLibraryPosterPresigner struct {
url string
}
func (p fakeLibraryPosterPresigner) PresignGetURL(context.Context, string, string, time.Duration) (string, error) {
return p.url, nil
}
func (p fakeLibraryPosterPresigner) Bucket() string {
return "test-bucket"
}
func withImageRouteParams(r *http.Request, routeID, imageType string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", routeID)
routeCtx.URLParams.Add("imageType", imageType)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))
}
+57 -31
View File
@@ -23,26 +23,33 @@ var allDetailFields = map[string]bool{
}
type mapper struct {
codec *ResourceIDCodec
serverID string
codec *ResourceIDCodec
serverID string
imageTagSigner *imageTagSigner
}
func newMapper(codec *ResourceIDCodec, cfg *config.Config) *mapper {
serverID := ""
imageTagSecret := ""
if cfg != nil {
serverID = cfg.JellyfinCompat.ServerID
imageTagSecret = cfg.Auth.JWTSecret
}
return &mapper{codec: codec, serverID: serverID}
return &mapper{codec: codec, serverID: serverID, imageTagSigner: newImageTagSigner(imageTagSecret)}
}
func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO {
imgTags := map[string]string{}
if library.PosterURL != "" {
imgTags["Primary"] = tagValue(library.PosterURL)
routeID := m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID))
if library.PosterPath != "" {
imgTags["Primary"] = m.imageTagSigner.Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}),
library.PosterURL,
)
}
return baseItemDTO{
ID: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
ID: routeID,
Type: "CollectionFolder",
MediaType: "Unknown",
IsFolder: true,
@@ -52,8 +59,8 @@ func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO {
SortName: strings.ToLower(library.Name),
ImageTags: imgTags,
UserData: &itemUserDataDTO{
Key: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
ItemID: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
Key: routeID,
ItemID: routeID,
},
}
}
@@ -102,13 +109,14 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
dto.ChildCount = *item.SeasonCount
dto.RecursiveItemCount = *item.SeasonCount
}
if tags := imageTagsWithSeed(
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash, item.UpdatedAt),
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, primaryPath, primaryThumbhash, item.UpdatedAt),
item.PosterURL,
); tags != nil {
dto.ImageTags = tags
}
if tags := backdropTagsWithSeed(
if tags := backdropTagsWithSeed(m.imageTagSigner,
imageTagSeed(item.ContentID, "Backdrop", compatCardImageSize, item.BackdropPath, item.BackdropThumbhash, item.UpdatedAt),
item.BackdropURL,
); tags != nil {
@@ -397,7 +405,7 @@ func (m *mapper) seasonFromUpstream(season upstreamSeason, seriesID string, isFa
RecursiveItemCount: season.EpisodeCount,
}
dto.IndexNumber = &season.SeasonNumber
if tags := imageTagsWithSeed(
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(season.ContentID, "Primary", compatCardImageSize, season.PosterPath, season.PosterThumbhash, season.UpdatedAt),
season.PosterURL,
); tags != nil {
@@ -430,7 +438,7 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre
dto.SeasonID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID)
dto.ParentID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID)
}
if tags := imageTagsWithSeed(
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(ep.ContentID, "Primary", compatCardImageSize, ep.StillPath, ep.StillThumbhash, ep.UpdatedAt),
ep.StillURL,
); tags != nil {
@@ -439,19 +447,37 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre
return dto
}
type seriesImageSet struct {
ContentID string
PosterURL string
PosterPath string
PosterThumbhash string
BackdropURL string
BackdropPath string
BackdropThumbhash string
UpdatedAt time.Time
}
// applySeriesImages sets series/parent image tags on an episode DTO so clients
// can display the series poster and backdrop in Continue Watching / Next Up.
func (m *mapper) applySeriesImages(dto *baseItemDTO, seriesPosterURL, seriesBackdropURL string) {
func (m *mapper) applySeriesImages(dto *baseItemDTO, series seriesImageSet) {
if dto.SeriesID == "" {
return
}
if seriesPosterURL != "" {
dto.SeriesPrimaryImageTag = tagValue(seriesPosterURL)
if series.PosterURL != "" {
dto.SeriesPrimaryImageTag = m.imageTagSigner.Tag(
imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, series.UpdatedAt),
series.PosterURL,
)
}
if seriesBackdropURL != "" {
dto.ParentBackdropImageTags = backdropTags(seriesBackdropURL)
if series.BackdropURL != "" {
tag := m.imageTagSigner.Tag(
imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, series.UpdatedAt),
series.BackdropURL,
)
dto.ParentBackdropImageTags = []string{tag}
dto.ParentBackdropItemID = dto.SeriesID
dto.ParentThumbImageTag = tagValue(seriesBackdropURL)
dto.ParentThumbImageTag = tag
dto.ParentThumbItemID = dto.SeriesID
}
}
@@ -638,22 +664,29 @@ func resumePositionTicks(position, duration float64, played bool) int64 {
return secondsToTicks(position)
}
func imageTagsWithSeed(seed, imageURL string) map[string]string {
func imageTagsWithSeed(signer *imageTagSigner, seed, imageURL string) map[string]string {
if imageURL == "" {
return nil
}
return map[string]string{"Primary": imageTagValue(seed, imageURL)}
return map[string]string{"Primary": signer.Tag(seed, imageURL)}
}
func backdropTags(imageURL string) []string {
return backdropTagsWithSeed("", imageURL)
return backdropTagsWithSeed(nil, "", imageURL)
}
func backdropTagsWithSeed(seed, imageURL string) []string {
func backdropTagsWithSeed(signer *imageTagSigner, seed, imageURL string) []string {
if imageURL == "" {
return nil
}
return []string{imageTagValue(seed, imageURL)}
return []string{signer.Tag(seed, imageURL)}
}
func listItemPrimaryImageSeedParts(item upstreamListItem) (string, string) {
if item.Type == "episode" && item.StillPath != "" {
return item.StillPath, item.StillThumbhash
}
return firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash
}
func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt time.Time) string {
@@ -675,13 +708,6 @@ func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt
return strings.Join(parts, "\x00")
}
func imageTagValue(seed, fallbackURL string) string {
if seed != "" {
return tagValue(seed)
}
return tagValue(fallbackURL)
}
func tagValue(raw string) string {
if raw == "" {
return ""
+130
View File
@@ -55,3 +55,133 @@ func TestItemImageTagsFallbackToURLWhenCanonicalSeedMissing(t *testing.T) {
t.Fatalf("fallback image tag did not change with URL: %q", first.ImageTags["Primary"])
}
}
func TestItemImageTagsUseConfiguredSecret(t *testing.T) {
item := upstreamListItem{
ContentID: "movie-1",
Type: "movie",
Title: "Movie",
PosterURL: "https://cdn.example.test/poster.jpg?sig=one",
PosterPath: "metadb://poster/movie-1",
PosterThumbhash: "thumbhash",
UpdatedAt: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC),
}
first := newMapper(NewResourceIDCodec(), &config.Config{
Auth: config.AuthConfig{JWTSecret: "secret-one"},
}).itemFromList(item, false, nil, nil)
second := newMapper(NewResourceIDCodec(), &config.Config{
Auth: config.AuthConfig{JWTSecret: "secret-two"},
}).itemFromList(item, false, nil, nil)
if first.ImageTags["Primary"] == second.ImageTags["Primary"] {
t.Fatalf("signed image tag did not change with configured secret: %q", first.ImageTags["Primary"])
}
}
func TestEpisodeListImageTagsUseStillThumbhash(t *testing.T) {
secret := "image-secret"
updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
item := upstreamListItem{
ContentID: "episode-1",
Type: "episode",
Title: "Episode",
PosterURL: "https://cdn.example.test/still.jpg?sig=one",
PosterPath: "metadb://still/episode-1",
PosterThumbhash: "poster-thumbhash",
StillPath: "metadb://still/episode-1",
StillThumbhash: "still-thumbhash",
UpdatedAt: updatedAt,
}
dto := newMapper(NewResourceIDCodec(), &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).itemFromList(item, false, nil, nil)
expected := newImageTagSigner(secret).Tag(
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, item.StillPath, item.StillThumbhash, updatedAt),
item.PosterURL,
)
if dto.ImageTags["Primary"] != expected {
t.Fatalf("primary tag = %q, want still-thumbhash seed %q", dto.ImageTags["Primary"], expected)
}
}
func TestLibraryImageTagsUseStablePosterPath(t *testing.T) {
secret := "image-secret"
codec := NewResourceIDCodec()
library := upstreamUserLibrary{
ID: 1,
Name: "Movies",
Type: "movies",
PosterURL: "https://cdn.example.test/library.jpg?sig=one",
PosterPath: "library-posters/1/original.jpg",
}
first := newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).viewFromLibrary(library)
library.PosterURL = "https://cdn.example.test/library.jpg?sig=two"
second := newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).viewFromLibrary(library)
routeID := codec.EncodeIntID(EncodedIDLibrary, int64(library.ID))
expected := newImageTagSigner(secret).Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}),
library.PosterURL,
)
if first.ImageTags["Primary"] == "" {
t.Fatal("library primary image tag is empty")
}
if first.ImageTags["Primary"] != second.ImageTags["Primary"] {
t.Fatalf("library tag changed when only signed URL changed: %q vs %q", first.ImageTags["Primary"], second.ImageTags["Primary"])
}
if second.ImageTags["Primary"] != expected {
t.Fatalf("library tag = %q, want %q", second.ImageTags["Primary"], expected)
}
}
func TestApplySeriesImagesUsesCanonicalSeriesSeeds(t *testing.T) {
secret := "image-secret"
codec := NewResourceIDCodec()
seriesContentID := "series-1"
seriesRouteID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
dto := baseItemDTO{SeriesID: seriesRouteID}
series := seriesImageSet{
ContentID: seriesContentID,
PosterURL: "https://cdn.example.test/poster.jpg?sig=one",
PosterPath: "metadb://poster/series-1",
PosterThumbhash: "poster-thumbhash",
BackdropURL: "https://cdn.example.test/backdrop.jpg?sig=one",
BackdropPath: "metadb://backdrop/series-1",
BackdropThumbhash: "backdrop-thumbhash",
UpdatedAt: updatedAt,
}
newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).applySeriesImages(&dto, series)
signer := newImageTagSigner(secret)
expectedPrimary := signer.Tag(
imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, updatedAt),
series.PosterURL,
)
expectedBackdrop := signer.Tag(
imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, updatedAt),
series.BackdropURL,
)
if dto.SeriesPrimaryImageTag != expectedPrimary {
t.Fatalf("SeriesPrimaryImageTag = %q, want %q", dto.SeriesPrimaryImageTag, expectedPrimary)
}
if len(dto.ParentBackdropImageTags) != 1 || dto.ParentBackdropImageTags[0] != expectedBackdrop {
t.Fatalf("ParentBackdropImageTags = %#v, want [%q]", dto.ParentBackdropImageTags, expectedBackdrop)
}
if dto.ParentThumbImageTag != expectedBackdrop {
t.Fatalf("ParentThumbImageTag = %q, want %q", dto.ParentThumbImageTag, expectedBackdrop)
}
}
@@ -33,6 +33,10 @@ func (r fakeSubtitleRepository) ListDownloadedSubtitles(_ context.Context, media
return r.downloaded[mediaFileID], nil
}
func (r fakeSubtitleRepository) UpdateDownloadedSubtitle(context.Context, int, subtitles.SubtitleMetadataUpdate) (*subtitles.DownloadedSubtitle, error) {
panic("unused")
}
func (r fakeSubtitleRepository) DeleteDownloadedSubtitle(context.Context, int) (*subtitles.DownloadedSubtitle, error) {
panic("unused")
}
+17 -2
View File
@@ -73,6 +73,16 @@ func NewRouter(deps Dependencies) chi.Router {
}
itemsHandler := NewItemsHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.ImageCache, nextUpRepo, deps.BrowseRepo, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.EpisodeRepo, deps.AccessFilterFn, subtitleRepo)
itemsHandler.recommender = deps.Recommender
autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler)
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader)
autoscanVirtualFoldersRegistered := false
if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil {
r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)).
Get("/Library/VirtualFolders", autoscanHandler.HandleVirtualFolders)
r.With(adminAPIKeyAuth.RequireAdminAPIKey).
Post("/Library/Media/Updated", autoscanHandler.HandleMediaUpdated)
autoscanVirtualFoldersRegistered = true
}
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
if deps.DB != nil {
@@ -88,7 +98,7 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.S3Client = deps.S3Client
playbackHandler.S3Bucket = deps.S3Bucket
}
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn)
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret)
displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider)
recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn)
@@ -120,7 +130,9 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/Users/{id}", authHandler.HandleUserByID)
r.Get("/UserViews", itemsHandler.HandleViews)
r.Get("/UserViews/GroupingOptions", itemsHandler.HandleGroupingOptionsStub)
r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders)
if !autoscanVirtualFoldersRegistered {
r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders)
}
r.Get("/Users/{userId}/Views", itemsHandler.HandleViews)
r.Get("/Items", itemsHandler.HandleItems)
r.Get("/Users/{id}/Items", itemsHandler.HandleItems)
@@ -216,6 +228,9 @@ func withDefaults(deps Dependencies) Dependencies {
if deps.Now == nil {
deps.Now = timeNow
}
if deps.JWTSecret == "" && deps.Config != nil {
deps.JWTSecret = deps.Config.Auth.JWTSecret
}
if deps.TokenGenerator == nil {
deps.TokenGenerator = uuidNewString
}
+6
View File
@@ -15,6 +15,7 @@ import (
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/recommendations"
"github.com/Silo-Server/silo-server/internal/scantrigger"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -41,6 +42,11 @@ type Dependencies struct {
UserDataService UserDataService
AuthService *auth.Service
// Autoscan / admin compatibility support.
APIKeyValidator apiKeyValidator
APIKeyUserLoader apiKeyUserLoader
ScanQueue scantrigger.Queuer
// Catalog repos (for ContentService construction)
BrowseRepo *catalog.BrowseRepository
ItemRepo *catalog.ItemRepository
+6 -4
View File
@@ -11,10 +11,11 @@ import (
// catalog/service layer and the Jellyfin DTO mapping layer.
type upstreamUserLibrary struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
PosterURL string `json:"poster_url,omitempty"`
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
PosterURL string `json:"poster_url,omitempty"`
PosterPath string `json:"-"`
}
type upstreamListItem struct {
@@ -36,6 +37,7 @@ type upstreamListItem struct {
BackdropThumbhash string `json:"-"`
LogoPath string `json:"-"`
StillPath string `json:"-"`
StillThumbhash string `json:"-"`
UpdatedAt time.Time `json:"-"`
SeasonCount *int `json:"season_count,omitempty"`
SeriesID string `json:"series_id,omitempty"`
+5 -1
View File
@@ -403,7 +403,7 @@ func scopeMatchPaths(folder *models.MediaFolder, mode scopeMode, scopePath strin
}
func shouldWaitForTVQueueSettle(folder *models.MediaFolder, scanResult *scanner.ScanResult) bool {
if folder == nil || !isTVLibraryType(folder.Type) {
if folder == nil || (!isTVLibraryType(folder.Type) && !isMixedLibraryType(folder.Type)) {
return false
}
if scanResult == nil {
@@ -421,6 +421,10 @@ func isTVLibraryType(libraryType string) bool {
}
}
func isMixedLibraryType(libraryType string) bool {
return strings.ToLower(strings.TrimSpace(libraryType)) == "mixed"
}
func (e *Executor) scan(ctx context.Context, folder *models.MediaFolder, mode scopeMode, scopePath string) ([]string, *scanner.ScanResult, error) {
switch mode {
case scopeModeLibrary:
+197 -17
View File
@@ -4,6 +4,7 @@ import (
"context"
"math"
"sort"
"strconv"
"strings"
"unicode"
@@ -23,6 +24,7 @@ type MatchCandidate struct {
Overview string `json:"overview,omitempty"`
Sources []string `json:"sources"`
AgreementHints []string `json:"agreement_hints"`
DetailScore int `json:"-"`
}
var canonicalCandidateIDKeys = []string{"tmdb", "tvdb", "imdb"}
@@ -53,6 +55,106 @@ func providerIDRichness(ids map[string]string) int {
return score
}
const (
minimumDetailTieBreakScore = 20
minimumDetailTieBreakGap = 12
)
func duplicateTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
if hints == nil || len(scoredCandidates) < 2 {
return nil, false
}
best := scoredCandidates[0]
contenders := []scoredMatchCandidate{best}
for i := 1; i < len(scoredCandidates); i++ {
next := scoredCandidates[i]
if best.score-next.score >= 15 {
break
}
if duplicateTieBreakComparable(hints, best.candidate, next.candidate) {
contenders = append(contenders, next)
}
}
if len(contenders) < 2 {
return nil, false
}
sort.SliceStable(contenders, func(i, j int) bool {
return contenders[i].candidate.DetailScore > contenders[j].candidate.DetailScore
})
if contenders[0].candidate.DetailScore < minimumDetailTieBreakScore {
return nil, false
}
if contenders[0].candidate.DetailScore-contenders[1].candidate.DetailScore < minimumDetailTieBreakGap {
return nil, false
}
return &contenders[0].candidate, true
}
func duplicateTieBreakComparable(hints *MatchHints, left, right MatchCandidate) bool {
if hints == nil {
return false
}
if hints.Year == 0 || left.Year == 0 || right.Year == 0 {
return false
}
if left.Year != hints.Year || right.Year != hints.Year || left.Year != right.Year {
return false
}
if !candidateTypeMatchesHint(hints.Type, left.ContentType) ||
!candidateTypeMatchesHint(hints.Type, right.ContentType) {
return false
}
if strings.TrimSpace(left.ContentType) != "" &&
strings.TrimSpace(right.ContentType) != "" &&
!strings.EqualFold(left.ContentType, right.ContentType) {
return false
}
if inferTitleSimilarity(left.Title, right.Title, hints.Year) != 1 {
return false
}
if inferTitleSimilarity(hints.Title, left.Title, hints.Year) != 1 {
return false
}
if inferTitleSimilarity(hints.Title, right.Title, hints.Year) != 1 {
return false
}
return samePrimaryProvider(left.ProviderIDs, right.ProviderIDs)
}
func candidateTypeMatchesHint(hintType, candidateType string) bool {
hintType = strings.ToLower(strings.TrimSpace(hintType))
candidateType = strings.ToLower(strings.TrimSpace(candidateType))
if hintType == "" || candidateType == "" {
return true
}
if hintType == candidateType {
return true
}
return isMovieTypeAlias(hintType) && isMovieTypeAlias(candidateType)
}
func isMovieTypeAlias(value string) bool {
switch value {
case "movie", "movies":
return true
default:
return false
}
}
func samePrimaryProvider(left, right map[string]string) bool {
for _, key := range canonicalCandidateIDKeys {
leftValue := strings.TrimSpace(left[key])
rightValue := strings.TrimSpace(right[key])
if leftValue != "" && rightValue != "" {
return true
}
}
return false
}
// normalizedKey returns a stable grouping key from provider IDs.
// Results with identical provider ID fingerprints (the exact set of
// tmdb/tvdb/imdb key=value pairs) are considered the same candidate.
@@ -211,13 +313,12 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
score += float64(len(candidate.Sources) * 12)
hintTitle := normalizeCandidateTitle(hints.Title)
candidateTitle := normalizeCandidateTitle(candidate.Title)
if hintTitle != "" && candidateTitle != "" {
if hintTitle == candidateTitle {
if strings.TrimSpace(hints.Title) != "" && strings.TrimSpace(candidate.Title) != "" {
titleSimilarity := inferTitleSimilarity(hints.Title, candidate.Title, hints.Year)
if titleSimilarity == 1 {
score += 45
} else {
score += inferTitleSimilarity(hints.Title, candidate.Title) * 35
score += titleSimilarity * 35
}
}
@@ -236,18 +337,19 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
return score
}
type scoredMatchCandidate struct {
candidate MatchCandidate
score float64
}
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) {
if len(candidates) == 0 {
return nil, false
}
type scored struct {
candidate MatchCandidate
score float64
}
scoredCandidates := make([]scored, 0, len(candidates))
scoredCandidates := make([]scoredMatchCandidate, 0, len(candidates))
for _, candidate := range candidates {
scoredCandidates = append(scoredCandidates, scored{
scoredCandidates = append(scoredCandidates, scoredMatchCandidate{
candidate: candidate,
score: scoreMatchCandidate(hints, candidate),
})
@@ -274,11 +376,76 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
return &best.candidate, true
}
if best.score-scoredCandidates[1].score < 15 {
return nil, false
if winner, ok := duplicateTieBreakWinner(hints, scoredCandidates); ok {
return winner, true
}
return providerOrderExactTieBreakWinner(hints, scoredCandidates)
}
return &best.candidate, true
}
func providerOrderExactTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
if hints == nil || len(scoredCandidates) < 2 {
return nil, false
}
best := scoredCandidates[0]
contenders := []scoredMatchCandidate{best}
for i := 1; i < len(scoredCandidates); i++ {
next := scoredCandidates[i]
if best.score-next.score >= 15 {
break
}
contenders = append(contenders, next)
}
if len(contenders) < 2 {
return nil, false
}
seenPrimaryProviders := make(map[string]struct{}, len(contenders))
for _, contender := range contenders {
if !exactTitleYearTypeMatch(hints, contender.candidate) {
return nil, false
}
primaryProvider := candidatePrimaryProvider(contender.candidate)
if primaryProvider == "" {
return nil, false
}
if _, exists := seenPrimaryProviders[primaryProvider]; exists {
return nil, false
}
seenPrimaryProviders[primaryProvider] = struct{}{}
}
return &best.candidate, true
}
func exactTitleYearTypeMatch(hints *MatchHints, candidate MatchCandidate) bool {
if hints == nil || hints.Year == 0 || candidate.Year == 0 {
return false
}
if candidate.Year != hints.Year {
return false
}
if !candidateTypeMatchesHint(hints.Type, candidate.ContentType) {
return false
}
return inferTitleSimilarity(hints.Title, candidate.Title, hints.Year) == 1
}
func candidatePrimaryProvider(candidate MatchCandidate) string {
for _, key := range canonicalCandidateIDKeys {
if strings.TrimSpace(candidate.ProviderIDs[key]) != "" {
return key
}
}
if len(candidate.Sources) == 1 {
return strings.TrimSpace(candidate.Sources[0])
}
return ""
}
func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchCandidate) (*MatchCandidate, bool) {
if existing == nil || len(candidates) == 0 {
return nil, false
@@ -345,17 +512,17 @@ func normalizeCandidateTitle(title string) string {
return strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
}
func inferTitleSimilarity(left, right string) float64 {
leftNorm := normalizeCandidateTitle(left)
rightNorm := normalizeCandidateTitle(right)
func inferTitleSimilarity(left, right string, year int) float64 {
leftNorm := normalizeCandidateTitleForYear(left, year)
rightNorm := normalizeCandidateTitleForYear(right, year)
if leftNorm == "" || rightNorm == "" {
return 0
}
if leftNorm == rightNorm {
return 1
}
leftComparable := strings.Join(strings.Fields(normalizeTitleForScoring(left)), " ")
rightComparable := strings.Join(strings.Fields(normalizeTitleForScoring(right)), " ")
leftComparable := strings.Join(strings.Fields(normalizeTitleForScoring(leftNorm)), " ")
rightComparable := strings.Join(strings.Fields(normalizeTitleForScoring(rightNorm)), " ")
if leftComparable == rightComparable {
return 1
}
@@ -365,6 +532,19 @@ func inferTitleSimilarity(left, right string) float64 {
return 0
}
func normalizeCandidateTitleForYear(title string, year int) string {
normalized := normalizeCandidateTitle(title)
if normalized == "" || year == 0 {
return normalized
}
yearText := strconv.Itoa(year)
fields := strings.Fields(normalized)
if len(fields) <= 1 || fields[len(fields)-1] != yearText {
return normalized
}
return strings.Join(fields[:len(fields)-1], " ")
}
func normalizeTitleForScoring(title string) string {
title = naming.StripComparisonSafeEditionSuffix(title)
title = strings.ToLower(strings.TrimSpace(title))
+284 -4
View File
@@ -6,12 +6,43 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)
func TestTrustedIDValue_UsesMetadbContentID(t *testing.T) {
func TestSelectInitialMatchCandidate_IgnoresLocalContentIDForTrustedSelection(t *testing.T) {
t.Parallel()
hints := &MatchHints{ContentID: "meta-123"}
if got := trustedIDValue(hints, "metadb"); got != "meta-123" {
t.Fatalf("trustedIDValue(metadb) = %q, want meta-123", got)
winner, ok := selectInitialMatchCandidate(
&MatchHints{
ContentID: "local-skeleton-id",
Title: "AEW Worlds End",
Year: 2023,
Type: "movie",
},
[]MatchCandidate{
{
Title: "AEW Worlds End",
Year: 2023,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1217341"},
Sources: []string{"tmdb"},
},
},
)
if !ok || winner == nil {
t.Fatal("expected local content_id not to force trusted-ID matching")
}
}
func TestSuppressTitleYearFallbackForTrustedIDs_IgnoresMetadb(t *testing.T) {
t.Parallel()
query := suppressTitleYearFallbackForTrustedIDs(SearchQuery{
Title: "AEW Worlds End",
Year: 2023,
ContentType: "movie",
ProviderIDs: map[string]string{"metadb": "local-skeleton-id"},
})
if query.Title != "AEW Worlds End" || query.Year != 2023 {
t.Fatalf("title/year were suppressed for metadb: title=%q year=%d", query.Title, query.Year)
}
}
@@ -357,6 +388,255 @@ func TestSelectInitialMatchCandidate_AcceptsSinglePunctuationEquivalentCandidate
}
}
func TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "AEW Worlds End",
Year: 2023,
Type: "movie",
},
[]MatchCandidate{
{
Title: "AEW Worlds End 2023",
Year: 2023,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1217341"},
Sources: []string{"tmdb"},
},
{
Title: "AEW Worlds End 2023: Zero Hour",
Year: 2023,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1217342"},
Sources: []string{"tmdb"},
},
},
)
if !ok || winner == nil {
t.Fatal("expected provider title with repeated release year to be accepted")
}
if winner.Title != "AEW Worlds End 2023" {
t.Fatalf("winner.Title = %q, want AEW Worlds End 2023", winner.Title)
}
}
func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "movie",
},
[]MatchCandidate{
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1558410"},
Sources: []string{"tmdb"},
DetailScore: 18,
},
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
Sources: []string{"tmdb"},
DetailScore: 46,
},
},
)
if !ok || winner == nil {
t.Fatal("expected richer duplicate TMDB candidate to be accepted")
}
if got := winner.ProviderIDs["tmdb"]; got != "17508" {
t.Fatalf("winner tmdb = %q, want 17508", got)
}
}
func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "movie",
},
[]MatchCandidate{
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1558410"},
Sources: []string{"tmdb"},
DetailScore: 28,
},
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "17508"},
Sources: []string{"tmdb"},
DetailScore: 34,
},
},
)
if ok || winner != nil {
t.Fatal("expected duplicate tie without clear detail gap to remain unmatched")
}
}
func TestSelectInitialMatchCandidate_UsesProviderOrderForExactCrossProviderTie(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "100 Days Wild",
Year: 2020,
Type: "series",
},
[]MatchCandidate{
{
Title: "100 Days Wild",
Year: 2020,
ContentType: "series",
ProviderIDs: map[string]string{"tvdb": "383893"},
Sources: []string{"tvdb"},
},
{
Title: "100 Days Wild",
Year: 2020,
ContentType: "series",
ProviderIDs: map[string]string{"tmdb": "109792"},
Sources: []string{"tmdb"},
},
},
)
if !ok || winner == nil {
t.Fatal("expected exact cross-provider tie to use provider order")
}
if got := winner.ProviderIDs["tvdb"]; got != "383893" {
t.Fatalf("winner tvdb = %q, want 383893", got)
}
}
func TestSelectInitialMatchCandidate_ProviderOrderTieRequiresExactTitleYear(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "100 Days Wild",
Year: 2020,
Type: "series",
},
[]MatchCandidate{
{
Title: "100 Days Wild",
Year: 2020,
ContentType: "series",
ProviderIDs: map[string]string{"tvdb": "383893"},
Sources: []string{"tvdb"},
},
{
Title: "Step Brothers",
Year: 2020,
ContentType: "series",
ProviderIDs: map[string]string{"tmdb": "109792", "imdb": "tt1234567"},
Sources: []string{"imdb", "metadb", "tmdb", "xattr"},
},
},
)
if ok || winner != nil {
t.Fatal("expected non-equivalent cross-provider tie to remain unmatched")
}
}
func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "movie",
},
[]MatchCandidate{
{
Title: "UFC 4: Revenge of the Warriors Event",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "17508"},
Sources: []string{"tmdb"},
DetailScore: 22,
},
{
Title: "UFC 4 Revenge of the Warriors Bonus",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "999999", "imdb": "tt9999999"},
Sources: []string{"imdb", "tmdb"},
DetailScore: 80,
},
},
)
if ok || winner != nil {
t.Fatal("expected richer different-title candidate to be rejected")
}
}
func TestSelectInitialMatchCandidate_DetailScoreRequiresDatedDuplicateCandidates(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "movie",
},
[]MatchCandidate{
{
Title: "UFC 4: Revenge of the Warriors",
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1558410"},
Sources: []string{"tmdb"},
DetailScore: 18,
},
{
Title: "UFC 4: Revenge of the Warriors",
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
Sources: []string{"tmdb"},
DetailScore: 46,
},
},
)
if ok || winner != nil {
t.Fatal("expected duplicate detail tie-breaker to reject candidates without matching years")
}
}
func TestSelectInitialMatchCandidate_DetailScoreRequiresHintCompatibleType(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
Title: "UFC 4 Revenge of the Warriors",
Year: 1994,
Type: "series",
},
[]MatchCandidate{
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "1558410"},
Sources: []string{"tmdb"},
DetailScore: 18,
},
{
Title: "UFC 4: Revenge of the Warriors",
Year: 1994,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
Sources: []string{"tmdb"},
DetailScore: 46,
},
},
)
if ok || winner != nil {
t.Fatal("expected duplicate detail tie-breaker to reject candidates with hint-incompatible type")
}
}
func TestSelectInitialMatchCandidate_RejectsWeakSingleCandidate(t *testing.T) {
winner, ok := selectInitialMatchCandidate(
&MatchHints{
+32 -8
View File
@@ -74,7 +74,10 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.id = $1
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('movie', 'movies')
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
@@ -98,7 +101,10 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.id = q.media_file_id
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('movie', 'movies')
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
@@ -146,7 +152,10 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('movie', 'movies')
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
@@ -170,7 +179,10 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.id = q.media_file_id
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('movie', 'movies')
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
@@ -223,7 +235,10 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('movie', 'movies')
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL
AND (
mf.file_path = $2 OR
@@ -267,7 +282,10 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.id = q.media_file_id
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('movie', 'movies')
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
@@ -301,7 +319,10 @@ func (r *MovieMatchQueueRepository) Claim(ctx context.Context, limit int) ([]*mo
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE q.available_at <= NOW()
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('movie', 'movies')
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
@@ -369,7 +390,10 @@ func (r *MovieMatchQueueRepository) ClaimByFolderAndPathPrefix(
WHERE q.media_folder_id = $1
AND q.available_at <= NOW()
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('movie', 'movies')
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL
AND (
mf.file_path = $2 OR
@@ -115,7 +115,10 @@ func (r *SeriesRootMatchQueueRepository) EnqueueSeriesRoot(ctx context.Context,
WHERE mf.media_folder_id = q.media_folder_id
AND mf.observed_root_path = q.observed_root_path
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
)
AND mf.missing_since IS NULL
AND mf.observed_root_path <> ''
AND (
@@ -164,7 +167,10 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
)
AND mf.missing_since IS NULL
AND mf.observed_root_path IS NOT NULL
AND mf.observed_root_path <> ''
@@ -191,7 +197,10 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold
WHERE mf.media_folder_id = q.media_folder_id
AND mf.observed_root_path = q.observed_root_path
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
)
AND mf.missing_since IS NULL
AND mf.observed_root_path <> ''
AND (
@@ -238,7 +247,10 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
)
AND mf.missing_since IS NULL
AND mf.observed_root_path IS NOT NULL
AND mf.observed_root_path <> ''
@@ -296,7 +308,10 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
WHERE mf.media_folder_id = q.media_folder_id
AND mf.observed_root_path = q.observed_root_path
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
)
AND mf.missing_since IS NULL
AND mf.observed_root_path <> ''
AND (
@@ -329,13 +344,17 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) (
JOIN media_folders folders ON folders.id = q.media_folder_id
WHERE q.available_at <= NOW()
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows', 'mixed')
AND EXISTS (
SELECT 1
FROM media_files mf
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = q.media_folder_id
AND mf.observed_root_path = q.observed_root_path
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
lower(trim(mf.base_type)) = 'series'
)
AND mf.missing_since IS NULL
AND mf.observed_root_path <> ''
AND (
@@ -412,7 +431,7 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
WHERE q.media_folder_id = $1
AND q.available_at <= NOW()
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows', 'mixed')
AND (
q.observed_root_path = $2 OR q.observed_root_path LIKE $3 ESCAPE '\' OR
EXISTS (
@@ -430,6 +449,10 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = q.media_folder_id
AND mf.observed_root_path = q.observed_root_path
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
lower(trim(mf.base_type)) = 'series'
)
AND mf.missing_since IS NULL
AND mf.observed_root_path <> ''
AND (
+4 -7
View File
@@ -183,7 +183,7 @@ type metadataServiceHooks struct {
ensureSeriesEpisodeLinks func(ctx context.Context, seriesID string) error
}
var trustedSearchIDKeys = []string{"metadb", "tmdb", "tvdb", "imdb"}
var trustedSearchIDKeys = []string{"tmdb", "tvdb", "imdb"}
var ErrMetadataNotFound = errors.New("no metadata found from any provider")
@@ -829,10 +829,8 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
if req.Hints == nil {
return nil, fmt.Errorf("initial match requires hints")
}
// Seed IDs from hints.
if req.Hints.ContentID != "" {
accumulatedIDs["metadb"] = req.Hints.ContentID
}
// Seed external IDs from hints. Hints.ContentID is Silo's local
// skeleton item ID, not a searchable provider ID.
if req.Hints.FileHash != "" {
accumulatedIDs["oshash"] = req.Hints.FileHash
}
@@ -962,8 +960,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
return nil, err
}
// Run search providers to resolve the metadb ID from external IDs.
// Without this, GetMetadata has no valid metadb ID to fetch from.
// Run search providers to refresh provider IDs before fetching full metadata.
searchQuery := SearchQuery{
Title: existing.Title,
Year: existing.Year,
+119 -51
View File
@@ -364,7 +364,7 @@ func (w *MatchWorker) ProcessBatch(ctx context.Context) (processed int, err erro
// ProcessBatchByFolderAndPathPrefix processes unmatched files within a single
// library subtree immediately instead of waiting for the periodic worker loop.
func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time) (processed int, err error) {
useSeriesQueue, err := w.useSeriesGroupQueueForFolder(ctx, folderID)
useSeriesQueue, useMovieQueue, err := w.queueUsageForFolder(ctx, folderID)
if err != nil {
return 0, err
}
@@ -373,21 +373,29 @@ func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, fol
if err != nil {
return 0, err
}
return w.processSeriesRoots(ctx, jobs)
processed, err := w.processSeriesRoots(ctx, jobs)
if err != nil || processed > 0 {
return processed, err
}
}
useMovieQueue, err := w.useMovieQueueForFolder(ctx, folderID)
if err != nil {
return 0, err
if useSeriesQueue && !useMovieQueue {
return processed, nil
}
if useMovieQueue {
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
if err != nil {
return 0, err
}
return w.processQueuedMovieFiles(ctx, files), nil
processed := w.processQueuedMovieFiles(ctx, files)
if processed > 0 {
return processed, nil
}
if !useSeriesQueue {
return processed, nil
}
}
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, false)
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, scopedFallbackMode(useSeriesQueue, useMovieQueue))
if err != nil {
return 0, err
}
@@ -400,11 +408,7 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
if attemptBefore.IsZero() {
attemptBefore = time.Now().UTC()
}
useSeriesQueue, err := w.useSeriesGroupQueueForFolder(ctx, folderID)
if err != nil {
return 0, err
}
useMovieQueue, err := w.useMovieQueueForFolder(ctx, folderID)
useSeriesQueue, useMovieQueue, err := w.queueUsageForFolder(ctx, folderID)
if err != nil {
return 0, err
}
@@ -424,30 +428,31 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
if err != nil {
return processed, err
}
if len(jobs) == 0 {
return processed, nil
if len(jobs) > 0 {
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
if err != nil {
return processed, err
}
processed += batchProcessed
continue
}
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
if err != nil {
return processed, err
}
processed += batchProcessed
continue
}
if useMovieQueue {
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
if err != nil {
return processed, err
}
if len(files) == 0 {
return processed, nil
if len(files) > 0 {
batchProcessed := w.processQueuedMovieFiles(ctx, files)
processed += batchProcessed
continue
}
batchProcessed := w.processQueuedMovieFiles(ctx, files)
processed += batchProcessed
continue
}
if useSeriesQueue != useMovieQueue {
return processed, nil
}
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, w.enableTVSeriesRootQueue)
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, scopedFallbackMode(useSeriesQueue, useMovieQueue))
if err != nil {
return processed, err
}
@@ -705,6 +710,8 @@ func (w *MatchWorker) reusableQueuedMovieSkeleton(ctx context.Context, file *mod
func scopedMatcherPath(useSeriesQueue bool, useMovieQueue bool) string {
switch {
case useSeriesQueue && useMovieQueue:
return "series_root_queue+movie_file_queue"
case useSeriesQueue:
return "series_root_queue"
case useMovieQueue:
@@ -792,6 +799,43 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
}
if !hasUnlinkedGroupFile(groupFiles) {
if strings.TrimSpace(representative.ContentID) != "" {
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, representative); ok && skeleton.ItemStatus != "ambiguous" {
req := w.buildProcessRequestForGroup(ctx, representative, skeleton, groupFiles)
result, processErr := w.service.Process(ctx, req)
if processErr != nil {
queueErr := truncateSeriesQueueError(processErr.Error())
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
return 0, updateErr
}
slog.Warn("metadata: enrichment failed",
"file_id", representative.ID,
"path", representative.FilePath,
"error", processErr)
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
"content_id", skeleton.ContentID,
"file_id", representative.ID,
"path", representative.FilePath)
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
slog.Warn("metadata: fallback episode synthesis failed after series root enrichment error",
"content_id", skeleton.ContentID, "error", fbErr)
}
return 0, nil
}
if result != nil && !result.Updated {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
return 0, updateErr
}
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
"content_id", skeleton.ContentID,
"file_id", representative.ID,
"path", representative.FilePath)
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
slog.Warn("metadata: fallback episode synthesis failed for unmatched series root",
"content_id", skeleton.ContentID, "error", fbErr)
}
return 0, nil
}
}
if err := w.service.ensureSeriesEpisodeLinks(ctx, representative.ContentID); err != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
@@ -1016,31 +1060,16 @@ func (w *MatchWorker) folderType(ctx context.Context, folderID int) (string, err
return strings.ToLower(strings.TrimSpace(folder.Type)), nil
}
func (w *MatchWorker) useSeriesGroupQueueForFolder(ctx context.Context, folderID int) (bool, error) {
if !w.enableTVSeriesRootQueue || w.seriesClaimer == nil {
return false, nil
}
func (w *MatchWorker) queueUsageForFolder(ctx context.Context, folderID int) (useSeriesQueue bool, useMovieQueue bool, err error) {
folderType, err := w.folderType(ctx, folderID)
if err != nil {
return false, err
}
return isTVLibraryType(folderType), nil
}
func (w *MatchWorker) useMovieQueueForFolder(ctx context.Context, folderID int) (bool, error) {
if w.movieClaimer == nil {
return false, nil
}
folderType, err := w.folderType(ctx, folderID)
if err != nil {
return false, err
}
switch folderType {
case "movie", "movies":
return true, nil
default:
return false, nil
return false, false, err
}
useSeriesQueue = w.enableTVSeriesRootQueue &&
w.seriesClaimer != nil &&
(isTVLibraryType(folderType) || isMixedLibraryType(folderType))
useMovieQueue = w.movieClaimer != nil && isMovieLibraryType(folderType)
return useSeriesQueue, useMovieQueue, nil
}
func (w *MatchWorker) claimBackgroundFiles(ctx context.Context) ([]*models.MediaFile, error) {
@@ -1059,14 +1088,40 @@ func (w *MatchWorker) claimBackgroundFiles(ctx context.Context) ([]*models.Media
return w.fileLister.ClaimUnmatched(ctx, w.batchSize)
}
func (w *MatchWorker) claimScopedFiles(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time, preferNonSeries bool) ([]*models.MediaFile, error) {
if preferNonSeries {
type scopedFallbackClaimMode int
const (
scopedFallbackGeneric scopedFallbackClaimMode = iota
scopedFallbackNonSeries
scopedFallbackMixed
)
func scopedFallbackMode(useSeriesQueue bool, useMovieQueue bool) scopedFallbackClaimMode {
switch {
case useSeriesQueue && useMovieQueue:
return scopedFallbackMixed
case useSeriesQueue:
return scopedFallbackNonSeries
default:
return scopedFallbackGeneric
}
}
func (w *MatchWorker) claimScopedFiles(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time, mode scopedFallbackClaimMode) ([]*models.MediaFile, error) {
switch mode {
case scopedFallbackMixed:
if claimer, ok := w.fileLister.(MixedFileClaimer); ok {
return claimer.ClaimUnmatchedMixedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
}
return nil, fmt.Errorf("mixed-library file claimer is not configured")
case scopedFallbackNonSeries:
if claimer, ok := w.fileLister.(NonSeriesFileClaimer); ok {
return claimer.ClaimUnmatchedNonSeriesByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
}
return nil, fmt.Errorf("non-series file claimer is not configured")
default:
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
}
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
}
func selectRepresentativeGroupFile(groupFiles []*models.MediaFile) *models.MediaFile {
@@ -1117,6 +1172,19 @@ func isTVLibraryType(folderType string) bool {
}
}
func isMixedLibraryType(folderType string) bool {
return strings.ToLower(strings.TrimSpace(folderType)) == "mixed"
}
func isMovieLibraryType(folderType string) bool {
switch strings.ToLower(strings.TrimSpace(folderType)) {
case "movie", "movies", "mixed":
return true
default:
return false
}
}
// RetryUnmatchedItemsByFolderAndPathPrefix revisits linked unmatched items in
// scope once. Per-item retry failures are counted as warnings, not fatal.
func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string) (retried int, stillUnmatched int, err error) {
+69 -1
View File
@@ -718,6 +718,74 @@ func TestWorkerProcessAllByFolderAndPathPrefix_MovieFolderUsesMovieQueue(t *test
}
}
func TestWorkerProcessAllByFolderAndPathPrefix_MixedFolderDrainsSeriesAndMovieQueues(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "mixed", Enabled: true},
},
}
seriesFile := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/mixed/television/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/mixed/television/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
BaseTitle: "Example Show",
BaseType: "series",
}
movieFile := &models.MediaFile{
ID: 2,
MediaFolderID: 10,
FilePath: "/media/mixed/movies/Example Movie (2024)/Example.Movie.mkv",
BaseTitle: "Example Movie",
BaseType: "movie",
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", seriesFile)
h.fileRepo.setGroupFiles(10, 1, "v1|movie|example_movie|2024", movieFile)
processedTypes := make([]string, 0, 2)
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
processedTypes = append(processedTypes, req.Hints.Type)
return &ProcessResult{Updated: true}, nil
}
seriesQueueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/mixed/television/Example Show",
SampleFilePath: seriesFile.FilePath,
ObservedFileCount: 1,
})
movieQueueRepo := newFakeMovieQueueRepo(movieFile)
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(seriesQueueRepo, true)
worker.SetMovieFileClaimer(movieQueueRepo)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/mixed", time.Time{})
if err != nil {
t.Fatalf("ProcessAllByFolderAndPathPrefix error = %v", err)
}
if processed != 2 {
t.Fatalf("processed = %d, want 2", processed)
}
if got, want := strings.Join(processedTypes, ","), "series,movie"; got != want {
t.Fatalf("processed types = %q, want %q", got, want)
}
if seriesQueueRepo.scopedClaimCalls == 0 {
t.Fatal("expected series queue to be claimed")
}
if movieQueueRepo.scopedClaimCalls == 0 {
t.Fatal("expected movie queue to be claimed")
}
if h.fileRepo.claimMixedCalls != 1 {
t.Fatalf("claimMixedCalls = %d, want 1", h.fileRepo.claimMixedCalls)
}
}
func TestWorkerProcessAllByFolderAndPathPrefix_SeriesRootSkeletonErrorKeepsQueueRow(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
@@ -1000,7 +1068,7 @@ func TestWorkerClaimScopedFiles_WithMovieQueueAndTVQueueDisabledUsesScopedGeneri
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetMovieFileClaimer(newFakeMovieQueueRepo())
files, err := worker.claimScopedFiles(ctx, 10, "/media/shows/Example Show", time.Time{}, false)
files, err := worker.claimScopedFiles(ctx, 10, "/media/shows/Example Show", time.Time{}, scopedFallbackGeneric)
if err != nil {
t.Fatalf("claimScopedFiles error = %v", err)
}
+3
View File
@@ -10,6 +10,7 @@ type User struct {
PasswordHash string
LocalPasswordLoginEnabled bool
Role string
Permissions []string
Enabled bool
LibraryIDs []int // nullable in PG (nil = all libraries)
MaxPlaybackQuality string
@@ -30,6 +31,7 @@ type CreateUserInput struct {
Password string // plaintext, will be bcrypt-hashed
LocalPasswordLoginEnabled *bool
Role string // e.g. "admin", "user"
Permissions []string
LibraryIDs []int
MaxPlaybackQuality string
MaxStreams *int // nil = use DB default (6)
@@ -47,6 +49,7 @@ type UpdateUserInput struct {
Password *string // plaintext, will be bcrypt-hashed if provided
LocalPasswordLoginEnabled *bool
Role *string
Permissions *[]string
Enabled *bool
LibraryIDs *[]int
MaxPlaybackQuality *string
+1 -1
View File
@@ -59,7 +59,7 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
group.TvdbID = ids.TvdbID
}
if group.TmdbID == "" || group.ImdbID == "" || group.TvdbID == "" {
if ids := ParseFolderIDs(strings.TrimSuffix(filepath.Base(cleanFilePath), filepath.Ext(cleanFilePath)), group.BaseType); ids != nil {
if ids := ParseStructuredFolderIDs(strings.TrimSuffix(filepath.Base(cleanFilePath), filepath.Ext(cleanFilePath))); ids != nil {
if group.TmdbID == "" {
group.TmdbID = ids.TmdbID
}
+2 -1
View File
@@ -1207,7 +1207,6 @@ func ptrFloatEqual(a, b *float64) bool {
return *a == *b
}
func nextSharedMarkerAttribution(
existingSource *string,
existingConfidence *float64,
@@ -1438,6 +1437,7 @@ func (r *FileRepository) ClaimUnmatchedMixed(ctx context.Context, limit int) ([]
AND mf.missing_since IS NULL
AND folders.enabled = true
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC
LIMIT $1
FOR UPDATE SKIP LOCKED
@@ -1695,6 +1695,7 @@ func (r *FileRepository) ClaimUnmatchedMixedByFolderAndPathPrefix(
AND mf.missing_since IS NULL
AND folders.enabled = true
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\')
`)
if !attemptBefore.IsZero() {
+22
View File
@@ -123,6 +123,28 @@ func TestInferGroupAssignments_SeriesSeasonDirsCollapse(t *testing.T) {
}
}
func TestInferGroupAssignments_SeriesEpisodeTitleNumberDoesNotBecomeTVDBID(t *testing.T) {
t.Parallel()
filePaths := []string{
"/sports/television/WWE SmackDown (1999)/Season 01/WWE SmackDown (1999) - S01E01 - SmackDown 01.mkv",
}
rootInference := inferRootAssignments(filePaths, "mixed", 7, nil)
groupInference := inferGroupAssignments(filePaths, "mixed", 7, rootInference.Assignments)
if got, want := len(groupInference.ScannedGroups), 1; got != want {
t.Fatalf("len(ScannedGroups) = %d, want %d", got, want)
}
group := groupInference.ScannedGroups[0]
if got, want := group.InferredType, "series"; got != want {
t.Fatalf("InferredType = %q, want %q", got, want)
}
if group.TvdbID != "" {
t.Fatalf("TvdbID = %q, want empty", group.TvdbID)
}
}
func TestInferGroupAssignments_MovieEpisodeTokenFolderStaysResolved(t *testing.T) {
t.Parallel()
+62
View File
@@ -121,6 +121,68 @@ func (r *Repository) Create(ctx context.Context, input CreateInput) (*models.Sca
return nil, false, fmt.Errorf("create scan run: %w", err)
}
func (r *Repository) CreateBatch(ctx context.Context, inputs []CreateInput) ([]*models.ScanRun, []bool, error) {
if len(inputs) == 0 {
return []*models.ScanRun{}, []bool{}, nil
}
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, nil, fmt.Errorf("begin scan run batch: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
runs := make([]*models.ScanRun, 0, len(inputs))
created := make([]bool, 0, len(inputs))
for _, input := range inputs {
run, err := scanRunRow(tx.QueryRow(ctx, `
INSERT INTO scan_runs (
id, media_folder_id, mode, path, trigger, status
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
RETURNING `+scanRunColumns,
ulid.Make().String(),
input.LibraryID,
input.Mode,
input.Path,
input.Trigger,
StatusAccepted,
))
if err == nil {
runs = append(runs, run)
created = append(created, true)
continue
}
if !errors.Is(err, ErrScanRunNotFound) {
return nil, nil, fmt.Errorf("create scan run: %w", err)
}
existing, lookupErr := scanRunRow(tx.QueryRow(ctx, `
SELECT `+scanRunColumns+`
FROM scan_runs
WHERE media_folder_id = $1
AND mode = $2
AND path = $3
AND status = ANY($4)
ORDER BY requested_at ASC
LIMIT 1`,
input.LibraryID,
input.Mode,
input.Path,
[]string{StatusAccepted, StatusRunning},
))
if lookupErr != nil {
return nil, nil, lookupErr
}
runs = append(runs, existing)
created = append(created, false)
}
if err := tx.Commit(ctx); err != nil {
return nil, nil, fmt.Errorf("commit scan run batch: %w", err)
}
return runs, created, nil
}
func (r *Repository) GetActiveByScope(ctx context.Context, libraryID int, mode, path string) (*models.ScanRun, error) {
return scanRunRow(r.pool.QueryRow(ctx, `
SELECT `+scanRunColumns+`
+29
View File
@@ -13,6 +13,7 @@ import (
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/libraryingest"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scantrigger"
)
const (
@@ -131,6 +132,34 @@ func (s *Service) EnqueueScan(ctx context.Context, folderID int, mode, path, tri
return created, nil
}
func (s *Service) EnqueueScans(ctx context.Context, targets []scantrigger.Target) error {
if s == nil || s.repo == nil {
return fmt.Errorf("scan queue is not configured")
}
inputs := make([]CreateInput, 0, len(targets))
for _, target := range targets {
if target.Folder == nil {
return fmt.Errorf("scan queue: target is missing folder")
}
inputs = append(inputs, CreateInput{
LibraryID: target.Folder.ID,
Mode: target.Mode,
Path: target.Path,
Trigger: target.Trigger,
})
}
runs, created, err := s.repo.CreateBatch(ctx, inputs)
if err != nil {
return err
}
for i, run := range runs {
if i < len(created) && created[i] {
s.publish(ctx, "scan.accepted", run)
}
}
return nil
}
func (s *Service) CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error) {
if s == nil || s.repo == nil {
return 0, nil
+280
View File
@@ -0,0 +1,280 @@
package scantrigger
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
const (
ModeLibrary = "library"
ModeSubtree = "subtree"
ModeFile = "file"
)
type FolderRepository interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
List(ctx context.Context) ([]*models.MediaFolder, error)
}
type Queuer interface {
EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error)
EnqueueScans(ctx context.Context, targets []Target) error
}
type Request struct {
LibraryID *int
Path string
Trigger string
}
// Target is a fully-resolved scan request. Folder is always non-nil for
// targets returned by Resolver; callers should read the library ID via
// target.Folder.ID rather than tracking it separately.
type Target struct {
Folder *models.MediaFolder
Mode string
Path string
Trigger string
}
type RequestError struct {
Status int
Code string
Message string
}
func (e *RequestError) Error() string {
return e.Message
}
type Resolver struct {
folders FolderRepository
}
func NewResolver(folders FolderRepository) *Resolver {
return &Resolver{folders: folders}
}
func (r *Resolver) ResolveAll(ctx context.Context, requests []Request) ([]Target, error) {
targets := make([]Target, 0, len(requests))
var pathFolders []*models.MediaFolder
pathFoldersLoaded := false
for _, req := range requests {
usePathFolders := req.LibraryID == nil && strings.TrimSpace(req.Path) != ""
if usePathFolders && !pathFoldersLoaded {
if r == nil || r.folders == nil {
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
folders, listErr := r.folders.List(ctx)
if listErr != nil {
return nil, fmt.Errorf("listing libraries for scan: %w", listErr)
}
pathFolders = folders
pathFoldersLoaded = true
}
target, err := r.resolve(ctx, req, pathFolders, usePathFolders)
if err != nil {
return nil, err
}
targets = append(targets, *target)
}
return targets, nil
}
func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) {
return r.resolve(ctx, req, nil, false)
}
func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*models.MediaFolder, usePathFolders bool) (*Target, error) {
if r == nil || r.folders == nil {
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Either library_id or path is required"}
}
var folder *models.MediaFolder
var err error
if req.LibraryID != nil {
folder, err = r.folders.GetByID(ctx, *req.LibraryID)
if err != nil {
if errors.Is(err, catalog.ErrFolderNotFound) {
return nil, &RequestError{Status: http.StatusNotFound, Code: "not_found", Message: "Library not found"}
}
return nil, fmt.Errorf("fetching library for scan: %w", err)
}
}
trigger := strings.TrimSpace(req.Trigger)
if trigger == "" {
trigger = "manual"
}
if strings.TrimSpace(req.Path) == "" {
if folder != nil && !folder.Enabled {
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
}
return &Target{Folder: folder, Mode: ModeLibrary, Trigger: trigger}, nil
}
cleanPath := filepath.Clean(req.Path)
var matchedRoot string
if folder != nil {
matchedRoot, err = LongestMatchingRoot(cleanPath, folder.Paths)
if err != nil {
return nil, err
}
if matchedRoot == "" {
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not belong to the specified library"}
}
} else {
folders := pathFolders
if !usePathFolders {
var listErr error
folders, listErr = r.folders.List(ctx)
if listErr != nil {
return nil, fmt.Errorf("listing libraries for scan: %w", listErr)
}
}
folder, matchedRoot, err = MatchFolderForPath(cleanPath, folders)
if err != nil {
return nil, err
}
}
if folder != nil && !folder.Enabled {
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
}
mode, err := ClassifyPath(cleanPath, matchedRoot)
if err != nil {
return nil, err
}
if trigger == "manual" {
trigger = "path"
if req.LibraryID != nil {
trigger = "library_id_path"
}
}
targetPath := cleanPath
if mode == ModeLibrary {
targetPath = ""
}
return &Target{Folder: folder, Mode: mode, Path: targetPath, Trigger: trigger}, nil
}
func EnqueueAll(ctx context.Context, queue Queuer, targets []Target) error {
if queue == nil {
return &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
if err := queue.EnqueueScans(ctx, targets); err != nil {
return fmt.Errorf("queueing library scans: %w", err)
}
return nil
}
func LongestMatchingRoot(targetPath string, roots []string) (string, error) {
bestRoot := ""
bestLen := -1
for _, root := range roots {
if !PathWithinRoot(targetPath, root) {
continue
}
cleanRoot := filepath.Clean(root)
rootLen := len(cleanRoot)
if rootLen > bestLen {
bestRoot = cleanRoot
bestLen = rootLen
}
}
return bestRoot, nil
}
func MatchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) {
var bestFolder *models.MediaFolder
bestRoot := ""
bestLen := -1
ambiguous := false
for _, folder := range folders {
if folder == nil {
continue
}
root, err := LongestMatchingRoot(targetPath, folder.Paths)
if err != nil {
return nil, "", err
}
if root == "" {
continue
}
rootLen := len(root)
if rootLen > bestLen {
bestFolder = folder
bestRoot = root
bestLen = rootLen
ambiguous = false
continue
}
if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID {
ambiguous = true
}
}
if ambiguous {
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path matches multiple libraries"}
}
if bestFolder == nil {
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "No library matches the given path"}
}
return bestFolder, bestRoot, nil
}
func ClassifyPath(targetPath, matchedRoot string) (string, error) {
if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) {
return ModeLibrary, nil
}
info, err := os.Stat(targetPath)
if err != nil {
switch {
case errors.Is(err, os.ErrNotExist):
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not exist"}
case errors.Is(err, os.ErrPermission):
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Permission denied for path"}
default:
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path could not be inspected"}
}
}
if info.IsDir() {
return ModeSubtree, nil
}
if !info.Mode().IsRegular() {
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path must be a file or directory"}
}
if !scanner.SupportsVideoFile(targetPath) {
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension"}
}
return ModeFile, nil
}
func PathWithinRoot(targetPath, rootPath string) bool {
cleanTarget := filepath.Clean(targetPath)
cleanRoot := filepath.Clean(rootPath)
rel, err := filepath.Rel(cleanRoot, cleanTarget)
if err != nil {
return false
}
if rel == "." || rel == "" {
return true
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
+210
View File
@@ -0,0 +1,210 @@
package scantrigger
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeFolderRepo struct {
folders []*models.MediaFolder
listCalls int
}
func (r *fakeFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
for _, folder := range r.folders {
if folder.ID == id {
return folder, nil
}
}
return nil, catalog.ErrFolderNotFound
}
func (r *fakeFolderRepo) List(context.Context) ([]*models.MediaFolder, error) {
r.listCalls++
return r.folders, nil
}
func TestResolverClassifiesLibraryRoot(t *testing.T) {
root := t.TempDir()
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 7,
Name: "Movies",
Enabled: true,
Paths: []string{root},
}}}
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if target.Folder == nil || target.Folder.ID != 7 || target.Mode != ModeLibrary || target.Path != "" {
t.Fatalf("unexpected target: %#v", target)
}
}
func TestResolverClassifiesSubtree(t *testing.T) {
root := t.TempDir()
subtree := filepath.Join(root, "Show")
if err := os.Mkdir(subtree, 0o755); err != nil {
t.Fatal(err)
}
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 8,
Name: "TV",
Enabled: true,
Paths: []string{root},
}}}
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: subtree})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if target.Folder == nil || target.Folder.ID != 8 || target.Mode != ModeSubtree || target.Path != filepath.Clean(subtree) {
t.Fatalf("unexpected target: %#v", target)
}
}
func TestResolverClassifiesVideoFile(t *testing.T) {
root := t.TempDir()
filePath := filepath.Join(root, "Movie (2024).mkv")
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 9,
Name: "Movies",
Enabled: true,
Paths: []string{root},
}}}
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: filePath})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if target.Folder == nil || target.Folder.ID != 9 || target.Mode != ModeFile || target.Path != filepath.Clean(filePath) {
t.Fatalf("unexpected target: %#v", target)
}
}
func TestResolverRejectsDisabledLibrary(t *testing.T) {
root := t.TempDir()
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 10,
Name: "Disabled",
Enabled: false,
Paths: []string{root},
}}}
_, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root})
var reqErr *RequestError
if !errors.As(err, &reqErr) {
t.Fatalf("expected RequestError, got %T: %v", err, err)
}
if reqErr.Status != http.StatusConflict || reqErr.Code != "conflict" {
t.Fatalf("unexpected error: %#v", reqErr)
}
}
func TestResolveAllIsAllOrFail(t *testing.T) {
root := t.TempDir()
valid := filepath.Join(root, "Movie.mkv")
if err := os.WriteFile(valid, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 11,
Name: "Movies",
Enabled: true,
Paths: []string{root},
}}}
_, err := NewResolver(repo).ResolveAll(context.Background(), []Request{
{Path: valid},
{Path: filepath.Join(root, "missing.mkv")},
})
var reqErr *RequestError
if !errors.As(err, &reqErr) {
t.Fatalf("expected RequestError, got %T: %v", err, err)
}
if reqErr.Message != "Path does not exist" {
t.Fatalf("unexpected error message: %q", reqErr.Message)
}
}
func TestResolveAllReusesPathOnlyLibraryList(t *testing.T) {
root := t.TempDir()
first := filepath.Join(root, "First.mkv")
second := filepath.Join(root, "Second.mkv")
for _, path := range []string{first, second} {
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
}
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 12,
Name: "Movies",
Enabled: true,
Paths: []string{root},
}}}
targets, err := NewResolver(repo).ResolveAll(context.Background(), []Request{
{Path: first},
{Path: second},
})
if err != nil {
t.Fatalf("ResolveAll returned error: %v", err)
}
if len(targets) != 2 {
t.Fatalf("expected two targets, got %d", len(targets))
}
if repo.listCalls != 1 {
t.Fatalf("expected one folder list lookup, got %d", repo.listCalls)
}
}
type fakeQueue struct {
calls []Target
batches [][]Target
batchErr error
}
func (q *fakeQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) {
q.calls = append(q.calls, Target{Folder: &models.MediaFolder{ID: folderID}, Mode: mode, Path: path, Trigger: trigger})
return true, nil
}
func (q *fakeQueue) EnqueueScans(_ context.Context, targets []Target) error {
copied := append([]Target(nil), targets...)
q.batches = append(q.batches, copied)
if q.batchErr != nil {
return q.batchErr
}
q.calls = append(q.calls, targets...)
return nil
}
func TestEnqueueAllUsesBatchQueue(t *testing.T) {
queue := &fakeQueue{}
folder := &models.MediaFolder{ID: 1}
targets := []Target{
{Folder: folder, Mode: ModeFile, Path: "/media/one.mkv", Trigger: "autoscan"},
{Folder: folder, Mode: ModeFile, Path: "/media/two.mkv", Trigger: "autoscan"},
}
if err := EnqueueAll(context.Background(), queue, targets); err != nil {
t.Fatalf("EnqueueAll returned error: %v", err)
}
if len(queue.batches) != 1 {
t.Fatalf("expected one batch enqueue, got %d", len(queue.batches))
}
if len(queue.calls) != 2 {
t.Fatalf("expected two queued calls from batch, got %d", len(queue.calls))
}
}
+50
View File
@@ -0,0 +1,50 @@
package subtitles
import (
"fmt"
"path/filepath"
"strings"
)
const (
// ProviderUpload identifies user-uploaded subtitles in downloaded_subtitles.
ProviderUpload = "upload"
// MaxUploadSize is the maximum allowed size for user-uploaded subtitle files.
MaxUploadSize = 5 << 20 // 5 MB
)
var allowedUploadFormats = map[string]SubtitleFormat{
"srt": FormatSRT,
"vtt": FormatVTT,
"ass": FormatASS,
"ssa": FormatSSA,
"sub": FormatSUB,
}
// SubtitleContentType returns the HTTP content type for a subtitle format.
func SubtitleContentType(format SubtitleFormat) string {
switch format {
case FormatVTT:
return "text/vtt; charset=utf-8"
case FormatSRT:
return "application/x-subrip; charset=utf-8"
case FormatASS, FormatSSA:
return "text/x-ssa; charset=utf-8"
default:
return "application/octet-stream"
}
}
// FormatFromFilename returns the subtitle format from a filename extension.
func FormatFromFilename(name string) (SubtitleFormat, error) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if ext == "" {
return "", fmt.Errorf("missing file extension")
}
format, ok := allowedUploadFormats[ext]
if !ok {
return "", fmt.Errorf("unsupported subtitle format: %s", ext)
}
return format, nil
}
+382
View File
@@ -0,0 +1,382 @@
package subtitles
import (
"bufio"
"bytes"
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/abadojack/whatlanggo"
"golang.org/x/text/language"
"github.com/Silo-Server/silo-server/internal/lang"
)
// LanguageDetectionSource describes where an upload language was resolved from.
type LanguageDetectionSource string
const (
LanguageSourceFilename LanguageDetectionSource = "filename"
LanguageSourceMetadata LanguageDetectionSource = "metadata"
LanguageSourceContent LanguageDetectionSource = "content"
LanguageSourceManual LanguageDetectionSource = "manual"
)
// LanguageDetection holds a resolved subtitle language and its origin.
type LanguageDetection struct {
Language string `json:"language"`
Source LanguageDetectionSource `json:"source"`
}
var (
subtitleTimestampLine = regexp.MustCompile(`^\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}`)
assDialoguePrefix = regexp.MustCompile(`(?i)^dialogue:\s*\d`)
metadataLanguageLine = regexp.MustCompile(`(?i)^(?:language|lang)\s*:\s*(.+)$`)
vttLanguageLine = regexp.MustCompile(`(?i)language\s*:\s*([^;]+)`)
)
var filenameLanguageSkipTokens = map[string]struct{}{
"forced": {}, "sdh": {}, "hi": {}, "cc": {}, "sub": {}, "subs": {},
"subtitle": {}, "subtitles": {}, "caption": {}, "captions": {},
"the": {}, "and": {}, "for": {}, "with": {},
}
var filenameLanguageReleaseTokens = map[string]struct{}{
"webrip": {}, "webdl": {}, "web": {}, "bluray": {}, "bdrip": {}, "dvdrip": {},
"hdtv": {}, "hdrip": {}, "remux": {}, "proper": {}, "repack": {}, "extended": {},
"unrated": {}, "dts": {}, "aac": {}, "ac3": {}, "eac3": {}, "truehd": {},
"atmos": {}, "x264": {}, "x265": {}, "h264": {}, "h265": {}, "hevc": {}, "avc": {},
"720p": {}, "1080p": {}, "2160p": {}, "4k": {}, "8k": {}, "hdr": {}, "sdr": {},
}
// filenameLanguageAliases maps common subtitle release abbreviations to ISO codes.
var filenameLanguageAliases = map[string]string{
"chs": "zh", "cht": "zh", "chi": "zh", "zho": "zh", "cn": "zh",
"eng": "en", "jpn": "ja", "ger": "de", "deu": "de", "fre": "fr", "fra": "fr",
"spa": "es", "esp": "es", "ita": "it", "por": "pt", "pob": "pt", "br": "pt",
"rus": "ru", "pol": "pl", "cze": "cs", "ces": "cs", "dan": "da", "dut": "nl",
"nld": "nl", "swe": "sv", "nor": "no", "fin": "fi", "gre": "el", "ell": "el",
"rum": "ro", "ron": "ro", "hrv": "hr", "srp": "sr", "bul": "bg", "ukr": "uk",
"vie": "vi", "ind": "id", "msa": "ms", "may": "ms", "heb": "he", "hin": "hi",
"kor": "ko", "ara": "ar", "tha": "th", "tur": "tr", "hun": "hu", "slo": "sk",
"slk": "sk", "slv": "sl", "est": "et", "lav": "lv", "lit": "lt", "ice": "is",
"isl": "is", "wel": "cy", "cym": "cy", "cat": "ca", "eus": "eu", "baq": "eu",
}
var metadataLanguageNames = map[string]string{
"english": "en", "spanish": "es", "french": "fr", "german": "de", "italian": "it",
"portuguese": "pt", "japanese": "ja", "korean": "ko", "chinese": "zh", "russian": "ru",
"arabic": "ar", "dutch": "nl", "polish": "pl", "swedish": "sv", "norwegian": "no",
"danish": "da", "finnish": "fi", "greek": "el", "turkish": "tr", "hungarian": "hu",
"czech": "cs", "romanian": "ro", "hebrew": "he", "hindi": "hi", "thai": "th",
"vietnamese": "vi", "indonesian": "id", "ukrainian": "uk",
}
// DetectSubtitleLanguage resolves a subtitle language from filename, embedded
// metadata, or dialogue text.
func DetectSubtitleLanguage(filename string, format SubtitleFormat, data []byte) LanguageDetection {
if language, ok := languageFromFilename(filename); ok {
return LanguageDetection{Language: language, Source: LanguageSourceFilename}
}
if language, ok := languageFromMetadata(format, data); ok {
return LanguageDetection{Language: language, Source: LanguageSourceMetadata}
}
if language, ok := languageFromContent(data, format); ok {
return LanguageDetection{Language: language, Source: LanguageSourceContent}
}
return LanguageDetection{}
}
// ResolveUploadLanguage prefers auto-detected language and falls back to the
// user-provided hint when detection fails. When preferUserLanguage is true, the
// user-provided language is used as an explicit override.
func ResolveUploadLanguage(filename string, format SubtitleFormat, data []byte, userLanguage string, preferUserLanguage bool) (LanguageDetection, error) {
if preferUserLanguage {
if manual := canonicalLanguageToken(userLanguage); manual != "" {
return LanguageDetection{Language: manual, Source: LanguageSourceManual}, nil
}
return LanguageDetection{}, fmt.Errorf("invalid subtitle language")
}
if detected := DetectSubtitleLanguage(filename, format, data); detected.Language != "" {
return detected, nil
}
if manual := canonicalLanguageToken(userLanguage); manual != "" {
return LanguageDetection{Language: manual, Source: LanguageSourceManual}, nil
}
return LanguageDetection{}, fmt.Errorf("could not detect subtitle language")
}
func languageFromFilename(filename string) (string, bool) {
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
tokens := splitFilenameTokens(base)
for i := len(tokens) - 1; i >= 0; i-- {
token := strings.Trim(tokens[i], "[](){}")
if token == "" {
continue
}
lower := strings.ToLower(token)
if _, skip := filenameLanguageSkipTokens[lower]; skip {
continue
}
if _, skip := filenameLanguageReleaseTokens[lower]; skip {
continue
}
if containsDigit(token) {
continue
}
if language, ok := filenameLanguageToken(token); ok {
return language, true
}
}
return "", false
}
func filenameLanguageToken(token string) (string, bool) {
trimmed := strings.TrimSpace(token)
if len(trimmed) < 2 || len(trimmed) > 3 {
return "", false
}
for _, r := range trimmed {
if r < 'A' || r > 'Z' {
if r < 'a' || r > 'z' {
return "", false
}
}
}
lower := strings.ToLower(trimmed)
if mapped, ok := filenameLanguageAliases[lower]; ok {
return mapped, true
}
language := canonicalLanguageToken(trimmed)
if language == "" {
return "", false
}
return language, true
}
func containsDigit(value string) bool {
for _, r := range value {
if r >= '0' && r <= '9' {
return true
}
}
return false
}
func languageFromMetadata(format SubtitleFormat, data []byte) (string, bool) {
switch format {
case FormatASS, FormatSSA:
return languageFromASSMetadata(data)
case FormatVTT:
return languageFromVTTMetadata(data)
default:
return "", false
}
}
func languageFromASSMetadata(data []byte) (string, bool) {
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(strings.ToLower(line), "[") {
continue
}
if matches := metadataLanguageLine.FindStringSubmatch(line); len(matches) == 2 {
if language, ok := languageFromMetadataValue(matches[1]); ok {
return language, true
}
}
}
return "", false
}
func languageFromVTTMetadata(data []byte) (string, bool) {
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.EqualFold(line, "WEBVTT") {
continue
}
if matches := vttLanguageLine.FindStringSubmatch(line); len(matches) == 2 {
if language, ok := languageFromMetadataValue(matches[1]); ok {
return language, true
}
}
if strings.Contains(line, "-->") {
break
}
}
return "", false
}
func languageFromContent(data []byte, format SubtitleFormat) (string, bool) {
text := extractSubtitleDialogue(data, format)
if len([]rune(text)) < 40 {
return "", false
}
info := whatlanggo.Detect(text)
if !info.IsReliable() {
return "", false
}
code := info.Lang.Iso6391()
if code == "" {
code = info.Lang.Iso6393()
}
if language := canonicalLanguageToken(code); language != "" {
return language, true
}
return "", false
}
func extractSubtitleDialogue(data []byte, format SubtitleFormat) string {
switch format {
case FormatASS, FormatSSA:
return extractASSDialogue(data)
case FormatVTT:
return extractVTTDialogue(data)
default:
return extractSRTDialogue(data)
}
}
func extractSRTDialogue(data []byte) string {
var b strings.Builder
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || subtitleTimestampLine.MatchString(line) {
continue
}
if _, err := fmt.Sscanf(line, "%d", new(int)); err == nil {
continue
}
appendDialogueLine(&b, line)
}
return b.String()
}
func extractVTTDialogue(data []byte) string {
var b strings.Builder
scanner := bufio.NewScanner(bytes.NewReader(data))
inCue := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
inCue = false
continue
}
if strings.EqualFold(line, "WEBVTT") || strings.HasPrefix(strings.ToUpper(line), "NOTE") {
continue
}
if strings.Contains(line, "-->") {
inCue = true
continue
}
if inCue {
appendDialogueLine(&b, line)
}
}
return b.String()
}
func extractASSDialogue(data []byte) string {
var b strings.Builder
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !assDialoguePrefix.MatchString(line) {
continue
}
parts := strings.SplitN(line, ",", 10)
if len(parts) < 10 {
continue
}
appendDialogueLine(&b, strings.TrimSpace(parts[9]))
}
return b.String()
}
func appendDialogueLine(b *strings.Builder, line string) {
cleaned := strings.TrimSpace(stripASSTags(line))
if cleaned == "" {
return
}
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(cleaned)
}
func stripASSTags(line string) string {
var b strings.Builder
b.Grow(len(line))
inTag := false
for _, r := range line {
switch {
case r == '{':
inTag = true
case r == '}':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return b.String()
}
func splitFilenameTokens(base string) []string {
replaced := strings.NewReplacer("_", ".", "-", ".", " ", ".").Replace(base)
raw := strings.Split(replaced, ".")
tokens := make([]string, 0, len(raw))
for _, token := range raw {
token = strings.TrimSpace(token)
if token != "" {
tokens = append(tokens, token)
}
}
return tokens
}
func languageFromMetadataValue(value string) (string, bool) {
if language := canonicalLanguageToken(value); language != "" {
return language, true
}
if mapped, ok := metadataLanguageNames[strings.ToLower(strings.TrimSpace(value))]; ok {
return mapped, true
}
return "", false
}
// NormalizeLanguageCode canonicalizes a subtitle language code to ISO 639-1 base form.
func NormalizeLanguageCode(value string) (string, error) {
language := canonicalLanguageToken(value)
if language == "" {
return "", fmt.Errorf("invalid subtitle language")
}
return language, nil
}
func canonicalLanguageToken(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
candidate := lang.Canonical(trimmed)
tag, err := language.Parse(candidate)
if err != nil {
return ""
}
base, conf := tag.Base()
if conf == language.No {
return ""
}
return strings.ToLower(base.String())
}
+119
View File
@@ -0,0 +1,119 @@
package subtitles
import "testing"
func TestDetectSubtitleLanguageFromReleaseFilename(t *testing.T) {
cases := []struct {
filename string
want string
}{
{
filename: "The.Super.Mario.Galaxy.Movie.2026.720p.WEBRip.x264.AAC-[YTS.BZ]-TR.srt",
want: "tr",
},
{
filename: "Dune.Part.Two.2024.1080p.BluRay.x265.DTS-HD.MA.5.1-EN.srt",
want: "en",
},
{
filename: "some.movie.chs.srt",
want: "zh",
},
}
for _, tc := range cases {
detected := DetectSubtitleLanguage(tc.filename, FormatSRT, nil)
if detected.Language != tc.want {
t.Fatalf("filename %q: language = %q, source = %q, want %q", tc.filename, detected.Language, detected.Source, tc.want)
}
if detected.Source != LanguageSourceFilename {
t.Fatalf("filename %q: source = %q, want filename", tc.filename, detected.Source)
}
}
}
func TestDetectSubtitleLanguageFromFilename(t *testing.T) {
detected := DetectSubtitleLanguage("Movie.en.srt", FormatSRT, []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"))
if detected.Language != "en" {
t.Fatalf("language = %q, want en", detected.Language)
}
if detected.Source != LanguageSourceFilename {
t.Fatalf("source = %q, want filename", detected.Source)
}
}
func TestDetectSubtitleLanguageFromASSMetadata(t *testing.T) {
data := []byte(`[Script Info]
Title: Example
Language: Spanish
[Events]
Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Hola
`)
detected := DetectSubtitleLanguage("subtitle.ass", FormatASS, data)
if detected.Language != "es" {
t.Fatalf("language = %q, want es", detected.Language)
}
if detected.Source != LanguageSourceMetadata {
t.Fatalf("source = %q, want metadata", detected.Source)
}
}
func TestDetectSubtitleLanguageFromContent(t *testing.T) {
data := []byte(`1
00:00:01,000 --> 00:00:04,000
Bonjour tout le monde, comment allez-vous aujourd'hui?
2
00:00:05,000 --> 00:00:08,000
Je suis tres heureux de vous voir ici ce soir.
`)
detected := DetectSubtitleLanguage("subtitle.srt", FormatSRT, data)
if detected.Language != "fr" {
t.Fatalf("language = %q, want fr", detected.Language)
}
if detected.Source != LanguageSourceContent {
t.Fatalf("source = %q, want content", detected.Source)
}
}
func TestResolveUploadLanguageUsesManualFallback(t *testing.T) {
detected, err := ResolveUploadLanguage("subtitle.srt", FormatSRT, []byte("hello"), "de", false)
if err != nil {
t.Fatalf("ResolveUploadLanguage() error = %v", err)
}
if detected.Language != "de" {
t.Fatalf("language = %q, want de", detected.Language)
}
if detected.Source != LanguageSourceManual {
t.Fatalf("source = %q, want manual", detected.Source)
}
}
func TestResolveUploadLanguagePrefersFilename(t *testing.T) {
detected, err := ResolveUploadLanguage("movie.ja.srt", FormatSRT, []byte("hello"), "de", false)
if err != nil {
t.Fatalf("ResolveUploadLanguage() error = %v", err)
}
if detected.Language != "ja" {
t.Fatalf("language = %q, want ja", detected.Language)
}
}
func TestManagerUploadDetectsLanguageFromFilename(t *testing.T) {
repo := newMockSubtitleRepo()
s3 := newMockS3Client()
manager := NewManager(repo, s3, "test-bucket")
data := []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n")
sub, err := manager.Upload(t.Context(), UploadRequest{
MediaFileID: 42,
Filename: "custom.fr.srt",
Data: data,
})
if err != nil {
t.Fatalf("Upload() error = %v", err)
}
if sub.Language != "fr" {
t.Fatalf("language = %q, want fr", sub.Language)
}
}
+194 -6
View File
@@ -4,12 +4,21 @@ package subtitles
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
var (
// ErrSubtitleNotFound indicates the requested subtitle record does not exist.
ErrSubtitleNotFound = errors.New("subtitle not found")
// ErrSubtitleLanguageConflict indicates another subtitle already uses the target S3 key.
ErrSubtitleLanguageConflict = errors.New("subtitle with this language already exists for this file")
)
// Manager orchestrates subtitle search and download across providers.
type Manager struct {
mu sync.RWMutex
@@ -121,6 +130,31 @@ type DownloadRequest struct {
HearingImpaired bool
}
// StoreSubtitleRequest contains metadata and content for persisting a subtitle.
type StoreSubtitleRequest struct {
MediaFileID int
UserID *int
Provider string
Language string
Format SubtitleFormat
ReleaseName string
Score float64
HearingImpaired bool
Data []byte
}
// UploadRequest contains metadata for a user-uploaded subtitle file.
type UploadRequest struct {
MediaFileID int
UserID *int
Language string
PreferUserLanguage bool
Filename string
ReleaseName string
HearingImpaired bool
Data []byte
}
// Download fetches a subtitle from a provider and stores it in S3.
func (m *Manager) Download(ctx context.Context, req DownloadRequest) (*DownloadedSubtitle, error) {
m.mu.RLock()
@@ -135,10 +169,72 @@ func (m *Manager) Download(ctx context.Context, req DownloadRequest) (*Downloade
return nil, fmt.Errorf("download from %s: %w", req.ProviderName, err)
}
hash := fmt.Sprintf("%x", sha256.Sum256(data))[:8]
s3Key := fmt.Sprintf("subtitles/%d/%s_%s_%s.%s", req.MediaFileID, req.Language, req.ProviderName, hash, format)
return m.StoreSubtitle(ctx, StoreSubtitleRequest{
MediaFileID: req.MediaFileID,
UserID: req.UserID,
Provider: req.ProviderName,
Language: req.Language,
Format: format,
ReleaseName: req.ReleaseName,
Score: req.Score,
HearingImpaired: req.HearingImpaired,
Data: data,
})
}
// Upload stores a user-provided subtitle file in S3.
func (m *Manager) Upload(ctx context.Context, req UploadRequest) (*DownloadedSubtitle, error) {
if len(req.Data) == 0 {
return nil, fmt.Errorf("empty subtitle file")
}
if len(req.Data) > MaxUploadSize {
return nil, fmt.Errorf("subtitle file exceeds maximum size of %d bytes", MaxUploadSize)
}
format, err := FormatFromFilename(req.Filename)
if err != nil {
return nil, err
}
detected, err := ResolveUploadLanguage(req.Filename, format, req.Data, req.Language, req.PreferUserLanguage)
if err != nil {
return nil, err
}
releaseName := req.ReleaseName
if releaseName == "" {
releaseName = req.Filename
}
return m.StoreSubtitle(ctx, StoreSubtitleRequest{
MediaFileID: req.MediaFileID,
UserID: req.UserID,
Provider: ProviderUpload,
Language: detected.Language,
Format: format,
ReleaseName: releaseName,
Score: 0,
HearingImpaired: req.HearingImpaired,
Data: req.Data,
})
}
func buildSubtitleS3Key(mediaFileID int, language, provider string, format SubtitleFormat, data []byte) string {
hash := fmt.Sprintf("%x", sha256.Sum256(data))[:8]
return fmt.Sprintf("subtitles/%d/%s_%s_%s.%s", mediaFileID, language, provider, hash, format)
}
// SubtitleMetadataPatch contains optional metadata updates for a downloaded subtitle.
type SubtitleMetadataPatch struct {
Language *string
ReleaseName *string
HearingImpaired *bool
}
// StoreSubtitle uploads subtitle content to S3 and records it in the database.
func (m *Manager) StoreSubtitle(ctx context.Context, req StoreSubtitleRequest) (*DownloadedSubtitle, error) {
s3Key := buildSubtitleS3Key(req.MediaFileID, req.Language, req.Provider, req.Format, req.Data)
// Check for duplicate
existing, err := m.repo.GetDownloadedSubtitleByS3Key(ctx, s3Key)
if err != nil {
return nil, fmt.Errorf("check duplicate: %w", err)
@@ -147,15 +243,15 @@ func (m *Manager) Download(ctx context.Context, req DownloadRequest) (*Downloade
return existing, nil
}
if err := m.s3.PutObject(ctx, m.s3Bucket, s3Key, data); err != nil {
if err := m.s3.PutObject(ctx, m.s3Bucket, s3Key, req.Data); err != nil {
return nil, fmt.Errorf("upload to s3: %w", err)
}
sub := &DownloadedSubtitle{
MediaFileID: req.MediaFileID,
Provider: req.ProviderName,
Provider: req.Provider,
Language: req.Language,
Format: format,
Format: req.Format,
ReleaseName: req.ReleaseName,
S3Key: s3Key,
Score: req.Score,
@@ -170,6 +266,98 @@ func (m *Manager) Download(ctx context.Context, req DownloadRequest) (*Downloade
return sub, nil
}
// UpdateDownloadedSubtitle updates subtitle metadata and migrates S3 keys when language changes.
func (m *Manager) UpdateDownloadedSubtitle(ctx context.Context, id int, patch SubtitleMetadataPatch) (*DownloadedSubtitle, error) {
sub, err := m.repo.GetDownloadedSubtitle(ctx, id)
if err != nil {
return nil, fmt.Errorf("lookup subtitle: %w", err)
}
if sub == nil {
return nil, ErrSubtitleNotFound
}
language := sub.Language
if patch.Language != nil {
normalized, err := NormalizeLanguageCode(*patch.Language)
if err != nil {
return nil, err
}
language = normalized
}
releaseName := sub.ReleaseName
if patch.ReleaseName != nil {
releaseName = strings.TrimSpace(*patch.ReleaseName)
}
hearingImpaired := sub.HearingImpaired
if patch.HearingImpaired != nil {
hearingImpaired = *patch.HearingImpaired
}
newS3Key := sub.S3Key
if language != sub.Language {
data, err := m.s3.GetObject(ctx, m.s3Bucket, sub.S3Key)
if err != nil {
return nil, fmt.Errorf("fetch subtitle content: %w", err)
}
newS3Key = buildSubtitleS3Key(sub.MediaFileID, language, sub.Provider, sub.Format, data)
existing, err := m.repo.GetDownloadedSubtitleByS3Key(ctx, newS3Key)
if err != nil {
return nil, fmt.Errorf("check duplicate: %w", err)
}
if existing != nil && existing.ID != id {
return nil, ErrSubtitleLanguageConflict
}
if newS3Key != sub.S3Key {
if err := m.s3.PutObject(ctx, m.s3Bucket, newS3Key, data); err != nil {
return nil, fmt.Errorf("upload migrated subtitle: %w", err)
}
}
}
updated, err := m.repo.UpdateDownloadedSubtitle(ctx, id, SubtitleMetadataUpdate{
Language: language,
ReleaseName: releaseName,
HearingImpaired: hearingImpaired,
S3Key: newS3Key,
})
if err != nil {
if newS3Key != sub.S3Key {
_ = m.s3.DeleteObject(ctx, m.s3Bucket, newS3Key)
}
return nil, err
}
if updated == nil {
return nil, ErrSubtitleNotFound
}
if newS3Key != sub.S3Key {
_ = m.s3.DeleteObject(ctx, m.s3Bucket, sub.S3Key)
}
return updated, nil
}
// GetSubtitleContent loads a downloaded subtitle record and its S3 bytes.
func (m *Manager) GetSubtitleContent(ctx context.Context, id int) (*DownloadedSubtitle, []byte, error) {
sub, err := m.repo.GetDownloadedSubtitle(ctx, id)
if err != nil {
return nil, nil, fmt.Errorf("lookup subtitle: %w", err)
}
if sub == nil {
return nil, nil, ErrSubtitleNotFound
}
data, err := m.s3.GetObject(ctx, m.s3Bucket, sub.S3Key)
if err != nil {
return nil, nil, fmt.Errorf("fetch subtitle content: %w", err)
}
return sub, data, nil
}
// DeleteSubtitle removes a downloaded subtitle from both DB and S3.
func (m *Manager) DeleteSubtitle(ctx context.Context, id int) error {
sub, err := m.repo.DeleteDownloadedSubtitle(ctx, id)
+209
View File
@@ -0,0 +1,209 @@
package subtitles
import (
"context"
"sync"
"testing"
"time"
)
type mockSubtitleRepo struct {
mu sync.Mutex
byKey map[string]*DownloadedSubtitle
nextID int
inserts int
}
func newMockSubtitleRepo() *mockSubtitleRepo {
return &mockSubtitleRepo{byKey: make(map[string]*DownloadedSubtitle)}
}
func (m *mockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, sub *DownloadedSubtitle) error {
m.mu.Lock()
defer m.mu.Unlock()
m.inserts++
m.nextID++
sub.ID = m.nextID
sub.CreatedAt = time.Now()
m.byKey[sub.S3Key] = sub
return nil
}
func (m *mockSubtitleRepo) GetDownloadedSubtitle(context.Context, int) (*DownloadedSubtitle, error) {
return nil, nil
}
func (m *mockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]DownloadedSubtitle, error) {
return nil, nil
}
func (m *mockSubtitleRepo) UpdateDownloadedSubtitle(_ context.Context, id int, update SubtitleMetadataUpdate) (*DownloadedSubtitle, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, sub := range m.byKey {
if sub.ID == id {
sub.Language = update.Language
sub.ReleaseName = update.ReleaseName
sub.HearingImpaired = update.HearingImpaired
if sub.S3Key != update.S3Key {
delete(m.byKey, sub.S3Key)
sub.S3Key = update.S3Key
m.byKey[sub.S3Key] = sub
}
copy := *sub
return &copy, nil
}
}
return nil, nil
}
func (m *mockSubtitleRepo) DeleteDownloadedSubtitle(context.Context, int) (*DownloadedSubtitle, error) {
return nil, nil
}
func (m *mockSubtitleRepo) GetDownloadedSubtitleByS3Key(_ context.Context, s3Key string) (*DownloadedSubtitle, error) {
m.mu.Lock()
defer m.mu.Unlock()
if sub, ok := m.byKey[s3Key]; ok {
copy := *sub
return &copy, nil
}
return nil, nil
}
func (m *mockSubtitleRepo) ListProviderConfigs(context.Context) ([]ProviderConfig, error) {
return nil, nil
}
func (m *mockSubtitleRepo) GetProviderConfig(context.Context, string) (*ProviderConfig, error) {
return nil, nil
}
func (m *mockSubtitleRepo) UpsertProviderConfig(context.Context, *ProviderConfig) error {
return nil
}
type mockS3Client struct {
mu sync.Mutex
keys map[string][]byte
puts int
deletes int
}
func newMockS3Client() *mockS3Client {
return &mockS3Client{keys: make(map[string][]byte)}
}
func (m *mockS3Client) PutObject(_ context.Context, _, key string, data []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
m.puts++
m.keys[key] = append([]byte(nil), data...)
return nil
}
func (m *mockS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
return append([]byte(nil), m.keys[key]...), nil
}
func (m *mockS3Client) DeleteObject(_ context.Context, _, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.deletes++
delete(m.keys, key)
return nil
}
func TestManagerUploadStoresSubtitle(t *testing.T) {
repo := newMockSubtitleRepo()
s3 := newMockS3Client()
manager := NewManager(repo, s3, "test-bucket")
data := []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n")
sub, err := manager.Upload(context.Background(), UploadRequest{
MediaFileID: 42,
Language: "en",
Filename: "custom.en.srt",
Data: data,
})
if err != nil {
t.Fatalf("Upload() error = %v", err)
}
if sub.Provider != ProviderUpload {
t.Fatalf("provider = %q, want %q", sub.Provider, ProviderUpload)
}
if sub.Format != FormatSRT {
t.Fatalf("format = %q, want srt", sub.Format)
}
if repo.inserts != 1 {
t.Fatalf("inserts = %d, want 1", repo.inserts)
}
if s3.puts != 1 {
t.Fatalf("puts = %d, want 1", s3.puts)
}
}
func TestManagerUploadDedupesIdenticalContent(t *testing.T) {
repo := newMockSubtitleRepo()
s3 := newMockS3Client()
manager := NewManager(repo, s3, "test-bucket")
data := []byte("duplicate content")
first, err := manager.Upload(context.Background(), UploadRequest{
MediaFileID: 7,
Language: "en",
Filename: "a.srt",
Data: data,
})
if err != nil {
t.Fatalf("first Upload() error = %v", err)
}
second, err := manager.Upload(context.Background(), UploadRequest{
MediaFileID: 7,
Language: "en",
Filename: "b.srt",
Data: data,
})
if err != nil {
t.Fatalf("second Upload() error = %v", err)
}
if first.ID != second.ID {
t.Fatalf("dedup failed: ids %d vs %d", first.ID, second.ID)
}
if repo.inserts != 1 {
t.Fatalf("inserts = %d, want 1", repo.inserts)
}
if s3.puts != 1 {
t.Fatalf("puts = %d, want 1", s3.puts)
}
}
func TestManagerUploadRejectsUnsupportedFormat(t *testing.T) {
manager := NewManager(newMockSubtitleRepo(), newMockS3Client(), "test-bucket")
_, err := manager.Upload(context.Background(), UploadRequest{
MediaFileID: 1,
Language: "en",
Filename: "notes.txt",
Data: []byte("hello"),
})
if err == nil {
t.Fatal("expected error for unsupported format")
}
}
func TestManagerUploadRejectsOversizedFile(t *testing.T) {
manager := NewManager(newMockSubtitleRepo(), newMockS3Client(), "test-bucket")
data := make([]byte, MaxUploadSize+1)
_, err := manager.Upload(context.Background(), UploadRequest{
MediaFileID: 1,
Language: "en",
Filename: "big.srt",
Data: data,
})
if err == nil {
t.Fatal("expected error for oversized file")
}
}
+21
View File
@@ -73,6 +73,27 @@ func (r *PgRepository) ListDownloadedSubtitles(ctx context.Context, mediaFileID
return subs, rows.Err()
}
func (r *PgRepository) UpdateDownloadedSubtitle(ctx context.Context, id int, update SubtitleMetadataUpdate) (*DownloadedSubtitle, error) {
var sub DownloadedSubtitle
err := r.pool.QueryRow(ctx,
`UPDATE downloaded_subtitles
SET language = $1, release_name = $2, hearing_impaired = $3, s3_key = $4
WHERE id = $5
RETURNING id, media_file_id, provider, language, format, release_name,
s3_key, score, hearing_impaired, downloaded_by, created_at`,
update.Language, update.ReleaseName, update.HearingImpaired, update.S3Key, id,
).Scan(&sub.ID, &sub.MediaFileID, &sub.Provider, &sub.Language, &sub.Format,
&sub.ReleaseName, &sub.S3Key, &sub.Score, &sub.HearingImpaired,
&sub.DownloadedBy, &sub.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("update downloaded subtitle: %w", err)
}
return &sub, nil
}
func (r *PgRepository) DeleteDownloadedSubtitle(ctx context.Context, id int) (*DownloadedSubtitle, error) {
var sub DownloadedSubtitle
err := r.pool.QueryRow(ctx,
+9
View File
@@ -3,11 +3,20 @@ package subtitles
import "context"
// SubtitleMetadataUpdate contains mutable fields for a downloaded subtitle record.
type SubtitleMetadataUpdate struct {
Language string
ReleaseName string
HearingImpaired bool
S3Key string
}
// Repository defines database operations for subtitle management.
type Repository interface {
InsertDownloadedSubtitle(ctx context.Context, sub *DownloadedSubtitle) error
GetDownloadedSubtitle(ctx context.Context, id int) (*DownloadedSubtitle, error)
ListDownloadedSubtitles(ctx context.Context, mediaFileID int) ([]DownloadedSubtitle, error)
UpdateDownloadedSubtitle(ctx context.Context, id int, update SubtitleMetadataUpdate) (*DownloadedSubtitle, error)
DeleteDownloadedSubtitle(ctx context.Context, id int) (*DownloadedSubtitle, error)
GetDownloadedSubtitleByS3Key(ctx context.Context, s3Key string) (*DownloadedSubtitle, error)
+2 -2
View File
@@ -77,7 +77,7 @@ func (m *TaskManager) Start(ctx context.Context) {
}
}
w.setTriggers(configs, m.triggerFactory, w.lastResult)
w.setTriggers(configs, m.triggerFactory, w.lastResult, false)
go m.triggerLoop(ctx, w)
}
@@ -261,7 +261,7 @@ func (m *TaskManager) UpdateTriggers(key string, triggerConfigs []TriggerConfig)
return err
}
w.setTriggers(triggerConfigs, m.triggerFactory, nil)
w.setTriggers(triggerConfigs, m.triggerFactory, nil, true)
m.notifyTaskUpdated(w.info())
return nil
}
+6 -2
View File
@@ -72,7 +72,7 @@ func (w *taskWorker) info() TaskInfo {
// setTriggers replaces active triggers. Stops old triggers, starts new ones.
// Pass lastResult to resume scheduling from the last execution, or nil to
// start the interval fresh from now (e.g. when the user edits the schedule).
func (w *taskWorker) setTriggers(configs []TriggerConfig, factory func(TriggerConfig) Trigger, lastResult *ExecutionResult) {
func (w *taskWorker) setTriggers(configs []TriggerConfig, factory func(TriggerConfig) Trigger, lastResult *ExecutionResult, notify bool) {
w.mu.Lock()
for _, tr := range w.triggers {
@@ -90,8 +90,12 @@ func (w *taskWorker) setTriggers(configs []TriggerConfig, factory func(TriggerCo
w.mu.Unlock()
w.triggerChanged.Store(true)
if !notify {
w.notify()
return
}
w.triggerChanged.Store(true)
select {
case w.triggerUpdate <- struct{}{}:
default:
+48
View File
@@ -0,0 +1,48 @@
package taskmanager
import (
"context"
"testing"
"time"
)
type testTask struct{}
func (testTask) Key() string { return "test" }
func (testTask) Name() string { return "test" }
func (testTask) Description() string { return "test" }
func (testTask) Category() TaskCategory { return TaskCategorySystem }
func (testTask) IsHidden() bool { return false }
func (testTask) DefaultTriggers() []TriggerConfig { return nil }
func (testTask) Execute(context.Context, ProgressReporter) error { return nil }
type testTrigger struct {
ch chan struct{}
}
func newTestTrigger(TriggerConfig) Trigger {
return &testTrigger{ch: make(chan struct{}, 1)}
}
func (t *testTrigger) Start(*ExecutionResult) {
t.ch <- struct{}{}
}
func (t *testTrigger) Stop() {}
func (t *testTrigger) NextRunTime() time.Time { return time.Time{} }
func (t *testTrigger) Config() TriggerConfig { return TriggerConfig{} }
func (t *testTrigger) C() <-chan struct{} { return t.ch }
func TestInitialSetTriggersDoesNotMarkTriggerChanged(t *testing.T) {
worker := newTaskWorker(testTask{}, nil)
worker.setTriggers([]TriggerConfig{{Type: TriggerTypeInterval, IntervalMs: 1}}, newTestTrigger, nil, false)
if worker.triggerChanged.Load() {
t.Fatal("initial trigger setup should not mark triggers changed")
}
select {
case <-worker.triggerUpdate:
t.Fatal("initial trigger setup should not queue a trigger update")
default:
}
}
+12 -7
View File
@@ -255,18 +255,16 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req
slog.Warn("webhook sync: failed to upsert seen external user", "connection_id", conn.ID, "external_user_id", event.UserID, "error", err)
}
profileID := conn.DefaultProfileID
if mapping, err := s.repo.GetMappingByUser(ctx, conn.ID, event.UserID); err != nil {
return s.failWebhook(ctx, conn.ID, result, err, "Failed to resolve profile mapping")
} else if mapping != nil && mapping.SiloProfileID != nil && *mapping.SiloProfileID != "" {
profileID = *mapping.SiloProfileID
}
result.ProfileID = profileID
if profileID == "" {
} else if profileID, ok := resolveWebhookProfileID(mapping); ok {
result.ProfileID = profileID
} else {
result.Outcome = OutcomeSkipped
result.Summary = "Skipped because no default or user-specific profile is configured"
result.Summary = "Skipped because external user is not linked to a Silo profile"
return result, nil
}
profileID := result.ProfileID
record := event.Record.toHistoryImportRecord()
match, _, err := s.matcher.Match(ctx, record)
@@ -432,6 +430,13 @@ func shouldSkipEvent(state *ItemState, event *CanonicalEvent) bool {
return true
}
func resolveWebhookProfileID(mapping *ProfileMapping) (string, bool) {
if mapping == nil || mapping.SiloProfileID == nil || strings.TrimSpace(*mapping.SiloProfileID) == "" {
return "", false
}
return *mapping.SiloProfileID, true
}
func buildWebhookURL(baseURL, secret string) string {
if baseURL == "" {
return webhookSyncPathPrefix + secret
+50
View File
@@ -42,6 +42,52 @@ func TestBuildWebhookURL(t *testing.T) {
}
}
func TestResolveWebhookProfileRequiresExplicitMapping(t *testing.T) {
t.Parallel()
linkedProfileID := "linked-profile"
cases := []struct {
name string
mapping *ProfileMapping
want string
wantOK bool
}{
{
name: "missing mapping is skipped",
wantOK: false,
},
{
name: "unmapped external user is skipped",
mapping: &ProfileMapping{},
wantOK: false,
},
{
name: "empty profile mapping is skipped",
mapping: &ProfileMapping{SiloProfileID: ptrString("")},
wantOK: false,
},
{
name: "explicit profile mapping is used",
mapping: &ProfileMapping{SiloProfileID: &linkedProfileID},
want: linkedProfileID,
wantOK: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, ok := resolveWebhookProfileID(tc.mapping)
if ok != tc.wantOK || got != tc.want {
t.Fatalf("resolveWebhookProfileID() = (%q, %v), want (%q, %v)", got, ok, tc.want, tc.wantOK)
}
})
}
}
func TestFilterDiscoveredAccounts(t *testing.T) {
t.Parallel()
@@ -82,3 +128,7 @@ func TestFilterDiscoveredAccountsFallsBackWhenFlagsMissing(t *testing.T) {
t.Fatalf("unexpected fallback accounts: %#v", filtered)
}
}
func ptrString(value string) *string {
return &value
}
+1
View File
@@ -1058,6 +1058,7 @@ CREATE TABLE public.users (
username text,
password_hash text,
role text,
permissions text[] DEFAULT '{}'::text[] NOT NULL,
enabled boolean DEFAULT true,
library_ids integer[],
max_streams integer DEFAULT 6,
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_downloaded_subtitles_provider_created;
DROP INDEX IF EXISTS idx_downloaded_subtitles_created;
@@ -0,0 +1,5 @@
CREATE INDEX IF NOT EXISTS idx_downloaded_subtitles_created
ON downloaded_subtitles (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_downloaded_subtitles_provider_created
ON downloaded_subtitles (provider, created_at DESC);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE public.users
DROP COLUMN IF EXISTS permissions;
+6
View File
@@ -0,0 +1,6 @@
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS permissions text[] NOT NULL DEFAULT '{}'::text[];
UPDATE public.users
SET permissions = '{}'::text[]
WHERE permissions IS NULL;
+2
View File
@@ -50,6 +50,7 @@ import AdminCollectionEditor from "@/pages/AdminCollectionEditor";
import AdminPlaybackHistory from "@/pages/AdminPlaybackHistory";
import AdminMaintenance from "@/pages/AdminMaintenance";
import AdminApiKeys from "@/pages/AdminApiKeys";
import AdminSubtitles from "@/pages/AdminSubtitles";
import AdminUserDetail from "@/pages/AdminUserDetail";
import AdminTasks from "@/pages/AdminTasks";
import AdminTaskDetail from "@/pages/AdminTaskDetail";
@@ -385,6 +386,7 @@ function AppRoutes() {
<Route path="settings" element={<AdminSettingsLayout />} />
<Route path="recommendations" element={<AdminRecommendations />} />
<Route path="api-keys" element={<AdminApiKeys />} />
<Route path="subtitles" element={<AdminSubtitles />} />
<Route path="tasks" element={<AdminTasks />} />
<Route path="tasks/:key" element={<AdminTaskDetail />} />
<Route path="stats" element={<Navigate to="/admin" replace />} />
+28
View File
@@ -121,6 +121,34 @@ describe("client helper inventory", () => {
});
describe("api", () => {
it("forwards AbortSignal from options to fetch", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const controller = new AbortController();
await api("/test", { signal: controller.signal });
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0]!;
const init = call[1] as RequestInit;
expect(init.signal).toBe(controller.signal);
});
it("treats 202 responses with an empty body as success", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
+59
View File
@@ -370,6 +370,65 @@ export async function api<T>(path: string, options: RequestInit = {}): Promise<T
return JSON.parse(text) as T;
}
function buildApiHeaders(options: RequestInit = {}): Record<string, string> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (!(options.body instanceof FormData)) {
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
}
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`;
}
const profileId = getProfileId();
if (profileId) {
headers["X-Profile-Id"] = profileId;
}
const profToken = getProfileToken();
if (profToken) {
headers["X-Profile-Token"] = profToken;
}
Object.assign(headers, getDeviceHeaders());
return headers;
}
/** Downloads a binary API response and triggers a browser file save. */
export async function apiDownload(
path: string,
filename: string,
options: RequestInit = {},
): Promise<void> {
let headers = buildApiHeaders(options);
let res = await fetch(`/api/v1${path}`, { ...options, headers });
if (res.status === 401 && getRefreshToken()) {
if (!refreshPromise) {
refreshPromise = attemptRefresh().finally(() => {
refreshPromise = null;
});
}
const refreshed = await refreshPromise;
if (refreshed) {
headers = buildApiHeaders(options);
headers["Authorization"] = `Bearer ${accessToken}`;
res = await fetch(`/api/v1${path}`, { ...options, headers });
}
}
if (!res.ok) {
const apiErr = await parseApiError(res);
throw new ApiClientError(res.status, apiErr.error, apiErr.message, apiErr);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
// People API
export async function searchPeople(query: string, limit = 20): Promise<import("./types").Person[]> {
const params = new URLSearchParams({ q: query, limit: String(limit) });
+59
View File
@@ -97,6 +97,7 @@ export interface User {
username: string;
email: string;
role: string;
permissions: string[];
download_allowed: boolean;
impersonation?: ImpersonationInfo | null;
}
@@ -1631,6 +1632,7 @@ export interface AdminUser {
username: string;
email: string;
role: string;
permissions: string[];
enabled: boolean;
library_ids: number[] | null;
max_playback_quality: string;
@@ -1649,6 +1651,7 @@ export interface CreateUserRequest {
email: string;
password: string;
role: string;
permissions?: string[];
create_default_profile?: boolean;
default_profile_name?: string;
library_ids?: number[] | null;
@@ -1665,6 +1668,7 @@ export interface UpdateUserRequest {
email?: string;
password?: string;
role?: string;
permissions?: string[];
enabled?: boolean;
library_ids?: number[] | null;
max_playback_quality?: string;
@@ -2952,6 +2956,20 @@ export interface SubtitleDownloadRequest {
hearing_impaired: boolean;
}
export interface SubtitleUploadRequest {
media_file_id: number;
file: File;
language?: string;
language_override?: boolean;
release_name?: string;
hearing_impaired?: boolean;
}
export interface SubtitleLanguageDetection {
language: string;
source: "filename" | "metadata" | "content" | "manual";
}
export interface DownloadedSubtitle {
id: number;
media_file_id: number;
@@ -2964,6 +2982,47 @@ export interface DownloadedSubtitle {
created_at: string;
}
export interface AdminDownloadedSubtitle {
id: number;
media_file_id: number;
media_content_id?: string;
provider: string;
language: string;
format: string;
release_name: string;
score: number;
hearing_impaired: boolean;
created_at: string;
downloaded_by?: number;
uploader_username: string;
media_title: string;
media_type: string;
file_path: string;
}
export interface AdminDownloadedSubtitlesResponse {
subtitles: AdminDownloadedSubtitle[];
total: number;
uploads: number;
provider_downloads: number;
}
export interface AdminDownloadedSubtitlesFilters {
provider?: string;
language?: string;
userId?: number;
mediaFileId?: number;
q?: string;
limit?: number;
offset?: number;
}
export interface AdminUpdateDownloadedSubtitleRequest {
language?: string;
release_name?: string;
hearing_impaired?: boolean;
}
export interface SubtitleProviderConfig {
provider_name: string;
enabled: boolean;
+22
View File
@@ -1643,6 +1643,28 @@
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
.caption-empty-state span {
display: block;
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 18%, transparent);
}
.caption-empty-state span:nth-child(1) {
width: 72%;
margin-inline: auto;
}
.caption-empty-state span:nth-child(2) {
width: 54%;
margin-inline: auto;
}
.caption-empty-state span:nth-child(3) {
width: 38%;
margin-inline: auto;
}
/* ── Scan Queue Accent ──────────────────────────────────── */
/* Animated gradient sweep across the top of the scan queue
to signal live activity. Only visible when scans are active. */
+6
View File
@@ -8,6 +8,7 @@ import {
Users,
MonitorSmartphone,
History,
Captions,
Download,
SlidersHorizontal,
Server,
@@ -114,6 +115,11 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
icon: <PanelsTopLeft className="h-[18px] w-[18px]" />,
href: "/admin/sections",
},
{
label: "Subtitles",
icon: <Captions className="h-[18px] w-[18px]" />,
href: "/admin/subtitles",
},
],
},
{
+23 -9
View File
@@ -13,6 +13,7 @@ import {
useRefreshItemMetadata,
type UpdateItemMetadataRequest,
} from "@/hooks/queries/items";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
// MetadataField enum values matching internal/metadata/types.go
@@ -92,6 +93,7 @@ function initFormState(item: ItemDetail) {
}
export default function EditMetadataDialog({ item, open, onOpenChange }: EditMetadataDialogProps) {
const { user } = useAuth();
const [activeSection, setActiveSection] = useState<Section>("general");
const [form, setForm] = useState(() => initFormState(item));
const [lockedFields, setLockedFields] = useState<Set<number>>(
@@ -103,7 +105,13 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
const refreshMutation = useRefreshItemMetadata();
const isLockable = item.type === "movie" || item.type === "series";
const visibleSections = SECTIONS.filter((s) => s.types.includes(item.type));
const canEditImages = user?.role === "admin";
const visibleSections = SECTIONS.filter(
(s) => s.types.includes(item.type) && (s.key !== "images" || canEditImages),
);
const effectiveActiveSection = visibleSections.some((section) => section.key === activeSection)
? activeSection
: "general";
const originalForm = useMemo(() => initFormState(item), [item]);
@@ -261,7 +269,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
onClick={() => setActiveSection(section.key)}
className={cn(
"px-4 py-2 text-left text-[13px] font-medium whitespace-nowrap transition-colors",
activeSection === section.key
effectiveActiveSection === section.key
? "border-primary bg-primary/8 text-primary max-sm:border-b-2 sm:border-r-2"
: "text-muted-foreground hover:text-foreground",
)}
@@ -273,7 +281,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
{activeSection === "general" && (
{effectiveActiveSection === "general" && (
<div className="space-y-4">
<FieldRow label="Title" lockIcon={renderLockIcon("title")}>
<Input value={form.title} onChange={(e) => setField("title", e.target.value)} />
@@ -389,7 +397,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
</div>
)}
{activeSection === "dates" && (
{effectiveActiveSection === "dates" && (
<div className="space-y-4">
{(item.type === "movie" || item.type === "series") && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -528,7 +536,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
</div>
)}
{activeSection === "tags" && (
{effectiveActiveSection === "tags" && (
<div className="space-y-4">
<FieldRow label="Genres" lockIcon={renderLockIcon("genres")}>
<TagInput
@@ -566,7 +574,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
</div>
)}
{activeSection === "ids" && (
{effectiveActiveSection === "ids" && (
<div className="space-y-4">
<FieldRow label="IMDb ID">
<Input
@@ -590,9 +598,15 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
</div>
)}
<div className={activeSection === "images" ? "flex h-full flex-col" : "hidden"}>
<ImageSelectorTab item={item} enabled={activeSection === "images"} />
</div>
{canEditImages && (
<div
className={
effectiveActiveSection === "images" ? "flex h-full flex-col" : "hidden"
}
>
<ImageSelectorTab item={item} enabled={effectiveActiveSection === "images"} />
</div>
)}
</div>
</div>
+229
View File
@@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
useQuery: vi.fn(),
useCanRequest: vi.fn(),
useRequestSearch: vi.fn(),
}));
vi.mock("@tanstack/react-query", async () => {
@@ -21,6 +23,30 @@ vi.mock("@/hooks/useDebounce", () => ({
useDebounce: <T,>(v: T) => v,
}));
vi.mock("@/hooks/useCanRequest", () => ({
useCanRequest: () => mocks.useCanRequest(),
}));
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args),
}));
vi.mock("@/components/RequestToAddSection", () => ({
RequestToAddSection: ({
variant,
query,
libraryHadHits,
}: {
variant: string;
query: string;
libraryHadHits: boolean;
}) => (
<div data-testid="request-section">
{`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`}
</div>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
@@ -66,6 +92,18 @@ function renderSearchMarkup(props: Partial<Parameters<typeof GlobalSearch>[0]> =
describe("GlobalSearch", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useCanRequest.mockReset();
mocks.useRequestSearch.mockReset();
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
mocks.useQuery.mockReturnValue({
data: {
total: 50,
@@ -105,3 +143,194 @@ describe("GlobalSearch", () => {
expect(lastCall.enabled).toBe(false);
});
});
describe("GlobalSearch + RequestToAddSection wiring", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useCanRequest.mockReset();
mocks.useRequestSearch.mockReset();
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
mocks.useQuery.mockReturnValue({
data: { total: 50, has_more: true, items: [browseFixture] },
isFetching: false,
isError: false,
});
});
it("renders the section with libraryHadHits=true when library returned results", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
expect(markup).toContain('data-testid="request-section"');
expect(markup).toContain("libraryHadHits=&quot;true&quot;");
expect(markup).toContain("variant=&quot;dialog&quot;");
});
it("renders the section with libraryHadHits=false when library returned 0 results", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useQuery.mockReturnValue({
data: { total: 0, has_more: false, items: [] },
isFetching: false,
isError: false,
});
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ThisDoesNotExist" });
expect(markup).toContain("libraryHadHits=&quot;false&quot;");
});
it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
expect(call?.[3]).toEqual({
enabled: false,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
});
it("does not mount RequestToAddSection when discovery is disabled", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
expect(markup).not.toContain('data-testid="request-section"');
});
it("suppresses 'No matches' when library is empty and TMDB is still loading", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useQuery.mockReturnValue({
data: { total: 0, has_more: false, items: [] },
isFetching: false,
isError: false,
});
mocks.useRequestSearch.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Pending" });
expect(markup).not.toContain("No matches");
});
it("suppresses 'No matches' when library is empty and TMDB has missing results", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useQuery.mockReturnValue({
data: { total: 0, has_more: false, items: [] },
isFetching: false,
isError: false,
});
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "FoundOnTmdb" });
expect(markup).not.toContain("No matches");
});
it("still shows 'No matches' when both library and TMDB are empty", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useQuery.mockReturnValue({
data: { total: 0, has_more: false, items: [] },
isFetching: false,
isError: false,
});
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 0, results: [] },
isLoading: false,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ZzzNothing" });
expect(markup).toContain("No matches");
});
});
+63 -29
View File
@@ -7,13 +7,17 @@ import { useDebounce } from "@/hooks/useDebounce";
import { buildQueryCatalogHref } from "@/pages/catalogSearchParams";
import type { BrowseItem } from "@/api/types";
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
import { useRequestSearch } from "@/hooks/queries/useRequests";
import { useCanRequest } from "@/hooks/useCanRequest";
import { catalogKeys } from "@/hooks/queries/keys";
import { decodeThumbhash } from "@/lib/thumbhash";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
import { RequestToAddSection } from "./RequestToAddSection";
const PREVIEW_LIMIT = 8;
const DEBOUNCE_MS = 200;
const TMDB_DEBOUNCE_MS = 400;
function typeLabel(type: BrowseItem["type"]): string {
switch (type) {
@@ -100,6 +104,24 @@ export function GlobalSearch({
const [selectedIndex, setSelectedIndex] = useState(-1);
const navigate = useViewTransitionNavigate();
const debouncedQuery = useDebounce(query.trim(), DEBOUNCE_MS);
const tmdbDebouncedQuery = useDebounce(query.trim(), TMDB_DEBOUNCE_MS);
const canRequest = useCanRequest();
const tmdbQuery = useRequestSearch("all", tmdbDebouncedQuery, 1, {
enabled: canRequest.discoveryEnabled,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
const tmdbMissingCount =
tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0;
// Cap at DIALOG_LIMIT (4) — RequestToAddSection slices results to that many rows.
const tmdbVisibleCount = Math.min(tmdbMissingCount, 4);
const tmdbStillLoading =
canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading;
const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0;
// Hide empty state while the TMDB debounce trails the library debounce; otherwise
// the user sees "No matches" flash between t=200ms and t=400ms after typing.
const tmdbDebounceCatchingUp =
canRequest.discoveryEnabled && tmdbDebouncedQuery !== debouncedQuery;
const searchState = useMemo(
() => createCatalogSearchState("query", { q: debouncedQuery || undefined }),
@@ -179,7 +201,11 @@ export function GlobalSearch({
!previewQuery.isFetching &&
debouncedQuery.length > 0 &&
items.length === 0 &&
!previewQuery.isError;
!previewQuery.isError &&
!tmdbStillLoading &&
!tmdbWillRender &&
!canRequest.isResolving &&
!tmdbDebounceCatchingUp;
const showError = previewQuery.isError;
return (
@@ -236,37 +262,45 @@ export function GlobalSearch({
</form>
{showResultsPanel && (
<div className="flex min-h-0 flex-1 flex-col">
<div
role="listbox"
className="max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain px-2 py-2"
>
{showLoading && (
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
Searching...
</div>
)}
{showError && (
<div className="text-destructive px-3 py-4 text-center text-sm">
Could not load results. Press Enter to open the search page.
</div>
)}
{showEmpty && (
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
No matches
</div>
)}
{items.map((item, i) => (
<GlobalSearchResultRow
key={item.content_id}
item={item}
index={i}
isSelected={i === selectedIndex}
onPick={handlePickItem}
<div className="max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain px-2 py-2">
<div role="listbox">
{showLoading && (
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
Searching...
</div>
)}
{showError && (
<div className="text-destructive px-3 py-4 text-center text-sm">
Could not load results. Press Enter to open the search page.
</div>
)}
{showEmpty && (
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
No matches
</div>
)}
{items.map((item, i) => (
<GlobalSearchResultRow
key={item.content_id}
item={item}
index={i}
isSelected={i === selectedIndex}
onPick={handlePickItem}
/>
))}
</div>
{tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && (
<RequestToAddSection
variant="dialog"
query={tmdbDebouncedQuery}
libraryHadHits={items.length > 0}
/>
))}
)}
</div>
<div role="status" aria-live="polite" className="sr-only">
{items.length} results found
{tmdbVisibleCount > 0
? `${items.length} library results, ${tmdbVisibleCount} request suggestions`
: `${items.length} results found`}
</div>
<div className="text-muted-foreground border-t px-3 py-2 text-center text-xs">
{total > PREVIEW_LIMIT ? (
+116
View File
@@ -0,0 +1,116 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
useNavigate: () => mocks.navigate,
};
});
import PageBack from "./PageBack";
describe("PageBack", () => {
afterEach(() => {
mocks.navigate.mockClear();
window.history.replaceState(null, "");
});
it("renders a button with the default 'Go back' aria-label", () => {
render(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "Go back" })).toBeInTheDocument();
});
it("uses a custom label when provided", () => {
render(
<MemoryRouter>
<PageBack label="Return to library" />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "Return to library" })).toBeInTheDocument();
});
it("falls back to the default route when there is no router history", async () => {
render(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("button", { name: "Go back" }));
expect(mocks.navigate).toHaveBeenCalledTimes(1);
expect(mocks.navigate).toHaveBeenCalledWith("/");
});
it("uses browser history when a router history entry is available", async () => {
window.history.replaceState({ idx: 1 }, "");
render(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("button", { name: "Go back" }));
expect(mocks.navigate).toHaveBeenCalledTimes(1);
expect(mocks.navigate).toHaveBeenCalledWith(-1);
});
it("uses the explicit target when history preference is disabled", async () => {
window.history.replaceState({ idx: 1 }, "");
render(
<MemoryRouter>
<PageBack to="/collections" preferHistory={false} />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("button", { name: "Go back" }));
expect(mocks.navigate).toHaveBeenCalledTimes(1);
expect(mocks.navigate).toHaveBeenCalledWith("/collections");
});
it("applies the documented positioning and glass styling", () => {
render(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
const button = screen.getByRole("button", { name: "Go back" });
expect(button).toHaveClass(
"glass",
"absolute",
"top-4",
"left-2",
"z-20",
"rounded-full",
"p-1.5",
);
});
it("pins to the viewport on lg+ when floating is set", () => {
render(
<MemoryRouter>
<PageBack floating />
</MemoryRouter>,
);
const button = screen.getByRole("button", { name: "Go back" });
expect(button).toHaveClass("lg:fixed", "lg:left-[268px]");
});
});
+48
View File
@@ -0,0 +1,48 @@
import { ChevronLeft } from "lucide-react";
import { type To, useNavigate } from "react-router";
interface PageBackProps {
label?: string;
to?: To;
preferHistory?: boolean;
/**
* When true, pins the button to the viewport on lg+ so it stays visible
* while scrolling. The offset matches the app sidebar (260px) so the
* button sits just inside the page content area.
*/
floating?: boolean;
}
export default function PageBack({
label = "Go back",
to = "/",
preferHistory = true,
floating = false,
}: PageBackProps) {
const navigate = useNavigate();
const position = floating
? "absolute top-4 left-2 sm:top-6 lg:fixed lg:left-[268px]"
: "absolute top-4 left-2 sm:top-6";
function goBack() {
const historyIndex = window.history.state?.idx;
if (preferHistory && typeof historyIndex === "number" && historyIndex > 0) {
navigate(-1);
return;
}
navigate(to);
}
return (
<button
type="button"
aria-label={label}
onClick={goBack}
className={`glass text-foreground hover:bg-accent ${position} z-20 flex items-center justify-center rounded-full p-1.5 shadow-md transition-colors`}
>
<ChevronLeft className="size-5" />
</button>
);
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import RequestPosterCard from "./RequestPosterCard";
import type { RequestMediaResult } from "@/api/types";
const requestable: RequestMediaResult = {
media_type: "movie",
tmdb_id: 42,
title: "Test Movie",
availability: "missing",
request: { requestable: true },
};
describe("RequestPosterCard (discover variant)", () => {
it("renders the hover Request button when onRequest is provided", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<RequestPosterCard
variant="discover"
item={requestable}
isSubmitting={false}
onRequest={() => {}}
/>
</MemoryRouter>,
);
// Must render an actual <button> with the "Request" label, not just any "Request"
// substring (the /requests/... URL would match a naive includes check).
expect(markup).toMatch(/<button[^>]*>[\s\S]*?Request[\s\S]*?<\/button>/);
});
it("does not render the hover Request button when onRequest is omitted", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<RequestPosterCard variant="discover" item={requestable} />
</MemoryRouter>,
);
// The discover variant only contains one <button> (the hover Request action);
// its absence is the strongest signal that the button was suppressed.
expect(markup).not.toContain("<button");
});
});
+8 -6
View File
@@ -9,8 +9,10 @@ const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]";
type DiscoverProps = {
variant: "discover";
item: RequestMediaResult;
isSubmitting: boolean;
onRequest: () => void;
/** Called when the inline hover Request button is clicked. Omit to suppress the button. */
onRequest?: () => void;
/** Displays the spinner state on the hover Request button. Ignored when onRequest is omitted. */
isSubmitting?: boolean;
/** When true, fills the parent (use inside grids). Default: fixed carousel width. */
fluid?: boolean;
};
@@ -44,8 +46,8 @@ function DiscoverCard({
fluid,
}: {
item: RequestMediaResult;
isSubmitting: boolean;
onRequest: () => void;
isSubmitting?: boolean;
onRequest?: () => void;
fluid?: boolean;
}) {
const poster = tmdbImageURL(item.poster_path);
@@ -92,11 +94,11 @@ function DiscoverCard({
/>
</Link>
{requestable && (
{requestable && onRequest && (
<div className="pointer-events-none absolute inset-x-0 top-0 flex aspect-[2/3] translate-y-2 items-end justify-center bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 opacity-0 transition-all duration-200 ease-out group-focus-within/req-card:translate-y-0 group-focus-within/req-card:opacity-100 group-hover/req-card:translate-y-0 group-hover/req-card:opacity-100">
<button
type="button"
disabled={isSubmitting}
disabled={Boolean(isSubmitting)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -0,0 +1,335 @@
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mocks = vi.hoisted(() => ({
useCanRequest: vi.fn(),
useRequestSearch: vi.fn(),
useCreateMediaRequest: vi.fn(),
useDebounce: vi.fn(),
}));
vi.mock("@/hooks/useCanRequest", () => ({
useCanRequest: () => mocks.useCanRequest(),
}));
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args),
useCreateMediaRequest: () => mocks.useCreateMediaRequest(),
}));
vi.mock("@/hooks/useDebounce", () => ({
useDebounce: <T,>(v: T) => mocks.useDebounce(v) ?? v,
}));
import { RequestToAddSection } from "./RequestToAddSection";
import type { RequestMediaResult } from "@/api/types";
function render(child: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<MemoryRouter>{child}</MemoryRouter>
</QueryClientProvider>,
);
}
const missingResult = (overrides: Partial<RequestMediaResult> = {}): RequestMediaResult => ({
media_type: "movie",
tmdb_id: 1,
title: "Dune: Prophecy",
year: 2024,
availability: "missing",
request: { requestable: true },
...overrides,
});
const availableResult = (overrides: Partial<RequestMediaResult> = {}): RequestMediaResult => ({
media_type: "movie",
tmdb_id: 2,
title: "Dune",
year: 2021,
availability: "available",
request: { requestable: false },
...overrides,
});
describe("RequestToAddSection (dialog variant)", () => {
beforeEach(() => {
mocks.useCanRequest.mockReset();
mocks.useRequestSearch.mockReset();
mocks.useDebounce.mockReset();
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useDebounce.mockImplementation((v: unknown) => v);
});
it("renders nothing when discovery is disabled", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toBe("");
});
it("passes enabled=false to useRequestSearch when discovery is disabled so no network call fires", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
expect(call?.[0]).toBe("all");
expect(call?.[1]).toBe("dune");
expect(call?.[2]).toBe(1);
expect(call?.[3]).toEqual({
enabled: false,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
});
it("passes enabled=true to useRequestSearch when discovery is enabled", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 0, results: [] },
isLoading: false,
isError: false,
});
render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
expect(call?.[3]).toEqual({
enabled: true,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
});
it("renders 'Request to Add' header when library had hits", () => {
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] },
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Request to Add");
expect(markup).toContain("Dune: Prophecy");
});
it("renders soft framing when library had 0 hits", () => {
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] },
isLoading: false,
isError: false,
});
const markup = render(
<RequestToAddSection variant="dialog" query="dune" libraryHadHits={false} />,
);
expect(markup).toContain("Not in your library, but you can request");
expect(markup).not.toContain("Request to Add");
});
it("filters out results already available in the library", () => {
// missingResult has tmdb_id 1, availableResult has tmdb_id 2. The DialogRow
// renders item.title only as text content (never as a `title=` attribute), so
// a substring check on `title="Dune"` would pass even with the filter removed.
// Check the link target instead — it's a precise, filter-driven signal.
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 2,
results: [availableResult(), missingResult()],
},
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("/requests/movie/1");
expect(markup).not.toContain("/requests/movie/2");
});
it("renders nothing when TMDB returned an error", () => {
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: true });
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toBe("");
});
it("keeps rendering cached TMDB results when a refetch errors", () => {
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] },
isLoading: false,
isError: true,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Dune: Prophecy");
});
it("renders nothing when all TMDB results are already in the library", () => {
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 1, results: [availableResult()] },
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toBe("");
});
it("limits the dialog variant to at most 4 rows", () => {
const many = Array.from({ length: 10 }, (_, i) =>
missingResult({ tmdb_id: i + 100, title: `Result ${i}` }),
);
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: many.length, results: many },
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Result 0");
expect(markup).toContain("Result 3");
expect(markup).not.toContain("Result 4");
});
it("renders the disabled affordance and reason when a row is not requestable", () => {
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
missingResult({
tmdb_id: 7,
title: "Quota Capped Movie",
// formatRequestReason recognises "quota_exceeded" (not "quota_exhausted");
// assert on the produced label so a regression in that mapping is caught.
request: { requestable: false, reason: "quota_exceeded" },
}),
],
},
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Quota Capped Movie");
expect(markup).not.toContain("bg-amber-400/15");
expect(markup).toContain("Limit reached");
expect(markup).toContain('title="Limit reached"');
});
it("prefers request status over reason when a row is already requested", () => {
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
missingResult({
tmdb_id: 8,
title: "Already Pending Movie",
request: { requestable: false, reason: "blocked", status: "pending" },
}),
],
},
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Already Pending Movie");
expect(markup).toContain("Pending");
expect(markup).toContain('title="Pending"');
expect(markup).not.toContain('title="Blocked"');
});
});
describe("RequestToAddSection (grid variant)", () => {
beforeEach(() => {
mocks.useCanRequest.mockReset();
mocks.useRequestSearch.mockReset();
mocks.useCreateMediaRequest.mockReset();
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useCreateMediaRequest.mockReturnValue({
mutate: vi.fn(),
isPending: false,
variables: undefined,
});
});
it("renders a card per result with the Request to Add header when library had hits", () => {
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 2,
results: [
missingResult({ tmdb_id: 1, title: "Dune: Prophecy" }),
missingResult({ tmdb_id: 2, title: "Dune (1984)" }),
],
},
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="grid" query="dune" libraryHadHits />);
expect(markup).toContain("Request to Add");
expect(markup).toContain("Dune: Prophecy");
expect(markup).toContain("Dune (1984)");
});
it("renders the soft framing in the grid variant when library had 0 hits", () => {
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [missingResult({ tmdb_id: 1, title: "Dune: Prophecy" })],
},
isLoading: false,
isError: false,
});
const markup = render(
<RequestToAddSection variant="grid" query="dune" libraryHadHits={false} />,
);
expect(markup).toContain("Not in your library, but you can request");
});
it("limits the grid to at most 20 cards", () => {
const many = Array.from({ length: 30 }, (_, i) =>
missingResult({ tmdb_id: i + 100, title: `Result ${i}` }),
);
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: many.length, results: many },
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="grid" query="dune" libraryHadHits />);
expect(markup).toContain("Result 0");
expect(markup).toContain("Result 19");
expect(markup).not.toContain("Result 20");
});
});

Some files were not shown because too many files have changed in this diff Show More