From b3e596c761a9df78783b98f7da18b428df0046ff Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 25 Jul 2026 08:51:30 +0800 Subject: [PATCH] fix(subscribe): preserve source identity across season flows --- src/components/dialog/SubscribeEditDialog.vue | 2 + .../dialog/SubscribeSeasonDialog.vue | 24 +++---- .../__tests__/SubscribeEditDialog.spec.ts | 28 +++++++- .../__tests__/SubscribeSeasonDialog.spec.ts | 38 ++++++++++- .../__tests__/useMediaSubscribe.spec.ts | 45 ++++++++++++- src/composables/useMediaSubscribe.ts | 65 +++++++++++++++---- src/views/discover/MediaDetailView.vue | 14 ++-- .../__tests__/MediaDetailView.spec.ts | 37 ++++++++++- 8 files changed, 217 insertions(+), 36 deletions(-) diff --git a/src/components/dialog/SubscribeEditDialog.vue b/src/components/dialog/SubscribeEditDialog.vue index 5a1142a7..18c9752e 100644 --- a/src/components/dialog/SubscribeEditDialog.vue +++ b/src/components/dialog/SubscribeEditDialog.vue @@ -131,6 +131,8 @@ function episodeGroupItemProps(item: { title: string; subtitle: string }) { // 查询所有剧集组 async function getEpisodeGroups() { + // 兼容未记录主来源的旧 TMDB 订阅;明确为其他来源时不使用辅助 TMDB ID 查询剧集组。 + if (subscribeForm.value.media_source && subscribeForm.value.media_source !== 'themoviedb') return if (!subscribeForm.value.tmdbid) { console.warn('tmdbid is not set or is empty') return diff --git a/src/components/dialog/SubscribeSeasonDialog.vue b/src/components/dialog/SubscribeSeasonDialog.vue index aaa9d188..7fb61f3c 100644 --- a/src/components/dialog/SubscribeSeasonDialog.vue +++ b/src/components/dialog/SubscribeSeasonDialog.vue @@ -6,7 +6,12 @@ import noImage from '@images/no-image.jpeg' import NoDataFound from '@/components/states/NoDataFound.vue' import { useI18n } from 'vue-i18n' import { useGlobalSettingsStore } from '@/stores' -import type { SeasonSubscribeModes, SubscribeMode } from '@/composables/useMediaSubscribe' +import { + getMediaSubscribeId, + getMediaSubscribeIdentity, + type SeasonSubscribeModes, + type SubscribeMode, +} from '@/composables/useMediaSubscribe' import { getDisplayImageUrl } from '@/utils/imageUtils' import { useDisplay } from 'vuetify' @@ -67,7 +72,9 @@ const isRefreshed = ref(false) const episodeGroups = ref<{ [key: string]: any }[]>([]) // 当前选择剧集组 -const episodeGroup = ref(props.initialEpisodeGroup ?? '') +const episodeGroup = ref( + getMediaSubscribeIdentity(props.media)?.source === 'themoviedb' ? (props.initialEpisodeGroup ?? '') : '', +) // 剧集组横向轨道 const episodeGroupRail = ref(null) @@ -166,19 +173,12 @@ const episodeGroupOptions = computed(() => { // 获得mediaid function getMediaId() { - if (props.media?.media_id && (props.media?.source || props.media?.mediaid_prefix)) { - const source = props.media.mediaid_prefix || props.media.source - return `${source === 'themoviedb' ? 'tmdb' : source}:${props.media.media_id}` - } - if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}` - else if (props.media?.douban_id) return `douban:${props.media?.douban_id}` - else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}` - else if (props.media?.anilist_id) return `anilist:${props.media?.anilist_id}` - return '' + return getMediaSubscribeId(props.media) } // 查询所有剧集组 async function getEpisodeGroups() { + if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb') return if (!props.media?.tmdb_id) { console.warn('tmdbid is not set or is empty') return @@ -213,7 +213,7 @@ async function getMediaSeasons() { // 查询剧集组的剧集 async function getGroupSeasons() { - if (!episodeGroup.value) return + if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb' || !episodeGroup.value) return isRefreshed.value = false try { seasonInfos.value = await api.get(`media/group/seasons/${episodeGroup.value}`) diff --git a/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts b/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts index 68134f60..f0b3a62e 100644 --- a/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts +++ b/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts @@ -128,6 +128,27 @@ describe('SubscribeEditDialog', () => { expect(episodeGroupsRequested).not.toHaveBeenCalled() }) + it('skips episode groups for a non-TMDB subscription with an auxiliary TMDB ID', async () => { + const record = createSubscribe({ + anilistid: 154587, + id: 810, + media_id: '154587', + media_source: 'anilist', + name: 'AniList 编辑测试剧', + season: 1, + tmdbid: 8100, + type: '电视剧', + }) + const episodeGroupsRequested = vi.fn() + server.use(subscribeDetailsHandler(810, record)) + useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8100 }) + + await renderDialog({ subid: 810 }) + + expect(await screen.findByText('AniList 编辑测试剧 S01')).toBeInTheDocument() + expect(episodeGroupsRequested).not.toHaveBeenCalled() + }) + it('shows enabled sites and stable downloader, directory, and rule options', async () => { const activeSite = createSubscribeSite({ id: 1, is_active: true, name: '启用站点' }) const inactiveSite = createSubscribeSite({ id: 2, is_active: false, name: '停用站点' }) @@ -202,7 +223,12 @@ describe('SubscribeEditDialog', () => { const configRequested = vi.fn() const saved = vi.fn() server.use( - defaultSubscribeConfigHandler(type, createSubscribe({ id: 0, show_edit_dialog: false, type }), 200, configRequested), + defaultSubscribeConfigHandler( + type, + createSubscribe({ id: 0, show_edit_dialog: false, type }), + 200, + configRequested, + ), saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved), ) useDialogOptions() diff --git a/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts b/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts index 2350945e..ba728bbf 100644 --- a/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts +++ b/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts @@ -189,9 +189,13 @@ describe('SubscribeSeasonDialog', () => { }) it.each([ - ['Douban', { douban_id: 'db-7303', tmdb_id: undefined }, 'douban:db-7303'], - ['Bangumi', { bangumi_id: 'bgm-7304', douban_id: undefined, tmdb_id: undefined }, 'bangumi:bgm-7304'], - ['AniList', { anilist_id: 154587, bangumi_id: undefined, tmdb_id: undefined }, 'anilist:154587'], + ['Douban', { douban_id: 'db-7303', source: 'douban', tmdb_id: undefined }, 'douban:db-7303'], + [ + 'Bangumi', + { bangumi_id: 'bgm-7304', douban_id: undefined, source: 'bangumi', tmdb_id: undefined }, + 'bangumi:bgm-7304', + ], + ['AniList', { anilist_id: 154587, bangumi_id: undefined, source: 'anilist', tmdb_id: undefined }, 'anilist:154587'], [ 'custom source', { @@ -231,6 +235,34 @@ describe('SubscribeSeasonDialog', () => { expect(requested.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId) }) + it.each([ + ['Douban', { douban_id: 'db-7310', source: 'douban' }, 'douban:db-7310'], + ['Bangumi', { bangumi_id: 'bgm-7310', source: 'bangumi' }, 'bangumi:bgm-7310'], + ['AniList', { anilist_id: 154587, source: 'anilist' }, 'anilist:154587'], + ] as const)( + 'keeps the %s identity and skips episode groups when an auxiliary TMDB ID exists', + async (_label, overrides, mediaId) => { + const media = createTvMedia({ ...overrides, tmdb_id: 7310 }) + const seasonRequest = vi.fn() + const groupRequest = vi.fn() + const groupSeasonsRequest = vi.fn() + server.use( + mediaSeasonsHandler([createMediaSeason({ season_number: 1 })], 200, seasonRequest), + mediaNotExistsHandler([]), + mediaEpisodeGroupsHandler(7310, [], 200, groupRequest), + mediaGroupSeasonsHandler('auxiliary-group', [], 200, groupSeasonsRequest), + ) + + await renderDialog({ initialEpisodeGroup: 'auxiliary-group', media }) + + expect(await screen.findByText('第 1 季')).toBeInTheDocument() + await settleRequests() + expect(seasonRequest.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId) + expect(groupRequest).not.toHaveBeenCalled() + expect(groupSeasonsRequest).not.toHaveBeenCalled() + }, + ) + it('synchronizes visible selections and modes, then emits the five-argument subscription payload', async () => { const media = createTvMedia({ tmdb_id: 7306 }) const seasons = [ diff --git a/src/composables/__tests__/useMediaSubscribe.spec.ts b/src/composables/__tests__/useMediaSubscribe.spec.ts index 7a83e032..d91a768c 100644 --- a/src/composables/__tests__/useMediaSubscribe.spec.ts +++ b/src/composables/__tests__/useMediaSubscribe.spec.ts @@ -1,6 +1,7 @@ import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types' import { getMediaSubscribeId, + getMediaSubscribeIdentity, getSubscribeMode, type SeasonSubscribeModes, type SubscribeMode, @@ -190,6 +191,20 @@ describe('media subscribe identifiers and modes', () => { expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected) }) + it('keeps the declared AniList identity when TMDB is only auxiliary data', () => { + const media = createSubscribeTv({ + anilist_id: 154587, + source: 'anilist', + tmdb_id: 209867, + }) + + expect(getMediaSubscribeIdentity(media)).toEqual({ + mediaId: '154587', + mediaKey: 'anilist:154587', + source: 'anilist', + }) + }) + it.each([ [{ best_version: false, best_version_full: true }, 'normal'], [{ best_version: 0, best_version_full: 1 }, 'normal'], @@ -223,8 +238,9 @@ describe('useMediaSubscribe entry flows', () => { await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce()) expect(created).toHaveBeenCalledWith({ episode_group: '', + media_id: '101', media_source: 'themoviedb', - mediaid: '', + mediaid: 'tmdb:101', name: '普通电影', season: null, tmdbid: 101, @@ -238,6 +254,33 @@ describe('useMediaSubscribe entry flows', () => { expect(mocks.doneProgress).toHaveBeenCalledOnce() }) + it('creates an AniList subscription without promoting its auxiliary TMDB ID', async () => { + const media = createSubscribeTv({ + anilist_id: 154587, + source: 'anilist', + title: 'AniList 订阅剧集', + tmdb_id: 209867, + }) + const created = vi.fn() + server.use( + createSubscribeHandler({ data: { id: 512 }, success: true }, 200, created), + defaultSubscribeConfigHandler('电视剧', { show_edit_dialog: false }), + ) + await renderSubscribeHarness({ media }) + + await fireEvent.click(screen.getByRole('button', { name: 'add-normal' })) + + await waitFor(() => expect(created).toHaveBeenCalledOnce()) + expect(created.mock.calls[0][0]).toMatchObject({ + anilistid: 154587, + media_id: '154587', + media_source: 'anilist', + mediaid: 'anilist:154587', + season: 1, + tmdbid: 209867, + }) + }) + it('keeps a successful creation successful when default configuration loading fails', async () => { const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) const media = createSubscribeMovie({ title: '辅助查询失败电影', tmdb_id: 111 }) diff --git a/src/composables/useMediaSubscribe.ts b/src/composables/useMediaSubscribe.ts index 68827292..5cc884cb 100644 --- a/src/composables/useMediaSubscribe.ts +++ b/src/composables/useMediaSubscribe.ts @@ -49,17 +49,55 @@ const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/di export type SeasonSubscribeModes = Record +export interface MediaSubscribeIdentity { + mediaId: string + mediaKey: string + source: string +} + +/** 按媒体声明的主来源解析订阅身份,避免辅助 ID 覆盖原始识别源。 */ +export function getMediaSubscribeIdentity(media?: MediaInfo): MediaSubscribeIdentity | undefined { + if (!media) return undefined + + const normalizeSource = (value?: string) => { + const source = (value || '').trim().toLowerCase() + return source === 'tmdb' ? 'themoviedb' : source + } + const sourceIds: Record = { + anilist: media.anilist_id, + bangumi: media.bangumi_id, + douban: media.douban_id, + themoviedb: media.tmdb_id, + } + const buildIdentity = (identitySource: string, value: unknown): MediaSubscribeIdentity | undefined => { + if (value === undefined || value === null || !String(value).trim()) return undefined + const mediaId = String(value).trim() + const prefix = identitySource === 'themoviedb' ? 'tmdb' : identitySource + return { + mediaId, + mediaKey: `${prefix}:${mediaId}`, + source: identitySource, + } + } + + const declaredSources = [media.mediaid_prefix, media.source] + .map(normalizeSource) + .filter((source, index, sources) => source && sources.indexOf(source) === index) + for (const source of declaredSources) { + const declaredIdentity = buildIdentity(source, media.media_id ?? sourceIds[source]) + if (declaredIdentity) return declaredIdentity + } + + for (const fallbackSource of ['themoviedb', 'douban', 'bangumi', 'anilist']) { + const fallbackIdentity = buildIdentity(fallbackSource, sourceIds[fallbackSource]) + if (fallbackIdentity) return fallbackIdentity + } + return undefined +} + // 生成跨媒体源稳定的订阅媒体标识。 export function getMediaSubscribeId(media?: MediaInfo) { - if (media?.media_id && (media.source || media.mediaid_prefix)) { - const source = media.mediaid_prefix || media.source - return `${source === 'themoviedb' ? 'tmdb' : source}:${media.media_id}` - } - if (media?.tmdb_id) return `tmdb:${media.tmdb_id}` - if (media?.douban_id) return `douban:${media.douban_id}` - if (media?.bangumi_id) return `bangumi:${media.bangumi_id}` - if (media?.anilist_id) return `anilist:${media.anilist_id}` - return '' + return getMediaSubscribeIdentity(media)?.mediaKey ?? '' } // 将订阅模式转换为后端订阅字段。 @@ -271,6 +309,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { ) { const media = currentMedia() if (!media) return + const identity = getMediaSubscribeIdentity(media) startNProgress() try { @@ -282,11 +321,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { doubanid: media.douban_id, bangumiid: media.bangumi_id, anilistid: media.anilist_id, - media_source: media.source || media.mediaid_prefix, - media_id: media.media_id, - mediaid: media.media_id - ? `${(media.mediaid_prefix || media.source) === 'themoviedb' ? 'tmdb' : media.mediaid_prefix || media.source}:${media.media_id}` - : '', + media_source: identity?.source, + media_id: identity?.mediaId, + mediaid: identity?.mediaKey ?? '', season: media.type === '电影' ? null : season, ...payload, episode_group: episodeGroup.value, diff --git a/src/views/discover/MediaDetailView.vue b/src/views/discover/MediaDetailView.vue index 16180aac..8f930002 100644 --- a/src/views/discover/MediaDetailView.vue +++ b/src/views/discover/MediaDetailView.vue @@ -27,6 +27,7 @@ import { openSharedDialog } from '@/composables/useSharedDialog' import { getDisplayImageUrl } from '@/utils/imageUtils' import { getMediaSubscribeId, + getMediaSubscribeIdentity, getSubscribeMode, useMediaSubscribe, type SeasonSubscribeModes, @@ -228,8 +229,13 @@ async function getMediaDetail() { }) if (!hasMediaIdentity()) return - selectedEpisodeGroup.value = mediaDetail.value.episode_group || '' - if (mediaDetail.value.type === '电视剧' && mediaDetail.value.tmdb_id) { + const supportsEpisodeGroups = getMediaSubscribeIdentity(mediaDetail.value)?.source === 'themoviedb' + selectedEpisodeGroup.value = supportsEpisodeGroups ? mediaDetail.value.episode_group || '' : '' + if (!supportsEpisodeGroups) { + episodeGroups.value = [] + episodeGroupSeasons.value = [] + } + if (mediaDetail.value.type === '电视剧' && supportsEpisodeGroups && mediaDetail.value.tmdb_id) { getEpisodeGroups() if (selectedEpisodeGroup.value) loadEpisodeGroupSeasons(selectedEpisodeGroup.value) } @@ -402,7 +408,7 @@ const getMediaSeasons = computed(() => { // 查询当前媒体可用的剧集组 async function getEpisodeGroups() { - if (!mediaDetail.value.tmdb_id) return + if (getMediaSubscribeIdentity(mediaDetail.value)?.source !== 'themoviedb' || !mediaDetail.value.tmdb_id) return episodeGroupsLoading.value = true try { @@ -419,7 +425,7 @@ async function getEpisodeGroups() { // 查询指定剧集组的季信息,并忽略过期响应 async function loadEpisodeGroupSeasons(groupId: string) { - if (!groupId) { + if (getMediaSubscribeIdentity(mediaDetail.value)?.source !== 'themoviedb' || !groupId) { episodeGroupSeasons.value = [] episodeGroupSeasonsLoading.value = false return diff --git a/src/views/discover/__tests__/MediaDetailView.spec.ts b/src/views/discover/__tests__/MediaDetailView.spec.ts index db1223c1..a142a793 100644 --- a/src/views/discover/__tests__/MediaDetailView.spec.ts +++ b/src/views/discover/__tests__/MediaDetailView.spec.ts @@ -116,6 +116,7 @@ interface RenderDetailOptions { detailRequest?: (url: URL) => void detailStatus?: number episodeGroups?: Record[] + episodeGroupsRequest?: (url: URL) => void | Promise episodeGroupsStatus?: number existsResponse?: { data?: Record; message?: string; success: boolean } existsStatus?: number @@ -158,7 +159,14 @@ async function renderDetail(options: RenderDetailOptions = {}) { querySubscribeByMediaHandler(getMediaSubscribeId(media), options.movieSubscribe ?? {}, 200, subscribeRequest), ) if (media.tmdb_id) { - server.use(mediaEpisodeGroupsHandler(media.tmdb_id, options.episodeGroups ?? [], options.episodeGroupsStatus)) + server.use( + mediaEpisodeGroupsHandler( + media.tmdb_id, + options.episodeGroups ?? [], + options.episodeGroupsStatus, + options.episodeGroupsRequest, + ), + ) } options.setupHandlers?.() installSiteHandlers(options.sites, options.selectedSites) @@ -581,6 +589,33 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) }) + it('does not query TMDB episode groups for an AniList detail with an auxiliary TMDB ID', async () => { + const episodeGroupsRequest = vi.fn() + const groupSeasonsRequest = vi.fn() + const media = createSubscribeTv({ + anilist_id: 154587, + episode_group: 'auxiliary-group', + source: 'anilist', + title: 'AniList 主来源剧集', + tmdb_id: 8700, + }) + + await renderDetail({ + episodeGroupsRequest, + media, + mediaId: 'anilist:154587', + setupHandlers: () => { + server.use(mediaGroupSeasonsHandler('auxiliary-group', [], 200, groupSeasonsRequest)) + }, + type: '电视剧', + }) + + expect(await screen.findByRole('heading', { name: /AniList 主来源剧集/ })).toBeInTheDocument() + await flushPromises() + expect(episodeGroupsRequest).not.toHaveBeenCalled() + expect(groupSeasonsRequest).not.toHaveBeenCalled() + }) + it('shows the current movie subscription state returned by the media endpoint', async () => { const media = createMediaInfo({ title: '已订阅电影', tmdb_id: 8701, type: '电影' }) const subscribe = createSubscribe({ id: 18701, name: media.title, tmdbid: 8701, type: '电影' })