Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dde026740 | ||
|
|
12342dfd41 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user