Merge remote-tracking branch 'origin/main' into feat/audiobooks
# Conflicts: # go.sum
This commit is contained in:
+24
-10
@@ -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
@@ -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 file’s 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ©, 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 ©, 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 ©, 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
|
||||
}
|
||||
@@ -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/"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()")
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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+`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ©, 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 ©, 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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.users
|
||||
DROP COLUMN IF EXISTS permissions;
|
||||
@@ -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;
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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="true"");
|
||||
expect(markup).toContain("variant="dialog"");
|
||||
});
|
||||
|
||||
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="false"");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user