refactor(media): unify frontend media identity

This commit is contained in:
jxxghp
2026-08-12 18:32:29 +08:00
parent 1355e3460e
commit 4fce4cd171
89 changed files with 1856 additions and 1087 deletions
@@ -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))
+17
View File
@@ -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: '旧名' },
+7 -1
View File
@@ -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
}
+23 -56
View File
@@ -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
+19 -18
View File
@@ -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,
+1 -1
View File
@@ -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))
}
</script>
+57 -23
View File
@@ -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"
>
<div class="music-card-content">
<div class="music-card-cover-shell">
<VImg v-if="showCover" :src="coverUrl" cover class="music-card-cover" @error="imageLoadError = true">
<template #placeholder>
<VSkeletonLoader class="h-100" />
</template>
</VImg>
<VIcon v-else :icon="entityMeta.icon" size="44" color="medium-emphasis" />
<div class="music-card-cover-column">
<div class="music-card-cover-shell">
<VImg v-if="showCover" :src="coverUrl" cover class="music-card-cover" @error="imageLoadError = true">
<template #placeholder>
<VSkeletonLoader class="h-100" />
</template>
</VImg>
<VIcon v-else :icon="entityMeta.icon" size="44" color="medium-emphasis" />
<VChip :prepend-icon="entityMeta.icon" size="x-small" variant="flat" class="music-card-entity">
{{ entityMeta.label }}
</VChip>
</div>
<div class="music-card-body">
<VChip :prepend-icon="entityMeta.icon" size="x-small" variant="flat" class="music-card-entity">
{{ entityMeta.label }}
</VChip>
</div>
<div class="music-card-source-row">
<VChip
data-testid="music-source"
:color="sourceMeta.color"
:prepend-icon="sourceMeta.icon"
class="music-card-source"
size="x-small"
variant="tonal"
>
{{ sourceLabel }}
</VChip>
</div>
</div>
<div class="music-card-body">
<div class="music-card-heading">
<div class="music-card-title" :title="props.music?.title">{{ props.music?.title }}</div>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="music-card-version">
<VChip
v-if="props.music?.version"
:title="props.music.version"
size="x-small"
variant="tonal"
class="music-card-version"
>
{{ props.music.version }}
</VChip>
</div>
@@ -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;
+7 -11
View File
@@ -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,
+7 -10
View File
@@ -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,
+5 -1
View File
@@ -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<SubtitleInfo>,
width: String,
mediaSource: String as PropType<MediaDataSource>,
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,
+5 -1
View File
@@ -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<SubtitleInfo>,
mediaSource: String as PropType<MediaDataSource>,
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,
@@ -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<string, unknown>) => 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],
],
@@ -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,
@@ -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()
@@ -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,
@@ -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,
+24 -28
View File
@@ -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<TorrentInfo>,
})
// 可选的媒体数据源
const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = [
'themoviedb',
'douban',
'bangumi',
'anilist',
'musicbrainz',
'theaudiodb',
'doubanmusic',
]
// 当前识别类型:优先使用媒体自身的数据源,否则使用全局识别来源
// 当前识别类型:优先使用已随媒体或种子传入的完整身份,否则使用识别上下文。
const mediaSource = computed<MediaDataSource>(() => {
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<MediaInfo, 'music_type'>) {
// 当前数据源对应的原生ID标签。
const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = {
const labels: Partial<Record<MediaDataSource, string>> = {
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
}
@@ -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<MediaDataSource>(
['themoviedb', 'douban', 'bangumi', 'anilist'].includes(globalSettings.RECOGNIZE_SOURCE)
? globalSettings.RECOGNIZE_SOURCE
: 'themoviedb',
)
// 输入参数
const props = defineProps({
title: String,
subtitle: Object as PropType<SubtitleInfo>,
})
const props = defineProps<{
title?: string
subtitle?: SubtitleInfo
mediaSource?: MediaDataSource
mediaId?: string
}>()
const initialMediaId = isMediaDataSource(props.mediaSource) ? props.mediaId?.trim() || undefined : undefined
const mediaSource = ref<MediaDataSource>(
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<TransferDirectoryConf[]>([])
const loading = ref(false)
// 是否显示高级选项
const showAdvancedOptions = ref(false)
const showAdvancedOptions = ref(!initialMediaId)
// 当前数据源的原生媒体ID
const mediaId = ref<string | undefined>(undefined)
const selectedMediaId = ref<string | undefined>(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<Record<MediaDataSource, string>> = {
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<MediaDataSource, string> = {
const labels: Partial<Record<MediaDataSource, string>> = {
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<unknown>, ApiResponse<unknown>>('download/subtitle', payload)
@@ -241,12 +276,24 @@ onMounted(() => {
</VCol>
</VRow>
<VRow v-show="showAdvancedOptions" class="px-5">
<VCol cols="12">
<VCol cols="12" md="5">
<VSelect
v-model="mediaSource"
:items="mediaSourceItems"
:label="t('setting.cache.reidentifyDialog.mediaSource')"
prepend-inner-icon="mdi-database-search"
variant="underlined"
density="comfortable"
/>
</VCol>
<VCol cols="12" md="7">
<VTextField
v-model="mediaId"
v-model="selectedMediaId"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
:rules="[
(value: any) => isValidMediaSourceId(value, mediaSource) || t('dialog.reorganize.mediaIdInvalid'),
]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
@@ -259,13 +306,19 @@ onMounted(() => {
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn variant="elevated" :disabled="loading" @click="addSubtitleDownload" :prepend-icon="icon" class="px-5">
<VBtn
variant="elevated"
:disabled="loading || !hasValidMediaIdentity"
@click="addSubtitleDownload"
:prepend-icon="icon"
class="px-5"
>
{{ buttonText }}
</VBtn>
</VCardText>
</VCard>
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<MediaIdSelector v-model="mediaId" @close="mediaSelectorDialog = false" :type="mediaSource" />
<MediaIdSelector v-model="selectedMediaId" @close="mediaSelectorDialog = false" :type="mediaSource" />
</VDialog>
</VDialog>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MediaDataSource, MusicEntityType } from '@/api/types'
import { isMusicMediaSource } from '@/utils/mediaId'
import { isMediaDataSource, isMusicMediaSource } from '@/utils/mediaId'
const { t } = useI18n()
@@ -35,7 +35,9 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const mediaSource = ref<MediaDataSource>((props.recognizeSource as MediaDataSource) || 'themoviedb')
const mediaSource = ref<MediaDataSource>(
isMediaDataSource(props.recognizeSource) ? props.recognizeSource : 'themoviedb',
)
const mediaId = ref<string>()
const musicType = ref<Exclude<MusicEntityType, 'artist'>>(props.musicType)
const isMusicSelection = computed(() => isMusicMediaSource(mediaSource.value))
@@ -76,9 +78,10 @@ const visible = computed({
// 提交重新识别参数给缓存页执行接口调用。
function submitReidentify() {
const normalizedMediaId = mediaId.value?.trim() || undefined
emit('confirm', {
mediaSource: mediaSource.value,
mediaId: mediaId.value?.trim() || undefined,
mediaSource: normalizedMediaId ? mediaSource.value : undefined,
mediaId: normalizedMediaId,
musicType: isMusicSelection.value ? musicType.value : undefined,
})
}
+7 -10
View File
@@ -95,24 +95,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,
+12 -6
View File
@@ -278,8 +278,8 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
})
// 查询指定 TMDB 剧集的所有剧集组。
async function getEpisodeGroups(tmdbid?: number | string) {
const normalizedTmdbId = Number(tmdbid)
async function getEpisodeGroups(tmdbId?: number | string) {
const normalizedTmdbId = Number(tmdbId)
if (!Number.isInteger(normalizedTmdbId) || normalizedTmdbId <= 0) {
episodeGroups.value = []
return
@@ -350,7 +350,7 @@ const mediaSource = computed(() => transferForm.media_source ?? 'themoviedb')
// 当前数据源对应的原生ID标签。
const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = {
const labels: Partial<Record<MediaDataSource, string>> = {
themoviedb: t('dialog.reorganize.tmdbId'),
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
@@ -359,7 +359,7 @@ const mediaIdLabel = computed(() => {
theaudiodb: 'TheAudioDB ID',
doubanmusic: t('dialog.reorganize.doubanId'),
}
return labels[mediaSource.value]
return labels[mediaSource.value] ?? t('dialog.reorganize.mediaId')
})
// 处理媒体搜索结果选择,同步搜索结果中已识别的媒体类型。
@@ -934,6 +934,7 @@ function getBatchItemsLabel(items: FileItem[]) {
// 构造整理请求
function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; logid?: number; preview?: boolean }) {
const sourceItem = options.item ?? (options.items?.length ? options.items[0] : ({} as FileItem))
const normalizedMediaId = normalizeOptionalText(transferForm.media_id)
const payload: ManualTransferPayload = {
...transferForm,
fileitem: sourceItem,
@@ -941,11 +942,16 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
target_storage: normalizeOptionalText(transferForm.target_storage),
target_path: normalizeTargetPath(transferForm.target_path),
transfer_type: normalizeOptionalText(transferForm.transfer_type),
media_source: mediaSource.value,
media_id: normalizeOptionalText(transferForm.media_id),
media_source: undefined,
media_id: undefined,
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
}
if (normalizedMediaId) {
payload.media_source = mediaSource.value
payload.media_id = normalizedMediaId
}
if (options.items?.length) {
payload.fileitems = options.items
if (!options.item) {
+2 -2
View File
@@ -56,7 +56,7 @@ const dialogSubtitle = computed(() => {
})
const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = {
const labels: Partial<Record<MediaDataSource, string>> = {
themoviedb: t('dialog.reorganize.tmdbId'),
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
@@ -65,7 +65,7 @@ const mediaIdLabel = computed(() => {
theaudiodb: 'TheAudioDB ID',
doubanmusic: t('dialog.reorganize.doubanId'),
}
return labels[mediaSource.value]
return labels[mediaSource.value] ?? t('dialog.reorganize.mediaId')
})
const canSubmit = computed(() => {
@@ -73,7 +73,8 @@ const subscribeForm = ref<Subscribe>({
name: '',
year: '',
type: '',
tmdbid: 0,
media_source: undefined,
media_id: undefined,
state: '',
last_update: '',
username: '',
@@ -141,14 +142,13 @@ function episodeGroupItemProps(item: { title: string; subtitle: string }) {
// 查询所有剧集组
async function getEpisodeGroups() {
// 兼容未记录主来源的旧 TMDB 订阅;明确为其他来源时不使用辅助 TMDB ID 查询剧集组。
if (subscribeForm.value.media_source && subscribeForm.value.media_source !== 'themoviedb') return
if (!subscribeForm.value.tmdbid) {
console.warn('tmdbid is not set or is empty')
if (subscribeForm.value.media_source !== 'themoviedb') return
if (!subscribeForm.value.media_id) {
console.warn('media_id is not set or is empty')
return
}
try {
episodeGroups.value = await api.get(`media/groups/${subscribeForm.value.tmdbid}`)
episodeGroups.value = await api.get(`media/groups/${encodeURIComponent(subscribeForm.value.media_id)}`)
} catch (error) {
console.error(error)
}
@@ -7,7 +7,6 @@ import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import {
getMediaSubscribeId,
getMediaSubscribeIdentity,
type SeasonSubscribeModes,
type SubscribeMode,
@@ -171,16 +170,11 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
return options
})
// 获得mediaid
function getMediaId() {
return getMediaSubscribeId(props.media)
}
// 查询所有剧集组
async function getEpisodeGroups() {
if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb') return
if (!props.media?.tmdb_id) {
console.warn('tmdbid is not set or is empty')
console.warn('tmdb_id is not set or is empty')
return
}
try {
@@ -194,11 +188,14 @@ async function getEpisodeGroups() {
// 查询媒体的季信息
async function getMediaSeasons() {
const identity = getMediaSubscribeIdentity(props.media)
if (!identity) return
isRefreshed.value = false
try {
seasonInfos.value = await api.get('media/seasons', {
params: {
mediaid: getMediaId(),
media_source: identity.source,
media_id: identity.mediaId,
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
@@ -138,7 +138,8 @@ function createTorrent(overrides: Partial<TorrentInfo> = {}): TorrentInfo {
freedate_diff: '',
grabs: 3,
hit_and_run: false,
imdbid: 'tt0060001',
media_id: 'tt0060001',
media_source: 'imdb',
labels: [],
peers: 2,
pri_order: 0,
@@ -158,6 +159,7 @@ function createMedia(overrides: Partial<MediaInfo> = {}): MediaInfo {
return {
episode_run_time: [],
origin_country: [],
media_id: '6001',
media_source: 'themoviedb',
title: '测试电影',
tmdb_id: 6001,
@@ -313,7 +315,10 @@ describe('AddDownloadDialog submissions', () => {
server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({ recognizeSource: 'bangumi' })
await renderDialog({
recognizeSource: 'bangumi',
torrent: createTorrent({ media_id: undefined, media_source: undefined }),
})
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
await user.type(screen.getByLabelText('Bangumi编号'), '24680')
@@ -330,7 +335,7 @@ describe('AddDownloadDialog submissions', () => {
const deferred = createDeferred<JsonBodyType>()
const submitted = vi.fn()
server.use(downloadHandler('download/add', deferred.promise, 200, submitted))
const torrent = createTorrent()
const torrent = createTorrent({ media_id: undefined, media_source: undefined })
const user = userEvent.setup()
const { events } = await renderDialog({
directories: [createDirectory({ download_path: '/downloads/remote', storage: 'rclone' })],
@@ -378,7 +383,12 @@ describe('AddDownloadDialog submissions', () => {
await renderDialog({
recognizeSource: 'themoviedb',
torrent: createTorrent({ category: '音乐', title: '周杰伦 - 叶惠美 FLAC' }),
torrent: createTorrent({
category: '音乐',
media_id: undefined,
media_source: undefined,
title: '周杰伦 - 叶惠美 FLAC',
}),
})
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
@@ -394,6 +404,22 @@ describe('AddDownloadDialog submissions', () => {
})
})
it('uses the source-native identity carried by a torrent without auxiliary ID fallback', async () => {
const submitted = vi.fn()
server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted))
const torrent = createTorrent({ media_id: 'tt0111161', media_source: 'imdb' })
const user = userEvent.setup()
await renderDialog({ torrent })
await user.click(screen.getByRole('button', { name: '开始下载' }))
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
expect(submitted.mock.calls[0][0]).toMatchObject({
media_id: 'tt0111161',
media_source: 'imdb',
})
})
it('uses download/ for an existing media without locking unrelated optional fields', async () => {
const submitted = vi.fn()
server.use(downloadHandler('download/', { data: null, success: true }, 200, submitted))
@@ -1,4 +1,4 @@
import type { SubtitleInfo, TransferDirectoryConf } from '@/api/types'
import type { MediaDataSource, SubtitleInfo, TransferDirectoryConf } from '@/api/types'
import AddSubtitleDownloadDialog from '@/components/dialog/AddSubtitleDownloadDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
@@ -30,7 +30,7 @@ vi.mock('vue-toastification', () => ({
const SelectStub = defineComponent({
name: 'NativeSelectStub',
props: {
items: { type: Array as PropType<string[]>, default: () => [] },
items: { type: Array as PropType<Array<string | { title: string; value: string }>>, default: () => [] },
label: String,
modelValue: { type: String, default: '' },
},
@@ -48,7 +48,11 @@ const SelectStub = defineComponent({
},
[
h('option', { value: '' }, '默认'),
...props.items.map(item => h('option', { key: item, value: item }, item)),
...props.items.map(item => {
const value = typeof item === 'string' ? item : item.value
const title = typeof item === 'string' ? item : item.title
return h('option', { key: value, value }, title)
}),
],
),
])
@@ -164,10 +168,14 @@ function subtitleDownloadHandler(
async function renderDialog({
directories = [],
mediaId = '6001',
mediaSource,
recognizeSource = 'themoviedb',
subtitle = createSubtitle(),
}: {
directories?: TransferDirectoryConf[]
mediaId?: string | null
mediaSource?: MediaDataSource
recognizeSource?: string
subtitle?: SubtitleInfo
} = {}) {
@@ -186,6 +194,7 @@ async function renderDialog({
VCombobox: SelectStub,
VDialog: DialogStub,
VDialogCloseBtn: DialogCloseButtonStub,
VSelect: SelectStub,
VTextField: TextFieldStub,
},
},
@@ -201,6 +210,8 @@ async function renderDialog({
onClose: events.close,
onDone: events.done,
onError: events.error,
mediaId: mediaId ?? undefined,
mediaSource,
subtitle,
title: '测试电影',
},
@@ -269,9 +280,8 @@ describe('AddSubtitleDownloadDialog submissions', () => {
server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({ recognizeSource: 'douban' })
await renderDialog({ mediaId: null, recognizeSource: 'douban' })
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
await user.type(screen.getByLabelText('豆瓣编号'), '13579')
await user.click(screen.getByRole('button', { name: '下载字幕' }))
@@ -291,13 +301,13 @@ describe('AddSubtitleDownloadDialog submissions', () => {
const user = userEvent.setup()
const { events } = await renderDialog({
directories: [createDirectory({ download_path: '/subtitles/remote', storage: 's3' })],
mediaId: null,
recognizeSource: 'anilist',
subtitle,
})
await screen.findByRole('option', { name: 's3:/subtitles/remote' })
await user.selectOptions(screen.getByLabelText('保存目录(自动)'), 's3:/subtitles/remote')
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
await user.click(screen.getByRole('button', { name: '查询媒体编号' }))
await user.click(screen.getByRole('button', { name: '选择媒体编号' }))
const submitButton = screen.getByRole('button', { name: '下载字幕' })
@@ -325,10 +335,25 @@ describe('AddSubtitleDownloadDialog submissions', () => {
expect(submitButton).not.toBeDisabled()
})
it('submits the exact-search identity passed by the resource result', async () => {
const submitted = vi.fn()
server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({ mediaId: '84', mediaSource: 'themoviedb', recognizeSource: 'douban' })
await user.click(screen.getByRole('button', { name: '下载字幕' }))
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
expect(submitted.mock.calls[0][0]).toMatchObject({
media_id: '84',
media_source: 'themoviedb',
})
})
it('treats success:false at HTTP 200 as a business failure', async () => {
server.use(subtitleDownloadHandler({ data: null, message: '签名已过期', success: false }))
const user = userEvent.setup()
const { events } = await renderDialog()
const { events } = await renderDialog({ mediaSource: 'themoviedb' })
await user.click(screen.getByRole('button', { name: '下载字幕' }))
@@ -343,7 +368,7 @@ describe('AddSubtitleDownloadDialog submissions', () => {
it('clears loading and progress after an HTTP failure without emitting done', async () => {
server.use(subtitleDownloadHandler({ message: '服务异常', success: false }, 500))
const user = userEvent.setup()
const { events } = await renderDialog()
const { events } = await renderDialog({ mediaSource: 'themoviedb' })
await user.click(screen.getByRole('button', { name: '下载字幕' }))
@@ -40,4 +40,17 @@ describe('CacheReidentifyDialog', () => {
musicType: 'album',
})
})
it('omits both identity fields when automatic recognition is requested', async () => {
const user = userEvent.setup()
const { confirm } = await renderDialog()
await user.click(screen.getByRole('button', { name: '重新识别' }))
expect(confirm).toHaveBeenCalledWith({
mediaId: undefined,
mediaSource: undefined,
musicType: 'album',
})
})
})
@@ -62,18 +62,11 @@ const PosterStub = defineComponent({
},
})
interface MediaIdentifiers {
anilistid?: number
bangumiid?: number
doubanid?: string
tmdbid?: number
}
const mediaDetailCases: Array<[string, MediaIdentifiers, string]> = [
['TMDB', { tmdbid: 6301 }, 'tmdb:6301'],
['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'],
['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'],
['AniList', { anilistid: 6304, bangumiid: undefined, tmdbid: undefined }, 'anilist:6304'],
const mediaDetailCases = [
['TMDB', 'themoviedb', '6301'],
['Douban', 'douban', 'db-6302'],
['Bangumi', 'bangumi', '6303'],
['AniList', 'anilist', '6304'],
]
function createDeferred() {
@@ -373,32 +366,26 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
expect(events.close).toHaveBeenCalledOnce()
})
it.each(mediaDetailCases)(
'routes %s shares to their media details',
async (_source, identifiers, expectedMediaId) => {
const media: SubscribeShare = {
...createSubscribeShare({
anilistid: identifiers.anilistid,
doubanid: identifiers.doubanid,
tmdbid: identifiers.tmdbid,
}),
bangumiid: identifiers.bangumiid,
}
server.use(followSubscribersSettingHandler([]))
const user = userEvent.setup()
await renderDialog(media)
it.each(mediaDetailCases)('routes %s shares to their media details', async (_source, mediaSource, mediaId) => {
const media = createSubscribeShare({
media_id: mediaId,
media_source: mediaSource as SubscribeShare['media_source'],
})
server.use(followSubscribersSettingHandler([]))
const user = userEvent.setup()
await renderDialog(media)
await user.click(screen.getByRole('button', { name: '查看媒体详情' }))
await user.click(screen.getByRole('button', { name: '查看媒体详情' }))
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid: expectedMediaId,
title: media.name,
type: media.type,
year: media.year,
},
})
},
)
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
media_id: mediaId,
media_source: mediaSource,
title: media.name,
type: media.type,
year: media.year,
},
})
})
})
@@ -485,14 +485,14 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
expect.objectContaining({
episode_group: null,
fileitems: [first, second],
media_id: null,
media_source: 'themoviedb',
target_path: null,
target_storage: null,
transfer_type: null,
}),
])
expect(bodies[0]).not.toHaveProperty('fileitem')
expect(bodies[0]).not.toHaveProperty('media_id')
expect(bodies[0]).not.toHaveProperty('media_source')
expect(mocks.progressControllers).toHaveLength(0)
expect(mocks.toastSuccess).toHaveBeenCalledWith('文件 共 2 项 已加入整理队列!')
})
@@ -728,10 +728,10 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
expect(bodies[1]).toEqual(
expect.objectContaining({
episode_group: null,
media_id: null,
media_source: 'douban',
}),
)
expect(bodies[1]).not.toHaveProperty('media_id')
expect(bodies[1]).not.toHaveProperty('media_source')
})
it('submits an explicit music entity namespace for manual transfer', async () => {
@@ -192,6 +192,26 @@ describe('SearchBarDialog media source selection', () => {
})
})
it('searches music with multiple selected sources', async () => {
const user = userEvent.setup()
const { router } = await renderSearchBar()
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
await user.type(input, 'Coldplay')
const musicItem = getSearchItem('音乐')
const musicGroup = within(musicItem).getByRole('group', { name: '音乐搜索数据源' })
await user.click(within(musicGroup).getByRole('button', { name: '使用 TheAudioDB 搜索' }))
await user.click(musicItem)
await waitFor(() => {
expect(router.currentRoute.value.path).toBe('/music')
expect(router.currentRoute.value.query).toEqual({
query: 'Coldplay',
media_source: 'musicbrainz,theaudiodb',
})
})
})
it('renders the bundled music icon in the music search action', async () => {
const user = userEvent.setup()
await renderSearchBar()
@@ -91,7 +91,8 @@ describe('SubscribeEditDialog', () => {
name: '季度测试剧',
search_imdbid: 0,
season: 2,
tmdbid: 8010,
media_id: '8010',
media_source: 'themoviedb',
type: '电视剧',
})
const episodeGroupsRequested = vi.fn()
@@ -117,7 +118,7 @@ describe('SubscribeEditDialog', () => {
})
it('keeps movie titles free of season suffixes and skips episode groups', async () => {
const record = createSubscribe({ id: 802, name: '电影测试项', season: undefined, tmdbid: 8020, type: '电影' })
const record = createSubscribe({ id: 802, media_id: '8020', name: '电影测试项', season: undefined, type: '电影' })
const episodeGroupsRequested = vi.fn()
server.use(subscribeDetailsHandler(802, record))
useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8020 })
@@ -130,13 +131,11 @@ describe('SubscribeEditDialog', () => {
it('skips episode groups for a non-TMDB subscription with an auxiliary TMDB ID', async () => {
const record = createSubscribe({
anilistid: 154587,
id: 810,
media_id: '154587',
media_source: 'anilist',
name: 'AniList 编辑测试剧',
season: 1,
tmdbid: 8100,
type: '电视剧',
})
const episodeGroupsRequested = vi.fn()
@@ -263,7 +262,6 @@ describe('SubscribeEditDialog', () => {
min_sample_rate: 96000,
music_type: 'album',
name: '音乐音质测试专辑',
tmdbid: 0,
type: '音乐',
})
const updated = vi.fn()
@@ -303,7 +301,8 @@ describe('SubscribeEditDialog', () => {
name: '完整表单测试剧',
search_imdbid: 0,
season: 1,
tmdbid: 8090,
media_id: '8090',
media_source: 'themoviedb',
type: '电视剧',
})
const updated = vi.fn()
@@ -406,7 +405,8 @@ describe('SubscribeEditDialog', () => {
keyword: '旧关键词',
name: '编辑测试剧',
season: 1,
tmdbid: 8030,
media_id: '8030',
media_source: 'themoviedb',
type: '电视剧',
})
const updated = vi.fn()
@@ -439,7 +439,7 @@ describe('SubscribeEditDialog', () => {
['HTTP failure', 500, { message: 'server down', success: false }, '失败编辑项 更新失败:server down'],
])('keeps an edit dialog usable after an update %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const record = createSubscribe({ id: 804, name: '失败编辑项', tmdbid: 8040 })
const record = createSubscribe({ id: 804, media_id: '8040', name: '失败编辑项' })
server.use(subscribeDetailsHandler(804, record), updateSubscribeHandler(response, status))
useDialogOptions({ tmdbId: 8040 })
const { events } = await renderDialog({ subid: 804 })
@@ -454,7 +454,7 @@ describe('SubscribeEditDialog', () => {
})
it('does not delete when confirmation is cancelled', async () => {
const record = createSubscribe({ id: 805, name: '保留订阅', tmdbid: 8050 })
const record = createSubscribe({ id: 805, media_id: '8050', name: '保留订阅' })
const deleted = vi.fn()
server.use(subscribeDetailsHandler(805, record), deleteSubscribeByIdHandler(805, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8050 })
@@ -469,7 +469,7 @@ describe('SubscribeEditDialog', () => {
})
it('emits remove only after a successful deletion', async () => {
const record = createSubscribe({ id: 806, name: '删除订阅', tmdbid: 8060 })
const record = createSubscribe({ id: 806, media_id: '8060', name: '删除订阅' })
const deleted = vi.fn()
server.use(subscribeDetailsHandler(806, record), deleteSubscribeByIdHandler(806, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8060 })
@@ -488,7 +488,7 @@ describe('SubscribeEditDialog', () => {
['HTTP failure', 500, { message: 'server down', success: false }, '删除失败项 取消订阅失败:server down'],
])('keeps the subscription after a delete %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const record = createSubscribe({ id: 807, name: '删除失败项', tmdbid: 8070 })
const record = createSubscribe({ id: 807, media_id: '8070', name: '删除失败项' })
server.use(subscribeDetailsHandler(807, record), deleteSubscribeByIdHandler(807, response, status))
useDialogOptions({ tmdbId: 8070 })
const { events } = await renderDialog({ subid: 807 })
@@ -504,7 +504,7 @@ describe('SubscribeEditDialog', () => {
it('remains editable when an auxiliary options request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const record = createSubscribe({ id: 808, keyword: '仍可编辑', name: '部分失败项', tmdbid: 8080 })
const record = createSubscribe({ id: 808, keyword: '仍可编辑', media_id: '8080', name: '部分失败项' })
const updated = vi.fn()
server.use(subscribeDetailsHandler(808, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({ tmdbId: 8080 })
@@ -34,7 +34,8 @@ function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
show_edit_dialog: false,
sites: [],
state: 'R',
tmdbid: 31010,
media_id: '31010',
media_source: 'themoviedb',
type: '电视剧',
username: 'tester',
year: '2026',
@@ -123,12 +124,7 @@ function setViewport(width: number) {
window.dispatchEvent(new Event('resize'))
}
function useFilesResponse(
id: number,
response: JsonBodyType,
status = 200,
onRequest: (url: URL) => void = () => {},
) {
function useFilesResponse(id: number, response: JsonBodyType, status = 200, onRequest: (url: URL) => void = () => {}) {
server.use(subscribeFilesHandler(id, response, status, onRequest))
}
@@ -205,9 +201,9 @@ describe('SubscribeFilesDialog', () => {
const info = createFilesInfo({
episodes,
subscribe: createSubscribe({
doubanid: 'douban-range-44-48',
media_id: 'douban-range-44-48',
media_source: 'douban',
start_episode: 44,
tmdbid: undefined,
total_episode: 48,
}),
})
@@ -233,7 +229,7 @@ describe('SubscribeFilesDialog', () => {
)
const info = createFilesInfo({
episodes,
subscribe: createSubscribe({ start_episode: 3, tmdbid: 31210, total_episode: 4 }),
subscribe: createSubscribe({ media_id: '31210', start_episode: 3, total_episode: 4 }),
})
useFilesResponse(3121, info as unknown as JsonBodyType)
@@ -437,11 +433,13 @@ describe('SubscribeFilesDialog', () => {
it('distinguishes an HTTP failure from an empty response and offers retry', async () => {
const info = createFilesInfo({ subscribe: createSubscribe({ name: '重试恢复剧', total_episode: 1 }) })
let requestCount = 0
server.use(http.get(subscribeApiUrls.filesById(3102), () => {
requestCount += 1
if (requestCount === 1) return HttpResponse.json({}, { status: 500 })
return HttpResponse.json(info as unknown as JsonBodyType)
}))
server.use(
http.get(subscribeApiUrls.filesById(3102), () => {
requestCount += 1
if (requestCount === 1) return HttpResponse.json({}, { status: 500 })
return HttpResponse.json(info as unknown as JsonBodyType)
}),
)
const user = userEvent.setup()
await renderDialog(3102)
@@ -456,9 +454,11 @@ describe('SubscribeFilesDialog', () => {
it('shows loading until the request resolves', async () => {
const deferred = createDeferred<JsonBodyType>()
server.use(http.get(subscribeApiUrls.filesById(3116), async () => {
return HttpResponse.json(await deferred.promise)
}))
server.use(
http.get(subscribeApiUrls.filesById(3116), async () => {
return HttpResponse.json(await deferred.promise)
}),
)
await renderDialog(3116)
@@ -137,7 +137,8 @@ function createHistory(overrides: Partial<Subscribe> = {}): Subscribe {
show_edit_dialog: false,
sites: [],
state: 'R',
tmdbid: historySeed,
media_id: String(historySeed),
media_source: 'themoviedb',
type: '电影',
username: 'tester',
year: '2026',
@@ -148,7 +148,8 @@ describe('SubscribeSeasonDialog', () => {
expect(seasonRequests).toHaveLength(1)
expect(missingPayloads).toHaveLength(1)
expect(groupRequests).toHaveBeenCalledOnce()
expect(seasonRequests[0].searchParams.get('mediaid')).toBe(`tmdb:${media.tmdb_id}`)
expect(seasonRequests[0].searchParams.get('media_id')).toBe(String(media.media_id))
expect(seasonRequests[0].searchParams.get('media_source')).toBe('themoviedb')
expect(seasonRequests[0].searchParams.get('title')).toBe(media.title)
expect(seasonRequests[0].searchParams.get('year')).toBe(media.year)
expect(seasonRequests[0].searchParams.get('season')).toBe('0')
@@ -220,27 +221,33 @@ describe('SubscribeSeasonDialog', () => {
})
it.each([
['Douban', { douban_id: 'db-7303', media_source: 'douban', tmdb_id: undefined }, 'douban:db-7303'],
['Douban', { douban_id: 'db-7303', media_id: 'db-7303', media_source: 'douban', tmdb_id: undefined }, 'db-7303'],
[
'Bangumi',
{ bangumi_id: 'bgm-7304', douban_id: undefined, media_source: 'bangumi', tmdb_id: undefined },
'bangumi:bgm-7304',
{
bangumi_id: 'bgm-7304',
douban_id: undefined,
media_id: 'bgm-7304',
media_source: 'bangumi',
tmdb_id: undefined,
},
'bgm-7304',
],
[
'AniList',
{ anilist_id: 154587, bangumi_id: undefined, media_source: 'anilist', tmdb_id: undefined },
'anilist:154587',
{ anilist_id: 154587, bangumi_id: undefined, media_id: '154587', media_source: 'anilist', tmdb_id: undefined },
'154587',
],
[
'custom source',
'Bilibili source',
{
bangumi_id: undefined,
douban_id: undefined,
media_id: 'custom-7305',
media_source: 'custom',
media_source: 'bilibili',
tmdb_id: undefined,
},
'custom:custom-7305',
'custom-7305',
],
[
'source-only TMDB',
@@ -251,7 +258,7 @@ describe('SubscribeSeasonDialog', () => {
media_source: 'themoviedb',
tmdb_id: undefined,
},
'tmdb:source-7306',
'source-7306',
],
] as const)('uses the %s media identifier without requesting TMDB groups', async (label, overrides, mediaId) => {
const consoleWarn =
@@ -268,14 +275,15 @@ describe('SubscribeSeasonDialog', () => {
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
await settleRequests()
expect(requested).toHaveBeenCalledOnce()
expect(requested.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId)
if (label === 'source-only TMDB') expect(consoleWarn).toHaveBeenCalledWith('tmdbid is not set or is empty')
expect(requested.mock.calls[0][0].searchParams.get('media_id')).toBe(mediaId)
expect(requested.mock.calls[0][0].searchParams.get('media_source')).toBe(media.media_source)
if (label === 'source-only TMDB') expect(consoleWarn).toHaveBeenCalledWith('tmdb_id is not set or is empty')
})
it.each([
['Douban', { douban_id: 'db-7310', media_source: 'douban' }, 'douban:db-7310'],
['Bangumi', { bangumi_id: 'bgm-7310', media_source: 'bangumi' }, 'bangumi:bgm-7310'],
['AniList', { anilist_id: 154587, media_source: 'anilist' }, 'anilist:154587'],
['Douban', { douban_id: 'db-7310', media_id: 'db-7310', media_source: 'douban' }, 'db-7310'],
['Bangumi', { bangumi_id: 'bgm-7310', media_id: 'bgm-7310', media_source: 'bangumi' }, 'bgm-7310'],
['AniList', { anilist_id: 154587, media_id: '154587', media_source: 'anilist' }, '154587'],
] as const)(
'keeps the %s identity and skips episode groups when an auxiliary TMDB ID exists',
async (_label, overrides, mediaId) => {
@@ -294,7 +302,8 @@ describe('SubscribeSeasonDialog', () => {
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
await settleRequests()
expect(seasonRequest.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId)
expect(seasonRequest.mock.calls[0][0].searchParams.get('media_id')).toBe(mediaId)
expect(seasonRequest.mock.calls[0][0].searchParams.get('media_source')).toBe(media.media_source)
expect(groupRequest).not.toHaveBeenCalled()
expect(groupSeasonsRequest).not.toHaveBeenCalled()
},
@@ -40,7 +40,8 @@ async function renderDialog(season = 2, name = '分享创建测试剧') {
id: 5201,
name,
season,
tmdbid: 52010,
media_id: '52010',
media_source: 'themoviedb',
type: '电视剧',
})
const result = await renderWithProviders(SubscribeShareDialog, {
@@ -197,7 +197,7 @@ describe('TransferQueueDialog', () => {
expect(screen.queryByText('来源 A.mkv')).not.toBeInTheDocument()
})
it('uses canonical built-in and custom identities before falling back to the title', async () => {
it('uses canonical built-in identities before falling back to the title', async () => {
const builtIn = createQueueItem({
id: 7301,
path: '/downloads/built-in.mkv',
@@ -210,7 +210,7 @@ describe('TransferQueueDialog', () => {
title: '自定义来源',
titleYear: '重复标题 (2026)',
})
custom.media.media_source = 'custom-source'
custom.media.media_source = 'bilibili'
custom.media.media_id = 'custom-7302'
const fallback = createQueueItem({
id: 7399,
+4 -8
View File
@@ -8,7 +8,7 @@ const { t } = useI18n()
//
const props = defineProps<{
type?: MediaDataSource
type: MediaDataSource
musicTypes?: MusicEntityType[]
}>()
@@ -68,7 +68,7 @@ async function searchMedias() {
type: isMusicMediaSource(props.type) ? 'music' : 'media',
page: 1,
count: 20,
source: props.type,
media_source: props.type,
},
})
@@ -77,15 +77,11 @@ async function searchMedias() {
//
for (const item of result) {
if (item.media_source !== props.type) continue
if (props.musicTypes?.length && item.music_type && !props.musicTypes.includes(item.music_type)) {
continue
}
const mediaId =
item.media_id ||
item.tmdb_id?.toString() ||
item.douban_id ||
item.bangumi_id?.toString() ||
item.anilist_id?.toString()
const mediaId = item.media_id?.toString().trim()
if (!mediaId) continue
const musicAlbum = item.music_type === 'album' || item.album === item.title ? undefined : item.album
items.value.push({
@@ -20,6 +20,7 @@ describe('MediaIdSelector layout', () => {
mocks.apiGet.mockResolvedValue([
{
media_id: 'tmdb-1',
media_source: 'themoviedb',
overview: '测试简介',
poster_path: '',
title: 'Hello Mini',
@@ -29,6 +30,7 @@ describe('MediaIdSelector layout', () => {
])
const { container } = await renderWithProviders(MediaIdSelector, {
props: { type: 'themoviedb' },
global: {
stubs: {
VDialogCloseBtn: {
@@ -52,6 +54,15 @@ describe('MediaIdSelector layout', () => {
await fireEvent.update(input, 'hello')
await fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Hello Mini2019')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
params: {
count: 20,
media_source: 'themoviedb',
page: 1,
title: 'hello',
type: 'media',
},
})
const results = container.querySelector('.media-id-selector__results')
expect(results).toBeInstanceOf(HTMLElement)
@@ -63,6 +74,7 @@ describe('MediaIdSelector layout', () => {
album: '叶惠美',
artist: '周杰伦',
media_id: 'album-1',
media_source: 'musicbrainz',
music_type: 'album',
title: '叶惠美',
type: '音乐',
@@ -72,6 +84,7 @@ describe('MediaIdSelector layout', () => {
album: '叶惠美',
artist: '周杰伦',
media_id: 'recording-1',
media_source: 'musicbrainz',
music_type: 'recording',
title: '以父之名',
type: '音乐',
@@ -79,6 +92,7 @@ describe('MediaIdSelector layout', () => {
])
const { container } = await renderWithProviders(MediaIdSelector, {
props: { type: 'musicbrainz' },
global: {
stubs: {
VDialogCloseBtn: {
@@ -99,4 +113,68 @@ describe('MediaIdSelector layout', () => {
)
expect(subtitles).toEqual(['音乐 周杰伦', '音乐 周杰伦 · 叶惠美'])
})
it('does not infer a primary identity from auxiliary provider IDs', async () => {
mocks.apiGet.mockResolvedValue([
{
douban_id: 'legacy-douban-id',
title: '仅辅助 ID',
type: '电影',
},
])
await renderWithProviders(MediaIdSelector, {
props: { type: 'douban' },
global: {
stubs: {
VDialogCloseBtn: {
props: ['innerClass'],
template: '<button type="button" :class="innerClass"><slot /></button>',
},
},
},
})
const input = screen.getByPlaceholderText('输入媒体名称')
await fireEvent.update(input, '辅助')
await fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
params: {
count: 20,
media_source: 'douban',
page: 1,
title: '辅助',
type: 'media',
},
})
expect(screen.queryByText('仅辅助 ID')).not.toBeInTheDocument()
})
it('ignores results whose declared source does not match the requested source', async () => {
mocks.apiGet.mockResolvedValue([
{
media_id: '42',
media_source: 'themoviedb',
title: '跨源结果',
type: '电影',
},
])
await renderWithProviders(MediaIdSelector, {
props: { type: 'douban' },
global: {
stubs: {
VDialogCloseBtn: {
props: ['innerClass'],
template: '<button type="button" :class="innerClass"><slot /></button>',
},
},
},
})
const input = screen.getByPlaceholderText('输入媒体名称')
await fireEvent.update(input, '跨源')
await fireEvent.keyDown(input, { key: 'Enter' })
expect(screen.queryByText('跨源结果')).not.toBeInTheDocument()
})
})
@@ -35,6 +35,9 @@ const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(),
}))
const musicBrainzAlbumId = '695f5ac8-cfd5-4e7b-96a0-6d545f5c9f17'
const musicBrainzRecordingId = '977e6978-139d-425c-bb98-6b0c62d1e45e'
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
@@ -173,30 +176,31 @@ function getDialogCall(index = 0) {
describe('media subscribe identifiers and modes', () => {
it.each([
['TMDB before all fallback identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: 10 }, 'tmdb:10'],
['TMDB primary identity', { media_id: '10', media_source: 'themoviedb', tmdb_id: 10 }, 'themoviedb:10'],
['Douban primary identity', { douban_id: '20', media_id: '20', media_source: 'douban', tmdb_id: 10 }, 'douban:20'],
[
'Douban before Bangumi and generic identifiers',
{ bangumi_id: '30', douban_id: '20', tmdb_id: undefined },
'douban:20',
],
[
'Bangumi before a generic identifier',
{ bangumi_id: '30', douban_id: undefined, tmdb_id: undefined },
'Bangumi primary identity',
{ bangumi_id: '30', media_id: '30', media_source: 'bangumi', tmdb_id: 10 },
'bangumi:30',
],
['AniList after Bangumi', { anilist_id: 40, bangumi_id: undefined, tmdb_id: undefined }, 'anilist:40'],
[
'generic identifiers when provider ids are absent',
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', media_source: 'custom', tmdb_id: undefined },
'custom:abc',
'AniList primary identity',
{ anilist_id: 40, media_id: '40', media_source: 'anilist', tmdb_id: 10 },
'anilist:40',
],
])('uses %s', (_case, overrides, expected) => {
[
'fixed non-video provider identity',
{ media_id: 'abc', media_source: 'bilibili', tmdb_id: undefined },
'bilibili:abc',
],
] as const)('uses %s', (_case, overrides, expected) => {
expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected)
})
it('keeps the declared AniList identity when TMDB is only auxiliary data', () => {
const media = createSubscribeTv({
anilist_id: 154587,
media_id: '154587',
media_source: 'anilist',
tmdb_id: 209867,
})
@@ -222,6 +226,11 @@ describe('media subscribe identifiers and modes', () => {
})
})
it('rejects zero as a media identity while keeping optional empty IDs absent', () => {
expect(getMediaSubscribeIdentity(createMediaInfo({ media_id: '0', media_source: 'themoviedb' }))).toBeUndefined()
expect(getMediaSubscribeIdentity(createMediaInfo({ media_id: '', media_source: 'douban' }))).toBeUndefined()
})
it.each([
[{ best_version: false, best_version_full: true }, 'normal'],
[{ best_version: 0, best_version_full: 1 }, 'normal'],
@@ -262,10 +271,8 @@ describe('useMediaSubscribe entry flows', () => {
episode_group: '',
media_id: '101',
media_source: 'themoviedb',
mediaid: 'tmdb:101',
name: '普通电影',
season: null,
tmdbid: 101,
type: '电影',
year: '2025',
})
@@ -278,7 +285,7 @@ describe('useMediaSubscribe entry flows', () => {
it('creates an album subscription with its entity type and complete track count', async () => {
const media = createMediaInfo({
media_id: 'release-group-1',
media_id: musicBrainzAlbumId,
music_type: 'album',
media_source: 'musicbrainz',
title: '叶惠美',
@@ -298,9 +305,8 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({
media_id: 'release-group-1',
media_id: musicBrainzAlbumId,
media_source: 'musicbrainz',
mediaid: 'musicbrainz:release-group-1',
music_type: 'album',
name: '叶惠美',
season: null,
@@ -312,7 +318,7 @@ describe('useMediaSubscribe entry flows', () => {
it('creates a recording subscription without its album track count', async () => {
const media = createMediaInfo({
media_id: 'recording-1',
media_id: musicBrainzRecordingId,
music_type: 'recording',
media_source: 'musicbrainz',
title: '晴天',
@@ -332,7 +338,7 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({
media_id: 'recording-1',
media_id: musicBrainzRecordingId,
media_source: 'musicbrainz',
music_type: 'recording',
name: '晴天',
@@ -360,9 +366,27 @@ describe('useMediaSubscribe entry flows', () => {
expect(mocks.startProgress).not.toHaveBeenCalled()
})
it('does not create a subscription for a zero media ID', async () => {
const media = createMediaInfo({
media_id: '0',
media_source: 'themoviedb',
title: '无效媒体身份',
type: '电影',
})
const created = vi.fn()
server.use(createSubscribeHandler({ data: { id: 504 }, success: true }, 200, created))
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
expect(created).not.toHaveBeenCalled()
expect(mocks.startProgress).not.toHaveBeenCalled()
})
it('creates an AniList subscription without promoting its auxiliary TMDB ID', async () => {
const media = createSubscribeTv({
anilist_id: 154587,
media_id: '154587',
media_source: 'anilist',
title: 'AniList 订阅剧集',
tmdb_id: 209867,
@@ -378,12 +402,9 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({
anilistid: 154587,
media_id: '154587',
media_source: 'anilist',
mediaid: 'anilist:154587',
season: 1,
tmdbid: 209867,
})
})
@@ -469,7 +490,7 @@ describe('useMediaSubscribe entry flows', () => {
it('cancels a subscribed movie from the primary entry', async () => {
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:1032', { success: true }, 200, url => deleted(url)))
server.use(deleteSubscribeByMediaHandler('1032', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
isSubscribed: true,
media: createSubscribeMovie({ title: '主入口取消电影', tmdb_id: 1032 }),
@@ -479,6 +500,7 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.has('season')).toBe(false)
expect((deleted.mock.calls[0][0] as URL).searchParams.get('media_source')).toBe('themoviedb')
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', false)
})
@@ -486,7 +508,7 @@ describe('useMediaSubscribe entry flows', () => {
it('cancels a subscribed TV season only after confirmation', async () => {
const media = createSubscribeTv({ season: 2, title: '取消季剧集', tmdb_id: 104 })
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:104', { success: true }, 200, url => deleted(url)))
server.use(deleteSubscribeByMediaHandler('104', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
isSubscribed: true,
media,
@@ -512,44 +534,57 @@ describe('useMediaSubscribe entry flows', () => {
it.each([
{
label: 'Douban',
media: createSubscribeTv({ douban_id: 'db-1', tmdb_id: undefined }),
mediaId: 'douban:db-1',
record: createSubscribe({ doubanid: 'db-1', season: 2, tmdbid: 0, type: '电视剧' }),
media: createSubscribeTv({ douban_id: 'db-1', media_id: 'db-1', media_source: 'douban', tmdb_id: undefined }),
mediaId: 'db-1',
mediaSource: 'douban',
record: createSubscribe({ media_id: 'db-1', media_source: 'douban', season: 2, type: '电视剧' }),
},
{
label: 'Bangumi',
media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }),
mediaId: 'bangumi:42',
record: createSubscribe({ bangumiid: 42, season: 2, tmdbid: 0, type: '电视剧' }),
media: createSubscribeTv({ bangumi_id: '42', media_id: '42', media_source: 'bangumi', tmdb_id: undefined }),
mediaId: '42',
mediaSource: 'bangumi',
record: createSubscribe({ media_id: '42', media_source: 'bangumi', season: 2, type: '电视剧' }),
},
{
label: 'AniList',
media: createSubscribeTv({ anilist_id: 154587, tmdb_id: undefined }),
mediaId: 'anilist:154587',
record: createSubscribe({ anilistid: 154587, season: 2, tmdbid: 0, type: '电视剧' }),
media: createSubscribeTv({ anilist_id: 154587, media_id: '154587', media_source: 'anilist', tmdb_id: undefined }),
mediaId: '154587',
mediaSource: 'anilist',
record: createSubscribe({ media_id: '154587', media_source: 'anilist', season: 2, type: '电视剧' }),
},
{
label: 'generic provider',
media: createSubscribeTv({ media_id: 'series-1', media_source: 'custom', tmdb_id: undefined }),
mediaId: 'custom:series-1',
record: createSubscribe({ mediaid: 'custom:series-1', season: 2, tmdbid: 0, type: '电视剧' }),
label: 'Bilibili',
media: createSubscribeTv({ media_id: 'series-1', media_source: 'bilibili', tmdb_id: undefined }),
mediaId: 'series-1',
mediaSource: 'bilibili',
record: createSubscribe({ media_id: 'series-1', media_source: 'bilibili', season: 2, type: '电视剧' }),
},
])('queries $label subscriptions through the media endpoint', async ({ media, mediaId, record }) => {
const queried = vi.fn()
server.use(querySubscribeByMediaHandler(mediaId, record, 200, url => queried(url)))
await renderSubscribeHarness({ actionSeason: 2, media })
] as const)(
'queries $label subscriptions through the media endpoint',
async ({ media, mediaId, mediaSource, record }) => {
const queried = vi.fn()
server.use(querySubscribeByMediaHandler(mediaId, record, 200, url => queried(url)))
await renderSubscribeHarness({ actionSeason: 2, media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
expect(queried).toHaveBeenCalledOnce()
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
})
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
expect(queried).toHaveBeenCalledOnce()
expect((queried.mock.calls[0][0] as URL).searchParams.get('media_source')).toBe(mediaSource)
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
},
)
it('cancels a non-TMDB season through the media endpoint', async () => {
const media = createSubscribeTv({ douban_id: 'db-delete', tmdb_id: undefined })
const media = createSubscribeTv({
douban_id: 'db-delete',
media_id: 'db-delete',
media_source: 'douban',
tmdb_id: undefined,
})
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('douban:db-delete', { success: true }, 200, url => deleted(url)))
server.use(deleteSubscribeByMediaHandler('db-delete', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
actionSeason: 2,
isSubscribed: true,
@@ -562,12 +597,13 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect((deleted.mock.calls[0][0] as URL).searchParams.get('media_source')).toBe('douban')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
})
it('queries and cancels music subscriptions with their entity type', async () => {
const media = createMediaInfo({
media_id: 'release-group-1',
media_id: musicBrainzAlbumId,
music_type: 'album',
media_source: 'musicbrainz',
title: '叶惠美',
@@ -578,22 +614,30 @@ describe('useMediaSubscribe entry flows', () => {
const deleted = vi.fn()
server.use(
querySubscribeByMediaHandler(
'musicbrainz:release-group-1',
createSubscribe({ id: 801, music_type: 'album', type: '音乐' }),
musicBrainzAlbumId,
createSubscribe({
id: 801,
media_id: musicBrainzAlbumId,
media_source: 'musicbrainz',
music_type: 'album',
type: '音乐',
}),
200,
url => queried(url),
),
deleteSubscribeByMediaHandler('musicbrainz:release-group-1', { success: true }, 200, url => deleted(url)),
deleteSubscribeByMediaHandler(musicBrainzAlbumId, { success: true }, 200, url => deleted(url)),
)
await renderSubscribeHarness({ isSubscribed: true, media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
expect((queried.mock.calls[0][0] as URL).searchParams.get('music_type')).toBe('album')
expect((queried.mock.calls[0][0] as URL).searchParams.get('media_source')).toBe('musicbrainz')
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('music_type')).toBe('album')
expect((deleted.mock.calls[0][0] as URL).searchParams.get('media_source')).toBe('musicbrainz')
})
it('aligns visible seasons while preserving hidden subscriptions', async () => {
@@ -603,10 +647,10 @@ describe('useMediaSubscribe entry flows', () => {
const updated = vi.fn()
const created = vi.fn()
server.use(
deleteSubscribeByMediaHandler('tmdb:105', { success: true }, 200, url => deleted(url)),
deleteSubscribeByMediaHandler('105', { success: true }, 200, url => deleted(url)),
querySubscribeByMediaHandler(
'tmdb:105',
createSubscribe({ id: 605, season: 2, tmdbid: 105, type: '电视剧' }),
'105',
createSubscribe({ id: 605, media_id: '105', media_source: 'themoviedb', season: 2, type: '电视剧' }),
200,
url => queried(url),
),
@@ -701,7 +745,7 @@ describe('useMediaSubscribe entry flows', () => {
])('keeps subscription state when removal returns a %s', async (_case, status, response) => {
const consoleError = status === 500 ? vi.spyOn(console, 'error').mockImplementation(() => {}) : undefined
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:108', response, status, url => deleted(url)))
server.use(deleteSubscribeByMediaHandler('108', response, status, url => deleted(url)))
await renderSubscribeHarness({
actionSeason: 2,
isSubscribed: true,
@@ -734,7 +778,10 @@ describe('useMediaSubscribe entry flows', () => {
const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 })
const updated = vi.fn()
server.use(
querySubscribeByMediaHandler('tmdb:110', createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' })),
querySubscribeByMediaHandler(
'110',
createSubscribe({ id: 710, media_id: '110', media_source: 'themoviedb', season: 2, type: '电视剧' }),
),
updateSubscribeHandler(response, status, updated),
)
await renderSubscribeHarness({
@@ -763,13 +810,13 @@ describe('useMediaSubscribe entry flows', () => {
it('maps a 404 query to missing and propagates other HTTP errors', async () => {
const media = createSubscribeMovie({ tmdb_id: 109 })
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 404))
server.use(querySubscribeByMediaHandler('109', {}, 404))
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('missing'))
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 500))
server.use(querySubscribeByMediaHandler('109', {}, 500))
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('error'))
})
@@ -78,13 +78,12 @@ async function renderNativeSubscribeHarness(subscribePermission = true) {
}
describe('native subscribe media normalization', () => {
it('normalizes legacy provider aliases and English media types', () => {
it('normalizes the declared media identity and English media types', () => {
const result = normalizeNativeSubscribeMedia({
anilistid: '154587',
bangumiid: 4011,
doubanid: 3601,
anilist_id: 999,
media_id: '154587',
media_source: 'anilist',
title: '测试剧集',
tmdbid: '2501',
type: 'tv',
year: 2026,
})
@@ -92,21 +91,20 @@ describe('native subscribe media normalization', () => {
expect(result).toEqual({
success: true,
media: expect.objectContaining({
anilist_id: 154587,
bangumi_id: '4011',
douban_id: '3601',
media_id: '154587',
media_source: 'anilist',
title: '测试剧集',
tmdb_id: 2501,
type: '电视剧',
year: '2026',
}),
})
if (result.success) expect(result.media).not.toHaveProperty('anilist_id')
})
it('accepts generic source identifiers', () => {
it('accepts another fixed source identifier', () => {
const result = normalizeNativeSubscribeMedia({
media_id: 'subject-42',
media_source: 'custom-source',
media_source: 'bilibili',
title: '自定义媒体',
type: 'movie',
})
@@ -115,7 +113,7 @@ describe('native subscribe media normalization', () => {
success: true,
media: expect.objectContaining({
media_id: 'subject-42',
media_source: 'custom-source',
media_source: 'bilibili',
type: '电影',
}),
})
@@ -126,6 +124,8 @@ describe('native subscribe media normalization', () => {
[{ title: '缺少类型', tmdb_id: 1 }, 'unsupportedType'],
[{ title: '', tmdb_id: 1, type: '电影' }, 'missingTitle'],
[{ title: '缺少ID', type: '电视剧' }, 'missingId'],
[{ title: '仅有旧来源 ID', tmdb_id: 1, type: '电影' }, 'missingId'],
[{ media_id: '1', media_source: 'custom-source', title: '未知来源', type: '电影' }, 'missingId'],
])('rejects invalid input %#', (input, reason) => {
expect(normalizeNativeSubscribeMedia(input)).toEqual({ success: false, reason })
})
@@ -147,7 +147,9 @@ describe('plugin native subscribe flow', () => {
])
const nativeSubscribe = await renderNativeSubscribeHarness()
await expect(nativeSubscribe({ title: '原生选季', tmdbid: 500, type: 'tv' })).resolves.toEqual({ success: true })
await expect(
nativeSubscribe({ media_id: '500', media_source: 'themoviedb', title: '原生选季', type: 'tv' }),
).resolves.toEqual({ success: true })
expect(mocks.apiGet).toHaveBeenCalledWith('subscribe/')
expect(mocks.subscribeOptions?.subscribedSeasons.value).toEqual([1, 3])
@@ -194,7 +196,12 @@ describe('plugin native subscribe flow', () => {
it('returns a structured fallback result when the user lacks subscribe permission', async () => {
const nativeSubscribe = await renderNativeSubscribeHarness(false)
const result = await nativeSubscribe({ title: '无权限', tmdb_id: 700, type: '电影' })
const result = await nativeSubscribe({
media_id: '700',
media_source: 'themoviedb',
title: '无权限',
type: '电影',
})
expect(result).toEqual({
code: 'PERMISSION_DENIED',
+33 -51
View File
@@ -4,10 +4,11 @@ import { useI18n } from 'vue-i18n'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { formatSeason } from '@/@core/utils/formatters'
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
import type { MediaDataSource, MediaInfo, MediaSeason, Subscribe } from '@/api/types'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useConfirm } from '@/composables/useConfirm'
import { setCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
import { isMediaDataSource, isValidMediaSourceId } from '@/utils/mediaId'
export type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
@@ -52,47 +53,20 @@ export type SeasonSubscribeModes = Record<number, SubscribeMode>
export interface MediaSubscribeIdentity {
mediaId: string
mediaKey: string
source: string
source: MediaDataSource
}
/** 按媒体声明的主来源解析订阅身份,避免辅助 ID 覆盖原始识别源。 */
export function getMediaSubscribeIdentity(media?: MediaInfo): MediaSubscribeIdentity | undefined {
if (!media) return undefined
const normalizeSource = (value?: string) => {
const source = (value || '').trim().toLowerCase()
return source === 'tmdb' ? 'themoviedb' : source
const mediaId = media?.media_id === undefined || media.media_id === null ? '' : String(media.media_id).trim()
if (!isMediaDataSource(media?.media_source) || !mediaId || !isValidMediaSourceId(mediaId, media.media_source)) {
return undefined
}
const sourceIds: Record<string, unknown> = {
anilist: media.anilist_id,
bangumi: media.bangumi_id,
douban: media.douban_id,
themoviedb: media.tmdb_id,
return {
mediaId,
mediaKey: `${media.media_source}:${mediaId}`,
source: media.media_source,
}
const buildIdentity = (identitySource: string, value: unknown): MediaSubscribeIdentity | undefined => {
if (value === undefined || value === null || !String(value).trim()) return undefined
const mediaId = String(value).trim()
const prefix = identitySource === 'themoviedb' ? 'tmdb' : identitySource
return {
mediaId,
mediaKey: `${prefix}:${mediaId}`,
source: identitySource,
}
}
const declaredSources = [media.media_source]
.map(normalizeSource)
.filter((source, index, sources) => source && sources.indexOf(source) === index)
for (const source of declaredSources) {
const declaredIdentity = buildIdentity(source, media.media_id ?? sourceIds[source])
if (declaredIdentity) return declaredIdentity
}
for (const fallbackSource of ['themoviedb', 'douban', 'bangumi', 'anilist']) {
const fallbackIdentity = buildIdentity(fallbackSource, sourceIds[fallbackSource])
if (fallbackIdentity) return fallbackIdentity
}
return undefined
}
// 生成跨媒体源稳定的订阅媒体标识。
@@ -164,7 +138,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 获取当前媒体的统一订阅标识。
function getMediaId() {
return getMediaSubscribeId(currentMedia())
return getMediaSubscribeIdentity(currentMedia())
}
// 获取主订阅入口默认对应的季号。
@@ -318,6 +292,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 艺术家仅用于继续浏览,其下作品必须按单曲或专辑分别订阅。
if (!media || media.music_type === 'artist') return
const identity = getMediaSubscribeIdentity(media)
if (!identity) return
startNProgress()
try {
@@ -326,13 +301,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
type: media.type,
// 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422
year: media.year?.toString() ?? '',
tmdbid: media.tmdb_id,
doubanid: media.douban_id,
bangumiid: media.bangumi_id,
anilistid: media.anilist_id,
media_source: identity?.source,
media_id: identity?.mediaId,
mediaid: identity?.mediaKey ?? '',
media_source: identity.source,
media_id: identity.mediaId,
// 专辑订阅必须保留实体类型和曲目总数,后端据此校验整专资源并决定何时完成订阅。
music_type: getMusicSubscribeType(media),
total_tracks: getMusicSubscribeType(media) === 'album' ? media.total_tracks : undefined,
@@ -385,17 +355,23 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const media = currentMedia()
if (!media) return
const identity = getMediaId()
if (!identity) return
let title = media.title ?? ''
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
startNProgress()
try {
const result: { [key: string]: any } = await api.delete(`subscribe/media/${getMediaId()}`, {
params: {
season: media.type === '电影' ? null : season,
music_type: getMusicSubscribeType(media),
const result: { [key: string]: any } = await api.delete(
`subscribe/media/${encodeURIComponent(identity.mediaId)}`,
{
params: {
media_source: identity.source,
season: media.type === '电影' ? null : season,
music_type: getMusicSubscribeType(media),
},
},
})
)
if (result.success) {
updateSubscribeStatus(media.type === '电影' ? null : season, false)
@@ -419,9 +395,12 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 检查当前媒体指定季是否已订阅。
async function checkSubscribe(season: number | null = null) {
const identity = getMediaId()
if (!identity) return false
try {
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
const result: Subscribe = await api.get(`subscribe/media/${encodeURIComponent(identity.mediaId)}`, {
params: {
media_source: identity.source,
season,
title: currentMedia()?.title,
music_type: getMusicSubscribeType(currentMedia()),
@@ -438,9 +417,12 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 查询当前媒体指定季的订阅记录。
async function querySubscribe(season: number | null = null) {
const identity = getMediaId()
if (!identity) return null
try {
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
const result: Subscribe = await api.get(`subscribe/media/${encodeURIComponent(identity.mediaId)}`, {
params: {
media_source: identity.source,
season,
title: currentMedia()?.title,
music_type: getMusicSubscribeType(currentMedia()),
+28 -35
View File
@@ -1,5 +1,5 @@
import api from '@/api'
import type { MediaInfo, Subscribe } from '@/api/types'
import type { MediaDataSource, MediaInfo, Subscribe } from '@/api/types'
import {
getMediaSubscribeId,
getSubscribeMode,
@@ -11,15 +11,27 @@ import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { isMediaDataSource } from '@/utils/mediaId'
export interface NativeSubscribeMediaInfo extends Partial<MediaInfo> {
anilistid?: number | string
bangumiid?: number | string
doubanid?: number | string
media_source?: string
tmdbid?: number | string
type AuxiliaryMediaIdKey =
'tmdb_id' | 'imdb_id' | 'tvdb_id' | 'douban_id' | 'bangumi_id' | 'anilist_id' | 'anidb_id' | 'collection_id'
export type NativeSubscribeMediaInfo = Omit<Partial<MediaInfo>, AuxiliaryMediaIdKey> & {
media_source?: MediaDataSource
}
// 插件输入只接受统一主身份;各来源辅助 ID 仍可存在于后端返回的 MediaInfo 中用于展示。
const auxiliaryMediaIdKeys = new Set<AuxiliaryMediaIdKey>([
'tmdb_id',
'imdb_id',
'tvdb_id',
'douban_id',
'bangumi_id',
'anilist_id',
'anidb_id',
'collection_id',
])
export type NativeSubscribeResult =
| { success: true }
| {
@@ -47,15 +59,6 @@ function normalizeMediaType(value: unknown) {
return undefined
}
/** 将数字或数字字符串转换为有效的正整数媒体 ID。 */
function normalizeNumericId(value: unknown) {
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return undefined
const id = Number(value)
return Number.isSafeInteger(id) && id > 0 ? id : undefined
}
/** 将字符串或数字 ID 转换为非空字符串。 */
function normalizeStringId(value: unknown) {
if (typeof value !== 'string' && typeof value !== 'number') return undefined
@@ -77,37 +80,27 @@ export function normalizeNativeSubscribeMedia(input: unknown): MediaNormalizatio
const title = typeof raw.title === 'string' ? raw.title.trim() : ''
if (!title) return { success: false, reason: 'missingTitle' }
const mediaSource = normalizeStringId(raw.media_source)
const mediaSource = isMediaDataSource(raw.media_source) ? raw.media_source : undefined
const mediaId = normalizeStringId(raw.media_id)
if (!mediaSource || !mediaId) return { success: false, reason: 'missingId' }
const publicFields = Object.fromEntries(
Object.entries(raw).filter(([key]) => !auxiliaryMediaIdKeys.has(key as AuxiliaryMediaIdKey)),
)
const normalizedMedia = {
...raw,
anilist_id: normalizeNumericId(raw.anilist_id ?? raw.anilistid),
bangumi_id: normalizeStringId(raw.bangumi_id ?? raw.bangumiid),
douban_id: normalizeStringId(raw.douban_id ?? raw.doubanid),
media_id: normalizeStringId(raw.media_id),
...publicFields,
media_id: mediaId,
media_source: mediaSource,
title,
tmdb_id: normalizeNumericId(raw.tmdb_id ?? raw.tmdbid),
type,
year: normalizeStringId(raw.year),
} as MediaInfo
if (!getMediaSubscribeId(normalizedMedia)) return { success: false, reason: 'missingId' }
return { success: true, media: normalizedMedia }
}
/** 生成订阅记录的统一媒体标识,用于恢复电视剧已订阅季状态。 */
function getSubscribeRecordMediaId(subscribe: Subscribe) {
if (subscribe.media_source && subscribe.media_id) {
const source = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source
return `${source}:${subscribe.media_id}`
}
if (subscribe.mediaid) return subscribe.mediaid
if (subscribe.tmdbid) return `tmdb:${subscribe.tmdbid}`
if (subscribe.doubanid) return `douban:${subscribe.doubanid}`
if (subscribe.bangumiid) return `bangumi:${subscribe.bangumiid}`
if (subscribe.anilistid) return `anilist:${subscribe.anilistid}`
return ''
return subscribe.media_source && subscribe.media_id ? `${subscribe.media_source}:${subscribe.media_id}` : ''
}
/** 为插件联邦组件创建主程序原生订阅入口。 */
+2 -2
View File
@@ -2476,7 +2476,7 @@ export default {
'Word to replace => Replacement\n' +
'Front word <> Back word >> Episode offset (EP)\n' +
'Word to replace => Replacement && Front word <> Back word >> Episode offset (EP)\n' +
'Replacement format supports: &#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; to directly specify a media data source ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
'Replacement format supports: &#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; to specify a media source and its native ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
identifierSaveSuccess: 'Custom identifiers saved successfully',
identifierSaveFailed: 'Failed to save custom identifiers!',
@@ -3476,7 +3476,7 @@ export default {
customWords: 'Custom Recognition Words',
customWordsHint: 'Recognition words only used for this subscription',
customWordsPlaceholder:
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: &#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; to directly specify a media data source ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: &#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; to specify a media source and its native ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
cancelSubscribe: 'Cancel Subscription',
save: 'Save',
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
+2 -2
View File
@@ -2434,7 +2434,7 @@ export default {
'被替换词 => 替换词\n' +
'前定位词 <> 后定位词 >> 集偏移量(EP\n' +
'被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP\n' +
'其中替换词支持格式:&#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
'其中替换词支持格式:&#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
identifierSaveSuccess: '自定义识别词保存成功',
identifierSaveFailed: '自定义识别词保存失败!',
@@ -3418,7 +3418,7 @@ export default {
customWords: '自定义识别词',
customWordsHint: '只对该订阅使用的识别词',
customWordsPlaceholder:
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:&#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:&#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
cancelSubscribe: '取消订阅',
save: '保存',
cancelSubscribeConfirm: '是否确认取消订阅?',
+2 -2
View File
@@ -2433,7 +2433,7 @@ export default {
'被替換詞 => 替換詞\n' +
'前定位詞 <> 後定位詞 >> 集偏移量(EP\n' +
'被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP\n' +
'其中替換詞支持格式:&#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒體數據源ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
'其中替換詞支持格式:&#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒體數據源ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
identifierSaveSuccess: '自定義識別詞保存成功',
identifierSaveFailed: '自定義識別詞保存失敗!',
@@ -3416,7 +3416,7 @@ export default {
customWords: '自定義識別詞',
customWordsHint: '只對該訂閱使用的識別詞',
customWordsPlaceholder:
'屏蔽詞\n被替換詞 => 替換詞\n前定位詞 <> 後定位詞 >> 集偏移量(EP\n被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n其中替換詞支援格式:&#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒體數據源ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
'屏蔽詞\n被替換詞 => 替換詞\n前定位詞 <> 後定位詞 >> 集偏移量(EP\n被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n其中替換詞支援格式:&#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒體數據源ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
cancelSubscribe: '取消訂閱',
save: '儲存',
cancelSubscribeConfirm: '是否確認取消訂閱?',
+27 -4
View File
@@ -91,11 +91,34 @@ describe('browse page', () => {
await renderBrowse(['person', 'search'], query)
expect(screen.getByRole('heading', { name: '演员: 张三' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: '人物 browse 列表' })).toHaveAttribute(
'data-api-path',
'person/search',
)
expect(screen.getByRole('region', { name: '人物 browse 列表' })).toHaveAttribute('data-api-path', 'person/search')
expect(projectedQuery('人物 browse 查询')).toEqual(query)
expect(screen.queryByRole('region', { name: '媒体 browse 列表' })).not.toBeInTheDocument()
})
it('normalizes only unified media search sources into a deduplicated enum array', async () => {
await renderBrowse(['media', 'search'], {
media_source: 'themoviedb,unknown,douban,themoviedb',
page: '4',
title: '多来源搜索',
type: 'movie',
})
expect(projectedQuery('媒体 browse 查询')).toEqual({
media_source: ['themoviedb', 'douban'],
page: '4',
title: '多来源搜索',
type: 'movie',
})
})
it('drops an invalid media source only from unified media search', async () => {
await renderBrowse(['media', 'search'], {
media_source: 'unknown',
title: '无有效来源',
type: 'movie',
})
expect(projectedQuery('媒体 browse 查询')).toEqual({ title: '无有效来源', type: 'movie' })
})
})
+12 -3
View File
@@ -7,7 +7,8 @@ import { describe, expect, it } from 'vitest'
const MediaDetailViewStub = defineComponent({
name: 'MediaDetailView',
props: {
mediaid: String,
mediaId: String,
mediaSource: String,
title: String,
type: String,
year: String,
@@ -35,14 +36,16 @@ function projectedProps() {
describe('media page', () => {
it('projects route query values as strings to the detail view', async () => {
await renderPage({
mediaid: ['tmdb:101', 'ignored'],
media_id: ['101', 'ignored'],
media_source: 'themoviedb',
title: '测试电影',
type: '电影',
year: '2026',
})
expect(projectedProps()).toEqual({
mediaid: 'tmdb:101,ignored',
mediaId: '101,ignored',
mediaSource: 'themoviedb',
title: '测试电影',
type: '电影',
year: '2026',
@@ -54,4 +57,10 @@ describe('media page', () => {
expect(projectedProps()).toEqual({})
})
it('drops an unknown media source at the route boundary', async () => {
await renderPage({ media_id: '101', media_source: 'custom-source', type: '电影' })
expect(projectedProps()).toEqual({ mediaId: '101', type: '电影' })
})
})
+22 -15
View File
@@ -13,6 +13,11 @@ const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(),
}))
const albumId = '695f5ac8-cfd5-4e7b-96a0-6d545f5c9f17'
const firstRecordingId = '977e6978-139d-425c-bb98-6b0c62d1e45e'
const secondRecordingId = 'be9d9b1b-8c1d-4dbe-85a5-4176dd8e7b6c'
const artistId = 'b47800e7-28e1-4df9-8519-fd4f47a29fc7'
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
@@ -36,12 +41,12 @@ const album = {
album_type: 'Album',
artist: 'Queen',
artists: ['Queen'],
artist_ids: ['artist-1'],
artist_ids: [artistId],
category: 'Album',
cover_url: 'https://coverartarchive.org/release-group/release-group-1/front-500',
cover_url: `https://coverartarchive.org/release-group/${albumId}/front-500`,
duration: 2580,
genres: ['rock', 'art rock'],
media_id: 'release-group-1',
media_id: albumId,
music_type: 'album',
rating: 8.5,
rating_votes: 44,
@@ -61,7 +66,7 @@ const album = {
total_tracks: 2,
tracks: [
{
media_id: 'recording-1',
media_id: firstRecordingId,
title: 'Death on Two Legs',
track_number: 1,
disc_number: 1,
@@ -69,7 +74,7 @@ const album = {
media_source: 'musicbrainz',
},
{
media_id: 'recording-2',
media_id: secondRecordingId,
title: 'Bohemian Rhapsody',
track_number: 2,
disc_number: 1,
@@ -96,7 +101,7 @@ const MediaCardSlideViewStub = defineComponent({
/** 按请求路径分派专辑详情与订阅状态查询。 */
function mockAlbumRequests(subscribed = false) {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/album/release-group-1') return Promise.resolve(album)
if (path === `music/album/${albumId}`) return Promise.resolve(album)
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [13] }, success: true })
@@ -112,7 +117,7 @@ function mockAlbumRequests(subscribed = false) {
/** 渲染专辑详情页,统一提供超级用户权限与路由身份。 */
function renderAlbumPage() {
return renderWithProviders(MusicAlbumPage, {
initialRoute: '/music/album?media_source=musicbrainz&mediaid=release-group-1',
initialRoute: `/music/album?media_source=musicbrainz&media_id=${albumId}`,
initialState: { user: { superUser: true } },
global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
@@ -134,7 +139,7 @@ describe('music album page', () => {
await renderAlbumPage()
expect(await screen.findByRole('heading', { name: 'A Night at the Opera' })).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
expect(mocks.apiGet).toHaveBeenCalledWith(`music/album/${albumId}`, {
params: { media_source: 'musicbrainz' },
})
expect(screen.getByText('Bohemian Rhapsody')).toBeInTheDocument()
@@ -149,7 +154,7 @@ describe('music album page', () => {
await fireEvent.click(await screen.findByText('Bohemian Rhapsody'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'recording-2' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: secondRecordingId })
})
it('opens the artist page from the header artist link', async () => {
@@ -158,7 +163,7 @@ describe('music album page', () => {
await fireEvent.click(await screen.findByRole('link', { name: 'Queen' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: artistId })
})
it('subscribes the whole album from the heart action', async () => {
@@ -170,7 +175,7 @@ describe('music album page', () => {
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: 'release-group-1',
media_id: albumId,
media_source: 'musicbrainz',
name: 'A Night at the Opera',
type: '音乐',
@@ -205,7 +210,8 @@ describe('music album page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:release-group-1',
media_id: albumId,
media_source: 'musicbrainz',
sites: '13',
type: '音乐',
})
@@ -227,7 +233,7 @@ describe('music album page', () => {
})
await renderWithProviders(MusicAlbumPage, {
initialRoute: '/music/album?media_source=doubanmusic&mediaid=1401853',
initialRoute: '/music/album?media_source=doubanmusic&media_id=1401853',
initialState: { user: { superUser: true } },
global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
@@ -267,7 +273,7 @@ describe('music album page', () => {
})
const { router } = await renderWithProviders(MusicAlbumPage, {
initialRoute: `/music/album?media_source=${source}&mediaid=${mediaId}`,
initialRoute: `/music/album?media_source=${source}&media_id=${mediaId}`,
initialState: { user: { superUser: true } },
global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
@@ -299,7 +305,8 @@ describe('music album page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: `${source}:${mediaId}`,
media_id: mediaId,
media_source: source,
music_type: 'album',
sites: '13',
type: '音乐',
+1 -1
View File
@@ -48,7 +48,7 @@ const musicSite = { id: 14, is_active: true, name: '艺术家站点', url: 'http
/** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */
function renderArtistPage() {
return renderWithProviders(MusicArtistPage, {
initialRoute: '/music/artist?media_source=musicbrainz&mediaid=artist-1',
initialRoute: '/music/artist?media_source=musicbrainz&media_id=artist-1',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true } },
})
+25 -19
View File
@@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(),
}))
const recordingId = '977e6978-139d-425c-bb98-6b0c62d1e45e'
const secondRecordingId = 'be9d9b1b-8c1d-4dbe-85a5-4176dd8e7b6c'
const albumId = '695f5ac8-cfd5-4e7b-96a0-6d545f5c9f17'
const artistId = 'b47800e7-28e1-4df9-8519-fd4f47a29fc7'
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
@@ -33,17 +38,17 @@ vi.mock('vue-toastification', () => ({
const recording = {
album: '叶惠美',
album_id: 'release-group-1',
album_id: albumId,
album_artist: '周杰伦',
artist: '周杰伦',
artists: ['周杰伦'],
artist_ids: ['artist-1'],
artist_ids: [artistId],
category: 'Album',
cover_url: 'https://coverartarchive.org/release-group/release-group-1/front-500',
cover_url: `https://coverartarchive.org/release-group/${albumId}/front-500`,
duration: 269,
genres: ['mandopop'],
isrc: 'TWA470301234',
media_id: 'recording-1',
media_id: recordingId,
music_type: 'recording',
release_date: '2003-07-31',
media_source: 'musicbrainz',
@@ -56,8 +61,8 @@ const album = {
album_type: 'Album',
artist: '周杰伦',
artists: ['周杰伦'],
artist_ids: ['artist-1'],
media_id: 'release-group-1',
artist_ids: [artistId],
media_id: albumId,
music_type: 'album',
release_date: '2003-07-31',
media_source: 'musicbrainz',
@@ -65,7 +70,7 @@ const album = {
total_tracks: 2,
tracks: [
{
media_id: 'recording-1',
media_id: recordingId,
title: '晴天',
track_number: 1,
disc_number: 1,
@@ -73,7 +78,7 @@ const album = {
media_source: 'musicbrainz',
},
{
media_id: 'recording-2',
media_id: secondRecordingId,
title: '以父之名',
track_number: 2,
disc_number: 1,
@@ -93,7 +98,7 @@ function mockDetailRequests(subscribed = false) {
return Promise.resolve({ data: { id: 1 }, success: true })
})
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/album/release-group-1') return Promise.resolve(album)
if (path === `music/album/${albumId}`) return Promise.resolve(album)
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [12] }, success: true })
@@ -108,7 +113,7 @@ function mockDetailRequests(subscribed = false) {
/** 渲染音乐详情页,统一提供超级用户权限与路由身份。 */
function renderMusicDetailPage() {
return renderWithProviders(MusicDetailPage, {
initialRoute: '/music/detail?media_source=musicbrainz&mediaid=recording-1&title=晴天',
initialRoute: `/music/detail?media_source=musicbrainz&media_id=${recordingId}&title=晴天`,
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
})
@@ -130,11 +135,11 @@ describe('music detail page', () => {
expect(await screen.findByRole('heading', { name: '晴天' })).toBeInTheDocument()
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
media_source: 'musicbrainz',
media_id: 'recording-1',
media_id: recordingId,
music_type: 'recording',
})
await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
expect(mocks.apiGet).toHaveBeenCalledWith(`music/album/${albumId}`, {
params: { media_source: 'musicbrainz' },
}),
)
@@ -148,7 +153,7 @@ describe('music detail page', () => {
await fireEvent.click(await screen.findByText('叶惠美'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: albumId })
})
it('opens the artist page from the header artist link', async () => {
@@ -157,7 +162,7 @@ describe('music detail page', () => {
await fireEvent.click(await screen.findByRole('link', { name: '周杰伦' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: artistId })
})
it('creates a subscription from the heart action', async () => {
@@ -169,7 +174,7 @@ describe('music detail page', () => {
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: 'recording-1',
media_id: recordingId,
media_source: 'musicbrainz',
name: '晴天',
type: '音乐',
@@ -189,19 +194,19 @@ describe('music detail page', () => {
it('redirects an album identity to the album page', async () => {
mocks.apiPost.mockImplementation((path: string) => {
if (path === 'music/recognize') {
return Promise.resolve({ ...album, media_id: 'release-group-1', title: '叶惠美' })
return Promise.resolve({ ...album, media_id: albumId, title: '叶惠美' })
}
return Promise.resolve({ data: { id: 1 }, success: true })
})
const { router } = await renderWithProviders(MusicDetailPage, {
initialRoute: '/music/detail?media_source=musicbrainz&mediaid=release-group-1',
initialRoute: `/music/detail?media_source=musicbrainz&media_id=${albumId}`,
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
})
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: albumId })
})
it('selects a music-capable site before routing the resource search', async () => {
@@ -221,7 +226,8 @@ describe('music detail page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1',
media_id: recordingId,
media_source: 'musicbrainz',
sites: '12',
type: '音乐',
})
+75 -18
View File
@@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(),
}))
const recordingId = '977e6978-139d-425c-bb98-6b0c62d1e45e'
const albumId = '695f5ac8-cfd5-4e7b-96a0-6d545f5c9f17'
const secondAlbumId = '55b3e279-98e0-44d4-86ad-d68109d6910f'
const artistId = 'b47800e7-28e1-4df9-8519-fd4f47a29fc7'
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
@@ -33,14 +38,14 @@ vi.mock('vue-toastification', () => ({
const musicResult = {
album: '叶惠美',
album_id: 'release-group-1',
album_id: albumId,
album_type: 'Album',
artist: '周杰伦',
artists: ['周杰伦'],
artist_ids: ['artist-1'],
artist_ids: [artistId],
category: 'Album',
duration: 269,
media_id: 'recording-1',
media_id: recordingId,
music_type: 'recording',
release_date: '2003-07-31',
media_source: 'musicbrainz',
@@ -51,13 +56,13 @@ const musicResult = {
const albumResult = {
album: '七里香',
album_id: 'release-group-2',
album_id: secondAlbumId,
album_type: 'Album',
artist: '周杰伦',
artists: ['周杰伦'],
artist_ids: ['artist-1'],
artist_ids: [artistId],
category: 'Album',
media_id: 'release-group-2',
media_id: secondAlbumId,
music_type: 'album',
release_date: '2004-08-03',
media_source: 'musicbrainz',
@@ -68,7 +73,7 @@ const albumResult = {
const artistResult = {
category: 'Person',
media_id: 'artist-1',
media_id: artistId,
music_type: 'artist',
media_source: 'musicbrainz',
title: '周杰伦',
@@ -79,7 +84,7 @@ const artistResult = {
const musicSite = { id: 11, is_active: true, name: '音乐站点', url: 'https://music.example' }
/** 按请求路径分派音乐搜索与订阅状态查询。 */
function mockSearchAndSubscribeState(subscribed: boolean, result = musicResult) {
function mockSearchAndSubscribeState(subscribed: boolean, result: Record<string, unknown> = musicResult) {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'media/search') return Promise.resolve([result])
if (path === 'site/media/music') return Promise.resolve([musicSite])
@@ -119,6 +124,7 @@ describe('music page', () => {
await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
params: { type: 'music', count: 30, title: '晴天' },
paramsSerializer: { indexes: null },
}),
)
expect(screen.queryByRole('button', { name: '搜索音乐' })).not.toBeInTheDocument()
@@ -134,11 +140,45 @@ describe('music page', () => {
await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
params: { type: 'music', count: 30, title: 'Coldplay', media_source: 'theaudiodb' },
params: { type: 'music', count: 30, title: 'Coldplay', media_source: ['theaudiodb'] },
paramsSerializer: { indexes: null },
}),
)
})
it('forwards multiple music sources and renders mixed-source results', async () => {
const theAudioDbResult = {
...albumResult,
media_id: 'album-2109619',
media_source: 'theaudiodb',
title: 'Parachutes',
}
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'media/search') return Promise.resolve([musicResult, theAudioDbResult])
if (path.startsWith('subscribe/media/')) return Promise.reject({ response: { status: 404 } })
return Promise.resolve([])
})
await renderMusicPage('/music?query=Coldplay&media_source=musicbrainz,unknown,theaudiodb,musicbrainz')
await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
params: {
type: 'music',
count: 30,
title: 'Coldplay',
media_source: ['musicbrainz', 'theaudiodb'],
},
paramsSerializer: { indexes: null },
}),
)
expect(await screen.findByText('晴天')).toBeInTheDocument()
expect(screen.getByText('Parachutes')).toBeInTheDocument()
expect(screen.getAllByTestId('music-source').map(item => item.textContent)).toEqual(
expect.arrayContaining([expect.stringContaining('MusicBrainz'), expect.stringContaining('TheAudioDB')]),
)
})
it('shows album, artist, release date and duration on the result card', async () => {
await renderMusicPage()
@@ -149,7 +189,22 @@ describe('music page', () => {
expect(screen.getByText('2003-07-31')).toBeInTheDocument()
expect(screen.getByText('4:29')).toBeInTheDocument()
expect(screen.getByText('Album')).toBeInTheDocument()
expect(screen.getByTestId('music-source')).toHaveTextContent('MusicBrainz')
const source = screen.getByTestId('music-source')
expect(source).toHaveTextContent('MusicBrainz')
expect(source.closest('.music-card-cover-column')).not.toBeNull()
expect(source.closest('.music-card-body')).toBeNull()
})
it('keeps a long title-side label constrained without hiding its full value', async () => {
const longVersion = 'Taiwanese singer-songwriter and multi-instrumentalist with a very long biography label'
mockSearchAndSubscribeState(false, { ...artistResult, version: longVersion })
const { container } = await renderMusicPage()
const version = await screen.findByTitle(longVersion)
expect(version).toHaveClass('music-card-version')
expect(version).toHaveAttribute('title', longVersion)
expect(version.closest('.music-card-heading')).toBe(container.querySelector('.music-card-heading'))
})
it('uses three columns from the desktop breakpoint', async () => {
@@ -181,7 +236,7 @@ describe('music page', () => {
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: 'recording-1',
media_id: recordingId,
media_source: 'musicbrainz',
name: '晴天',
type: '音乐',
@@ -207,7 +262,7 @@ describe('music page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail'))
expect(router.currentRoute.value.query).toMatchObject({
media_source: 'musicbrainz',
mediaid: 'recording-1',
media_id: recordingId,
title: '晴天',
})
})
@@ -218,7 +273,7 @@ describe('music page', () => {
await fireEvent.click(await screen.findByText('叶惠美'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: albumId })
})
it('opens the artist page from the result card artist link', async () => {
@@ -227,7 +282,7 @@ describe('music page', () => {
await fireEvent.click(await screen.findByText('周杰伦'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: artistId })
})
it('renders album and artist search entities with entity-correct actions and routes', async () => {
@@ -246,14 +301,14 @@ describe('music page', () => {
await fireEvent.click(screen.getByText('七里香'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-2' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: secondAlbumId })
await router.push('/music?query=晴天')
const restoredArtistEntity = await screen.findByText('艺术家')
const restoredArtistCard = restoredArtistEntity.closest('.music-card')
await fireEvent.click(within(restoredArtistCard as HTMLElement).getByText('周杰伦'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
expect(router.currentRoute.value.query).toMatchObject({ media_id: artistId })
})
it('selects a music-capable site before routing the resource search', async () => {
@@ -276,7 +331,8 @@ describe('music page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1',
media_id: recordingId,
media_source: 'musicbrainz',
sites: '11',
type: '音乐',
})
@@ -325,7 +381,8 @@ describe('music page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: `${source}:${mediaId}`,
media_id: mediaId,
media_source: source,
music_type: 'album',
sites: '11',
type: '音乐',
+76 -12
View File
@@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({
useDynamicButton: vi.fn(),
}))
const musicBrainzAlbumId = '695f5ac8-cfd5-4e7b-96a0-6d545f5c9f17'
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -158,15 +160,21 @@ const TorrentItemStub = defineComponent({
const SubtitleCardStub = defineComponent({
props: {
subtitle: { type: Object, required: true },
mediaSource: String,
mediaId: String,
},
template: '<article data-testid="subtitle-card">{{ subtitle.title }}</article>',
template:
'<article data-testid="subtitle-card" :data-media-source="mediaSource || \'\'" :data-media-id="mediaId || \'\'">{{ subtitle.title }}</article>',
})
const SubtitleItemStub = defineComponent({
props: {
subtitle: { type: Object, required: true },
mediaSource: String,
mediaId: String,
},
template: '<article data-testid="subtitle-row">{{ subtitle.title }}</article>',
template:
'<article data-testid="subtitle-row" :data-media-source="mediaSource || \'\'" :data-media-id="mediaId || \'\'">{{ subtitle.title }}</article>',
})
const ProgressiveCardGridStub = defineComponent({
@@ -294,9 +302,10 @@ const searchRouteCases: SearchRouteCase[] = [
streamParams: { keyword: '普通标题', sites: '1,2' },
},
{
apiEndpoint: 'search/media/tmdb:42',
apiEndpoint: 'search/media/42',
apiParams: {
area: 'CN',
media_source: 'themoviedb',
mtype: '电视剧',
season: '2',
sites: '1',
@@ -304,10 +313,11 @@ const searchRouteCases: SearchRouteCase[] = [
year: '2025',
},
displayTitle: '媒体资源结果',
expectedPath: '/api/v1/search/media/tmdb%3A42/stream',
expectedPath: '/api/v1/search/media/42/stream',
query: {
area: 'CN',
keyword: 'tmdb:42',
media_id: '42',
media_source: 'themoviedb',
result_type: 'torrent',
season: '2',
sites: '1',
@@ -318,6 +328,7 @@ const searchRouteCases: SearchRouteCase[] = [
result: createTorrent({ title: '媒体资源结果' }),
streamParams: {
area: 'CN',
media_source: 'themoviedb',
mtype: '电视剧',
season: '2',
sites: '1',
@@ -326,19 +337,21 @@ const searchRouteCases: SearchRouteCase[] = [
},
},
{
apiEndpoint: 'search/media/musicbrainz:album-1',
apiEndpoint: `search/media/${musicBrainzAlbumId}`,
apiParams: {
area: 'title',
media_source: 'musicbrainz',
mtype: '音乐',
music_type: 'album',
title: '叶惠美',
year: '2003',
},
displayTitle: '专辑资源结果',
expectedPath: '/api/v1/search/media/musicbrainz%3Aalbum-1/stream',
expectedPath: `/api/v1/search/media/${musicBrainzAlbumId}/stream`,
query: {
area: 'title',
keyword: 'musicbrainz:album-1',
media_id: musicBrainzAlbumId,
media_source: 'musicbrainz',
music_type: 'album',
result_type: 'torrent',
title: '叶惠美',
@@ -348,6 +361,7 @@ const searchRouteCases: SearchRouteCase[] = [
result: createTorrent({ title: '专辑资源结果' }),
streamParams: {
area: 'title',
media_source: 'musicbrainz',
mtype: '音乐',
music_type: 'album',
title: '叶惠美',
@@ -364,9 +378,10 @@ const searchRouteCases: SearchRouteCase[] = [
streamParams: { keyword: '字幕标题', sites: '2' },
},
{
apiEndpoint: 'search/subtitle/media/tmdb:84',
apiEndpoint: 'search/subtitle/media/84',
apiParams: {
episode: '3',
media_source: 'themoviedb',
mtype: '电视剧',
season: '2',
sites: '2',
@@ -374,10 +389,11 @@ const searchRouteCases: SearchRouteCase[] = [
year: '2025',
},
displayTitle: '字幕媒体结果',
expectedPath: '/api/v1/search/subtitle/media/tmdb%3A84/stream',
expectedPath: '/api/v1/search/subtitle/media/84/stream',
query: {
episode: '3',
keyword: 'tmdb:84',
media_id: '84',
media_source: 'themoviedb',
result_type: 'subtitle',
season: '2',
sites: '2',
@@ -388,6 +404,7 @@ const searchRouteCases: SearchRouteCase[] = [
result: createSubtitle('字幕媒体结果'),
streamParams: {
episode: '3',
media_source: 'themoviedb',
mtype: '电视剧',
season: '2',
sites: '2',
@@ -439,13 +456,19 @@ describe('resource page search flow', () => {
}),
)
expect(await screen.findByText(displayTitle)).toBeInTheDocument()
if (query.result_type === 'subtitle') {
expect(screen.getByTestId('subtitle-card')).toHaveAttribute('data-media-source', query.media_source ?? '')
expect(screen.getByTestId('subtitle-card')).toHaveAttribute('data-media-id', query.media_id ?? '')
}
await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
const storedParams = JSON.parse(localStorage.getItem('MP_ResourceSearchParams') || '{}')
expect(storedParams).toEqual({
area: query.area ?? '',
episode: query.episode ?? '',
keyword: query.keyword,
keyword: query.keyword ?? '',
media_id: query.media_id ?? '',
media_source: query.media_source ?? '',
music_type: query.music_type ?? '',
result_type: query.result_type === 'subtitle' ? 'subtitle' : 'torrent',
season: query.season ?? '',
@@ -477,6 +500,8 @@ describe('resource page search flow', () => {
area: '',
episode: '',
keyword: '上次关键词',
media_id: '',
media_source: '',
music_type: '',
result_type: 'torrent',
season: '',
@@ -487,6 +512,45 @@ describe('resource page search flow', () => {
})
})
it('migrates a legacy composite media keyword only when restoring local search state', async () => {
localStorage.setItem(
'MP_ResourceSearchParams',
JSON.stringify({ keyword: 'tmdb:77', result_type: 'torrent', sites: '6', title: '旧版媒体' }),
)
await renderResource()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('search/last/context'))
const refreshPromise = mocks.keepAliveRefresh()
const source = await latestEventSource()
const streamUrl = new URL(source.url)
expect(streamUrl.pathname).toBe('/api/v1/search/media/77/stream')
expect(Object.fromEntries(streamUrl.searchParams)).toMatchObject({
media_source: 'themoviedb',
sites: '6',
title: '旧版媒体',
})
finishStream(source, [])
await refreshPromise
expect(JSON.parse(localStorage.getItem('MP_ResourceSearchParams') || '{}')).toMatchObject({
keyword: '',
media_id: '77',
media_source: 'themoviedb',
})
})
it('ignores a zero media ID instead of starting a media identity search', async () => {
await renderResource({
path: '/resource',
query: { media_id: '0', media_source: 'themoviedb', result_type: 'torrent' },
})
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('search/last/context'))
expect(EventSourceFake.instances).toHaveLength(0)
expect(localStorage.getItem('MP_ResourceSearchParams')).toBeNull()
})
it('does not automatically repeat a completed empty search when KeepAlive reactivates', async () => {
const rendered = await renderResource({
path: '/resource',
+15 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
import { parseMediaDataSources } from '@/utils/mediaId'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -20,16 +21,28 @@ let title = route.query?.title?.toString()
const type = route.query?.type?.toString()
if (type === 'person') title = t('browse.actor') + ': ' + title
/** 将路由段转换为后端 API 路径。 */
function getApiPath(paths: string[]) {
return paths.join('/')
}
// browse
const requestParams = computed<Record<string, unknown>>(() => {
const params: Record<string, unknown> = { ...route.query }
if (getApiPath(props.paths) !== 'media/search') return params
const mediaSources = parseMediaDataSources(route.query.media_source)
if (mediaSources.length > 0) params.media_source = mediaSources
else delete params.media_source
return params
})
</script>
<template>
<div>
<VPageContentTitle :title="title" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths)" :params="route.query" />
<MediaCardListView v-else :apipath="getApiPath(props.paths)" :params="route.query" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths)" :params="requestParams" />
<MediaCardListView v-else :apipath="getApiPath(props.paths)" :params="requestParams" />
<Teleport to="body">
<VScrollToTopBtn />
</Teleport>
+11 -6
View File
@@ -1,24 +1,29 @@
<script setup lang="ts">
import MediaDetailView from '@/views/discover/MediaDetailView.vue'
import { isMediaDataSource } from '@/utils/mediaId'
//
const route = useRoute()
// TMDB ID
const mediaid = route.query?.mediaid?.toString()
//
const mediaSource = computed(() => {
const source = route.query?.media_source?.toString()
return isMediaDataSource(source) ? source : undefined
})
const mediaId = computed(() => route.query?.media_id?.toString())
//
const type = route.query?.type?.toString()
const type = computed(() => route.query?.type?.toString())
//
const title = route.query?.title?.toString()
const title = computed(() => route.query?.title?.toString())
//
const year = route.query?.year?.toString()
const year = computed(() => route.query?.year?.toString())
</script>
<template>
<div>
<MediaDetailView :mediaid="mediaid" :type="type" :title="title" :year="year" />
<MediaDetailView :media-source="mediaSource" :media-id="mediaId" :type="type" :title="title" :year="year" />
</div>
</template>
+7 -3
View File
@@ -1,18 +1,22 @@
<script setup lang="ts">
import MusicAlbumView from '@/views/discover/MusicAlbumView.vue'
import { isMediaDataSource } from '@/utils/mediaId'
//
const route = useRoute()
// ID
const mediaid = computed(() => route.query?.mediaid?.toString())
const mediaId = computed(() => route.query?.media_id?.toString())
//
const mediaSource = computed(() => route.query?.media_source?.toString() || 'musicbrainz')
const mediaSource = computed(() => {
const source = route.query?.media_source?.toString()
return isMediaDataSource(source) ? source : undefined
})
</script>
<template>
<div>
<MusicAlbumView :mediaid="mediaid" :media-source="mediaSource" />
<MusicAlbumView :media-id="mediaId" :media-source="mediaSource" />
</div>
</template>
+7 -3
View File
@@ -1,18 +1,22 @@
<script setup lang="ts">
import MusicArtistView from '@/views/discover/MusicArtistView.vue'
import { isMediaDataSource } from '@/utils/mediaId'
//
const route = useRoute()
// ID
const mediaid = computed(() => route.query?.mediaid?.toString())
const mediaId = computed(() => route.query?.media_id?.toString())
//
const mediaSource = computed(() => route.query?.media_source?.toString() || 'musicbrainz')
const mediaSource = computed(() => {
const source = route.query?.media_source?.toString()
return isMediaDataSource(source) ? source : undefined
})
</script>
<template>
<div>
<MusicArtistView :mediaid="mediaid" :media-source="mediaSource" />
<MusicArtistView :media-id="mediaId" :media-source="mediaSource" />
</div>
</template>
+7 -3
View File
@@ -1,18 +1,22 @@
<script setup lang="ts">
import MusicDetailView from '@/views/discover/MusicDetailView.vue'
import { isMediaDataSource } from '@/utils/mediaId'
//
const route = useRoute()
// ID
const mediaid = computed(() => route.query?.mediaid?.toString())
const mediaId = computed(() => route.query?.media_id?.toString())
//
const mediaSource = computed(() => route.query?.media_source?.toString() || 'musicbrainz')
const mediaSource = computed(() => {
const source = route.query?.media_source?.toString()
return isMediaDataSource(source) ? source : undefined
})
</script>
<template>
<div>
<MusicDetailView :mediaid="mediaid" :media-source="mediaSource" />
<MusicDetailView :media-id="mediaId" :media-source="mediaSource" />
</div>
</template>
+11 -6
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import type { MediaDataSource, MediaInfo } from '@/api/types'
import MusicCard from '@/components/cards/MusicCard.vue'
import { useI18n } from 'vue-i18n'
import { parseMediaDataSources } from '@/utils/mediaId'
import { getMusicKey } from '@/utils/music'
const { t } = useI18n()
@@ -14,7 +15,7 @@ const results = ref<MediaInfo[]>([])
//
const query = computed(() => route.query.query?.toString().trim() || '')
const mediaSource = computed(() => route.query.media_source?.toString().trim() || '')
const mediaSources = computed(() => parseMediaDataSources(route.query.media_source))
/** 调用统一音乐元数据接口搜索候选。 */
async function searchMusic() {
@@ -27,13 +28,17 @@ async function searchMusic() {
loading.value = true
searched.value = true
try {
const params: Record<string, string | number> = {
const params: Record<string, string | number | MediaDataSource[]> = {
title: query.value,
type: 'music',
count: 30,
}
if (mediaSource.value) params.media_source = mediaSource.value
results.value = (await api.get('media/search', { params })) || []
if (mediaSources.value.length > 0) params.media_source = mediaSources.value
results.value =
(await api.get('media/search', {
params,
paramsSerializer: { indexes: null },
})) || []
} catch (error) {
console.error(error)
results.value = []
@@ -42,7 +47,7 @@ async function searchMusic() {
}
}
watch([query, mediaSource], searchMusic, { immediate: true })
watch([query, mediaSources], searchMusic, { immediate: true })
</script>
<template>
+81 -16
View File
@@ -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<SearchParams> | 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<SearchParams> | 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<SearchParams>): Partial<SearchParams> {
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<string, MediaDataSource> = {
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<SearchParams>)
const storedParams = JSON.parse(rawParams) as Partial<SearchParams>
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:xxxtmdb: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 :xxxxID
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"
/>
</div>
@@ -1572,7 +1625,11 @@ onUnmounted(() => {
:estimated-item-height="320"
>
<template #default="{ item }">
<SubtitleCard :subtitle="item" />
<SubtitleCard
:subtitle="item"
:media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined"
/>
</template>
</ProgressiveCardGrid>
<div
@@ -1635,7 +1692,11 @@ onUnmounted(() => {
:key="getSubtitleItemKey(item, index)"
class="stream-result-item"
>
<SubtitleItem :subtitle="item" />
<SubtitleItem
:subtitle="item"
:media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined"
/>
<VDivider v-if="index < streamPreviewSubtitleDataList.length - 1" class="my-2" />
</div>
</div>
@@ -1649,7 +1710,11 @@ onUnmounted(() => {
:get-item-key="getSubtitleItemKey"
>
<template #default="{ item, index }">
<SubtitleItem :subtitle="item" />
<SubtitleItem
:subtitle="item"
:media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined"
/>
<VDivider v-if="index < rawSubtitleDataList.length - 1" class="my-2" />
</template>
</ProgressiveCardGrid>
+23 -3
View File
@@ -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)
})
})
+10 -9
View File
@@ -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: '音乐',
+25 -7
View File
@@ -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
}
+19 -10
View File
@@ -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,
+1 -1
View File
@@ -73,7 +73,7 @@ const backgroundTasks = computed<BackgroundTaskItem[]>(() => {
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'),
+6 -3
View File
@@ -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,
@@ -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',
+4 -2
View File
@@ -43,7 +43,7 @@ const seenKeys = new Set<string>()
const seenPageSignatures = new Set<string>()
//
function getParams() {
function getParams(): Record<string, unknown> {
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)
+1 -3
View File
@@ -106,9 +106,7 @@ onActivated(() => {
<VirtualSlideView
:items="dataList"
:loading="!componentLoaded"
:get-item-key="
item => item.media_id || item.tmdb_id || item.douban_id || item.bangumi_id || item.anilist_id || item.title
"
:get-item-key="item => `${item.media_source || 'unknown'}:${item.media_id || item.title || ''}`"
>
<template #item="{ item }">
<MediaCard :media="item" width="9rem" />
+55 -55
View File
@@ -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,
+20 -18
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import type { MediaInfo, MusicAlbumInfo } from '@/api/types'
import type { MediaDataSource, MediaInfo, MusicAlbumInfo } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
@@ -21,15 +21,12 @@ import {
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
const props = defineProps<{
// ID
mediaid: String,
mediaId?: string
//
mediaSource: {
type: String,
default: 'musicbrainz',
},
})
mediaSource?: MediaDataSource
}>()
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
@@ -44,11 +41,12 @@ const isSubscribed = ref(false)
const artistLinks = computed(() => getMusicArtistLinks(album.value))
//
const supportsArtistBrowsing = computed(() => ['musicbrainz', 'theaudiodb'].includes(props.mediaSource))
const supportsArtistBrowsing = computed(() => props.mediaSource === 'musicbrainz' || props.mediaSource === 'theaudiodb')
const primaryArtistId = computed(() =>
supportsArtistBrowsing.value ? artistLinks.value.find(artist => artist.id)?.id : undefined,
)
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
const encodedMediaSource = computed(() => (props.mediaSource ? encodeURIComponent(props.mediaSource) : ''))
//
const albumMedia = computed<MediaInfo | undefined>(() => {
@@ -87,10 +85,14 @@ const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
/** 加载专辑详情、曲目列表和发行版本。 */
async function loadAlbumDetail() {
if (!props.mediaSource || !props.mediaid) return
if (!props.mediaSource || !props.mediaId) {
album.value = undefined
isRefreshed.value = true
return
}
isRefreshed.value = false
try {
album.value = await api.get(`music/album/${props.mediaid}`, { params: { media_source: props.mediaSource } })
album.value = await api.get(`music/album/${props.mediaId}`, { params: { media_source: props.mediaSource } })
} catch (error) {
console.error(error)
album.value = undefined
@@ -112,7 +114,7 @@ async function checkSubscribeStatus() {
/** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) {
if (!artistId) return
if (!artistId || !props.mediaSource) return
router.push(buildMusicArtistRoute(artistId, name, props.mediaSource))
}
@@ -121,7 +123,7 @@ function getReleaseSubtitle(release: NonNullable<MusicAlbumInfo['releases']>[num
return [release.formats?.join(' + '), release.country, release.packaging, release.status].filter(Boolean).join(' · ')
}
watch(() => [props.mediaSource, props.mediaid], loadAlbumDetail, { immediate: true })
watch(() => [props.mediaSource, props.mediaId], loadAlbumDetail, { immediate: true })
</script>
<template>
@@ -231,10 +233,10 @@ watch(() => [props.mediaSource, props.mediaid], loadAlbumDetail, { immediate: tr
</template>
</template>
<div v-if="primaryArtistId" class="music-section">
<div v-if="primaryArtistId && props.mediaSource" class="music-section">
<MediaCardSlideView
:apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
:apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodedMediaSource}`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodedMediaSource}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
:title="t('music.artistAlbums')"
/>
</div>
@@ -248,8 +250,8 @@ watch(() => [props.mediaSource, props.mediaid], loadAlbumDetail, { immediate: tr
<div v-if="props.mediaSource === 'doubanmusic'" class="music-section">
<MediaCardSlideView
:apipath="`music/album/${props.mediaid}/related?media_source=doubanmusic`"
:linkurl="`/browse/music/album/${props.mediaid}/related?media_source=doubanmusic&title=${encodeURIComponent(t('music.relatedAlbums'))}`"
:apipath="`music/album/${props.mediaId}/related?media_source=doubanmusic`"
:linkurl="`/browse/music/album/${props.mediaId}/related?media_source=doubanmusic&title=${encodeURIComponent(t('music.relatedAlbums'))}`"
:title="t('music.relatedAlbums')"
/>
</div>
+17 -14
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import type { MusicArtistInfo } from '@/api/types'
import type { MediaDataSource, MusicArtistInfo } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
@@ -12,15 +12,12 @@ import { getMusicSourceLabel } from '@/utils/music'
const { t } = useI18n()
const props = defineProps({
const props = defineProps<{
// ID
mediaid: String,
mediaId?: string
//
mediaSource: {
type: String,
default: 'musicbrainz',
},
})
mediaSource?: MediaDataSource
}>()
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
@@ -29,6 +26,7 @@ const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const isRefreshed = ref(false)
const artist = ref<MusicArtistInfo>()
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
const encodedMediaSource = computed(() => (props.mediaSource ? encodeURIComponent(props.mediaSource) : ''))
const { openMusicSiteSearch } = useMusicSiteSearch(sites => {
if (!artist.value?.name) return undefined
@@ -63,10 +61,14 @@ const attributes = computed(() => {
/** 加载艺术家详情。 */
async function loadArtistDetail() {
if (!props.mediaSource || !props.mediaid) return
if (!props.mediaSource || !props.mediaId) {
artist.value = undefined
isRefreshed.value = true
return
}
isRefreshed.value = false
try {
artist.value = await api.get(`music/artist/${props.mediaid}`, { params: { media_source: props.mediaSource } })
artist.value = await api.get(`music/artist/${props.mediaId}`, { params: { media_source: props.mediaSource } })
} catch (error) {
console.error(error)
artist.value = undefined
@@ -77,10 +79,11 @@ async function loadArtistDetail() {
/** 返回指定专辑类型的浏览列表路由。 */
function getAlbumsBrowseRoute(albumType: string, title: string) {
return `/browse/music/artist/${props.mediaid}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(title)}&album_type=${albumType}`
if (!props.mediaSource) return ''
return `/browse/music/artist/${props.mediaId}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(title)}&album_type=${albumType}`
}
watch(() => [props.mediaSource, props.mediaid], loadArtistDetail, { immediate: true })
watch(() => [props.mediaSource, props.mediaId], loadArtistDetail, { immediate: true })
</script>
<template>
@@ -163,7 +166,7 @@ watch(() => [props.mediaSource, props.mediaid], loadArtistDetail, { immediate: t
<div v-for="section in albumSections" :key="section.type" class="music-section">
<MediaCardSlideView
:apipath="`music/artist/${props.mediaid}/albums?media_source=${encodeURIComponent(props.mediaSource)}&album_type=${section.type}`"
:apipath="`music/artist/${props.mediaId}/albums?media_source=${encodedMediaSource}&album_type=${section.type}`"
:linkurl="getAlbumsBrowseRoute(section.type, section.title)"
:title="section.title"
/>
@@ -171,7 +174,7 @@ watch(() => [props.mediaSource, props.mediaid], loadArtistDetail, { immediate: t
<div v-if="props.mediaSource === 'musicbrainz'" class="music-section">
<MusicArtistSlideView
:apipath="`music/artist/${props.mediaid}/related?media_source=musicbrainz`"
:apipath="`music/artist/${props.mediaId}/related?media_source=musicbrainz`"
:title="t('music.relatedArtists')"
/>
</div>
+19 -16
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import type { MediaInfo, MusicAlbumInfo } from '@/api/types'
import type { MediaDataSource, MediaInfo, MusicAlbumInfo } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
@@ -22,15 +22,12 @@ import {
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
const props = defineProps<{
// ID
mediaid: String,
mediaId?: string
//
mediaSource: {
type: String,
default: 'musicbrainz',
},
})
mediaSource?: MediaDataSource
}>()
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
@@ -62,6 +59,7 @@ const attributes = computed(() => {
// 便
const albumTracks = computed(() => album.value?.tracks ?? [])
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
const encodedMediaSource = computed(() => (props.mediaSource ? encodeURIComponent(props.mediaSource) : ''))
function getSubscribeStatusKey() {
return `${getMediaSubscribeId(music.value)}::all`
@@ -80,13 +78,18 @@ const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
/** 加载单曲详情,并按所属专辑补全曲目列表。 */
async function loadMusicDetail() {
if (!props.mediaSource || !props.mediaid) return
if (!props.mediaSource || !props.mediaId) {
music.value = undefined
album.value = undefined
isRefreshed.value = true
return
}
isRefreshed.value = false
album.value = undefined
try {
music.value = await api.post('music/recognize', {
media_source: props.mediaSource,
media_id: props.mediaid,
media_id: props.mediaId,
music_type: 'recording',
})
} catch (error) {
@@ -126,17 +129,17 @@ async function checkSubscribeStatus() {
/** 打开所属专辑详情页。 */
function goAlbum() {
if (!music.value?.album_id) return
if (!music.value?.album_id || !props.mediaSource) return
router.push(buildMusicAlbumRoute(music.value.album_id, music.value.album, props.mediaSource))
}
/** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) {
if (!artistId) return
if (!artistId || !props.mediaSource) return
router.push(buildMusicArtistRoute(artistId, name, props.mediaSource))
}
watch(() => [props.mediaSource, props.mediaid], loadMusicDetail, { immediate: true })
watch(() => [props.mediaSource, props.mediaId], loadMusicDetail, { immediate: true })
</script>
<template>
@@ -247,10 +250,10 @@ watch(() => [props.mediaSource, props.mediaid], loadMusicDetail, { immediate: tr
/>
</div>
<div v-if="primaryArtistId" class="music-section">
<div v-if="primaryArtistId && props.mediaSource" class="music-section">
<MediaCardSlideView
:apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
:apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodedMediaSource}`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodedMediaSource}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
:title="t('music.artistAlbums')"
/>
</div>
+5 -3
View File
@@ -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<Person[]>
}
//
function getParams() {
let params = {
function getParams(): Record<string, unknown> {
let params: Record<string, unknown> = {
page: page.value,
}
if (props.params) params = { ...params, ...props.params }
@@ -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[] = []
@@ -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'))
@@ -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<Subscribe>
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 }),
)
@@ -87,9 +87,9 @@ const LoadingBannerStub = defineComponent({
template: '<div role="status">正在加载人物列表</div>',
})
async function renderList() {
async function renderList(params?: Record<string, unknown>) {
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(
+155 -139
View File
@@ -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 === '电影') {
// APITMDB
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(() => {
</script>
<template>
<div v-if="display.mdAndUp.value" class="calendar-media-type-filter" role="group" :aria-label="t('calendar.mediaTypeFilterTitle')">
<div
v-if="display.mdAndUp.value"
class="calendar-media-type-filter"
role="group"
:aria-label="t('calendar.mediaTypeFilterTitle')"
>
<button
v-for="option in mediaTypeFilterOptions"
:key="option.value"
@@ -779,10 +792,7 @@ onActivated(() => {
{{ t('calendar.episode', { number: calendarEvent.subtitle }) }}
</div>
<div v-if="calendarEvent.totalEpisode" class="calendar-event-library-row">
<span
class="calendar-event-status"
:class="`calendar-event-status--${calendarEvent.libraryState}`"
>
<span class="calendar-event-status" :class="`calendar-event-status--${calendarEvent.libraryState}`">
<VIcon :icon="getLibraryStateIcon(calendarEvent.libraryState)" size="13" />
{{ getCompactLibraryProgressText(calendarEvent) }}
</span>
@@ -814,139 +824,145 @@ onActivated(() => {
<template v-else>
<section class="mobile-calendar-filter-card">
<div class="mobile-calendar-filter-head">
<div class="mobile-calendar-filter-copy">
<h2>{{ t('calendar.mobileFilterTitle') }}</h2>
<span>{{ t('calendar.itemCount', { count: mobileSeriesFilterOptions[0]?.count || 0 }) }}</span>
<div class="mobile-calendar-filter-head">
<div class="mobile-calendar-filter-copy">
<h2>{{ t('calendar.mobileFilterTitle') }}</h2>
<span>{{ t('calendar.itemCount', { count: mobileSeriesFilterOptions[0]?.count || 0 }) }}</span>
</div>
<button
type="button"
class="mobile-calendar-expired-toggle"
:class="{ 'mobile-calendar-expired-toggle--active': mobileHideExpired }"
@click="mobileHideExpired = !mobileHideExpired"
>
<VIcon :icon="mobileHideExpired ? 'mdi-eye-off-outline' : 'mdi-eye-outline'" size="18" />
<span>{{ mobileHideExpired ? t('calendar.hideExpired') : t('calendar.showExpired') }}</span>
</button>
</div>
<button
type="button"
class="mobile-calendar-expired-toggle"
:class="{ 'mobile-calendar-expired-toggle--active': mobileHideExpired }"
@click="mobileHideExpired = !mobileHideExpired"
>
<VIcon :icon="mobileHideExpired ? 'mdi-eye-off-outline' : 'mdi-eye-outline'" size="18" />
<span>{{ mobileHideExpired ? t('calendar.hideExpired') : t('calendar.showExpired') }}</span>
</button>
</div>
<div class="mobile-calendar-mediatype-list" role="group" :aria-label="t('calendar.mediaTypeFilterTitle')">
<button
v-for="option in mediaTypeFilterOptions"
:key="option.value"
type="button"
class="mobile-calendar-filter-chip mobile-calendar-mediatype-chip"
:class="{ 'mobile-calendar-filter-chip--active': mediaTypeFilter === option.value }"
:aria-pressed="mediaTypeFilter === option.value"
@click="mediaTypeFilter = option.value"
>
{{ option.label }}
</button>
</div>
<div class="mobile-calendar-mediatype-list" role="group" :aria-label="t('calendar.mediaTypeFilterTitle')">
<button
v-for="option in mediaTypeFilterOptions"
:key="option.value"
type="button"
class="mobile-calendar-filter-chip mobile-calendar-mediatype-chip"
:class="{ 'mobile-calendar-filter-chip--active': mediaTypeFilter === option.value }"
:aria-pressed="mediaTypeFilter === option.value"
@click="mediaTypeFilter = option.value"
>
{{ option.label }}
</button>
</div>
<div class="mobile-calendar-filter-list" role="listbox" :aria-label="t('calendar.mobileFilterTitle')">
<button
v-for="option in mobileSeriesFilterOptions"
:key="option.value"
type="button"
class="mobile-calendar-filter-chip"
:class="{ 'mobile-calendar-filter-chip--active': mobileSelectedFilterValue === option.value }"
role="option"
:aria-selected="mobileSelectedFilterValue === option.value"
@click="mobileSelectedFilterValue = option.value"
>
{{ option.label }}
</button>
</div>
<div class="mobile-calendar-filter-list" role="listbox" :aria-label="t('calendar.mobileFilterTitle')">
<button
v-for="option in mobileSeriesFilterOptions"
:key="option.value"
type="button"
class="mobile-calendar-filter-chip"
:class="{ 'mobile-calendar-filter-chip--active': mobileSelectedFilterValue === option.value }"
role="option"
:aria-selected="mobileSelectedFilterValue === option.value"
@click="mobileSelectedFilterValue = option.value"
>
{{ option.label }}
</button>
</div>
</section>
<div v-if="mobileCalendarDayGroups.length" class="mobile-calendar-timeline">
<section v-for="group in mobileCalendarDayGroups" :key="group.dateKey" class="mobile-calendar-day">
<div class="mobile-calendar-day-marker">
<span class="mobile-calendar-day-dot" :class="{ 'mobile-calendar-day-dot--today': isDateToday(group.date) }" />
</div>
<div class="mobile-calendar-day-body">
<header class="mobile-calendar-day-head">
<div class="mobile-calendar-day-title-wrap">
<h2>{{ group.title }}</h2>
<span>{{ group.subtitle }}</span>
</div>
<div class="mobile-calendar-day-meta">
<span
v-if="getMobileDayStatus(group.date)"
class="mobile-calendar-day-status"
:class="{
'mobile-calendar-day-status--upcoming': isDateAfterToday(group.date),
'mobile-calendar-day-status--expired': isDateBeforeToday(group.date),
}"
>
{{ getMobileDayStatus(group.date) }}
</span>
<span class="mobile-calendar-day-count">{{ t('calendar.episodeCount', { count: group.count }) }}</span>
</div>
</header>
<div class="mobile-calendar-event-list">
<article
v-for="calendarEvent in group.events"
:key="`${group.dateKey}-${calendarEvent.title}-${calendarEvent.subtitle}-${calendarEvent.calendarSortIndex}`"
class="mobile-calendar-event-card"
:class="`mobile-calendar-event-card--${calendarEvent.libraryState}`"
:title="getCalendarEventInfoTooltip(calendarEvent)"
>
<div class="mobile-calendar-event-poster-wrap">
<VImg
:src="calendarEvent.posterPath"
aspect-ratio="2/3"
class="mobile-calendar-event-poster object-cover"
cover
>
<template #placeholder>
<div class="mobile-calendar-event-poster-placeholder">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<template #error>
<div class="mobile-calendar-event-poster-error">
<VIcon icon="mdi-image-off-outline" size="32" />
<span>{{ t('calendar.imageLoadFailed') }}</span>
</div>
</template>
</VImg>
</div>
<div class="mobile-calendar-event-content">
<h3>{{ getMobileEventMainTitle(calendarEvent) }}</h3>
<p v-if="getMobileEventSubtitle(calendarEvent)">{{ getMobileEventSubtitle(calendarEvent) }}</p>
<div class="mobile-calendar-event-tags">
<span v-if="getMobileEventEpisodeTag(calendarEvent)" class="mobile-calendar-event-tag mobile-calendar-event-tag--primary">
{{ getMobileEventEpisodeTag(calendarEvent) }}
</span>
<span v-if="getMobileEventRuntimeTag(calendarEvent)" class="mobile-calendar-event-tag">
{{ getMobileEventRuntimeTag(calendarEvent) }}
</span>
<span
class="mobile-calendar-event-tag"
:class="`mobile-calendar-event-tag--library-${calendarEvent.libraryState}`"
>
{{ getLibraryStateText(calendarEvent.libraryState) }}
</span>
</div>
</div>
</article>
<section v-for="group in mobileCalendarDayGroups" :key="group.dateKey" class="mobile-calendar-day">
<div class="mobile-calendar-day-marker">
<span
class="mobile-calendar-day-dot"
:class="{ 'mobile-calendar-day-dot--today': isDateToday(group.date) }"
/>
</div>
</div>
</section>
<div class="mobile-calendar-day-body">
<header class="mobile-calendar-day-head">
<div class="mobile-calendar-day-title-wrap">
<h2>{{ group.title }}</h2>
<span>{{ group.subtitle }}</span>
</div>
<div class="mobile-calendar-day-meta">
<span
v-if="getMobileDayStatus(group.date)"
class="mobile-calendar-day-status"
:class="{
'mobile-calendar-day-status--upcoming': isDateAfterToday(group.date),
'mobile-calendar-day-status--expired': isDateBeforeToday(group.date),
}"
>
{{ getMobileDayStatus(group.date) }}
</span>
<span class="mobile-calendar-day-count">{{ t('calendar.episodeCount', { count: group.count }) }}</span>
</div>
</header>
<div class="mobile-calendar-event-list">
<article
v-for="calendarEvent in group.events"
:key="`${group.dateKey}-${calendarEvent.title}-${calendarEvent.subtitle}-${calendarEvent.calendarSortIndex}`"
class="mobile-calendar-event-card"
:class="`mobile-calendar-event-card--${calendarEvent.libraryState}`"
:title="getCalendarEventInfoTooltip(calendarEvent)"
>
<div class="mobile-calendar-event-poster-wrap">
<VImg
:src="calendarEvent.posterPath"
aspect-ratio="2/3"
class="mobile-calendar-event-poster object-cover"
cover
>
<template #placeholder>
<div class="mobile-calendar-event-poster-placeholder">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<template #error>
<div class="mobile-calendar-event-poster-error">
<VIcon icon="mdi-image-off-outline" size="32" />
<span>{{ t('calendar.imageLoadFailed') }}</span>
</div>
</template>
</VImg>
</div>
<div class="mobile-calendar-event-content">
<h3>{{ getMobileEventMainTitle(calendarEvent) }}</h3>
<p v-if="getMobileEventSubtitle(calendarEvent)">{{ getMobileEventSubtitle(calendarEvent) }}</p>
<div class="mobile-calendar-event-tags">
<span
v-if="getMobileEventEpisodeTag(calendarEvent)"
class="mobile-calendar-event-tag mobile-calendar-event-tag--primary"
>
{{ getMobileEventEpisodeTag(calendarEvent) }}
</span>
<span v-if="getMobileEventRuntimeTag(calendarEvent)" class="mobile-calendar-event-tag">
{{ getMobileEventRuntimeTag(calendarEvent) }}
</span>
<span
class="mobile-calendar-event-tag"
:class="`mobile-calendar-event-tag--library-${calendarEvent.libraryState}`"
>
{{ getLibraryStateText(calendarEvent.libraryState) }}
</span>
</div>
</div>
</article>
</div>
</div>
</section>
</div>
<div v-else class="mobile-calendar-empty">
<VIcon icon="mdi-calendar-blank-outline" size="44" />
<h2>{{ t('common.noData') }}</h2>
<p>{{ t('calendar.noMatchingEvents') }}</p>
<VIcon icon="mdi-calendar-blank-outline" size="44" />
<h2>{{ t('common.noData') }}</h2>
<p>{{ t('calendar.noMatchingEvents') }}</p>
</div>
</template>
</div>
+1 -15
View File
@@ -152,23 +152,9 @@ function getParams() {
return params
}
// MediaInfo
const dedupFields = [
'media_source',
'type',
'season',
'tmdb_id',
'imdb_id',
'tvdb_id',
'douban_id',
'bangumi_id',
'anilist_id',
'media_id',
] as const
// DOM key
function getMediaIdentity(item: MediaInfo) {
return JSON.stringify(dedupFields.map(field => item[field] ?? null))
return JSON.stringify([item.media_source ?? null, item.media_id ?? null, item.type ?? null, item.season ?? null])
}
function deduplicate(items: MediaInfo[]): MediaInfo[] {
+1 -3
View File
@@ -288,9 +288,7 @@ function removeData(id: number) {
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="
item =>
item.id ||
`${item.media_id || item.tmdbid || item.doubanid || item.bangumiid || item.anilistid || item.name}-${item.share_user}`
item => item.id || `${item.media_source || 'unknown'}:${item.media_id || item.name}-${item.share_user}`
"
:min-item-width="240"
:estimated-item-height="260"
@@ -92,7 +92,15 @@ function queryMobileCalendarEventCard(title: string) {
}
function movieSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {}) {
return createSubscribe({ id, name, tmdbid: id, type: '电影', username: `user-${id}`, ...overrides })
return createSubscribe({
id,
media_id: String(id),
media_source: 'themoviedb',
name,
type: '电影',
username: `user-${id}`,
...overrides,
})
}
function tvSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {}) {
@@ -100,7 +108,8 @@ function tvSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {
id,
name,
season: 1,
tmdbid: id,
media_id: String(id),
media_source: 'themoviedb',
total_episode: 4,
type: '电视剧',
username: `user-${id}`,
@@ -216,7 +225,7 @@ describe('FullCalendarView', () => {
]
server.use(
subscribeListHandler(subscriptions),
...subscriptions.map(subscribe => tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, episodes)),
...subscriptions.map(subscribe => tmdbSeasonEpisodesHandler(Number(subscribe.media_id), 1, episodes)),
)
await renderCalendar()
@@ -264,7 +273,7 @@ describe('FullCalendarView', () => {
server.use(
subscribeListHandler(subscriptions),
...sameDaySubscriptions.map(subscribe =>
tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, [sameDayEpisode]),
tmdbSeasonEpisodesHandler(Number(subscribe.media_id), 1, [sameDayEpisode]),
),
tmdbSeasonEpisodesHandler(3499, 1, [createTmdbEpisode({ air_date: '2026-08-02', episode_number: 1 })]),
)
@@ -314,7 +323,7 @@ describe('FullCalendarView', () => {
]
server.use(
subscribeListHandler(details.map(([subscribe]) => subscribe)),
...details.map(([subscribe, media]) => mediaDetailsHandler(subscribe.tmdbid as number, media)),
...details.map(([subscribe, media]) => mediaDetailsHandler(String(subscribe.media_id), media)),
)
await renderCalendar()
@@ -461,7 +470,7 @@ describe('FullCalendarView', () => {
server.use(
subscribeListHandler(sameDaySubscriptions),
...sameDaySubscriptions.map(subscribe =>
tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, [sameDayEpisode]),
tmdbSeasonEpisodesHandler(Number(subscribe.media_id), 1, [sameDayEpisode]),
),
)
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
@@ -288,6 +288,27 @@ describe('SubscribePopularView', () => {
expect(screen.queryByText('第一页页内重复项')).not.toBeInTheDocument()
})
it('keeps equal native IDs from different media sources as distinct items', async () => {
server.use(
popularSubscribesHandler([
createSubscribeMovie({ media_id: '900', media_source: 'themoviedb', title: 'TMDB 媒体' }),
createSubscribeMovie({
media_id: '900',
media_source: 'douban',
title: '豆瓣媒体',
tmdb_id: undefined,
}),
]),
)
await renderPopular()
expect(await screen.findByText('TMDB 媒体')).toBeInTheDocument()
expect(screen.getByText('豆瓣媒体')).toBeInTheDocument()
const keys = JSON.parse(screen.getByLabelText('热门订阅渐进网格键').textContent || '[]') as string[]
expect(new Set(keys).size).toBe(2)
})
it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
const first = createSubscribeMovie({ title: '未满屏第一页' })
const second = createSubscribeMovie({ title: '未满屏第二页' })
@@ -443,25 +464,12 @@ describe('SubscribePopularView', () => {
expect(await screen.findByText('同剧第一季')).toBeInTheDocument()
expect(screen.getByText('同剧第二季')).toBeInTheDocument()
// 渲染键与生产媒体身份字段保持一致,避免不同季条目发生键冲突
const identityFields = [
'media_source',
'type',
'season',
'tmdb_id',
'imdb_id',
'tvdb_id',
'douban_id',
'bangumi_id',
'anilist_id',
'media_id',
] as const
// 渲染键只使用统一媒体身份和季号,不再混入数据源专属辅助 ID
const item1 = createSubscribeTv({ season: 1, title: '同剧第一季', tmdb_id: 880 })
const item2 = createSubscribeTv({ season: 2, title: '同剧第二季', tmdb_id: 880 })
const expectedKeys = [
JSON.stringify(identityFields.map(field => item1[field] ?? null)),
JSON.stringify(identityFields.map(field => item2[field] ?? null)),
]
const expectedKeys = [item1, item2].map(item =>
JSON.stringify([item.media_source ?? null, item.media_id ?? null, item.type ?? null, item.season ?? null]),
)
const keysText = screen.getByRole('status', { name: '热门订阅渐进网格键' }).textContent ?? ''
const keys = JSON.parse(keysText)
+6 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import type { MusicEntityType, TorrentCacheData, TorrentCacheItem } from '@/api/types'
import type { MediaDataSource, MusicEntityType, TorrentCacheData, TorrentCacheItem } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { formatFileSize, formatDateDifference } from '@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm'
@@ -250,7 +250,7 @@ function openReidentifyDialog(item: TorrentCacheItem) {
/** 执行缓存项重新识别。 */
async function performReidentify(
payload: {
mediaSource?: string
mediaSource?: MediaDataSource
mediaId?: string
musicType?: Exclude<MusicEntityType, 'artist'>
} = {},
@@ -261,8 +261,10 @@ async function performReidentify(
loading.value = true
reidentifyDialogController?.updateProps({ loading: true })
const params: any = {}
if (payload.mediaSource) params.media_source = payload.mediaSource
if (payload.mediaId) params.media_id = payload.mediaId
if (payload.mediaSource && payload.mediaId) {
params.media_source = payload.mediaSource
params.media_id = payload.mediaId
}
if (payload.musicType) params.music_type = payload.musicType
const res: any = await api.post(
+7 -12
View File
@@ -4,7 +4,7 @@ import { useToast } from 'vue-toastification'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Context, MediaDataSource, MediaInfo } from '@/api/types'
import { getMediaSubscribeId, getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
import { getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { getLogoUrl } from '@/utils/imageUtils'
@@ -159,15 +159,7 @@ const resourceChips = computed(() => {
].filter(Boolean) as string[]
})
//
const canViewMediaDetail = computed(() =>
Boolean(
mediaInfo.value?.tmdb_id ||
mediaInfo.value?.douban_id ||
mediaInfo.value?.bangumi_id ||
mediaInfo.value?.anilist_id ||
mediaInfo.value?.media_id,
),
)
const canViewMediaDetail = computed(() => Boolean(getMediaSubscribeIdentity(mediaInfo.value)))
/** 生成媒体源官方详情页地址。 */
function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string) {
@@ -196,7 +188,7 @@ function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string)
}
}
/** 生成识别结果中的数据源原生 ID,并兼容旧接口字段。 */
/** 生成识别结果中的数据源原生 ID。 */
function getMediaIdentity(media?: MediaInfo): MediaIdentity | undefined {
if (!media) return undefined
@@ -271,11 +263,14 @@ function getPosterImage(url = '') {
/** 关闭识别测试弹窗后,跳转查看当前识别结果匹配到的媒体详情。 */
async function viewMediaDetail() {
if (!canViewMediaDetail.value || !mediaInfo.value) return
const identity = getMediaSubscribeIdentity(mediaInfo.value)
if (!identity) return
const target = {
path: '/media',
query: {
mediaid: getMediaSubscribeId(mediaInfo.value),
media_source: identity.source,
media_id: identity.mediaId,
title: mediaInfo.value.title,
year: mediaInfo.value.year,
type: mediaInfo.value.type,
@@ -282,11 +282,34 @@ describe('NameTestView media identity', () => {
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid: 'tmdb:271016',
media_id: '271016',
media_source: 'themoviedb',
title: '测试剧集',
type: '电视剧',
year: '2026',
},
})
})
it('does not offer navigation when only an auxiliary provider ID is present', async () => {
mocks.apiGet.mockResolvedValueOnce({
media_info: {
episode_run_time: [],
origin_country: [],
title: '仅辅助身份',
tmdb_id: 271016,
type: '电影',
},
meta_info: { apply_words: [], name: '仅辅助身份', org_string: 'Auxiliary.Only' },
torrent_info: {},
})
await renderWithProviders(NameTestView)
const user = userEvent.setup()
await user.type(screen.getByLabelText('标题'), 'Auxiliary.Only')
await user.click(screen.getByRole('button', { name: '识别' }))
expect(await screen.findAllByText('仅辅助身份')).not.toHaveLength(0)
expect(screen.queryByRole('button', { name: '查看详情' })).not.toBeInTheDocument()
})
})
+6 -1
View File
@@ -20,12 +20,17 @@ export function createTmdbEpisode(overrides: Partial<TmdbEpisode> = {}): TmdbEpi
export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
mediaSeed += 1
const mediaSource = overrides.media_source ?? 'themoviedb'
const mediaId =
overrides.media_id ??
(mediaSource === 'themoviedb' && overrides.tmdb_id !== undefined ? String(overrides.tmdb_id) : String(mediaSeed))
return {
backdrop_path: `/images/media-${mediaSeed}.jpg`,
episode_run_time: [],
genres: ['剧情', '冒险'],
origin_country: [],
media_source: 'themoviedb',
media_source: mediaSource,
media_id: mediaId,
title: `测试媒体 ${mediaSeed}`,
tmdb_id: mediaSeed,
type: '电影',
+2 -1
View File
@@ -76,8 +76,9 @@ export function createTorrentInfo(overrides: Partial<TorrentInfo> = {}): Torrent
freedate_diff: '',
grabs: 10,
hit_and_run: false,
imdbid: '',
labels: [],
media_id: '',
media_source: 'themoviedb',
peers: 3,
pri_order: 0,
seeders: 12,
+4 -2
View File
@@ -30,7 +30,8 @@ export function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
show_edit_dialog: false,
sites: [],
state: 'R',
tmdbid: subscribeSeed,
media_id: String(subscribeSeed),
media_source: 'themoviedb',
type: '电影',
username: 'tester',
year: '2026',
@@ -53,7 +54,8 @@ export function createSubscribeShare(overrides: Partial<SubscribeShare> = {}): S
share_uid: `share-user-${subscribeShareSeed}`,
share_user: `分享用户 ${subscribeShareSeed}`,
subscribe_id: subscribeShareSeed + 1000,
tmdbid: subscribeShareSeed,
media_id: String(subscribeShareSeed),
media_source: 'themoviedb',
type: '电影',
vote: 8.2,
year: '2026',
+1 -2
View File
@@ -31,8 +31,7 @@ export function mediaDetailsHandler(
status = 200,
onRequest: (url: URL) => void = () => {},
) {
const normalizedMediaId = typeof mediaId === 'number' ? `tmdb:${mediaId}` : mediaId
return http.get(mediaApiUrls.details(normalizedMediaId), ({ request }) => {
return http.get(mediaApiUrls.details(String(mediaId)), ({ request }) => {
onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})