Add unified literary works for ebooks and audiobooks (#107)

* docs: add literary works design and plan

* feat(literary): add work link schema

* feat(literary): add work domain primitives

* feat(literary): persist work links

* feat(literary): score work matches

* feat(catalog): include literary work summary on item detail

* feat(literary): expose work detail API

* feat(literary): assemble work detail

* feat(literary): add admin work linking primitives

* feat(catalog): group literary items by work

* feat(literary): auto-link works during book scans

* fix(literary): narrow work match candidates

* fix(literary): address work merge blockers

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
RXWatcher
2026-06-16 18:17:08 -04:00
committed by GitHub
co-authored by rxwatcher Quick
parent 9cd00a877b
commit e084cdd1d6
26 changed files with 4054 additions and 81 deletions
+9
View File
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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.
+202 -12
View File
@@ -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")
@@ -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)
}
}
+34 -31
View File
@@ -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 {
+182
View File
@@ -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"})
}
@@ -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())
}
}
+16
View File
@@ -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)
+27
View File
@@ -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.
+46
View File
@@ -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))
@@ -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)
}
}
+27
View File
@@ -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
+63
View File
@@ -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
}
+52
View File
@@ -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
}
+47
View File
@@ -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 ""
}
+22
View File
@@ -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)
}
}
+689
View File
@@ -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
}
+110
View File
@@ -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)
}
}
+265
View File
@@ -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]
}
+39
View File
@@ -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)
}
}
+107
View File
@@ -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"`
}
+14 -12
View File
@@ -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,
+29 -10
View File
@@ -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
+28 -16
View File
@@ -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 {
@@ -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