diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go index d4886d7..6c18674 100644 --- a/internal/handler/routes_authenticated.go +++ b/internal/handler/routes_authenticated.go @@ -97,6 +97,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc * authed.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc)) authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc)) authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc)) + authed.POST("/subscriptions/:id/restore", requirePermission(svc, "can_manage_subscriptions"), restoreSubscriptionHandler(svc)) authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc)) // Stats / dashboard. diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index db31d34..53175f5 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -130,3 +130,16 @@ func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{"queued": n}) } } + +func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + sub, err := svc.Subscription.Restore(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + enriched := []model.Subscription{*sub} + svc.Subscription.EnrichProgress(c.Request.Context(), enriched) + c.JSON(http.StatusOK, enriched[0]) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index e0ff62c..dd9f802 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -103,6 +103,13 @@ type Media struct { // Used to expose WebDAV / Alist / S3 / HTTP direct links as media items. STRMURL string `gorm:"size:2048" json:"strm_url,omitempty"` + LibraryName string `gorm:"-" json:"library_name,omitempty"` + LibraryPath string `gorm:"-" json:"library_path,omitempty"` + + DisplayLibraryID string `gorm:"-" json:"display_library_id,omitempty"` + DisplayLibraryName string `gorm:"-" json:"display_library_name,omitempty"` + DisplayLibraryPath string `gorm:"-" json:"display_library_path,omitempty"` + // FileHash is a sparse-sample MD5 used for duplicate detection. // Computed on-demand by the duplicate finder; format: "-". FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"` diff --git a/internal/service/cloud_mount_filter_test.go b/internal/service/cloud_mount_filter_test.go index 67c2f79..ff02466 100644 --- a/internal/service/cloud_mount_filter_test.go +++ b/internal/service/cloud_mount_filter_test.go @@ -134,6 +134,9 @@ func TestListMediaVisibleIncludesMergedCloudLibraryItems(t *testing.T) { if got := mediaTitles(items); !slices.Equal(got, []string{"云盘剧", "本地剧"}) { t.Fatalf("items = %#v, want local+cloud only", got) } + if cloudItem := mediaByTitle(items, "云盘剧"); cloudItem == nil || cloudItem.DisplayLibraryID != local.ID { + t.Fatalf("cloud item display library = %#v, want merged local library %s", cloudItem, local.ID) + } items, total, err = svc.ListMediaVisible(t.Context(), local.ID, 1, 20, MediaVisibility{ IncludeNSFW: true, @@ -156,6 +159,49 @@ func TestListMediaVisibleIncludesMergedCloudLibraryItems(t *testing.T) { if got := mediaTitles(searchItems); !slices.Equal(got, []string{"云盘剧", "本地剧"}) { t.Fatalf("profile-limited merged search items=%#v, want local+hidden cloud", got) } + if cloudItem := mediaByTitle(searchItems, "云盘剧"); cloudItem == nil || cloudItem.DisplayLibraryID != local.ID { + t.Fatalf("search cloud item display library = %#v, want merged local library %s", cloudItem, local.ID) + } +} + +func TestListMediaVisibleUsesSpecificCloudChildLibraryAsDisplayTarget(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} + child := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} + for _, lib := range []*model.Library{&root, &child} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + if err := repos.DB.Create(&model.Media{ + LibraryID: root.ID, + Title: "折腰", + Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv", + }).Error; err != nil { + t.Fatal(err) + } + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + items, total, err := svc.ListMediaVisible(t.Context(), root.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(items) != 1 { + t.Fatalf("items total=%d len=%d, want one root cloud item", total, len(items)) + } + if items[0].DisplayLibraryID != child.ID { + t.Fatalf("display library = %q, want child cloud library %q", items[0].DisplayLibraryID, child.ID) + } + if items[0].DisplayLibraryPath != child.Path { + t.Fatalf("display library path = %q, want %q", items[0].DisplayLibraryPath, child.Path) + } } func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) { @@ -201,3 +247,12 @@ func mediaTitles(items []model.Media) []string { slices.Sort(out) return out } + +func mediaByTitle(items []model.Media, title string) *model.Media { + for i := range items { + if items[i].Title == title { + return &items[i] + } + } + return nil +} diff --git a/internal/service/media.go b/internal/service/media.go index 314df28..5c015e4 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -381,12 +381,14 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, page, pageSize, filter) var cached mediaListCacheValue if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) { + s.attachLibraryMetadata(ctx, cached.Items) return cached.Items, cached.Total, nil } items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, (page-1)*pageSize, pageSize, filter) if err != nil { return nil, 0, err } + s.attachLibraryMetadata(ctx, items) if s.cache != nil { s.cache.SetJSON(ctx, cacheKey, mediaListCacheValue{Items: items, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second) } @@ -438,6 +440,138 @@ func (s *MediaService) invalidateMediaCache(ctx context.Context) { } } +func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.Media) { + if s == nil || s.repo == nil || s.repo.Library == nil || len(items) == 0 { + return + } + libs, err := s.repo.Library.List(ctx) + if err != nil { + return + } + byID := make(map[string]model.Library, len(libs)) + for _, lib := range libs { + byID[lib.ID] = lib + } + resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs) + for i := range items { + if lib, ok := byID[items[i].LibraryID]; ok { + items[i].LibraryName = lib.Name + items[i].LibraryPath = lib.Path + } + if lib, ok := resolver.DisplayLibraryForMedia(items[i]); ok { + items[i].DisplayLibraryID = lib.ID + items[i].DisplayLibraryName = lib.Name + items[i].DisplayLibraryPath = lib.Path + } + } +} + +type mediaDisplayLibraryResolver struct { + byID map[string]model.Library + displayByID map[string]model.Library + displayByMergeKey map[string]model.Library + displayLibraries []model.Library +} + +func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Container, libs []model.Library) mediaDisplayLibraryResolver { + displayLibraries := FilterDisplayCloudLibraries(ctx, repo, append([]model.Library(nil), libs...)) + resolver := mediaDisplayLibraryResolver{ + byID: make(map[string]model.Library, len(libs)), + displayByID: make(map[string]model.Library, len(displayLibraries)), + displayByMergeKey: make(map[string]model.Library, len(displayLibraries)), + displayLibraries: displayLibraries, + } + for _, lib := range libs { + resolver.byID[lib.ID] = lib + } + for _, lib := range displayLibraries { + resolver.displayByID[lib.ID] = lib + if key, ok := CloudLibraryMergeKey(lib); ok { + if _, exists := resolver.displayByMergeKey[key]; !exists { + resolver.displayByMergeKey[key] = lib + } + } + } + return resolver +} + +func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (model.Library, bool) { + if lib, ok := r.bestPathDisplayLibrary(media); ok { + return lib, true + } + if lib, ok := r.displayByID[media.LibraryID]; ok { + return lib, true + } + own, hasOwn := r.byID[media.LibraryID] + if hasOwn { + if key, ok := CloudLibraryMergeKey(own); ok { + if lib, exists := r.displayByMergeKey[key]; exists { + return lib, true + } + } + return own, true + } + return model.Library{}, false +} + +func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (model.Library, bool) { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") { + mediaInfo, ok := ParseCloudLibraryMount(media.Path) + if !ok { + return model.Library{}, false + } + var best model.Library + bestDepth := 0 + for _, lib := range r.displayLibraries { + info, ok := ParseCloudLibraryMount(lib.Path) + if !ok || info.Provider != mediaInfo.Provider || !lib.Enabled { + continue + } + dir := strings.Trim(firstNonEmpty(info.DisplayDir, info.ScanDir), "/") + if dir == "" { + continue + } + mediaDir := strings.Trim(firstNonEmpty(mediaInfo.DisplayDir, mediaInfo.ScanDir), "/") + if mediaDir != dir && !cloudMountAncestor(dir, mediaDir) { + continue + } + depth := len(strings.Split(dir, "/")) + if depth > bestDepth { + best = lib + bestDepth = depth + } + } + if bestDepth > 0 { + return best, true + } + return model.Library{}, false + } + + mediaPath := cleanPathForVolumeMapping(media.Path) + var best model.Library + bestLen := 0 + for _, lib := range r.displayLibraries { + if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled { + continue + } + libPath := cleanPathForVolumeMapping(lib.Path) + if libPath == "" || libPath == "." { + continue + } + if mediaPath != libPath && !strings.HasPrefix(mediaPath, strings.TrimRight(libPath, "/")+"/") { + continue + } + if len(libPath) > bestLen { + best = lib + bestLen = len(libPath) + } + } + if bestLen > 0 { + return best, true + } + return model.Library{}, false +} + func groupMediaVersions(items []model.Media) []MediaItem { if len(items) == 0 { return nil @@ -585,11 +719,16 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim limit = maxMediaSearchLimit } visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) - return s.repo.Media.SearchFiltered(ctx, query, limit, repository.MediaQueryFilter{ + items, err := s.repo.Media.SearchFiltered(ctx, query, limit, repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, }) + if err != nil { + return nil, err + } + s.attachLibraryMetadata(ctx, items) + return items, nil } func (s *MediaService) SearchMediaVisibleGrouped(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]MediaItem, error) { @@ -611,11 +750,16 @@ func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, page = 1 } visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) - return s.repo.Media.SearchFilteredPage(ctx, query, (page-1)*pageSize, pageSize, repository.MediaQueryFilter{ + items, total, err := s.repo.Media.SearchFilteredPage(ctx, query, (page-1)*pageSize, pageSize, repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, }) + if err != nil { + return nil, 0, err + } + s.attachLibraryMetadata(ctx, items) + return items, total, nil } func (s *MediaService) SearchMediaVisiblePageGrouped(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) { @@ -629,7 +773,14 @@ func (s *MediaService) SearchMediaVisiblePageGrouped(ctx context.Context, query // GetMedia returns a single media row. func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { - return s.repo.Media.FindByID(ctx, id) + media, err := s.repo.Media.FindByID(ctx, id) + if err != nil || media == nil { + return media, err + } + items := []model.Media{*media} + s.attachLibraryMetadata(ctx, items) + *media = items[0] + return media, nil } const maxRecycleBinRecords = 200 diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 349a951..6abbe6f 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -130,6 +130,36 @@ func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription return s.repo.Subscription.History(ctx) } +// Restore moves an archived subscription back to the active management list. +// It also clears the per-subscription seen state so an unfinished historical +// rule can match resources again when it is run next. +func (s *SubscriptionService) Restore(ctx context.Context, id string) (*model.Subscription, error) { + var sub model.Subscription + if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil { + return nil, err + } + if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}). + Where("id = ?", id). + Updates(map[string]any{ + "enabled": true, + "archive_reason": "", + }).Error; err != nil { + return nil, err + } + if err := s.repo.DB.WithContext(ctx). + Exec("UPDATE subscriptions SET archived_at = NULL WHERE id = ?", id).Error; err != nil { + return nil, err + } + if s.repo.Setting != nil { + _ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id)) + } + var restored model.Subscription + if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&restored).Error; err != nil { + return nil, err + } + return &restored, nil +} + // Delete removes a subscription. func (s *SubscriptionService) Delete(ctx context.Context, id string) error { var sub model.Subscription diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go index 6f3f7c7..9b933ab 100644 --- a/internal/service/subscription_test.go +++ b/internal/service/subscription_test.go @@ -9,6 +9,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "github.com/glebarez/sqlite" "go.uber.org/zap" @@ -810,6 +811,63 @@ func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) { } } +func TestRestoreArchivedSubscriptionReturnsToActiveAndClearsSeenState(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Subscription{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) + sub := &model.Subscription{ + Name: "南部档案 自动订阅", + FeedURL: "https://rss.example/feed", + Filter: "南部档案", + MediaType: "tv", + TotalEpisodes: 33, + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + archivedAt := time.Now() + if err := repos.Subscription.Archive(t.Context(), sub.ID, "已下载 1/33 集,缺 33 集", archivedAt); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil { + t.Fatal(err) + } + restored, err := svc.Restore(t.Context(), sub.ID) + if err != nil { + t.Fatal(err) + } + if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled { + t.Fatalf("restored subscription not active: archived=%v reason=%q enabled=%v", restored.ArchivedAt, restored.ArchiveReason, restored.Enabled) + } + active, err := repos.Subscription.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(active) != 1 || active[0].ID != sub.ID { + t.Fatalf("active subscriptions = %#v, want restored subscription", active) + } + history, err := repos.Subscription.History(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(history) != 0 { + t.Fatalf("history subscriptions = %d, want 0 after restore", len(history)) + } + seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") + if err != nil { + t.Fatal(err) + } + if seen != "" { + t.Fatalf("seen state = %q, want cleared", seen) + } +} + func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) { rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") diff --git a/web/src/api/subscriptions.ts b/web/src/api/subscriptions.ts index d424d3e..d0e79c0 100644 --- a/web/src/api/subscriptions.ts +++ b/web/src/api/subscriptions.ts @@ -39,6 +39,9 @@ export const subscriptionsAPI = { remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data), + restore: (id: string) => + api.post(`/subscriptions/${id}/restore`).then((r) => r.data), + runNow: (id: string) => api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data), } diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index ff9abd3..97d2e70 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -8,7 +8,7 @@ import { playbackAPI, type HistoryItem } from '../api/playback' import { imageURL } from '../api/client' import { MediaCard } from '../components/MediaCard' import type { Library, Media } from '../types' -import { groupSeries } from '../utils/groupSeries' +import { groupSeries, seriesCardLink } from '../utils/groupSeries' const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url) const asArray = (value: unknown): T[] => (Array.isArray(value) ? value as T[] : []) @@ -220,7 +220,7 @@ export function HomePage() { key={card.key} media={card.rep} count={card.count} - linkTo={card.count > 1 ? `/library/${card.rep.library_id}?series=${encodeURIComponent(card.key)}` : undefined} + linkTo={card.count > 1 ? seriesCardLink(card) : undefined} /> ))} diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 67cb1e2..42c2f3d 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useState } from 'react' import toast from 'react-hot-toast' -import { Archive, CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react' +import { Archive, CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, RotateCcw, Save, ShieldCheck, Trash2 } from 'lucide-react' import { subscriptionsAPI } from '../api/subscriptions' import { imageURL } from '../api/client' @@ -117,6 +117,22 @@ export function SubscriptionsPage() { window.scrollTo({ top: 0, behavior: 'smooth' }) } + const restoreHistorySubscription = async (subscription: Subscription, runAfterRestore = false) => { + try { + const restored = await subscriptionsAPI.restore(subscription.id) + if (runAfterRestore) { + const result = await subscriptionsAPI.runNow(restored.id) + toast.success(`已恢复订阅并加入 ${result.queued} 项`) + } else { + toast.success('已恢复到正在订阅') + } + await refresh() + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '恢复订阅失败' + toast.error(msg) + } + } + return (

