diff --git a/src/@core/components/__tests__/DialogCloseBtn.spec.ts b/src/@core/components/__tests__/DialogCloseBtn.spec.ts index e8ba2e58..b156d76c 100644 --- a/src/@core/components/__tests__/DialogCloseBtn.spec.ts +++ b/src/@core/components/__tests__/DialogCloseBtn.spec.ts @@ -16,15 +16,12 @@ describe('DialogCloseBtn', () => { const button = screen.getByRole('button', { name: '关闭' }) expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10') - const icon = container.querySelector('svg.v-icon') - expect(icon).not.toBeNull() + const icon = button.querySelector('svg.v-icon') + expect(container.querySelectorAll('svg.v-icon')).toHaveLength(1) expect(icon).toHaveAttribute('aria-hidden', 'true') - await waitFor(() => - expect(icon?.querySelector('path')).toHaveAttribute( - 'd', - 'M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z', - ), - ) + expect(icon).toHaveAttribute('role', 'img') + expect(icon).toHaveAttribute('height', '1em') + expect(icon).toHaveAttribute('width', '1em') i18n.global.locale.value = 'en-US' await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBe(button)) diff --git a/src/__tests__/ace-config.spec.ts b/src/__tests__/ace-config.spec.ts index a81dfa48..43dc2003 100644 --- a/src/__tests__/ace-config.spec.ts +++ b/src/__tests__/ace-config.spec.ts @@ -47,6 +47,23 @@ describe('word list syntax mode', () => { ]) }) + it('accepts the unified media source and native media ID parameters', () => { + const tokens = tokenize('旧名 => 新名 {[media_source=douban;media_id=1295644;type=movie]}') + + expect(tokens).toContainEqual({ type: 'word_list_parameter_key', value: 'media_source' }) + expect(tokens).toContainEqual({ type: 'word_list_parameter_value', value: 'douban' }) + expect(tokens).toContainEqual({ type: 'word_list_parameter_key', value: 'media_id' }) + expect(tokens).toContainEqual({ type: 'word_list_parameter_value', value: '1295644' }) + expect(tokens).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' })) + }) + + it('rejects an unknown media source in the unified parameters', () => { + expect(tokenize('旧名 => {[media_source=custom;media_id=42]}')).toContainEqual({ + type: 'invalid.word-list', + value: 'custom', + }) + }) + it('marks invalid replacement parameter keys and values', () => { expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([ { type: 'word_list_replaced', value: '旧名' }, diff --git a/src/ace-config.ts b/src/ace-config.ts index 982fcf8f..242dd679 100644 --- a/src/ace-config.ts +++ b/src/ace-config.ts @@ -538,11 +538,15 @@ interface WordListToken { const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g const wordListUnsignedIntegerPattern = /^\d+$/ const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/ +const wordListMediaSourcePattern = + /^(?:themoviedb|douban|bangumi|anilist|imdb|tvdb|musicbrainz|theaudiodb|doubanmusic|bilibili|mangguodiscover|migu|tencentvideodiscover)$/ const wordListParameterTypes = { tmdbid: 'uint', - doubanid: 'uint', + doubanid: 'string', bangumiid: 'uint', anilistid: 'uint', + media_source: 'media-source', + media_id: 'string', type: 'media-type', g: 'string', s: 'uint-or-range', @@ -565,6 +569,8 @@ function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, return wordListUnsignedIntegerOrRangePattern.test(value) case 'media-type': return value === 'movie' || value === 'tv' + case 'media-source': + return wordListMediaSourcePattern.test(value) case 'string': return value.length > 0 } diff --git a/src/api/types.ts b/src/api/types.ts index 26b757f5..d4bef33c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,5 +1,21 @@ -export type MediaDataSource = - 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | 'theaudiodb' | 'doubanmusic' | (string & {}) +/** 后端、前端、插件与中心服务共同使用的固定媒体来源枚举。 */ +export enum MediaSource { + TMDB = 'themoviedb', + Douban = 'douban', + Bangumi = 'bangumi', + AniList = 'anilist', + IMDb = 'imdb', + TVDB = 'tvdb', + MusicBrainz = 'musicbrainz', + TheAudioDB = 'theaudiodb', + DoubanMusic = 'doubanmusic', + Bilibili = 'bilibili', + MangoTV = 'mangguodiscover', + MiguVideo = 'migu', + TencentVideo = 'tencentvideodiscover', +} + +export type MediaDataSource = `${MediaSource}` // 手动刮削选项 export interface ManualScrapeOptions { @@ -25,14 +41,6 @@ export interface Subscribe { type: string // 搜索关键字 keyword?: string - // TMDB ID - tmdbid: number - // 豆瓣ID - doubanid?: string - // Bangumi ID - bangumiid?: number - // AniList ID - anilistid?: number // 主媒体数据源 media_source?: MediaDataSource // 数据源原生ID @@ -41,8 +49,6 @@ export interface Subscribe { music_type?: MusicEntityType // 专辑总曲目数 total_tracks?: number - // 其它媒体ID - mediaid?: string // 季号 season?: number // 海报 @@ -153,14 +159,6 @@ export interface SubscribeShare { type?: string // 搜索关键字 keyword?: string - // TMDB ID - tmdbid?: number - // 豆瓣ID - doubanid?: string - // Bangumi ID - bangumiid?: number - // AniList ID - anilistid?: number // 主媒体数据源 media_source?: MediaDataSource // 数据源原生ID @@ -263,18 +261,6 @@ export interface TransferHistory { title?: string // 年份 year?: string - // TMDBID - tmdbid?: number - // IMDBID - imdbid?: string - // TVDBID - tvdbid?: number - // 豆瓣ID - doubanid?: string - // Bangumi ID - bangumiid?: number - // AniList ID - anilistid?: number // 媒体数据源 media_source?: MediaDataSource // 数据源原生ID @@ -323,18 +309,6 @@ export interface DownloadHistory { title?: string // 年份 year?: string - // TMDB ID - tmdbid?: number - // IMDB ID - imdbid?: string - // TVDB ID - tvdbid?: number - // 豆瓣 ID - doubanid?: string - // Bangumi ID - bangumiid?: number - // AniList ID - anilistid?: number // 媒体数据源 media_source?: MediaDataSource // 数据源原生 ID @@ -403,8 +377,7 @@ export interface MediaInfo { anidb_id?: number // 合集ID collection_id?: number - // 其它媒体ID前缀 - // 其它媒体ID值 + // 主媒体数据源的原生 ID media_id?: string // 媒体原语种 original_language?: string @@ -1107,8 +1080,10 @@ export interface TorrentInfo { title?: string // 种子副标题 description?: string - // IMDB ID - imdbid: string + // 种子页面声明的媒体来源 + media_source?: MediaDataSource + // 种子页面声明的数据源原生 ID + media_id?: string // 种子链接 enclosure?: string // 详情页面 @@ -1895,14 +1870,6 @@ export interface TransferForm { target_storage: string | null // 目标路径 target_path: string | null - // TMDB ID - 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 348ab67e..88127a90 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -17,6 +17,7 @@ import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { openSharedDialog } from '@/composables/useSharedDialog' import { getMediaSubscribeId, + getMediaSubscribeIdentity, getSubscribeMode, useMediaSubscribe, type SeasonSubscribeModes, @@ -182,7 +183,7 @@ async function querySelectedSites() { } } -// 获得mediaid +// 获取当前卡片的统一媒体身份缓存键 function getMediaId() { return getMediaSubscribeId(props.media) } @@ -193,14 +194,14 @@ function getSubscribeStatusKey(season: number | null = props.media?.season ?? nu } function getExistsStatusKey() { + const identity = getMediaSubscribeIdentity(props.media) return [ - props.media?.tmdb_id ?? '', + identity?.source ?? '', + identity?.mediaId ?? '', props.media?.title ?? '', props.media?.year ?? '', props.media?.season ?? '', props.media?.type ?? '', - props.media?.media_source ?? '', - props.media?.media_id ?? '', ].join('::') } @@ -210,17 +211,10 @@ function isSameSubscribeMedia(subscribe: Subscribe) { const subscribeMusicType = subscribe.music_type ?? 'recording' if (subscribeMusicType !== expectedMusicType) return false } - 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 - if (props.media?.anilist_id && subscribe.anilistid) return props.media.anilist_id === subscribe.anilistid - return false + const identity = getMediaSubscribeIdentity(props.media) + return Boolean( + identity && subscribe.media_source === identity.source && String(subscribe.media_id || '') === identity.mediaId, + ) } // 角标颜色 @@ -278,9 +272,10 @@ async function handleCheckExists() { if (props.media?.type === '音乐') return try { const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => { + const identity = getMediaSubscribeIdentity(props.media) const result: { [key: string]: any } = await api.get('mediaserver/exists', { params: { - tmdbid: props.media?.tmdb_id, + ...(identity ? { media_source: identity.source, media_id: identity.mediaId } : {}), title: props.media?.title, year: props.media?.year, season: props.media?.season, @@ -325,11 +320,14 @@ function goMediaDetail(isHovering = false) { }, }) } else { + const identity = getMediaSubscribeIdentity(props.media) + if (!identity) return // 跳转到媒体详情页 router.push({ path: '/media', query: { - mediaid: getMediaId(), + media_source: identity.source, + media_id: identity.mediaId, title: props.media?.title, year: props.media?.year, type: props.media?.type, @@ -390,10 +388,13 @@ async function clickSearch() { // 开始搜索 function handleSearch() { + const identity = getMediaSubscribeIdentity(props.media) + if (!identity) return router.push({ path: '/resource', query: { - keyword: getMediaId(), + media_source: identity.source, + media_id: identity.mediaId, type: props.media?.type, area: 'title', title: props.media?.title, diff --git a/src/components/cards/MusicArtistCard.vue b/src/components/cards/MusicArtistCard.vue index 2262fe80..1d21c8b2 100644 --- a/src/components/cards/MusicArtistCard.vue +++ b/src/components/cards/MusicArtistCard.vue @@ -24,7 +24,7 @@ const subtitle = computed(() => getMusicArtistSubtitle(props.artist)) /** 打开艺术家详情页。 */ function goArtistDetail() { - if (!props.artist?.media_id) return + if (!props.artist?.media_id || !props.artist.media_source) return router.push(buildMusicArtistRoute(props.artist.media_id, props.artist.name, props.artist.media_source)) } diff --git a/src/components/cards/MusicCard.vue b/src/components/cards/MusicCard.vue index e4f7dfed..4f27a995 100644 --- a/src/components/cards/MusicCard.vue +++ b/src/components/cards/MusicCard.vue @@ -128,14 +128,14 @@ function goDetail() { /** 打开所属专辑详情页。 */ function goAlbum() { - if (!props.music?.album_id) return + if (!props.music?.album_id || !props.music.media_source) return router.push(buildMusicAlbumRoute(props.music.album_id, props.music.album, props.music.media_source)) } /** 打开艺术家详情页。 */ function goArtist(artistId?: string, name?: string) { - if (!artistId) return - router.push(buildMusicArtistRoute(artistId, name, props.music?.media_source)) + if (!artistId || !props.music?.media_source) return + router.push(buildMusicArtistRoute(artistId, name, props.music.media_source)) } watch( @@ -160,34 +160,43 @@ onMounted(checkSubscribeStatus) @click="goDetail" >
-
- - - - +
+
+ + + + - - {{ entityMeta.label }} - -
- -
+ + {{ entityMeta.label }} + +
{{ sourceLabel }}
+
+ +
{{ props.music?.title }}
- + {{ props.music.version }}
@@ -297,6 +306,14 @@ onMounted(checkSubscribeStatus) padding: 1rem; } +.music-card-cover-column { + display: flex; + flex-direction: column; + gap: 0.375rem; + inline-size: 112px; + min-inline-size: 0; +} + .music-card-cover-shell { position: relative; display: flex; @@ -340,8 +357,14 @@ onMounted(checkSubscribeStatus) .music-card-source-row { display: flex; align-items: center; + justify-content: center; min-block-size: 20px; - margin-block-end: 0.25rem; + min-inline-size: 0; +} + +.music-card-source { + max-inline-size: 100%; + min-inline-size: 0; } .music-card-heading { @@ -355,16 +378,27 @@ onMounted(checkSubscribeStatus) display: -webkit-box; color: rgb(var(--v-theme-on-surface)); font-size: 1rem; + flex: 1 1 auto; font-weight: 600; -webkit-box-orient: vertical; -webkit-line-clamp: 2; letter-spacing: 0; line-height: 1.4; + min-inline-size: 0; } .music-card-version { - flex: 0 0 auto; + flex: 0 1 auto; margin-block-start: 0.125rem; + max-inline-size: min(45%, 12rem); + min-inline-size: 0; +} + +.music-card-source :deep(.v-chip__content), +.music-card-version :deep(.v-chip__content) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .music-card-supporting { @@ -437,6 +471,10 @@ onMounted(checkSubscribeStatus) inline-size: 88px; } + .music-card-cover-column { + inline-size: 88px; + } + .music-card-body { position: relative; } @@ -453,10 +491,6 @@ onMounted(checkSubscribeStatus) max-inline-size: calc(100% - 0.75rem); } - .music-card-source-row { - padding-inline-end: 5.5rem; - } - .music-card-footer { display: block; padding-block-start: 0.5rem; diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index db150f04..d58b2844 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -322,17 +322,10 @@ 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 + if (!props.media?.media_source || !props.media.media_id) return undefined + return { mediaSource: props.media.media_source, mediaId: String(props.media.media_id) } } // 查看媒体详情 @@ -341,10 +334,13 @@ async function viewMediaDetail() { router.push(buildMusicDetailRoute(props.media)) return } + const identity = getMediaId() + if (!identity) return router.push({ path: '/media', query: { - mediaid: getMediaId(), + media_source: identity.mediaSource, + media_id: identity.mediaId, title: props.media?.name, year: props.media?.year, type: props.media?.type, diff --git a/src/components/cards/SubscribeShareCard.vue b/src/components/cards/SubscribeShareCard.vue index 9cd43b72..62d02648 100644 --- a/src/components/cards/SubscribeShareCard.vue +++ b/src/components/cards/SubscribeShareCard.vue @@ -45,24 +45,21 @@ const posterUrl = computed(() => { return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE) }) -// 获得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}` + if (!props.media?.media_source || !props.media.media_id) return undefined + return { mediaSource: props.media.media_source, mediaId: String(props.media.media_id) } } // 查看媒体详情 async function viewMediaDetail() { + const identity = getMediaId() + if (!identity) return router.push({ path: '/media', query: { - mediaid: getMediaId(), + media_source: identity.mediaSource, + media_id: identity.mediaId, title: props.media?.name, year: props.media?.year, type: props.media?.type, diff --git a/src/components/cards/SubtitleCard.vue b/src/components/cards/SubtitleCard.vue index 30128ca4..98d8d965 100644 --- a/src/components/cards/SubtitleCard.vue +++ b/src/components/cards/SubtitleCard.vue @@ -2,7 +2,7 @@ import type { PropType } from 'vue' import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters' import api from '@/api' -import type { SubtitleInfo } from '@/api/types' +import type { MediaDataSource, SubtitleInfo } from '@/api/types' import { getCachedSiteIcon } from '@/utils/siteIconCache' import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache' import { openSharedDialog } from '@/composables/useSharedDialog' @@ -19,6 +19,8 @@ const { t } = useI18n() const props = defineProps({ subtitle: Object as PropType, width: String, + mediaSource: String as PropType, + mediaId: String, }) const globalSettingsStore = useGlobalSettingsStore() @@ -79,6 +81,8 @@ async function handleAddDownload() { { title: subtitle.value?.title, subtitle: subtitle.value, + mediaSource: props.mediaSource, + mediaId: props.mediaId, }, { done: addDownloadSuccess, diff --git a/src/components/cards/SubtitleItem.vue b/src/components/cards/SubtitleItem.vue index 4bba4365..d46e9af2 100644 --- a/src/components/cards/SubtitleItem.vue +++ b/src/components/cards/SubtitleItem.vue @@ -2,7 +2,7 @@ import type { PropType } from 'vue' import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters' import api from '@/api' -import type { SubtitleInfo } from '@/api/types' +import type { MediaDataSource, SubtitleInfo } from '@/api/types' import { getCachedSiteIcon } from '@/utils/siteIconCache' import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache' import { openSharedDialog } from '@/composables/useSharedDialog' @@ -18,6 +18,8 @@ const { t } = useI18n() // 输入参数 const props = defineProps({ subtitle: Object as PropType, + mediaSource: String as PropType, + mediaId: String, }) const globalSettingsStore = useGlobalSettingsStore() @@ -68,6 +70,8 @@ async function handleAddDownload() { { title: subtitle.value?.title, subtitle: subtitle.value, + mediaSource: props.mediaSource, + mediaId: props.mediaId, }, { done: addDownloadSuccess, diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index 8d6e1288..99cc5005 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -32,6 +32,8 @@ vi.mock('@/router', () => ({ })) const API_BASE_URL = 'http://localhost/api/v1/' +const musicBrainzRecordingId = '977e6978-139d-425c-bb98-6b0c62d1e45e' +const secondMusicBrainzRecordingId = 'be9d9b1b-8c1d-4dbe-85a5-4176dd8e7b6c' const movieSiteListUrl = new URL('site/media/movie', API_BASE_URL).href const tvSiteListUrl = new URL('site/media/tv', API_BASE_URL).href const musicSiteListUrl = new URL('site/media/music', API_BASE_URL).href @@ -224,7 +226,7 @@ describe('MediaCard', () => { const subscribeRequest = vi.fn<(url: URL) => void>() const existsRequest = vi.fn<(url: URL) => void>() server.use( - querySubscribeByMediaHandler('tmdb:9101', { id: 71, season: 2 }, 200, subscribeRequest), + querySubscribeByMediaHandler('9101', { id: 71, season: 2 }, 200, subscribeRequest), mediaExistsHandler({ data: { item: { id: 'library-item' } }, success: true }, 200, existsRequest), ) @@ -240,11 +242,13 @@ describe('MediaCard', () => { }) expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe('2') expect(subscribeRequest.mock.calls[0][0].searchParams.get('title')).toBe('视口状态剧集') + expect(subscribeRequest.mock.calls[0][0].searchParams.get('media_source')).toBe('themoviedb') expect(Object.fromEntries(existsRequest.mock.calls[0][0].searchParams)).toEqual({ + media_id: '9101', + media_source: 'themoviedb', mtype: '电视剧', season: '2', title: '视口状态剧集', - tmdbid: '9101', year: '2026', }) await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error')) @@ -256,7 +260,7 @@ describe('MediaCard', () => { const subscribeRequest = vi.fn<(url: URL) => void>() const existsRequest = vi.fn<(url: URL) => void>() server.use( - querySubscribeByMediaHandler('tmdb:9102', { id: 72 }, 200, subscribeRequest), + querySubscribeByMediaHandler('9102', { id: 72 }, 200, subscribeRequest), mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), ) @@ -280,23 +284,36 @@ describe('MediaCard', () => { }) it.each([ - ['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), 'tmdb:9201', '3'], + ['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), '9201', '3'], [ 'Douban', - createMediaInfo({ douban_id: 'db-9202', season: undefined, tmdb_id: undefined }), - 'douban:db-9202', + createMediaInfo({ + douban_id: 'db-9202', + media_id: 'db-9202', + media_source: 'douban', + season: undefined, + tmdb_id: undefined, + }), + 'db-9202', null, ], [ 'Bangumi', - createMediaInfo({ bangumi_id: '9203', season: 1, tmdb_id: undefined, type: '电视剧' }), - 'bangumi:9203', + createMediaInfo({ + bangumi_id: '9203', + media_id: '9203', + media_source: 'bangumi', + season: 1, + tmdb_id: undefined, + type: '电视剧', + }), + '9203', '1', ], [ - 'extension', - createMediaInfo({ media_id: 'item-9204', media_source: 'custom' as never, tmdb_id: undefined }), - 'custom:item-9204', + 'Bilibili', + createMediaInfo({ media_id: 'item-9204', media_source: 'bilibili', tmdb_id: undefined }), + 'item-9204', null, ], ])('queries the current %s media identifier and season', async (_source, media, mediaId, season) => { @@ -311,6 +328,7 @@ describe('MediaCard', () => { await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce()) expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe(season) + expect(subscribeRequest.mock.calls[0][0].searchParams.get('media_source')).toBe(media.media_source) }) it('skips status requests for collections and releases observer and touch listeners on unmount', async () => { @@ -323,7 +341,7 @@ describe('MediaCard', () => { const addListener = vi.spyOn(document, 'addEventListener') const removeListener = vi.spyOn(document, 'removeEventListener') server.use( - querySubscribeByMediaHandler('tmdb:9301', {}, 200, subscribeRequest), + querySubscribeByMediaHandler('9301', {}, 200, subscribeRequest), mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), ) @@ -346,7 +364,7 @@ describe('MediaCard', () => { 'media details', createMediaInfo({ title: '详情电影', tmdb_id: 9401 }), '/media', - { mediaid: 'tmdb:9401', title: '详情电影', type: '电影', year: '2026' }, + { media_id: '9401', media_source: 'themoviedb', title: '详情电影', type: '电影', year: '2026' }, ], [ 'collection browse', @@ -367,7 +385,7 @@ describe('MediaCard', () => { it('opens music detail and skips media-library existence checks', async () => { const media = createMediaInfo({ artist: '周杰伦', - media_id: 'recording-1', + media_id: musicBrainzRecordingId, media_source: 'musicbrainz', title: '晴天', tmdb_id: undefined, @@ -376,7 +394,7 @@ describe('MediaCard', () => { const subscribeRequest = vi.fn<(url: URL) => void>() const existsRequest = vi.fn<(url: URL) => void>() server.use( - querySubscribeByMediaHandler('musicbrainz:recording-1', {}, 200, subscribeRequest), + querySubscribeByMediaHandler(musicBrainzRecordingId, {}, 200, subscribeRequest), mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), ) @@ -395,7 +413,7 @@ describe('MediaCard', () => { path: '/music/detail', query: { media_source: 'musicbrainz', - mediaid: 'recording-1', + media_id: musicBrainzRecordingId, title: '晴天', }, }), @@ -405,7 +423,7 @@ describe('MediaCard', () => { it.each([ ['TheAudioDB', 'theaudiodb', 'album-2109619', 'Parachutes'], ['豆瓣音乐', 'doubanmusic', '1401853', '范特西'], - ])( + ] as const)( 'keeps %s identity for explore-card detail, subscribe, and resource actions', async (_label, source, mediaId, title) => { const media = createMediaInfo({ @@ -422,7 +440,7 @@ describe('MediaCard', () => { const subscribeRequest = vi.fn<(url: URL) => void>() const created = vi.fn<(payload: Record) => void>() server.use( - querySubscribeByMediaHandler(`${source}:${mediaId}`, {}, 200, subscribeRequest), + querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest), createSubscribeHandler({ data: { id: 101 }, success: true }, 200, created), defaultSubscribeConfigHandler('音乐', { show_edit_dialog: false }), ) @@ -439,7 +457,7 @@ describe('MediaCard', () => { await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path: '/music/album', - query: { media_source: source, mediaid: mediaId, title }, + query: { media_id: mediaId, media_source: source, title }, }), ) @@ -458,7 +476,8 @@ describe('MediaCard', () => { expect(mocks.routerPush).toHaveBeenCalledWith({ path: '/resource', query: expect.objectContaining({ - keyword: `${source}:${mediaId}`, + media_id: mediaId, + media_source: source, music_type: 'album', sites: '21', type: '音乐', @@ -470,7 +489,7 @@ describe('MediaCard', () => { it('uses an album placeholder instead of the movie fallback image for music without a cover', async () => { const media = createMediaInfo({ - media_id: 'recording-2', + media_id: secondMusicBrainzRecordingId, media_source: 'musicbrainz', poster_path: undefined, title: '无封面歌曲', @@ -497,7 +516,8 @@ describe('MediaCard', () => { path: '/resource', query: { area: 'title', - keyword: 'tmdb:9501', + media_id: '9501', + media_source: 'themoviedb', season: 4, sites: '3,5', title: '直接搜索剧集', @@ -598,14 +618,22 @@ describe('MediaCard', () => { const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })) const subscribeListRequest = vi.fn<(url: URL) => void>() server.use( - querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }), + querySubscribeByMediaHandler('9551', { id: 81, season: 2 }), mediaExistsHandler({ data: { item: {} }, success: false }), subscribeListHandler( [ - { best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' }, - { best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' }, - { id: 83, season: 4, tmdbid: 9999, type: '电视剧' }, - { id: 84, tmdbid: 9551, type: '电影' }, + { best_version: 0, id: 81, media_id: '9551', media_source: 'themoviedb', season: 3, type: '电视剧' }, + { + best_version: 1, + best_version_full: 1, + id: 82, + media_id: '9551', + media_source: 'themoviedb', + season: 1, + type: '电视剧', + }, + { id: 83, media_id: '9999', media_source: 'themoviedb', season: 4, type: '电视剧' }, + { id: 84, media_id: '9551', media_source: 'themoviedb', type: '电影' }, ], 200, subscribeListRequest, @@ -643,20 +671,20 @@ describe('MediaCard', () => { }) }) - it('matches custom media IDs when collecting subscribed TV seasons', async () => { + it('matches a fixed extension media source when collecting subscribed TV seasons', async () => { const media = createMediaInfo({ media_id: 'series-9553', - media_source: 'custom' as never, + media_source: 'bilibili', season: 2, tmdb_id: undefined, type: '电视剧', }) server.use( - querySubscribeByMediaHandler('custom:series-9553', { id: 91, season: 2 }), + querySubscribeByMediaHandler('series-9553', { id: 91, season: 2 }), mediaExistsHandler({ data: { item: {} }, success: false }), subscribeListHandler([ - { id: 91, mediaid: 'custom:series-9553', season: 2, type: '电视剧' }, - { id: 92, mediaid: 'custom:other', season: 5, type: '电视剧' }, + { id: 91, media_id: 'series-9553', media_source: 'bilibili', season: 2, type: '电视剧' }, + { id: 92, media_id: 'other', media_source: 'bilibili', season: 5, type: '电视剧' }, ]), http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () => HttpResponse.json({ data: { value: {} }, success: true }), @@ -684,7 +712,7 @@ describe('MediaCard', () => { tmdb_id: undefined, type: '电视剧', }), - 'tmdb:series-9554', + 'series-9554', [ { id: 93, media_id: 'series-9554', media_source: 'themoviedb', season: 4, type: '电视剧' }, { id: 94, media_id: 'other', media_source: 'themoviedb', season: 5, type: '电视剧' }, @@ -692,12 +720,19 @@ describe('MediaCard', () => { [4], ], [ - 'legacy AniList identity', - createMediaInfo({ anilist_id: 154588, season: 2, media_source: 'anilist', tmdb_id: undefined, type: '电视剧' }), - 'anilist:154588', + 'AniList identity', + createMediaInfo({ + anilist_id: 154588, + media_id: '154588', + season: 2, + media_source: 'anilist', + tmdb_id: undefined, + type: '电视剧', + }), + '154588', [ - { anilistid: 154588, id: 95, season: 1, type: '电视剧' }, - { anilistid: 154589, id: 96, season: 3, type: '电视剧' }, + { id: 95, media_id: '154588', media_source: 'anilist', season: 1, type: '电视剧' }, + { id: 96, media_id: '154589', media_source: 'anilist', season: 3, type: '电视剧' }, ], [1], ], diff --git a/src/components/cards/__tests__/SubscribeCard.spec.ts b/src/components/cards/__tests__/SubscribeCard.spec.ts index 81d5acba..6459ef9e 100644 --- a/src/components/cards/__tests__/SubscribeCard.spec.ts +++ b/src/components/cards/__tests__/SubscribeCard.spec.ts @@ -411,21 +411,11 @@ 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'], - [ - '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) => { + ['TMDB', { media_id: '11', media_source: 'themoviedb' }, 'themoviedb', '11'], + ['Douban', { media_id: '22', media_source: 'douban' }, 'douban', '22'], + ['Bangumi', { media_id: '33', media_source: 'bangumi' }, 'bangumi', '33'], + ['AniList', { media_id: '55', media_source: 'anilist' }, 'anilist', '55'], + ] as const)('routes media details with %s', async (_case, identifiers, mediaSource, mediaId) => { const { container, media } = await renderCard(identifiers) await chooseMenuItem(container, '媒体详情') @@ -433,7 +423,8 @@ describe('SubscribeCard interaction boundaries', () => { expect(mocks.routerPush).toHaveBeenCalledWith({ path: '/media', query: { - mediaid: expectedMediaId, + media_id: mediaId, + media_source: mediaSource, title: media.name, type: media.type, year: media.year, diff --git a/src/components/cards/__tests__/SubscribeShareCard.spec.ts b/src/components/cards/__tests__/SubscribeShareCard.spec.ts index e3d3fd51..99656368 100644 --- a/src/components/cards/__tests__/SubscribeShareCard.spec.ts +++ b/src/components/cards/__tests__/SubscribeShareCard.spec.ts @@ -124,27 +124,31 @@ describe('SubscribeShareCard', () => { }) it.each([ - ['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) + ['TMDB', { media_id: '1101', media_source: 'themoviedb' }, 'themoviedb', '1101'], + ['Douban', { media_id: '2202', media_source: 'douban' }, 'douban', '2202'], + ['Bangumi', { media_id: '3303', media_source: 'bangumi' }, 'bangumi', '3303'], + ['AniList', { media_id: '4404', media_source: 'anilist' }, 'anilist', '4404'], + ] as const)( + 'routes media details with %s while keeping the fork dialog closed', + async (_case, ids, mediaSource, mediaId) => { + const { container, media } = await renderCard(ids) + const poster = await loadPoster(container) - await fireEvent.click(poster) + await fireEvent.click(poster) - expect(mocks.routerPush).toHaveBeenCalledWith({ - path: '/media', - query: { - mediaid, - title: media.name, - type: media.type, - year: media.year, - }, - }) - expect(mocks.openSharedDialog).not.toHaveBeenCalled() - }) + expect(mocks.routerPush).toHaveBeenCalledWith({ + path: '/media', + query: { + media_id: mediaId, + media_source: mediaSource, + title: media.name, + type: media.type, + year: media.year, + }, + }) + expect(mocks.openSharedDialog).not.toHaveBeenCalled() + }, + ) it('opens the fork dialog with the exact media and replaces it with editing after fork success', async () => { const { container, media } = await renderCard() diff --git a/src/components/cards/__tests__/TorrentCard.spec.ts b/src/components/cards/__tests__/TorrentCard.spec.ts index d9e0a7db..2135bf05 100644 --- a/src/components/cards/__tests__/TorrentCard.spec.ts +++ b/src/components/cards/__tests__/TorrentCard.spec.ts @@ -77,7 +77,8 @@ function createContext(overrides: ContextOverrides = {}): Context { freedate_diff: '', grabs: 3, hit_and_run: false, - imdbid: 'tt1000001', + media_id: 'tt1000001', + media_source: 'imdb', labels: [], page_url: 'https://tracker.example.com/details/1001', peers: 2, diff --git a/src/components/cards/__tests__/TorrentItem.spec.ts b/src/components/cards/__tests__/TorrentItem.spec.ts index 24e99bc8..f1aa665a 100644 --- a/src/components/cards/__tests__/TorrentItem.spec.ts +++ b/src/components/cards/__tests__/TorrentItem.spec.ts @@ -90,7 +90,8 @@ function createContext(overrides: ContextOverrides = {}): Context { freedate_diff: '', grabs: 3, hit_and_run: false, - imdbid: 'tt1000001', + media_id: 'tt1000001', + media_source: 'imdb', labels: [], page_url: 'https://tracker.example.com/details/1001', peers: 2, diff --git a/src/components/dialog/AddDownloadDialog.vue b/src/components/dialog/AddDownloadDialog.vue index add718ce..9e29286d 100644 --- a/src/components/dialog/AddDownloadDialog.vue +++ b/src/components/dialog/AddDownloadDialog.vue @@ -15,7 +15,7 @@ import { formatFileSize } from '@/@core/utils/formatters' import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs' import { useI18n } from 'vue-i18n' import MediaIdSelector from '../misc/MediaIdSelector.vue' -import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId' +import { isMediaDataSource, isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId' import { useGlobalSettingsStore } from '@/stores' // 多语言支持 @@ -32,25 +32,15 @@ const props = defineProps({ torrent: Object as PropType, }) -// 可选的媒体数据源 -const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = [ - 'themoviedb', - 'douban', - 'bangumi', - 'anilist', - 'musicbrainz', - 'theaudiodb', - 'doubanmusic', -] - -// 当前识别类型:优先使用媒体自身的数据源,否则使用全局识别来源 +// 当前识别类型:优先使用已随媒体或种子传入的完整身份,否则使用识别上下文。 const mediaSource = computed(() => { - const source = props.media?.media_source - if (source && SUPPORTED_MEDIA_SOURCES.includes(source)) return source + if (isMediaDataSource(props.media?.media_source) && props.media?.media_id?.trim()) return props.media.media_source + if (isMediaDataSource(props.torrent?.media_source) && props.torrent?.media_id?.trim()) + return props.torrent.media_source + if (isMediaDataSource(props.media?.media_source)) return props.media.media_source + if (isMediaDataSource(props.torrent?.media_source)) return props.torrent.media_source if (props.torrent?.category === '音乐' || props.torrent?.category === 'music') return 'musicbrainz' - if (SUPPORTED_MEDIA_SOURCES.includes(globalSettings.RECOGNIZE_SOURCE as MediaDataSource)) { - return globalSettings.RECOGNIZE_SOURCE as MediaDataSource - } + if (isMediaDataSource(globalSettings.RECOGNIZE_SOURCE)) return globalSettings.RECOGNIZE_SOURCE return 'themoviedb' }) @@ -91,12 +81,16 @@ const musicEntityOptions = computed(() => [ { title: t('setting.cache.musicType.album'), value: 'album' }, ]) -// 音乐媒体自带来源原生 ID,打开对话框时预填到高级选项中辅助识别。 +// 打开对话框时预填媒体或种子携带的完整身份,不从辅助 ID 字段推导。 watch( - () => props.media, - media => { - if (media?.media_source && SUPPORTED_MEDIA_SOURCES.includes(media.media_source) && media.media_id) { - mediaId.value = media.media_id + () => [props.media, props.torrent] as const, + ([media, torrent]) => { + if (isMediaDataSource(media?.media_source) && media.media_id?.trim()) { + mediaId.value = media.media_id.trim() + } else if (isMediaDataSource(torrent?.media_source) && torrent.media_id?.trim()) { + mediaId.value = torrent.media_id.trim() + } else { + mediaId.value = undefined } if (media?.music_type === 'recording' || media?.music_type === 'album') { musicType.value = media.music_type @@ -114,16 +108,18 @@ function handleMediaSelected(item: Pick) { // 当前数据源对应的原生ID标签。 const mediaIdLabel = computed(() => { - const labels: Record = { + const labels: Partial> = { themoviedb: t('dialog.reorganize.tmdbId'), douban: t('dialog.reorganize.doubanId'), bangumi: t('dialog.reorganize.bangumiId'), anilist: t('dialog.reorganize.anilistId'), + imdb: 'IMDb ID', + tvdb: 'TVDB ID', musicbrainz: 'MusicBrainz ID', theaudiodb: 'TheAudioDB ID', doubanmusic: t('dialog.reorganize.doubanId'), } - return labels[mediaSource.value] + return labels[mediaSource.value] ?? t('dialog.reorganize.mediaId') }) // TMDB选择对话框 @@ -221,10 +217,10 @@ async function addDownload() { payload.media_in = props.media } - // 添加媒体ID辅助识别 - if (mediaId.value) { + const normalizedMediaId = mediaId.value?.trim() + if (normalizedMediaId && isValidMediaSourceId(normalizedMediaId, mediaSource.value)) { payload.media_source = mediaSource.value - payload.media_id = mediaId.value + payload.media_id = normalizedMediaId if (isMusicSelection.value) payload.music_type = musicType.value } diff --git a/src/components/dialog/AddSubtitleDownloadDialog.vue b/src/components/dialog/AddSubtitleDownloadDialog.vue index 1d7549f9..1647d793 100644 --- a/src/components/dialog/AddSubtitleDownloadDialog.vue +++ b/src/components/dialog/AddSubtitleDownloadDialog.vue @@ -2,12 +2,18 @@ import { useToast } from 'vue-toastification' import api from '@/api' import { doneNProgress, startNProgress } from '@/api/nprogress' -import type { ApiResponse, MediaDataSource, SubtitleInfo, TransferDirectoryConf } from '@/api/types' +import { + MediaSource, + type ApiResponse, + type MediaDataSource, + type SubtitleInfo, + type TransferDirectoryConf, +} from '@/api/types' import { formatFileSize } from '@/@core/utils/formatters' import { useI18n } from 'vue-i18n' import MediaIdSelector from '../misc/MediaIdSelector.vue' -import { numberValidator } from '@/@validators' import { useGlobalSettingsStore } from '@/stores' +import { isMediaDataSource, isValidMediaSourceId } from '@/utils/mediaId' // 多语言支持 const { t } = useI18n() @@ -16,18 +22,22 @@ const { t } = useI18n() const globalSettingsStore = useGlobalSettingsStore() const globalSettings = globalSettingsStore.globalSettings -// 当前识别类型 -const mediaSource = ref( - ['themoviedb', 'douban', 'bangumi', 'anilist'].includes(globalSettings.RECOGNIZE_SOURCE) - ? globalSettings.RECOGNIZE_SOURCE - : 'themoviedb', -) - // 输入参数 -const props = defineProps({ - title: String, - subtitle: Object as PropType, -}) +const props = defineProps<{ + title?: string + subtitle?: SubtitleInfo + mediaSource?: MediaDataSource + mediaId?: string +}>() + +const initialMediaId = isMediaDataSource(props.mediaSource) ? props.mediaId?.trim() || undefined : undefined +const mediaSource = ref( + isMediaDataSource(props.mediaSource) + ? props.mediaSource + : isMediaDataSource(globalSettings.RECOGNIZE_SOURCE) + ? globalSettings.RECOGNIZE_SOURCE + : MediaSource.TMDB, +) // 定义成功和失败事件 const emit = defineEmits(['done', 'error', 'close']) @@ -45,23 +55,49 @@ const directories = ref([]) const loading = ref(false) // 是否显示高级选项 -const showAdvancedOptions = ref(false) +const showAdvancedOptions = ref(!initialMediaId) // 当前数据源的原生媒体ID -const mediaId = ref(undefined) +const selectedMediaId = ref(initialMediaId) + +const normalizedMediaId = computed(() => selectedMediaId.value?.trim() || undefined) +const hasValidMediaIdentity = computed( + () => Boolean(normalizedMediaId.value) && isValidMediaSourceId(normalizedMediaId.value, mediaSource.value), +) + +const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => { + const labels: Partial> = { + themoviedb: t('setting.cache.recognitionSource.themoviedb'), + douban: t('setting.cache.recognitionSource.douban'), + bangumi: t('setting.cache.recognitionSource.bangumi'), + anilist: t('setting.cache.recognitionSource.anilist'), + imdb: 'IMDb', + tvdb: 'TVDB', + musicbrainz: 'MusicBrainz', + theaudiodb: 'TheAudioDB', + doubanmusic: t('setting.cache.recognitionSource.doubanmusic'), + bilibili: 'Bilibili', + mangguodiscover: 'Mango TV', + migu: 'Migu Video', + tencentvideodiscover: 'Tencent Video', + } + return Object.values(MediaSource).map(value => ({ title: labels[value] ?? value, value })) +}) // 当前数据源对应的原生ID标签。 const mediaIdLabel = computed(() => { - const labels: Record = { + const labels: Partial> = { themoviedb: t('dialog.reorganize.tmdbId'), douban: t('dialog.reorganize.doubanId'), bangumi: t('dialog.reorganize.bangumiId'), anilist: t('dialog.reorganize.anilistId'), + imdb: 'IMDb ID', + tvdb: 'TVDB ID', musicbrainz: 'MusicBrainz ID', theaudiodb: 'TheAudioDB ID', doubanmusic: t('dialog.reorganize.doubanId'), } - return labels[mediaSource.value] + return labels[mediaSource.value] ?? t('dialog.reorganize.mediaId') }) // TMDB选择对话框 @@ -109,22 +145,21 @@ const targetDirectories = computed(() => { // 下载字幕 async function addSubtitleDownload() { + if (!normalizedMediaId.value || !hasValidMediaIdentity.value) return + startNProgress() loading.value = true try { const payload: { - media_id?: string - media_source?: MediaDataSource + media_id: string + media_source: MediaDataSource save_path: string | null subtitle_in: SubtitleInfo | undefined } = { subtitle_in: props.subtitle, save_path: selectedDirectory.value, - } - - if (mediaId.value) { - payload.media_source = mediaSource.value - payload.media_id = mediaId.value + media_source: mediaSource.value, + media_id: normalizedMediaId.value, } const result = await api.post, ApiResponse>('download/subtitle', payload) @@ -241,12 +276,24 @@ onMounted(() => { - + + + + { - + {{ buttonText }} - + diff --git a/src/components/dialog/CacheReidentifyDialog.vue b/src/components/dialog/CacheReidentifyDialog.vue index 5ae1a9bc..781b939a 100644 --- a/src/components/dialog/CacheReidentifyDialog.vue +++ b/src/components/dialog/CacheReidentifyDialog.vue @@ -1,7 +1,7 @@