diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 3fbf2f0e..d55daae6 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -57,6 +57,7 @@ import ( "github.com/Silo-Server/silo-server/internal/intromarkers" "github.com/Silo-Server/silo-server/internal/jellycompat" "github.com/Silo-Server/silo-server/internal/libraryingest" + "github.com/Silo-Server/silo-server/internal/literaryworks" "github.com/Silo-Server/silo-server/internal/logfilter" "github.com/Silo-Server/silo-server/internal/logstream" "github.com/Silo-Server/silo-server/internal/mail" @@ -802,6 +803,11 @@ func main() { configureS3Clients(cfg, &deps) } + var literaryWorkService *literaryworks.Service + if deps.DB != nil { + literaryWorkService = literaryworks.NewService(literaryworks.NewRepository(deps.DB)) + } + // Step 4: Create scanner (if needed). if needsScanner && deps.DB != nil { folderRepo := catalog.NewFolderRepository(deps.DB) @@ -814,6 +820,7 @@ func main() { configWatcher.OnChange(func(_, updated *config.Config) { s.SetWorkers(updated.Scanner.Workers) }) + s.SetLiteraryWorkLinker(literaryWorkService) deps.Scanner = s deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second) slog.Info("scanner initialized") @@ -1140,6 +1147,8 @@ func main() { personRepo, providerIDRepo, ) + audiobookEnricher.SetLiteraryWorkLinker(literaryWorkService) + ebookEnricher.SetLiteraryWorkLinker(literaryWorkService) // Always wire the image resolver so plugin-prefixed URLs (e.g. // metadb://) can be resolved to presigned HTTP URLs in API responses. diff --git a/docs/superpowers/plans/2026-06-09-literary-works.md b/docs/superpowers/plans/2026-06-09-literary-works.md new file mode 100644 index 00000000..e1196fad --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-literary-works.md @@ -0,0 +1,1560 @@ +# Unified Literary Works 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:** Add a server-owned literary work identity that links ebooks, audiobooks, and future literary formats without collapsing their existing `media_items` identities. + +**Architecture:** Add neutral `literary_works` and `literary_work_items` tables, then build an `internal/literaryworks` package for repository, matching, and response assembly. Existing item detail and catalog APIs remain item-based by default, with additive work fields and opt-in grouped catalog results. + +**Tech Stack:** Go, pgx, PostgreSQL goose migrations, existing catalog/detail handlers, existing ebook reader progress and audiobook playback progress storage, chi routes, standard Go tests. + +--- + +## File Structure + +- Create `migrations/sql/20260609150000_literary_works.sql`: work tables, indexes, and cleanup trigger. +- Create `internal/literaryworks/types.go`: API/domain response structs and constants. +- Create `internal/literaryworks/normalize.go`: title/author/series normalization helpers. +- Create `internal/literaryworks/repository.go`: SQL read/write methods. +- Create `internal/literaryworks/matcher.go`: candidate scoring and automatic linking rules. +- Create `internal/literaryworks/service.go`: work detail assembly and admin orchestration. +- Create `internal/literaryworks/*_test.go`: focused repository, matcher, and service tests. +- Modify `internal/catalog/detail.go`: add optional work fields to `ItemDetail` and a tiny `WorkSummaryProvider` interface. +- Modify `internal/api/handlers/catalog_resources.go`: ensure work fields are enriched on item detail responses. +- Create `internal/api/handlers/literary_works.go`: public work detail and admin link/match endpoints. +- Modify `internal/api/router.go`: register `/api/v1/works/{work_id}` and admin literary work routes. +- Modify catalog request/query files for `group=work` only after the core work endpoint passes. + +## Task 1: Prepare The Correct Feature Base + +**Files:** +- No source files changed. + +- [ ] **Step 1: Create an implementation worktree from the ebook stack head** + +Run: + +```bash +cd /Users/jimcole/projects/silo/silo-server +git fetch origin +git worktree add ../silo-server-literary-works origin/work/ebook-reader-ruler-profiles +cd ../silo-server-literary-works +git switch -c feat/literary-works +``` + +Expected: new branch `feat/literary-works` exists and includes `internal/ebooks`, `internal/scanner/ebook_scan.go`, and `internal/api/handlers/ebook_reader.go`. + +- [ ] **Step 2: Copy the approved spec into the feature worktree if absent** + +Run: + +```bash +test -f docs/superpowers/specs/2026-06-09-literary-works-design.md || \ + cp ../silo-server/docs/superpowers/specs/2026-06-09-literary-works-design.md docs/superpowers/specs/ +``` + +Expected: `docs/superpowers/specs/2026-06-09-literary-works-design.md` exists in the worktree. + +- [ ] **Step 3: Verify the starting point** + +Run: + +```bash +go test ./internal/catalog ./internal/scanner ./internal/ebooks -count=1 +``` + +Expected: packages pass before literary work changes begin. If this fails, stop and fix/rebase the base branch first. + +## Task 2: Add The Literary Work Schema + +**Files:** +- Create: `migrations/sql/20260609150000_literary_works.sql` +- Test: migration smoke through package tests that load migrations. + +- [ ] **Step 1: Write the migration** + +Create `migrations/sql/20260609150000_literary_works.sql`: + +```sql +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS literary_works ( + work_id TEXT PRIMARY KEY, + canonical_title TEXT NOT NULL, + sort_title TEXT, + normalized_title TEXT NOT NULL, + primary_author_key TEXT NOT NULL DEFAULT '', + primary_cover_content_id TEXT REFERENCES media_items(content_id) ON DELETE SET NULL, + description TEXT, + published_date DATE, + publisher TEXT, + genres TEXT[] NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS literary_work_items ( + work_id TEXT NOT NULL REFERENCES literary_works(work_id) ON DELETE CASCADE, + content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + format_type TEXT NOT NULL, + link_source TEXT NOT NULL, + confidence DOUBLE PRECISION NOT NULL DEFAULT 1, + confirmed_at TIMESTAMPTZ, + ignored_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (work_id, content_id), + UNIQUE (content_id), + CHECK (format_type IN ('ebook', 'audiobook', 'comic', 'manga')), + CHECK (link_source IN ('manual', 'external_id', 'metadata_match', 'series_match', 'scan_seed')), + CHECK (confidence >= 0 AND confidence <= 1) +); + +CREATE TABLE IF NOT EXISTS literary_work_match_decisions ( + source_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + target_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + decision TEXT NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (source_content_id, target_content_id), + CHECK (source_content_id <> target_content_id), + CHECK (decision IN ('confirmed', 'ignored')) +); + +CREATE INDEX IF NOT EXISTS idx_literary_works_normalized + ON literary_works (normalized_title, primary_author_key); + +CREATE INDEX IF NOT EXISTS idx_literary_work_items_content + ON literary_work_items (content_id); + +CREATE INDEX IF NOT EXISTS idx_literary_work_items_format + ON literary_work_items (format_type, work_id); + +CREATE INDEX IF NOT EXISTS idx_literary_work_match_decisions_target + ON literary_work_match_decisions (target_content_id, decision); + +CREATE OR REPLACE FUNCTION delete_empty_literary_works() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM literary_works lw + WHERE lw.work_id = OLD.work_id + AND NOT EXISTS ( + SELECT 1 FROM literary_work_items lwi WHERE lwi.work_id = OLD.work_id + ); + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_delete_empty_literary_works ON literary_work_items; +CREATE TRIGGER trg_delete_empty_literary_works +AFTER DELETE ON literary_work_items +FOR EACH ROW EXECUTE FUNCTION delete_empty_literary_works(); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS trg_delete_empty_literary_works ON literary_work_items; +DROP FUNCTION IF EXISTS delete_empty_literary_works(); +DROP TABLE IF EXISTS literary_work_match_decisions; +DROP TABLE IF EXISTS literary_work_items; +DROP TABLE IF EXISTS literary_works; +-- +goose StatementEnd +``` + +- [ ] **Step 2: Run migration-aware tests** + +Run: + +```bash +go test ./migrations -count=1 +``` + +Expected: migrations package passes. + +- [ ] **Step 3: Commit** + +Run: + +```bash +git add migrations/sql/20260609150000_literary_works.sql +git commit -m "feat(literary): add work link schema" +``` + +## Task 3: Add Domain Types And Normalization + +**Files:** +- Create: `internal/literaryworks/types.go` +- Create: `internal/literaryworks/normalize.go` +- Test: `internal/literaryworks/normalize_test.go` + +- [ ] **Step 1: Write failing normalization tests** + +Create `internal/literaryworks/normalize_test.go`: + +```go +package literaryworks + +import "testing" + +func TestNormalizeKey(t *testing.T) { + tests := map[string]string{ + "Project Hail Mary": "project hail mary", + "The Last Adventure: A Novel": "last adventure novel", + " A Constance-Verity Tale! ": "constance verity tale", + } + for input, want := range tests { + if got := normalizeKey(input); got != want { + t.Fatalf("normalizeKey(%q) = %q, want %q", input, got, want) + } + } +} + +func TestPersonKey(t *testing.T) { + if got := personKey([]string{" Andy Weir ", "Other"}); got != "andy weir" { + t.Fatalf("personKey = %q, want primary normalized author", got) + } +} +``` + +- [ ] **Step 2: Run the failing tests** + +Run: + +```bash +go test ./internal/literaryworks -run 'TestNormalizeKey|TestPersonKey' -count=1 +``` + +Expected: FAIL because package/files do not exist. + +- [ ] **Step 3: Add types and normalization** + +Create `internal/literaryworks/types.go`: + +```go +package literaryworks + +import "time" + +const ( + FormatEbook = "ebook" + FormatAudiobook = "audiobook" + FormatComic = "comic" + FormatManga = "manga" + + LinkManual = "manual" + LinkExternalID = "external_id" + LinkMetadataMatch = "metadata_match" + LinkSeriesMatch = "series_match" + LinkScanSeed = "scan_seed" + + DecisionConfirmed = "confirmed" + DecisionIgnored = "ignored" +) + +type Work struct { + WorkID string + CanonicalTitle string + SortTitle string + NormalizedTitle string + PrimaryAuthorKey string + PrimaryCoverContentID string + Description string + PublishedDate *time.Time + Publisher string + Genres []string + CreatedAt time.Time + UpdatedAt time.Time +} + +type WorkItem struct { + WorkID string + ContentID string + FormatType string + LinkSource string + Confidence float64 + ConfirmedAt *time.Time + IgnoredAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type Candidate struct { + SourceContentID string `json:"source_content_id"` + TargetContentID string `json:"target_content_id"` + TargetWorkID string `json:"target_work_id,omitempty"` + Score float64 `json:"score"` + LinkSource string `json:"link_source"` + Evidence map[string]string `json:"evidence"` +} + +type WorkSummary struct { + WorkID string `json:"work_id,omitempty"` + Title string `json:"work_title,omitempty"` + Formats []WorkFormatSummary `json:"work_formats,omitempty"` +} + +type WorkFormatSummary struct { + Type string `json:"type"` + ContentID string `json:"content_id"` + LibraryID int `json:"library_id,omitempty"` +} +``` + +Create `internal/literaryworks/normalize.go`: + +```go +package literaryworks + +import ( + "strings" + "unicode" +) + +var leadingArticles = map[string]struct{}{ + "a": {}, + "an": {}, + "the": {}, +} + +func normalizeKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastSpace := true + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + lastSpace = false + continue + } + if !lastSpace { + b.WriteByte(' ') + lastSpace = true + } + } + parts := strings.Fields(b.String()) + if len(parts) > 0 { + if _, ok := leadingArticles[parts[0]]; ok { + parts = parts[1:] + } + } + return strings.Join(parts, " ") +} + +func personKey(names []string) string { + for _, name := range names { + if key := normalizeKey(name); key != "" { + return key + } + } + return "" +} +``` + +- [ ] **Step 4: Run the tests** + +Run: + +```bash +go test ./internal/literaryworks -run 'TestNormalizeKey|TestPersonKey' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add internal/literaryworks +git commit -m "feat(literary): add work domain primitives" +``` + +## Task 4: Implement Repository CRUD + +**Files:** +- Create: `internal/literaryworks/repository.go` +- Test: `internal/literaryworks/repository_test.go` + +- [ ] **Step 1: Write repository tests** + +Create `internal/literaryworks/repository_test.go` with tests that use the repo test database helper pattern already present in catalog tests. The core assertions must be: + +```go +func TestRepositoryLinkAndFetchSummary(t *testing.T) { + ctx := context.Background() + pool := newLiteraryWorksTestPool(t) + seedLiteraryMediaItem(t, pool, "ebook-1", "ebook", "Project Hail Mary") + seedLiteraryMediaItem(t, pool, "audio-1", "audiobook", "Project Hail Mary") + + repo := NewRepository(pool) + work, err := repo.CreateWork(ctx, CreateWorkParams{ + WorkID: "work-1", CanonicalTitle: "Project Hail Mary", + NormalizedTitle: "project hail mary", PrimaryAuthorKey: "andy weir", + }) + if err != nil { + t.Fatal(err) + } + if work.WorkID != "work-1" { + t.Fatalf("work id = %q", work.WorkID) + } + if err := repo.LinkItems(ctx, "work-1", []LinkItemParams{ + {ContentID: "ebook-1", FormatType: FormatEbook, LinkSource: LinkManual, Confidence: 1}, + {ContentID: "audio-1", FormatType: FormatAudiobook, LinkSource: LinkManual, Confidence: 1}, + }); err != nil { + t.Fatal(err) + } + summary, err := repo.GetSummaryForContentID(ctx, "ebook-1") + if err != nil { + t.Fatal(err) + } + if summary.WorkID != "work-1" || len(summary.Formats) != 2 { + t.Fatalf("summary = %#v, want work with two formats", summary) + } +} +``` + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +go test ./internal/literaryworks -run TestRepositoryLinkAndFetchSummary -count=1 +``` + +Expected: FAIL because repository functions are undefined. + +- [ ] **Step 3: Implement repository methods** + +Create `internal/literaryworks/repository.go` with: + +```go +package literaryworks + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +type CreateWorkParams struct { + WorkID string + CanonicalTitle string + SortTitle string + NormalizedTitle string + PrimaryAuthorKey string + Description string + Publisher string + Genres []string +} + +type LinkItemParams struct { + ContentID string + FormatType string + LinkSource string + Confidence float64 +} + +func (r *Repository) CreateWork(ctx context.Context, p CreateWorkParams) (*Work, error) { + row := r.pool.QueryRow(ctx, ` + INSERT INTO literary_works ( + work_id, canonical_title, sort_title, normalized_title, + primary_author_key, description, publisher, genres + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (work_id) DO UPDATE SET + canonical_title = EXCLUDED.canonical_title, + sort_title = EXCLUDED.sort_title, + normalized_title = EXCLUDED.normalized_title, + primary_author_key = EXCLUDED.primary_author_key, + description = EXCLUDED.description, + publisher = EXCLUDED.publisher, + genres = EXCLUDED.genres, + updated_at = NOW() + RETURNING work_id, canonical_title, COALESCE(sort_title, ''), normalized_title, + primary_author_key, COALESCE(primary_cover_content_id, ''), + COALESCE(description, ''), NULL::timestamptz, COALESCE(publisher, ''), + genres, created_at, updated_at + `, p.WorkID, p.CanonicalTitle, p.SortTitle, p.NormalizedTitle, p.PrimaryAuthorKey, p.Description, p.Publisher, p.Genres) + return scanWork(row) +} + +func (r *Repository) LinkItems(ctx context.Context, workID string, items []LinkItemParams) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + for _, item := range items { + if item.Confidence == 0 { + item.Confidence = 1 + } + _, err := tx.Exec(ctx, ` + INSERT INTO literary_work_items (work_id, content_id, format_type, link_source, confidence, confirmed_at) + VALUES ($1,$2,$3,$4,$5, CASE WHEN $4 = 'manual' THEN NOW() ELSE NULL END) + ON CONFLICT (content_id) DO UPDATE SET + work_id = EXCLUDED.work_id, + format_type = EXCLUDED.format_type, + link_source = EXCLUDED.link_source, + confidence = EXCLUDED.confidence, + confirmed_at = EXCLUDED.confirmed_at, + ignored_at = NULL, + updated_at = NOW() + `, workID, item.ContentID, item.FormatType, item.LinkSource, item.Confidence) + if err != nil { + return fmt.Errorf("linking %s to work %s: %w", item.ContentID, workID, err) + } + } + return tx.Commit(ctx) +} + +func (r *Repository) GetSummaryForContentID(ctx context.Context, contentID string) (*WorkSummary, error) { + rows, err := r.pool.Query(ctx, ` + SELECT lw.work_id, lw.canonical_title, lwi.format_type, lwi.content_id, + COALESCE(MIN(mil.media_folder_id), 0)::int + FROM literary_work_items anchor + JOIN literary_works lw ON lw.work_id = anchor.work_id + JOIN literary_work_items lwi ON lwi.work_id = lw.work_id + LEFT JOIN media_item_libraries mil ON mil.content_id = lwi.content_id + WHERE anchor.content_id = $1 + GROUP BY lw.work_id, lw.canonical_title, lwi.format_type, lwi.content_id + ORDER BY lwi.format_type, lwi.content_id + `, contentID) + if err != nil { + return nil, err + } + defer rows.Close() + var summary *WorkSummary + for rows.Next() { + var format WorkFormatSummary + var workID, title string + if err := rows.Scan(&workID, &title, &format.Type, &format.ContentID, &format.LibraryID); err != nil { + return nil, err + } + if summary == nil { + summary = &WorkSummary{WorkID: workID, Title: title} + } + summary.Formats = append(summary.Formats, format) + } + return summary, rows.Err() +} + +func scanWork(row pgx.Row) (*Work, error) { + var w Work + if err := row.Scan( + &w.WorkID, &w.CanonicalTitle, &w.SortTitle, &w.NormalizedTitle, + &w.PrimaryAuthorKey, &w.PrimaryCoverContentID, &w.Description, + &w.PublishedDate, &w.Publisher, &w.Genres, &w.CreatedAt, &w.UpdatedAt, + ); err != nil { + return nil, err + } + return &w, nil +} +``` + +- [ ] **Step 4: Run repository tests** + +Run: + +```bash +go test ./internal/literaryworks -run TestRepositoryLinkAndFetchSummary -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add internal/literaryworks +git commit -m "feat(literary): persist work links" +``` + +## Task 5: Implement Matching Rules + +**Files:** +- Create: `internal/literaryworks/matcher.go` +- Test: `internal/literaryworks/matcher_test.go` + +- [ ] **Step 1: Write matcher tests** + +Create tests for these concrete cases: + +```go +func TestMatcherSameTitleAuthorLinks(t *testing.T) { + ebook := MatchItem{ContentID: "e1", Type: FormatEbook, Title: "Project Hail Mary", Authors: []string{"Andy Weir"}} + audio := MatchItem{ContentID: "a1", Type: FormatAudiobook, Title: "Project Hail Mary", Authors: []string{"Andy Weir"}, Narrators: []string{"Ray Porter"}} + candidate := ScoreCandidate(ebook, audio) + if candidate.Score < AutoLinkThreshold || candidate.LinkSource != LinkMetadataMatch { + t.Fatalf("candidate = %#v, want metadata auto-link", candidate) + } +} + +func TestMatcherSameTitleDifferentAuthorDoesNotLink(t *testing.T) { + ebook := MatchItem{ContentID: "e1", Type: FormatEbook, Title: "The Last Adventure", Authors: []string{"A. Lee"}} + audio := MatchItem{ContentID: "a1", Type: FormatAudiobook, Title: "The Last Adventure", Authors: []string{"B. Lee"}} + candidate := ScoreCandidate(ebook, audio) + if candidate.Score >= AutoLinkThreshold { + t.Fatalf("score = %v, want below threshold", candidate.Score) + } +} + +func TestMatcherSeriesIndexLinksSubtitleVariants(t *testing.T) { + ebook := MatchItem{ContentID: "e1", Type: FormatEbook, Title: "The Last Adventure", Authors: []string{"A. Lee"}, SeriesName: "Constance Verity", SeriesIndex: floatPtr(1)} + audio := MatchItem{ContentID: "a1", Type: FormatAudiobook, Title: "Constance Verity 1 - The Last Adventure of Constance Verity", Authors: []string{"A. Lee"}, SeriesName: "Constance Verity", SeriesIndex: floatPtr(1)} + candidate := ScoreCandidate(ebook, audio) + if candidate.Score < AutoLinkThreshold || candidate.LinkSource != LinkSeriesMatch { + t.Fatalf("candidate = %#v, want series auto-link", candidate) + } +} +``` + +- [ ] **Step 2: Run failing matcher tests** + +Run: + +```bash +go test ./internal/literaryworks -run 'TestMatcher' -count=1 +``` + +Expected: FAIL because matcher types/functions are undefined. + +- [ ] **Step 3: Implement matcher** + +Create `internal/literaryworks/matcher.go`: + +```go +package literaryworks + +const AutoLinkThreshold = 0.86 + +type MatchItem struct { + ContentID string + Type string + Title string + Authors []string + Narrators []string + SeriesName string + SeriesIndex *float64 + ExternalIDs map[string]string + Publisher string + Year int +} + +func ScoreCandidate(source, target MatchItem) Candidate { + if source.ContentID == "" || target.ContentID == "" || source.ContentID == target.ContentID { + return Candidate{Score: 0, Evidence: map[string]string{"reason": "same_or_missing_content_id"}} + } + evidence := map[string]string{} + sourceAuthor := personKey(source.Authors) + targetAuthor := personKey(target.Authors) + titleMatch := normalizeKey(source.Title) != "" && normalizeKey(source.Title) == normalizeKey(target.Title) + authorMatch := sourceAuthor != "" && sourceAuthor == targetAuthor + seriesMatch := normalizeKey(source.SeriesName) != "" && + normalizeKey(source.SeriesName) == normalizeKey(target.SeriesName) && + source.SeriesIndex != nil && target.SeriesIndex != nil && + *source.SeriesIndex == *target.SeriesIndex + + if provider, id, ok := sharedExternalID(source.ExternalIDs, target.ExternalIDs); ok { + evidence["external_id"] = provider + ":" + id + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.98, LinkSource: LinkExternalID, Evidence: evidence} + } + if seriesMatch && authorMatch { + evidence["series"] = source.SeriesName + evidence["author"] = sourceAuthor + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.92, LinkSource: LinkSeriesMatch, Evidence: evidence} + } + if titleMatch && authorMatch { + evidence["title"] = source.Title + evidence["author"] = sourceAuthor + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.9, LinkSource: LinkMetadataMatch, Evidence: evidence} + } + if titleMatch && sourceAuthor != "" && targetAuthor != "" && sourceAuthor != targetAuthor { + evidence["conflict"] = "author" + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.2, LinkSource: LinkMetadataMatch, Evidence: evidence} + } + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.4, LinkSource: LinkMetadataMatch, Evidence: evidence} +} + +func sharedExternalID(a, b map[string]string) (string, string, bool) { + for provider, aID := range a { + if aID == "" || provider == "asin" { + continue + } + if bID := b[provider]; bID != "" && bID == aID { + return provider, aID, true + } + } + return "", "", false +} +``` + +- [ ] **Step 4: Run matcher tests** + +Run: + +```bash +go test ./internal/literaryworks -run 'TestMatcher' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add internal/literaryworks +git commit -m "feat(literary): score work matches" +``` + +## Task 6: Add Work Summary To Existing Item Detail + +**Files:** +- Modify: `internal/catalog/detail.go` +- Test: `internal/catalog/detail_literary_work_test.go` + +- [ ] **Step 1: Write item detail test** + +Create `internal/catalog/detail_literary_work_test.go`: + +```go +package catalog + +import ( + "context" + "testing" +) + +type fakeWorkSummaryProvider struct{} + +func (fakeWorkSummaryProvider) GetSummaryForContentID(ctx context.Context, contentID string) (*WorkSummary, error) { + return &WorkSummary{ + WorkID: "work-1", + Title: "Project Hail Mary", + Formats: []WorkFormatSummary{ + {Type: "ebook", ContentID: contentID, LibraryID: 1}, + {Type: "audiobook", ContentID: "audio-1", LibraryID: 2}, + }, + }, nil +} + +func TestItemDetailIncludesWorkSummaryWhenProviderConfigured(t *testing.T) { + detail := &ItemDetail{ContentID: "ebook-1", Type: "ebook", Title: "Project Hail Mary"} + applyWorkSummary(context.Background(), detail, fakeWorkSummaryProvider{}) + if detail.WorkID != "work-1" || len(detail.WorkFormats) != 2 { + t.Fatalf("detail work fields = %#v", detail) + } +} +``` + +- [ ] **Step 2: Run failing test** + +Run: + +```bash +go test ./internal/catalog -run TestItemDetailIncludesWorkSummaryWhenProviderConfigured -count=1 +``` + +Expected: FAIL because work fields/types/functions are undefined. + +- [ ] **Step 3: Add catalog-facing types and helper** + +In `internal/catalog/detail.go`, add fields to `ItemDetail`: + +```go +WorkID string `json:"work_id,omitempty"` +WorkTitle string `json:"work_title,omitempty"` +WorkFormats []WorkFormatSummary `json:"work_formats,omitempty"` +``` + +Add catalog-local interface/types near the detail service interfaces: + +```go +type WorkSummaryProvider interface { + GetSummaryForContentID(ctx context.Context, contentID string) (*WorkSummary, error) +} + +type WorkSummary struct { + WorkID string + Title string + Formats []WorkFormatSummary +} + +type WorkFormatSummary struct { + Type string `json:"type"` + ContentID string `json:"content_id"` + LibraryID int `json:"library_id,omitempty"` +} + +func applyWorkSummary(ctx context.Context, detail *ItemDetail, provider WorkSummaryProvider) { + if detail == nil || provider == nil || detail.ContentID == "" { + return + } + summary, err := provider.GetSummaryForContentID(ctx, detail.ContentID) + if err != nil || summary == nil { + return + } + detail.WorkID = summary.WorkID + detail.WorkTitle = summary.Title + detail.WorkFormats = summary.Formats +} +``` + +Add `workSummaryProvider WorkSummaryProvider` to `DetailService`, plus setter: + +```go +func (s *DetailService) SetWorkSummaryProvider(provider WorkSummaryProvider) { + if s != nil { + s.workSummaryProvider = provider + } +} +``` + +Call `applyWorkSummary(ctx, detail, s.workSummaryProvider)` before returning media item details. + +- [ ] **Step 4: Run detail tests** + +Run: + +```bash +go test ./internal/catalog -run 'TestItemDetailIncludesWorkSummary|Audiobook|Ebook' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add internal/catalog/detail.go internal/catalog/detail_literary_work_test.go +git commit -m "feat(catalog): include literary work summary on item detail" +``` + +## Task 7: Add Public Work Detail Endpoint + +**Files:** +- Create: `internal/api/handlers/literary_works.go` +- Modify: `internal/api/router.go` +- Modify: `cmd/silo/main.go` +- Test: `internal/api/handlers/literary_works_test.go` + +- [ ] **Step 1: Write handler test** + +Create a handler test asserting `GET /api/v1/works/work-1` returns a work response with separate format entries: + +```go +func TestLiteraryWorkHandlerGetWork(t *testing.T) { + handler := &LiteraryWorkHandler{Service: fakeLiteraryWorkService{ + work: &literaryworks.DetailResponse{ + WorkID: "work-1", WorkTitle: "Project Hail Mary", + Formats: []literaryworks.FormatResponse{ + {Type: "ebook", ContentID: "ebook-1"}, + {Type: "audiobook", ContentID: "audio-1"}, + }, + }, + }} + req := httptest.NewRequest(http.MethodGet, "/api/v1/works/work-1", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("work_id", "work-1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rec := httptest.NewRecorder() + handler.HandleGetWork(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"formats"`) || !strings.Contains(rec.Body.String(), `"audiobook"`) { + t.Fatalf("body = %s, want work formats", rec.Body.String()) + } +} +``` + +- [ ] **Step 2: Run failing handler test** + +Run: + +```bash +go test ./internal/api/handlers -run TestLiteraryWorkHandlerGetWork -count=1 +``` + +Expected: FAIL because handler/service response types are undefined. + +- [ ] **Step 3: Add response types and handler** + +Extend `internal/literaryworks/types.go` with: + +```go +type DetailResponse struct { + WorkID string `json:"work_id"` + WorkTitle string `json:"work_title"` + Authors []PersonResponse `json:"authors"` + Formats []FormatResponse `json:"formats"` + PrimaryCoverURL string `json:"primary_cover_url,omitempty"` + Metadata WorkMetadata `json:"metadata"` +} + +type PersonResponse struct { + PersonID string `json:"person_id,omitempty"` + Name string `json:"name"` +} + +type FormatResponse struct { + Type string `json:"type"` + ContentID string `json:"content_id"` + LibraryID int `json:"library_id,omitempty"` + AvailableFiles []FileResponse `json:"available_files"` + Progress *ProgressResponse `json:"progress,omitempty"` +} + +type FileResponse struct { + FileID int `json:"file_id"` + OriginalName string `json:"original_filename"` + Format string `json:"format"` + MIMEType string `json:"mime_type,omitempty"` + Size int64 `json:"size,omitempty"` + DurationSeconds float64 `json:"duration_seconds,omitempty"` +} + +type ProgressResponse struct { + Kind string `json:"kind"` + Progress *float64 `json:"progress,omitempty"` + PositionSeconds *float64 `json:"position_seconds,omitempty"` + DurationSeconds *float64 `json:"duration_seconds,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type WorkMetadata struct { + Description string `json:"description,omitempty"` + Series *SeriesInfo `json:"series,omitempty"` + Genres []string `json:"genres"` + PublishedDate string `json:"published_date,omitempty"` + Publisher string `json:"publisher,omitempty"` +} + +type SeriesInfo struct { + Name string `json:"name"` + Index *float64 `json:"index,omitempty"` +} +``` + +Create `internal/api/handlers/literary_works.go`: + +```go +package handlers + +import ( + "context" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/literaryworks" +) + +type LiteraryWorkService interface { + GetWork(ctx context.Context, workID string, filter catalog.AccessFilter) (*literaryworks.DetailResponse, error) +} + +type LiteraryWorkHandler struct { + Service LiteraryWorkService +} + +func (h *LiteraryWorkHandler) HandleGetWork(w http.ResponseWriter, r *http.Request) { + workID := strings.TrimSpace(chi.URLParam(r, "work_id")) + if workID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "work_id is required") + return + } + if h == nil || h.Service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured") + return + } + resp, err := h.Service.GetWork(r.Context(), workID, requestAccessFilter(r)) + if err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "Work not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load work") + return + } + writeJSON(w, http.StatusOK, resp) +} +``` + +- [ ] **Step 4: Register routes and dependencies** + +In `internal/api/router.go`, add authenticated route: + +```go +r.Get("/works/{work_id}", literaryWorkHandler.HandleGetWork) +``` + +In `cmd/silo/main.go`, construct `literaryworks.Repository` and `literaryworks.Service`, set it on `DetailService`, and pass a `LiteraryWorkHandler` into router dependency construction following existing handler patterns. + +- [ ] **Step 5: Run handler and build tests** + +Run: + +```bash +go test ./internal/api/handlers ./internal/literaryworks ./internal/catalog -count=1 +go test ./cmd/silo -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add internal/api/handlers/literary_works.go internal/api/router.go cmd/silo/main.go internal/literaryworks internal/catalog/detail.go +git commit -m "feat(literary): expose work detail API" +``` + +## Task 8: Assemble Work Detail With Files And Progress + +**Files:** +- Create/modify: `internal/literaryworks/service.go` +- Modify: `internal/literaryworks/repository.go` +- Test: `internal/literaryworks/service_test.go` + +- [ ] **Step 1: Write service tests** + +Write `TestServiceGetWorkReturnsSeparateFormatProgress` that seeds one ebook, one audiobook, `media_files`, ebook progress, and audiobook progress. Assert: + +```go +if len(resp.Formats) != 2 { + t.Fatalf("formats = %d, want 2", len(resp.Formats)) +} +if ebook.Progress == nil || ebook.Progress.Kind != "reading" { + t.Fatalf("ebook progress = %#v, want reading", ebook.Progress) +} +if audio.Progress == nil || audio.Progress.Kind != "listening" { + t.Fatalf("audio progress = %#v, want listening", audio.Progress) +} +if ebook.AvailableFiles[0].Format != "epub" || audio.AvailableFiles[0].DurationSeconds == 0 { + t.Fatalf("files = %#v %#v, want ebook format and audio duration", ebook.AvailableFiles, audio.AvailableFiles) +} +``` + +- [ ] **Step 2: Run failing test** + +Run: + +```bash +go test ./internal/literaryworks -run TestServiceGetWorkReturnsSeparateFormatProgress -count=1 +``` + +Expected: FAIL because work detail assembly is not implemented. + +- [ ] **Step 3: Implement service assembly** + +Create `internal/literaryworks/service.go`: + +```go +package literaryworks + +import ( + "context" + "mime" + "path/filepath" + "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetWork(ctx context.Context, workID string, filter catalog.AccessFilter) (*DetailResponse, error) { + work, items, err := s.repo.GetWorkWithItems(ctx, workID, filter) + if err != nil { + return nil, err + } + resp := &DetailResponse{ + WorkID: work.WorkID, + WorkTitle: work.CanonicalTitle, + Metadata: WorkMetadata{ + Description: work.Description, + Genres: work.Genres, + Publisher: work.Publisher, + }, + } + for _, item := range items { + format := FormatResponse{ + Type: item.FormatType, + ContentID: item.ContentID, + LibraryID: item.LibraryID, + AvailableFiles: filesToResponse(item.Files), + Progress: item.Progress, + } + resp.Formats = append(resp.Formats, format) + } + return resp, nil +} + +func filesToResponse(files []WorkFile) []FileResponse { + out := make([]FileResponse, 0, len(files)) + for _, f := range files { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(f.FilePath)), ".") + mimeType := mime.TypeByExtension("." + ext) + out = append(out, FileResponse{ + FileID: f.FileID, + OriginalName: filepath.Base(f.FilePath), + Format: ext, + MIMEType: mimeType, + Size: f.Size, + DurationSeconds: f.DurationSeconds, + }) + } + return out +} +``` + +Add repository methods/types for `GetWorkWithItems`, `WorkItemDetail`, and `WorkFile`: + +```go +type WorkItemDetail struct { + ContentID string + FormatType string + LibraryID int + Files []WorkFile + Progress *ProgressResponse +} + +type WorkFile struct { + FileID int + FilePath string + Size int64 + DurationSeconds float64 +} + +func (r *Repository) GetWorkWithItems(ctx context.Context, workID string, filter catalog.AccessFilter) (*Work, []WorkItemDetail, error) { + work, err := r.GetWork(ctx, workID) + if err != nil { + return nil, nil, err + } + rows, err := r.pool.Query(ctx, ` + SELECT lwi.content_id, lwi.format_type, COALESCE(MIN(mil.media_folder_id), 0)::int + FROM literary_work_items lwi + JOIN media_items mi ON mi.content_id = lwi.content_id + LEFT JOIN media_item_libraries mil ON mil.content_id = lwi.content_id + WHERE lwi.work_id = $1 + GROUP BY lwi.content_id, lwi.format_type + ORDER BY lwi.format_type, lwi.content_id + `, workID) + if err != nil { + return nil, nil, err + } + defer rows.Close() + var items []WorkItemDetail + for rows.Next() { + var item WorkItemDetail + if err := rows.Scan(&item.ContentID, &item.FormatType, &item.LibraryID); err != nil { + return nil, nil, err + } + item.Files, err = r.ListFiles(ctx, item.ContentID) + if err != nil { + return nil, nil, err + } + item.Progress, err = r.GetProgress(ctx, item.ContentID, item.FormatType, filter) + if err != nil { + return nil, nil, err + } + items = append(items, item) + } + return work, items, rows.Err() +} +``` + +Implement `ListFiles` with: + +```sql +SELECT id, file_path, COALESCE(file_size, 0), COALESCE(duration, 0)::double precision / 1000 +FROM media_files +WHERE content_id = $1 AND missing_since IS NULL +ORDER BY file_path ASC +``` + +Implement `GetProgress` so `format_type = 'ebook'` reads `ebook_reader_progress` by `filter.UserID` and `filter.ProfileID`; `format_type = 'audiobook'` reads the same progress source currently used by audiobook item user state. If audiobook progress lookup is not exposed as a repository yet, add a narrow interface to `Service` rather than querying a second progress implementation directly from handlers. + +- [ ] **Step 4: Run service tests** + +Run: + +```bash +go test ./internal/literaryworks -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add internal/literaryworks +git commit -m "feat(literary): assemble work detail" +``` + +## Task 9: Add Admin Match/Link Primitives + +**Files:** +- Modify: `internal/api/handlers/literary_works.go` +- Modify: `internal/literaryworks/service.go` +- Test: `internal/api/handlers/literary_works_admin_test.go` + +- [ ] **Step 1: Write admin handler tests** + +Create `internal/api/handlers/literary_works_admin_test.go` with explicit request/response tests: + +```go +func TestAdminLiteraryWorkLinkRequiresTwoItems(t *testing.T) { + handler := &LiteraryWorkHandler{Service: &fakeAdminLiteraryWorkService{}} + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/literary-works/link", strings.NewReader(`{"content_ids":["ebook-1"]}`)) + rec := httptest.NewRecorder() + handler.HandleAdminLink(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String()) + } +} + +func TestAdminLiteraryWorkLinkCreatesManualWork(t *testing.T) { + svc := &fakeAdminLiteraryWorkService{} + handler := &LiteraryWorkHandler{Service: svc} + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/literary-works/link", strings.NewReader(`{"content_ids":["ebook-1","audio-1"]}`)) + rec := httptest.NewRecorder() + handler.HandleAdminLink(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s, want 200", rec.Code, rec.Body.String()) + } + if !reflect.DeepEqual(svc.linkedIDs, []string{"ebook-1", "audio-1"}) { + t.Fatalf("linked ids = %#v", svc.linkedIDs) + } + if !strings.Contains(rec.Body.String(), `"work_id":"work-1"`) { + t.Fatalf("body = %s, want work id", rec.Body.String()) + } +} + +func TestAdminLiteraryWorkIgnoreStoresDecision(t *testing.T) { + svc := &fakeAdminLiteraryWorkService{} + handler := &LiteraryWorkHandler{Service: svc} + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/literary-works/matches/ignore", strings.NewReader(`{"source_content_id":"ebook-1","target_content_id":"audio-1"}`)) + rec := httptest.NewRecorder() + handler.HandleAdminIgnoreMatch(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d body=%s, want 204", rec.Code, rec.Body.String()) + } + if svc.ignoredSource != "ebook-1" || svc.ignoredTarget != "audio-1" { + t.Fatalf("ignored = %q/%q", svc.ignoredSource, svc.ignoredTarget) + } +} +``` + +- [ ] **Step 2: Run failing admin tests** + +Run: + +```bash +go test ./internal/api/handlers -run 'TestAdminLiteraryWork' -count=1 +``` + +Expected: FAIL because admin handlers are missing. + +- [ ] **Step 3: Implement admin endpoints** + +Add request structs: + +```go +type literaryWorkLinkRequest struct { + ContentIDs []string `json:"content_ids"` +} + +type literaryWorkDecisionRequest struct { + SourceContentID string `json:"source_content_id"` + TargetContentID string `json:"target_content_id"` +} +``` + +Add handler methods with these signatures: + +```go +func (h *LiteraryWorkHandler) HandleAdminLink(w http.ResponseWriter, r *http.Request) +func (h *LiteraryWorkHandler) HandleAdminUnlink(w http.ResponseWriter, r *http.Request) +func (h *LiteraryWorkHandler) HandleAdminMatches(w http.ResponseWriter, r *http.Request) +func (h *LiteraryWorkHandler) HandleAdminIgnoreMatch(w http.ResponseWriter, r *http.Request) +func (h *LiteraryWorkHandler) HandleAdminConfirmMatch(w http.ResponseWriter, r *http.Request) +``` + +Each method validates IDs before calling the service. `HandleAdminLink` rejects fewer than two IDs. `HandleAdminIgnoreMatch` and `HandleAdminConfirmMatch` reject blank IDs or identical IDs. Not-found service errors use `404`; other service errors use `500`; successful ignore/unlink returns `204`; successful link/confirm returns JSON containing `work_id`. + +- [ ] **Step 4: Register admin routes** + +In `internal/api/router.go`, inside admin route group: + +```go +r.Get("/literary-works/items/{content_id}/matches", literaryWorkHandler.HandleAdminMatches) +r.Post("/literary-works/link", literaryWorkHandler.HandleAdminLink) +r.Post("/literary-works/{work_id}/unlink", literaryWorkHandler.HandleAdminUnlink) +r.Post("/literary-works/matches/ignore", literaryWorkHandler.HandleAdminIgnoreMatch) +r.Post("/literary-works/matches/confirm", literaryWorkHandler.HandleAdminConfirmMatch) +``` + +- [ ] **Step 5: Run admin tests** + +Run: + +```bash +go test ./internal/api/handlers ./internal/literaryworks -run 'LiteraryWork|Matcher|Repository' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add internal/api/handlers/literary_works.go internal/api/router.go internal/literaryworks +git commit -m "feat(literary): add admin work linking primitives" +``` + +## Task 10: Add Opt-In Work-Grouped Catalog Results + +**Files:** +- Modify: `internal/catalog/catalog_request.go` +- Modify: `internal/catalog/catalog_parser.go` +- Modify: `internal/catalog/query_executor.go` +- Modify: `internal/api/handlers/catalog.go` +- Test: `internal/catalog/catalog_parser_test.go` +- Test: `internal/catalog/query_executor_literary_work_test.go` + +- [ ] **Step 1: Write parser test** + +Add: + +```go +func TestParseCatalogRequestGroupWork(t *testing.T) { + req, err := ParseCatalogRequest(url.Values{ + "type": {"reading"}, + "group": {"work"}, + }) + if err != nil { + t.Fatal(err) + } + if req.Group != "work" { + t.Fatalf("Group = %q, want work", req.Group) + } +} +``` + +- [ ] **Step 2: Run failing parser test** + +Run: + +```bash +go test ./internal/catalog -run TestParseCatalogRequestGroupWork -count=1 +``` + +Expected: FAIL because `CatalogRequest.Group` is missing. + +- [ ] **Step 3: Add request parsing** + +Add to `CatalogRequest`: + +```go +Group string +``` + +In `ParseCatalogRequest`, accept only `group=work` or empty: + +```go +if group := strings.ToLower(strings.TrimSpace(values.Get("group"))); group != "" { + if group != "work" { + return CatalogRequest{}, fmt.Errorf("unsupported catalog group %q", group) + } + req.Group = group +} +``` + +- [ ] **Step 4: Implement grouped query projection** + +Add a grouped query path only when `req.Group == "work"` and media scope is reading/literary. Start with this CTE shape and adapt aliases to the existing `QueryExecutor` plan builder: + +```sql +WITH scoped_items AS ( + SELECT mi.* + FROM media_items mi + WHERE mi.type IN ('ebook', 'audiobook') +), +grouped AS ( + SELECT + COALESCE(lwi.work_id, si.content_id) AS group_id, + ARRAY_AGG(DISTINCT si.type ORDER BY si.type) AS work_formats, + (ARRAY_AGG(si.content_id ORDER BY + CASE si.type WHEN 'ebook' THEN 0 WHEN 'audiobook' THEN 1 ELSE 2 END, + si.content_id + ))[1] AS representative_content_id + FROM scoped_items si + LEFT JOIN literary_work_items lwi ON lwi.content_id = si.content_id + GROUP BY COALESCE(lwi.work_id, si.content_id) +) +SELECT mi.*, grouped.group_id, grouped.work_formats +FROM grouped +JOIN media_items mi ON mi.content_id = grouped.representative_content_id +``` + +The default item query SQL remains unchanged when `group` is empty. The grouped result should add response fields `work_id` and `work_formats` without removing `content_id`, so existing card rendering can still navigate to a representative item until the client adopts `/api/v1/works/{work_id}`. + +- [ ] **Step 5: Run catalog tests** + +Run: + +```bash +go test ./internal/catalog ./internal/api/handlers -run 'Catalog|Work' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add internal/catalog internal/api/handlers +git commit -m "feat(catalog): support work-grouped literary browsing" +``` + +## Task 11: Wire Automatic Matching After Scan/Enrichment + +**Files:** +- Modify: `internal/scanner/ebook_scan.go` +- Modify: `internal/scanner/audiobook_scan.go` +- Modify: `internal/ebooks/enrichment.go` +- Modify: `internal/audiobooks/enrichment.go` +- Test: scanner/enrichment tests with fake matcher. + +- [ ] **Step 1: Add tiny matcher interface near scanners/enrichers** + +Use an interface to avoid importing handler code: + +```go +type LiteraryWorkMatcher interface { + MatchContentID(ctx context.Context, contentID string) error +} +``` + +- [ ] **Step 2: Invoke matcher after successful item/file/people/series persistence** + +For ebook and audiobook scan paths, call: + +```go +if s.literaryMatcher != nil { + if err := s.literaryMatcher.MatchContentID(ctx, contentID); err != nil { + slog.Warn("literary work match failed", "content_id", contentID, "error", err) + } +} +``` + +Do the same after successful metadata enrichment so improved external IDs and authors can link works. + +- [ ] **Step 3: Test non-fatal matcher failure** + +Add tests that make the fake matcher return an error and assert the scan/enrichment still succeeds. + +- [ ] **Step 4: Run scanner/enrichment tests** + +Run: + +```bash +go test ./internal/scanner ./internal/ebooks ./internal/audiobooks -run 'Ebook|Audiobook|Literary' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add internal/scanner internal/ebooks internal/audiobooks +git commit -m "feat(literary): match works after literary metadata updates" +``` + +## Task 12: Final Verification + +**Files:** +- No planned source edits unless verification exposes defects. + +- [ ] **Step 1: Run focused backend tests** + +Run: + +```bash +go test ./internal/literaryworks ./internal/catalog ./internal/api/handlers ./internal/scanner ./internal/ebooks ./internal/audiobooks ./migrations -count=1 +``` + +Expected: PASS. + +- [ ] **Step 2: Run broader server tests** + +Run: + +```bash +go test ./internal/... ./cmd/silo ./migrations -count=1 +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend tests if work-grouped catalog UI is touched** + +Run: + +```bash +cd web +pnpm test --run +``` + +Expected: PASS. If `node_modules` is unavailable, run `pnpm install --frozen-lockfile` first or document the exact failure. + +- [ ] **Step 4: Build the Docker image** + +Run: + +```bash +docker buildx build --load -t silo-server:literary-works . +``` + +Expected: image builds successfully, including frontend build. + +- [ ] **Step 5: Prepare PR** + +Run: + +```bash +git status --short +git log --oneline origin/work/ebook-reader-ruler-profiles..HEAD +``` + +Expected: clean or intentionally documented working tree, with commits from this plan only. diff --git a/docs/superpowers/specs/2026-06-09-literary-works-design.md b/docs/superpowers/specs/2026-06-09-literary-works-design.md new file mode 100644 index 00000000..abf349a9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-literary-works-design.md @@ -0,0 +1,248 @@ +# Unified Literary Works Design + +## Goal + +Silo should expose a server-owned literary work identity that links multiple format-specific catalog items for the same book, especially ebooks and audiobooks, without collapsing those items into one `media_items` row. Clients can then render one detail page with format tabs/actions while existing item detail, playback, reading, download, and catalog APIs keep working. + +The implementation should target the ebook stack head, currently `work/ebook-reader-ruler-profiles`, which includes the ebook foundation, #98 local-cover/grouping work, and #99 reader profile/ruler work. That stack already has ebook scanning, ebook series metadata, ebook reader progress, and ebook detail extensions. The work layer should sit above those existing format-specific systems. + +## Existing Context + +`media_items.content_id` is the stable identity for each catalog item. Audiobooks are `media_items.type = 'audiobook'`; ebooks are introduced as `media_items.type = 'ebook'` in the ebook stack. These identities must remain independent because each format has different files, progress, playback/reader behavior, external compatibility, and library membership. + +Audiobooks already store author and narrator credits in `item_people`, series data in `audiobook_series`, file data in `media_files`, and listening progress in existing playback/watch progress tables. The ebook stack adds ebook author credits, `ebook_series`, `ebook_reader_progress`, reader state/config, and ebook file serving. #98 also groups sibling ebook file formats by book identity during scan; that is an intra-ebook item grouping behavior, not the cross-format work identity. + +The current catalog detail endpoint is `GET /api/v1/catalog/items/{id}` through `CatalogResourceHandler.HandleGetItemDetail`, backed by `catalog.DetailService.GetItemDetail`. Catalog browse/search remains item-oriented by default through catalog query/browse code. + +## Data Model + +Add a neutral work layer: + +```sql +CREATE TABLE literary_works ( + work_id TEXT PRIMARY KEY, + canonical_title TEXT NOT NULL, + sort_title TEXT, + normalized_title TEXT NOT NULL, + primary_author_key TEXT NOT NULL DEFAULT '', + primary_cover_content_id TEXT REFERENCES media_items(content_id) ON DELETE SET NULL, + description TEXT, + published_date DATE, + publisher TEXT, + genres TEXT[] NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE literary_work_items ( + work_id TEXT NOT NULL REFERENCES literary_works(work_id) ON DELETE CASCADE, + content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + format_type TEXT NOT NULL, + link_source TEXT NOT NULL, + confidence DOUBLE PRECISION NOT NULL DEFAULT 1, + confirmed_at TIMESTAMPTZ, + ignored_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (work_id, content_id), + UNIQUE (content_id), + CHECK (format_type IN ('ebook', 'audiobook', 'comic', 'manga')), + CHECK (link_source IN ('manual', 'external_id', 'metadata_match', 'series_match', 'scan_seed')) +); + +CREATE TABLE literary_work_match_decisions ( + source_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + target_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + decision TEXT NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (source_content_id, target_content_id), + CHECK (source_content_id <> target_content_id), + CHECK (decision IN ('confirmed', 'ignored')) +); +``` + +Every ebook or audiobook can belong to at most one work through `UNIQUE (content_id)`. A work can contain many format-specific items. Manual/admin actions update `literary_work_items` and `literary_work_match_decisions`; automatic matching must not override an ignored pair or move a manually linked item without an explicit admin request. + +Work IDs should be generated by the existing server ID generator, not derived directly from title text. Matching keys can change when metadata improves, but the `work_id` remains stable. + +## Matching Rules + +Matching runs in a `literaryworks` service after scan/enrichment or as an admin-triggered repair job. It considers literary item types only: `ebook`, `audiobook`, and later `comic`/`manga`. + +Authority order: + +1. Manual confirmed links and manual unlinks/ignored pairs. +2. Shared external IDs from `media_item_provider_ids`, excluding format-specific aliases that are not true work identity. +3. Normalized title plus normalized author. +4. Normalized series name plus series index plus author. +5. Publication year/date and publisher as supporting evidence only. + +Automatic matching should be conservative. Same title alone is not enough. Same title plus different primary author should not link. Same title plus same author can link unless there is strong conflicting series/index evidence. Same series/index plus same author can link even if titles differ slightly due subtitle/edition formatting. Narrator is never required for ebook matching and must not prevent an ebook/audiobook work link; narrator remains audiobook-format metadata. + +The matcher should produce scored candidates. Scores at or above the automatic threshold can create links when neither item has conflicting manual state. Lower scores are exposed as possible matches for admin review. + +## Service Boundaries + +Create an `internal/literaryworks` package with: + +- `Repository`: reads/writes `literary_works`, memberships, decisions, and candidate rows. +- `Matcher`: normalizes item metadata, scores candidates, and applies conservative automatic links. +- `Service`: public orchestration used by handlers and background jobs. + +The package depends on catalog repositories and the database pool, but catalog detail should depend on a small interface so item detail can include work data without creating a large import cycle. + +The matcher should reuse existing item metadata: + +- Titles from `media_items`. +- Authors from `item_people.kind = author`. +- Audiobook narrators from `item_people.kind = narrator` only for display inside audiobook format details. +- Series from `audiobook_series` and `ebook_series`. +- External IDs from `media_item_provider_ids`. +- File metadata from `media_files`. + +## API Contract + +Add a work endpoint: + +`GET /api/v1/works/{work_id}` + +Response: + +```json +{ + "work_id": "work_123", + "work_title": "Project Hail Mary", + "authors": [{ "name": "Andy Weir", "person_id": "42" }], + "formats": [ + { + "type": "ebook", + "content_id": "ebook_1", + "library_id": 1, + "available_files": [ + { + "file_id": 10, + "original_filename": "Project Hail Mary.epub", + "format": "epub", + "mime_type": "application/epub+zip", + "size": 12345 + } + ], + "progress": { + "kind": "reading", + "progress": 0.42, + "updated_at": "2026-06-09T12:00:00Z" + } + }, + { + "type": "audiobook", + "content_id": "audio_1", + "library_id": 2, + "available_files": [ + { + "file_id": 20, + "original_filename": "Project Hail Mary.m4b", + "format": "m4b", + "mime_type": "audio/mp4", + "size": 987654321, + "duration_seconds": 58200 + } + ], + "progress": { + "kind": "listening", + "position_seconds": 1200, + "duration_seconds": 58200, + "updated_at": "2026-06-09T12:00:00Z" + } + } + ], + "primary_cover_url": "/api/v1/images/...", + "metadata": { + "description": "...", + "series": { "name": "The Series", "index": 1 }, + "genres": ["Science Fiction"], + "published_date": "2021-05-04", + "publisher": "Ballantine Books" + } +} +``` + +Extend `GET /api/v1/catalog/items/{content_id}` additively: + +```json +{ + "content_id": "ebook_1", + "type": "ebook", + "work_id": "work_123", + "work_title": "Project Hail Mary", + "work_formats": [ + { "type": "ebook", "content_id": "ebook_1", "library_id": 1 }, + { "type": "audiobook", "content_id": "audio_1", "library_id": 2 } + ] +} +``` + +Existing fields and behavior remain unchanged. Clients that do not know about works can ignore the new fields. + +## Catalog Grouping + +Add optional work grouping for literary media: + +`GET /api/v1/catalog?type=reading&group=work` + +The grouped response should return one card per work when a linked work exists, with format badges such as `ebook` and `audiobook`. Unlinked literary items still appear as single-item cards with an implicit one-item work projection. Non-literary media remain item-based. + +Do not change default catalog/search behavior. Work grouping must be opt-in so current clients and Jellyfin/ABS compatibility routes are unaffected. + +## Progress and Files + +Progress remains per format and per existing content identity: + +- Ebook progress reads from `ebook_reader_progress`. +- Audiobook progress reads from existing playback/watch progress storage. + +The work response aggregates those format-specific progress rows without merging them. + +Available files come from `media_files`, grouped by format content ID. File metadata should include `file_id`, original basename, extension/container, MIME type when known, byte size, and duration for audio. Android can continue using existing file/download routes by `file_id`; the work endpoint only tells it what is available. + +## Admin Primitives + +Add admin endpoints: + +- `GET /api/v1/admin/literary-works/items/{content_id}/matches`: list possible work/item matches with score and evidence. +- `POST /api/v1/admin/literary-works/link`: link two or more content IDs into one work. +- `POST /api/v1/admin/literary-works/{work_id}/unlink`: unlink one content ID from a work. +- `POST /api/v1/admin/literary-works/matches/ignore`: mark a candidate pair ignored. +- `POST /api/v1/admin/literary-works/matches/confirm`: confirm a candidate pair and link them. + +Admin linking must validate that every content ID is a supported literary type and accessible to the admin. Unlinking the last item can delete the empty work. + +## Backward Compatibility + +All existing item detail, catalog, reader, playback, download, Jellyfin compatibility, and ABS compatibility routes remain valid. The new tables cascade from `media_items`, so library deletion and orphan cleanup continue to remove format items; work cleanup should delete empty works after membership deletion. + +No caller is required to supply `work_id` for existing format actions. Work APIs are additive and read-oriented except for admin management endpoints. + +## Testing Strategy + +Repository and matcher tests should cover: + +- Ebook and audiobook with same normalized title and author link to one work. +- Same title with different authors does not auto-link. +- Same author and series/index links despite title subtitle differences. +- Shared external ID links above fuzzy metadata. +- Manual ignored pair blocks automatic linking. +- Manual confirmed link wins over automatic scoring. +- Unlink removes one item without deleting the format media item. +- Existing item detail JSON still includes normal fields when no work exists. +- Existing catalog browse/search remains item-based unless `group=work`. +- Work endpoint returns separate ebook and audiobook progress. +- Work endpoint returns file metadata for EPUB/PDF/CBZ and M4B/MP3 without changing download behavior. + +Handler tests should assert route status codes and response shape. SQL tests should include cascade/empty-work cleanup behavior. + +## Rollout Plan + +Implement the schema and service behind additive APIs first. Then wire automatic matching after scan/enrichment. Finally add grouped catalog support. This keeps default behavior stable while giving clients and admin tooling a safe way to adopt works. diff --git a/internal/api/handlers/catalog.go b/internal/api/handlers/catalog.go index 38eb6efe..e62de5a7 100644 --- a/internal/api/handlers/catalog.go +++ b/internal/api/handlers/catalog.go @@ -17,8 +17,9 @@ import ( ) type CatalogHandler struct { - resolver *catalog.CatalogResolver - itemsH *ItemsHandler + resolver *catalog.CatalogResolver + itemsH *ItemsHandler + workSummary catalog.WorkSummaryProvider } func NewCatalogHandler(resolver *catalog.CatalogResolver, itemsH *ItemsHandler) *CatalogHandler { @@ -28,6 +29,10 @@ func NewCatalogHandler(resolver *catalog.CatalogResolver, itemsH *ItemsHandler) } } +func (h *CatalogHandler) SetWorkSummaryProvider(provider catalog.WorkSummaryProvider) { + h.workSummary = provider +} + type catalogResponse struct { Total int `json:"total"` TotalExact bool `json:"total_exact"` @@ -62,8 +67,36 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } + accessFilter := h.itemsH.accessFilter(r) + groupedByWork := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("group")), "work") + if groupedByWork { + result, entries, err := h.resolveGroupedCatalogByWork(r, req, accessFilter) + if err != nil { + if errors.Is(err, catalog.ErrInvalidCatalogRequest) { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if errors.Is(err, catalog.ErrCatalogSourceNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Catalog source not found") + return + } + slog.Error("catalog: resolve grouped by work failed", "err_msg", err.Error()) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog") + return + } - result, err := h.resolver.Resolve(r.Context(), req, h.itemsH.accessFilter(r)) + resultItems := groupedCatalogItems(entries) + items := h.catalogItemResponses(r, resultItems, catalog.NormalizeQuerySort(req.Query.Sort).Field, accessFilter) + for i := range items { + if i < len(entries) && entries[i].summary != nil { + applyWorkSummaryToCatalogItem(&items[i], entries[i].summary) + } + } + h.writeCatalogResponse(w, result, items, groupedByWork) + return + } + + result, err := h.resolver.Resolve(r.Context(), req, accessFilter) if err != nil { if errors.Is(err, catalog.ErrInvalidCatalogRequest) { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) @@ -77,24 +110,27 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog") return } + items := h.catalogItemResponses(r, result.Items, catalog.NormalizeQuerySort(req.Query.Sort).Field, accessFilter) + h.writeCatalogResponse(w, result, items, groupedByWork) +} - overlaySummaries := h.itemsH.listOverlaySummaries(r.Context(), result.Items, h.itemsH.accessFilter(r)) - userStates := h.itemsH.listItemUserStates(r, result.Items) - episodeMetadata := h.itemsH.listEpisodeBrowseMetadata(r.Context(), result.Items) - sortField := catalog.NormalizeQuerySort(req.Query.Sort).Field +func (h *CatalogHandler) catalogItemResponses(r *http.Request, resultItems []*models.MediaItem, sortField string, accessFilter catalog.AccessFilter) []itemListResponse { + overlaySummaries := h.itemsH.listOverlaySummaries(r.Context(), resultItems, accessFilter) + userStates := h.itemsH.listItemUserStates(r, resultItems) + episodeMetadata := h.itemsH.listEpisodeBrowseMetadata(r.Context(), resultItems) store, profileID, _ := h.itemsH.userStoreForRequest(r) sortMetrics := h.itemsH.listSortMetrics( r.Context(), - result.Items, + resultItems, sortField, - h.itemsH.accessFilter(r), + accessFilter, overlaySummaries, store, apimw.GetUserID(r.Context()), profileID, ) - items := make([]itemListResponse, 0, len(result.Items)) - for _, item := range result.Items { + items := make([]itemListResponse, 0, len(resultItems)) + for _, item := range resultItems { resp := h.itemsH.toItemListResponseWithOverlay( r, item, @@ -109,7 +145,10 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request resp.SortMetrics = sortMetrics[item.ContentID] items = append(items, resp) } + return items +} +func (h *CatalogHandler) writeCatalogResponse(w http.ResponseWriter, result *catalog.CatalogResult, items []itemListResponse, groupedByWork bool) { var snapshot string if !result.SnapshotAt.IsZero() { snapshot = result.SnapshotAt.Format(time.RFC3339Nano) @@ -117,13 +156,164 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, catalogResponse{ Total: result.Total, - TotalExact: result.TotalExact, + TotalExact: result.TotalExact && !groupedByWork, HasMore: result.HasMore, Items: items, Snapshot: snapshot, }) } +type groupedCatalogEntry struct { + item *models.MediaItem + summary *catalog.WorkSummary +} + +func (h *CatalogHandler) resolveGroupedCatalogByWork(r *http.Request, req catalog.CatalogRequest, accessFilter catalog.AccessFilter) (*catalog.CatalogResult, []groupedCatalogEntry, error) { + fetchReq := req + fetchReq.Offset = 0 + fetchReq.Limit = groupedCatalogFetchLimit(req.Limit) + fetchReq.SkipTotal = true + + seen := map[string]struct{}{} + entries := make([]groupedCatalogEntry, 0, req.Limit+1) + groupIndex := 0 + var snapshot time.Time + + for { + result, err := h.resolver.Resolve(r.Context(), fetchReq, accessFilter) + if err != nil { + return nil, nil, err + } + if snapshot.IsZero() { + snapshot = result.SnapshotAt + if !snapshot.IsZero() { + fetchReq.SnapshotAt = &snapshot + } + } + summaries, err := h.workSummariesForItems(r, result.Items, accessFilter) + if err != nil { + return nil, nil, err + } + for _, item := range result.Items { + summary := summaries[item.ContentID] + key := groupedCatalogEntryKey(item, summary) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if groupIndex >= req.Offset { + entries = append(entries, groupedCatalogEntry{item: item, summary: summary}) + } + groupIndex++ + if len(entries) > req.Limit { + break + } + } + if len(entries) > req.Limit || !result.HasMore || len(result.Items) == 0 { + break + } + fetchReq.Offset += len(result.Items) + } + + hasMore := len(entries) > req.Limit + if hasMore { + entries = entries[:req.Limit] + } + total := req.Offset + len(entries) + if hasMore { + total++ + } + result := &catalog.CatalogResult{ + Items: groupedCatalogItems(entries), + Total: total, + HasMore: hasMore, + TotalExact: false, + SnapshotAt: snapshot, + } + return result, entries, nil +} + +func groupedCatalogFetchLimit(limit int) int { + switch { + case limit <= 0: + return 100 + case limit >= 100: + return 100 + default: + return min(100, limit*4+10) + } +} + +func (h *CatalogHandler) workSummariesForItems(r *http.Request, items []*models.MediaItem, filter catalog.AccessFilter) (map[string]*catalog.WorkSummary, error) { + summaries := map[string]*catalog.WorkSummary{} + if h == nil || h.workSummary == nil || len(items) == 0 { + return summaries, nil + } + contentIDs := make([]string, 0, len(items)) + seen := map[string]struct{}{} + for _, item := range items { + if !catalogItemCanGroupByWork(item) { + continue + } + if _, ok := seen[item.ContentID]; ok { + continue + } + seen[item.ContentID] = struct{}{} + contentIDs = append(contentIDs, item.ContentID) + } + if len(contentIDs) == 0 { + return summaries, nil + } + if batch, ok := h.workSummary.(catalog.WorkSummaryBatchProvider); ok { + return batch.ListSummariesForContentIDs(r.Context(), contentIDs, filter) + } + for _, contentID := range contentIDs { + summary, err := h.workSummary.GetSummaryForContentID(r.Context(), contentID, filter) + if err != nil { + return nil, err + } + if summary != nil && summary.WorkID != "" { + summaries[contentID] = summary + } + } + return summaries, nil +} + +func groupedCatalogItems(entries []groupedCatalogEntry) []*models.MediaItem { + items := make([]*models.MediaItem, 0, len(entries)) + for _, entry := range entries { + items = append(items, entry.item) + } + return items +} + +func groupedCatalogEntryKey(item *models.MediaItem, summary *catalog.WorkSummary) string { + if catalogItemCanGroupByWork(item) && summary != nil && summary.WorkID != "" { + return "work:" + summary.WorkID + } + if item == nil { + return "item:" + } + return "item:" + item.ContentID +} + +func catalogItemCanGroupByWork(item *models.MediaItem) bool { + return item != nil && (item.Type == "ebook" || item.Type == "audiobook") +} + +func applyWorkSummaryToCatalogItem(item *itemListResponse, summary *catalog.WorkSummary) { + if item == nil || summary == nil || summary.WorkID == "" { + return + } + item.Type = "work" + item.WorkID = summary.WorkID + item.WorkTitle = summary.Title + item.WorkFormats = summary.Formats + if summary.Title != "" { + item.Title = summary.Title + } +} + func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.Request) { if h == nil || h.resolver == nil || h.itemsH == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") diff --git a/internal/api/handlers/catalog_literary_work_test.go b/internal/api/handlers/catalog_literary_work_test.go new file mode 100644 index 00000000..723c5a16 --- /dev/null +++ b/internal/api/handlers/catalog_literary_work_test.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "context" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeCatalogWorkSummaryProvider struct{} + +func (fakeCatalogWorkSummaryProvider) GetSummaryForContentID(ctx context.Context, contentID string, filter catalog.AccessFilter) (*catalog.WorkSummary, error) { + if contentID != "ebook-1" && contentID != "audio-1" { + return nil, nil + } + return &catalog.WorkSummary{ + WorkID: "work-1", + Title: "Project Hail Mary", + Formats: []catalog.WorkFormatSummary{ + {Type: "ebook", ContentID: "ebook-1", LibraryID: 1}, + {Type: "audiobook", ContentID: "audio-1", LibraryID: 2}, + }, + }, nil +} + +func TestGroupedCatalogEntryKeyDeduplicatesLinkedFormats(t *testing.T) { + summary := &catalog.WorkSummary{ + WorkID: "work-1", + Title: "Project Hail Mary", + Formats: []catalog.WorkFormatSummary{ + {Type: "ebook", ContentID: "ebook-1", LibraryID: 1}, + {Type: "audiobook", ContentID: "audio-1", LibraryID: 2}, + }, + } + + ebookKey := groupedCatalogEntryKey(&models.MediaItem{ContentID: "ebook-1", Type: "ebook"}, summary) + audioKey := groupedCatalogEntryKey(&models.MediaItem{ContentID: "audio-1", Type: "audiobook"}, summary) + + if ebookKey != audioKey { + t.Fatalf("linked format keys differ: %q vs %q", ebookKey, audioKey) + } + movieKey := groupedCatalogEntryKey(&models.MediaItem{ContentID: "movie-1", Type: "movie"}, nil) + if movieKey != "item:movie-1" { + t.Fatalf("movie key = %q, want item key", movieKey) + } + + resp := itemListResponse{ContentID: "ebook-1", Type: "ebook", Title: "Project Hail Mary"} + applyWorkSummaryToCatalogItem(&resp, summary) + if resp.Type != "work" || resp.WorkID != "work-1" || len(resp.WorkFormats) != 2 { + t.Fatalf("work response = %#v", resp) + } +} diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index 7e4bf37c..2ba88540 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -195,37 +195,40 @@ func (h *ItemsHandler) requestStaleMetadataRefresh(ctx context.Context, targetTy // itemListResponse is the shape of a single item in browse/search list responses. type itemListResponse struct { - ContentID string `json:"content_id"` - Type string `json:"type"` - Title string `json:"title"` - SeriesTitle string `json:"series_title,omitempty"` - SeasonNumber *int `json:"season_number,omitempty"` - EpisodeNumber *int `json:"episode_number,omitempty"` - Year int `json:"year,omitempty"` - Runtime int `json:"runtime,omitempty"` - Genres []string `json:"genres"` - Keywords []string `json:"keywords"` - Studios []string `json:"studios,omitempty"` - Networks []string `json:"networks,omitempty"` - ContentRating string `json:"content_rating,omitempty"` - Status string `json:"status"` - ShowStatus string `json:"show_status,omitempty"` - RatingIMDB *float64 `json:"rating_imdb,omitempty"` - RatingTMDB *float64 `json:"rating_tmdb,omitempty"` - RatingRTCritic *int `json:"rating_rt_critic,omitempty"` - RatingRTAudience *int `json:"rating_rt_audience,omitempty"` - OriginalLanguage string `json:"original_language,omitempty"` - Overview string `json:"overview,omitempty"` - PosterURL string `json:"poster_url,omitempty"` - PosterThumbhash string `json:"poster_thumbhash,omitempty"` - BackdropURL string `json:"backdrop_url,omitempty"` - BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` - ReleaseDate *string `json:"release_date,omitempty"` - LastAirDate *string `json:"last_air_date,omitempty"` - AddedAt *time.Time `json:"added_at,omitempty"` - OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"` - SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"` - UserState *itemUserStateResponse `json:"user_state,omitempty"` + ContentID string `json:"content_id"` + Type string `json:"type"` + Title string `json:"title"` + SeriesTitle string `json:"series_title,omitempty"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` + Year int `json:"year,omitempty"` + Runtime int `json:"runtime,omitempty"` + Genres []string `json:"genres"` + Keywords []string `json:"keywords"` + Studios []string `json:"studios,omitempty"` + Networks []string `json:"networks,omitempty"` + ContentRating string `json:"content_rating,omitempty"` + Status string `json:"status"` + ShowStatus string `json:"show_status,omitempty"` + RatingIMDB *float64 `json:"rating_imdb,omitempty"` + RatingTMDB *float64 `json:"rating_tmdb,omitempty"` + RatingRTCritic *int `json:"rating_rt_critic,omitempty"` + RatingRTAudience *int `json:"rating_rt_audience,omitempty"` + OriginalLanguage string `json:"original_language,omitempty"` + Overview string `json:"overview,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty"` + BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` + ReleaseDate *string `json:"release_date,omitempty"` + LastAirDate *string `json:"last_air_date,omitempty"` + AddedAt *time.Time `json:"added_at,omitempty"` + OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"` + SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"` + UserState *itemUserStateResponse `json:"user_state,omitempty"` + WorkID string `json:"work_id,omitempty"` + WorkTitle string `json:"work_title,omitempty"` + WorkFormats []catalog.WorkFormatSummary `json:"work_formats,omitempty"` } type sortMetricsResponse struct { diff --git a/internal/api/handlers/literary_works.go b/internal/api/handlers/literary_works.go new file mode 100644 index 00000000..7e265e49 --- /dev/null +++ b/internal/api/handlers/literary_works.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/literaryworks" +) + +type LiteraryWorkService interface { + GetWork(ctx context.Context, workID string, filter catalog.AccessFilter) (*literaryworks.DetailResponse, error) + ListCandidates(ctx context.Context, contentID string, limit int) ([]literaryworks.Candidate, error) + LinkItems(ctx context.Context, workID string, contentIDs []string) (string, error) + UnlinkItem(ctx context.Context, workID, contentID string) error + ConfirmMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) (string, error) + IgnoreMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) error +} + +type LiteraryWorkHandler struct { + Service LiteraryWorkService +} + +type literaryWorkLinkRequest struct { + WorkID string `json:"work_id"` + ContentIDs []string `json:"content_ids"` +} + +type literaryWorkDecisionRequest struct { + SourceContentID string `json:"source_content_id"` + TargetContentID string `json:"target_content_id"` +} + +func (h *LiteraryWorkHandler) HandleGetWork(w http.ResponseWriter, r *http.Request) { + workID := strings.TrimSpace(chi.URLParam(r, "work_id")) + if workID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "work_id is required") + return + } + if h == nil || h.Service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured") + return + } + resp, err := h.Service.GetWork(r.Context(), workID, requestAccessFilter(r)) + if err != nil { + if errors.Is(err, literaryworks.ErrWorkNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Work not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load work") + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *LiteraryWorkHandler) HandleListCandidates(w http.ResponseWriter, r *http.Request) { + contentID := strings.TrimSpace(chi.URLParam(r, "content_id")) + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "content_id is required") + return + } + if h == nil || h.Service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured") + return + } + limit := 20 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 100 { + limit = parsed + } + } + candidates, err := h.Service.ListCandidates(r.Context(), contentID, limit) + if err != nil { + if errors.Is(err, literaryworks.ErrWorkNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list work candidates") + return + } + writeJSON(w, http.StatusOK, map[string]any{"candidates": candidates}) +} + +func (h *LiteraryWorkHandler) HandleLinkItems(w http.ResponseWriter, r *http.Request) { + if h == nil || h.Service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured") + return + } + var req literaryWorkLinkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + workID, err := h.Service.LinkItems(r.Context(), req.WorkID, req.ContentIDs) + if err != nil { + if errors.Is(err, literaryworks.ErrWorkNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to link work items") + return + } + writeJSON(w, http.StatusOK, map[string]string{"work_id": workID}) +} + +func (h *LiteraryWorkHandler) HandleUnlinkItem(w http.ResponseWriter, r *http.Request) { + workID := strings.TrimSpace(chi.URLParam(r, "work_id")) + contentID := strings.TrimSpace(chi.URLParam(r, "content_id")) + if workID == "" || contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "work_id and content_id are required") + return + } + if h == nil || h.Service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured") + return + } + if err := h.Service.UnlinkItem(r.Context(), workID, contentID); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to unlink work item") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *LiteraryWorkHandler) HandleConfirmMatch(w http.ResponseWriter, r *http.Request) { + h.handleDecision(w, r, true) +} + +func (h *LiteraryWorkHandler) HandleIgnoreMatch(w http.ResponseWriter, r *http.Request) { + h.handleDecision(w, r, false) +} + +func (h *LiteraryWorkHandler) handleDecision(w http.ResponseWriter, r *http.Request, confirm bool) { + if h == nil || h.Service == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured") + return + } + var req literaryWorkDecisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + req.SourceContentID = strings.TrimSpace(req.SourceContentID) + req.TargetContentID = strings.TrimSpace(req.TargetContentID) + if req.SourceContentID == "" || req.TargetContentID == "" || req.SourceContentID == req.TargetContentID { + writeError(w, http.StatusBadRequest, "bad_request", "source_content_id and target_content_id are required") + return + } + claims := apimw.GetClaims(r.Context()) + userID := 0 + if claims != nil { + userID = claims.UserID + } + var ( + workID string + err error + ) + if confirm { + workID, err = h.Service.ConfirmMatch(r.Context(), req.SourceContentID, req.TargetContentID, userID) + } else { + err = h.Service.IgnoreMatch(r.Context(), req.SourceContentID, req.TargetContentID, userID) + } + if err != nil { + if errors.Is(err, literaryworks.ErrWorkNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to record work match decision") + return + } + if confirm { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "work_id": workID}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/api/handlers/literary_works_test.go b/internal/api/handlers/literary_works_test.go new file mode 100644 index 00000000..211c5051 --- /dev/null +++ b/internal/api/handlers/literary_works_test.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/literaryworks" +) + +type fakeLiteraryWorkService struct { + work *literaryworks.DetailResponse + err error +} + +func (f fakeLiteraryWorkService) GetWork(ctx context.Context, workID string, filter catalog.AccessFilter) (*literaryworks.DetailResponse, error) { + return f.work, f.err +} + +func (f fakeLiteraryWorkService) ListCandidates(ctx context.Context, contentID string, limit int) ([]literaryworks.Candidate, error) { + return []literaryworks.Candidate{{SourceContentID: contentID, TargetContentID: "audio-1", Score: 0.9}}, f.err +} + +func (f fakeLiteraryWorkService) LinkItems(ctx context.Context, workID string, contentIDs []string) (string, error) { + if workID != "" { + return workID, f.err + } + return "work-1", f.err +} + +func (f fakeLiteraryWorkService) UnlinkItem(ctx context.Context, workID, contentID string) error { + return f.err +} + +func (f fakeLiteraryWorkService) ConfirmMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) (string, error) { + return "work-1", f.err +} + +func (f fakeLiteraryWorkService) IgnoreMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) error { + return f.err +} + +func TestLiteraryWorkHandlerGetWork(t *testing.T) { + handler := &LiteraryWorkHandler{Service: fakeLiteraryWorkService{ + work: &literaryworks.DetailResponse{ + WorkID: "work-1", + WorkTitle: "Project Hail Mary", + Formats: []literaryworks.FormatResponse{ + {Type: "ebook", ContentID: "ebook-1"}, + {Type: "audiobook", ContentID: "audio-1"}, + }, + }, + }} + req := httptest.NewRequest(http.MethodGet, "/api/v1/works/work-1", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("work_id", "work-1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rec := httptest.NewRecorder() + + handler.HandleGetWork(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"formats"`) || !strings.Contains(rec.Body.String(), `"audiobook"`) { + t.Fatalf("body = %s, want work formats", rec.Body.String()) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index cd209bcd..2837ea69 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -37,6 +37,7 @@ import ( "github.com/Silo-Server/silo-server/internal/historyimport" "github.com/Silo-Server/silo-server/internal/intromarkers" "github.com/Silo-Server/silo-server/internal/libraryingest" + "github.com/Silo-Server/silo-server/internal/literaryworks" "github.com/Silo-Server/silo-server/internal/logstream" "github.com/Silo-Server/silo-server/internal/mail" "github.com/Silo-Server/silo-server/internal/markers" @@ -424,6 +425,7 @@ func NewRouter(deps Dependencies) chi.Router { var itemsHandler *handlers.ItemsHandler var catalogResourceHandler *handlers.CatalogResourceHandler var catalogHandler *handlers.CatalogHandler + var literaryWorkHandler *handlers.LiteraryWorkHandler var peopleHandler *handlers.PeopleHandler var itemRepo *catalog.ItemRepository var episodeRepo *catalog.EpisodeRepository @@ -463,10 +465,13 @@ func NewRouter(deps Dependencies) chi.Router { rootClaimRepo := catalog.NewRootClaimRepository(deps.DB) groupClaimRepo := catalog.NewGroupClaimRepository(deps.DB) + literaryRepo := literaryworks.NewRepository(deps.DB) + literaryWorkHandler = &handlers.LiteraryWorkHandler{Service: literaryworks.NewService(literaryRepo)} detailSvc = catalog.NewDetailService(itemRepo, episodeRepo, seasonRepo, deps.PersonRepo, fileFetcher) detailSvc.SetFolderRepository(folderRepo) detailSvc.SetRootClaimRepository(rootClaimRepo) detailSvc.SetGroupClaimRepository(groupClaimRepo) + detailSvc.SetWorkSummaryProvider(literaryRepo) detailSvc.SetProbeEnsurer(deps.ProbeEnsurer) detailSvc.SetChapterThumbnailQueuer(deps.ChapterThumbnailQueuer) if deps.ImageResolver != nil { @@ -518,6 +523,7 @@ func NewRouter(deps Dependencies) chi.Router { WithUserStoreProvider(deps.UserStoreProvider), itemsHandler, ) + catalogHandler.SetWorkSummaryProvider(literaryRepo) tmdbAPIKey := "" if deps.Config != nil { @@ -1688,6 +1694,9 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/catalog/filters/search", catalogHandler.HandleGetCatalogFacetSearch) r.Get("/catalog/audiobook-groups", catalogHandler.HandleGetAudiobookGroups) r.Post("/catalog/query", catalogHandler.HandlePostCatalogQuery) + if literaryWorkHandler != nil { + r.Get("/works/{work_id}", literaryWorkHandler.HandleGetWork) + } if catalogResourceHandler != nil { r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail) r.Get("/catalog/items/{id}/episodes", catalogResourceHandler.HandleGetItemEpisodes) @@ -2221,6 +2230,13 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/unmatched", adminHandler.HandleListUnmatched) r.Get("/stats", adminHandler.HandleGetStats) r.Get("/server/status", adminHandler.HandleGetServerStatus) + if literaryWorkHandler != nil { + r.Get("/literary-works/items/{content_id}/candidates", literaryWorkHandler.HandleListCandidates) + r.Post("/literary-works/link", literaryWorkHandler.HandleLinkItems) + r.Delete("/literary-works/{work_id}/items/{content_id}", literaryWorkHandler.HandleUnlinkItem) + r.Post("/literary-works/matches/confirm", literaryWorkHandler.HandleConfirmMatch) + r.Post("/literary-works/matches/ignore", literaryWorkHandler.HandleIgnoreMatch) + } r.Post("/server/restart", serverControlHandler.HandleRestart) r.Get("/jellyfin-compat/status", adminHandler.HandleGetJellyfinCompatStatus) r.Patch("/jellyfin-compat/settings", adminHandler.HandleUpdateJellyfinCompatSettings) diff --git a/internal/audiobooks/enrichment.go b/internal/audiobooks/enrichment.go index 8f63bb8f..767e592b 100644 --- a/internal/audiobooks/enrichment.go +++ b/internal/audiobooks/enrichment.go @@ -100,11 +100,16 @@ type Enricher struct { personRepo *catalog.PersonRepository providerIDs *catalog.ProviderIDRepository imageCacher audiobookCoverCacher + workLinker literaryWorkLinker ffmpegPath string batchSize int workers int } +type literaryWorkLinker interface { + AutoLinkContent(ctx context.Context, contentID string) (workID string, linked bool, err error) +} + // NewEnricher constructs an Enricher. All fields are required; a nil pool or // chainRepo causes the sweep to be a no-op (safe for tests that don't wire // everything up). @@ -139,6 +144,13 @@ func (e *Enricher) SetImageCacher(cacher audiobookCoverCacher) { e.imageCacher = cacher } +func (e *Enricher) SetLiteraryWorkLinker(linker literaryWorkLinker) { + if e == nil { + return + } + e.workLinker = linker +} + // SetFFmpegPath installs the ffmpeg binary path used by the deferred // local-cover fallback. Empty string disables the fallback. func (e *Enricher) SetFFmpegPath(path string) { @@ -462,6 +474,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error if err := e.persist(ctx, item.ContentID, accumulatedIDs, accumulator); err != nil { return fmt.Errorf("persisting enrichment for %s: %w", item.ContentID, err) } + e.autoLinkLiteraryWork(ctx, item.ContentID) slog.Info("audiobook enrichment: enriched", "content_id", item.ContentID, @@ -474,6 +487,20 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error return nil } +func (e *Enricher) autoLinkLiteraryWork(ctx context.Context, contentID string) { + if e == nil || e.workLinker == nil || strings.TrimSpace(contentID) == "" { + return + } + workID, linked, err := e.workLinker.AutoLinkContent(ctx, contentID) + if err != nil { + slog.Warn("audiobook enrichment: literary work auto-link failed", "content_id", contentID, "error", err) + return + } + if linked { + slog.Info("audiobook enrichment: literary work auto-linked", "content_id", contentID, "work_id", workID) + } +} + // maybeApplyCoverFallback wraps applyLocalCoverFallback with the nil-guards // and warn-logging, suitable for `defer` at the top of enrichItem so every // exit path (success and early returns alike) gets a fallback attempt. diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index bcaf14d8..2402f293 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -61,6 +61,26 @@ type expiringImageResolver interface { ResolveImageURLsWithExpiry(ctx context.Context, paths []string, variant string) map[string]ResolvedImageURL } +type WorkSummaryProvider interface { + GetSummaryForContentID(ctx context.Context, contentID string, filter AccessFilter) (*WorkSummary, error) +} + +type WorkSummaryBatchProvider interface { + ListSummariesForContentIDs(ctx context.Context, contentIDs []string, filter AccessFilter) (map[string]*WorkSummary, error) +} + +type WorkSummary struct { + WorkID string + Title string + Formats []WorkFormatSummary +} + +type WorkFormatSummary struct { + Type string `json:"type"` + ContentID string `json:"content_id"` + LibraryID int `json:"library_id,omitempty"` +} + // ItemDetail is the full detail response for a single media item, including // metadata, file versions, subtitles, intro/credits markers, and presigned image URLs. type ItemDetail struct { @@ -108,6 +128,11 @@ type ItemDetail struct { BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` LogoURL string `json:"logo_url,omitempty"` + // Literary work linkage. Present for linked ebook/audiobook items. + WorkID string `json:"work_id,omitempty"` + WorkTitle string `json:"work_title,omitempty"` + WorkFormats []WorkFormatSummary `json:"work_formats,omitempty"` + // Series-specific. SeasonCount *int `json:"season_count,omitempty"` @@ -497,6 +522,7 @@ type DetailService struct { groupClaimRepo *GroupClaimRepository imageResolver ImageResolver userStoreProvider userstore.UserStoreProvider + workSummary WorkSummaryProvider originalLangFn func(context.Context, string) string probeEnsurer PlaybackProbeEnsurer chapterThumbs ChapterThumbnailQueuer @@ -533,6 +559,12 @@ func (s *DetailService) SetUserStoreProvider(provider userstore.UserStoreProvide s.userStoreProvider = provider } +func (s *DetailService) SetWorkSummaryProvider(provider WorkSummaryProvider) { + if s != nil { + s.workSummary = provider + } +} + func (s *DetailService) SetProbeEnsurer(ensurer PlaybackProbeEnsurer) { s.probeEnsurer = ensurer } @@ -999,9 +1031,23 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M } } + applyWorkSummary(ctx, detail, s.workSummary, filter) return detail, nil } +func applyWorkSummary(ctx context.Context, detail *ItemDetail, provider WorkSummaryProvider, filter AccessFilter) { + if detail == nil || provider == nil || detail.ContentID == "" { + return + } + summary, err := provider.GetSummaryForContentID(ctx, detail.ContentID, filter) + if err != nil || summary == nil { + return + } + detail.WorkID = summary.WorkID + detail.WorkTitle = summary.Title + detail.WorkFormats = summary.Formats +} + // personCredits converts ItemPerson slice to PersonCredit slice with presigned URLs. func (s *DetailService) personCredits(ctx context.Context, people []models.ItemPerson) []PersonCredit { credits := make([]PersonCredit, 0, len(people)) diff --git a/internal/catalog/detail_literary_work_test.go b/internal/catalog/detail_literary_work_test.go new file mode 100644 index 00000000..9c2f854d --- /dev/null +++ b/internal/catalog/detail_literary_work_test.go @@ -0,0 +1,27 @@ +package catalog + +import ( + "context" + "testing" +) + +type fakeWorkSummaryProvider struct{} + +func (fakeWorkSummaryProvider) GetSummaryForContentID(ctx context.Context, contentID string, filter AccessFilter) (*WorkSummary, error) { + return &WorkSummary{ + WorkID: "work-1", + Title: "Project Hail Mary", + Formats: []WorkFormatSummary{ + {Type: "ebook", ContentID: contentID, LibraryID: 1}, + {Type: "audiobook", ContentID: "audio-1", LibraryID: 2}, + }, + }, nil +} + +func TestItemDetailIncludesWorkSummaryWhenProviderConfigured(t *testing.T) { + detail := &ItemDetail{ContentID: "ebook-1", Type: "ebook", Title: "Project Hail Mary"} + applyWorkSummary(context.Background(), detail, fakeWorkSummaryProvider{}, AccessFilter{}) + if detail.WorkID != "work-1" || len(detail.WorkFormats) != 2 { + t.Fatalf("detail work fields = %#v", detail) + } +} diff --git a/internal/ebooks/enrichment.go b/internal/ebooks/enrichment.go index fe1b8124..bb6c459b 100644 --- a/internal/ebooks/enrichment.go +++ b/internal/ebooks/enrichment.go @@ -80,10 +80,15 @@ type Enricher struct { personRepo *catalog.PersonRepository providerIDs *catalog.ProviderIDRepository imageCacher metadata.ImageCacher + workLinker literaryWorkLinker batchSize int workers int } +type literaryWorkLinker interface { + AutoLinkContent(ctx context.Context, contentID string) (workID string, linked bool, err error) +} + func NewEnricher( pool *pgxpool.Pool, chainRepo *metadata.ChainRepository, @@ -111,6 +116,13 @@ func (e *Enricher) SetImageCacher(cacher metadata.ImageCacher) { e.imageCacher = cacher } +func (e *Enricher) SetLiteraryWorkLinker(linker literaryWorkLinker) { + if e == nil { + return + } + e.workLinker = linker +} + func (e *Enricher) Run(ctx context.Context) (int, error) { if e == nil || e.pool == nil || e.chainRepo == nil { return 0, nil @@ -330,6 +342,7 @@ func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemR if err := e.persist(ctx, item.ContentID, accumulatedIDs, accumulator); err != nil { return fmt.Errorf("persisting enrichment for %s: %w", item.ContentID, err) } + e.autoLinkLiteraryWork(ctx, item.ContentID) slog.Info("ebook enrichment: enriched", "content_id", item.ContentID, @@ -418,6 +431,20 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers return accumulator, accumulator.ProviderIDs, providerErrs } +func (e *Enricher) autoLinkLiteraryWork(ctx context.Context, contentID string) { + if e == nil || e.workLinker == nil || strings.TrimSpace(contentID) == "" { + return + } + workID, linked, err := e.workLinker.AutoLinkContent(ctx, contentID) + if err != nil { + slog.Warn("ebook enrichment: literary work auto-link failed", "content_id", contentID, "error", err) + return + } + if linked { + slog.Info("ebook enrichment: literary work auto-linked", "content_id", contentID, "work_id", workID) + } +} + func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, result *metadata.MetadataResult) { if e == nil || result == nil || result.PosterPath == "" { return diff --git a/internal/literaryworks/matcher.go b/internal/literaryworks/matcher.go new file mode 100644 index 00000000..31e0bfa5 --- /dev/null +++ b/internal/literaryworks/matcher.go @@ -0,0 +1,63 @@ +package literaryworks + +const AutoLinkThreshold = 0.86 + +type MatchItem struct { + ContentID string + Type string + Title string + Authors []string + Narrators []string + SeriesName string + SeriesIndex *float64 + ExternalIDs map[string]string + Publisher string + Year int +} + +func ScoreCandidate(source, target MatchItem) Candidate { + if source.ContentID == "" || target.ContentID == "" || source.ContentID == target.ContentID { + return Candidate{Score: 0, Evidence: map[string]string{"reason": "same_or_missing_content_id"}} + } + evidence := map[string]string{} + sourceAuthor := personKey(source.Authors) + targetAuthor := personKey(target.Authors) + titleMatch := normalizeKey(source.Title) != "" && normalizeKey(source.Title) == normalizeKey(target.Title) + authorMatch := sourceAuthor != "" && sourceAuthor == targetAuthor + seriesMatch := normalizeKey(source.SeriesName) != "" && + normalizeKey(source.SeriesName) == normalizeKey(target.SeriesName) && + source.SeriesIndex != nil && target.SeriesIndex != nil && + *source.SeriesIndex == *target.SeriesIndex + + if provider, id, ok := sharedExternalID(source.ExternalIDs, target.ExternalIDs); ok { + evidence["external_id"] = provider + ":" + id + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.98, LinkSource: LinkExternalID, Evidence: evidence} + } + if seriesMatch && authorMatch { + evidence["series"] = source.SeriesName + evidence["author"] = sourceAuthor + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.92, LinkSource: LinkSeriesMatch, Evidence: evidence} + } + if titleMatch && authorMatch { + evidence["title"] = source.Title + evidence["author"] = sourceAuthor + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.9, LinkSource: LinkMetadataMatch, Evidence: evidence} + } + if titleMatch && sourceAuthor != "" && targetAuthor != "" && sourceAuthor != targetAuthor { + evidence["conflict"] = "author" + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.2, LinkSource: LinkMetadataMatch, Evidence: evidence} + } + return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.4, LinkSource: LinkMetadataMatch, Evidence: evidence} +} + +func sharedExternalID(a, b map[string]string) (string, string, bool) { + for provider, aID := range a { + if aID == "" || provider == "asin" { + continue + } + if bID := b[provider]; bID != "" && bID == aID { + return provider, aID, true + } + } + return "", "", false +} diff --git a/internal/literaryworks/matcher_test.go b/internal/literaryworks/matcher_test.go new file mode 100644 index 00000000..8b511e1a --- /dev/null +++ b/internal/literaryworks/matcher_test.go @@ -0,0 +1,52 @@ +package literaryworks + +import "testing" + +func TestMatcherSameTitleAuthorLinks(t *testing.T) { + ebook := MatchItem{ContentID: "e1", Type: FormatEbook, Title: "Project Hail Mary", Authors: []string{"Andy Weir"}} + audio := MatchItem{ContentID: "a1", Type: FormatAudiobook, Title: "Project Hail Mary", Authors: []string{"Andy Weir"}, Narrators: []string{"Ray Porter"}} + candidate := ScoreCandidate(ebook, audio) + if candidate.Score < AutoLinkThreshold || candidate.LinkSource != LinkMetadataMatch { + t.Fatalf("candidate = %#v, want metadata auto-link", candidate) + } +} + +func TestMatcherSameTitleDifferentAuthorDoesNotLink(t *testing.T) { + ebook := MatchItem{ContentID: "e1", Type: FormatEbook, Title: "The Last Adventure", Authors: []string{"A. Lee"}} + audio := MatchItem{ContentID: "a1", Type: FormatAudiobook, Title: "The Last Adventure", Authors: []string{"B. Lee"}} + candidate := ScoreCandidate(ebook, audio) + if candidate.Score >= AutoLinkThreshold { + t.Fatalf("score = %v, want below threshold", candidate.Score) + } +} + +func TestMatcherSeriesIndexLinksSubtitleVariants(t *testing.T) { + ebook := MatchItem{ContentID: "e1", Type: FormatEbook, Title: "The Last Adventure", Authors: []string{"A. Lee"}, SeriesName: "Constance Verity", SeriesIndex: floatPtr(1)} + audio := MatchItem{ContentID: "a1", Type: FormatAudiobook, Title: "Constance Verity 1 - The Last Adventure of Constance Verity", Authors: []string{"A. Lee"}, SeriesName: "Constance Verity", SeriesIndex: floatPtr(1)} + candidate := ScoreCandidate(ebook, audio) + if candidate.Score < AutoLinkThreshold || candidate.LinkSource != LinkSeriesMatch { + t.Fatalf("candidate = %#v, want series auto-link", candidate) + } +} + +func TestMatcherSharedExternalIDWins(t *testing.T) { + ebook := MatchItem{ContentID: "e1", Type: FormatEbook, Title: "Different Ebook Title", Authors: []string{"A. Lee"}, ExternalIDs: map[string]string{"isbn": "978123"}} + audio := MatchItem{ContentID: "a1", Type: FormatAudiobook, Title: "Different Audio Title", Authors: []string{"B. Lee"}, ExternalIDs: map[string]string{"isbn": "978123"}} + candidate := ScoreCandidate(ebook, audio) + if candidate.Score < AutoLinkThreshold || candidate.LinkSource != LinkExternalID { + t.Fatalf("candidate = %#v, want external id auto-link", candidate) + } +} + +func TestMatcherIgnoresASINAsWorkIdentity(t *testing.T) { + ebook := MatchItem{ContentID: "e1", Type: FormatEbook, Title: "Different Ebook Title", Authors: []string{"A. Lee"}, ExternalIDs: map[string]string{"asin": "B123"}} + audio := MatchItem{ContentID: "a1", Type: FormatAudiobook, Title: "Different Audio Title", Authors: []string{"B. Lee"}, ExternalIDs: map[string]string{"asin": "B123"}} + candidate := ScoreCandidate(ebook, audio) + if candidate.Score >= AutoLinkThreshold { + t.Fatalf("candidate = %#v, want ASIN below auto-link threshold", candidate) + } +} + +func floatPtr(v float64) *float64 { + return &v +} diff --git a/internal/literaryworks/normalize.go b/internal/literaryworks/normalize.go new file mode 100644 index 00000000..031ecd95 --- /dev/null +++ b/internal/literaryworks/normalize.go @@ -0,0 +1,47 @@ +package literaryworks + +import ( + "strings" + "unicode" +) + +var leadingArticles = map[string]struct{}{ + "a": {}, + "an": {}, + "the": {}, +} + +func normalizeKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastSpace := true + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + lastSpace = false + continue + } + if !lastSpace { + b.WriteByte(' ') + lastSpace = true + } + } + parts := strings.Fields(b.String()) + filtered := parts[:0] + for _, part := range parts { + if _, ok := leadingArticles[part]; ok { + continue + } + filtered = append(filtered, part) + } + return strings.Join(filtered, " ") +} + +func personKey(names []string) string { + for _, name := range names { + if key := normalizeKey(name); key != "" { + return key + } + } + return "" +} diff --git a/internal/literaryworks/normalize_test.go b/internal/literaryworks/normalize_test.go new file mode 100644 index 00000000..a65afbf6 --- /dev/null +++ b/internal/literaryworks/normalize_test.go @@ -0,0 +1,22 @@ +package literaryworks + +import "testing" + +func TestNormalizeKey(t *testing.T) { + tests := map[string]string{ + "Project Hail Mary": "project hail mary", + "The Last Adventure: A Novel": "last adventure novel", + " A Constance-Verity Tale! ": "constance verity tale", + } + for input, want := range tests { + if got := normalizeKey(input); got != want { + t.Fatalf("normalizeKey(%q) = %q, want %q", input, got, want) + } + } +} + +func TestPersonKey(t *testing.T) { + if got := personKey([]string{" Andy Weir ", "Other"}); got != "andy weir" { + t.Fatalf("personKey = %q, want primary normalized author", got) + } +} diff --git a/internal/literaryworks/repository.go b/internal/literaryworks/repository.go new file mode 100644 index 00000000..8bcc5b6a --- /dev/null +++ b/internal/literaryworks/repository.go @@ -0,0 +1,689 @@ +package literaryworks + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrWorkNotFound = errors.New("literary work not found") + +type Repository struct { + pool *pgxpool.Pool +} + +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +type CreateWorkParams struct { + WorkID string + CanonicalTitle string + SortTitle string + NormalizedTitle string + PrimaryAuthorKey string + Description string + Publisher string + Genres []string +} + +type LinkItemParams struct { + ContentID string + FormatType string + LinkSource string + Confidence float64 +} + +type WorkItemDetail struct { + ContentID string + FormatType string + LibraryID int + Files []WorkFile + Progress *ProgressResponse +} + +type WorkFile struct { + FileID int + MediaFolderID int + FilePath string + Size int64 + DurationSeconds float64 + Resolution string +} + +type MatchItemWithWork struct { + MatchItem + WorkID string +} + +func (r *Repository) CreateWork(ctx context.Context, p CreateWorkParams) (*Work, error) { + if r == nil || r.pool == nil { + return nil, fmt.Errorf("literary works repository requires a database pool") + } + if p.Genres == nil { + p.Genres = []string{} + } + row := r.pool.QueryRow(ctx, ` + INSERT INTO literary_works ( + work_id, canonical_title, sort_title, normalized_title, + primary_author_key, description, publisher, genres + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (work_id) DO UPDATE SET + canonical_title = EXCLUDED.canonical_title, + sort_title = EXCLUDED.sort_title, + normalized_title = EXCLUDED.normalized_title, + primary_author_key = EXCLUDED.primary_author_key, + description = EXCLUDED.description, + publisher = EXCLUDED.publisher, + genres = EXCLUDED.genres, + updated_at = NOW() + RETURNING work_id, canonical_title, COALESCE(sort_title, ''), normalized_title, + primary_author_key, COALESCE(primary_cover_content_id, ''), + COALESCE(description, ''), published_date, COALESCE(publisher, ''), + genres, created_at, updated_at + `, p.WorkID, p.CanonicalTitle, p.SortTitle, p.NormalizedTitle, p.PrimaryAuthorKey, p.Description, p.Publisher, p.Genres) + return scanWork(row) +} + +func (r *Repository) GetWork(ctx context.Context, workID string) (*Work, error) { + if r == nil || r.pool == nil { + return nil, fmt.Errorf("literary works repository requires a database pool") + } + row := r.pool.QueryRow(ctx, ` + SELECT work_id, canonical_title, COALESCE(sort_title, ''), normalized_title, + primary_author_key, COALESCE(primary_cover_content_id, ''), + COALESCE(description, ''), published_date, COALESCE(publisher, ''), + genres, created_at, updated_at + FROM literary_works + WHERE work_id = $1 + `, workID) + work, err := scanWork(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrWorkNotFound + } + return work, err +} + +func (r *Repository) GetFirstWorkIDForContentIDs(ctx context.Context, contentIDs []string) (string, error) { + if r == nil || r.pool == nil { + return "", fmt.Errorf("literary works repository requires a database pool") + } + if len(contentIDs) == 0 { + return "", nil + } + var workID string + err := r.pool.QueryRow(ctx, ` + SELECT work_id + FROM literary_work_items + WHERE content_id = ANY($1) + ORDER BY updated_at DESC + LIMIT 1 + `, contentIDs).Scan(&workID) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return workID, err +} + +func (r *Repository) GetMatchItem(ctx context.Context, contentID string) (MatchItemWithWork, error) { + rows, err := r.queryMatchItems(ctx, ` + WHERE mi.content_id = $1 + AND mi.type IN ('ebook', 'audiobook') + `, contentID) + if err != nil { + return MatchItemWithWork{}, err + } + defer rows.Close() + if rows.Next() { + item, err := scanMatchItem(rows) + if err != nil { + return MatchItemWithWork{}, err + } + return item, rows.Err() + } + if err := rows.Err(); err != nil { + return MatchItemWithWork{}, err + } + return MatchItemWithWork{}, ErrWorkNotFound +} + +func (r *Repository) ListMatchCandidates(ctx context.Context, source MatchItem, limit int) ([]MatchItemWithWork, error) { + if limit <= 0 { + limit = 20 + } + args := []any{source.ContentID, source.Type} + matchFilters := make([]string, 0, 3) + if strings.TrimSpace(source.Title) != "" { + args = append(args, source.Title) + matchFilters = append(matchFilters, fmt.Sprintf("LOWER(mi.title) = LOWER($%d)", len(args))) + } + for provider, providerID := range source.ExternalIDs { + if provider == "" || provider == "asin" || providerID == "" { + continue + } + args = append(args, provider, providerID) + matchFilters = append(matchFilters, fmt.Sprintf("provider_ids.external_ids ->> $%d = $%d", len(args)-1, len(args))) + } + if strings.TrimSpace(source.SeriesName) != "" && source.SeriesIndex != nil { + args = append(args, source.SeriesName, *source.SeriesIndex) + matchFilters = append(matchFilters, fmt.Sprintf( + "(LOWER(COALESCE(book_series.series_name, '')) = LOWER($%d) AND book_series.series_index = $%d)", + len(args)-1, + len(args), + )) + } + matchWhere := "" + if len(matchFilters) > 0 { + matchWhere = " AND (" + strings.Join(matchFilters, " OR ") + ")" + } + args = append(args, limit) + rows, err := r.queryMatchItems(ctx, ` + WHERE mi.content_id <> $1 + AND mi.type IN ('ebook', 'audiobook') + AND mi.type <> $2 + AND NOT EXISTS ( + SELECT 1 FROM literary_work_match_decisions d + WHERE d.decision = 'ignored' + AND ( + (d.source_content_id = $1 AND d.target_content_id = mi.content_id) + OR (d.source_content_id = mi.content_id AND d.target_content_id = $1) + ) + ) + `+matchWhere+` + ORDER BY mi.title ASC, mi.content_id ASC + LIMIT $`+fmt.Sprint(len(args))+` + `, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MatchItemWithWork + for rows.Next() { + item, err := scanMatchItem(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *Repository) queryMatchItems(ctx context.Context, suffix string, args ...any) (pgx.Rows, error) { + if r == nil || r.pool == nil { + return nil, fmt.Errorf("literary works repository requires a database pool") + } + return r.pool.Query(ctx, ` + SELECT mi.content_id, + mi.type, + mi.title, + COALESCE(mi.year, 0), + COALESCE(mi.studios[1], '') AS publisher, + COALESCE(people.authors, '{}'::text[]) AS authors, + COALESCE(people.narrators, '{}'::text[]) AS narrators, + COALESCE(book_series.series_name, '') AS series_name, + book_series.series_index, + COALESCE(provider_ids.external_ids, '{}'::jsonb) AS external_ids, + COALESCE(lwi.work_id, '') AS work_id + FROM media_items mi + LEFT JOIN literary_work_items lwi ON lwi.content_id = mi.content_id + LEFT JOIN LATERAL ( + SELECT + array_agg(p.name ORDER BY ip.sort_order, p.name) FILTER (WHERE ip.kind = 7) AS authors, + array_agg(p.name ORDER BY ip.sort_order, p.name) FILTER (WHERE ip.kind = 8) AS narrators + FROM item_people ip + JOIN people p ON p.id = ip.person_id + WHERE ip.content_id = mi.content_id + ) people ON TRUE + LEFT JOIN LATERAL ( + SELECT s.series_name, s.series_index + FROM ( + SELECT content_id, series_name, series_index FROM ebook_series WHERE mi.type = 'ebook' + UNION ALL + SELECT content_id, series_name, series_index FROM audiobook_series WHERE mi.type = 'audiobook' + ) s + WHERE s.content_id = mi.content_id + LIMIT 1 + ) book_series ON TRUE + LEFT JOIN LATERAL ( + SELECT jsonb_object_agg(provider, provider_id) AS external_ids + FROM media_item_provider_ids mip + WHERE mip.content_id = mi.content_id + ) provider_ids ON TRUE + `+suffix, args...) +} + +func (r *Repository) GetWorkWithItems(ctx context.Context, workID string, filter catalog.AccessFilter) (*Work, []WorkItemDetail, error) { + work, err := r.GetWork(ctx, workID) + if err != nil { + return nil, nil, err + } + where, args := workItemsAccessWhere(workID, filter) + rows, err := r.pool.Query(ctx, ` + SELECT lwi.content_id, lwi.format_type, COALESCE(MIN(mil.media_folder_id), 0)::int + FROM literary_work_items lwi + JOIN media_items mi ON mi.content_id = lwi.content_id + LEFT JOIN media_item_libraries mil ON mil.content_id = lwi.content_id + WHERE `+where+` + GROUP BY lwi.content_id, lwi.format_type + ORDER BY lwi.format_type, lwi.content_id + `, args...) + if err != nil { + return nil, nil, err + } + defer rows.Close() + var items []WorkItemDetail + for rows.Next() { + var item WorkItemDetail + if err := rows.Scan(&item.ContentID, &item.FormatType, &item.LibraryID); err != nil { + return nil, nil, err + } + item.Files, err = r.ListFiles(ctx, item.ContentID, filter) + if err != nil { + return nil, nil, err + } + item.Progress, err = r.GetProgress(ctx, item.ContentID, item.FormatType, filter) + if err != nil { + return nil, nil, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, nil, err + } + if len(items) == 0 { + return nil, nil, ErrWorkNotFound + } + return work, items, nil +} + +func workItemsAccessWhere(workID string, filter catalog.AccessFilter) (string, []any) { + conditions := []string{"lwi.work_id = $1"} + args := []any{workID} + argIdx := 2 + appendWorkItemsAccessFilters(&conditions, &args, &argIdx, filter) + return strings.Join(conditions, " AND "), args +} + +func workItemsAccessWhereForWorkIDs(workIDs []string, filter catalog.AccessFilter) (string, []any) { + conditions := []string{"lwi.work_id = ANY($1)"} + args := []any{workIDs} + argIdx := 2 + appendWorkItemsAccessFilters(&conditions, &args, &argIdx, filter) + return strings.Join(conditions, " AND "), args +} + +func appendWorkItemsAccessFilters(conditions *[]string, args *[]any, argIdx *int, filter catalog.AccessFilter) { + if filter.AllowedContentIDs != nil { + if len(filter.AllowedContentIDs) == 0 { + *conditions = append(*conditions, "FALSE") + } else { + *conditions = append(*conditions, fmt.Sprintf("lwi.content_id = ANY($%d)", *argIdx)) + *args = append(*args, filter.AllowedContentIDs) + *argIdx = *argIdx + 1 + } + } + if filter.AllowedLibraryIDs != nil { + if len(filter.AllowedLibraryIDs) == 0 { + *conditions = append(*conditions, "FALSE") + } else { + *conditions = append(*conditions, fmt.Sprintf(` + EXISTS ( + SELECT 1 FROM media_item_libraries mil_allowed + WHERE mil_allowed.content_id = lwi.content_id + AND mil_allowed.media_folder_id = ANY($%d) + )`, *argIdx)) + *args = append(*args, filter.AllowedLibraryIDs) + *argIdx = *argIdx + 1 + } + } else if len(filter.DisabledLibraryIDs) > 0 { + *conditions = append(*conditions, ` + EXISTS ( + SELECT 1 FROM media_item_libraries mil_visible + WHERE mil_visible.content_id = lwi.content_id + )`) + *conditions = append(*conditions, fmt.Sprintf(` + NOT EXISTS ( + SELECT 1 FROM media_item_libraries mil_disabled + WHERE mil_disabled.content_id = lwi.content_id + AND mil_disabled.media_folder_id = ANY($%d) + )`, *argIdx)) + *args = append(*args, filter.DisabledLibraryIDs) + *argIdx = *argIdx + 1 + } + catalog.ApplySectionAccessFilter("mi", catalog.AccessFilter{MaxContentRating: filter.MaxContentRating}, conditions, args, argIdx) +} + +func (r *Repository) ListFiles(ctx context.Context, contentID string, filter catalog.AccessFilter) ([]WorkFile, error) { + if r == nil || r.pool == nil { + return nil, fmt.Errorf("literary works repository requires a database pool") + } + rows, err := r.pool.Query(ctx, ` + SELECT id, media_folder_id, file_path, COALESCE(file_size, 0), + COALESCE(duration, 0)::double precision, COALESCE(resolution, '') + FROM media_files + WHERE content_id = $1 AND missing_since IS NULL + ORDER BY file_path ASC + `, contentID) + if err != nil { + return nil, err + } + defer rows.Close() + var files []WorkFile + for rows.Next() { + var file WorkFile + if err := rows.Scan(&file.FileID, &file.MediaFolderID, &file.FilePath, &file.Size, &file.DurationSeconds, &file.Resolution); err != nil { + return nil, err + } + if !catalog.FileAllowedByAccess(&models.MediaFile{ + MediaFolderID: file.MediaFolderID, + Resolution: file.Resolution, + }, filter) { + continue + } + files = append(files, file) + } + return files, rows.Err() +} + +func (r *Repository) GetProgress(ctx context.Context, contentID, formatType string, filter catalog.AccessFilter) (*ProgressResponse, error) { + if r == nil || r.pool == nil { + return nil, fmt.Errorf("literary works repository requires a database pool") + } + if filter.UserID == 0 || strings.TrimSpace(filter.ProfileID) == "" { + return nil, nil + } + switch formatType { + case FormatEbook: + return r.getEbookProgress(ctx, contentID, filter) + case FormatAudiobook: + return r.getAudiobookProgress(ctx, contentID, filter) + default: + return nil, nil + } +} + +func (r *Repository) getEbookProgress(ctx context.Context, contentID string, filter catalog.AccessFilter) (*ProgressResponse, error) { + var progress float64 + var updatedAt time.Time + err := r.pool.QueryRow(ctx, ` + SELECT progress, updated_at + FROM ebook_reader_progress + WHERE user_id = $1 AND profile_id = $2 AND content_id = $3 + `, filter.UserID, filter.ProfileID, contentID).Scan(&progress, &updatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &ProgressResponse{ + Kind: "reading", + Progress: &progress, + UpdatedAt: updatedAt.UTC().Format(time.RFC3339), + }, nil +} + +func (r *Repository) getAudiobookProgress(ctx context.Context, contentID string, filter catalog.AccessFilter) (*ProgressResponse, error) { + var positionSeconds, durationSeconds float64 + var updatedAt time.Time + err := r.pool.QueryRow(ctx, ` + SELECT position_seconds, duration_seconds, updated_at + FROM user_watch_progress + WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3 + `, filter.UserID, filter.ProfileID, contentID).Scan(&positionSeconds, &durationSeconds, &updatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &ProgressResponse{ + Kind: "listening", + PositionSeconds: &positionSeconds, + DurationSeconds: &durationSeconds, + UpdatedAt: updatedAt.UTC().Format(time.RFC3339), + }, nil +} + +func (r *Repository) LinkItems(ctx context.Context, workID string, items []LinkItemParams) error { + if r == nil || r.pool == nil { + return fmt.Errorf("literary works repository requires a database pool") + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + for _, item := range items { + if item.Confidence == 0 { + item.Confidence = 1 + } + _, err := tx.Exec(ctx, ` + INSERT INTO literary_work_items (work_id, content_id, format_type, link_source, confidence, confirmed_at) + VALUES ($1,$2,$3,$4,$5, CASE WHEN $4 = 'manual' THEN NOW() ELSE NULL END) + ON CONFLICT (content_id) DO UPDATE SET + work_id = EXCLUDED.work_id, + format_type = EXCLUDED.format_type, + link_source = EXCLUDED.link_source, + confidence = EXCLUDED.confidence, + confirmed_at = EXCLUDED.confirmed_at, + ignored_at = NULL, + updated_at = NOW() + `, workID, item.ContentID, item.FormatType, item.LinkSource, item.Confidence) + if err != nil { + return fmt.Errorf("linking %s to work %s: %w", item.ContentID, workID, err) + } + } + return tx.Commit(ctx) +} + +func (r *Repository) UnlinkItem(ctx context.Context, workID, contentID string) error { + if r == nil || r.pool == nil { + return fmt.Errorf("literary works repository requires a database pool") + } + _, err := r.pool.Exec(ctx, ` + DELETE FROM literary_work_items + WHERE work_id = $1 AND content_id = $2 + `, workID, contentID) + return err +} + +func (r *Repository) RecordDecision(ctx context.Context, sourceContentID, targetContentID, decision string, userID int) error { + if r == nil || r.pool == nil { + return fmt.Errorf("literary works repository requires a database pool") + } + _, err := r.pool.Exec(ctx, ` + INSERT INTO literary_work_match_decisions (source_content_id, target_content_id, decision, created_by) + VALUES ($1, $2, $3, NULLIF($4, 0)) + ON CONFLICT (source_content_id, target_content_id) DO UPDATE SET + decision = EXCLUDED.decision, + created_by = EXCLUDED.created_by, + updated_at = NOW() + `, sourceContentID, targetContentID, decision, userID) + return err +} + +func (r *Repository) GetSummaryForContentID(ctx context.Context, contentID string, filter catalog.AccessFilter) (*catalog.WorkSummary, error) { + if r == nil || r.pool == nil { + return nil, fmt.Errorf("literary works repository requires a database pool") + } + var summary catalog.WorkSummary + err := r.pool.QueryRow(ctx, ` + SELECT lw.work_id, lw.canonical_title + FROM literary_work_items anchor + JOIN literary_works lw ON lw.work_id = anchor.work_id + WHERE anchor.content_id = $1 + `, contentID).Scan(&summary.WorkID, &summary.Title) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + where, args := workItemsAccessWhere(summary.WorkID, filter) + rows, err := r.pool.Query(ctx, ` + SELECT lwi.format_type, lwi.content_id, COALESCE(MIN(mil.media_folder_id), 0)::int + FROM literary_work_items lwi + JOIN media_items mi ON mi.content_id = lwi.content_id + LEFT JOIN media_item_libraries mil ON mil.content_id = lwi.content_id + WHERE `+where+` + GROUP BY lwi.format_type, lwi.content_id + ORDER BY lwi.format_type, lwi.content_id + `, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var format catalog.WorkFormatSummary + if err := rows.Scan(&format.Type, &format.ContentID, &format.LibraryID); err != nil { + return nil, err + } + summary.Formats = append(summary.Formats, format) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(summary.Formats) == 0 { + return nil, nil + } + return &summary, nil +} + +func (r *Repository) ListSummariesForContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) (map[string]*catalog.WorkSummary, error) { + summariesByContentID := make(map[string]*catalog.WorkSummary, len(contentIDs)) + if r == nil || r.pool == nil || len(contentIDs) == 0 { + return summariesByContentID, nil + } + + rows, err := r.pool.Query(ctx, ` + SELECT anchor.content_id, lw.work_id, lw.canonical_title + FROM literary_work_items anchor + JOIN literary_works lw ON lw.work_id = anchor.work_id + WHERE anchor.content_id = ANY($1) + `, contentIDs) + if err != nil { + return nil, err + } + defer rows.Close() + + anchorWorkIDs := make(map[string]string, len(contentIDs)) + summariesByWorkID := map[string]*catalog.WorkSummary{} + for rows.Next() { + var contentID, workID, title string + if err := rows.Scan(&contentID, &workID, &title); err != nil { + return nil, err + } + anchorWorkIDs[contentID] = workID + if _, ok := summariesByWorkID[workID]; !ok { + summariesByWorkID[workID] = &catalog.WorkSummary{ + WorkID: workID, + Title: title, + } + } + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(summariesByWorkID) == 0 { + return summariesByContentID, nil + } + + workIDs := make([]string, 0, len(summariesByWorkID)) + for workID := range summariesByWorkID { + workIDs = append(workIDs, workID) + } + where, args := workItemsAccessWhereForWorkIDs(workIDs, filter) + rows, err = r.pool.Query(ctx, ` + SELECT lwi.work_id, lwi.format_type, lwi.content_id, COALESCE(MIN(mil.media_folder_id), 0)::int + FROM literary_work_items lwi + JOIN media_items mi ON mi.content_id = lwi.content_id + LEFT JOIN media_item_libraries mil ON mil.content_id = lwi.content_id + WHERE `+where+` + GROUP BY lwi.work_id, lwi.format_type, lwi.content_id + ORDER BY lwi.work_id, lwi.format_type, lwi.content_id + `, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var workID string + var format catalog.WorkFormatSummary + if err := rows.Scan(&workID, &format.Type, &format.ContentID, &format.LibraryID); err != nil { + return nil, err + } + if summary := summariesByWorkID[workID]; summary != nil { + summary.Formats = append(summary.Formats, format) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + for contentID, workID := range anchorWorkIDs { + summary := summariesByWorkID[workID] + if summary == nil || len(summary.Formats) == 0 { + continue + } + summariesByContentID[contentID] = summary + } + return summariesByContentID, nil +} + +func scanWork(row pgx.Row) (*Work, error) { + var w Work + if err := row.Scan( + &w.WorkID, + &w.CanonicalTitle, + &w.SortTitle, + &w.NormalizedTitle, + &w.PrimaryAuthorKey, + &w.PrimaryCoverContentID, + &w.Description, + &w.PublishedDate, + &w.Publisher, + &w.Genres, + &w.CreatedAt, + &w.UpdatedAt, + ); err != nil { + return nil, err + } + return &w, nil +} + +func scanMatchItem(row pgx.Row) (MatchItemWithWork, error) { + var item MatchItemWithWork + var providerIDs []byte + if err := row.Scan( + &item.ContentID, + &item.Type, + &item.Title, + &item.Year, + &item.Publisher, + &item.Authors, + &item.Narrators, + &item.SeriesName, + &item.SeriesIndex, + &providerIDs, + &item.WorkID, + ); err != nil { + return MatchItemWithWork{}, err + } + item.ExternalIDs = map[string]string{} + if len(providerIDs) > 0 { + if err := json.Unmarshal(providerIDs, &item.ExternalIDs); err != nil { + return MatchItemWithWork{}, err + } + } + return item, nil +} diff --git a/internal/literaryworks/repository_test.go b/internal/literaryworks/repository_test.go new file mode 100644 index 00000000..f16083be --- /dev/null +++ b/internal/literaryworks/repository_test.go @@ -0,0 +1,110 @@ +package literaryworks + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +func TestRepositoryLinkAndFetchSummary(t *testing.T) { + ctx := context.Background() + pool := newLiteraryWorksTestPool(t) + suffix := time.Now().UnixNano() + workID := fmt.Sprintf("work-test-%d", suffix) + ebookID := fmt.Sprintf("ebook-test-%d", suffix) + audioID := fmt.Sprintf("audio-test-%d", suffix) + folderID := seedLiteraryFolder(t, pool) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{ebookID, audioID}) + _, _ = pool.Exec(ctx, `DELETE FROM literary_works WHERE work_id = $1`, workID) + _, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID) + }) + + seedLiteraryMediaItem(t, pool, ebookID, FormatEbook, "Project Hail Mary", folderID) + seedLiteraryMediaItem(t, pool, audioID, FormatAudiobook, "Project Hail Mary", folderID) + + repo := NewRepository(pool) + work, err := repo.CreateWork(ctx, CreateWorkParams{ + WorkID: workID, + CanonicalTitle: "Project Hail Mary", + NormalizedTitle: "project hail mary", + PrimaryAuthorKey: "andy weir", + }) + if err != nil { + t.Fatal(err) + } + if work.WorkID != workID { + t.Fatalf("work id = %q, want %q", work.WorkID, workID) + } + if err := repo.LinkItems(ctx, workID, []LinkItemParams{ + {ContentID: ebookID, FormatType: FormatEbook, LinkSource: LinkManual, Confidence: 1}, + {ContentID: audioID, FormatType: FormatAudiobook, LinkSource: LinkManual, Confidence: 1}, + }); err != nil { + t.Fatal(err) + } + summary, err := repo.GetSummaryForContentID(ctx, ebookID, catalog.AccessFilter{}) + if err != nil { + t.Fatal(err) + } + if summary == nil || summary.WorkID != workID || len(summary.Formats) != 2 { + t.Fatalf("summary = %#v, want work with two formats", summary) + } +} + +func newLiteraryWorksTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + var tableName *string + if err := pool.QueryRow(ctx, `SELECT to_regclass('public.literary_works')::text`).Scan(&tableName); err != nil { + t.Fatalf("check literary_works table: %v", err) + } + if tableName == nil || *tableName == "" { + t.Skip("test database has not applied literary works migration") + } + return pool +} + +func seedLiteraryFolder(t *testing.T, pool *pgxpool.Pool) int { + t.Helper() + var id int + if err := pool.QueryRow(context.Background(), ` + INSERT INTO media_folders (type, name, enabled) + VALUES ('books', 'Literary Works Test', true) + RETURNING id + `).Scan(&id); err != nil { + t.Fatalf("seed media folder: %v", err) + } + return id +} + +func seedLiteraryMediaItem(t *testing.T, pool *pgxpool.Pool, contentID, mediaType, title string, folderID int) { + t.Helper() + ctx := context.Background() + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, genres) + VALUES ($1, $2, $3, '{}'::text[]) + `, contentID, mediaType, title); err != nil { + t.Fatalf("seed media item %s: %v", contentID, err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO media_item_libraries (content_id, media_folder_id) + VALUES ($1, $2) + `, contentID, folderID); err != nil { + t.Fatalf("seed media item library %s: %v", contentID, err) + } +} diff --git a/internal/literaryworks/service.go b/internal/literaryworks/service.go new file mode 100644 index 00000000..83b36141 --- /dev/null +++ b/internal/literaryworks/service.go @@ -0,0 +1,265 @@ +package literaryworks + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "mime" + "path/filepath" + "sort" + "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetWork(ctx context.Context, workID string, filter catalog.AccessFilter) (*DetailResponse, error) { + if s == nil || s.repo == nil { + return nil, ErrWorkNotFound + } + work, items, err := s.repo.GetWorkWithItems(ctx, workID, filter) + if err != nil { + return nil, err + } + resp := &DetailResponse{ + WorkID: work.WorkID, + WorkTitle: work.CanonicalTitle, + Metadata: WorkMetadata{ + Description: work.Description, + Genres: work.Genres, + Publisher: work.Publisher, + }, + } + if work.PublishedDate != nil { + resp.Metadata.PublishedDate = work.PublishedDate.Format("2006-01-02") + } + for _, item := range items { + resp.Formats = append(resp.Formats, FormatResponse{ + Type: item.FormatType, + ContentID: item.ContentID, + LibraryID: item.LibraryID, + AvailableFiles: filesToResponse(item.Files), + Progress: item.Progress, + }) + } + return resp, nil +} + +func (s *Service) ListCandidates(ctx context.Context, contentID string, limit int) ([]Candidate, error) { + if s == nil || s.repo == nil { + return nil, ErrWorkNotFound + } + source, err := s.repo.GetMatchItem(ctx, contentID) + if err != nil { + return nil, err + } + targets, err := s.repo.ListMatchCandidates(ctx, source.MatchItem, limit*5) + if err != nil { + return nil, err + } + candidates := make([]Candidate, 0, len(targets)) + for _, target := range targets { + candidate := ScoreCandidate(source.MatchItem, target.MatchItem) + candidate.TargetWorkID = target.WorkID + if candidate.Score >= 0.75 { + candidates = append(candidates, candidate) + } + } + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Score == candidates[j].Score { + return candidates[i].TargetContentID < candidates[j].TargetContentID + } + return candidates[i].Score > candidates[j].Score + }) + if limit > 0 && len(candidates) > limit { + candidates = candidates[:limit] + } + return candidates, nil +} + +func (s *Service) LinkItems(ctx context.Context, workID string, contentIDs []string) (string, error) { + if s == nil || s.repo == nil { + return "", ErrWorkNotFound + } + contentIDs = compactContentIDs(contentIDs) + if len(contentIDs) == 0 { + return "", ErrWorkNotFound + } + items := make([]MatchItemWithWork, 0, len(contentIDs)) + for _, contentID := range contentIDs { + item, err := s.repo.GetMatchItem(ctx, contentID) + if err != nil { + return "", err + } + items = append(items, item) + } + if strings.TrimSpace(workID) == "" { + existing, err := s.repo.GetFirstWorkIDForContentIDs(ctx, contentIDs) + if err != nil { + return "", err + } + workID = existing + } + if strings.TrimSpace(workID) == "" { + workID = generatedWorkID(items[0].MatchItem) + if _, err := s.repo.CreateWork(ctx, CreateWorkParams{ + WorkID: workID, + CanonicalTitle: items[0].Title, + SortTitle: items[0].Title, + NormalizedTitle: normalizeKey(items[0].Title), + PrimaryAuthorKey: personKey(items[0].Authors), + Publisher: items[0].Publisher, + }); err != nil { + return "", err + } + } + linkItems := make([]LinkItemParams, 0, len(items)) + for _, item := range items { + linkItems = append(linkItems, LinkItemParams{ + ContentID: item.ContentID, + FormatType: item.Type, + LinkSource: LinkManual, + Confidence: 1, + }) + } + if err := s.repo.LinkItems(ctx, workID, linkItems); err != nil { + return "", err + } + return workID, nil +} + +func (s *Service) AutoLinkContent(ctx context.Context, contentID string) (string, bool, error) { + if s == nil || s.repo == nil { + return "", false, ErrWorkNotFound + } + source, err := s.repo.GetMatchItem(ctx, contentID) + if err != nil { + return "", false, err + } + if strings.TrimSpace(source.WorkID) != "" { + return source.WorkID, false, nil + } + targets, err := s.repo.ListMatchCandidates(ctx, source.MatchItem, 100) + if err != nil { + return "", false, err + } + var best Candidate + var bestTarget MatchItemWithWork + for _, target := range targets { + candidate := ScoreCandidate(source.MatchItem, target.MatchItem) + if candidate.Score > best.Score { + best = candidate + bestTarget = target + } + } + if best.Score < AutoLinkThreshold || best.TargetContentID == "" { + return "", false, nil + } + workID := strings.TrimSpace(bestTarget.WorkID) + if workID == "" { + workID = generatedWorkID(source.MatchItem) + if _, err := s.repo.CreateWork(ctx, CreateWorkParams{ + WorkID: workID, + CanonicalTitle: source.Title, + SortTitle: source.Title, + NormalizedTitle: normalizeKey(source.Title), + PrimaryAuthorKey: personKey(source.Authors), + Publisher: source.Publisher, + }); err != nil { + return "", false, err + } + } + items := []LinkItemParams{{ + ContentID: source.ContentID, + FormatType: source.Type, + LinkSource: best.LinkSource, + Confidence: best.Score, + }} + if bestTarget.WorkID == "" { + items = append(items, LinkItemParams{ + ContentID: bestTarget.ContentID, + FormatType: bestTarget.Type, + LinkSource: best.LinkSource, + Confidence: best.Score, + }) + } + if err := s.repo.LinkItems(ctx, workID, items); err != nil { + return "", false, err + } + return workID, true, nil +} + +func (s *Service) UnlinkItem(ctx context.Context, workID, contentID string) error { + if s == nil || s.repo == nil { + return ErrWorkNotFound + } + return s.repo.UnlinkItem(ctx, workID, contentID) +} + +func (s *Service) ConfirmMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) (string, error) { + if s == nil || s.repo == nil { + return "", ErrWorkNotFound + } + workID, err := s.LinkItems(ctx, "", []string{sourceContentID, targetContentID}) + if err != nil { + return "", err + } + if err := s.repo.RecordDecision(ctx, sourceContentID, targetContentID, DecisionConfirmed, userID); err != nil { + return "", err + } + return workID, nil +} + +func (s *Service) IgnoreMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) error { + if s == nil || s.repo == nil { + return ErrWorkNotFound + } + return s.repo.RecordDecision(ctx, sourceContentID, targetContentID, DecisionIgnored, userID) +} + +func filesToResponse(files []WorkFile) []FileResponse { + out := make([]FileResponse, 0, len(files)) + for _, f := range files { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(f.FilePath)), ".") + out = append(out, FileResponse{ + FileID: f.FileID, + OriginalName: filepath.Base(f.FilePath), + Format: ext, + MIMEType: mime.TypeByExtension("." + ext), + Size: f.Size, + DurationSeconds: f.DurationSeconds, + }) + } + return out +} + +func compactContentIDs(contentIDs []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(contentIDs)) + for _, contentID := range contentIDs { + contentID = strings.TrimSpace(contentID) + if contentID == "" { + continue + } + if _, ok := seen[contentID]; ok { + continue + } + seen[contentID] = struct{}{} + out = append(out, contentID) + } + return out +} + +func generatedWorkID(item MatchItem) string { + title := normalizeKey(item.Title) + author := personKey(item.Authors) + sum := sha256.Sum256([]byte(title + "|" + author)) + return "work-" + hex.EncodeToString(sum[:])[:16] +} diff --git a/internal/literaryworks/service_test.go b/internal/literaryworks/service_test.go new file mode 100644 index 00000000..7b75cca2 --- /dev/null +++ b/internal/literaryworks/service_test.go @@ -0,0 +1,39 @@ +package literaryworks + +import "testing" + +func TestFilesToResponseIncludesOriginalFormatAndDuration(t *testing.T) { + files := []WorkFile{ + {FileID: 10, FilePath: "/books/Project Hail Mary.epub", Size: 12345}, + {FileID: 20, FilePath: "/books/Project Hail Mary.m4b", Size: 98765, DurationSeconds: 58200}, + } + + resp := filesToResponse(files) + + if len(resp) != 2 { + t.Fatalf("len = %d, want 2", len(resp)) + } + if resp[0].OriginalName != "Project Hail Mary.epub" || resp[0].Format != "epub" || resp[0].MIMEType == "" { + t.Fatalf("ebook file response = %#v", resp[0]) + } + if resp[1].Format != "m4b" || resp[1].DurationSeconds != 58200 { + t.Fatalf("audio file response = %#v", resp[1]) + } +} + +func TestGeneratedWorkIDUsesTitleAndAuthorNotFormat(t *testing.T) { + ebook := MatchItem{Title: "Project Hail Mary", Type: FormatEbook, Authors: []string{"Andy Weir"}} + audio := MatchItem{Title: " Project Hail Mary ", Type: FormatAudiobook, Authors: []string{"Andy Weir"}} + + if generatedWorkID(ebook) != generatedWorkID(audio) { + t.Fatalf("generated IDs differ for same title/author across formats") + } +} + +func TestCompactContentIDsTrimsAndDeduplicates(t *testing.T) { + got := compactContentIDs([]string{" ebook-1 ", "", "audio-1", "ebook-1"}) + + if len(got) != 2 || got[0] != "ebook-1" || got[1] != "audio-1" { + t.Fatalf("compactContentIDs = %#v", got) + } +} diff --git a/internal/literaryworks/types.go b/internal/literaryworks/types.go new file mode 100644 index 00000000..622d8c17 --- /dev/null +++ b/internal/literaryworks/types.go @@ -0,0 +1,107 @@ +package literaryworks + +import "time" + +const ( + FormatEbook = "ebook" + FormatAudiobook = "audiobook" + FormatComic = "comic" + FormatManga = "manga" + + LinkManual = "manual" + LinkExternalID = "external_id" + LinkMetadataMatch = "metadata_match" + LinkSeriesMatch = "series_match" + LinkScanSeed = "scan_seed" + + DecisionConfirmed = "confirmed" + DecisionIgnored = "ignored" +) + +type Work struct { + WorkID string + CanonicalTitle string + SortTitle string + NormalizedTitle string + PrimaryAuthorKey string + PrimaryCoverContentID string + Description string + PublishedDate *time.Time + Publisher string + Genres []string + CreatedAt time.Time + UpdatedAt time.Time +} + +type WorkItem struct { + WorkID string + ContentID string + FormatType string + LinkSource string + Confidence float64 + ConfirmedAt *time.Time + IgnoredAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type Candidate struct { + SourceContentID string `json:"source_content_id"` + TargetContentID string `json:"target_content_id"` + TargetWorkID string `json:"target_work_id,omitempty"` + Score float64 `json:"score"` + LinkSource string `json:"link_source"` + Evidence map[string]string `json:"evidence"` +} + +type DetailResponse struct { + WorkID string `json:"work_id"` + WorkTitle string `json:"work_title"` + Authors []PersonResponse `json:"authors"` + Formats []FormatResponse `json:"formats"` + PrimaryCoverURL string `json:"primary_cover_url,omitempty"` + Metadata WorkMetadata `json:"metadata"` +} + +type PersonResponse struct { + PersonID string `json:"person_id,omitempty"` + Name string `json:"name"` +} + +type FormatResponse struct { + Type string `json:"type"` + ContentID string `json:"content_id"` + LibraryID int `json:"library_id,omitempty"` + AvailableFiles []FileResponse `json:"available_files"` + Progress *ProgressResponse `json:"progress,omitempty"` +} + +type FileResponse struct { + FileID int `json:"file_id"` + OriginalName string `json:"original_filename"` + Format string `json:"format"` + MIMEType string `json:"mime_type,omitempty"` + Size int64 `json:"size,omitempty"` + DurationSeconds float64 `json:"duration_seconds,omitempty"` +} + +type ProgressResponse struct { + Kind string `json:"kind"` + Progress *float64 `json:"progress,omitempty"` + PositionSeconds *float64 `json:"position_seconds,omitempty"` + DurationSeconds *float64 `json:"duration_seconds,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type WorkMetadata struct { + Description string `json:"description,omitempty"` + Series *SeriesInfo `json:"series,omitempty"` + Genres []string `json:"genres"` + PublishedDate string `json:"published_date,omitempty"` + Publisher string `json:"publisher,omitempty"` +} + +type SeriesInfo struct { + Name string `json:"name"` + Index *float64 `json:"index,omitempty"` +} diff --git a/internal/scanner/audiobook_scan.go b/internal/scanner/audiobook_scan.go index 703102fe..77c7acf4 100644 --- a/internal/scanner/audiobook_scan.go +++ b/internal/scanner/audiobook_scan.go @@ -112,14 +112,14 @@ func audiobookFolderUnchanged(existing []*models.MediaFile, onDisk []audiobookDi // to the full reconcile path. // // Errors are returned but the worker loop treats them as "do not skip". -func (s *Scanner) audiobookFolderShouldSkip(ctx context.Context, folder *models.MediaFolder, folderPath string) (bool, error) { +func (s *Scanner) audiobookFolderShouldSkip(ctx context.Context, folder *models.MediaFolder, folderPath string) (string, bool, error) { if s.fileRepo == nil || s.itemRepo == nil { - return false, nil + return "", false, nil } entries, err := os.ReadDir(folderPath) if err != nil { - return false, fmt.Errorf("read folder: %w", err) + return "", false, fmt.Errorf("read folder: %w", err) } var onDisk []audiobookDiskFile for _, e := range entries { @@ -129,7 +129,7 @@ func (s *Scanner) audiobookFolderShouldSkip(ctx context.Context, folder *models. full := filepath.Join(folderPath, e.Name()) info, statErr := os.Stat(full) if statErr != nil { - return false, fmt.Errorf("stat %s: %w", full, statErr) + return "", false, fmt.Errorf("stat %s: %w", full, statErr) } onDisk = append(onDisk, audiobookDiskFile{ Path: full, @@ -138,29 +138,29 @@ func (s *Scanner) audiobookFolderShouldSkip(ctx context.Context, folder *models. }) } if len(onDisk) == 0 { - return false, nil + return "", false, nil } existing, err := s.fileRepo.ListByObservedRootPath(ctx, folder.ID, folderPath) if err != nil { - return false, fmt.Errorf("list existing files: %w", err) + return "", false, fmt.Errorf("list existing files: %w", err) } if !audiobookFolderUnchanged(existing, onDisk) { - return false, nil + return "", false, nil } contentID := existing[0].ContentID if contentID == "" { - return false, nil + return "", false, nil } statuses, err := s.itemRepo.GetStatusByIDs(ctx, []string{contentID}) if err != nil { - return false, fmt.Errorf("get item status: %w", err) + return "", false, fmt.Errorf("get item status: %w", err) } if strings.EqualFold(strings.TrimSpace(statuses[contentID]), "unmatched") { - return false, nil + return "", false, nil } - return true, nil + return contentID, true, nil } // audiobookScanWorkers returns the configured number of parallel workers @@ -329,7 +329,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF } func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.MediaFolder, folderPath string, skipped *int64) error { - isUnchanged, skipErr := s.audiobookFolderShouldSkip(ctx, folder, folderPath) + existingContentID, isUnchanged, skipErr := s.audiobookFolderShouldSkip(ctx, folder, folderPath) if skipErr != nil { slog.Warn("audiobook scan: skip-check failed, falling through", "folder_id", folder.ID, @@ -337,6 +337,7 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M "error", skipErr, ) } else if isUnchanged { + s.autoLinkLiteraryWork(ctx, existingContentID) atomic.AddInt64(skipped, 1) return nil } @@ -399,6 +400,7 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M return fmt.Errorf("upsert audiobook ASIN provider id: %w", err) } } + s.autoLinkLiteraryWork(ctx, contentID) slog.Info("audiobook scan: indexed", "folder_id", folder.ID, "content_id", contentID, diff --git a/internal/scanner/ebook_scan.go b/internal/scanner/ebook_scan.go index ea560edc..c4ba4703 100644 --- a/internal/scanner/ebook_scan.go +++ b/internal/scanner/ebook_scan.go @@ -433,7 +433,7 @@ func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFo size := info.Size() modifiedAt := normalizeFileModifiedAt(info.ModTime()) - isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt) + existingContentID, isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt) if skipErr != nil { slog.Warn("ebook scan: skip-check failed, falling through", "folder_id", folder.ID, @@ -441,6 +441,7 @@ func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFo "error", skipErr, ) } else if isUnchanged { + s.autoLinkLiteraryWork(ctx, existingContentID) atomic.AddInt64(skipped, 1) return nil } @@ -486,6 +487,7 @@ func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFo return fmt.Errorf("upsert ebook ISBN provider id: %w", err) } } + s.autoLinkLiteraryWork(ctx, contentID) slog.Info("ebook scan: indexed", "folder_id", folder.ID, "content_id", contentID, @@ -496,35 +498,52 @@ func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFo return nil } -func (s *Scanner) ebookFileShouldSkip(ctx context.Context, folder *models.MediaFolder, filePath string, size int64, modifiedAt time.Time) (bool, error) { +func (s *Scanner) autoLinkLiteraryWork(ctx context.Context, contentID string) { + if s == nil || s.literaryWorkLinker == nil || strings.TrimSpace(contentID) == "" { + return + } + workID, linked, err := s.literaryWorkLinker.AutoLinkContent(ctx, contentID) + if err != nil { + slog.Warn("literary work auto-link failed", "content_id", contentID, "error", err) + return + } + if linked { + slog.Info("literary work auto-linked", "content_id", contentID, "work_id", workID) + } +} + +func (s *Scanner) ebookFileShouldSkip(ctx context.Context, folder *models.MediaFolder, filePath string, size int64, modifiedAt time.Time) (string, bool, error) { if s.fileRepo == nil || s.itemRepo == nil { - return false, nil + return "", false, nil } existing, err := s.fileRepo.ListByObservedRootPath(ctx, folder.ID, filePath) if err != nil { - return false, fmt.Errorf("list existing files: %w", err) + return "", false, fmt.Errorf("list existing files: %w", err) } if len(existing) != 1 { - return false, nil + return "", false, nil } mf := existing[0] if mf.FilePath != filePath || mf.FileSize != size || mf.FileModifiedAt == nil || !sameFileModifiedAt(mf.FileModifiedAt, modifiedAt) { - return false, nil + return "", false, nil } if mf.ContentID == "" { - return false, nil + return "", false, nil } if mf.GroupKeyVersion != ebookGroupKeyVersion { // The grouping scheme changed since this row was written; reprocess // once so the stored key is rewritten under the current scheme and // sibling-format lookups can find it again. - return false, nil + return "", false, nil } statuses, err := s.itemRepo.GetStatusByIDs(ctx, []string{mf.ContentID}) if err != nil { - return false, fmt.Errorf("get item status: %w", err) + return "", false, fmt.Errorf("get item status: %w", err) } - return !strings.EqualFold(strings.TrimSpace(statuses[mf.ContentID]), "unmatched"), nil + if strings.EqualFold(strings.TrimSpace(statuses[mf.ContentID]), "unmatched") { + return "", false, nil + } + return mf.ContentID, true, nil } // upsertEbookMediaItem resolves or creates the media item for the file and diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 340a907b..5ff2ec57 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -105,22 +105,22 @@ type scannerImageCacher interface { } type Scanner struct { - fileRepo *FileRepository - rootSnapshotRepo *ScannedRootRepository - groupSnapshotRepo *ScannedGroupRepository - rootOverrideRepo *MediaRootOverrideRepository - groupOverrideRepo *MediaGroupOverrideRepository - locationRepo *ObservedLocationRepository - groupLocationRepo *GroupLocationRepository - folderRepo *catalog.FolderRepository - libraryRepo *catalog.LibraryItemRepository - episodeLibraryRepo *catalog.EpisodeLibraryRepository - itemRepo *catalog.ItemRepository - personRepo *catalog.PersonRepository - episodeRepo *catalog.EpisodeRepository - ffprobePath string - s3Client *s3client.Client // public assets bucket (may be nil) - imageCacher scannerImageCacher + fileRepo *FileRepository + rootSnapshotRepo *ScannedRootRepository + groupSnapshotRepo *ScannedGroupRepository + rootOverrideRepo *MediaRootOverrideRepository + groupOverrideRepo *MediaGroupOverrideRepository + locationRepo *ObservedLocationRepository + groupLocationRepo *GroupLocationRepository + folderRepo *catalog.FolderRepository + libraryRepo *catalog.LibraryItemRepository + episodeLibraryRepo *catalog.EpisodeLibraryRepository + itemRepo *catalog.ItemRepository + personRepo *catalog.PersonRepository + episodeRepo *catalog.EpisodeRepository + ffprobePath string + s3Client *s3client.Client // public assets bucket (may be nil) + imageCacher scannerImageCacher // workers is atomic so admin settings changes can resize the per-scan // worker pool while a scan is running (applies to the next scan). workers atomic.Int32 @@ -129,6 +129,7 @@ type Scanner struct { metadataQueue MetadataQueueProducer movieQueueSyncer MovieQueueSyncer seriesQueueSyncer SeriesQueueSyncer + literaryWorkLinker LiteraryWorkLinker } // SetImageCacher installs the imagecache.Cacher used by book scanners to push @@ -161,6 +162,17 @@ type MetadataQueueProducer interface { EnqueueSeriesRoot(ctx context.Context, folderID int, observedRootPath string) error } +type LiteraryWorkLinker interface { + AutoLinkContent(ctx context.Context, contentID string) (workID string, linked bool, err error) +} + +func (s *Scanner) SetLiteraryWorkLinker(linker LiteraryWorkLinker) { + if s == nil { + return + } + s.literaryWorkLinker = linker +} + // MovieQueueSyncer synchronizes pending movie-file match queue state from the // scanner's persisted file rows. type MovieQueueSyncer interface { diff --git a/migrations/sql/20260609150000_literary_works.sql b/migrations/sql/20260609150000_literary_works.sql new file mode 100644 index 00000000..30ed1abe --- /dev/null +++ b/migrations/sql/20260609150000_literary_works.sql @@ -0,0 +1,85 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS literary_works ( + work_id TEXT PRIMARY KEY, + canonical_title TEXT NOT NULL, + sort_title TEXT, + normalized_title TEXT NOT NULL, + primary_author_key TEXT NOT NULL DEFAULT '', + primary_cover_content_id TEXT REFERENCES media_items(content_id) ON DELETE SET NULL, + description TEXT, + published_date DATE, + publisher TEXT, + genres TEXT[] NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS literary_work_items ( + work_id TEXT NOT NULL REFERENCES literary_works(work_id) ON DELETE CASCADE, + content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + format_type TEXT NOT NULL, + link_source TEXT NOT NULL, + confidence DOUBLE PRECISION NOT NULL DEFAULT 1, + confirmed_at TIMESTAMPTZ, + ignored_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (work_id, content_id), + UNIQUE (content_id), + CHECK (format_type IN ('ebook', 'audiobook', 'comic', 'manga')), + CHECK (link_source IN ('manual', 'external_id', 'metadata_match', 'series_match', 'scan_seed')), + CHECK (confidence >= 0 AND confidence <= 1) +); + +CREATE TABLE IF NOT EXISTS literary_work_match_decisions ( + source_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + target_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE, + decision TEXT NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (source_content_id, target_content_id), + CHECK (source_content_id <> target_content_id), + CHECK (decision IN ('confirmed', 'ignored')) +); + +CREATE INDEX IF NOT EXISTS idx_literary_works_normalized + ON literary_works (normalized_title, primary_author_key); + +CREATE INDEX IF NOT EXISTS idx_literary_work_items_content + ON literary_work_items (content_id); + +CREATE INDEX IF NOT EXISTS idx_literary_work_items_format + ON literary_work_items (format_type, work_id); + +CREATE INDEX IF NOT EXISTS idx_literary_work_match_decisions_target + ON literary_work_match_decisions (target_content_id, decision); + +CREATE OR REPLACE FUNCTION delete_empty_literary_works() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM literary_works lw + WHERE lw.work_id = OLD.work_id + AND NOT EXISTS ( + SELECT 1 FROM literary_work_items lwi WHERE lwi.work_id = OLD.work_id + ); + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_delete_empty_literary_works ON literary_work_items; +CREATE TRIGGER trg_delete_empty_literary_works +AFTER DELETE ON literary_work_items +FOR EACH ROW EXECUTE FUNCTION delete_empty_literary_works(); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS trg_delete_empty_literary_works ON literary_work_items; +DROP FUNCTION IF EXISTS delete_empty_literary_works(); +DROP TABLE IF EXISTS literary_work_match_decisions; +DROP TABLE IF EXISTS literary_work_items; +DROP TABLE IF EXISTS literary_works; +-- +goose StatementEnd