RSS 订阅

@@ -344,6 +360,22 @@ export function SubscriptionsPage() { {subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'}

{subscriptionProgressLabel(subscription)}

+
+ + +
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 9df7bee..cf8a6a6 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -106,6 +106,11 @@ export interface Library { export interface Media { id: string library_id: string + library_name?: string + library_path?: string + display_library_id?: string + display_library_name?: string + display_library_path?: string series_id?: string title: string original_name?: string diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 3e78a8f..acbe139 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -16,7 +16,7 @@ import type { Media } from '../types' * * 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。 */ -export type SeriesCard = { key: string; rep: Media; count: number } +export type SeriesCard = { key: string; rep: Media; linkMedia: Media; count: number } export function getSeriesKey(media: Media): string { if (media.series_id) return `series:${media.series_id}` @@ -74,9 +74,12 @@ export function groupSeries(items: Media[] = []): SeriesCard[] { const g = groups.get(key) if (!g) { - groups.set(key, { key, rep: m, count: 1 }) + groups.set(key, { key, rep: m, linkMedia: m, count: 1 }) } else { g.count += 1 + if (betterSeriesLinkMedia(m, g.linkMedia)) { + g.linkMedia = m + } const currentArtwork = artworkScore(m) const representativeArtwork = artworkScore(g.rep) if (currentArtwork > representativeArtwork) { @@ -93,11 +96,36 @@ export function groupSeries(items: Media[] = []): SeriesCard[] { export function seriesCardLink(card: SeriesCard): string { if (card.count > 1) { - return `/library/${card.rep.library_id}?series=${encodeURIComponent(card.key)}` + return `/library/${targetLibraryID(card.linkMedia)}` } return `/media/${card.rep.id}` } +function betterSeriesLinkMedia(candidate: Media, current: Media): boolean { + const candidateScore = librarySpecificityScore(candidate) + const currentScore = librarySpecificityScore(current) + if (candidateScore !== currentScore) return candidateScore > currentScore + return artworkScore(candidate) > artworkScore(current) +} + +function librarySpecificityScore(media: Media): number { + const rawPath = (media.display_library_path || media.library_path || '').trim() + if (!rawPath) return 0 + const normalized = rawPath.replace(/\\/g, '/').replace(/\/+$/, '') + const lower = normalized.toLowerCase() + if (lower.startsWith('cloud://')) { + const rest = normalized.slice('cloud://'.length) + const slash = rest.indexOf('/') + if (slash < 0 || slash === rest.length - 1) return 0 + return 100 + rest.slice(slash + 1).split('/').filter(Boolean).length + } + return 200 + normalized.split('/').filter(Boolean).length +} + +function targetLibraryID(media: Media): string { + return media.display_library_id || media.library_id +} + export function artworkScore(media: Media): number { const poster = (media.poster_url ?? '').toLowerCase() const backdrop = (media.backdrop_url ?? '').toLowerCase()