From a7c8d46589e83e61c9506afa23639de14b1f0d54 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:17:38 +0800 Subject: [PATCH] fix: show latest discover posters --- internal/handler/discover_extra.go | 11 ++++--- internal/handler/discover_extra_test.go | 13 ++++++++ internal/service/discover.go | 4 +++ internal/service/discover_artwork_test.go | 15 +++++++++ web/src/pages/DiscoverContentRow.tsx | 29 +++++++++++++--- web/src/pages/DiscoverPage.tsx | 3 +- web/src/pages/discoverPageModel.ts | 40 +++++++++++++++++++++-- 7 files changed, 103 insertions(+), 12 deletions(-) diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go index 6b9271b..94ae5f0 100644 --- a/internal/handler/discover_extra.go +++ b/internal/handler/discover_extra.go @@ -25,9 +25,12 @@ type discoverSectionDef struct { var discoverSectionCatalog = []discoverSectionDef{ {Key: "tmdb_trending_day", Label: "TMDb 今日趋势", Provider: "tmdb"}, {Key: "tmdb_trending_week", Label: "TMDb 本周热门", Provider: "tmdb"}, + {Key: "tmdb_latest_movie", Label: "TMDb 最新电影", Provider: "tmdb"}, + {Key: "tmdb_latest_tv", Label: "TMDb 最新剧集", Provider: "tmdb"}, {Key: "tmdb_popular_movie", Label: "TMDb 热门电影", Provider: "tmdb"}, {Key: "tmdb_popular_tv", Label: "TMDb 热门剧集", Provider: "tmdb"}, {Key: "tmdb_top_rated_movie", Label: "TMDb 高分电影", Provider: "tmdb"}, + {Key: "tmdb_upcoming_movie", Label: "TMDb 即将上映", Provider: "tmdb"}, {Key: "douban_hot_movie", Label: "豆瓣热门电影", Provider: "douban"}, {Key: "douban_hot_tv", Label: "豆瓣热门剧集", Provider: "douban"}, {Key: "douban_top_movie", Label: "豆瓣高分电影", Provider: "douban"}, @@ -94,7 +97,7 @@ func enabledDiscoverSections(ctx context.Context, svc *service.Container) []disc } func defaultDiscoverSectionKeys(ctx context.Context, svc *service.Container) []string { - preferred := []string{"tmdb_trending_day", "douban_hot_movie", "douban_hot_tv", "bangumi_calendar"} + preferred := []string{"tmdb_trending_day", "tmdb_latest_movie", "tmdb_latest_tv", "douban_hot_movie", "douban_hot_tv", "bangumi_calendar"} enabled := map[string]struct{}{} for _, section := range enabledDiscoverSections(ctx, svc) { enabled[section.Key] = struct{}{} @@ -124,7 +127,7 @@ func discoverSectionProvider(key string) string { } } switch key { - case "trending_day", "trending_week", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": + case "trending_day", "trending_week", "latest_movie", "latest_tv", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": return "tmdb" default: return "" @@ -144,8 +147,8 @@ func discoverProviderEnabled(ctx context.Context, svc *service.Container, provid func discoverSectionItems(ctx context.Context, svc *service.Container, k string) ([]service.ExternalMediaResult, error) { switch k { - case "tmdb_trending_day", "tmdb_trending_week", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie", - "trending_day", "trending_week", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": + case "tmdb_trending_day", "tmdb_trending_week", "tmdb_latest_movie", "tmdb_latest_tv", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie", "tmdb_upcoming_movie", + "trending_day", "trending_week", "latest_movie", "latest_tv", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": return svc.Discover.TMDbSection(ctx, k) case "douban_hot_movie", "douban_hot_tv", "douban_top_movie": if svc.Douban == nil { diff --git a/internal/handler/discover_extra_test.go b/internal/handler/discover_extra_test.go index 1c7524e..c1b8745 100644 --- a/internal/handler/discover_extra_test.go +++ b/internal/handler/discover_extra_test.go @@ -65,3 +65,16 @@ func TestDefaultDiscoverSectionKeysSkipDisabledProviders(t *testing.T) { t.Fatal("default keys should keep enabled providers") } } + +func TestDefaultDiscoverSectionKeysIncludeLatestTMDbRails(t *testing.T) { + keys := defaultDiscoverSectionKeys(t.Context(), &service.Container{}) + keySet := map[string]struct{}{} + for _, key := range keys { + keySet[key] = struct{}{} + } + for _, key := range []string{"tmdb_latest_movie", "tmdb_latest_tv"} { + if _, ok := keySet[key]; !ok { + t.Fatalf("default discover keys should include %q: %v", key, keys) + } + } +} diff --git a/internal/service/discover.go b/internal/service/discover.go index b40213e..0e52f20 100644 --- a/internal/service/discover.go +++ b/internal/service/discover.go @@ -178,6 +178,10 @@ func tmdbDiscoverPath(key string) string { return "/trending/movie/day" case "tmdb_trending_week", "trending_week": return "/trending/movie/week" + case "tmdb_latest_movie", "latest_movie": + return "/movie/now_playing" + case "tmdb_latest_tv", "latest_tv": + return "/tv/on_the_air" case "tmdb_popular_movie", "popular_movie": return "/movie/popular" case "tmdb_popular_tv", "popular_tv": diff --git a/internal/service/discover_artwork_test.go b/internal/service/discover_artwork_test.go index 170805a..fb28f09 100644 --- a/internal/service/discover_artwork_test.go +++ b/internal/service/discover_artwork_test.go @@ -83,6 +83,21 @@ func TestDiscoverWarmArtworkNoImageProxyIsNoop(t *testing.T) { } } +func TestTMDbDiscoverPathIncludesLatestSections(t *testing.T) { + cases := map[string]string{ + "tmdb_latest_movie": "/movie/now_playing", + "latest_movie": "/movie/now_playing", + "tmdb_latest_tv": "/tv/on_the_air", + "latest_tv": "/tv/on_the_air", + "tmdb_upcoming_movie": "/movie/upcoming", + } + for key, want := range cases { + if got := tmdbDiscoverPath(key); got != want { + t.Fatalf("tmdbDiscoverPath(%q) = %q, want %q", key, got, want) + } + } +} + func waitForDiscoverArtworkCache(t *testing.T, calls *int32, wantCalls int32, cachePath, raw string) { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/web/src/pages/DiscoverContentRow.tsx b/web/src/pages/DiscoverContentRow.tsx index f902bcf..28dd22b 100644 --- a/web/src/pages/DiscoverContentRow.tsx +++ b/web/src/pages/DiscoverContentRow.tsx @@ -65,34 +65,53 @@ function DiscoverCard({ onSelect: (item: DiscoverItem) => void }) { const source = discoverItemSource(item) + const imageCandidates = useMemo( + () => + [item.poster_url, item.backdrop_url] + .map((value) => value?.trim()) + .filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index), + [item.poster_url, item.backdrop_url], + ) + const [imageIndex, setImageIndex] = useState(0) const [posterRetry, setPosterRetry] = useState(0) const [posterUnavailable, setPosterUnavailable] = useState(false) const posterVersion = [imageVersion, posterRetry > 0 ? `r${posterRetry}` : ''].filter(Boolean).join('-') + const activeImage = imageCandidates[imageIndex] ?? '' const shouldRefreshCache = Boolean( (imageVersion && refreshImageVersion === imageVersion) || posterRetry > 0, ) const posterSrc = useMemo( () => - imageURL(item.poster_url, posterVersion, { + imageURL(activeImage, posterVersion, { refreshCache: shouldRefreshCache, retryFailed: true, }), - [item.poster_url, posterVersion, shouldRefreshCache], + [activeImage, posterVersion, shouldRefreshCache], ) useEffect(() => { + setImageIndex(0) setPosterRetry(0) setPosterUnavailable(false) - }, [item.poster_url]) + }, [item.poster_url, item.backdrop_url, imageVersion]) useEffect(() => { - if (!posterUnavailable || posterRetry >= 3) return + if (!posterUnavailable) return + if (imageIndex + 1 < imageCandidates.length) { + const timer = window.setTimeout(() => { + setImageIndex((current) => Math.min(current + 1, imageCandidates.length - 1)) + setPosterRetry(0) + setPosterUnavailable(false) + }, 150) + return () => window.clearTimeout(timer) + } + if (posterRetry >= 3) return const timer = window.setTimeout(() => { setPosterRetry((current) => current + 1) setPosterUnavailable(false) }, 1200 * (posterRetry + 1)) return () => window.clearTimeout(timer) - }, [posterRetry, posterUnavailable]) + }, [imageCandidates.length, imageIndex, posterRetry, posterUnavailable]) const markPosterUnavailable = () => setPosterUnavailable(true) diff --git a/web/src/pages/DiscoverPage.tsx b/web/src/pages/DiscoverPage.tsx index 03b14c1..6500cfc 100644 --- a/web/src/pages/DiscoverPage.tsx +++ b/web/src/pages/DiscoverPage.tsx @@ -8,6 +8,7 @@ import { defaultSections, discoverStorageKey, readSavedSections, + serializeSavedSections, } from './discoverPageModel' export function DiscoverPage() { @@ -74,7 +75,7 @@ export function DiscoverPage() { } return next }) - window.localStorage.setItem(discoverStorageKey, JSON.stringify(selected)) + window.localStorage.setItem(discoverStorageKey, serializeSavedSections(selected)) let pending = selected.length const markDone = () => { diff --git a/web/src/pages/discoverPageModel.ts b/web/src/pages/discoverPageModel.ts index b094ec6..679dbbb 100644 --- a/web/src/pages/discoverPageModel.ts +++ b/web/src/pages/discoverPageModel.ts @@ -2,15 +2,21 @@ import type { DiscoverItem, DiscoverSection } from '../api/discover' export const defaultSections = [ 'tmdb_trending_day', + 'tmdb_latest_movie', + 'tmdb_latest_tv', 'douban_hot_movie', 'douban_hot_tv', 'bangumi_calendar', ] export const discoverStorageKey = 'mediastation.discover.sections' +const discoverStorageVersion = 2 +const legacyDefaultAdditions = ['tmdb_latest_movie', 'tmdb_latest_tv'] export const defaultSectionDefs: DiscoverSection[] = [ { key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' }, + { key: 'tmdb_latest_movie', label: 'TMDb 最新电影', provider: 'tmdb' }, + { key: 'tmdb_latest_tv', label: 'TMDb 最新剧集', provider: 'tmdb' }, { key: 'tmdb_popular_movie', label: 'TMDb 热门电影', provider: 'tmdb' }, { key: 'douban_hot_movie', label: '豆瓣热门电影', provider: 'douban' }, { key: 'douban_hot_tv', label: '豆瓣热门剧集', provider: 'douban' }, @@ -26,14 +32,44 @@ export function readSavedSections(sections: DiscoverSection[]): string[] { const raw = window.localStorage.getItem(discoverStorageKey) if (!raw) return [] const parsed = JSON.parse(raw) - if (!Array.isArray(parsed)) return [] const allowed = new Set(sections.map((section) => section.key)) - return parsed.filter((key) => typeof key === 'string' && allowed.has(key)) + if (Array.isArray(parsed)) { + return orderSectionKeys(addLegacyDefaults(parsed, allowed), sections) + } + if (!parsed || !Array.isArray(parsed.selected)) return [] + const selected = sanitizeSectionKeys(parsed.selected, allowed) + if (parsed.version === discoverStorageVersion) { + return orderSectionKeys(selected, sections) + } + return orderSectionKeys(addLegacyDefaults(selected, allowed), sections) } catch { return [] } } +export function serializeSavedSections(selected: string[]): string { + return JSON.stringify({ version: discoverStorageVersion, selected }) +} + +function sanitizeSectionKeys(keys: unknown[], allowed: Set): string[] { + return keys.filter((key): key is string => typeof key === 'string' && allowed.has(key)) +} + +function addLegacyDefaults(keys: unknown[], allowed: Set): string[] { + const out = sanitizeSectionKeys(keys, allowed) + for (const key of legacyDefaultAdditions) { + if (allowed.has(key) && !out.includes(key)) { + out.push(key) + } + } + return out +} + +function orderSectionKeys(keys: string[], sections: DiscoverSection[]): string[] { + const selected = new Set(keys) + return sections.map((section) => section.key).filter((key) => selected.has(key)) +} + export function buildSubscribeKeyword(item: DiscoverItem): string { return [item.title, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' ') }