-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
- {{ 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 @@
-
-
+
+
diff --git a/src/pages/media.vue b/src/pages/media.vue
index 8629a863..e452b28b 100644
--- a/src/pages/media.vue
+++ b/src/pages/media.vue
@@ -1,24 +1,29 @@
-
+
diff --git a/src/pages/music-album.vue b/src/pages/music-album.vue
index 9438b055..f7ceb525 100644
--- a/src/pages/music-album.vue
+++ b/src/pages/music-album.vue
@@ -1,18 +1,22 @@
-
+
diff --git a/src/pages/music-artist.vue b/src/pages/music-artist.vue
index 8ca3eb3c..1ff09754 100644
--- a/src/pages/music-artist.vue
+++ b/src/pages/music-artist.vue
@@ -1,18 +1,22 @@
-
+
diff --git a/src/pages/music-detail.vue b/src/pages/music-detail.vue
index fc908919..96b46d8a 100644
--- a/src/pages/music-detail.vue
+++ b/src/pages/music-detail.vue
@@ -1,18 +1,22 @@
-
+
diff --git a/src/pages/music.vue b/src/pages/music.vue
index f4817516..f9cc800d 100644
--- a/src/pages/music.vue
+++ b/src/pages/music.vue
@@ -1,8 +1,9 @@
diff --git a/src/pages/resource.vue b/src/pages/resource.vue
index fdd012f3..84dab99b 100644
--- a/src/pages/resource.vue
+++ b/src/pages/resource.vue
@@ -2,7 +2,7 @@
import type { LocationQuery } from 'vue-router'
import NoDataFound from '@/components/states/NoDataFound.vue'
import api from '@/api'
-import type { Context, SubtitleInfo } from '@/api/types'
+import type { Context, MediaDataSource, SubtitleInfo } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import SubtitleCard from '@/components/cards/SubtitleCard.vue'
@@ -20,6 +20,7 @@ import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { SearchReplaceBatchCollector, isSearchReplaceBatchEvent } from '@/utils/searchStream'
import { getCurrentLocale } from '@/plugins/i18n'
+import { isMediaDataSource, isValidMediaSourceId } from '@/utils/mediaId'
// 国际化
const { t } = useI18n()
@@ -45,6 +46,8 @@ const router = useRouter()
interface SearchParams {
keyword: string
+ media_source: MediaDataSource | ''
+ media_id: string
type: string
area: string
title: string
@@ -69,9 +72,17 @@ type TorrentViewType = 'card' | 'row'
// 只有最新搜索可以提交结果和可重放参数,避免旧请求覆盖新查询。
let activeSearchRequestId = 0
+/** 只接受产品协议中固定的数据源枚举,避免未知来源进入搜索链路。 */
+function normalizeMediaSource(value: unknown): MediaDataSource | '' {
+ const normalized = value?.toString().trim()
+ return isMediaDataSource(normalized) ? normalized : ''
+}
+
function createSearchParams(query: LocationQuery): SearchParams {
return {
keyword: query?.keyword?.toString() ?? '',
+ media_source: normalizeMediaSource(query?.media_source),
+ media_id: query?.media_id?.toString() ?? '',
type: query?.type?.toString() ?? '',
area: query?.area?.toString() ?? '',
title: query?.title?.toString() ?? '',
@@ -87,6 +98,8 @@ function createSearchParams(query: LocationQuery): SearchParams {
function normalizeSearchParams(params?: Partial | null): SearchParams {
return {
keyword: params?.keyword?.toString() ?? '',
+ media_source: normalizeMediaSource(params?.media_source),
+ media_id: params?.media_id?.toString() ?? '',
type: params?.type?.toString() ?? '',
area: params?.area?.toString() ?? '',
title: params?.title?.toString() ?? '',
@@ -99,8 +112,42 @@ function normalizeSearchParams(params?: Partial | null): SearchPar
}
}
+/** 判断搜索参数是否包含一组有效的枚举来源与原生媒体 ID。 */
+function hasValidMediaIdentity(params: SearchParams): boolean {
+ const mediaId = params.media_id.trim()
+ return Boolean(params.media_source && mediaId && isValidMediaSourceId(mediaId, params.media_source))
+}
+
function hasSearchKeyword(params: SearchParams): boolean {
- return params.keyword.trim().length > 0
+ return params.keyword.trim().length > 0 || hasValidMediaIdentity(params)
+}
+
+/** 只在本地历史状态读取边界迁移旧版 `source:id` 复合关键词。 */
+function migrateLegacyStoredSearchParams(params: Partial): Partial {
+ if (params.media_source || params.media_id || typeof params.keyword !== 'string') return params
+ const match = params.keyword.match(/^([a-zA-Z_]+):(.+)$/)
+ if (!match) return params
+ const aliases: Record = {
+ tmdb: 'themoviedb',
+ themoviedb: 'themoviedb',
+ douban: 'douban',
+ bangumi: 'bangumi',
+ anilist: 'anilist',
+ imdb: 'imdb',
+ tvdb: 'tvdb',
+ musicbrainz: 'musicbrainz',
+ theaudiodb: 'theaudiodb',
+ doubanmusic: 'doubanmusic',
+ bilibili: 'bilibili',
+ mangguodiscover: 'mangguodiscover',
+ migu: 'migu',
+ tencentvideodiscover: 'tencentvideodiscover',
+ }
+ const mediaSource = aliases[match[1].toLowerCase()]
+ const mediaId = match[2].trim()
+ return mediaSource && mediaId && isValidMediaSourceId(mediaId, mediaSource)
+ ? { ...params, keyword: '', media_source: mediaSource, media_id: mediaId }
+ : params
}
function createSearchRequestToken(): string {
@@ -116,7 +163,8 @@ function loadStoredSearchParams(): SearchParams | null {
const rawParams = localStorage.getItem(resourceSearchParamsStorageKey)
if (!rawParams) return null
- const params = normalizeSearchParams(JSON.parse(rawParams) as Partial)
+ const storedParams = JSON.parse(rawParams) as Partial
+ const params = normalizeSearchParams(migrateLegacyStoredSearchParams(storedParams))
return hasSearchKeyword(params) ? params : null
} catch (error) {
console.warn('读取资源搜索参数失败:', error)
@@ -199,8 +247,8 @@ async function resolveRefreshSearchParams() {
// 查询TMDBID或标题
const keyword = computed(() => activeSearchParams.value.keyword)
-// 媒体 ID 形式的关键词(如 musicbrainz:xxx、tmdb:xxx)对用户无意义,进度卡片中仅展示标题即可。
-const isMediaIdKeyword = computed(() => /^[a-zA-Z]+:/.test(keyword.value || ''))
+// 精确媒体身份对用户无意义,进度卡片中仅展示标题即可。
+const isMediaIdKeyword = computed(() => hasValidMediaIdentity(activeSearchParams.value))
// 查询类型
const type = computed(() => activeSearchParams.value.type)
@@ -555,18 +603,19 @@ function setSearchParam(params: URLSearchParams, key: string, value: unknown) {
// 构建搜索流URL
function buildSearchStreamUrl(params: SearchParams, requestToken?: string) {
- const isMediaSearch = /^[a-zA-Z]+:/.test(params.keyword)
+ const isMediaSearch = hasValidMediaIdentity(params)
const url = getApiUrl(
params.result_type === 'subtitle'
? isMediaSearch
- ? `search/subtitle/media/${encodeURIComponent(params.keyword)}/stream`
+ ? `search/subtitle/media/${encodeURIComponent(params.media_id)}/stream`
: 'search/subtitle/title/stream'
: isMediaSearch
- ? `search/media/${encodeURIComponent(params.keyword)}/stream`
+ ? `search/media/${encodeURIComponent(params.media_id)}/stream`
: 'search/title/stream',
)
if (params.result_type === 'subtitle' && isMediaSearch) {
+ setSearchParam(url.searchParams, 'media_source', params.media_source)
setSearchParam(url.searchParams, 'mtype', params.type)
setSearchParam(url.searchParams, 'title', params.title)
setSearchParam(url.searchParams, 'year', params.year)
@@ -577,6 +626,7 @@ function buildSearchStreamUrl(params: SearchParams, requestToken?: string) {
setSearchParam(url.searchParams, 'keyword', params.keyword)
setSearchParam(url.searchParams, 'sites', params.sites)
} else if (isMediaSearch) {
+ setSearchParam(url.searchParams, 'media_source', params.media_source)
setSearchParam(url.searchParams, 'mtype', params.type)
setSearchParam(url.searchParams, 'area', params.area)
setSearchParam(url.searchParams, 'title', params.title)
@@ -823,11 +873,11 @@ async function searchByRequest(params: SearchParams, requestToken: string | unde
// 静默刷新使用普通请求,保留当前结果直到新数据完整返回,避免返回页面时露出搜索进度态。
async function requestSearchResults(params: SearchParams, requestToken?: string) {
let result: { [key: string]: any }
- const isMediaSearch = /^[a-zA-Z]+:/.test(params.keyword)
- // 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索
+ const isMediaSearch = hasValidMediaIdentity(params)
if (params.result_type === 'subtitle' && isMediaSearch) {
- result = await api.get(`search/subtitle/media/${params.keyword}`, {
+ result = await api.get(`search/subtitle/media/${encodeURIComponent(params.media_id)}`, {
params: {
+ media_source: params.media_source,
mtype: params.type,
title: params.title,
year: params.year,
@@ -846,8 +896,9 @@ async function requestSearchResults(params: SearchParams, requestToken?: string)
},
})
} else if (isMediaSearch) {
- result = await api.get(`search/media/${params.keyword}`, {
+ result = await api.get(`search/media/${encodeURIComponent(params.media_id)}`, {
params: {
+ media_source: params.media_source,
mtype: params.type,
area: params.area,
title: params.title,
@@ -971,7 +1022,7 @@ async function fetchData(options: { force?: boolean; params?: SearchParams; sile
activeSearchParams.value = { ...currentSearchParams }
rememberSearchParams(currentSearchParams)
}
- const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined
+ const requestToken = options.force || hasSearchKeyword(currentSearchParams) ? createSearchRequestToken() : undefined
const hasCurrentResults = isSubtitleSearch.value ? rawSubtitleDataList.value.length > 0 : rawDataList.value.length > 0
const silentRefresh = Boolean(options.silent && isRefreshed.value && hasCurrentResults)
@@ -1561,6 +1612,8 @@ onUnmounted(() => {
v-for="(item, index) in streamPreviewSubtitleDataList"
:key="getSubtitleItemKey(item, index)"
:subtitle="item"
+ :media-source="activeSearchParams.media_source || undefined"
+ :media-id="activeSearchParams.media_id || undefined"
class="stream-result-item"
/>
@@ -1572,7 +1625,11 @@ onUnmounted(() => {
:estimated-item-height="320"
>
-
+
{
:key="getSubtitleItemKey(item, index)"
class="stream-result-item"
>
-
+
@@ -1649,7 +1710,11 @@ onUnmounted(() => {
:get-item-key="getSubtitleItemKey"
>
-
+
diff --git a/src/utils/__tests__/mediaId.spec.ts b/src/utils/__tests__/mediaId.spec.ts
index c389fbb0..042ad2a2 100644
--- a/src/utils/__tests__/mediaId.spec.ts
+++ b/src/utils/__tests__/mediaId.spec.ts
@@ -1,4 +1,4 @@
-import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
+import { isMusicMediaSource, isValidMediaSourceId, parseMediaDataSources } from '@/utils/mediaId'
import { describe, expect, it } from 'vitest'
describe('media source identity utils', () => {
@@ -6,12 +6,32 @@ describe('media source identity utils', () => {
expect(isMusicMediaSource(source)).toBe(true)
})
- it('uses UUIDs for MusicBrainz and numeric IDs for alternate music sources', () => {
+ it.each([
+ ['themoviedb', ['themoviedb']],
+ [' musicbrainz, theaudiodb,unknown,musicbrainz ', ['musicbrainz', 'theaudiodb']],
+ [
+ ['douban', 'anilist,bangumi', null, 'douban'],
+ ['douban', 'anilist', 'bangumi'],
+ ],
+ [undefined, []],
+ ])('normalizes route media sources from %j', (value, expected) => {
+ expect(parseMediaDataSources(value)).toEqual(expected)
+ })
+
+ it('validates source-specific IDs without assuming every provider uses numeric IDs', () => {
+ expect(isValidMediaSourceId('', 'themoviedb')).toBe(true)
+ expect(isValidMediaSourceId(' ', 'douban')).toBe(true)
+ expect(isValidMediaSourceId(0, 'themoviedb')).toBe(false)
+ expect(isValidMediaSourceId('0', 'bilibili')).toBe(false)
expect(isValidMediaSourceId('977e6978-139d-425c-bb98-6b0c62d1e45e', 'musicbrainz')).toBe(true)
+ expect(isValidMediaSourceId('not-a-uuid', 'musicbrainz')).toBe(false)
expect(isValidMediaSourceId('32793500', 'theaudiodb')).toBe(true)
expect(isValidMediaSourceId('1401853', 'doubanmusic')).toBe(true)
expect(isValidMediaSourceId('1401853:3', 'doubanmusic')).toBe(true)
expect(isValidMediaSourceId('1401853:track', 'doubanmusic')).toBe(false)
- expect(isValidMediaSourceId('not-a-number', 'theaudiodb')).toBe(false)
+ expect(isValidMediaSourceId('album-32793500', 'theaudiodb')).toBe(true)
+ expect(isValidMediaSourceId('tt0111161', 'imdb')).toBe(true)
+ expect(isValidMediaSourceId('0111161', 'imdb')).toBe(false)
+ expect(isValidMediaSourceId('BV1xx411c7mD', 'bilibili')).toBe(true)
})
})
diff --git a/src/utils/__tests__/music.spec.ts b/src/utils/__tests__/music.spec.ts
index b24fe1c3..757ac9f0 100644
--- a/src/utils/__tests__/music.spec.ts
+++ b/src/utils/__tests__/music.spec.ts
@@ -14,7 +14,7 @@ describe('music utils', () => {
it('routes a recording to the music detail page', () => {
expect(buildMusicDetailRoute({ media_source: 'musicbrainz', media_id: 'recording-1', title: '晴天' })).toEqual({
path: '/music/detail',
- query: { media_source: 'musicbrainz', mediaid: 'recording-1', title: '晴天' },
+ query: { media_source: 'musicbrainz', media_id: 'recording-1', title: '晴天' },
})
})
@@ -28,7 +28,7 @@ describe('music utils', () => {
}),
).toEqual({
path: '/music/album',
- query: { media_source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
+ query: { media_source: 'musicbrainz', media_id: 'release-group-1', title: '叶惠美' },
})
})
@@ -42,7 +42,7 @@ describe('music utils', () => {
}),
).toEqual({
path: '/music/artist',
- query: { media_source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
+ query: { media_source: 'musicbrainz', media_id: 'artist-1', title: 'Queen' },
})
})
@@ -53,14 +53,14 @@ describe('music utils', () => {
})
})
- it('builds album and artist routes with the default source', () => {
- expect(buildMusicAlbumRoute('release-group-1', '叶惠美')).toEqual({
+ it('builds album and artist routes with an explicit source', () => {
+ expect(buildMusicAlbumRoute('release-group-1', '叶惠美', 'musicbrainz')).toEqual({
path: '/music/album',
- query: { media_source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
+ query: { media_source: 'musicbrainz', media_id: 'release-group-1', title: '叶惠美' },
})
- expect(buildMusicArtistRoute('artist-1', 'Queen')).toEqual({
+ expect(buildMusicArtistRoute('artist-1', 'Queen', 'musicbrainz')).toEqual({
path: '/music/artist',
- query: { media_source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
+ query: { media_source: 'musicbrainz', media_id: 'artist-1', title: 'Queen' },
})
})
@@ -78,7 +78,8 @@ describe('music utils', () => {
).toMatchObject({
path: '/resource',
query: {
- keyword: 'musicbrainz:recording-1',
+ media_id: 'recording-1',
+ media_source: 'musicbrainz',
music_type: 'recording',
sites: '11,12',
type: '音乐',
diff --git a/src/utils/mediaId.ts b/src/utils/mediaId.ts
index 7d1195df..f991e32d 100644
--- a/src/utils/mediaId.ts
+++ b/src/utils/mediaId.ts
@@ -1,23 +1,41 @@
-import type { MediaDataSource } from '@/api/types'
+import { MediaSource, type MediaDataSource } from '@/api/types'
const MUSICBRAINZ_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+const IMDB_ID_PATTERN = /^tt\d+$/i
export const MUSIC_MEDIA_SOURCES = ['musicbrainz', 'theaudiodb', 'doubanmusic'] as const
+/** 判断外部输入是否属于产品协议中固定的媒体来源枚举。 */
+export function isMediaDataSource(value: unknown): value is MediaDataSource {
+ return typeof value === 'string' && Object.values(MediaSource).includes(value as MediaSource)
+}
+
+/** 将路由或表单中的单值、逗号分隔值及数组统一解析为去重后的媒体来源枚举。 */
+export function parseMediaDataSources(value: unknown): MediaDataSource[] {
+ const values = Array.isArray(value) ? value : [value]
+ return [
+ ...new Set(
+ values
+ .flatMap(item => (typeof item === 'string' ? item.split(',') : []))
+ .map(item => item.trim())
+ .filter(isMediaDataSource),
+ ),
+ ]
+}
+
/** 判断当前请求来源是否为内置音乐元数据源。 */
export function isMusicMediaSource(source?: MediaDataSource): boolean {
return MUSIC_MEDIA_SOURCES.includes(source as (typeof MUSIC_MEDIA_SOURCES)[number])
}
-/** 按媒体数据源校验原生 ID,并兼容豆瓣音乐的曲目复合 ID。 */
+/** 按媒体数据源校验有固定格式的原生 ID,其余来源只要求非空。 */
export function isValidMediaSourceId(value: string | number | null | undefined, source?: MediaDataSource): boolean {
const normalized = value?.toString().trim()
if (!normalized) return true
- // 无媒体上下文或来源为非 MusicBrainz 时,UUID 形态同样按 MusicBrainz ID 放行
- if (source === 'musicbrainz' || MUSICBRAINZ_ID_PATTERN.test(normalized)) {
- return MUSICBRAINZ_ID_PATTERN.test(normalized)
- }
+ if (normalized === '0') return false
+ if (source === 'musicbrainz') return MUSICBRAINZ_ID_PATTERN.test(normalized)
if (source === 'doubanmusic' && normalized.includes(':')) {
return /^\d+:\d+$/.test(normalized)
}
- return /^\d+$/.test(normalized)
+ if (source === 'imdb') return IMDB_ID_PATTERN.test(normalized)
+ return true
}
diff --git a/src/utils/music.ts b/src/utils/music.ts
index b679e1fd..969756f0 100644
--- a/src/utils/music.ts
+++ b/src/utils/music.ts
@@ -1,8 +1,8 @@
-import type { MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicEntityType } from '@/api/types'
+import type { MediaDataSource, MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicEntityType } from '@/api/types'
import type { RouteLocationRaw } from 'vue-router'
export interface MusicRouteTarget {
- media_source?: string
+ media_source?: MediaDataSource
media_id?: string | number
music_type?: MusicEntityType
title?: string
@@ -57,7 +57,7 @@ export function formatMusicAudioSpecs(item?: MusicAudioInfo): string {
}
/** 返回音乐对象可用于路由和订阅的统一来源。 */
-export function getMusicSource(item: MusicRouteTarget): string | undefined {
+export function getMusicSource(item: MusicRouteTarget): MediaDataSource | undefined {
return item.media_source
}
@@ -68,7 +68,7 @@ export function getMusicSourceLabel(source?: string, translate?: (key: string) =
theaudiodb: 'TheAudioDB',
doubanmusic: translate?.('setting.cache.recognitionSource.doubanmusic') || '豆瓣音乐',
}
- return (source && labels[source]) || source || 'MusicBrainz'
+ return (source && labels[source]) || source || ''
}
/** 返回音乐候选在列表和状态缓存中的稳定身份。 */
@@ -79,13 +79,21 @@ export function getMusicKey(item: MusicRouteTarget): string {
}
/** 构造专辑详情路由。 */
-export function buildMusicAlbumRoute(albumId: string, title?: string, mediaSource = 'musicbrainz'): RouteLocationRaw {
- return { path: '/music/album', query: { media_source: mediaSource, mediaid: albumId, title } }
+export function buildMusicAlbumRoute(
+ albumId: string,
+ title: string | undefined,
+ mediaSource: MediaDataSource,
+): RouteLocationRaw {
+ return { path: '/music/album', query: { media_source: mediaSource, media_id: albumId, title } }
}
/** 构造艺术家详情路由。 */
-export function buildMusicArtistRoute(artistId: string, name?: string, mediaSource = 'musicbrainz'): RouteLocationRaw {
- return { path: '/music/artist', query: { media_source: mediaSource, mediaid: artistId, title: name } }
+export function buildMusicArtistRoute(
+ artistId: string,
+ name: string | undefined,
+ mediaSource: MediaDataSource,
+): RouteLocationRaw {
+ return { path: '/music/artist', query: { media_source: mediaSource, media_id: artistId, title: name } }
}
/** 按音乐实体类型构造详情路由,缺少标准身份时回退到音乐搜索页。 */
@@ -101,7 +109,7 @@ export function buildMusicDetailRoute(item: MusicRouteTarget): RouteLocationRaw
path: '/music/detail',
query: {
media_source: source,
- mediaid: mediaId,
+ media_id: mediaId,
title: item.title || item.name,
},
}
@@ -119,7 +127,8 @@ export function buildMusicResourceRoute(
return {
path: '/resource',
query: {
- keyword: `${source}:${item.media_id}`,
+ media_source: source,
+ media_id: item.media_id,
type: '音乐',
music_type: (item as MusicRouteTarget).music_type || 'recording',
title: item.title,
diff --git a/src/views/dashboard/AnalyticsScheduler.vue b/src/views/dashboard/AnalyticsScheduler.vue
index 9162ef50..43835095 100644
--- a/src/views/dashboard/AnalyticsScheduler.vue
+++ b/src/views/dashboard/AnalyticsScheduler.vue
@@ -73,7 +73,7 @@ const backgroundTasks = computed
(() => {
const isRunning = tasks.some(task => task.state === 'running')
return {
- id: `transfer-${item.media?.tmdb_id ?? index}-${item.season ?? ''}`,
+ id: `transfer-${item.media?.media_source ?? 'unknown'}-${item.media?.media_id ?? index}-${item.season ?? ''}`,
title: item.media?.title_year || item.media?.title || t('dashboard.transferQueue'),
subtitle: t('dashboard.transferProgress', { completed, total: tasks.length }),
status: isRunning ? t('dashboard.taskRunning') : t('dashboard.taskWaiting'),
diff --git a/src/views/dashboard/MediaRecommend.vue b/src/views/dashboard/MediaRecommend.vue
index c1f7a805..c634cddf 100644
--- a/src/views/dashboard/MediaRecommend.vue
+++ b/src/views/dashboard/MediaRecommend.vue
@@ -3,7 +3,7 @@ import api from '@/api'
import type { MediaInfo } from '@/api/types'
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
-import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
+import { getMediaSubscribeId, getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { createBuiltInRecommendSources, type RecommendViewSource } from '@/utils/recommendSources'
@@ -75,7 +75,7 @@ function normalizeMediaResponse(response: unknown): MediaInfo[] {
/** 判断媒体是否具备可展示图片和可进入详情页的标识。 */
function isUsableMedia(item: MediaInfo) {
- const hasMediaId = Boolean(item.tmdb_id || item.collection_id)
+ const hasMediaId = Boolean((item.media_source && item.media_id) || item.collection_id)
return Boolean(item.title && (item.backdrop_path || item.poster_path) && hasMediaId)
}
@@ -155,11 +155,14 @@ function goToMediaDetail() {
void router.push({ path: `/browse/tmdb/collection/${item.collection_id}`, query: { title: item.title } })
return
}
+ const identity = getMediaSubscribeIdentity(item)
+ if (!identity) return
void router.push({
path: '/media',
query: {
- mediaid: getMediaSubscribeId(item),
+ media_source: identity.source,
+ media_id: identity.mediaId,
title: item.title,
type: item.type,
year: item.year,
diff --git a/src/views/dashboard/__tests__/MediaRecommend.spec.ts b/src/views/dashboard/__tests__/MediaRecommend.spec.ts
index a4be0f5d..2f3a4c45 100644
--- a/src/views/dashboard/__tests__/MediaRecommend.spec.ts
+++ b/src/views/dashboard/__tests__/MediaRecommend.spec.ts
@@ -61,7 +61,7 @@ describe('MediaRecommend', () => {
list: [
createMediaInfo({ title: undefined }),
createMediaInfo({ backdrop_path: undefined, poster_path: undefined, title: '无图片' }),
- createMediaInfo({ collection_id: undefined, title: '无标识', tmdb_id: undefined }),
+ createMediaInfo({ collection_id: undefined, media_id: undefined, title: '无标识', tmdb_id: 999 }),
...validMedia,
],
},
@@ -205,7 +205,8 @@ describe('MediaRecommend', () => {
await fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter' })
await waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
expect(router.currentRoute.value.query).toMatchObject({
- mediaid: 'tmdb:101',
+ media_id: '101',
+ media_source: 'themoviedb',
title: '普通媒体',
type: '电影',
year: '2025',
diff --git a/src/views/discover/MediaCardListView.vue b/src/views/discover/MediaCardListView.vue
index ccf1bebf..ba975146 100644
--- a/src/views/discover/MediaCardListView.vue
+++ b/src/views/discover/MediaCardListView.vue
@@ -43,7 +43,7 @@ const seenKeys = new Set()
const seenPageSignatures = new Set()
// 拼装参数
-function getParams() {
+function getParams(): Record {
return {
...props.params,
page: page.value,
@@ -84,8 +84,10 @@ function appendData(items: MediaInfo[]) {
}
async function loadPageData() {
+ const params = getParams()
const rawData: MediaInfo[] = await api.get(props.apipath, {
- params: getParams(),
+ params,
+ ...(Array.isArray(params.media_source) ? { paramsSerializer: { indexes: null } } : {}),
})
const pageSignature = [...new Set(rawData.map(getMediaIdentity))].sort().join('\n')
const isTerminal = rawData.length === 0 || seenPageSignatures.has(pageSignature)
diff --git a/src/views/discover/MediaCardSlideView.vue b/src/views/discover/MediaCardSlideView.vue
index bd2080d7..01bcbf49 100644
--- a/src/views/discover/MediaCardSlideView.vue
+++ b/src/views/discover/MediaCardSlideView.vue
@@ -106,9 +106,7 @@ onActivated(() => {
diff --git a/src/views/discover/MediaDetailView.vue b/src/views/discover/MediaDetailView.vue
index 1119e718..2d30d511 100644
--- a/src/views/discover/MediaDetailView.vue
+++ b/src/views/discover/MediaDetailView.vue
@@ -5,6 +5,7 @@ import MediaCardSlideView from './MediaCardSlideView.vue'
import api from '@/api'
import type {
ApiResponse,
+ MediaDataSource,
MediaInfo,
MediaRelease,
MediaSeason,
@@ -41,12 +42,13 @@ const { t } = useI18n()
const $toast = useToast()
// 输入参数
-const mediaProps = defineProps({
- mediaid: String,
- title: String,
- year: [String, Number],
- type: String,
-})
+const mediaProps = defineProps<{
+ mediaSource?: MediaDataSource
+ mediaId?: string
+ title?: string
+ year?: string | number
+ type?: string
+}>()
// 从 provide 中获取全局设置
// 全局设置
@@ -200,7 +202,7 @@ async function querySelectedSites() {
}
}
-// 获得mediaid
+// 获取当前详情的统一媒体身份缓存键
function getMediaId() {
return getMediaSubscribeId(mediaDetail.value)
}
@@ -217,42 +219,46 @@ function getSubscribeStatusKey(season: number | null = mediaDetail.value?.season
// 调用API查询详情
async function getMediaDetail() {
- if (mediaProps.mediaid && mediaProps.type) {
+ if (!mediaProps.mediaSource || !mediaProps.mediaId || !mediaProps.type) {
+ mediaDetail.value = {} as MediaInfo
detailLoadFailed.value = false
- isRefreshed.value = false
- try {
- mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, {
- params: {
- title: mediaProps.title,
- year: mediaProps.year,
- type_name: mediaProps.type,
- },
- })
- if (!hasMediaIdentity()) return
+ isRefreshed.value = true
+ return
+ }
- 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)
- }
+ detailLoadFailed.value = false
+ isRefreshed.value = false
+ try {
+ mediaDetail.value = await api.get(`media/${encodeURIComponent(mediaProps.mediaId)}`, {
+ params: {
+ media_source: mediaProps.mediaSource,
+ type_name: mediaProps.type,
+ },
+ })
+ if (!hasMediaIdentity()) return
- // 检查存在状态
- checkExists()
- if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()
- // 检查订阅状态
- if (mediaDetail.value.type === '电影') checkMovieSubscribed()
- else checkSeasonsSubscribed()
- } catch (error) {
- console.error(error)
- detailLoadFailed.value = true
- } finally {
- isRefreshed.value = true
+ 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)
+ }
+
+ // 检查存在状态
+ checkExists()
+ if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()
+ // 检查订阅状态
+ if (mediaDetail.value.type === '电影') checkMovieSubscribed()
+ else checkSeasonsSubscribed()
+ } catch (error) {
+ console.error(error)
+ detailLoadFailed.value = true
+ } finally {
+ isRefreshed.value = true
}
}
@@ -297,7 +303,8 @@ async function checkExists() {
try {
const result: ApiResponse<{ item: { id: string } }> = await api.get('mediaserver/exists', {
params: {
- tmdbid: mediaDetail.value.tmdb_id,
+ media_source: mediaDetail.value.media_source,
+ media_id: mediaDetail.value.media_id,
title: mediaDetail.value.title,
year: mediaDetail.value.year,
season: mediaDetail.value.season,
@@ -324,19 +331,10 @@ async function checkSubscribe(season: number | null = null) {
// 判断订阅记录是否属于当前媒体
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 (mediaDetail.value?.tmdb_id && subscribe.tmdbid) return mediaDetail.value.tmdb_id === subscribe.tmdbid
- if (mediaDetail.value?.douban_id && subscribe.doubanid) return mediaDetail.value.douban_id === subscribe.doubanid
- if (mediaDetail.value?.bangumi_id && subscribe.bangumiid) return mediaDetail.value.bangumi_id === subscribe.bangumiid
- if (mediaDetail.value?.anilist_id && subscribe.anilistid) {
- return mediaDetail.value.anilist_id === subscribe.anilistid
- }
- return false
+ const identity = getMediaSubscribeIdentity(mediaDetail.value)
+ return Boolean(
+ identity && subscribe.media_source === identity.source && String(subscribe.media_id || '') === identity.mediaId,
+ )
}
// 检查所有季的缺失状态
@@ -702,13 +700,15 @@ function joinArray(arr: string[]) {
// 开始搜索
function handleSearch(resultType: 'torrent' | 'subtitle' = 'torrent', options: MediaSearchOptions = {}) {
- const keyword = getMediaId()
+ const identity = getMediaSubscribeIdentity(mediaDetail.value)
+ if (!identity) return
const season = options.season ?? mediaDetail.value.season
const episode = options.episode ?? null
router.push({
path: '/resource',
query: {
- keyword,
+ media_source: identity.source,
+ media_id: identity.mediaId,
type: mediaDetail.value.type,
area: searchType.value,
title: mediaDetail.value.title,
diff --git a/src/views/discover/MusicAlbumView.vue b/src/views/discover/MusicAlbumView.vue
index 3dd4c434..cf9f8d27 100644
--- a/src/views/discover/MusicAlbumView.vue
+++ b/src/views/discover/MusicAlbumView.vue
@@ -1,6 +1,6 @@
@@ -231,10 +233,10 @@ watch(() => [props.mediaSource, props.mediaid], loadAlbumDetail, { immediate: tr
-
+
@@ -248,8 +250,8 @@ watch(() => [props.mediaSource, props.mediaid], loadAlbumDetail, { immediate: tr
diff --git a/src/views/discover/MusicArtistView.vue b/src/views/discover/MusicArtistView.vue
index 4beaa05b..3ac2be53 100644
--- a/src/views/discover/MusicArtistView.vue
+++ b/src/views/discover/MusicArtistView.vue
@@ -1,6 +1,6 @@
@@ -163,7 +166,7 @@ watch(() => [props.mediaSource, props.mediaid], loadArtistDetail, { immediate: t
@@ -171,7 +174,7 @@ watch(() => [props.mediaSource, props.mediaid], loadArtistDetail, { immediate: t
diff --git a/src/views/discover/MusicDetailView.vue b/src/views/discover/MusicDetailView.vue
index eb9b42fd..4a785150 100644
--- a/src/views/discover/MusicDetailView.vue
+++ b/src/views/discover/MusicDetailView.vue
@@ -1,6 +1,6 @@
@@ -247,10 +250,10 @@ watch(() => [props.mediaSource, props.mediaid], loadMusicDetail, { immediate: tr
/>
-
+
diff --git a/src/views/discover/PersonCardListView.vue b/src/views/discover/PersonCardListView.vue
index 2e5a5e1d..122cdc7b 100644
--- a/src/views/discover/PersonCardListView.vue
+++ b/src/views/discover/PersonCardListView.vue
@@ -40,14 +40,16 @@ function appendData(items: Person[]) {
}
async function loadPageData() {
+ const params = getParams()
return api.get(props.apipath!, {
- params: getParams(),
+ params,
+ ...(Array.isArray(params.media_source) ? { paramsSerializer: { indexes: null } } : {}),
}) as Promise
}
// 拼装参数
-function getParams() {
- let params = {
+function getParams(): Record {
+ let params: Record = {
page: page.value,
}
if (props.params) params = { ...params, ...props.params }
diff --git a/src/views/discover/__tests__/MediaCardListView.spec.ts b/src/views/discover/__tests__/MediaCardListView.spec.ts
index 7a38bbe2..8ac162bd 100644
--- a/src/views/discover/__tests__/MediaCardListView.spec.ts
+++ b/src/views/discover/__tests__/MediaCardListView.spec.ts
@@ -192,6 +192,23 @@ describe('MediaCardListView', () => {
expect(requests[0].searchParams.get('genre')).toBe('科幻')
})
+ it('serializes media source arrays as repeated query keys without brackets', async () => {
+ setScrollHeight(() => 900)
+ const requests: URL[] = []
+ server.use(
+ http.get(LIST_URL, ({ request }) => {
+ requests.push(new URL(request.url))
+ return HttpResponse.json([createMediaInfo({ title: '多来源结果' })])
+ }),
+ )
+
+ await renderList({ params: { media_source: ['musicbrainz', 'theaudiodb'] } })
+
+ expect(await screen.findByRole('article', { name: '媒体卡片 多来源结果' })).toBeInTheDocument()
+ expect(requests[0].searchParams.getAll('media_source')).toEqual(['musicbrainz', 'theaudiodb'])
+ expect(requests[0].searchParams.has('media_source[]')).toBe(false)
+ })
+
it('loads only one page when the viewport is already scrollable', async () => {
setScrollHeight(() => 900)
const requestedPages: string[] = []
diff --git a/src/views/discover/__tests__/MediaCardSlideView.spec.ts b/src/views/discover/__tests__/MediaCardSlideView.spec.ts
index bd64c269..0de1e0fb 100644
--- a/src/views/discover/__tests__/MediaCardSlideView.spec.ts
+++ b/src/views/discover/__tests__/MediaCardSlideView.spec.ts
@@ -115,18 +115,20 @@ function installLoadTriggerControls() {
animationFrameCallbacks.push(callback)
return animationFrameCallbacks.length
})
- vi.spyOn(window, 'setTimeout').mockImplementation(
- ((handler: TimerHandler, timeout?: number, ...args: unknown[]): TimeoutHandle => {
- if (timeout === 600) {
- fallbackCallbacks.push(() => {
- if (typeof handler === 'function') handler(...args)
- })
- return fallbackCallbacks.length as unknown as TimeoutHandle
- }
+ vi.spyOn(window, 'setTimeout').mockImplementation(((
+ handler: TimerHandler,
+ timeout?: number,
+ ...args: unknown[]
+ ): TimeoutHandle => {
+ if (timeout === 600) {
+ fallbackCallbacks.push(() => {
+ if (typeof handler === 'function') handler(...args)
+ })
+ return fallbackCallbacks.length as unknown as TimeoutHandle
+ }
- return nativeSetTimeout(handler, timeout, ...args) as unknown as TimeoutHandle
- }) as unknown as typeof window.setTimeout,
- )
+ return nativeSetTimeout(handler, timeout, ...args) as unknown as TimeoutHandle
+ }) as unknown as typeof window.setTimeout)
}
async function renderSlide(props: { ready?: boolean } = {}) {
@@ -306,16 +308,30 @@ describe('MediaCardSlideView', () => {
expect(requested).toHaveBeenCalledTimes(2)
})
- it('projects items, stable key fallbacks, and fixed card width', async () => {
+ it('projects items with pair-based stable keys and fixed card width', async () => {
const media = [
createMediaInfo({ douban_id: 'unused-douban', title: 'TMDB 媒体', tmdb_id: 101 }),
- createMediaInfo({ douban_id: 'douban-202', title: '豆瓣媒体', tmdb_id: undefined }),
- createMediaInfo({ bangumi_id: 'bangumi-303', douban_id: undefined, title: 'Bangumi 媒体', tmdb_id: undefined }),
+ createMediaInfo({
+ douban_id: 'douban-202',
+ media_id: 'douban-202',
+ media_source: 'douban',
+ title: '豆瓣媒体',
+ tmdb_id: undefined,
+ }),
+ createMediaInfo({
+ bangumi_id: 'bangumi-303',
+ douban_id: undefined,
+ media_id: 'bangumi-303',
+ media_source: 'bangumi',
+ title: 'Bangumi 媒体',
+ tmdb_id: undefined,
+ }),
createMediaInfo({
bangumi_id: undefined,
douban_id: undefined,
- media_id: 'custom-404',
- title: '自定义媒体',
+ media_id: 'bilibili-404',
+ media_source: 'bilibili',
+ title: 'Bilibili 媒体',
tmdb_id: undefined,
}),
createMediaInfo({
@@ -334,7 +350,7 @@ describe('MediaCardSlideView', () => {
await waitFor(() => expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-loading', 'false'))
expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-item-count', '5')
expect(screen.getByLabelText('媒体横向列表键')).toHaveTextContent(
- '101|douban-202|bangumi-303|custom-404|标题回退',
+ 'themoviedb:101|douban:douban-202|bangumi:bangumi-303|bilibili:bilibili-404|themoviedb:标题回退',
)
expect(screen.getAllByRole('article')).toHaveLength(5)
screen.getAllByRole('article').forEach(card => expect(card).toHaveAttribute('data-width', '9rem'))
diff --git a/src/views/discover/__tests__/MediaDetailView.spec.ts b/src/views/discover/__tests__/MediaDetailView.spec.ts
index 2ed1f9ba..ad50203b 100644
--- a/src/views/discover/__tests__/MediaDetailView.spec.ts
+++ b/src/views/discover/__tests__/MediaDetailView.spec.ts
@@ -1,5 +1,4 @@
-import type { MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
-import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
+import type { MediaDataSource, MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
import vuetify from '@/plugins/vuetify'
import MediaDetailView from '@/views/discover/MediaDetailView.vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
@@ -123,6 +122,7 @@ interface RenderDetailOptions {
existsStatus?: number
media?: MediaInfo
mediaId?: string
+ mediaSource?: MediaDataSource | null
movieSubscribe?: Partial
notExists?: NotExistMediaInfo[]
notExistsStatus?: number
@@ -144,7 +144,9 @@ function installSiteHandlers(sites: Site[] = [], selected: number[] = [], type =
async function renderDetail(options: RenderDetailOptions = {}) {
const media = options.media ?? createMediaInfo({ title: '详情测试电影', tmdb_id: 8101, type: '电影' })
- const mediaId = options.mediaId ?? `tmdb:${media.tmdb_id}`
+ const mediaId = options.mediaId ?? String(media.media_id)
+ const mediaSource =
+ options.mediaSource === null ? undefined : (options.mediaSource ?? media.media_source ?? 'themoviedb')
const type = options.type ?? media.type ?? '电影'
const existsRequest = vi.fn()
const subscribeRequest = vi.fn()
@@ -157,7 +159,7 @@ async function renderDetail(options: RenderDetailOptions = {}) {
),
mediaNotExistsHandler(options.notExists ?? [], options.notExistsStatus),
subscribeListHandler(options.subscribes ?? [], options.subscribesStatus, subscribeRequest),
- querySubscribeByMediaHandler(getMediaSubscribeId(media), options.movieSubscribe ?? {}, 200, subscribeRequest),
+ querySubscribeByMediaHandler(mediaId, options.movieSubscribe ?? {}, 200, subscribeRequest),
)
if (media.tmdb_id) {
server.use(
@@ -191,7 +193,8 @@ async function renderDetail(options: RenderDetailOptions = {}) {
},
},
props: {
- mediaid: mediaId,
+ mediaId,
+ mediaSource,
title: media.title,
type,
year: media.year,
@@ -206,7 +209,7 @@ async function renderDetail(options: RenderDetailOptions = {}) {
},
})
- const recognized = Boolean(media.media_id || media.tmdb_id || media.douban_id || media.bangumi_id || media.anilist_id)
+ const recognized = Boolean(media.media_source && media.media_id)
if (recognized && (options.detailStatus ?? 200) < 400) {
await waitFor(() => {
expect(existsRequest).toHaveBeenCalledOnce()
@@ -234,14 +237,44 @@ describe('MediaDetailView detail and actions', () => {
})
it.each([
- ['TMDB', 'tmdb:8201', createMediaInfo({ tmdb_id: 8201, type: '电影' })],
- ['Douban', 'douban:db-8202', createMediaInfo({ douban_id: 'db-8202', tmdb_id: undefined, type: '电影' })],
- ['Bangumi', 'bangumi:8203', createMediaInfo({ bangumi_id: '8203', tmdb_id: undefined, type: '电视剧' })],
- ['AniList', 'anilist:154587', createMediaInfo({ anilist_id: 154587, tmdb_id: undefined, type: '电视剧' })],
+ ['TMDB', '8201', createMediaInfo({ tmdb_id: 8201, type: '电影' })],
[
- 'extension',
- 'custom:item-8204',
- createMediaInfo({ media_id: 'item-8204', media_source: 'custom', tmdb_id: 8204, type: '电影' }),
+ 'Douban',
+ 'db-8202',
+ createMediaInfo({
+ douban_id: 'db-8202',
+ media_id: 'db-8202',
+ media_source: 'douban',
+ tmdb_id: undefined,
+ type: '电影',
+ }),
+ ],
+ [
+ 'Bangumi',
+ '8203',
+ createMediaInfo({
+ bangumi_id: '8203',
+ media_id: '8203',
+ media_source: 'bangumi',
+ tmdb_id: undefined,
+ type: '电视剧',
+ }),
+ ],
+ [
+ 'AniList',
+ '154587',
+ createMediaInfo({
+ anilist_id: 154587,
+ media_id: '154587',
+ media_source: 'anilist',
+ tmdb_id: undefined,
+ type: '电视剧',
+ }),
+ ],
+ [
+ 'Bilibili',
+ 'item-8204',
+ createMediaInfo({ media_id: 'item-8204', media_source: 'bilibili', tmdb_id: 8204, type: '电影' }),
],
])('loads the exact %s media path and query', async (_source, mediaId, media) => {
const requested = vi.fn<(url: URL) => void>()
@@ -251,9 +284,8 @@ describe('MediaDetailView detail and actions', () => {
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(requested.mock.calls[0][0].pathname).toBe(`/api/v1/media/${mediaId}`)
expect(Object.fromEntries(requested.mock.calls[0][0].searchParams)).toEqual({
- title: media.title,
+ media_source: media.media_source,
type_name: media.type,
- year: media.year,
})
})
@@ -274,20 +306,35 @@ describe('MediaDetailView detail and actions', () => {
it('renders legal empty media separately from loading', async () => {
const empty = createEmptyMediaInfo()
- await renderDetail({ media: empty, mediaId: 'tmdb:8301', type: '电影' })
+ await renderDetail({ media: empty, mediaId: '8301', type: '电影' })
expect(await screen.findByText('未识别到媒体信息。')).toBeInTheDocument()
expect(screen.queryByText('加载中')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument()
})
+ it('settles a detail route without a complete media identity without sending a request', async () => {
+ const requested = vi.fn()
+ await renderDetail({
+ detailRequest: requested,
+ media: createEmptyMediaInfo(),
+ mediaId: '8300',
+ mediaSource: null,
+ type: '电影',
+ })
+
+ expect(await screen.findByText('未识别到媒体信息。')).toBeInTheDocument()
+ expect(screen.queryByText('加载中')).not.toBeInTheDocument()
+ expect(requested).not.toHaveBeenCalled()
+ })
+
it('shows a distinct retryable error when detail loading fails', async () => {
const requested = vi.fn()
await renderDetail({
detailRequest: requested,
detailStatus: 500,
media: createEmptyMediaInfo(),
- mediaId: 'tmdb:8302',
+ mediaId: '8302',
type: '电影',
})
@@ -298,9 +345,9 @@ describe('MediaDetailView detail and actions', () => {
const existsRequested = vi.fn()
const subscribeRequested = vi.fn()
server.use(
- mediaDetailsHandler('tmdb:8302', createMediaInfo({ title: '重试成功', tmdb_id: 8302 })),
+ mediaDetailsHandler('8302', createMediaInfo({ title: '重试成功', tmdb_id: 8302 })),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequested),
- querySubscribeByMediaHandler('tmdb:8302', {}, 200, subscribeRequested),
+ querySubscribeByMediaHandler('8302', {}, 200, subscribeRequested),
)
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
@@ -366,7 +413,7 @@ describe('MediaDetailView detail and actions', () => {
tvdb_slug: 'speed-and-love',
type: '电视剧',
})
- await renderDetail({ media: mediaWithSlug, mediaId: 'tmdb:1', type: '电视剧' })
+ await renderDetail({ media: mediaWithSlug, mediaId: '1', type: '电视剧' })
expect(screen.getByRole('link', { name: /TheTvDb/ })).toHaveAttribute(
'href',
'https://www.thetvdb.com/series/speed-and-love',
@@ -377,6 +424,8 @@ describe('MediaDetailView detail and actions', () => {
const media = createMediaInfo({
backdrop_path: 'https://images.example.com/douban-backdrop.jpg',
douban_id: 'db-8402',
+ media_id: 'db-8402',
+ media_source: 'douban',
original_title: 'Douban Original',
poster_path: 'https://images.example.com/douban-poster.jpg',
production_countries: [{ name: '中国大陆' }],
@@ -385,7 +434,7 @@ describe('MediaDetailView detail and actions', () => {
tmdb_id: undefined,
type: '电影',
})
- const { container } = await renderDetail({ media, mediaId: 'douban:db-8402' })
+ const { container } = await renderDetail({ media, mediaId: 'db-8402' })
expect(await screen.findByRole('heading', { name: /豆瓣独立电影/ })).toBeInTheDocument()
expect(screen.getByText('Douban Original')).toBeInTheDocument()
@@ -405,13 +454,15 @@ describe('MediaDetailView detail and actions', () => {
it('renders Bangumi-only facts, external link, credits, and recommendations', async () => {
const media = createMediaInfo({
bangumi_id: '8403',
+ media_id: '8403',
+ media_source: 'bangumi',
original_title: 'Bangumi Original',
release_date: '2026-03-03',
title: 'Bangumi 独立条目',
tmdb_id: undefined,
type: '电视剧',
})
- await renderDetail({ media, mediaId: 'bangumi:8403', type: '电视剧' })
+ await renderDetail({ media, mediaId: '8403', type: '电视剧' })
expect(await screen.findByRole('heading', { name: /Bangumi 独立条目/ })).toBeInTheDocument()
expect(screen.getByText('Bangumi Original')).toBeInTheDocument()
@@ -425,13 +476,15 @@ describe('MediaDetailView detail and actions', () => {
it('renders AniList-only facts, external link, credits, and recommendations', async () => {
const media = createMediaInfo({
anilist_id: 154587,
+ media_id: '154587',
+ media_source: 'anilist',
original_title: '葬送のフリーレン',
release_date: '2023-09-29',
title: '葬送的芙莉莲',
tmdb_id: undefined,
type: '电视剧',
})
- await renderDetail({ media, mediaId: 'anilist:154587', type: '电视剧' })
+ await renderDetail({ media, mediaId: '154587', type: '电视剧' })
expect(await screen.findByRole('heading', { name: /葬送的芙莉莲/ })).toBeInTheDocument()
expect(screen.getByText('葬送のフリーレン')).toBeInTheDocument()
@@ -463,7 +516,8 @@ describe('MediaDetailView detail and actions', () => {
query: {
area: 'title',
episode: null,
- keyword: 'tmdb:8501',
+ media_id: '8501',
+ media_source: 'themoviedb',
result_type: 'subtitle',
season: 2,
sites: '',
@@ -488,7 +542,8 @@ describe('MediaDetailView detail and actions', () => {
query: {
area: 'imdbid',
episode: null,
- keyword: 'tmdb:8502',
+ media_id: '8502',
+ media_source: 'themoviedb',
result_type: 'torrent',
season: 3,
sites: '',
@@ -611,6 +666,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
const media = createSubscribeTv({
anilist_id: 154587,
episode_group: 'auxiliary-group',
+ media_id: '154587',
media_source: 'anilist',
title: 'AniList 主来源剧集',
tmdb_id: 8700,
@@ -619,7 +675,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
await renderDetail({
episodeGroupsRequest,
media,
- mediaId: 'anilist:154587',
+ mediaId: '154587',
setupHandlers: () => {
server.use(mediaGroupSeasonsHandler('auxiliary-group', [], 200, groupSeasonsRequest))
},
@@ -634,7 +690,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
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: '电影' })
+ const subscribe = createSubscribe({ id: 18701, media_id: '8701', name: media.title, type: '电影' })
await renderDetail({ media, movieSubscribe: subscribe })
@@ -648,8 +704,8 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
tmdb_id: 8702,
})
const subscribes = [
- createSubscribe({ season: 1, tmdbid: 8702, type: '电视剧' }),
- createSubscribe({ season: 99, tmdbid: 8702, type: '电视剧' }),
+ createSubscribe({ media_id: '8702', season: 1, type: '电视剧' }),
+ createSubscribe({ media_id: '8702', season: 99, type: '电视剧' }),
]
await renderDetail({ media, subscribes, type: '电视剧' })
@@ -668,7 +724,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
title: '全季订阅剧',
tmdb_id: 8703,
})
- const subscribes = [0, 1, 2].map(season => createSubscribe({ season, tmdbid: 8703, type: '电视剧' }))
+ const subscribes = [0, 1, 2].map(season => createSubscribe({ media_id: '8703', season, type: '电视剧' }))
await renderDetail({ media, subscribes, type: '电视剧' })
@@ -685,7 +741,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
title: '缺少特别篇订阅剧',
tmdb_id: 8709,
})
- const subscribes = [1, 2, 99].map(season => createSubscribe({ season, tmdbid: 8709, type: '电视剧' }))
+ const subscribes = [1, 2, 99].map(season => createSubscribe({ media_id: '8709', season, type: '电视剧' }))
await renderDetail({ media, subscribes, type: '电视剧' })
@@ -794,7 +850,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
const refreshed = vi.fn()
await renderDetail({ media })
server.use(
- querySubscribeByMediaHandler('tmdb:8713', {}, 200, refreshed),
+ querySubscribeByMediaHandler('8713', {}, 200, refreshed),
createSubscribeHandler({ data: { id: 18713 }, success: true }),
defaultSubscribeConfigHandler('电影', { show_edit_dialog: true }),
)
diff --git a/src/views/discover/__tests__/PersonCardListView.spec.ts b/src/views/discover/__tests__/PersonCardListView.spec.ts
index 9a4ad236..ec1f7792 100644
--- a/src/views/discover/__tests__/PersonCardListView.spec.ts
+++ b/src/views/discover/__tests__/PersonCardListView.spec.ts
@@ -87,9 +87,9 @@ const LoadingBannerStub = defineComponent({
template: '正在加载人物列表
',
})
-async function renderList() {
+async function renderList(params?: Record) {
return renderWithProviders(PersonCardListView, {
- props: { apipath: LIST_PATH },
+ props: { apipath: LIST_PATH, params },
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
@@ -124,6 +124,22 @@ describe('PersonCardListView', () => {
expect(initialLoadMargins[0]).toBe(0)
})
+ it('serializes media source arrays as repeated query keys without brackets', async () => {
+ const requests: URL[] = []
+ server.use(
+ http.get(LIST_URL, ({ request }) => {
+ requests.push(new URL(request.url))
+ return HttpResponse.json([{ id: 303, name: '多来源人物', source: 'themoviedb' } satisfies Person])
+ }),
+ )
+
+ await renderList({ media_source: ['themoviedb', 'douban'] })
+
+ expect(await screen.findByText('多来源人物')).toBeInTheDocument()
+ expect(requests[0].searchParams.getAll('media_source')).toEqual(['themoviedb', 'douban'])
+ expect(requests[0].searchParams.has('media_source[]')).toBe(false)
+ })
+
it('shows an inline retry and retries the same page after a request failure', async () => {
let requests = 0
server.use(
diff --git a/src/views/subscribe/FullCalendarView.vue b/src/views/subscribe/FullCalendarView.vue
index 39f57fb2..b9ed7f22 100644
--- a/src/views/subscribe/FullCalendarView.vue
+++ b/src/views/subscribe/FullCalendarView.vue
@@ -204,7 +204,10 @@ const mobileFilteredCalendarEvents = computed(() => {
return false
}
- if (mobileSelectedFilterValue.value !== ALL_MOBILE_FILTER_VALUE && event.title !== mobileSelectedFilterValue.value) {
+ if (
+ mobileSelectedFilterValue.value !== ALL_MOBILE_FILTER_VALUE &&
+ event.title !== mobileSelectedFilterValue.value
+ ) {
return false
}
@@ -606,27 +609,32 @@ function getEpisodeTitle(episodeNumbers: number[], episodeTitles: string[]) {
// 生成单个订阅对应的日历事件。
async function eventsHander(subscribe: Subscribe) {
+ if (!subscribe.media_source || !subscribe.media_id) return []
// 如果是电影直接返回
if (subscribe.type === '电影') {
- // 调用API查询TMDB详情
- const movie: MediaInfo = await api.get(`media/tmdb:${subscribe.tmdbid}`, {
- params: { type_name: subscribe.type },
+ const media: MediaInfo = await api.get(`media/${encodeURIComponent(subscribe.media_id)}`, {
+ params: { media_source: subscribe.media_source, type_name: subscribe.type },
})
-
return buildCalendarEventInfo(subscribe, {
subtitle: '',
- start: parseDate(movie.release_date || ''),
+ start: parseDate(media.release_date || ''),
len: 1,
- runtime: movie.runtime,
+ runtime: media.runtime,
episodeNumbers: [],
})
} else {
+ // TMDB 主来源的原生媒体 ID 可直接供单源剧集接口使用;其他来源才需要识别转换。
+ let tmdbId = subscribe.media_source === 'themoviedb' ? Number(subscribe.media_id) : undefined
+ if (!tmdbId) {
+ const media: MediaInfo = await api.get(`media/${encodeURIComponent(subscribe.media_id)}`, {
+ params: { media_source: subscribe.media_source, type_name: subscribe.type },
+ })
+ tmdbId = media.tmdb_id ? Number(media.tmdb_id) : undefined
+ }
+ if (!tmdbId || !Number.isSafeInteger(tmdbId) || tmdbId <= 0) return []
// 调用API查询集信息
const params = subscribe.episode_group ? { episode_group: subscribe.episode_group } : undefined
- const episodes: TmdbEpisode[] = await api.get(
- `tmdb/${subscribe.tmdbid}/${subscribe.season}`,
- params ? { params } : undefined,
- )
+ const episodes: TmdbEpisode[] = await api.get(`tmdb/${tmdbId}/${subscribe.season}`, params ? { params } : undefined)
// 按播出日期聚合 TMDB 剧集。
interface EpisodesDictionary {
@@ -702,7 +710,12 @@ onActivated(() => {
-