fix(subscribe): preserve source identity across season flows

This commit is contained in:
jxxghp
2026-07-25 08:51:30 +08:00
parent 0ee9e466c3
commit b3e596c761
8 changed files with 217 additions and 36 deletions

View File

@@ -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

View File

@@ -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<HTMLElement | null>(null)
@@ -166,19 +173,12 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
// 获得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}`)

View File

@@ -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()

View File

@@ -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 = [

View File

@@ -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 })

View File

@@ -49,17 +49,55 @@ const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/di
export type SeasonSubscribeModes = Record<number, SubscribeMode>
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<string, unknown> = {
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,

View File

@@ -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

View File

@@ -116,6 +116,7 @@ interface RenderDetailOptions {
detailRequest?: (url: URL) => void
detailStatus?: number
episodeGroups?: Record<string, unknown>[]
episodeGroupsRequest?: (url: URL) => void | Promise<void>
episodeGroupsStatus?: number
existsResponse?: { data?: Record<string, unknown>; 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: '电影' })