Compare commits

..
Author SHA1 Message Date
Quick 3dde026740 fix(audiobooks): cache plugin poster images 2026-05-28 09:49:42 -04:00
Quick 12342dfd41 fix(migrations): preserve personal collection types 2026-05-27 19:55:02 -04:00
5 changed files with 167 additions and 10 deletions
+57
View File
@@ -40,6 +40,8 @@ type audiobookCoverCacher interface {
}
const (
audiobookMetadataImageProviderID = "audiobook-metadata"
// defaultEnrichBatchSize is the maximum number of audiobook items processed
// per sweep invocation. Keeps latency bounded for large libraries.
defaultEnrichBatchSize = 50
@@ -416,6 +418,8 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
return e.stampLastRefreshed(ctx, item.ContentID)
}
e.cacheRemotePoster(ctx, item.ContentID, accumulator)
// Phase 3: Persist.
if err := e.persist(ctx, item.ContentID, accumulatedIDs, accumulator); err != nil {
return fmt.Errorf("persisting enrichment for %s: %w", item.ContentID, err)
@@ -448,6 +452,59 @@ func (e *Enricher) maybeApplyCoverFallback(ctx context.Context, contentID string
}
}
// cacheRemotePoster stores provider-hosted audiobook posters in the same S3
// image cache used by movie/TV metadata. Failures are non-fatal: keeping the
// provider URL is better than dropping the poster.
func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, result *metadata.MetadataResult) {
if e == nil || result == nil || result.PosterPath == "" {
return
}
if !strings.HasPrefix(result.PosterPath, "http://") && !strings.HasPrefix(result.PosterPath, "https://") {
return
}
imageCacher, ok := e.imageCacher.(metadata.ImageCacher)
if !ok || imageCacher == nil {
return
}
cached, err := imageCacher.CacheImage(ctx, metadata.CacheImageRequest{
SourceURL: result.PosterPath,
ProviderID: audiobookMetadataImageProviderID,
ContentType: "audiobooks",
ContentID: contentID,
ImageType: metadata.ImagePoster,
})
if err != nil {
slog.Warn("audiobook enrichment: poster cache failed, keeping provider URL",
"content_id", contentID,
"url", result.PosterPath,
"error", err,
)
return
}
if storedPath := cachedOriginalImagePath(cached.BasePath, cached.Ext); storedPath != "" {
result.PosterPath = storedPath
}
if cached.Thumbhash != "" {
result.PosterThumbhash = cached.Thumbhash
}
}
func cachedOriginalImagePath(basePath, ext string) string {
if basePath == "" {
return ""
}
if strings.Contains(basePath, "/original.") {
return basePath
}
if ext == "" {
ext = ".jpg"
}
return strings.TrimRight(basePath, "/") + "/original" + ext
}
// persist writes the enriched metadata back to the database.
func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs map[string]string, result *metadata.MetadataResult) error {
// Build the MetadataUpdate — only set fields that the provider returned.
+64
View File
@@ -6,6 +6,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/metadata"
)
// TestEnricherRunFansOut verifies that runBatch processes a claimed batch with
@@ -60,3 +62,65 @@ func TestEnricherRunFansOut(t *testing.T) {
t.Errorf("max in-flight = %d, want >= %d", got, wantWorkers)
}
}
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
cacher := &fakeAudiobookImageCacher{}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://m.media-amazon.com/images/I/example.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 1 {
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
}
if cacher.req.ProviderID != audiobookMetadataImageProviderID {
t.Fatalf("ProviderID = %q, want %q", cacher.req.ProviderID, audiobookMetadataImageProviderID)
}
if cacher.req.ContentType != "audiobooks" || cacher.req.ContentID != "content-1" {
t.Fatalf("cache target = %q/%q", cacher.req.ContentType, cacher.req.ContentID)
}
if result.PosterPath != "audiobook-metadata/audiobooks/content-1/poster/original.webp" {
t.Fatalf("PosterPath = %q", result.PosterPath)
}
if result.PosterThumbhash != "thumb" {
t.Fatalf("PosterThumbhash = %q", result.PosterThumbhash)
}
}
func TestCacheRemotePosterSkipsAlreadyCachedPath(t *testing.T) {
cacher := &fakeAudiobookImageCacher{}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "local/audiobooks/content-1/poster/original.webp",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 0 {
t.Fatalf("CacheImage calls = %d, want 0", cacher.calls)
}
if result.PosterPath != "local/audiobooks/content-1/poster/original.webp" {
t.Fatalf("PosterPath = %q", result.PosterPath)
}
}
type fakeAudiobookImageCacher struct {
calls int
req metadata.CacheImageRequest
}
func (f *fakeAudiobookImageCacher) CacheAudiobookCover(context.Context, []byte, string) (string, string, string, error) {
return "", "", "", nil
}
func (f *fakeAudiobookImageCacher) CacheImage(_ context.Context, req metadata.CacheImageRequest) (*metadata.CacheImageResult, error) {
f.calls++
f.req = req
return &metadata.CacheImageResult{
BasePath: req.ProviderID + "/" + req.ContentType + "/" + req.ContentID + "/poster",
Thumbhash: "thumb",
Ext: ".webp",
}, nil
}
@@ -1,5 +1,5 @@
-- Reverse migration 156. Lossy in reverse — playlist/smart rows
-- created after the up migration are deleted, not migrated back.
-- Reverse migration 156. Lossy in reverse — ABS playlist rows created after
-- the up migration are deleted, not migrated back.
-- 1. Remove the page_sections column.
ALTER TABLE page_sections DROP COLUMN media_types;
@@ -95,21 +95,23 @@ CREATE INDEX IF NOT EXISTS abs_smart_collections_user_profile_idx
);
-- END inlined
-- 3. Remove rows we promoted from abs_playlists during the up.
-- 3. Remove rows we promoted from abs_playlists during the up. Smart personal
-- collections predate migration 156, so they must not be deleted here.
DELETE FROM user_personal_collection_items
WHERE collection_id IN (
SELECT id FROM user_personal_collections
WHERE collection_type IN ('playlist', 'smart')
WHERE collection_type = 'playlist'
);
DELETE FROM user_personal_collections
WHERE collection_type IN ('playlist', 'smart');
WHERE collection_type = 'playlist';
-- 4. Restore the narrow CHECK constraint.
-- 4. Remove the ABS-only playlist type while preserving the pre-156 personal
-- collection domain, including smart and import-backed collections.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced'));
CHECK (collection_type IN ('manual', 'smart', 'mdblist', 'tmdb', 'trakt', 'synced'));
-- 5. Drop the sub_item_id column.
ALTER TABLE user_personal_collection_items
+4 -3
View File
@@ -15,13 +15,14 @@ ALTER TABLE user_personal_collection_items
ADD CONSTRAINT user_personal_collection_items_pkey
PRIMARY KEY (user_id, collection_id, media_item_id, sub_item_id);
-- 2. Widen the collection_type domain. The pre-existing CHECK (if any)
-- only admits 'manual' and 'synced'.
-- 2. Widen the collection_type domain. Personal collections already support
-- smart and import-backed types; this migration adds ABS playlists without
-- narrowing existing rows.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
CHECK (collection_type IN ('manual', 'smart', 'mdblist', 'tmdb', 'trakt', 'synced', 'playlist'));
-- 3. Move existing ABS manual collections into the canonical store.
INSERT INTO user_personal_collections
+33
View File
@@ -0,0 +1,33 @@
package migrations
import (
"strings"
"testing"
)
func TestUnifyUserCollectionsPreservesExistingCollectionTypes(t *testing.T) {
upBytes, err := FS.ReadFile("156_unify_user_collections.up.sql")
if err != nil {
t.Fatalf("read up migration: %v", err)
}
downBytes, err := FS.ReadFile("156_unify_user_collections.down.sql")
if err != nil {
t.Fatalf("read down migration: %v", err)
}
up := normalizeSQL(string(upBytes))
down := normalizeSQL(string(downBytes))
for _, collectionType := range []string{"'manual'", "'smart'", "'mdblist'", "'tmdb'", "'trakt'", "'synced'", "'playlist'"} {
if !strings.Contains(up, collectionType) {
t.Fatalf("up migration collection_type constraint missing %s", collectionType)
}
}
for _, collectionType := range []string{"'manual'", "'smart'", "'mdblist'", "'tmdb'", "'trakt'", "'synced'"} {
if !strings.Contains(down, collectionType) {
t.Fatalf("down migration collection_type constraint missing %s", collectionType)
}
}
if strings.Contains(down, "collection_type IN ('playlist', 'smart')") {
t.Fatal("down migration must not delete all smart personal collections")
}
}