mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
fix(subscribe): preserve source identity across season flows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '电影' })
|
||||
|
||||
Reference in New Issue
Block a user