Merge remote-tracking branch 'origin/main' into feat/audiobooks
# Conflicts: # go.sum
This commit is contained in:
+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.
|
||||
Reference in New Issue
Block a user