diff --git a/src/ace-config.ts b/src/ace-config.ts index 7621d39e..cf75cec0 100644 --- a/src/ace-config.ts +++ b/src/ace-config.ts @@ -70,7 +70,7 @@ function registerJinja2Mode() { 'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper' const operators = 'and|in|is|not|or' const contextVariables = - 'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__' + 'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__' const keywordMapper = this.createKeywordMapper( { @@ -282,7 +282,7 @@ function registerJinja2Mode() { 'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper' const operators = 'and|in|is|not|or' const contextVariables = - 'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__' + 'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__' const keywordMapper = this.createKeywordMapper( { diff --git a/src/api/types.ts b/src/api/types.ts index e9f714af..4cf35750 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,4 @@ -export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' +export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | (string & {}) // 手动刮削选项 export interface ManualScrapeOptions { @@ -27,7 +27,13 @@ export interface Subscribe { // 豆瓣ID doubanid?: string // Bangumi ID - bangumiid?: string + bangumiid?: number + // AniList ID + anilistid?: number + // 主媒体数据源 + media_source?: MediaDataSource + // 数据源原生ID + media_id?: string // 其它媒体ID mediaid?: string // 季号 @@ -128,6 +134,12 @@ export interface SubscribeShare { doubanid?: string // Bangumi ID bangumiid?: number + // AniList ID + anilistid?: number + // 主媒体数据源 + media_source?: MediaDataSource + // 数据源原生ID + media_id?: string // 季号 season?: number // 海报 @@ -230,6 +242,10 @@ export interface TransferHistory { tvdbid?: number // 豆瓣ID doubanid?: string + // Bangumi ID + bangumiid?: number + // AniList ID + anilistid?: number // 媒体数据源 media_source?: MediaDataSource // 数据源原生ID @@ -1535,6 +1551,10 @@ export interface TransferForm { tmdbid?: number // 豆瓣 ID doubanid?: string + // Bangumi ID + bangumiid?: number + // AniList ID + anilistid?: number // 媒体数据源 media_source?: MediaDataSource // 数据源原生ID diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index 0b73425a..1240a772 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -166,12 +166,17 @@ function getExistsStatusKey() { } function isSameSubscribeMedia(subscribe: Subscribe) { + const mediaId = getMediaId() + if (subscribe.media_source && subscribe.media_id) { + const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source + return mediaId === `${prefix}:${subscribe.media_id}` + } + if (subscribe.mediaid) return mediaId === subscribe.mediaid if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid - - const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : '' - return Boolean(mediaId && subscribe.mediaid === mediaId) + if (props.media?.anilist_id && subscribe.anilistid) return props.media.anilist_id === subscribe.anilistid + return false } // 角标颜色 diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index 07581725..977a8965 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -264,9 +264,14 @@ async function editSubscribeDialog() { // 获得mediaid function getMediaId() { + if (props.media?.media_source && props.media?.media_id) { + const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source + return `${prefix}:${props.media.media_id}` + } if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}` else if (props.media?.doubanid) return `douban:${props.media?.doubanid}` else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}` + else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}` else return props.media?.mediaid } @@ -480,13 +485,7 @@ function handleCardClick() {
- -
- - - -
-
-
{{ props.media?.year }}
-
- {{ props.media?.name }} - {{ formatSeasonLabel(props.media?.season, t('media.specials')) }} -
-
-
- -
- - - -
- {{ subscribeProgressText }} - - {{ subscribeProgressTooltip }} - -
- - - - +
- {{ props.media?.username }} - -
- - - - - {{ rightBottomStateDisplay.label }} - - - - {{ lastUpdateText }} - -
- +
+ {{ subscribeProgressText }} + + {{ subscribeProgressTooltip }} + +
+ + + + + {{ props.media?.username }} + +
+ + + + + {{ rightBottomStateDisplay.label }} + + + + {{ lastUpdateText }} + +
+ - - -
+ + +
diff --git a/src/components/cards/SubscribeShareCard.vue b/src/components/cards/SubscribeShareCard.vue index 3699812a..9cd43b72 100644 --- a/src/components/cards/SubscribeShareCard.vue +++ b/src/components/cards/SubscribeShareCard.vue @@ -47,9 +47,14 @@ const posterUrl = computed(() => { // 获得mediaid function getMediaId() { + if (props.media?.media_source && props.media?.media_id) { + const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source + return `${prefix}:${props.media.media_id}` + } if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}` else if (props.media?.doubanid) return `douban:${props.media?.doubanid}` else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}` + else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}` } // 查看媒体详情 @@ -102,61 +107,65 @@ function doDelete() { 'app-hover-lift-card--hovering': hover.isHovering, }" > - - -
- -
- - - -
-
-
- {{ props.media?.share_title }} +
+
+ {{ props.media?.share_title }} +
+
+ {{ props.media?.share_comment }} +
-
- {{ props.media?.share_comment }} + + +
+ +
+ {{ props.media?.share_user }} +
+ + + {{ props.media?.count.toLocaleString() }} +
-
- - -
- -
- {{ props.media?.share_user }} -
- - - {{ props.media?.count.toLocaleString() }} - -
-
- - - {{ dateText }} - -
+ + + + {{ dateText }} + +
diff --git a/src/components/cards/__tests__/SubscribeCard.spec.ts b/src/components/cards/__tests__/SubscribeCard.spec.ts index b7b2e357..a272a99e 100644 --- a/src/components/cards/__tests__/SubscribeCard.spec.ts +++ b/src/components/cards/__tests__/SubscribeCard.spec.ts @@ -164,7 +164,16 @@ describe('SubscribeCard display and progress', () => { ['disabled flag', false, true, 3, '电视剧', 80, false, false], ])( 'normalizes %s for wash progress and badges', - async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => { + async ( + _case, + bestVersion, + bestVersionFull, + completedEpisode, + type, + expectedProgress, + expectedWash, + expectedFull, + ) => { const { container } = await renderCard({ best_version: bestVersion, best_version_full: bestVersionFull, @@ -291,10 +300,20 @@ describe('SubscribeCard interaction boundaries', () => { }) it.each([ - ['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'], - ['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'], - ['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'], - ['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'], + ['TMDB before all fallbacks', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'], + ['Douban before Bangumi', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'], + ['Bangumi before custom', { bangumiid: 33, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'], + [ + 'AniList before legacy custom', + { anilistid: 55, bangumiid: undefined, mediaid: 'custom:44', tmdbid: 0 }, + 'anilist:55', + ], + ['selected primary identity', { media_id: '66', media_source: 'anilist', tmdbid: 11 }, 'anilist:66'], + [ + 'custom media ID last', + { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, + 'custom:44', + ], ])('routes media details with %s', async (_case, identifiers, expectedMediaId) => { const { container, media } = await renderCard(identifiers) @@ -364,30 +383,34 @@ describe('SubscribeCard item operations', () => { ['confirmation cancellation', false, 200, { success: true }, null], ['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'], ['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'], - ] as const)( - 'keeps status unchanged after %s', - async (_case, confirmed, status, response, expectedError) => { - const requested = vi.fn() - mocks.confirm.mockResolvedValue(confirmed) - const { container, emitted, media } = await renderCard({ state: 'R' }) - server.use(updateSubscribeStatusHandler(media.id, response, status, requested)) + ] as const)('keeps status unchanged after %s', async (_case, confirmed, status, response, expectedError) => { + const requested = vi.fn() + mocks.confirm.mockResolvedValue(confirmed) + const { container, emitted, media } = await renderCard({ state: 'R' }) + server.use(updateSubscribeStatusHandler(media.id, response, status, requested)) - await chooseMenuItem(container, '暂停') - await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()) + await chooseMenuItem(container, '暂停') + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()) - if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce()) - else expect(requested).not.toHaveBeenCalled() - if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError)) - else expect(mocks.toastError).not.toHaveBeenCalled() - expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused') - expect(emitted('save') ?? []).toHaveLength(0) - }, - ) + if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + else expect(requested).not.toHaveBeenCalled() + if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError)) + else expect(mocks.toastError).not.toHaveBeenCalled() + expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused') + expect(emitted('save') ?? []).toHaveLength(0) + }) it.each([ ['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'], ['confirmation cancellation', false, 200, { success: true }, null, null], - ['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败:rejected'], + [ + 'business failure', + true, + 200, + { message: 'rejected', success: false }, + 'error', + '卡片测试媒体 重置失败:rejected', + ], ['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'], ] as const)( 'handles reset %s without speculative state', @@ -423,16 +446,19 @@ describe('SubscribeCard item operations', () => { it.each([ ['success', 200, { success: true }, true, null], ['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'], - ] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => { - const requested = vi.fn() - const { container, emitted, media } = await renderCard() - server.use(deleteSubscribeByIdHandler(media.id, response, status, requested)) + ] as const)( + 'handles delete %s without a synthetic business-failure branch', + async (_case, status, response, removed, error) => { + const requested = vi.fn() + const { container, emitted, media } = await renderCard() + server.use(deleteSubscribeByIdHandler(media.id, response, status, requested)) - await chooseMenuItem(container, '取消订阅') + await chooseMenuItem(container, '取消订阅') - await waitFor(() => expect(requested).toHaveBeenCalledOnce()) - expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0) - if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error)) - else expect(mocks.toastError).not.toHaveBeenCalled() - }) + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0) + if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error)) + else expect(mocks.toastError).not.toHaveBeenCalled() + }, + ) }) diff --git a/src/components/cards/__tests__/SubscribeShareCard.spec.ts b/src/components/cards/__tests__/SubscribeShareCard.spec.ts index 050056e8..e3d3fd51 100644 --- a/src/components/cards/__tests__/SubscribeShareCard.spec.ts +++ b/src/components/cards/__tests__/SubscribeShareCard.spec.ts @@ -127,6 +127,7 @@ describe('SubscribeShareCard', () => { ['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'], ['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'], ['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'], + ['AniList without other IDs', { anilistid: 4404, bangumiid: undefined, tmdbid: undefined }, 'anilist:4404'], ] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => { const { container, media } = await renderCard(ids) const poster = await loadPoster(container) diff --git a/src/components/dialog/CacheReidentifyDialog.vue b/src/components/dialog/CacheReidentifyDialog.vue index ef447922..45fac9b4 100644 --- a/src/components/dialog/CacheReidentifyDialog.vue +++ b/src/components/dialog/CacheReidentifyDialog.vue @@ -1,5 +1,6 @@ @@ -59,20 +76,20 @@ function submitReidentify() { - + + { // 获得mediaid function getMediaId() { + if (props.media?.media_source && props.media?.media_id) { + const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source + return `${prefix}:${props.media.media_id}` + } if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}` else if (props.media?.doubanid) return `douban:${props.media?.doubanid}` else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}` + else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}` } // 查看媒体详情 diff --git a/src/components/dialog/ReorganizeDialog.vue b/src/components/dialog/ReorganizeDialog.vue index 52159f78..028a6e26 100644 --- a/src/components/dialog/ReorganizeDialog.vue +++ b/src/components/dialog/ReorganizeDialog.vue @@ -42,17 +42,17 @@ const props = defineProps({ const globalSettingsStore = useGlobalSettingsStore() const globalSettings = globalSettingsStore.globalSettings -const mediaSourceItems: { title: string; value: MediaDataSource }[] = [ - { title: 'TheMovieDb', value: 'themoviedb' }, - { title: '豆瓣', value: 'douban' }, - { title: 'Bangumi', value: 'bangumi' }, - { title: 'AniList', value: 'anilist' }, -] +const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [ + { title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' }, + { title: t('setting.cache.recognitionSource.douban'), value: 'douban' }, + { title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' }, + { title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' }, +]) // 获取后台设置中的默认识别数据源,未知值兼容回退到TheMovieDb。 function getDefaultMediaSource(): MediaDataSource { const configuredSource = globalSettings.RECOGNIZE_SOURCE as MediaDataSource - return mediaSourceItems.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb' + return mediaSourceItems.value.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb' } // 定义事件 diff --git a/src/components/dialog/ScrapeDialog.vue b/src/components/dialog/ScrapeDialog.vue index 530702b4..df117716 100644 --- a/src/components/dialog/ScrapeDialog.vue +++ b/src/components/dialog/ScrapeDialog.vue @@ -24,12 +24,12 @@ const emit = defineEmits<{ (event: 'update:modelValue', value: boolean): void }>() -const mediaSourceItems: { title: string; value: MediaDataSource }[] = [ - { title: 'TheMovieDb', value: 'themoviedb' }, - { title: '豆瓣', value: 'douban' }, - { title: 'Bangumi', value: 'bangumi' }, - { title: 'AniList', value: 'anilist' }, -] +const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [ + { title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' }, + { title: t('setting.cache.recognitionSource.douban'), value: 'douban' }, + { title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' }, + { title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' }, +]) const globalSettingsStore = useGlobalSettingsStore() const mediaType = ref('') @@ -67,7 +67,7 @@ const canSubmit = computed(() => { // 获取后台设置中的默认识别数据源,未知值兼容回退到 TheMovieDb。 function getDefaultMediaSource(): MediaDataSource { const configuredSource = globalSettingsStore.globalSettings.RECOGNIZE_SOURCE as MediaDataSource - return mediaSourceItems.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb' + return mediaSourceItems.value.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb' } // 将搜索结果媒体类型映射为手动刮削接口接受的类型名。 diff --git a/src/components/dialog/SubscribeSeasonDialog.vue b/src/components/dialog/SubscribeSeasonDialog.vue index 34b3d23a..52604e04 100644 --- a/src/components/dialog/SubscribeSeasonDialog.vue +++ b/src/components/dialog/SubscribeSeasonDialog.vue @@ -164,10 +164,15 @@ 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 return `${props.media?.mediaid_prefix}:${props.media?.media_id}` + else if (props.media?.anilist_id) return `anilist:${props.media?.anilist_id}` + return '' } // 查询所有剧集组 @@ -355,7 +360,8 @@ function getDefaultSeasonMode(season: number) { // 确保指定季已初始化订阅模式。 function ensureSeasonMode(season: number) { - if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season)) + if (!seasonModes.value[season]) + setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season)) } // 在入库状态刷新后同步尚未手动修改的默认模式。 diff --git a/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts b/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts index d853c072..bbe14088 100644 --- a/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts +++ b/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts @@ -63,6 +63,7 @@ const PosterStub = defineComponent({ }) interface MediaIdentifiers { + anilistid?: number bangumiid?: number doubanid?: string tmdbid?: number @@ -72,6 +73,7 @@ const mediaDetailCases: Array<[string, MediaIdentifiers, string]> = [ ['TMDB', { tmdbid: 6301 }, 'tmdb:6301'], ['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'], ['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'], + ['AniList', { anilistid: 6304, bangumiid: undefined, tmdbid: undefined }, 'anilist:6304'], ] function createDeferred() { @@ -82,10 +84,7 @@ function createDeferred() { return { promise, resolve } } -async function renderDialog( - media: SubscribeShare = createSubscribeShare(), - settings: Record = {}, -) { +async function renderDialog(media: SubscribeShare = createSubscribeShare(), settings: Record = {}) { const events = { close: vi.fn(), delete: vi.fn(), @@ -149,10 +148,7 @@ describe('ForkSubscribeDialog follow behavior', () => { const writeRequest = vi.fn((url: URL) => { users.push(url.searchParams.get('share_uid') || '') }) - server.use( - followSubscribersSettingHandler(users), - followSubscriberHandler({ success: true }, 200, writeRequest), - ) + server.use(followSubscribersSettingHandler(users), followSubscriberHandler({ success: true }, 200, writeRequest)) const user = userEvent.setup() await renderDialog(media) @@ -170,10 +166,7 @@ describe('ForkSubscribeDialog follow behavior', () => { const writeRequest = vi.fn((url: URL) => { users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1) }) - server.use( - followSubscribersSettingHandler(users), - unfollowSubscriberHandler({ success: true }, 200, writeRequest), - ) + server.use(followSubscribersSettingHandler(users), unfollowSubscriberHandler({ success: true }, 200, writeRequest)) const user = userEvent.setup() await renderDialog(media) @@ -263,10 +256,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { }) it('reports a fork business failure and does not emit', async () => { - server.use( - followSubscribersSettingHandler([]), - forkSubscribeHandler({ message: '订阅已存在', success: false }), - ) + server.use(followSubscribersSettingHandler([]), forkSubscribeHandler({ message: '订阅已存在', success: false })) const user = userEvent.setup() const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' })) @@ -368,28 +358,32 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { expect(events.close).toHaveBeenCalledOnce() }) - it.each(mediaDetailCases)('routes %s shares to their media details', async (_source, identifiers, expectedMediaId) => { - const media: SubscribeShare = { - ...createSubscribeShare({ - doubanid: identifiers.doubanid, - tmdbid: identifiers.tmdbid, - }), - bangumiid: identifiers.bangumiid, - } - server.use(followSubscribersSettingHandler([])) - const user = userEvent.setup() - await renderDialog(media) + it.each(mediaDetailCases)( + 'routes %s shares to their media details', + async (_source, identifiers, expectedMediaId) => { + const media: SubscribeShare = { + ...createSubscribeShare({ + anilistid: identifiers.anilistid, + doubanid: identifiers.doubanid, + tmdbid: identifiers.tmdbid, + }), + bangumiid: identifiers.bangumiid, + } + server.use(followSubscribersSettingHandler([])) + const user = userEvent.setup() + await renderDialog(media) - await user.click(screen.getByRole('button', { name: '查看媒体详情' })) + await user.click(screen.getByRole('button', { name: '查看媒体详情' })) - expect(mocks.routerPush).toHaveBeenCalledWith({ - path: '/media', - query: { - mediaid: expectedMediaId, - title: media.name, - type: media.type, - year: media.year, - }, - }) - }) + expect(mocks.routerPush).toHaveBeenCalledWith({ + path: '/media', + query: { + mediaid: expectedMediaId, + title: media.name, + type: media.type, + year: media.year, + }, + }) + }, + ) }) diff --git a/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts b/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts index 690191eb..e308b3b3 100644 --- a/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts +++ b/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts @@ -4,11 +4,7 @@ import SubscribeSeasonDialog from '@/components/dialog/SubscribeSeasonDialog.vue import type { SubscribeMode } from '@/composables/useMediaSubscribe' import { fireEvent, screen, waitFor, within } from '@testing-library/vue' import userEvent from '@testing-library/user-event' -import { - createMediaInfo, - createMediaSeason, - createNotExistMediaInfo, -} from '@tests/support/factories/media' +import { createMediaInfo, createMediaSeason, createNotExistMediaInfo } from '@tests/support/factories/media' import { mediaEpisodeGroupsHandler, mediaGroupSeasonsHandler, @@ -141,15 +137,21 @@ describe('SubscribeSeasonDialog', () => { expect(seasonRequests[0].searchParams.get('year')).toBe(media.year) expect(seasonRequests[0].searchParams.get('season')).toBe('0') expect(missingPayloads[0]).toMatchObject({ episode_group: '', season: 0, tmdb_id: media.tmdb_id }) - }) 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'], [ 'custom source', - { bangumi_id: undefined, douban_id: undefined, media_id: 'custom-7305', mediaid_prefix: 'custom', tmdb_id: undefined }, + { + bangumi_id: undefined, + douban_id: undefined, + media_id: 'custom-7305', + mediaid_prefix: 'custom', + tmdb_id: undefined, + }, 'custom:custom-7305', ], ] as const)('uses the %s media identifier without requesting TMDB groups', async (_label, overrides, mediaId) => { @@ -269,14 +271,10 @@ describe('SubscribeSeasonDialog', () => { { episode_count: 8, group_count: 1, id: 'group-a', name: '自定义排序 A' }, ]), mediaGroupSeasonsHandler('group-a', [createMediaSeason({ season_number: 5 })]), - mediaSeasonsHandler( - [createMediaSeason({ season_number: 1 })], - 200, - async () => { - defaultRequestStarted.resolve() - await defaultResponseGate.promise - }, - ), + mediaSeasonsHandler([createMediaSeason({ season_number: 1 })], 200, async () => { + defaultRequestStarted.resolve() + await defaultResponseGate.promise + }), mediaNotExistsHandler([]), ) const user = userEvent.setup() @@ -355,11 +353,7 @@ describe('SubscribeSeasonDialog', () => { it('renders the successful empty state and emits close without submitting', async () => { const media = createTvMedia({ tmdb_id: 7311 }) - server.use( - mediaSeasonsHandler([]), - mediaNotExistsHandler([]), - mediaEpisodeGroupsHandler(media.tmdb_id!, []), - ) + server.use(mediaSeasonsHandler([]), mediaNotExistsHandler([]), mediaEpisodeGroupsHandler(media.tmdb_id!, [])) const { events } = await renderDialog({ media }) expect(await screen.findByText(`${media.title} 未查询到季集信息`)).toBeInTheDocument() diff --git a/src/composables/__tests__/useMediaSubscribe.spec.ts b/src/composables/__tests__/useMediaSubscribe.spec.ts index 27f2c069..7a83e032 100644 --- a/src/composables/__tests__/useMediaSubscribe.spec.ts +++ b/src/composables/__tests__/useMediaSubscribe.spec.ts @@ -7,11 +7,7 @@ import { useMediaSubscribe, } from '@/composables/useMediaSubscribe' import { fireEvent, screen, waitFor } from '@testing-library/vue' -import { - createSubscribe, - createSubscribeMovie, - createSubscribeTv, -} from '@tests/support/factories/subscribe' +import { createSubscribe, createSubscribeMovie, createSubscribeTv } from '@tests/support/factories/subscribe' import { createSubscribeHandler, defaultSubscribeConfigHandler, @@ -78,7 +74,7 @@ interface HarnessOptions { async function renderSubscribeHarness(options: HarnessOptions = {}) { const media = options.media - const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? media.season ?? 1 : null) + const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? (media.season ?? 1) : null) const Harness = defineComponent({ name: 'MediaSubscribeHarness', setup() { @@ -174,8 +170,17 @@ function getDialogCall(index = 0) { describe('media subscribe identifiers and modes', () => { it.each([ ['TMDB before all fallback identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: 10 }, 'tmdb:10'], - ['Douban before Bangumi and generic identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, 'douban:20'], - ['Bangumi before a generic identifier', { bangumi_id: '30', douban_id: undefined, tmdb_id: undefined }, 'bangumi:30'], + [ + 'Douban before Bangumi and generic identifiers', + { bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, + 'douban:20', + ], + [ + 'Bangumi before a generic identifier', + { bangumi_id: '30', douban_id: undefined, tmdb_id: undefined }, + 'bangumi:30', + ], + ['AniList after Bangumi', { anilist_id: 40, bangumi_id: undefined, tmdb_id: undefined }, 'anilist:40'], [ 'generic identifiers when provider ids are absent', { bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined }, @@ -217,9 +222,8 @@ describe('useMediaSubscribe entry flows', () => { await waitFor(() => expect(created).toHaveBeenCalledOnce()) await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce()) expect(created).toHaveBeenCalledWith({ - bangumiid: undefined, - doubanid: undefined, episode_group: '', + media_source: 'themoviedb', mediaid: '', name: '普通电影', season: null, @@ -367,7 +371,13 @@ describe('useMediaSubscribe entry flows', () => { label: 'Bangumi', media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }), mediaId: 'bangumi:42', - record: createSubscribe({ bangumiid: 42 as unknown as string, season: 2, tmdbid: 0, type: '电视剧' }), + record: createSubscribe({ bangumiid: 42, season: 2, tmdbid: 0, type: '电视剧' }), + }, + { + label: 'AniList', + media: createSubscribeTv({ anilist_id: 154587, tmdb_id: undefined }), + mediaId: 'anilist:154587', + record: createSubscribe({ anilistid: 154587, season: 2, tmdbid: 0, type: '电视剧' }), }, { label: 'generic provider', @@ -474,7 +484,9 @@ describe('useMediaSubscribe entry flows', () => { const editDialog = getDialogCall() expect(editDialog.props).toEqual({ subid: 701 }) - editDialog.events.save(createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' })) + editDialog.events.save( + createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' }), + ) await waitFor(() => expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')) editDialog.events.remove() @@ -542,10 +554,7 @@ describe('useMediaSubscribe entry flows', () => { const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 }) const updated = vi.fn() server.use( - querySubscribeByMediaHandler( - 'tmdb:110', - createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' }), - ), + querySubscribeByMediaHandler('tmdb:110', createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' })), updateSubscribeHandler(response, status, updated), ) await renderSubscribeHarness({ diff --git a/src/composables/useMediaSubscribe.ts b/src/composables/useMediaSubscribe.ts index 96af1d7e..68827292 100644 --- a/src/composables/useMediaSubscribe.ts +++ b/src/composables/useMediaSubscribe.ts @@ -51,11 +51,15 @@ export type SeasonSubscribeModes = Record // 生成跨媒体源稳定的订阅媒体标识。 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 `${media?.mediaid_prefix}:${media?.media_id}` + return '' } // 将订阅模式转换为后端订阅字段。 @@ -277,7 +281,12 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { tmdbid: media.tmdb_id, doubanid: media.douban_id, bangumiid: media.bangumi_id, - mediaid: media.media_id ? `${media.mediaid_prefix}:${media.media_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}` + : '', season: media.type === '电影' ? null : season, ...payload, episode_group: episodeGroup.value, @@ -342,7 +351,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { updateSubscribeStatus(media.type === '电影' ? null : season, false) $toast.success(`${title} ${t('subscribe.cancelSuccess')}`) } else { - $toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`) + $toast.error( + `${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`, + ) } } catch (error) { console.error(error) @@ -492,7 +503,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { episodeGroup.value = groupId const subscribedSeasonSet = new Set(options.subscribedSeasons?.value ?? []) const selectedSeasonSet = new Set( - seasons.map(season => season.season_number).filter((season): season is number => season !== null && season !== undefined), + seasons + .map(season => season.season_number) + .filter((season): season is number => season !== null && season !== undefined), ) const visibleSeasonSet = new Set(visibleSeasonNumbers) const seasonsToSubscribe = seasons.filter(season => { @@ -506,7 +519,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { const seasonNumber = season.season_number ?? null if (seasonNumber === null || !subscribedSeasonSet.has(seasonNumber)) return false - const nextMode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal' + const nextMode = typeof seasonModes === 'string' ? seasonModes : (seasonModes[seasonNumber] ?? 'normal') return (options.subscribedSeasonModes?.value[seasonNumber] ?? 'normal') !== nextMode }) @@ -518,7 +531,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { const seasonNumber = season.season_number ?? null if (seasonNumber === null) return - const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal' + const mode = typeof seasonModes === 'string' ? seasonModes : (seasonModes[seasonNumber] ?? 'normal') updateSubscribeMode(seasonNumber, mode) }) @@ -526,15 +539,11 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { const seasonNumber = season.season_number ?? null if (seasonNumber === null) return - const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal' + const mode = typeof seasonModes === 'string' ? seasonModes : (seasonModes[seasonNumber] ?? 'normal') const payload = getSubscribePayload(mode) - addSubscribe( - seasonNumber, - payload, - { - openEditDialog: seasonsToSubscribe.length === 1 && seasonsToUnsubscribe.length === 0, - }, - ) + addSubscribe(seasonNumber, payload, { + openEditDialog: seasonsToSubscribe.length === 1 && seasonsToUnsubscribe.length === 0, + }) }) } diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index d01234d9..63dd2a8e 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -2523,6 +2523,12 @@ export default { tmdbIdHint: 'Optional, manually specify TMDB ID for recognition', doubanId: 'Douban ID', doubanIdHint: 'Optional, manually specify Douban ID for recognition', + bangumiId: 'Bangumi ID', + anilistId: 'AniList ID', + mediaSource: 'Recognition source', + mediaSourceHint: 'Applies only to this re-identification and does not change the system default', + mediaId: 'Media ID', + mediaIdHint: 'Optional source-native media ID; leave blank to recognize by title', autoHint: 'If no ID is specified, the torrent will be automatically re-identified', cancel: 'Cancel', confirm: 'Re-identify', @@ -2538,6 +2544,8 @@ export default { recognitionSource: { themoviedb: 'TheMovieDb', douban: 'Douban', + bangumi: 'Bangumi', + anilist: 'AniList', }, }, }, diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index d0c4e97d..2a30fe60 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -2477,6 +2477,12 @@ export default { tmdbIdHint: '可选,手动指定TMDB ID进行识别', doubanId: '豆瓣 ID', doubanIdHint: '可选,手动指定豆瓣ID进行识别', + bangumiId: 'Bangumi ID', + anilistId: 'AniList ID', + mediaSource: '识别数据源', + mediaSourceHint: '仅用于本次重新识别,不修改系统默认数据源', + mediaId: '媒体 ID', + mediaIdHint: '可选,填写所选数据源中的原生媒体 ID;留空时按标题识别', autoHint: '如果不指定ID,将自动重新识别该种子', cancel: '取消', confirm: '重新识别', @@ -2492,6 +2498,8 @@ export default { recognitionSource: { themoviedb: 'TheMovieDb', douban: '豆瓣', + bangumi: 'Bangumi', + anilist: 'AniList', }, }, }, diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index ee07d4ee..70855340 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -2476,6 +2476,12 @@ export default { tmdbIdHint: '可選,手動指定TMDB ID進行識別', doubanId: '豆瓣 ID', doubanIdHint: '可選,手動指定豆瓣ID進行識別', + bangumiId: 'Bangumi ID', + anilistId: 'AniList ID', + mediaSource: '識別資料源', + mediaSourceHint: '僅用於本次重新識別,不修改系統預設資料源', + mediaId: '媒體 ID', + mediaIdHint: '可選,填寫所選資料源中的原生媒體 ID;留空時按標題識別', autoHint: '如果不指定ID,將自動重新識別該種子', cancel: '取消', confirm: '重新識別', @@ -2491,6 +2497,8 @@ export default { recognitionSource: { themoviedb: 'TheMovieDb', douban: '豆瓣', + bangumi: 'Bangumi', + anilist: 'AniList', }, }, }, diff --git a/src/views/discover/MediaCardListView.vue b/src/views/discover/MediaCardListView.vue index f161b543..10a0d09c 100644 --- a/src/views/discover/MediaCardListView.vue +++ b/src/views/discover/MediaCardListView.vue @@ -60,6 +60,7 @@ const dedupFields = [ 'tvdb_id', 'douban_id', 'bangumi_id', + 'anilist_id', 'mediaid_prefix', 'media_id', ] as const @@ -100,11 +101,7 @@ async function loadPageData() { } // 获取列表数据 -async function fetchData({ - done, -}: { - done: (status: 'empty' | 'error' | 'loading' | 'ok') => void -}) { +async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'loading' | 'ok') => void }) { if (loading.value) { done('ok') return @@ -172,10 +169,6 @@ async function fetchData({ - + diff --git a/src/views/discover/MediaCardSlideView.vue b/src/views/discover/MediaCardSlideView.vue index e1c78625..bd2080d7 100644 --- a/src/views/discover/MediaCardSlideView.vue +++ b/src/views/discover/MediaCardSlideView.vue @@ -106,7 +106,9 @@ onActivated(() => {