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: '关闭' }) const button = screen.getByRole('button', { name: '关闭' })
expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10') expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
const icon = container.querySelector('svg.v-icon') const icon = button.querySelector('svg.v-icon')
expect(icon).not.toBeNull() expect(container.querySelectorAll('svg.v-icon')).toHaveLength(1)
expect(icon).toHaveAttribute('aria-hidden', 'true') expect(icon).toHaveAttribute('aria-hidden', 'true')
await waitFor(() => expect(icon).toHaveAttribute('role', 'img')
expect(icon?.querySelector('path')).toHaveAttribute( expect(icon).toHaveAttribute('height', '1em')
'd', expect(icon).toHaveAttribute('width', '1em')
'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',
),
)
i18n.global.locale.value = 'en-US' i18n.global.locale.value = 'en-US'
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBe(button)) 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', () => { it('marks invalid replacement parameter keys and values', () => {
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([ expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名' }, { type: 'word_list_replaced', value: '旧名' },
+7 -1
View File
@@ -538,11 +538,15 @@ interface WordListToken {
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
const wordListUnsignedIntegerPattern = /^\d+$/ const wordListUnsignedIntegerPattern = /^\d+$/
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/ const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
const wordListMediaSourcePattern =
/^(?:themoviedb|douban|bangumi|anilist|imdb|tvdb|musicbrainz|theaudiodb|doubanmusic|bilibili|mangguodiscover|migu|tencentvideodiscover)$/
const wordListParameterTypes = { const wordListParameterTypes = {
tmdbid: 'uint', tmdbid: 'uint',
doubanid: 'uint', doubanid: 'string',
bangumiid: 'uint', bangumiid: 'uint',
anilistid: 'uint', anilistid: 'uint',
media_source: 'media-source',
media_id: 'string',
type: 'media-type', type: 'media-type',
g: 'string', g: 'string',
s: 'uint-or-range', s: 'uint-or-range',
@@ -565,6 +569,8 @@ function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes,
return wordListUnsignedIntegerOrRangePattern.test(value) return wordListUnsignedIntegerOrRangePattern.test(value)
case 'media-type': case 'media-type':
return value === 'movie' || value === 'tv' return value === 'movie' || value === 'tv'
case 'media-source':
return wordListMediaSourcePattern.test(value)
case 'string': case 'string':
return value.length > 0 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 { export interface ManualScrapeOptions {
@@ -25,14 +41,6 @@ export interface Subscribe {
type: string type: string
// 搜索关键字 // 搜索关键字
keyword?: string keyword?: string
// TMDB ID
tmdbid: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// AniList ID
anilistid?: number
// 主媒体数据源 // 主媒体数据源
media_source?: MediaDataSource media_source?: MediaDataSource
// 数据源原生ID // 数据源原生ID
@@ -41,8 +49,6 @@ export interface Subscribe {
music_type?: MusicEntityType music_type?: MusicEntityType
// 专辑总曲目数 // 专辑总曲目数
total_tracks?: number total_tracks?: number
// 其它媒体ID
mediaid?: string
// 季号 // 季号
season?: number season?: number
// 海报 // 海报
@@ -153,14 +159,6 @@ export interface SubscribeShare {
type?: string type?: string
// 搜索关键字 // 搜索关键字
keyword?: string keyword?: string
// TMDB ID
tmdbid?: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// AniList ID
anilistid?: number
// 主媒体数据源 // 主媒体数据源
media_source?: MediaDataSource media_source?: MediaDataSource
// 数据源原生ID // 数据源原生ID
@@ -263,18 +261,6 @@ export interface TransferHistory {
title?: string title?: string
// 年份 // 年份
year?: 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 media_source?: MediaDataSource
// 数据源原生ID // 数据源原生ID
@@ -323,18 +309,6 @@ export interface DownloadHistory {
title?: string title?: string
// 年份 // 年份
year?: 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 media_source?: MediaDataSource
// 数据源原生 ID // 数据源原生 ID
@@ -403,8 +377,7 @@ export interface MediaInfo {
anidb_id?: number anidb_id?: number
// 合集ID // 合集ID
collection_id?: number collection_id?: number
// 其它媒体ID前缀 // 主媒体数据源的原生 ID
// 其它媒体ID值
media_id?: string media_id?: string
// 媒体原语种 // 媒体原语种
original_language?: string original_language?: string
@@ -1107,8 +1080,10 @@ export interface TorrentInfo {
title?: string title?: string
// 种子副标题 // 种子副标题
description?: string description?: string
// IMDB ID // 种子页面声明的媒体来源
imdbid: string media_source?: MediaDataSource
// 种子页面声明的数据源原生 ID
media_id?: string
// 种子链接 // 种子链接
enclosure?: string enclosure?: string
// 详情页面 // 详情页面
@@ -1895,14 +1870,6 @@ export interface TransferForm {
target_storage: string | null target_storage: string | null
// 目标路径 // 目标路径
target_path: string | null target_path: string | null
// TMDB ID
tmdbid?: number
// 豆瓣 ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// AniList ID
anilistid?: number
// 媒体数据源 // 媒体数据源
media_source?: MediaDataSource media_source?: MediaDataSource
// 数据源原生ID // 数据源原生ID
+19 -18
View File
@@ -17,6 +17,7 @@ import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { import {
getMediaSubscribeId, getMediaSubscribeId,
getMediaSubscribeIdentity,
getSubscribeMode, getSubscribeMode,
useMediaSubscribe, useMediaSubscribe,
type SeasonSubscribeModes, type SeasonSubscribeModes,
@@ -182,7 +183,7 @@ async function querySelectedSites() {
} }
} }
// 获得mediaid // 获取当前卡片的统一媒体身份缓存键
function getMediaId() { function getMediaId() {
return getMediaSubscribeId(props.media) return getMediaSubscribeId(props.media)
} }
@@ -193,14 +194,14 @@ function getSubscribeStatusKey(season: number | null = props.media?.season ?? nu
} }
function getExistsStatusKey() { function getExistsStatusKey() {
const identity = getMediaSubscribeIdentity(props.media)
return [ return [
props.media?.tmdb_id ?? '', identity?.source ?? '',
identity?.mediaId ?? '',
props.media?.title ?? '', props.media?.title ?? '',
props.media?.year ?? '', props.media?.year ?? '',
props.media?.season ?? '', props.media?.season ?? '',
props.media?.type ?? '', props.media?.type ?? '',
props.media?.media_source ?? '',
props.media?.media_id ?? '',
].join('::') ].join('::')
} }
@@ -210,17 +211,10 @@ function isSameSubscribeMedia(subscribe: Subscribe) {
const subscribeMusicType = subscribe.music_type ?? 'recording' const subscribeMusicType = subscribe.music_type ?? 'recording'
if (subscribeMusicType !== expectedMusicType) return false if (subscribeMusicType !== expectedMusicType) return false
} }
const mediaId = getMediaId() const identity = getMediaSubscribeIdentity(props.media)
if (subscribe.media_source && subscribe.media_id) { return Boolean(
const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source identity && subscribe.media_source === identity.source && String(subscribe.media_id || '') === identity.mediaId,
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
} }
// 角标颜色 // 角标颜色
@@ -278,9 +272,10 @@ async function handleCheckExists() {
if (props.media?.type === '音乐') return if (props.media?.type === '音乐') return
try { try {
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => { const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
const identity = getMediaSubscribeIdentity(props.media)
const result: { [key: string]: any } = await api.get('mediaserver/exists', { const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: { params: {
tmdbid: props.media?.tmdb_id, ...(identity ? { media_source: identity.source, media_id: identity.mediaId } : {}),
title: props.media?.title, title: props.media?.title,
year: props.media?.year, year: props.media?.year,
season: props.media?.season, season: props.media?.season,
@@ -325,11 +320,14 @@ function goMediaDetail(isHovering = false) {
}, },
}) })
} else { } else {
const identity = getMediaSubscribeIdentity(props.media)
if (!identity) return
// 跳转到媒体详情页 // 跳转到媒体详情页
router.push({ router.push({
path: '/media', path: '/media',
query: { query: {
mediaid: getMediaId(), media_source: identity.source,
media_id: identity.mediaId,
title: props.media?.title, title: props.media?.title,
year: props.media?.year, year: props.media?.year,
type: props.media?.type, type: props.media?.type,
@@ -390,10 +388,13 @@ async function clickSearch() {
// 开始搜索 // 开始搜索
function handleSearch() { function handleSearch() {
const identity = getMediaSubscribeIdentity(props.media)
if (!identity) return
router.push({ router.push({
path: '/resource', path: '/resource',
query: { query: {
keyword: getMediaId(), media_source: identity.source,
media_id: identity.mediaId,
type: props.media?.type, type: props.media?.type,
area: 'title', area: 'title',
title: props.media?.title, title: props.media?.title,
+1 -1
View File
@@ -24,7 +24,7 @@ const subtitle = computed(() => getMusicArtistSubtitle(props.artist))
/** 打开艺术家详情页。 */ /** 打开艺术家详情页。 */
function goArtistDetail() { 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)) router.push(buildMusicArtistRoute(props.artist.media_id, props.artist.name, props.artist.media_source))
} }
</script> </script>
+57 -23
View File
@@ -128,14 +128,14 @@ function goDetail() {
/** 打开所属专辑详情页。 */ /** 打开所属专辑详情页。 */
function goAlbum() { 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)) router.push(buildMusicAlbumRoute(props.music.album_id, props.music.album, props.music.media_source))
} }
/** 打开艺术家详情页。 */ /** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) { function goArtist(artistId?: string, name?: string) {
if (!artistId) return if (!artistId || !props.music?.media_source) return
router.push(buildMusicArtistRoute(artistId, name, props.music?.media_source)) router.push(buildMusicArtistRoute(artistId, name, props.music.media_source))
} }
watch( watch(
@@ -160,34 +160,43 @@ onMounted(checkSubscribeStatus)
@click="goDetail" @click="goDetail"
> >
<div class="music-card-content"> <div class="music-card-content">
<div class="music-card-cover-shell"> <div class="music-card-cover-column">
<VImg v-if="showCover" :src="coverUrl" cover class="music-card-cover" @error="imageLoadError = true"> <div class="music-card-cover-shell">
<template #placeholder> <VImg v-if="showCover" :src="coverUrl" cover class="music-card-cover" @error="imageLoadError = true">
<VSkeletonLoader class="h-100" /> <template #placeholder>
</template> <VSkeletonLoader class="h-100" />
</VImg> </template>
<VIcon v-else :icon="entityMeta.icon" size="44" color="medium-emphasis" /> </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"> <VChip :prepend-icon="entityMeta.icon" size="x-small" variant="flat" class="music-card-entity">
{{ entityMeta.label }} {{ entityMeta.label }}
</VChip> </VChip>
</div> </div>
<div class="music-card-body">
<div class="music-card-source-row"> <div class="music-card-source-row">
<VChip <VChip
data-testid="music-source" data-testid="music-source"
:color="sourceMeta.color" :color="sourceMeta.color"
:prepend-icon="sourceMeta.icon" :prepend-icon="sourceMeta.icon"
class="music-card-source"
size="x-small" size="x-small"
variant="tonal" variant="tonal"
> >
{{ sourceLabel }} {{ sourceLabel }}
</VChip> </VChip>
</div> </div>
</div>
<div class="music-card-body">
<div class="music-card-heading"> <div class="music-card-heading">
<div class="music-card-title" :title="props.music?.title">{{ props.music?.title }}</div> <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 }} {{ props.music.version }}
</VChip> </VChip>
</div> </div>
@@ -297,6 +306,14 @@ onMounted(checkSubscribeStatus)
padding: 1rem; 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 { .music-card-cover-shell {
position: relative; position: relative;
display: flex; display: flex;
@@ -340,8 +357,14 @@ onMounted(checkSubscribeStatus)
.music-card-source-row { .music-card-source-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
min-block-size: 20px; 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 { .music-card-heading {
@@ -355,16 +378,27 @@ onMounted(checkSubscribeStatus)
display: -webkit-box; display: -webkit-box;
color: rgb(var(--v-theme-on-surface)); color: rgb(var(--v-theme-on-surface));
font-size: 1rem; font-size: 1rem;
flex: 1 1 auto;
font-weight: 600; font-weight: 600;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
letter-spacing: 0; letter-spacing: 0;
line-height: 1.4; line-height: 1.4;
min-inline-size: 0;
} }
.music-card-version { .music-card-version {
flex: 0 0 auto; flex: 0 1 auto;
margin-block-start: 0.125rem; 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 { .music-card-supporting {
@@ -437,6 +471,10 @@ onMounted(checkSubscribeStatus)
inline-size: 88px; inline-size: 88px;
} }
.music-card-cover-column {
inline-size: 88px;
}
.music-card-body { .music-card-body {
position: relative; position: relative;
} }
@@ -453,10 +491,6 @@ onMounted(checkSubscribeStatus)
max-inline-size: calc(100% - 0.75rem); max-inline-size: calc(100% - 0.75rem);
} }
.music-card-source-row {
padding-inline-end: 5.5rem;
}
.music-card-footer { .music-card-footer {
display: block; display: block;
padding-block-start: 0.5rem; padding-block-start: 0.5rem;
+7 -11
View File
@@ -322,17 +322,10 @@ async function editSubscribeDialog() {
) )
} }
// 获得mediaid // 获取订阅的统一媒体身份
function getMediaId() { function getMediaId() {
if (props.media?.media_source && props.media?.media_id) { if (!props.media?.media_source || !props.media.media_id) return undefined
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source return { mediaSource: props.media.media_source, mediaId: String(props.media.media_id) }
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
} }
// 查看媒体详情 // 查看媒体详情
@@ -341,10 +334,13 @@ async function viewMediaDetail() {
router.push(buildMusicDetailRoute(props.media)) router.push(buildMusicDetailRoute(props.media))
return return
} }
const identity = getMediaId()
if (!identity) return
router.push({ router.push({
path: '/media', path: '/media',
query: { query: {
mediaid: getMediaId(), media_source: identity.mediaSource,
media_id: identity.mediaId,
title: props.media?.name, title: props.media?.name,
year: props.media?.year, year: props.media?.year,
type: props.media?.type, type: props.media?.type,
+7 -10
View File
@@ -45,24 +45,21 @@ const posterUrl = computed(() => {
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE) return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
}) })
// 获得mediaid // 获取分享订阅的统一媒体身份
function getMediaId() { function getMediaId() {
if (props.media?.media_source && props.media?.media_id) { if (!props.media?.media_source || !props.media.media_id) return undefined
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source return { mediaSource: props.media.media_source, mediaId: String(props.media.media_id) }
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}`
} }
// 查看媒体详情 // 查看媒体详情
async function viewMediaDetail() { async function viewMediaDetail() {
const identity = getMediaId()
if (!identity) return
router.push({ router.push({
path: '/media', path: '/media',
query: { query: {
mediaid: getMediaId(), media_source: identity.mediaSource,
media_id: identity.mediaId,
title: props.media?.name, title: props.media?.name,
year: props.media?.year, year: props.media?.year,
type: props.media?.type, type: props.media?.type,
+5 -1
View File
@@ -2,7 +2,7 @@
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters' import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { SubtitleInfo } from '@/api/types' import type { MediaDataSource, SubtitleInfo } from '@/api/types'
import { getCachedSiteIcon } from '@/utils/siteIconCache' import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache' import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
@@ -19,6 +19,8 @@ const { t } = useI18n()
const props = defineProps({ const props = defineProps({
subtitle: Object as PropType<SubtitleInfo>, subtitle: Object as PropType<SubtitleInfo>,
width: String, width: String,
mediaSource: String as PropType<MediaDataSource>,
mediaId: String,
}) })
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
@@ -79,6 +81,8 @@ async function handleAddDownload() {
{ {
title: subtitle.value?.title, title: subtitle.value?.title,
subtitle: subtitle.value, subtitle: subtitle.value,
mediaSource: props.mediaSource,
mediaId: props.mediaId,
}, },
{ {
done: addDownloadSuccess, done: addDownloadSuccess,
+5 -1
View File
@@ -2,7 +2,7 @@
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters' import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { SubtitleInfo } from '@/api/types' import type { MediaDataSource, SubtitleInfo } from '@/api/types'
import { getCachedSiteIcon } from '@/utils/siteIconCache' import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache' import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
@@ -18,6 +18,8 @@ const { t } = useI18n()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
subtitle: Object as PropType<SubtitleInfo>, subtitle: Object as PropType<SubtitleInfo>,
mediaSource: String as PropType<MediaDataSource>,
mediaId: String,
}) })
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
@@ -68,6 +70,8 @@ async function handleAddDownload() {
{ {
title: subtitle.value?.title, title: subtitle.value?.title,
subtitle: subtitle.value, subtitle: subtitle.value,
mediaSource: props.mediaSource,
mediaId: props.mediaId,
}, },
{ {
done: addDownloadSuccess, done: addDownloadSuccess,
@@ -32,6 +32,8 @@ vi.mock('@/router', () => ({
})) }))
const API_BASE_URL = 'http://localhost/api/v1/' 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 movieSiteListUrl = new URL('site/media/movie', API_BASE_URL).href
const tvSiteListUrl = new URL('site/media/tv', 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 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 subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>() const existsRequest = vi.fn<(url: URL) => void>()
server.use( 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), 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('season')).toBe('2')
expect(subscribeRequest.mock.calls[0][0].searchParams.get('title')).toBe('视口状态剧集') 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({ expect(Object.fromEntries(existsRequest.mock.calls[0][0].searchParams)).toEqual({
media_id: '9101',
media_source: 'themoviedb',
mtype: '电视剧', mtype: '电视剧',
season: '2', season: '2',
title: '视口状态剧集', title: '视口状态剧集',
tmdbid: '9101',
year: '2026', year: '2026',
}) })
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error')) await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
@@ -256,7 +260,7 @@ describe('MediaCard', () => {
const subscribeRequest = vi.fn<(url: URL) => void>() const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>() const existsRequest = vi.fn<(url: URL) => void>()
server.use( server.use(
querySubscribeByMediaHandler('tmdb:9102', { id: 72 }, 200, subscribeRequest), querySubscribeByMediaHandler('9102', { id: 72 }, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
) )
@@ -280,23 +284,36 @@ describe('MediaCard', () => {
}) })
it.each([ it.each([
['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), 'tmdb:9201', '3'], ['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), '9201', '3'],
[ [
'Douban', 'Douban',
createMediaInfo({ douban_id: 'db-9202', season: undefined, tmdb_id: undefined }), createMediaInfo({
'douban:db-9202', douban_id: 'db-9202',
media_id: 'db-9202',
media_source: 'douban',
season: undefined,
tmdb_id: undefined,
}),
'db-9202',
null, null,
], ],
[ [
'Bangumi', 'Bangumi',
createMediaInfo({ bangumi_id: '9203', season: 1, tmdb_id: undefined, type: '电视剧' }), createMediaInfo({
'bangumi:9203', bangumi_id: '9203',
media_id: '9203',
media_source: 'bangumi',
season: 1,
tmdb_id: undefined,
type: '电视剧',
}),
'9203',
'1', '1',
], ],
[ [
'extension', 'Bilibili',
createMediaInfo({ media_id: 'item-9204', media_source: 'custom' as never, tmdb_id: undefined }), createMediaInfo({ media_id: 'item-9204', media_source: 'bilibili', tmdb_id: undefined }),
'custom:item-9204', 'item-9204',
null, null,
], ],
])('queries the current %s media identifier and season', async (_source, media, mediaId, season) => { ])('queries the current %s media identifier and season', async (_source, media, mediaId, season) => {
@@ -311,6 +328,7 @@ describe('MediaCard', () => {
await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce()) await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce())
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe(season) 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 () => { 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 addListener = vi.spyOn(document, 'addEventListener')
const removeListener = vi.spyOn(document, 'removeEventListener') const removeListener = vi.spyOn(document, 'removeEventListener')
server.use( server.use(
querySubscribeByMediaHandler('tmdb:9301', {}, 200, subscribeRequest), querySubscribeByMediaHandler('9301', {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
) )
@@ -346,7 +364,7 @@ describe('MediaCard', () => {
'media details', 'media details',
createMediaInfo({ title: '详情电影', tmdb_id: 9401 }), createMediaInfo({ title: '详情电影', tmdb_id: 9401 }),
'/media', '/media',
{ mediaid: 'tmdb:9401', title: '详情电影', type: '电影', year: '2026' }, { media_id: '9401', media_source: 'themoviedb', title: '详情电影', type: '电影', year: '2026' },
], ],
[ [
'collection browse', 'collection browse',
@@ -367,7 +385,7 @@ describe('MediaCard', () => {
it('opens music detail and skips media-library existence checks', async () => { it('opens music detail and skips media-library existence checks', async () => {
const media = createMediaInfo({ const media = createMediaInfo({
artist: '周杰伦', artist: '周杰伦',
media_id: 'recording-1', media_id: musicBrainzRecordingId,
media_source: 'musicbrainz', media_source: 'musicbrainz',
title: '晴天', title: '晴天',
tmdb_id: undefined, tmdb_id: undefined,
@@ -376,7 +394,7 @@ describe('MediaCard', () => {
const subscribeRequest = vi.fn<(url: URL) => void>() const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>() const existsRequest = vi.fn<(url: URL) => void>()
server.use( server.use(
querySubscribeByMediaHandler('musicbrainz:recording-1', {}, 200, subscribeRequest), querySubscribeByMediaHandler(musicBrainzRecordingId, {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
) )
@@ -395,7 +413,7 @@ describe('MediaCard', () => {
path: '/music/detail', path: '/music/detail',
query: { query: {
media_source: 'musicbrainz', media_source: 'musicbrainz',
mediaid: 'recording-1', media_id: musicBrainzRecordingId,
title: '晴天', title: '晴天',
}, },
}), }),
@@ -405,7 +423,7 @@ describe('MediaCard', () => {
it.each([ it.each([
['TheAudioDB', 'theaudiodb', 'album-2109619', 'Parachutes'], ['TheAudioDB', 'theaudiodb', 'album-2109619', 'Parachutes'],
['豆瓣音乐', 'doubanmusic', '1401853', '范特西'], ['豆瓣音乐', 'doubanmusic', '1401853', '范特西'],
])( ] as const)(
'keeps %s identity for explore-card detail, subscribe, and resource actions', 'keeps %s identity for explore-card detail, subscribe, and resource actions',
async (_label, source, mediaId, title) => { async (_label, source, mediaId, title) => {
const media = createMediaInfo({ const media = createMediaInfo({
@@ -422,7 +440,7 @@ describe('MediaCard', () => {
const subscribeRequest = vi.fn<(url: URL) => void>() const subscribeRequest = vi.fn<(url: URL) => void>()
const created = vi.fn<(payload: Record<string, unknown>) => void>() const created = vi.fn<(payload: Record<string, unknown>) => void>()
server.use( server.use(
querySubscribeByMediaHandler(`${source}:${mediaId}`, {}, 200, subscribeRequest), querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest),
createSubscribeHandler({ data: { id: 101 }, success: true }, 200, created), createSubscribeHandler({ data: { id: 101 }, success: true }, 200, created),
defaultSubscribeConfigHandler('音乐', { show_edit_dialog: false }), defaultSubscribeConfigHandler('音乐', { show_edit_dialog: false }),
) )
@@ -439,7 +457,7 @@ describe('MediaCard', () => {
await waitFor(() => await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith({ expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/music/album', 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({ expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/resource', path: '/resource',
query: expect.objectContaining({ query: expect.objectContaining({
keyword: `${source}:${mediaId}`, media_id: mediaId,
media_source: source,
music_type: 'album', music_type: 'album',
sites: '21', sites: '21',
type: '音乐', type: '音乐',
@@ -470,7 +489,7 @@ describe('MediaCard', () => {
it('uses an album placeholder instead of the movie fallback image for music without a cover', async () => { it('uses an album placeholder instead of the movie fallback image for music without a cover', async () => {
const media = createMediaInfo({ const media = createMediaInfo({
media_id: 'recording-2', media_id: secondMusicBrainzRecordingId,
media_source: 'musicbrainz', media_source: 'musicbrainz',
poster_path: undefined, poster_path: undefined,
title: '无封面歌曲', title: '无封面歌曲',
@@ -497,7 +516,8 @@ describe('MediaCard', () => {
path: '/resource', path: '/resource',
query: { query: {
area: 'title', area: 'title',
keyword: 'tmdb:9501', media_id: '9501',
media_source: 'themoviedb',
season: 4, season: 4,
sites: '3,5', sites: '3,5',
title: '直接搜索剧集', title: '直接搜索剧集',
@@ -598,14 +618,22 @@ describe('MediaCard', () => {
const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })) const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' }))
const subscribeListRequest = vi.fn<(url: URL) => void>() const subscribeListRequest = vi.fn<(url: URL) => void>()
server.use( server.use(
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }), querySubscribeByMediaHandler('9551', { id: 81, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }), mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler( subscribeListHandler(
[ [
{ best_version: 0, id: 81, season: 3, 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, season: 1, tmdbid: 9551, type: '电视剧' }, {
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' }, best_version: 1,
{ id: 84, tmdbid: 9551, type: '电影' }, 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, 200,
subscribeListRequest, 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({ const media = createMediaInfo({
media_id: 'series-9553', media_id: 'series-9553',
media_source: 'custom' as never, media_source: 'bilibili',
season: 2, season: 2,
tmdb_id: undefined, tmdb_id: undefined,
type: '电视剧', type: '电视剧',
}) })
server.use( server.use(
querySubscribeByMediaHandler('custom:series-9553', { id: 91, season: 2 }), querySubscribeByMediaHandler('series-9553', { id: 91, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }), mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler([ subscribeListHandler([
{ id: 91, mediaid: 'custom:series-9553', season: 2, type: '电视剧' }, { id: 91, media_id: 'series-9553', media_source: 'bilibili', season: 2, type: '电视剧' },
{ id: 92, mediaid: 'custom:other', season: 5, type: '电视剧' }, { id: 92, media_id: 'other', media_source: 'bilibili', season: 5, type: '电视剧' },
]), ]),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () => http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: {} }, success: true }), HttpResponse.json({ data: { value: {} }, success: true }),
@@ -684,7 +712,7 @@ describe('MediaCard', () => {
tmdb_id: undefined, tmdb_id: undefined,
type: '电视剧', type: '电视剧',
}), }),
'tmdb:series-9554', 'series-9554',
[ [
{ id: 93, media_id: 'series-9554', media_source: 'themoviedb', season: 4, type: '电视剧' }, { id: 93, media_id: 'series-9554', media_source: 'themoviedb', season: 4, type: '电视剧' },
{ id: 94, media_id: 'other', media_source: 'themoviedb', season: 5, type: '电视剧' }, { id: 94, media_id: 'other', media_source: 'themoviedb', season: 5, type: '电视剧' },
@@ -692,12 +720,19 @@ describe('MediaCard', () => {
[4], [4],
], ],
[ [
'legacy AniList identity', 'AniList identity',
createMediaInfo({ anilist_id: 154588, season: 2, media_source: 'anilist', tmdb_id: undefined, type: '电视剧' }), createMediaInfo({
'anilist:154588', anilist_id: 154588,
media_id: '154588',
season: 2,
media_source: 'anilist',
tmdb_id: undefined,
type: '电视剧',
}),
'154588',
[ [
{ anilistid: 154588, id: 95, season: 1, type: '电视剧' }, { id: 95, media_id: '154588', media_source: 'anilist', season: 1, type: '电视剧' },
{ anilistid: 154589, id: 96, season: 3, type: '电视剧' }, { id: 96, media_id: '154589', media_source: 'anilist', season: 3, type: '电视剧' },
], ],
[1], [1],
], ],
@@ -411,21 +411,11 @@ describe('SubscribeCard interaction boundaries', () => {
}) })
it.each([ it.each([
['TMDB before all fallbacks', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'], ['TMDB', { media_id: '11', media_source: 'themoviedb' }, 'themoviedb', '11'],
['Douban before Bangumi', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'], ['Douban', { media_id: '22', media_source: 'douban' }, 'douban', '22'],
['Bangumi before custom', { bangumiid: 33, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'], ['Bangumi', { media_id: '33', media_source: 'bangumi' }, 'bangumi', '33'],
[ ['AniList', { media_id: '55', media_source: 'anilist' }, 'anilist', '55'],
'AniList before legacy custom', ] as const)('routes media details with %s', async (_case, identifiers, mediaSource, mediaId) => {
{ anilistid: 55, bangumiid: undefined, mediaid: 'custom:44', tmdbid: 0 },
'anilist:55',
],
['selected primary identity', { media_id: '66', media_source: 'anilist', tmdbid: 11 }, 'anilist:66'],
[
'custom media ID last',
{ bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 },
'custom:44',
],
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
const { container, media } = await renderCard(identifiers) const { container, media } = await renderCard(identifiers)
await chooseMenuItem(container, '媒体详情') await chooseMenuItem(container, '媒体详情')
@@ -433,7 +423,8 @@ describe('SubscribeCard interaction boundaries', () => {
expect(mocks.routerPush).toHaveBeenCalledWith({ expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media', path: '/media',
query: { query: {
mediaid: expectedMediaId, media_id: mediaId,
media_source: mediaSource,
title: media.name, title: media.name,
type: media.type, type: media.type,
year: media.year, year: media.year,
@@ -124,27 +124,31 @@ describe('SubscribeShareCard', () => {
}) })
it.each([ it.each([
['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'], ['TMDB', { media_id: '1101', media_source: 'themoviedb' }, 'themoviedb', '1101'],
['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'], ['Douban', { media_id: '2202', media_source: 'douban' }, 'douban', '2202'],
['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'], ['Bangumi', { media_id: '3303', media_source: 'bangumi' }, 'bangumi', '3303'],
['AniList without other IDs', { anilistid: 4404, bangumiid: undefined, tmdbid: undefined }, 'anilist:4404'], ['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, mediaid) => { ] as const)(
const { container, media } = await renderCard(ids) 'routes media details with %s while keeping the fork dialog closed',
const poster = await loadPoster(container) 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({ expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media', path: '/media',
query: { query: {
mediaid, media_id: mediaId,
title: media.name, media_source: mediaSource,
type: media.type, title: media.name,
year: media.year, type: media.type,
}, year: media.year,
}) },
expect(mocks.openSharedDialog).not.toHaveBeenCalled() })
}) expect(mocks.openSharedDialog).not.toHaveBeenCalled()
},
)
it('opens the fork dialog with the exact media and replaces it with editing after fork success', async () => { it('opens the fork dialog with the exact media and replaces it with editing after fork success', async () => {
const { container, media } = await renderCard() const { container, media } = await renderCard()
@@ -77,7 +77,8 @@ function createContext(overrides: ContextOverrides = {}): Context {
freedate_diff: '', freedate_diff: '',
grabs: 3, grabs: 3,
hit_and_run: false, hit_and_run: false,
imdbid: 'tt1000001', media_id: 'tt1000001',
media_source: 'imdb',
labels: [], labels: [],
page_url: 'https://tracker.example.com/details/1001', page_url: 'https://tracker.example.com/details/1001',
peers: 2, peers: 2,
@@ -90,7 +90,8 @@ function createContext(overrides: ContextOverrides = {}): Context {
freedate_diff: '', freedate_diff: '',
grabs: 3, grabs: 3,
hit_and_run: false, hit_and_run: false,
imdbid: 'tt1000001', media_id: 'tt1000001',
media_source: 'imdb',
labels: [], labels: [],
page_url: 'https://tracker.example.com/details/1001', page_url: 'https://tracker.example.com/details/1001',
peers: 2, 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 { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue' import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId' import { isMediaDataSource, isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
// 多语言支持 // 多语言支持
@@ -32,25 +32,15 @@ const props = defineProps({
torrent: Object as PropType<TorrentInfo>, torrent: Object as PropType<TorrentInfo>,
}) })
// 可选的媒体数据源 // 当前识别类型:优先使用已随媒体或种子传入的完整身份,否则使用识别上下文。
const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = [
'themoviedb',
'douban',
'bangumi',
'anilist',
'musicbrainz',
'theaudiodb',
'doubanmusic',
]
// 当前识别类型:优先使用媒体自身的数据源,否则使用全局识别来源
const mediaSource = computed<MediaDataSource>(() => { const mediaSource = computed<MediaDataSource>(() => {
const source = props.media?.media_source if (isMediaDataSource(props.media?.media_source) && props.media?.media_id?.trim()) return props.media.media_source
if (source && SUPPORTED_MEDIA_SOURCES.includes(source)) return 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 (props.torrent?.category === '音乐' || props.torrent?.category === 'music') return 'musicbrainz'
if (SUPPORTED_MEDIA_SOURCES.includes(globalSettings.RECOGNIZE_SOURCE as MediaDataSource)) { if (isMediaDataSource(globalSettings.RECOGNIZE_SOURCE)) return globalSettings.RECOGNIZE_SOURCE
return globalSettings.RECOGNIZE_SOURCE as MediaDataSource
}
return 'themoviedb' return 'themoviedb'
}) })
@@ -91,12 +81,16 @@ const musicEntityOptions = computed(() => [
{ title: t('setting.cache.musicType.album'), value: 'album' }, { title: t('setting.cache.musicType.album'), value: 'album' },
]) ])
// 音乐媒体自带来源原生 ID,打开对话框时预填到高级选项中辅助识别 // 打开对话框时预填媒体或种子携带的完整身份,不从辅助 ID 字段推导
watch( watch(
() => props.media, () => [props.media, props.torrent] as const,
media => { ([media, torrent]) => {
if (media?.media_source && SUPPORTED_MEDIA_SOURCES.includes(media.media_source) && media.media_id) { if (isMediaDataSource(media?.media_source) && media.media_id?.trim()) {
mediaId.value = media.media_id 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') { if (media?.music_type === 'recording' || media?.music_type === 'album') {
musicType.value = media.music_type musicType.value = media.music_type
@@ -114,16 +108,18 @@ function handleMediaSelected(item: Pick<MediaInfo, 'music_type'>) {
// 当前数据源对应的原生ID标签。 // 当前数据源对应的原生ID标签。
const mediaIdLabel = computed(() => { const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = { const labels: Partial<Record<MediaDataSource, string>> = {
themoviedb: t('dialog.reorganize.tmdbId'), themoviedb: t('dialog.reorganize.tmdbId'),
douban: t('dialog.reorganize.doubanId'), douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'), bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'), anilist: t('dialog.reorganize.anilistId'),
imdb: 'IMDb ID',
tvdb: 'TVDB ID',
musicbrainz: 'MusicBrainz ID', musicbrainz: 'MusicBrainz ID',
theaudiodb: 'TheAudioDB ID', theaudiodb: 'TheAudioDB ID',
doubanmusic: t('dialog.reorganize.doubanId'), doubanmusic: t('dialog.reorganize.doubanId'),
} }
return labels[mediaSource.value] return labels[mediaSource.value] ?? t('dialog.reorganize.mediaId')
}) })
// TMDB选择对话框 // TMDB选择对话框
@@ -221,10 +217,10 @@ async function addDownload() {
payload.media_in = props.media payload.media_in = props.media
} }
// 添加媒体ID辅助识别 const normalizedMediaId = mediaId.value?.trim()
if (mediaId.value) { if (normalizedMediaId && isValidMediaSourceId(normalizedMediaId, mediaSource.value)) {
payload.media_source = mediaSource.value payload.media_source = mediaSource.value
payload.media_id = mediaId.value payload.media_id = normalizedMediaId
if (isMusicSelection.value) payload.music_type = musicType.value if (isMusicSelection.value) payload.music_type = musicType.value
} }
@@ -2,12 +2,18 @@
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import api from '@/api' import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress' 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 { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue' import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { numberValidator } from '@/@validators'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { isMediaDataSource, isValidMediaSourceId } from '@/utils/mediaId'
// 多语言支持 // 多语言支持
const { t } = useI18n() const { t } = useI18n()
@@ -16,18 +22,22 @@ const { t } = useI18n()
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings const globalSettings = globalSettingsStore.globalSettings
// 当前识别类型
const mediaSource = ref<MediaDataSource>(
['themoviedb', 'douban', 'bangumi', 'anilist'].includes(globalSettings.RECOGNIZE_SOURCE)
? globalSettings.RECOGNIZE_SOURCE
: 'themoviedb',
)
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps<{
title: String, title?: string
subtitle: Object as PropType<SubtitleInfo>, 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']) const emit = defineEmits(['done', 'error', 'close'])
@@ -45,23 +55,49 @@ const directories = ref<TransferDirectoryConf[]>([])
const loading = ref(false) const loading = ref(false)
// 是否显示高级选项 // 是否显示高级选项
const showAdvancedOptions = ref(false) const showAdvancedOptions = ref(!initialMediaId)
// 当前数据源的原生媒体ID // 当前数据源的原生媒体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标签。 // 当前数据源对应的原生ID标签。
const mediaIdLabel = computed(() => { const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = { const labels: Partial<Record<MediaDataSource, string>> = {
themoviedb: t('dialog.reorganize.tmdbId'), themoviedb: t('dialog.reorganize.tmdbId'),
douban: t('dialog.reorganize.doubanId'), douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'), bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'), anilist: t('dialog.reorganize.anilistId'),
imdb: 'IMDb ID',
tvdb: 'TVDB ID',
musicbrainz: 'MusicBrainz ID', musicbrainz: 'MusicBrainz ID',
theaudiodb: 'TheAudioDB ID', theaudiodb: 'TheAudioDB ID',
doubanmusic: t('dialog.reorganize.doubanId'), doubanmusic: t('dialog.reorganize.doubanId'),
} }
return labels[mediaSource.value] return labels[mediaSource.value] ?? t('dialog.reorganize.mediaId')
}) })
// TMDB选择对话框 // TMDB选择对话框
@@ -109,22 +145,21 @@ const targetDirectories = computed(() => {
// 下载字幕 // 下载字幕
async function addSubtitleDownload() { async function addSubtitleDownload() {
if (!normalizedMediaId.value || !hasValidMediaIdentity.value) return
startNProgress() startNProgress()
loading.value = true loading.value = true
try { try {
const payload: { const payload: {
media_id?: string media_id: string
media_source?: MediaDataSource media_source: MediaDataSource
save_path: string | null save_path: string | null
subtitle_in: SubtitleInfo | undefined subtitle_in: SubtitleInfo | undefined
} = { } = {
subtitle_in: props.subtitle, subtitle_in: props.subtitle,
save_path: selectedDirectory.value, save_path: selectedDirectory.value,
} media_source: mediaSource.value,
media_id: normalizedMediaId.value,
if (mediaId.value) {
payload.media_source = mediaSource.value
payload.media_id = mediaId.value
} }
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>('download/subtitle', payload) const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>('download/subtitle', payload)
@@ -241,12 +276,24 @@ onMounted(() => {
</VCol> </VCol>
</VRow> </VRow>
<VRow v-show="showAdvancedOptions" class="px-5"> <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 <VTextField
v-model="mediaId" v-model="selectedMediaId"
:label="mediaIdLabel" :label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')" :placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]" :rules="[
(value: any) => isValidMediaSourceId(value, mediaSource) || t('dialog.reorganize.mediaIdInvalid'),
]"
append-inner-icon="mdi-magnify" append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')" :hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint persistent-hint
@@ -259,13 +306,19 @@ onMounted(() => {
</VRow> </VRow>
</VCardText> </VCardText>
<VCardText class="text-center"> <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 }} {{ buttonText }}
</VBtn> </VBtn>
</VCardText> </VCardText>
</VCard> </VCard>
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh"> <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>
</VDialog> </VDialog>
</template> </template>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { MediaDataSource, MusicEntityType } from '@/api/types' import type { MediaDataSource, MusicEntityType } from '@/api/types'
import { isMusicMediaSource } from '@/utils/mediaId' import { isMediaDataSource, isMusicMediaSource } from '@/utils/mediaId'
const { t } = useI18n() const { t } = useI18n()
@@ -35,7 +35,9 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void (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 mediaId = ref<string>()
const musicType = ref<Exclude<MusicEntityType, 'artist'>>(props.musicType) const musicType = ref<Exclude<MusicEntityType, 'artist'>>(props.musicType)
const isMusicSelection = computed(() => isMusicMediaSource(mediaSource.value)) const isMusicSelection = computed(() => isMusicMediaSource(mediaSource.value))
@@ -76,9 +78,10 @@ const visible = computed({
// 提交重新识别参数给缓存页执行接口调用。 // 提交重新识别参数给缓存页执行接口调用。
function submitReidentify() { function submitReidentify() {
const normalizedMediaId = mediaId.value?.trim() || undefined
emit('confirm', { emit('confirm', {
mediaSource: mediaSource.value, mediaSource: normalizedMediaId ? mediaSource.value : undefined,
mediaId: mediaId.value?.trim() || undefined, mediaId: normalizedMediaId,
musicType: isMusicSelection.value ? musicType.value : undefined, musicType: isMusicSelection.value ? musicType.value : undefined,
}) })
} }
+7 -10
View File
@@ -95,24 +95,21 @@ const posterUrl = computed(() => {
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE) return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
}) })
// 获得mediaid // 获取待复制订阅的统一媒体身份
function getMediaId() { function getMediaId() {
if (props.media?.media_source && props.media?.media_id) { if (!props.media?.media_source || !props.media.media_id) return undefined
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source return { mediaSource: props.media.media_source, mediaId: String(props.media.media_id) }
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}`
} }
// 查看媒体详情 // 查看媒体详情
async function viewMediaDetail() { async function viewMediaDetail() {
const identity = getMediaId()
if (!identity) return
router.push({ router.push({
path: '/media', path: '/media',
query: { query: {
mediaid: getMediaId(), media_source: identity.mediaSource,
media_id: identity.mediaId,
title: props.media?.name, title: props.media?.name,
year: props.media?.year, year: props.media?.year,
type: props.media?.type, type: props.media?.type,
+12 -6
View File
@@ -278,8 +278,8 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
}) })
// 查询指定 TMDB 剧集的所有剧集组。 // 查询指定 TMDB 剧集的所有剧集组。
async function getEpisodeGroups(tmdbid?: number | string) { async function getEpisodeGroups(tmdbId?: number | string) {
const normalizedTmdbId = Number(tmdbid) const normalizedTmdbId = Number(tmdbId)
if (!Number.isInteger(normalizedTmdbId) || normalizedTmdbId <= 0) { if (!Number.isInteger(normalizedTmdbId) || normalizedTmdbId <= 0) {
episodeGroups.value = [] episodeGroups.value = []
return return
@@ -350,7 +350,7 @@ const mediaSource = computed(() => transferForm.media_source ?? 'themoviedb')
// 当前数据源对应的原生ID标签。 // 当前数据源对应的原生ID标签。
const mediaIdLabel = computed(() => { const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = { const labels: Partial<Record<MediaDataSource, string>> = {
themoviedb: t('dialog.reorganize.tmdbId'), themoviedb: t('dialog.reorganize.tmdbId'),
douban: t('dialog.reorganize.doubanId'), douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'), bangumi: t('dialog.reorganize.bangumiId'),
@@ -359,7 +359,7 @@ const mediaIdLabel = computed(() => {
theaudiodb: 'TheAudioDB ID', theaudiodb: 'TheAudioDB ID',
doubanmusic: t('dialog.reorganize.doubanId'), 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 }) { function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; logid?: number; preview?: boolean }) {
const sourceItem = options.item ?? (options.items?.length ? options.items[0] : ({} as FileItem)) const sourceItem = options.item ?? (options.items?.length ? options.items[0] : ({} as FileItem))
const normalizedMediaId = normalizeOptionalText(transferForm.media_id)
const payload: ManualTransferPayload = { const payload: ManualTransferPayload = {
...transferForm, ...transferForm,
fileitem: sourceItem, fileitem: sourceItem,
@@ -941,11 +942,16 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
target_storage: normalizeOptionalText(transferForm.target_storage), target_storage: normalizeOptionalText(transferForm.target_storage),
target_path: normalizeTargetPath(transferForm.target_path), target_path: normalizeTargetPath(transferForm.target_path),
transfer_type: normalizeOptionalText(transferForm.transfer_type), transfer_type: normalizeOptionalText(transferForm.transfer_type),
media_source: mediaSource.value, media_source: undefined,
media_id: normalizeOptionalText(transferForm.media_id), media_id: undefined,
episode_group: normalizeEpisodeGroup(transferForm.episode_group), episode_group: normalizeEpisodeGroup(transferForm.episode_group),
} }
if (normalizedMediaId) {
payload.media_source = mediaSource.value
payload.media_id = normalizedMediaId
}
if (options.items?.length) { if (options.items?.length) {
payload.fileitems = options.items payload.fileitems = options.items
if (!options.item) { if (!options.item) {
+2 -2
View File
@@ -56,7 +56,7 @@ const dialogSubtitle = computed(() => {
}) })
const mediaIdLabel = computed(() => { const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = { const labels: Partial<Record<MediaDataSource, string>> = {
themoviedb: t('dialog.reorganize.tmdbId'), themoviedb: t('dialog.reorganize.tmdbId'),
douban: t('dialog.reorganize.doubanId'), douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'), bangumi: t('dialog.reorganize.bangumiId'),
@@ -65,7 +65,7 @@ const mediaIdLabel = computed(() => {
theaudiodb: 'TheAudioDB ID', theaudiodb: 'TheAudioDB ID',
doubanmusic: t('dialog.reorganize.doubanId'), doubanmusic: t('dialog.reorganize.doubanId'),
} }
return labels[mediaSource.value] return labels[mediaSource.value] ?? t('dialog.reorganize.mediaId')
}) })
const canSubmit = computed(() => { const canSubmit = computed(() => {
@@ -73,7 +73,8 @@ const subscribeForm = ref<Subscribe>({
name: '', name: '',
year: '', year: '',
type: '', type: '',
tmdbid: 0, media_source: undefined,
media_id: undefined,
state: '', state: '',
last_update: '', last_update: '',
username: '', username: '',
@@ -141,14 +142,13 @@ function episodeGroupItemProps(item: { title: string; subtitle: string }) {
// 查询所有剧集组 // 查询所有剧集组
async function getEpisodeGroups() { async function getEpisodeGroups() {
// 兼容未记录主来源的旧 TMDB 订阅;明确为其他来源时不使用辅助 TMDB ID 查询剧集组。 if (subscribeForm.value.media_source !== 'themoviedb') return
if (subscribeForm.value.media_source && subscribeForm.value.media_source !== 'themoviedb') return if (!subscribeForm.value.media_id) {
if (!subscribeForm.value.tmdbid) { console.warn('media_id is not set or is empty')
console.warn('tmdbid is not set or is empty')
return return
} }
try { 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) { } catch (error) {
console.error(error) console.error(error)
} }
@@ -7,7 +7,6 @@ import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { import {
getMediaSubscribeId,
getMediaSubscribeIdentity, getMediaSubscribeIdentity,
type SeasonSubscribeModes, type SeasonSubscribeModes,
type SubscribeMode, type SubscribeMode,
@@ -171,16 +170,11 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
return options return options
}) })
// 获得mediaid
function getMediaId() {
return getMediaSubscribeId(props.media)
}
// 查询所有剧集组 // 查询所有剧集组
async function getEpisodeGroups() { async function getEpisodeGroups() {
if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb') return if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb') return
if (!props.media?.tmdb_id) { 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 return
} }
try { try {
@@ -194,11 +188,14 @@ async function getEpisodeGroups() {
// 查询媒体的季信息 // 查询媒体的季信息
async function getMediaSeasons() { async function getMediaSeasons() {
const identity = getMediaSubscribeIdentity(props.media)
if (!identity) return
isRefreshed.value = false isRefreshed.value = false
try { try {
seasonInfos.value = await api.get('media/seasons', { seasonInfos.value = await api.get('media/seasons', {
params: { params: {
mediaid: getMediaId(), media_source: identity.source,
media_id: identity.mediaId,
title: props.media?.title, title: props.media?.title,
year: props.media?.year, year: props.media?.year,
season: props.media?.season, season: props.media?.season,
@@ -138,7 +138,8 @@ function createTorrent(overrides: Partial<TorrentInfo> = {}): TorrentInfo {
freedate_diff: '', freedate_diff: '',
grabs: 3, grabs: 3,
hit_and_run: false, hit_and_run: false,
imdbid: 'tt0060001', media_id: 'tt0060001',
media_source: 'imdb',
labels: [], labels: [],
peers: 2, peers: 2,
pri_order: 0, pri_order: 0,
@@ -158,6 +159,7 @@ function createMedia(overrides: Partial<MediaInfo> = {}): MediaInfo {
return { return {
episode_run_time: [], episode_run_time: [],
origin_country: [], origin_country: [],
media_id: '6001',
media_source: 'themoviedb', media_source: 'themoviedb',
title: '测试电影', title: '测试电影',
tmdb_id: 6001, tmdb_id: 6001,
@@ -313,7 +315,10 @@ describe('AddDownloadDialog submissions', () => {
server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted)) server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted))
const user = userEvent.setup() 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.click(screen.getByRole('button', { name: '显示高级选项' }))
await user.type(screen.getByLabelText('Bangumi编号'), '24680') await user.type(screen.getByLabelText('Bangumi编号'), '24680')
@@ -330,7 +335,7 @@ describe('AddDownloadDialog submissions', () => {
const deferred = createDeferred<JsonBodyType>() const deferred = createDeferred<JsonBodyType>()
const submitted = vi.fn() const submitted = vi.fn()
server.use(downloadHandler('download/add', deferred.promise, 200, submitted)) 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 user = userEvent.setup()
const { events } = await renderDialog({ const { events } = await renderDialog({
directories: [createDirectory({ download_path: '/downloads/remote', storage: 'rclone' })], directories: [createDirectory({ download_path: '/downloads/remote', storage: 'rclone' })],
@@ -378,7 +383,12 @@ describe('AddDownloadDialog submissions', () => {
await renderDialog({ await renderDialog({
recognizeSource: 'themoviedb', 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: '显示高级选项' })) 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 () => { it('uses download/ for an existing media without locking unrelated optional fields', async () => {
const submitted = vi.fn() const submitted = vi.fn()
server.use(downloadHandler('download/', { data: null, success: true }, 200, submitted)) 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 AddSubtitleDownloadDialog from '@/components/dialog/AddSubtitleDownloadDialog.vue'
import { screen, waitFor } from '@testing-library/vue' import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
@@ -30,7 +30,7 @@ vi.mock('vue-toastification', () => ({
const SelectStub = defineComponent({ const SelectStub = defineComponent({
name: 'NativeSelectStub', name: 'NativeSelectStub',
props: { props: {
items: { type: Array as PropType<string[]>, default: () => [] }, items: { type: Array as PropType<Array<string | { title: string; value: string }>>, default: () => [] },
label: String, label: String,
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
}, },
@@ -48,7 +48,11 @@ const SelectStub = defineComponent({
}, },
[ [
h('option', { value: '' }, '默认'), 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({ async function renderDialog({
directories = [], directories = [],
mediaId = '6001',
mediaSource,
recognizeSource = 'themoviedb', recognizeSource = 'themoviedb',
subtitle = createSubtitle(), subtitle = createSubtitle(),
}: { }: {
directories?: TransferDirectoryConf[] directories?: TransferDirectoryConf[]
mediaId?: string | null
mediaSource?: MediaDataSource
recognizeSource?: string recognizeSource?: string
subtitle?: SubtitleInfo subtitle?: SubtitleInfo
} = {}) { } = {}) {
@@ -186,6 +194,7 @@ async function renderDialog({
VCombobox: SelectStub, VCombobox: SelectStub,
VDialog: DialogStub, VDialog: DialogStub,
VDialogCloseBtn: DialogCloseButtonStub, VDialogCloseBtn: DialogCloseButtonStub,
VSelect: SelectStub,
VTextField: TextFieldStub, VTextField: TextFieldStub,
}, },
}, },
@@ -201,6 +210,8 @@ async function renderDialog({
onClose: events.close, onClose: events.close,
onDone: events.done, onDone: events.done,
onError: events.error, onError: events.error,
mediaId: mediaId ?? undefined,
mediaSource,
subtitle, subtitle,
title: '测试电影', title: '测试电影',
}, },
@@ -269,9 +280,8 @@ describe('AddSubtitleDownloadDialog submissions', () => {
server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted)) server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted))
const user = userEvent.setup() 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.type(screen.getByLabelText('豆瓣编号'), '13579')
await user.click(screen.getByRole('button', { name: '下载字幕' })) await user.click(screen.getByRole('button', { name: '下载字幕' }))
@@ -291,13 +301,13 @@ describe('AddSubtitleDownloadDialog submissions', () => {
const user = userEvent.setup() const user = userEvent.setup()
const { events } = await renderDialog({ const { events } = await renderDialog({
directories: [createDirectory({ download_path: '/subtitles/remote', storage: 's3' })], directories: [createDirectory({ download_path: '/subtitles/remote', storage: 's3' })],
mediaId: null,
recognizeSource: 'anilist', recognizeSource: 'anilist',
subtitle, subtitle,
}) })
await screen.findByRole('option', { name: 's3:/subtitles/remote' }) await screen.findByRole('option', { name: 's3:/subtitles/remote' })
await user.selectOptions(screen.getByLabelText('保存目录(自动)'), '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: '查询媒体编号' }))
await user.click(screen.getByRole('button', { name: '选择媒体编号' })) await user.click(screen.getByRole('button', { name: '选择媒体编号' }))
const submitButton = screen.getByRole('button', { name: '下载字幕' }) const submitButton = screen.getByRole('button', { name: '下载字幕' })
@@ -325,10 +335,25 @@ describe('AddSubtitleDownloadDialog submissions', () => {
expect(submitButton).not.toBeDisabled() 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 () => { it('treats success:false at HTTP 200 as a business failure', async () => {
server.use(subtitleDownloadHandler({ data: null, message: '签名已过期', success: false })) server.use(subtitleDownloadHandler({ data: null, message: '签名已过期', success: false }))
const user = userEvent.setup() const user = userEvent.setup()
const { events } = await renderDialog() const { events } = await renderDialog({ mediaSource: 'themoviedb' })
await user.click(screen.getByRole('button', { name: '下载字幕' })) 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 () => { it('clears loading and progress after an HTTP failure without emitting done', async () => {
server.use(subtitleDownloadHandler({ message: '服务异常', success: false }, 500)) server.use(subtitleDownloadHandler({ message: '服务异常', success: false }, 500))
const user = userEvent.setup() const user = userEvent.setup()
const { events } = await renderDialog() const { events } = await renderDialog({ mediaSource: 'themoviedb' })
await user.click(screen.getByRole('button', { name: '下载字幕' })) await user.click(screen.getByRole('button', { name: '下载字幕' }))
@@ -40,4 +40,17 @@ describe('CacheReidentifyDialog', () => {
musicType: 'album', 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 { const mediaDetailCases = [
anilistid?: number ['TMDB', 'themoviedb', '6301'],
bangumiid?: number ['Douban', 'douban', 'db-6302'],
doubanid?: string ['Bangumi', 'bangumi', '6303'],
tmdbid?: number ['AniList', 'anilist', '6304'],
}
const mediaDetailCases: Array<[string, MediaIdentifiers, string]> = [
['TMDB', { tmdbid: 6301 }, 'tmdb:6301'],
['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'],
['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'],
['AniList', { anilistid: 6304, bangumiid: undefined, tmdbid: undefined }, 'anilist:6304'],
] ]
function createDeferred() { function createDeferred() {
@@ -373,32 +366,26 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
expect(events.close).toHaveBeenCalledOnce() expect(events.close).toHaveBeenCalledOnce()
}) })
it.each(mediaDetailCases)( it.each(mediaDetailCases)('routes %s shares to their media details', async (_source, mediaSource, mediaId) => {
'routes %s shares to their media details', const media = createSubscribeShare({
async (_source, identifiers, expectedMediaId) => { media_id: mediaId,
const media: SubscribeShare = { media_source: mediaSource as SubscribeShare['media_source'],
...createSubscribeShare({ })
anilistid: identifiers.anilistid, server.use(followSubscribersSettingHandler([]))
doubanid: identifiers.doubanid, const user = userEvent.setup()
tmdbid: identifiers.tmdbid, await renderDialog(media)
}),
bangumiid: identifiers.bangumiid,
}
server.use(followSubscribersSettingHandler([]))
const user = userEvent.setup()
await renderDialog(media)
await user.click(screen.getByRole('button', { name: '查看媒体详情' })) await user.click(screen.getByRole('button', { name: '查看媒体详情' }))
expect(mocks.routerPush).toHaveBeenCalledWith({ expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media', path: '/media',
query: { query: {
mediaid: expectedMediaId, media_id: mediaId,
title: media.name, media_source: mediaSource,
type: media.type, title: media.name,
year: media.year, type: media.type,
}, year: media.year,
}) },
}, })
) })
}) })
@@ -485,14 +485,14 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
expect.objectContaining({ expect.objectContaining({
episode_group: null, episode_group: null,
fileitems: [first, second], fileitems: [first, second],
media_id: null,
media_source: 'themoviedb',
target_path: null, target_path: null,
target_storage: null, target_storage: null,
transfer_type: null, transfer_type: null,
}), }),
]) ])
expect(bodies[0]).not.toHaveProperty('fileitem') 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.progressControllers).toHaveLength(0)
expect(mocks.toastSuccess).toHaveBeenCalledWith('文件 共 2 项 已加入整理队列!') expect(mocks.toastSuccess).toHaveBeenCalledWith('文件 共 2 项 已加入整理队列!')
}) })
@@ -728,10 +728,10 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
expect(bodies[1]).toEqual( expect(bodies[1]).toEqual(
expect.objectContaining({ expect.objectContaining({
episode_group: null, 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 () => { 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 () => { it('renders the bundled music icon in the music search action', async () => {
const user = userEvent.setup() const user = userEvent.setup()
await renderSearchBar() await renderSearchBar()
@@ -91,7 +91,8 @@ describe('SubscribeEditDialog', () => {
name: '季度测试剧', name: '季度测试剧',
search_imdbid: 0, search_imdbid: 0,
season: 2, season: 2,
tmdbid: 8010, media_id: '8010',
media_source: 'themoviedb',
type: '电视剧', type: '电视剧',
}) })
const episodeGroupsRequested = vi.fn() const episodeGroupsRequested = vi.fn()
@@ -117,7 +118,7 @@ describe('SubscribeEditDialog', () => {
}) })
it('keeps movie titles free of season suffixes and skips episode groups', async () => { 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() const episodeGroupsRequested = vi.fn()
server.use(subscribeDetailsHandler(802, record)) server.use(subscribeDetailsHandler(802, record))
useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8020 }) 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 () => { it('skips episode groups for a non-TMDB subscription with an auxiliary TMDB ID', async () => {
const record = createSubscribe({ const record = createSubscribe({
anilistid: 154587,
id: 810, id: 810,
media_id: '154587', media_id: '154587',
media_source: 'anilist', media_source: 'anilist',
name: 'AniList 编辑测试剧', name: 'AniList 编辑测试剧',
season: 1, season: 1,
tmdbid: 8100,
type: '电视剧', type: '电视剧',
}) })
const episodeGroupsRequested = vi.fn() const episodeGroupsRequested = vi.fn()
@@ -263,7 +262,6 @@ describe('SubscribeEditDialog', () => {
min_sample_rate: 96000, min_sample_rate: 96000,
music_type: 'album', music_type: 'album',
name: '音乐音质测试专辑', name: '音乐音质测试专辑',
tmdbid: 0,
type: '音乐', type: '音乐',
}) })
const updated = vi.fn() const updated = vi.fn()
@@ -303,7 +301,8 @@ describe('SubscribeEditDialog', () => {
name: '完整表单测试剧', name: '完整表单测试剧',
search_imdbid: 0, search_imdbid: 0,
season: 1, season: 1,
tmdbid: 8090, media_id: '8090',
media_source: 'themoviedb',
type: '电视剧', type: '电视剧',
}) })
const updated = vi.fn() const updated = vi.fn()
@@ -406,7 +405,8 @@ describe('SubscribeEditDialog', () => {
keyword: '旧关键词', keyword: '旧关键词',
name: '编辑测试剧', name: '编辑测试剧',
season: 1, season: 1,
tmdbid: 8030, media_id: '8030',
media_source: 'themoviedb',
type: '电视剧', type: '电视剧',
}) })
const updated = vi.fn() const updated = vi.fn()
@@ -439,7 +439,7 @@ describe('SubscribeEditDialog', () => {
['HTTP failure', 500, { message: 'server down', success: false }, '失败编辑项 更新失败:server down'], ['HTTP failure', 500, { message: 'server down', success: false }, '失败编辑项 更新失败:server down'],
])('keeps an edit dialog usable after an update %s', async (_case, status, response, expectedMessage) => { ])('keeps an edit dialog usable after an update %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) 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)) server.use(subscribeDetailsHandler(804, record), updateSubscribeHandler(response, status))
useDialogOptions({ tmdbId: 8040 }) useDialogOptions({ tmdbId: 8040 })
const { events } = await renderDialog({ subid: 804 }) const { events } = await renderDialog({ subid: 804 })
@@ -454,7 +454,7 @@ describe('SubscribeEditDialog', () => {
}) })
it('does not delete when confirmation is cancelled', async () => { 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() const deleted = vi.fn()
server.use(subscribeDetailsHandler(805, record), deleteSubscribeByIdHandler(805, { success: true }, 200, deleted)) server.use(subscribeDetailsHandler(805, record), deleteSubscribeByIdHandler(805, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8050 }) useDialogOptions({ tmdbId: 8050 })
@@ -469,7 +469,7 @@ describe('SubscribeEditDialog', () => {
}) })
it('emits remove only after a successful deletion', async () => { 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() const deleted = vi.fn()
server.use(subscribeDetailsHandler(806, record), deleteSubscribeByIdHandler(806, { success: true }, 200, deleted)) server.use(subscribeDetailsHandler(806, record), deleteSubscribeByIdHandler(806, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8060 }) useDialogOptions({ tmdbId: 8060 })
@@ -488,7 +488,7 @@ describe('SubscribeEditDialog', () => {
['HTTP failure', 500, { message: 'server down', success: false }, '删除失败项 取消订阅失败:server down'], ['HTTP failure', 500, { message: 'server down', success: false }, '删除失败项 取消订阅失败:server down'],
])('keeps the subscription after a delete %s', async (_case, status, response, expectedMessage) => { ])('keeps the subscription after a delete %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) 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)) server.use(subscribeDetailsHandler(807, record), deleteSubscribeByIdHandler(807, response, status))
useDialogOptions({ tmdbId: 8070 }) useDialogOptions({ tmdbId: 8070 })
const { events } = await renderDialog({ subid: 807 }) const { events } = await renderDialog({ subid: 807 })
@@ -504,7 +504,7 @@ describe('SubscribeEditDialog', () => {
it('remains editable when an auxiliary options request fails', async () => { it('remains editable when an auxiliary options request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) 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() const updated = vi.fn()
server.use(subscribeDetailsHandler(808, record), updateSubscribeHandler({ success: true }, 200, updated)) server.use(subscribeDetailsHandler(808, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({ tmdbId: 8080 }) useDialogOptions({ tmdbId: 8080 })
@@ -34,7 +34,8 @@ function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
show_edit_dialog: false, show_edit_dialog: false,
sites: [], sites: [],
state: 'R', state: 'R',
tmdbid: 31010, media_id: '31010',
media_source: 'themoviedb',
type: '电视剧', type: '电视剧',
username: 'tester', username: 'tester',
year: '2026', year: '2026',
@@ -123,12 +124,7 @@ function setViewport(width: number) {
window.dispatchEvent(new Event('resize')) window.dispatchEvent(new Event('resize'))
} }
function useFilesResponse( function useFilesResponse(id: number, response: JsonBodyType, status = 200, onRequest: (url: URL) => void = () => {}) {
id: number,
response: JsonBodyType,
status = 200,
onRequest: (url: URL) => void = () => {},
) {
server.use(subscribeFilesHandler(id, response, status, onRequest)) server.use(subscribeFilesHandler(id, response, status, onRequest))
} }
@@ -205,9 +201,9 @@ describe('SubscribeFilesDialog', () => {
const info = createFilesInfo({ const info = createFilesInfo({
episodes, episodes,
subscribe: createSubscribe({ subscribe: createSubscribe({
doubanid: 'douban-range-44-48', media_id: 'douban-range-44-48',
media_source: 'douban',
start_episode: 44, start_episode: 44,
tmdbid: undefined,
total_episode: 48, total_episode: 48,
}), }),
}) })
@@ -233,7 +229,7 @@ describe('SubscribeFilesDialog', () => {
) )
const info = createFilesInfo({ const info = createFilesInfo({
episodes, 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) 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 () => { it('distinguishes an HTTP failure from an empty response and offers retry', async () => {
const info = createFilesInfo({ subscribe: createSubscribe({ name: '重试恢复剧', total_episode: 1 }) }) const info = createFilesInfo({ subscribe: createSubscribe({ name: '重试恢复剧', total_episode: 1 }) })
let requestCount = 0 let requestCount = 0
server.use(http.get(subscribeApiUrls.filesById(3102), () => { server.use(
requestCount += 1 http.get(subscribeApiUrls.filesById(3102), () => {
if (requestCount === 1) return HttpResponse.json({}, { status: 500 }) requestCount += 1
return HttpResponse.json(info as unknown as JsonBodyType) if (requestCount === 1) return HttpResponse.json({}, { status: 500 })
})) return HttpResponse.json(info as unknown as JsonBodyType)
}),
)
const user = userEvent.setup() const user = userEvent.setup()
await renderDialog(3102) await renderDialog(3102)
@@ -456,9 +454,11 @@ describe('SubscribeFilesDialog', () => {
it('shows loading until the request resolves', async () => { it('shows loading until the request resolves', async () => {
const deferred = createDeferred<JsonBodyType>() const deferred = createDeferred<JsonBodyType>()
server.use(http.get(subscribeApiUrls.filesById(3116), async () => { server.use(
return HttpResponse.json(await deferred.promise) http.get(subscribeApiUrls.filesById(3116), async () => {
})) return HttpResponse.json(await deferred.promise)
}),
)
await renderDialog(3116) await renderDialog(3116)
@@ -137,7 +137,8 @@ function createHistory(overrides: Partial<Subscribe> = {}): Subscribe {
show_edit_dialog: false, show_edit_dialog: false,
sites: [], sites: [],
state: 'R', state: 'R',
tmdbid: historySeed, media_id: String(historySeed),
media_source: 'themoviedb',
type: '电影', type: '电影',
username: 'tester', username: 'tester',
year: '2026', year: '2026',
@@ -148,7 +148,8 @@ describe('SubscribeSeasonDialog', () => {
expect(seasonRequests).toHaveLength(1) expect(seasonRequests).toHaveLength(1)
expect(missingPayloads).toHaveLength(1) expect(missingPayloads).toHaveLength(1)
expect(groupRequests).toHaveBeenCalledOnce() 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('title')).toBe(media.title)
expect(seasonRequests[0].searchParams.get('year')).toBe(media.year) expect(seasonRequests[0].searchParams.get('year')).toBe(media.year)
expect(seasonRequests[0].searchParams.get('season')).toBe('0') expect(seasonRequests[0].searchParams.get('season')).toBe('0')
@@ -220,27 +221,33 @@ describe('SubscribeSeasonDialog', () => {
}) })
it.each([ 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',
{ 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',
{ anilist_id: 154587, bangumi_id: undefined, media_source: 'anilist', tmdb_id: undefined }, { anilist_id: 154587, bangumi_id: undefined, media_id: '154587', media_source: 'anilist', tmdb_id: undefined },
'anilist:154587', '154587',
], ],
[ [
'custom source', 'Bilibili source',
{ {
bangumi_id: undefined, bangumi_id: undefined,
douban_id: undefined, douban_id: undefined,
media_id: 'custom-7305', media_id: 'custom-7305',
media_source: 'custom', media_source: 'bilibili',
tmdb_id: undefined, tmdb_id: undefined,
}, },
'custom:custom-7305', 'custom-7305',
], ],
[ [
'source-only TMDB', 'source-only TMDB',
@@ -251,7 +258,7 @@ describe('SubscribeSeasonDialog', () => {
media_source: 'themoviedb', media_source: 'themoviedb',
tmdb_id: undefined, tmdb_id: undefined,
}, },
'tmdb:source-7306', 'source-7306',
], ],
] as const)('uses the %s media identifier without requesting TMDB groups', async (label, overrides, mediaId) => { ] as const)('uses the %s media identifier without requesting TMDB groups', async (label, overrides, mediaId) => {
const consoleWarn = const consoleWarn =
@@ -268,14 +275,15 @@ describe('SubscribeSeasonDialog', () => {
expect(await screen.findByText('第 1 季')).toBeInTheDocument() expect(await screen.findByText('第 1 季')).toBeInTheDocument()
await settleRequests() await settleRequests()
expect(requested).toHaveBeenCalledOnce() expect(requested).toHaveBeenCalledOnce()
expect(requested.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId) expect(requested.mock.calls[0][0].searchParams.get('media_id')).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_source')).toBe(media.media_source)
if (label === 'source-only TMDB') expect(consoleWarn).toHaveBeenCalledWith('tmdb_id is not set or is empty')
}) })
it.each([ it.each([
['Douban', { douban_id: 'db-7310', media_source: 'douban' }, 'douban:db-7310'], ['Douban', { douban_id: 'db-7310', media_id: 'db-7310', media_source: 'douban' }, 'db-7310'],
['Bangumi', { bangumi_id: 'bgm-7310', media_source: 'bangumi' }, 'bangumi:bgm-7310'], ['Bangumi', { bangumi_id: 'bgm-7310', media_id: 'bgm-7310', media_source: 'bangumi' }, 'bgm-7310'],
['AniList', { anilist_id: 154587, media_source: 'anilist' }, 'anilist:154587'], ['AniList', { anilist_id: 154587, media_id: '154587', media_source: 'anilist' }, '154587'],
] as const)( ] as const)(
'keeps the %s identity and skips episode groups when an auxiliary TMDB ID exists', 'keeps the %s identity and skips episode groups when an auxiliary TMDB ID exists',
async (_label, overrides, mediaId) => { async (_label, overrides, mediaId) => {
@@ -294,7 +302,8 @@ describe('SubscribeSeasonDialog', () => {
expect(await screen.findByText('第 1 季')).toBeInTheDocument() expect(await screen.findByText('第 1 季')).toBeInTheDocument()
await settleRequests() 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(groupRequest).not.toHaveBeenCalled()
expect(groupSeasonsRequest).not.toHaveBeenCalled() expect(groupSeasonsRequest).not.toHaveBeenCalled()
}, },
@@ -40,7 +40,8 @@ async function renderDialog(season = 2, name = '分享创建测试剧') {
id: 5201, id: 5201,
name, name,
season, season,
tmdbid: 52010, media_id: '52010',
media_source: 'themoviedb',
type: '电视剧', type: '电视剧',
}) })
const result = await renderWithProviders(SubscribeShareDialog, { const result = await renderWithProviders(SubscribeShareDialog, {
@@ -197,7 +197,7 @@ describe('TransferQueueDialog', () => {
expect(screen.queryByText('来源 A.mkv')).not.toBeInTheDocument() 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({ const builtIn = createQueueItem({
id: 7301, id: 7301,
path: '/downloads/built-in.mkv', path: '/downloads/built-in.mkv',
@@ -210,7 +210,7 @@ describe('TransferQueueDialog', () => {
title: '自定义来源', title: '自定义来源',
titleYear: '重复标题 (2026)', titleYear: '重复标题 (2026)',
}) })
custom.media.media_source = 'custom-source' custom.media.media_source = 'bilibili'
custom.media.media_id = 'custom-7302' custom.media.media_id = 'custom-7302'
const fallback = createQueueItem({ const fallback = createQueueItem({
id: 7399, id: 7399,
+4 -8
View File
@@ -8,7 +8,7 @@ const { t } = useI18n()
// //
const props = defineProps<{ const props = defineProps<{
type?: MediaDataSource type: MediaDataSource
musicTypes?: MusicEntityType[] musicTypes?: MusicEntityType[]
}>() }>()
@@ -68,7 +68,7 @@ async function searchMedias() {
type: isMusicMediaSource(props.type) ? 'music' : 'media', type: isMusicMediaSource(props.type) ? 'music' : 'media',
page: 1, page: 1,
count: 20, count: 20,
source: props.type, media_source: props.type,
}, },
}) })
@@ -77,15 +77,11 @@ async function searchMedias() {
// //
for (const item of result) { 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)) { if (props.musicTypes?.length && item.music_type && !props.musicTypes.includes(item.music_type)) {
continue continue
} }
const mediaId = const mediaId = item.media_id?.toString().trim()
item.media_id ||
item.tmdb_id?.toString() ||
item.douban_id ||
item.bangumi_id?.toString() ||
item.anilist_id?.toString()
if (!mediaId) continue if (!mediaId) continue
const musicAlbum = item.music_type === 'album' || item.album === item.title ? undefined : item.album const musicAlbum = item.music_type === 'album' || item.album === item.title ? undefined : item.album
items.value.push({ items.value.push({
@@ -20,6 +20,7 @@ describe('MediaIdSelector layout', () => {
mocks.apiGet.mockResolvedValue([ mocks.apiGet.mockResolvedValue([
{ {
media_id: 'tmdb-1', media_id: 'tmdb-1',
media_source: 'themoviedb',
overview: '测试简介', overview: '测试简介',
poster_path: '', poster_path: '',
title: 'Hello Mini', title: 'Hello Mini',
@@ -29,6 +30,7 @@ describe('MediaIdSelector layout', () => {
]) ])
const { container } = await renderWithProviders(MediaIdSelector, { const { container } = await renderWithProviders(MediaIdSelector, {
props: { type: 'themoviedb' },
global: { global: {
stubs: { stubs: {
VDialogCloseBtn: { VDialogCloseBtn: {
@@ -52,6 +54,15 @@ describe('MediaIdSelector layout', () => {
await fireEvent.update(input, 'hello') await fireEvent.update(input, 'hello')
await fireEvent.keyDown(input, { key: 'Enter' }) await fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Hello Mini2019')).toBeInTheDocument() 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') const results = container.querySelector('.media-id-selector__results')
expect(results).toBeInstanceOf(HTMLElement) expect(results).toBeInstanceOf(HTMLElement)
@@ -63,6 +74,7 @@ describe('MediaIdSelector layout', () => {
album: '叶惠美', album: '叶惠美',
artist: '周杰伦', artist: '周杰伦',
media_id: 'album-1', media_id: 'album-1',
media_source: 'musicbrainz',
music_type: 'album', music_type: 'album',
title: '叶惠美', title: '叶惠美',
type: '音乐', type: '音乐',
@@ -72,6 +84,7 @@ describe('MediaIdSelector layout', () => {
album: '叶惠美', album: '叶惠美',
artist: '周杰伦', artist: '周杰伦',
media_id: 'recording-1', media_id: 'recording-1',
media_source: 'musicbrainz',
music_type: 'recording', music_type: 'recording',
title: '以父之名', title: '以父之名',
type: '音乐', type: '音乐',
@@ -79,6 +92,7 @@ describe('MediaIdSelector layout', () => {
]) ])
const { container } = await renderWithProviders(MediaIdSelector, { const { container } = await renderWithProviders(MediaIdSelector, {
props: { type: 'musicbrainz' },
global: { global: {
stubs: { stubs: {
VDialogCloseBtn: { VDialogCloseBtn: {
@@ -99,4 +113,68 @@ describe('MediaIdSelector layout', () => {
) )
expect(subtitles).toEqual(['音乐 周杰伦', '音乐 周杰伦 · 叶惠美']) 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(), toastSuccess: vi.fn(),
})) }))
const musicBrainzAlbumId = '695f5ac8-cfd5-4e7b-96a0-6d545f5c9f17'
const musicBrainzRecordingId = '977e6978-139d-425c-bb98-6b0c62d1e45e'
vi.mock('vue-toastification', () => ({ vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
})) }))
@@ -173,30 +176,31 @@ function getDialogCall(index = 0) {
describe('media subscribe identifiers and modes', () => { describe('media subscribe identifiers and modes', () => {
it.each([ 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 primary identity',
{ bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, { bangumi_id: '30', media_id: '30', media_source: 'bangumi', tmdb_id: 10 },
'douban:20',
],
[
'Bangumi before a generic identifier',
{ bangumi_id: '30', douban_id: undefined, tmdb_id: undefined },
'bangumi:30', 'bangumi:30',
], ],
['AniList after Bangumi', { anilist_id: 40, bangumi_id: undefined, tmdb_id: undefined }, 'anilist:40'],
[ [
'generic identifiers when provider ids are absent', 'AniList primary identity',
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', media_source: 'custom', tmdb_id: undefined }, { anilist_id: 40, media_id: '40', media_source: 'anilist', tmdb_id: 10 },
'custom:abc', '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) expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected)
}) })
it('keeps the declared AniList identity when TMDB is only auxiliary data', () => { it('keeps the declared AniList identity when TMDB is only auxiliary data', () => {
const media = createSubscribeTv({ const media = createSubscribeTv({
anilist_id: 154587, anilist_id: 154587,
media_id: '154587',
media_source: 'anilist', media_source: 'anilist',
tmdb_id: 209867, 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([ it.each([
[{ best_version: false, best_version_full: true }, 'normal'], [{ best_version: false, best_version_full: true }, 'normal'],
[{ best_version: 0, best_version_full: 1 }, 'normal'], [{ best_version: 0, best_version_full: 1 }, 'normal'],
@@ -262,10 +271,8 @@ describe('useMediaSubscribe entry flows', () => {
episode_group: '', episode_group: '',
media_id: '101', media_id: '101',
media_source: 'themoviedb', media_source: 'themoviedb',
mediaid: 'tmdb:101',
name: '普通电影', name: '普通电影',
season: null, season: null,
tmdbid: 101,
type: '电影', type: '电影',
year: '2025', year: '2025',
}) })
@@ -278,7 +285,7 @@ describe('useMediaSubscribe entry flows', () => {
it('creates an album subscription with its entity type and complete track count', async () => { it('creates an album subscription with its entity type and complete track count', async () => {
const media = createMediaInfo({ const media = createMediaInfo({
media_id: 'release-group-1', media_id: musicBrainzAlbumId,
music_type: 'album', music_type: 'album',
media_source: 'musicbrainz', media_source: 'musicbrainz',
title: '叶惠美', title: '叶惠美',
@@ -298,9 +305,8 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(created).toHaveBeenCalledOnce()) await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({ expect(created.mock.calls[0][0]).toMatchObject({
media_id: 'release-group-1', media_id: musicBrainzAlbumId,
media_source: 'musicbrainz', media_source: 'musicbrainz',
mediaid: 'musicbrainz:release-group-1',
music_type: 'album', music_type: 'album',
name: '叶惠美', name: '叶惠美',
season: null, season: null,
@@ -312,7 +318,7 @@ describe('useMediaSubscribe entry flows', () => {
it('creates a recording subscription without its album track count', async () => { it('creates a recording subscription without its album track count', async () => {
const media = createMediaInfo({ const media = createMediaInfo({
media_id: 'recording-1', media_id: musicBrainzRecordingId,
music_type: 'recording', music_type: 'recording',
media_source: 'musicbrainz', media_source: 'musicbrainz',
title: '晴天', title: '晴天',
@@ -332,7 +338,7 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(created).toHaveBeenCalledOnce()) await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({ expect(created.mock.calls[0][0]).toMatchObject({
media_id: 'recording-1', media_id: musicBrainzRecordingId,
media_source: 'musicbrainz', media_source: 'musicbrainz',
music_type: 'recording', music_type: 'recording',
name: '晴天', name: '晴天',
@@ -360,9 +366,27 @@ describe('useMediaSubscribe entry flows', () => {
expect(mocks.startProgress).not.toHaveBeenCalled() 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 () => { it('creates an AniList subscription without promoting its auxiliary TMDB ID', async () => {
const media = createSubscribeTv({ const media = createSubscribeTv({
anilist_id: 154587, anilist_id: 154587,
media_id: '154587',
media_source: 'anilist', media_source: 'anilist',
title: 'AniList 订阅剧集', title: 'AniList 订阅剧集',
tmdb_id: 209867, tmdb_id: 209867,
@@ -378,12 +402,9 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(created).toHaveBeenCalledOnce()) await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({ expect(created.mock.calls[0][0]).toMatchObject({
anilistid: 154587,
media_id: '154587', media_id: '154587',
media_source: 'anilist', media_source: 'anilist',
mediaid: 'anilist:154587',
season: 1, season: 1,
tmdbid: 209867,
}) })
}) })
@@ -469,7 +490,7 @@ describe('useMediaSubscribe entry flows', () => {
it('cancels a subscribed movie from the primary entry', async () => { it('cancels a subscribed movie from the primary entry', async () => {
const deleted = vi.fn() 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({ await renderSubscribeHarness({
isSubscribed: true, isSubscribed: true,
media: createSubscribeMovie({ title: '主入口取消电影', tmdb_id: 1032 }), media: createSubscribeMovie({ title: '主入口取消电影', tmdb_id: 1032 }),
@@ -479,6 +500,7 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) 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.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(screen.getByTestId('subscribed')).toHaveTextContent('false')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', 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 () => { it('cancels a subscribed TV season only after confirmation', async () => {
const media = createSubscribeTv({ season: 2, title: '取消季剧集', tmdb_id: 104 }) const media = createSubscribeTv({ season: 2, title: '取消季剧集', tmdb_id: 104 })
const deleted = vi.fn() 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({ await renderSubscribeHarness({
isSubscribed: true, isSubscribed: true,
media, media,
@@ -512,44 +534,57 @@ describe('useMediaSubscribe entry flows', () => {
it.each([ it.each([
{ {
label: 'Douban', label: 'Douban',
media: createSubscribeTv({ douban_id: 'db-1', tmdb_id: undefined }), media: createSubscribeTv({ douban_id: 'db-1', media_id: 'db-1', media_source: 'douban', tmdb_id: undefined }),
mediaId: 'douban:db-1', mediaId: 'db-1',
record: createSubscribe({ doubanid: 'db-1', season: 2, tmdbid: 0, type: '电视剧' }), mediaSource: 'douban',
record: createSubscribe({ media_id: 'db-1', media_source: 'douban', season: 2, type: '电视剧' }),
}, },
{ {
label: 'Bangumi', label: 'Bangumi',
media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }), media: createSubscribeTv({ bangumi_id: '42', media_id: '42', media_source: 'bangumi', tmdb_id: undefined }),
mediaId: 'bangumi:42', mediaId: '42',
record: createSubscribe({ bangumiid: 42, season: 2, tmdbid: 0, type: '电视剧' }), mediaSource: 'bangumi',
record: createSubscribe({ media_id: '42', media_source: 'bangumi', season: 2, type: '电视剧' }),
}, },
{ {
label: 'AniList', label: 'AniList',
media: createSubscribeTv({ anilist_id: 154587, tmdb_id: undefined }), media: createSubscribeTv({ anilist_id: 154587, media_id: '154587', media_source: 'anilist', tmdb_id: undefined }),
mediaId: 'anilist:154587', mediaId: '154587',
record: createSubscribe({ anilistid: 154587, season: 2, tmdbid: 0, type: '电视剧' }), mediaSource: 'anilist',
record: createSubscribe({ media_id: '154587', media_source: 'anilist', season: 2, type: '电视剧' }),
}, },
{ {
label: 'generic provider', label: 'Bilibili',
media: createSubscribeTv({ media_id: 'series-1', media_source: 'custom', tmdb_id: undefined }), media: createSubscribeTv({ media_id: 'series-1', media_source: 'bilibili', tmdb_id: undefined }),
mediaId: 'custom:series-1', mediaId: 'series-1',
record: createSubscribe({ mediaid: 'custom:series-1', season: 2, tmdbid: 0, type: '电视剧' }), 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 }) => { ] as const)(
const queried = vi.fn() 'queries $label subscriptions through the media endpoint',
server.use(querySubscribeByMediaHandler(mediaId, record, 200, url => queried(url))) async ({ media, mediaId, mediaSource, record }) => {
await renderSubscribeHarness({ actionSeason: 2, media }) 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')) await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
expect(queried).toHaveBeenCalledOnce() expect(queried).toHaveBeenCalledOnce()
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2') 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 () => { 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() 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({ await renderSubscribeHarness({
actionSeason: 2, actionSeason: 2,
isSubscribed: true, isSubscribed: true,
@@ -562,12 +597,13 @@ describe('useMediaSubscribe entry flows', () => {
await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) 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('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') expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
}) })
it('queries and cancels music subscriptions with their entity type', async () => { it('queries and cancels music subscriptions with their entity type', async () => {
const media = createMediaInfo({ const media = createMediaInfo({
media_id: 'release-group-1', media_id: musicBrainzAlbumId,
music_type: 'album', music_type: 'album',
media_source: 'musicbrainz', media_source: 'musicbrainz',
title: '叶惠美', title: '叶惠美',
@@ -578,22 +614,30 @@ describe('useMediaSubscribe entry flows', () => {
const deleted = vi.fn() const deleted = vi.fn()
server.use( server.use(
querySubscribeByMediaHandler( querySubscribeByMediaHandler(
'musicbrainz:release-group-1', musicBrainzAlbumId,
createSubscribe({ id: 801, music_type: 'album', type: '音乐' }), createSubscribe({
id: 801,
media_id: musicBrainzAlbumId,
media_source: 'musicbrainz',
music_type: 'album',
type: '音乐',
}),
200, 200,
url => queried(url), 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 renderSubscribeHarness({ isSubscribed: true, 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')) 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('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 fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) 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('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 () => { it('aligns visible seasons while preserving hidden subscriptions', async () => {
@@ -603,10 +647,10 @@ describe('useMediaSubscribe entry flows', () => {
const updated = vi.fn() const updated = vi.fn()
const created = vi.fn() const created = vi.fn()
server.use( server.use(
deleteSubscribeByMediaHandler('tmdb:105', { success: true }, 200, url => deleted(url)), deleteSubscribeByMediaHandler('105', { success: true }, 200, url => deleted(url)),
querySubscribeByMediaHandler( querySubscribeByMediaHandler(
'tmdb:105', '105',
createSubscribe({ id: 605, season: 2, tmdbid: 105, type: '电视剧' }), createSubscribe({ id: 605, media_id: '105', media_source: 'themoviedb', season: 2, type: '电视剧' }),
200, 200,
url => queried(url), url => queried(url),
), ),
@@ -701,7 +745,7 @@ describe('useMediaSubscribe entry flows', () => {
])('keeps subscription state when removal returns a %s', async (_case, status, response) => { ])('keeps subscription state when removal returns a %s', async (_case, status, response) => {
const consoleError = status === 500 ? vi.spyOn(console, 'error').mockImplementation(() => {}) : undefined const consoleError = status === 500 ? vi.spyOn(console, 'error').mockImplementation(() => {}) : undefined
const deleted = vi.fn() 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({ await renderSubscribeHarness({
actionSeason: 2, actionSeason: 2,
isSubscribed: true, isSubscribed: true,
@@ -734,7 +778,10 @@ describe('useMediaSubscribe entry flows', () => {
const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 }) const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 })
const updated = vi.fn() const updated = vi.fn()
server.use( 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), updateSubscribeHandler(response, status, updated),
) )
await renderSubscribeHarness({ await renderSubscribeHarness({
@@ -763,13 +810,13 @@ describe('useMediaSubscribe entry flows', () => {
it('maps a 404 query to missing and propagates other HTTP errors', async () => { it('maps a 404 query to missing and propagates other HTTP errors', async () => {
const media = createSubscribeMovie({ tmdb_id: 109 }) const media = createSubscribeMovie({ tmdb_id: 109 })
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 404)) server.use(querySubscribeByMediaHandler('109', {}, 404))
await renderSubscribeHarness({ media }) await renderSubscribeHarness({ 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('missing')) 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 fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('error')) await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('error'))
}) })
@@ -78,13 +78,12 @@ async function renderNativeSubscribeHarness(subscribePermission = true) {
} }
describe('native subscribe media normalization', () => { 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({ const result = normalizeNativeSubscribeMedia({
anilistid: '154587', anilist_id: 999,
bangumiid: 4011, media_id: '154587',
doubanid: 3601, media_source: 'anilist',
title: '测试剧集', title: '测试剧集',
tmdbid: '2501',
type: 'tv', type: 'tv',
year: 2026, year: 2026,
}) })
@@ -92,21 +91,20 @@ describe('native subscribe media normalization', () => {
expect(result).toEqual({ expect(result).toEqual({
success: true, success: true,
media: expect.objectContaining({ media: expect.objectContaining({
anilist_id: 154587, media_id: '154587',
bangumi_id: '4011', media_source: 'anilist',
douban_id: '3601',
title: '测试剧集', title: '测试剧集',
tmdb_id: 2501,
type: '电视剧', type: '电视剧',
year: '2026', 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({ const result = normalizeNativeSubscribeMedia({
media_id: 'subject-42', media_id: 'subject-42',
media_source: 'custom-source', media_source: 'bilibili',
title: '自定义媒体', title: '自定义媒体',
type: 'movie', type: 'movie',
}) })
@@ -115,7 +113,7 @@ describe('native subscribe media normalization', () => {
success: true, success: true,
media: expect.objectContaining({ media: expect.objectContaining({
media_id: 'subject-42', media_id: 'subject-42',
media_source: 'custom-source', media_source: 'bilibili',
type: '电影', type: '电影',
}), }),
}) })
@@ -126,6 +124,8 @@ describe('native subscribe media normalization', () => {
[{ title: '缺少类型', tmdb_id: 1 }, 'unsupportedType'], [{ title: '缺少类型', tmdb_id: 1 }, 'unsupportedType'],
[{ title: '', tmdb_id: 1, type: '电影' }, 'missingTitle'], [{ title: '', tmdb_id: 1, type: '电影' }, 'missingTitle'],
[{ title: '缺少ID', type: '电视剧' }, 'missingId'], [{ 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) => { ])('rejects invalid input %#', (input, reason) => {
expect(normalizeNativeSubscribeMedia(input)).toEqual({ success: false, reason }) expect(normalizeNativeSubscribeMedia(input)).toEqual({ success: false, reason })
}) })
@@ -147,7 +147,9 @@ describe('plugin native subscribe flow', () => {
]) ])
const nativeSubscribe = await renderNativeSubscribeHarness() 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.apiGet).toHaveBeenCalledWith('subscribe/')
expect(mocks.subscribeOptions?.subscribedSeasons.value).toEqual([1, 3]) 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 () => { it('returns a structured fallback result when the user lacks subscribe permission', async () => {
const nativeSubscribe = await renderNativeSubscribeHarness(false) 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({ expect(result).toEqual({
code: 'PERMISSION_DENIED', code: 'PERMISSION_DENIED',
+33 -51
View File
@@ -4,10 +4,11 @@ import { useI18n } from 'vue-i18n'
import api from '@/api' import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress' import { doneNProgress, startNProgress } from '@/api/nprogress'
import { formatSeason } from '@/@core/utils/formatters' 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 { openSharedDialog } from '@/composables/useSharedDialog'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { setCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache' import { setCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
import { isMediaDataSource, isValidMediaSourceId } from '@/utils/mediaId'
export type SubscribeMode = 'normal' | 'best_version' | 'best_version_full' export type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
@@ -52,47 +53,20 @@ export type SeasonSubscribeModes = Record<number, SubscribeMode>
export interface MediaSubscribeIdentity { export interface MediaSubscribeIdentity {
mediaId: string mediaId: string
mediaKey: string mediaKey: string
source: string source: MediaDataSource
} }
/** 按媒体声明的主来源解析订阅身份,避免辅助 ID 覆盖原始识别源。 */ /** 按媒体声明的主来源解析订阅身份,避免辅助 ID 覆盖原始识别源。 */
export function getMediaSubscribeIdentity(media?: MediaInfo): MediaSubscribeIdentity | undefined { export function getMediaSubscribeIdentity(media?: MediaInfo): MediaSubscribeIdentity | undefined {
if (!media) return undefined 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)) {
const normalizeSource = (value?: string) => { return undefined
const source = (value || '').trim().toLowerCase()
return source === 'tmdb' ? 'themoviedb' : source
} }
const sourceIds: Record<string, unknown> = { return {
anilist: media.anilist_id, mediaId,
bangumi: media.bangumi_id, mediaKey: `${media.media_source}:${mediaId}`,
douban: media.douban_id, source: media.media_source,
themoviedb: media.tmdb_id,
} }
const buildIdentity = (identitySource: string, value: unknown): MediaSubscribeIdentity | undefined => {
if (value === undefined || value === null || !String(value).trim()) return undefined
const mediaId = String(value).trim()
const prefix = identitySource === 'themoviedb' ? 'tmdb' : identitySource
return {
mediaId,
mediaKey: `${prefix}:${mediaId}`,
source: identitySource,
}
}
const declaredSources = [media.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() { function getMediaId() {
return getMediaSubscribeId(currentMedia()) return getMediaSubscribeIdentity(currentMedia())
} }
// 获取主订阅入口默认对应的季号。 // 获取主订阅入口默认对应的季号。
@@ -318,6 +292,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 艺术家仅用于继续浏览,其下作品必须按单曲或专辑分别订阅。 // 艺术家仅用于继续浏览,其下作品必须按单曲或专辑分别订阅。
if (!media || media.music_type === 'artist') return if (!media || media.music_type === 'artist') return
const identity = getMediaSubscribeIdentity(media) const identity = getMediaSubscribeIdentity(media)
if (!identity) return
startNProgress() startNProgress()
try { try {
@@ -326,13 +301,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
type: media.type, type: media.type,
// 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422 // 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422
year: media.year?.toString() ?? '', year: media.year?.toString() ?? '',
tmdbid: media.tmdb_id, media_source: identity.source,
doubanid: media.douban_id, media_id: identity.mediaId,
bangumiid: media.bangumi_id,
anilistid: media.anilist_id,
media_source: identity?.source,
media_id: identity?.mediaId,
mediaid: identity?.mediaKey ?? '',
// 专辑订阅必须保留实体类型和曲目总数,后端据此校验整专资源并决定何时完成订阅。 // 专辑订阅必须保留实体类型和曲目总数,后端据此校验整专资源并决定何时完成订阅。
music_type: getMusicSubscribeType(media), music_type: getMusicSubscribeType(media),
total_tracks: getMusicSubscribeType(media) === 'album' ? media.total_tracks : undefined, total_tracks: getMusicSubscribeType(media) === 'album' ? media.total_tracks : undefined,
@@ -385,17 +355,23 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const media = currentMedia() const media = currentMedia()
if (!media) return if (!media) return
const identity = getMediaId()
if (!identity) return
let title = media.title ?? '' let title = media.title ?? ''
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}` if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
startNProgress() startNProgress()
try { try {
const result: { [key: string]: any } = await api.delete(`subscribe/media/${getMediaId()}`, { const result: { [key: string]: any } = await api.delete(
params: { `subscribe/media/${encodeURIComponent(identity.mediaId)}`,
season: media.type === '电影' ? null : season, {
music_type: getMusicSubscribeType(media), params: {
media_source: identity.source,
season: media.type === '电影' ? null : season,
music_type: getMusicSubscribeType(media),
},
}, },
}) )
if (result.success) { if (result.success) {
updateSubscribeStatus(media.type === '电影' ? null : season, false) updateSubscribeStatus(media.type === '电影' ? null : season, false)
@@ -419,9 +395,12 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 检查当前媒体指定季是否已订阅。 // 检查当前媒体指定季是否已订阅。
async function checkSubscribe(season: number | null = null) { async function checkSubscribe(season: number | null = null) {
const identity = getMediaId()
if (!identity) return false
try { try {
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, { const result: Subscribe = await api.get(`subscribe/media/${encodeURIComponent(identity.mediaId)}`, {
params: { params: {
media_source: identity.source,
season, season,
title: currentMedia()?.title, title: currentMedia()?.title,
music_type: getMusicSubscribeType(currentMedia()), music_type: getMusicSubscribeType(currentMedia()),
@@ -438,9 +417,12 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 查询当前媒体指定季的订阅记录。 // 查询当前媒体指定季的订阅记录。
async function querySubscribe(season: number | null = null) { async function querySubscribe(season: number | null = null) {
const identity = getMediaId()
if (!identity) return null
try { try {
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, { const result: Subscribe = await api.get(`subscribe/media/${encodeURIComponent(identity.mediaId)}`, {
params: { params: {
media_source: identity.source,
season, season,
title: currentMedia()?.title, title: currentMedia()?.title,
music_type: getMusicSubscribeType(currentMedia()), music_type: getMusicSubscribeType(currentMedia()),
+28 -35
View File
@@ -1,5 +1,5 @@
import api from '@/api' import api from '@/api'
import type { MediaInfo, Subscribe } from '@/api/types' import type { MediaDataSource, MediaInfo, Subscribe } from '@/api/types'
import { import {
getMediaSubscribeId, getMediaSubscribeId,
getSubscribeMode, getSubscribeMode,
@@ -11,15 +11,27 @@ import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { computed, ref, shallowRef } from 'vue' import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { isMediaDataSource } from '@/utils/mediaId'
export interface NativeSubscribeMediaInfo extends Partial<MediaInfo> { type AuxiliaryMediaIdKey =
anilistid?: number | string 'tmdb_id' | 'imdb_id' | 'tvdb_id' | 'douban_id' | 'bangumi_id' | 'anilist_id' | 'anidb_id' | 'collection_id'
bangumiid?: number | string
doubanid?: number | string export type NativeSubscribeMediaInfo = Omit<Partial<MediaInfo>, AuxiliaryMediaIdKey> & {
media_source?: string media_source?: MediaDataSource
tmdbid?: number | string
} }
// 插件输入只接受统一主身份;各来源辅助 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 = export type NativeSubscribeResult =
| { success: true } | { success: true }
| { | {
@@ -47,15 +59,6 @@ function normalizeMediaType(value: unknown) {
return undefined 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 转换为非空字符串。 */ /** 将字符串或数字 ID 转换为非空字符串。 */
function normalizeStringId(value: unknown) { function normalizeStringId(value: unknown) {
if (typeof value !== 'string' && typeof value !== 'number') return undefined 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() : '' const title = typeof raw.title === 'string' ? raw.title.trim() : ''
if (!title) return { success: false, reason: 'missingTitle' } 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 = { const normalizedMedia = {
...raw, ...publicFields,
anilist_id: normalizeNumericId(raw.anilist_id ?? raw.anilistid), media_id: mediaId,
bangumi_id: normalizeStringId(raw.bangumi_id ?? raw.bangumiid),
douban_id: normalizeStringId(raw.douban_id ?? raw.doubanid),
media_id: normalizeStringId(raw.media_id),
media_source: mediaSource, media_source: mediaSource,
title, title,
tmdb_id: normalizeNumericId(raw.tmdb_id ?? raw.tmdbid),
type, type,
year: normalizeStringId(raw.year), year: normalizeStringId(raw.year),
} as MediaInfo } as MediaInfo
if (!getMediaSubscribeId(normalizedMedia)) return { success: false, reason: 'missingId' }
return { success: true, media: normalizedMedia } return { success: true, media: normalizedMedia }
} }
/** 生成订阅记录的统一媒体标识,用于恢复电视剧已订阅季状态。 */ /** 生成订阅记录的统一媒体标识,用于恢复电视剧已订阅季状态。 */
function getSubscribeRecordMediaId(subscribe: Subscribe) { function getSubscribeRecordMediaId(subscribe: Subscribe) {
if (subscribe.media_source && subscribe.media_id) { return subscribe.media_source && subscribe.media_id ? `${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 ''
} }
/** 为插件联邦组件创建主程序原生订阅入口。 */ /** 为插件联邦组件创建主程序原生订阅入口。 */
+2 -2
View File
@@ -2476,7 +2476,7 @@ export default {
'Word to replace => Replacement\n' + 'Word to replace => Replacement\n' +
'Front word <> Back word >> Episode offset (EP)\n' + 'Front word <> Back word >> Episode offset (EP)\n' +
'Word to replace => Replacement && 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', identifierSaveSuccess: 'Custom identifiers saved successfully',
identifierSaveFailed: 'Failed to save custom identifiers!', identifierSaveFailed: 'Failed to save custom identifiers!',
@@ -3476,7 +3476,7 @@ export default {
customWords: 'Custom Recognition Words', customWords: 'Custom Recognition Words',
customWordsHint: 'Recognition words only used for this subscription', customWordsHint: 'Recognition words only used for this subscription',
customWordsPlaceholder: 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', cancelSubscribe: 'Cancel Subscription',
save: 'Save', save: 'Save',
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?', cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
+2 -2
View File
@@ -2434,7 +2434,7 @@ export default {
'被替换词 => 替换词\n' + '被替换词 => 替换词\n' +
'前定位词 <> 后定位词 >> 集偏移量(EP\n' + '前定位词 <> 后定位词 >> 集偏移量(EP\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: '自定义识别词保存成功', identifierSaveSuccess: '自定义识别词保存成功',
identifierSaveFailed: '自定义识别词保存失败!', identifierSaveFailed: '自定义识别词保存失败!',
@@ -3418,7 +3418,7 @@ export default {
customWords: '自定义识别词', customWords: '自定义识别词',
customWordsHint: '只对该订阅使用的识别词', customWordsHint: '只对该订阅使用的识别词',
customWordsPlaceholder: 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: '取消订阅', cancelSubscribe: '取消订阅',
save: '保存', save: '保存',
cancelSubscribeConfirm: '是否确认取消订阅?', cancelSubscribeConfirm: '是否确认取消订阅?',
+2 -2
View File
@@ -2433,7 +2433,7 @@ export default {
'被替換詞 => 替換詞\n' + '被替換詞 => 替換詞\n' +
'前定位詞 <> 後定位詞 >> 集偏移量(EP\n' + '前定位詞 <> 後定位詞 >> 集偏移量(EP\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: '自定義識別詞保存成功', identifierSaveSuccess: '自定義識別詞保存成功',
identifierSaveFailed: '自定義識別詞保存失敗!', identifierSaveFailed: '自定義識別詞保存失敗!',
@@ -3416,7 +3416,7 @@ export default {
customWords: '自定義識別詞', customWords: '自定義識別詞',
customWordsHint: '只對該訂閱使用的識別詞', customWordsHint: '只對該訂閱使用的識別詞',
customWordsPlaceholder: 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: '取消訂閱', cancelSubscribe: '取消訂閱',
save: '儲存', save: '儲存',
cancelSubscribeConfirm: '是否確認取消訂閱?', cancelSubscribeConfirm: '是否確認取消訂閱?',
+27 -4
View File
@@ -91,11 +91,34 @@ describe('browse page', () => {
await renderBrowse(['person', 'search'], query) await renderBrowse(['person', 'search'], query)
expect(screen.getByRole('heading', { name: '演员: 张三' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: '演员: 张三' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: '人物 browse 列表' })).toHaveAttribute( expect(screen.getByRole('region', { name: '人物 browse 列表' })).toHaveAttribute('data-api-path', 'person/search')
'data-api-path',
'person/search',
)
expect(projectedQuery('人物 browse 查询')).toEqual(query) expect(projectedQuery('人物 browse 查询')).toEqual(query)
expect(screen.queryByRole('region', { name: '媒体 browse 列表' })).not.toBeInTheDocument() 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({ const MediaDetailViewStub = defineComponent({
name: 'MediaDetailView', name: 'MediaDetailView',
props: { props: {
mediaid: String, mediaId: String,
mediaSource: String,
title: String, title: String,
type: String, type: String,
year: String, year: String,
@@ -35,14 +36,16 @@ function projectedProps() {
describe('media page', () => { describe('media page', () => {
it('projects route query values as strings to the detail view', async () => { it('projects route query values as strings to the detail view', async () => {
await renderPage({ await renderPage({
mediaid: ['tmdb:101', 'ignored'], media_id: ['101', 'ignored'],
media_source: 'themoviedb',
title: '测试电影', title: '测试电影',
type: '电影', type: '电影',
year: '2026', year: '2026',
}) })
expect(projectedProps()).toEqual({ expect(projectedProps()).toEqual({
mediaid: 'tmdb:101,ignored', mediaId: '101,ignored',
mediaSource: 'themoviedb',
title: '测试电影', title: '测试电影',
type: '电影', type: '电影',
year: '2026', year: '2026',
@@ -54,4 +57,10 @@ describe('media page', () => {
expect(projectedProps()).toEqual({}) 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(), 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', () => ({ vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
})) }))
@@ -36,12 +41,12 @@ const album = {
album_type: 'Album', album_type: 'Album',
artist: 'Queen', artist: 'Queen',
artists: ['Queen'], artists: ['Queen'],
artist_ids: ['artist-1'], artist_ids: [artistId],
category: 'Album', 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, duration: 2580,
genres: ['rock', 'art rock'], genres: ['rock', 'art rock'],
media_id: 'release-group-1', media_id: albumId,
music_type: 'album', music_type: 'album',
rating: 8.5, rating: 8.5,
rating_votes: 44, rating_votes: 44,
@@ -61,7 +66,7 @@ const album = {
total_tracks: 2, total_tracks: 2,
tracks: [ tracks: [
{ {
media_id: 'recording-1', media_id: firstRecordingId,
title: 'Death on Two Legs', title: 'Death on Two Legs',
track_number: 1, track_number: 1,
disc_number: 1, disc_number: 1,
@@ -69,7 +74,7 @@ const album = {
media_source: 'musicbrainz', media_source: 'musicbrainz',
}, },
{ {
media_id: 'recording-2', media_id: secondRecordingId,
title: 'Bohemian Rhapsody', title: 'Bohemian Rhapsody',
track_number: 2, track_number: 2,
disc_number: 1, disc_number: 1,
@@ -96,7 +101,7 @@ const MediaCardSlideViewStub = defineComponent({
/** 按请求路径分派专辑详情与订阅状态查询。 */ /** 按请求路径分派专辑详情与订阅状态查询。 */
function mockAlbumRequests(subscribed = false) { function mockAlbumRequests(subscribed = false) {
mocks.apiGet.mockImplementation((path: string) => { 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 === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') { if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [13] }, success: true }) return Promise.resolve({ data: { value: [13] }, success: true })
@@ -112,7 +117,7 @@ function mockAlbumRequests(subscribed = false) {
/** 渲染专辑详情页,统一提供超级用户权限与路由身份。 */ /** 渲染专辑详情页,统一提供超级用户权限与路由身份。 */
function renderAlbumPage() { function renderAlbumPage() {
return renderWithProviders(MusicAlbumPage, { 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 } }, initialState: { user: { superUser: true } },
global: { global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true }, stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
@@ -134,7 +139,7 @@ describe('music album page', () => {
await renderAlbumPage() await renderAlbumPage()
expect(await screen.findByRole('heading', { name: 'A Night at the Opera' })).toBeInTheDocument() 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' }, params: { media_source: 'musicbrainz' },
}) })
expect(screen.getByText('Bohemian Rhapsody')).toBeInTheDocument() expect(screen.getByText('Bohemian Rhapsody')).toBeInTheDocument()
@@ -149,7 +154,7 @@ describe('music album page', () => {
await fireEvent.click(await screen.findByText('Bohemian Rhapsody')) await fireEvent.click(await screen.findByText('Bohemian Rhapsody'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail')) 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 () => { 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 fireEvent.click(await screen.findByRole('link', { name: 'Queen' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist')) 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 () => { it('subscribes the whole album from the heart action', async () => {
@@ -170,7 +175,7 @@ describe('music album page', () => {
expect(mocks.apiPost).toHaveBeenCalledWith( expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/', 'subscribe/',
expect.objectContaining({ expect.objectContaining({
media_id: 'release-group-1', media_id: albumId,
media_source: 'musicbrainz', media_source: 'musicbrainz',
name: 'A Night at the Opera', name: 'A Night at the Opera',
type: '音乐', type: '音乐',
@@ -205,7 +210,8 @@ describe('music album page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:release-group-1', media_id: albumId,
media_source: 'musicbrainz',
sites: '13', sites: '13',
type: '音乐', type: '音乐',
}) })
@@ -227,7 +233,7 @@ describe('music album page', () => {
}) })
await renderWithProviders(MusicAlbumPage, { await renderWithProviders(MusicAlbumPage, {
initialRoute: '/music/album?media_source=doubanmusic&mediaid=1401853', initialRoute: '/music/album?media_source=doubanmusic&media_id=1401853',
initialState: { user: { superUser: true } }, initialState: { user: { superUser: true } },
global: { global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true }, stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
@@ -267,7 +273,7 @@ describe('music album page', () => {
}) })
const { router } = await renderWithProviders(MusicAlbumPage, { 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 } }, initialState: { user: { superUser: true } },
global: { global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true }, stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
@@ -299,7 +305,8 @@ describe('music album page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
keyword: `${source}:${mediaId}`, media_id: mediaId,
media_source: source,
music_type: 'album', music_type: 'album',
sites: '13', sites: '13',
type: '音乐', type: '音乐',
+1 -1
View File
@@ -48,7 +48,7 @@ const musicSite = { id: 14, is_active: true, name: '艺术家站点', url: 'http
/** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */ /** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */
function renderArtistPage() { function renderArtistPage() {
return renderWithProviders(MusicArtistPage, { 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 } }, initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true } }, global: { stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true } },
}) })
+25 -19
View File
@@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(), 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', () => ({ vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
})) }))
@@ -33,17 +38,17 @@ vi.mock('vue-toastification', () => ({
const recording = { const recording = {
album: '叶惠美', album: '叶惠美',
album_id: 'release-group-1', album_id: albumId,
album_artist: '周杰伦', album_artist: '周杰伦',
artist: '周杰伦', artist: '周杰伦',
artists: ['周杰伦'], artists: ['周杰伦'],
artist_ids: ['artist-1'], artist_ids: [artistId],
category: 'Album', 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, duration: 269,
genres: ['mandopop'], genres: ['mandopop'],
isrc: 'TWA470301234', isrc: 'TWA470301234',
media_id: 'recording-1', media_id: recordingId,
music_type: 'recording', music_type: 'recording',
release_date: '2003-07-31', release_date: '2003-07-31',
media_source: 'musicbrainz', media_source: 'musicbrainz',
@@ -56,8 +61,8 @@ const album = {
album_type: 'Album', album_type: 'Album',
artist: '周杰伦', artist: '周杰伦',
artists: ['周杰伦'], artists: ['周杰伦'],
artist_ids: ['artist-1'], artist_ids: [artistId],
media_id: 'release-group-1', media_id: albumId,
music_type: 'album', music_type: 'album',
release_date: '2003-07-31', release_date: '2003-07-31',
media_source: 'musicbrainz', media_source: 'musicbrainz',
@@ -65,7 +70,7 @@ const album = {
total_tracks: 2, total_tracks: 2,
tracks: [ tracks: [
{ {
media_id: 'recording-1', media_id: recordingId,
title: '晴天', title: '晴天',
track_number: 1, track_number: 1,
disc_number: 1, disc_number: 1,
@@ -73,7 +78,7 @@ const album = {
media_source: 'musicbrainz', media_source: 'musicbrainz',
}, },
{ {
media_id: 'recording-2', media_id: secondRecordingId,
title: '以父之名', title: '以父之名',
track_number: 2, track_number: 2,
disc_number: 1, disc_number: 1,
@@ -93,7 +98,7 @@ function mockDetailRequests(subscribed = false) {
return Promise.resolve({ data: { id: 1 }, success: true }) return Promise.resolve({ data: { id: 1 }, success: true })
}) })
mocks.apiGet.mockImplementation((path: string) => { 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 === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') { if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [12] }, success: true }) return Promise.resolve({ data: { value: [12] }, success: true })
@@ -108,7 +113,7 @@ function mockDetailRequests(subscribed = false) {
/** 渲染音乐详情页,统一提供超级用户权限与路由身份。 */ /** 渲染音乐详情页,统一提供超级用户权限与路由身份。 */
function renderMusicDetailPage() { function renderMusicDetailPage() {
return renderWithProviders(MusicDetailPage, { 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 } }, initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: 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(await screen.findByRole('heading', { name: '晴天' })).toBeInTheDocument()
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', { expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
media_source: 'musicbrainz', media_source: 'musicbrainz',
media_id: 'recording-1', media_id: recordingId,
music_type: 'recording', music_type: 'recording',
}) })
await waitFor(() => await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', { expect(mocks.apiGet).toHaveBeenCalledWith(`music/album/${albumId}`, {
params: { media_source: 'musicbrainz' }, params: { media_source: 'musicbrainz' },
}), }),
) )
@@ -148,7 +153,7 @@ describe('music detail page', () => {
await fireEvent.click(await screen.findByText('叶惠美')) await fireEvent.click(await screen.findByText('叶惠美'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album')) 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 () => { 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 fireEvent.click(await screen.findByRole('link', { name: '周杰伦' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist')) 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 () => { it('creates a subscription from the heart action', async () => {
@@ -169,7 +174,7 @@ describe('music detail page', () => {
expect(mocks.apiPost).toHaveBeenCalledWith( expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/', 'subscribe/',
expect.objectContaining({ expect.objectContaining({
media_id: 'recording-1', media_id: recordingId,
media_source: 'musicbrainz', media_source: 'musicbrainz',
name: '晴天', name: '晴天',
type: '音乐', type: '音乐',
@@ -189,19 +194,19 @@ describe('music detail page', () => {
it('redirects an album identity to the album page', async () => { it('redirects an album identity to the album page', async () => {
mocks.apiPost.mockImplementation((path: string) => { mocks.apiPost.mockImplementation((path: string) => {
if (path === 'music/recognize') { 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 }) return Promise.resolve({ data: { id: 1 }, success: true })
}) })
const { router } = await renderWithProviders(MusicDetailPage, { 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 } }, initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } }, global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
}) })
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album')) 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 () => { 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')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1', media_id: recordingId,
media_source: 'musicbrainz',
sites: '12', sites: '12',
type: '音乐', type: '音乐',
}) })
+75 -18
View File
@@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(), 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', () => ({ vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
})) }))
@@ -33,14 +38,14 @@ vi.mock('vue-toastification', () => ({
const musicResult = { const musicResult = {
album: '叶惠美', album: '叶惠美',
album_id: 'release-group-1', album_id: albumId,
album_type: 'Album', album_type: 'Album',
artist: '周杰伦', artist: '周杰伦',
artists: ['周杰伦'], artists: ['周杰伦'],
artist_ids: ['artist-1'], artist_ids: [artistId],
category: 'Album', category: 'Album',
duration: 269, duration: 269,
media_id: 'recording-1', media_id: recordingId,
music_type: 'recording', music_type: 'recording',
release_date: '2003-07-31', release_date: '2003-07-31',
media_source: 'musicbrainz', media_source: 'musicbrainz',
@@ -51,13 +56,13 @@ const musicResult = {
const albumResult = { const albumResult = {
album: '七里香', album: '七里香',
album_id: 'release-group-2', album_id: secondAlbumId,
album_type: 'Album', album_type: 'Album',
artist: '周杰伦', artist: '周杰伦',
artists: ['周杰伦'], artists: ['周杰伦'],
artist_ids: ['artist-1'], artist_ids: [artistId],
category: 'Album', category: 'Album',
media_id: 'release-group-2', media_id: secondAlbumId,
music_type: 'album', music_type: 'album',
release_date: '2004-08-03', release_date: '2004-08-03',
media_source: 'musicbrainz', media_source: 'musicbrainz',
@@ -68,7 +73,7 @@ const albumResult = {
const artistResult = { const artistResult = {
category: 'Person', category: 'Person',
media_id: 'artist-1', media_id: artistId,
music_type: 'artist', music_type: 'artist',
media_source: 'musicbrainz', media_source: 'musicbrainz',
title: '周杰伦', title: '周杰伦',
@@ -79,7 +84,7 @@ const artistResult = {
const musicSite = { id: 11, is_active: true, name: '音乐站点', url: 'https://music.example' } 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) => { mocks.apiGet.mockImplementation((path: string) => {
if (path === 'media/search') return Promise.resolve([result]) if (path === 'media/search') return Promise.resolve([result])
if (path === 'site/media/music') return Promise.resolve([musicSite]) if (path === 'site/media/music') return Promise.resolve([musicSite])
@@ -119,6 +124,7 @@ describe('music page', () => {
await waitFor(() => await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', { expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
params: { type: 'music', count: 30, title: '晴天' }, params: { type: 'music', count: 30, title: '晴天' },
paramsSerializer: { indexes: null },
}), }),
) )
expect(screen.queryByRole('button', { name: '搜索音乐' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '搜索音乐' })).not.toBeInTheDocument()
@@ -134,11 +140,45 @@ describe('music page', () => {
await waitFor(() => await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', { 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 () => { it('shows album, artist, release date and duration on the result card', async () => {
await renderMusicPage() await renderMusicPage()
@@ -149,7 +189,22 @@ describe('music page', () => {
expect(screen.getByText('2003-07-31')).toBeInTheDocument() expect(screen.getByText('2003-07-31')).toBeInTheDocument()
expect(screen.getByText('4:29')).toBeInTheDocument() expect(screen.getByText('4:29')).toBeInTheDocument()
expect(screen.getByText('Album')).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 () => { it('uses three columns from the desktop breakpoint', async () => {
@@ -181,7 +236,7 @@ describe('music page', () => {
expect(mocks.apiPost).toHaveBeenCalledWith( expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/', 'subscribe/',
expect.objectContaining({ expect.objectContaining({
media_id: 'recording-1', media_id: recordingId,
media_source: 'musicbrainz', media_source: 'musicbrainz',
name: '晴天', name: '晴天',
type: '音乐', type: '音乐',
@@ -207,7 +262,7 @@ describe('music page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
media_source: 'musicbrainz', media_source: 'musicbrainz',
mediaid: 'recording-1', media_id: recordingId,
title: '晴天', title: '晴天',
}) })
}) })
@@ -218,7 +273,7 @@ describe('music page', () => {
await fireEvent.click(await screen.findByText('叶惠美')) await fireEvent.click(await screen.findByText('叶惠美'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album')) 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 () => { 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 fireEvent.click(await screen.findByText('周杰伦'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist')) 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 () => { 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 fireEvent.click(screen.getByText('七里香'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album')) 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=晴天') await router.push('/music?query=晴天')
const restoredArtistEntity = await screen.findByText('艺术家') const restoredArtistEntity = await screen.findByText('艺术家')
const restoredArtistCard = restoredArtistEntity.closest('.music-card') const restoredArtistCard = restoredArtistEntity.closest('.music-card')
await fireEvent.click(within(restoredArtistCard as HTMLElement).getByText('周杰伦')) await fireEvent.click(within(restoredArtistCard as HTMLElement).getByText('周杰伦'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist')) 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 () => { 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')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1', media_id: recordingId,
media_source: 'musicbrainz',
sites: '11', sites: '11',
type: '音乐', type: '音乐',
}) })
@@ -325,7 +381,8 @@ describe('music page', () => {
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
keyword: `${source}:${mediaId}`, media_id: mediaId,
media_source: source,
music_type: 'album', music_type: 'album',
sites: '11', sites: '11',
type: '音乐', type: '音乐',
+76 -12
View File
@@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({
useDynamicButton: vi.fn(), useDynamicButton: vi.fn(),
})) }))
const musicBrainzAlbumId = '695f5ac8-cfd5-4e7b-96a0-6d545f5c9f17'
vi.mock('@/api', () => ({ vi.mock('@/api', () => ({
default: { default: {
get: (...args: unknown[]) => mocks.apiGet(...args), get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -158,15 +160,21 @@ const TorrentItemStub = defineComponent({
const SubtitleCardStub = defineComponent({ const SubtitleCardStub = defineComponent({
props: { props: {
subtitle: { type: Object, required: true }, 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({ const SubtitleItemStub = defineComponent({
props: { props: {
subtitle: { type: Object, required: true }, 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({ const ProgressiveCardGridStub = defineComponent({
@@ -294,9 +302,10 @@ const searchRouteCases: SearchRouteCase[] = [
streamParams: { keyword: '普通标题', sites: '1,2' }, streamParams: { keyword: '普通标题', sites: '1,2' },
}, },
{ {
apiEndpoint: 'search/media/tmdb:42', apiEndpoint: 'search/media/42',
apiParams: { apiParams: {
area: 'CN', area: 'CN',
media_source: 'themoviedb',
mtype: '电视剧', mtype: '电视剧',
season: '2', season: '2',
sites: '1', sites: '1',
@@ -304,10 +313,11 @@ const searchRouteCases: SearchRouteCase[] = [
year: '2025', year: '2025',
}, },
displayTitle: '媒体资源结果', displayTitle: '媒体资源结果',
expectedPath: '/api/v1/search/media/tmdb%3A42/stream', expectedPath: '/api/v1/search/media/42/stream',
query: { query: {
area: 'CN', area: 'CN',
keyword: 'tmdb:42', media_id: '42',
media_source: 'themoviedb',
result_type: 'torrent', result_type: 'torrent',
season: '2', season: '2',
sites: '1', sites: '1',
@@ -318,6 +328,7 @@ const searchRouteCases: SearchRouteCase[] = [
result: createTorrent({ title: '媒体资源结果' }), result: createTorrent({ title: '媒体资源结果' }),
streamParams: { streamParams: {
area: 'CN', area: 'CN',
media_source: 'themoviedb',
mtype: '电视剧', mtype: '电视剧',
season: '2', season: '2',
sites: '1', sites: '1',
@@ -326,19 +337,21 @@ const searchRouteCases: SearchRouteCase[] = [
}, },
}, },
{ {
apiEndpoint: 'search/media/musicbrainz:album-1', apiEndpoint: `search/media/${musicBrainzAlbumId}`,
apiParams: { apiParams: {
area: 'title', area: 'title',
media_source: 'musicbrainz',
mtype: '音乐', mtype: '音乐',
music_type: 'album', music_type: 'album',
title: '叶惠美', title: '叶惠美',
year: '2003', year: '2003',
}, },
displayTitle: '专辑资源结果', displayTitle: '专辑资源结果',
expectedPath: '/api/v1/search/media/musicbrainz%3Aalbum-1/stream', expectedPath: `/api/v1/search/media/${musicBrainzAlbumId}/stream`,
query: { query: {
area: 'title', area: 'title',
keyword: 'musicbrainz:album-1', media_id: musicBrainzAlbumId,
media_source: 'musicbrainz',
music_type: 'album', music_type: 'album',
result_type: 'torrent', result_type: 'torrent',
title: '叶惠美', title: '叶惠美',
@@ -348,6 +361,7 @@ const searchRouteCases: SearchRouteCase[] = [
result: createTorrent({ title: '专辑资源结果' }), result: createTorrent({ title: '专辑资源结果' }),
streamParams: { streamParams: {
area: 'title', area: 'title',
media_source: 'musicbrainz',
mtype: '音乐', mtype: '音乐',
music_type: 'album', music_type: 'album',
title: '叶惠美', title: '叶惠美',
@@ -364,9 +378,10 @@ const searchRouteCases: SearchRouteCase[] = [
streamParams: { keyword: '字幕标题', sites: '2' }, streamParams: { keyword: '字幕标题', sites: '2' },
}, },
{ {
apiEndpoint: 'search/subtitle/media/tmdb:84', apiEndpoint: 'search/subtitle/media/84',
apiParams: { apiParams: {
episode: '3', episode: '3',
media_source: 'themoviedb',
mtype: '电视剧', mtype: '电视剧',
season: '2', season: '2',
sites: '2', sites: '2',
@@ -374,10 +389,11 @@ const searchRouteCases: SearchRouteCase[] = [
year: '2025', year: '2025',
}, },
displayTitle: '字幕媒体结果', displayTitle: '字幕媒体结果',
expectedPath: '/api/v1/search/subtitle/media/tmdb%3A84/stream', expectedPath: '/api/v1/search/subtitle/media/84/stream',
query: { query: {
episode: '3', episode: '3',
keyword: 'tmdb:84', media_id: '84',
media_source: 'themoviedb',
result_type: 'subtitle', result_type: 'subtitle',
season: '2', season: '2',
sites: '2', sites: '2',
@@ -388,6 +404,7 @@ const searchRouteCases: SearchRouteCase[] = [
result: createSubtitle('字幕媒体结果'), result: createSubtitle('字幕媒体结果'),
streamParams: { streamParams: {
episode: '3', episode: '3',
media_source: 'themoviedb',
mtype: '电视剧', mtype: '电视剧',
season: '2', season: '2',
sites: '2', sites: '2',
@@ -439,13 +456,19 @@ describe('resource page search flow', () => {
}), }),
) )
expect(await screen.findByText(displayTitle)).toBeInTheDocument() 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({})) await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
const storedParams = JSON.parse(localStorage.getItem('MP_ResourceSearchParams') || '{}') const storedParams = JSON.parse(localStorage.getItem('MP_ResourceSearchParams') || '{}')
expect(storedParams).toEqual({ expect(storedParams).toEqual({
area: query.area ?? '', area: query.area ?? '',
episode: query.episode ?? '', episode: query.episode ?? '',
keyword: query.keyword, keyword: query.keyword ?? '',
media_id: query.media_id ?? '',
media_source: query.media_source ?? '',
music_type: query.music_type ?? '', music_type: query.music_type ?? '',
result_type: query.result_type === 'subtitle' ? 'subtitle' : 'torrent', result_type: query.result_type === 'subtitle' ? 'subtitle' : 'torrent',
season: query.season ?? '', season: query.season ?? '',
@@ -477,6 +500,8 @@ describe('resource page search flow', () => {
area: '', area: '',
episode: '', episode: '',
keyword: '上次关键词', keyword: '上次关键词',
media_id: '',
media_source: '',
music_type: '', music_type: '',
result_type: 'torrent', result_type: 'torrent',
season: '', 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 () => { it('does not automatically repeat a completed empty search when KeepAlive reactivates', async () => {
const rendered = await renderResource({ const rendered = await renderResource({
path: '/resource', path: '/resource',
+15 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import MediaCardListView from '@/views/discover/MediaCardListView.vue' import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import PersonCardListView from '@/views/discover/PersonCardListView.vue' import PersonCardListView from '@/views/discover/PersonCardListView.vue'
import { parseMediaDataSources } from '@/utils/mediaId'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
@@ -20,16 +21,28 @@ let title = route.query?.title?.toString()
const type = route.query?.type?.toString() const type = route.query?.type?.toString()
if (type === 'person') title = t('browse.actor') + ': ' + title if (type === 'person') title = t('browse.actor') + ': ' + title
/** 将路由段转换为后端 API 路径。 */
function getApiPath(paths: string[]) { function getApiPath(paths: string[]) {
return paths.join('/') 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> </script>
<template> <template>
<div> <div>
<VPageContentTitle :title="title" /> <VPageContentTitle :title="title" />
<PersonCardListView v-if="type === 'person'" :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="route.query" /> <MediaCardListView v-else :apipath="getApiPath(props.paths)" :params="requestParams" />
<Teleport to="body"> <Teleport to="body">
<VScrollToTopBtn /> <VScrollToTopBtn />
</Teleport> </Teleport>
+11 -6
View File
@@ -1,24 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import MediaDetailView from '@/views/discover/MediaDetailView.vue' import MediaDetailView from '@/views/discover/MediaDetailView.vue'
import { isMediaDataSource } from '@/utils/mediaId'
// //
const route = useRoute() 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> </script>
<template> <template>
<div> <div>
<MediaDetailView :mediaid="mediaid" :type="type" :title="title" :year="year" /> <MediaDetailView :media-source="mediaSource" :media-id="mediaId" :type="type" :title="title" :year="year" />
</div> </div>
</template> </template>
+7 -3
View File
@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import MusicAlbumView from '@/views/discover/MusicAlbumView.vue' import MusicAlbumView from '@/views/discover/MusicAlbumView.vue'
import { isMediaDataSource } from '@/utils/mediaId'
// //
const route = useRoute() const route = useRoute()
// ID // 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> </script>
<template> <template>
<div> <div>
<MusicAlbumView :mediaid="mediaid" :media-source="mediaSource" /> <MusicAlbumView :media-id="mediaId" :media-source="mediaSource" />
</div> </div>
</template> </template>
+7 -3
View File
@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import MusicArtistView from '@/views/discover/MusicArtistView.vue' import MusicArtistView from '@/views/discover/MusicArtistView.vue'
import { isMediaDataSource } from '@/utils/mediaId'
// //
const route = useRoute() const route = useRoute()
// ID // 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> </script>
<template> <template>
<div> <div>
<MusicArtistView :mediaid="mediaid" :media-source="mediaSource" /> <MusicArtistView :media-id="mediaId" :media-source="mediaSource" />
</div> </div>
</template> </template>
+7 -3
View File
@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import MusicDetailView from '@/views/discover/MusicDetailView.vue' import MusicDetailView from '@/views/discover/MusicDetailView.vue'
import { isMediaDataSource } from '@/utils/mediaId'
// //
const route = useRoute() const route = useRoute()
// ID // 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> </script>
<template> <template>
<div> <div>
<MusicDetailView :mediaid="mediaid" :media-source="mediaSource" /> <MusicDetailView :media-id="mediaId" :media-source="mediaSource" />
</div> </div>
</template> </template>
+11 -6
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import api from '@/api' 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 MusicCard from '@/components/cards/MusicCard.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { parseMediaDataSources } from '@/utils/mediaId'
import { getMusicKey } from '@/utils/music' import { getMusicKey } from '@/utils/music'
const { t } = useI18n() const { t } = useI18n()
@@ -14,7 +15,7 @@ const results = ref<MediaInfo[]>([])
// //
const query = computed(() => route.query.query?.toString().trim() || '') 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() { async function searchMusic() {
@@ -27,13 +28,17 @@ async function searchMusic() {
loading.value = true loading.value = true
searched.value = true searched.value = true
try { try {
const params: Record<string, string | number> = { const params: Record<string, string | number | MediaDataSource[]> = {
title: query.value, title: query.value,
type: 'music', type: 'music',
count: 30, count: 30,
} }
if (mediaSource.value) params.media_source = mediaSource.value if (mediaSources.value.length > 0) params.media_source = mediaSources.value
results.value = (await api.get('media/search', { params })) || [] results.value =
(await api.get('media/search', {
params,
paramsSerializer: { indexes: null },
})) || []
} catch (error) { } catch (error) {
console.error(error) console.error(error)
results.value = [] results.value = []
@@ -42,7 +47,7 @@ async function searchMusic() {
} }
} }
watch([query, mediaSource], searchMusic, { immediate: true }) watch([query, mediaSources], searchMusic, { immediate: true })
</script> </script>
<template> <template>
+81 -16
View File
@@ -2,7 +2,7 @@
import type { LocationQuery } from 'vue-router' import type { LocationQuery } from 'vue-router'
import NoDataFound from '@/components/states/NoDataFound.vue' import NoDataFound from '@/components/states/NoDataFound.vue'
import api from '@/api' 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 TorrentCard from '@/components/cards/TorrentCard.vue'
import TorrentItem from '@/components/cards/TorrentItem.vue' import TorrentItem from '@/components/cards/TorrentItem.vue'
import SubtitleCard from '@/components/cards/SubtitleCard.vue' import SubtitleCard from '@/components/cards/SubtitleCard.vue'
@@ -20,6 +20,7 @@ import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { SearchReplaceBatchCollector, isSearchReplaceBatchEvent } from '@/utils/searchStream' import { SearchReplaceBatchCollector, isSearchReplaceBatchEvent } from '@/utils/searchStream'
import { getCurrentLocale } from '@/plugins/i18n' import { getCurrentLocale } from '@/plugins/i18n'
import { isMediaDataSource, isValidMediaSourceId } from '@/utils/mediaId'
// //
const { t } = useI18n() const { t } = useI18n()
@@ -45,6 +46,8 @@ const router = useRouter()
interface SearchParams { interface SearchParams {
keyword: string keyword: string
media_source: MediaDataSource | ''
media_id: string
type: string type: string
area: string area: string
title: string title: string
@@ -69,9 +72,17 @@ type TorrentViewType = 'card' | 'row'
// //
let activeSearchRequestId = 0 let activeSearchRequestId = 0
/** 只接受产品协议中固定的数据源枚举,避免未知来源进入搜索链路。 */
function normalizeMediaSource(value: unknown): MediaDataSource | '' {
const normalized = value?.toString().trim()
return isMediaDataSource(normalized) ? normalized : ''
}
function createSearchParams(query: LocationQuery): SearchParams { function createSearchParams(query: LocationQuery): SearchParams {
return { return {
keyword: query?.keyword?.toString() ?? '', keyword: query?.keyword?.toString() ?? '',
media_source: normalizeMediaSource(query?.media_source),
media_id: query?.media_id?.toString() ?? '',
type: query?.type?.toString() ?? '', type: query?.type?.toString() ?? '',
area: query?.area?.toString() ?? '', area: query?.area?.toString() ?? '',
title: query?.title?.toString() ?? '', title: query?.title?.toString() ?? '',
@@ -87,6 +98,8 @@ function createSearchParams(query: LocationQuery): SearchParams {
function normalizeSearchParams(params?: Partial<SearchParams> | null): SearchParams { function normalizeSearchParams(params?: Partial<SearchParams> | null): SearchParams {
return { return {
keyword: params?.keyword?.toString() ?? '', keyword: params?.keyword?.toString() ?? '',
media_source: normalizeMediaSource(params?.media_source),
media_id: params?.media_id?.toString() ?? '',
type: params?.type?.toString() ?? '', type: params?.type?.toString() ?? '',
area: params?.area?.toString() ?? '', area: params?.area?.toString() ?? '',
title: params?.title?.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 { 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 { function createSearchRequestToken(): string {
@@ -116,7 +163,8 @@ function loadStoredSearchParams(): SearchParams | null {
const rawParams = localStorage.getItem(resourceSearchParamsStorageKey) const rawParams = localStorage.getItem(resourceSearchParamsStorageKey)
if (!rawParams) return null 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 return hasSearchKeyword(params) ? params : null
} catch (error) { } catch (error) {
console.warn('读取资源搜索参数失败:', error) console.warn('读取资源搜索参数失败:', error)
@@ -199,8 +247,8 @@ async function resolveRefreshSearchParams() {
// TMDBID // TMDBID
const keyword = computed(() => activeSearchParams.value.keyword) 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) const type = computed(() => activeSearchParams.value.type)
@@ -555,18 +603,19 @@ function setSearchParam(params: URLSearchParams, key: string, value: unknown) {
// URL // URL
function buildSearchStreamUrl(params: SearchParams, requestToken?: string) { function buildSearchStreamUrl(params: SearchParams, requestToken?: string) {
const isMediaSearch = /^[a-zA-Z]+:/.test(params.keyword) const isMediaSearch = hasValidMediaIdentity(params)
const url = getApiUrl( const url = getApiUrl(
params.result_type === 'subtitle' params.result_type === 'subtitle'
? isMediaSearch ? isMediaSearch
? `search/subtitle/media/${encodeURIComponent(params.keyword)}/stream` ? `search/subtitle/media/${encodeURIComponent(params.media_id)}/stream`
: 'search/subtitle/title/stream' : 'search/subtitle/title/stream'
: isMediaSearch : isMediaSearch
? `search/media/${encodeURIComponent(params.keyword)}/stream` ? `search/media/${encodeURIComponent(params.media_id)}/stream`
: 'search/title/stream', : 'search/title/stream',
) )
if (params.result_type === 'subtitle' && isMediaSearch) { if (params.result_type === 'subtitle' && isMediaSearch) {
setSearchParam(url.searchParams, 'media_source', params.media_source)
setSearchParam(url.searchParams, 'mtype', params.type) setSearchParam(url.searchParams, 'mtype', params.type)
setSearchParam(url.searchParams, 'title', params.title) setSearchParam(url.searchParams, 'title', params.title)
setSearchParam(url.searchParams, 'year', params.year) 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, 'keyword', params.keyword)
setSearchParam(url.searchParams, 'sites', params.sites) setSearchParam(url.searchParams, 'sites', params.sites)
} else if (isMediaSearch) { } else if (isMediaSearch) {
setSearchParam(url.searchParams, 'media_source', params.media_source)
setSearchParam(url.searchParams, 'mtype', params.type) setSearchParam(url.searchParams, 'mtype', params.type)
setSearchParam(url.searchParams, 'area', params.area) setSearchParam(url.searchParams, 'area', params.area)
setSearchParam(url.searchParams, 'title', params.title) 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) { async function requestSearchResults(params: SearchParams, requestToken?: string) {
let result: { [key: string]: any } let result: { [key: string]: any }
const isMediaSearch = /^[a-zA-Z]+:/.test(params.keyword) const isMediaSearch = hasValidMediaIdentity(params)
// keyword xxxx:xxxxx :xxxxID
if (params.result_type === 'subtitle' && isMediaSearch) { 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: { params: {
media_source: params.media_source,
mtype: params.type, mtype: params.type,
title: params.title, title: params.title,
year: params.year, year: params.year,
@@ -846,8 +896,9 @@ async function requestSearchResults(params: SearchParams, requestToken?: string)
}, },
}) })
} else if (isMediaSearch) { } else if (isMediaSearch) {
result = await api.get(`search/media/${params.keyword}`, { result = await api.get(`search/media/${encodeURIComponent(params.media_id)}`, {
params: { params: {
media_source: params.media_source,
mtype: params.type, mtype: params.type,
area: params.area, area: params.area,
title: params.title, title: params.title,
@@ -971,7 +1022,7 @@ async function fetchData(options: { force?: boolean; params?: SearchParams; sile
activeSearchParams.value = { ...currentSearchParams } activeSearchParams.value = { ...currentSearchParams }
rememberSearchParams(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 hasCurrentResults = isSubtitleSearch.value ? rawSubtitleDataList.value.length > 0 : rawDataList.value.length > 0
const silentRefresh = Boolean(options.silent && isRefreshed.value && hasCurrentResults) const silentRefresh = Boolean(options.silent && isRefreshed.value && hasCurrentResults)
@@ -1561,6 +1612,8 @@ onUnmounted(() => {
v-for="(item, index) in streamPreviewSubtitleDataList" v-for="(item, index) in streamPreviewSubtitleDataList"
:key="getSubtitleItemKey(item, index)" :key="getSubtitleItemKey(item, index)"
:subtitle="item" :subtitle="item"
:media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined"
class="stream-result-item" class="stream-result-item"
/> />
</div> </div>
@@ -1572,7 +1625,11 @@ onUnmounted(() => {
:estimated-item-height="320" :estimated-item-height="320"
> >
<template #default="{ item }"> <template #default="{ item }">
<SubtitleCard :subtitle="item" /> <SubtitleCard
:subtitle="item"
:media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined"
/>
</template> </template>
</ProgressiveCardGrid> </ProgressiveCardGrid>
<div <div
@@ -1635,7 +1692,11 @@ onUnmounted(() => {
:key="getSubtitleItemKey(item, index)" :key="getSubtitleItemKey(item, index)"
class="stream-result-item" 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" /> <VDivider v-if="index < streamPreviewSubtitleDataList.length - 1" class="my-2" />
</div> </div>
</div> </div>
@@ -1649,7 +1710,11 @@ onUnmounted(() => {
:get-item-key="getSubtitleItemKey" :get-item-key="getSubtitleItemKey"
> >
<template #default="{ item, index }"> <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" /> <VDivider v-if="index < rawSubtitleDataList.length - 1" class="my-2" />
</template> </template>
</ProgressiveCardGrid> </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' import { describe, expect, it } from 'vitest'
describe('media source identity utils', () => { describe('media source identity utils', () => {
@@ -6,12 +6,32 @@ describe('media source identity utils', () => {
expect(isMusicMediaSource(source)).toBe(true) 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('977e6978-139d-425c-bb98-6b0c62d1e45e', 'musicbrainz')).toBe(true)
expect(isValidMediaSourceId('not-a-uuid', 'musicbrainz')).toBe(false)
expect(isValidMediaSourceId('32793500', 'theaudiodb')).toBe(true) expect(isValidMediaSourceId('32793500', 'theaudiodb')).toBe(true)
expect(isValidMediaSourceId('1401853', 'doubanmusic')).toBe(true) expect(isValidMediaSourceId('1401853', 'doubanmusic')).toBe(true)
expect(isValidMediaSourceId('1401853:3', 'doubanmusic')).toBe(true) expect(isValidMediaSourceId('1401853:3', 'doubanmusic')).toBe(true)
expect(isValidMediaSourceId('1401853:track', 'doubanmusic')).toBe(false) 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', () => { it('routes a recording to the music detail page', () => {
expect(buildMusicDetailRoute({ media_source: 'musicbrainz', media_id: 'recording-1', title: '晴天' })).toEqual({ expect(buildMusicDetailRoute({ media_source: 'musicbrainz', media_id: 'recording-1', title: '晴天' })).toEqual({
path: '/music/detail', 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({ ).toEqual({
path: '/music/album', 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({ ).toEqual({
path: '/music/artist', 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', () => { it('builds album and artist routes with an explicit source', () => {
expect(buildMusicAlbumRoute('release-group-1', '叶惠美')).toEqual({ expect(buildMusicAlbumRoute('release-group-1', '叶惠美', 'musicbrainz')).toEqual({
path: '/music/album', 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', 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({ ).toMatchObject({
path: '/resource', path: '/resource',
query: { query: {
keyword: 'musicbrainz:recording-1', media_id: 'recording-1',
media_source: 'musicbrainz',
music_type: 'recording', music_type: 'recording',
sites: '11,12', sites: '11,12',
type: '音乐', 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 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 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 { export function isMusicMediaSource(source?: MediaDataSource): boolean {
return MUSIC_MEDIA_SOURCES.includes(source as (typeof MUSIC_MEDIA_SOURCES)[number]) 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 { export function isValidMediaSourceId(value: string | number | null | undefined, source?: MediaDataSource): boolean {
const normalized = value?.toString().trim() const normalized = value?.toString().trim()
if (!normalized) return true if (!normalized) return true
// 无媒体上下文或来源为非 MusicBrainz 时,UUID 形态同样按 MusicBrainz ID 放行 if (normalized === '0') return false
if (source === 'musicbrainz' || MUSICBRAINZ_ID_PATTERN.test(normalized)) { if (source === 'musicbrainz') return MUSICBRAINZ_ID_PATTERN.test(normalized)
return MUSICBRAINZ_ID_PATTERN.test(normalized)
}
if (source === 'doubanmusic' && normalized.includes(':')) { if (source === 'doubanmusic' && normalized.includes(':')) {
return /^\d+:\d+$/.test(normalized) 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' import type { RouteLocationRaw } from 'vue-router'
export interface MusicRouteTarget { export interface MusicRouteTarget {
media_source?: string media_source?: MediaDataSource
media_id?: string | number media_id?: string | number
music_type?: MusicEntityType music_type?: MusicEntityType
title?: string 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 return item.media_source
} }
@@ -68,7 +68,7 @@ export function getMusicSourceLabel(source?: string, translate?: (key: string) =
theaudiodb: 'TheAudioDB', theaudiodb: 'TheAudioDB',
doubanmusic: translate?.('setting.cache.recognitionSource.doubanmusic') || '豆瓣音乐', 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 { export function buildMusicAlbumRoute(
return { path: '/music/album', query: { media_source: mediaSource, mediaid: albumId, title } } 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 { export function buildMusicArtistRoute(
return { path: '/music/artist', query: { media_source: mediaSource, mediaid: artistId, title: name } } 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', path: '/music/detail',
query: { query: {
media_source: source, media_source: source,
mediaid: mediaId, media_id: mediaId,
title: item.title || item.name, title: item.title || item.name,
}, },
} }
@@ -119,7 +127,8 @@ export function buildMusicResourceRoute(
return { return {
path: '/resource', path: '/resource',
query: { query: {
keyword: `${source}:${item.media_id}`, media_source: source,
media_id: item.media_id,
type: '音乐', type: '音乐',
music_type: (item as MusicRouteTarget).music_type || 'recording', music_type: (item as MusicRouteTarget).music_type || 'recording',
title: item.title, title: item.title,
+1 -1
View File
@@ -73,7 +73,7 @@ const backgroundTasks = computed<BackgroundTaskItem[]>(() => {
const isRunning = tasks.some(task => task.state === 'running') const isRunning = tasks.some(task => task.state === 'running')
return { 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'), title: item.media?.title_year || item.media?.title || t('dashboard.transferQueue'),
subtitle: t('dashboard.transferProgress', { completed, total: tasks.length }), subtitle: t('dashboard.transferProgress', { completed, total: tasks.length }),
status: isRunning ? t('dashboard.taskRunning') : t('dashboard.taskWaiting'), 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 type { MediaInfo } from '@/api/types'
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue' import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot' import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe' import { getMediaSubscribeId, getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils' import { getDisplayImageUrl } from '@/utils/imageUtils'
import { createBuiltInRecommendSources, type RecommendViewSource } from '@/utils/recommendSources' import { createBuiltInRecommendSources, type RecommendViewSource } from '@/utils/recommendSources'
@@ -75,7 +75,7 @@ function normalizeMediaResponse(response: unknown): MediaInfo[] {
/** 判断媒体是否具备可展示图片和可进入详情页的标识。 */ /** 判断媒体是否具备可展示图片和可进入详情页的标识。 */
function isUsableMedia(item: 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) 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 } }) void router.push({ path: `/browse/tmdb/collection/${item.collection_id}`, query: { title: item.title } })
return return
} }
const identity = getMediaSubscribeIdentity(item)
if (!identity) return
void router.push({ void router.push({
path: '/media', path: '/media',
query: { query: {
mediaid: getMediaSubscribeId(item), media_source: identity.source,
media_id: identity.mediaId,
title: item.title, title: item.title,
type: item.type, type: item.type,
year: item.year, year: item.year,
@@ -61,7 +61,7 @@ describe('MediaRecommend', () => {
list: [ list: [
createMediaInfo({ title: undefined }), createMediaInfo({ title: undefined }),
createMediaInfo({ backdrop_path: undefined, poster_path: undefined, title: '无图片' }), 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, ...validMedia,
], ],
}, },
@@ -205,7 +205,8 @@ describe('MediaRecommend', () => {
await fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter' }) await fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter' })
await waitFor(() => expect(router.currentRoute.value.path).toBe('/media')) await waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
expect(router.currentRoute.value.query).toMatchObject({ expect(router.currentRoute.value.query).toMatchObject({
mediaid: 'tmdb:101', media_id: '101',
media_source: 'themoviedb',
title: '普通媒体', title: '普通媒体',
type: '电影', type: '电影',
year: '2025', year: '2025',
+4 -2
View File
@@ -43,7 +43,7 @@ const seenKeys = new Set<string>()
const seenPageSignatures = new Set<string>() const seenPageSignatures = new Set<string>()
// //
function getParams() { function getParams(): Record<string, unknown> {
return { return {
...props.params, ...props.params,
page: page.value, page: page.value,
@@ -84,8 +84,10 @@ function appendData(items: MediaInfo[]) {
} }
async function loadPageData() { async function loadPageData() {
const params = getParams()
const rawData: MediaInfo[] = await api.get(props.apipath, { 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 pageSignature = [...new Set(rawData.map(getMediaIdentity))].sort().join('\n')
const isTerminal = rawData.length === 0 || seenPageSignatures.has(pageSignature) const isTerminal = rawData.length === 0 || seenPageSignatures.has(pageSignature)
+1 -3
View File
@@ -106,9 +106,7 @@ onActivated(() => {
<VirtualSlideView <VirtualSlideView
:items="dataList" :items="dataList"
:loading="!componentLoaded" :loading="!componentLoaded"
:get-item-key=" :get-item-key="item => `${item.media_source || 'unknown'}:${item.media_id || item.title || ''}`"
item => item.media_id || item.tmdb_id || item.douban_id || item.bangumi_id || item.anilist_id || item.title
"
> >
<template #item="{ item }"> <template #item="{ item }">
<MediaCard :media="item" width="9rem" /> <MediaCard :media="item" width="9rem" />
+55 -55
View File
@@ -5,6 +5,7 @@ import MediaCardSlideView from './MediaCardSlideView.vue'
import api from '@/api' import api from '@/api'
import type { import type {
ApiResponse, ApiResponse,
MediaDataSource,
MediaInfo, MediaInfo,
MediaRelease, MediaRelease,
MediaSeason, MediaSeason,
@@ -41,12 +42,13 @@ const { t } = useI18n()
const $toast = useToast() const $toast = useToast()
// //
const mediaProps = defineProps({ const mediaProps = defineProps<{
mediaid: String, mediaSource?: MediaDataSource
title: String, mediaId?: string
year: [String, Number], title?: string
type: String, year?: string | number
}) type?: string
}>()
// provide // provide
// //
@@ -200,7 +202,7 @@ async function querySelectedSites() {
} }
} }
// mediaid //
function getMediaId() { function getMediaId() {
return getMediaSubscribeId(mediaDetail.value) return getMediaSubscribeId(mediaDetail.value)
} }
@@ -217,42 +219,46 @@ function getSubscribeStatusKey(season: number | null = mediaDetail.value?.season
// API // API
async function getMediaDetail() { async function getMediaDetail() {
if (mediaProps.mediaid && mediaProps.type) { if (!mediaProps.mediaSource || !mediaProps.mediaId || !mediaProps.type) {
mediaDetail.value = {} as MediaInfo
detailLoadFailed.value = false detailLoadFailed.value = false
isRefreshed.value = false isRefreshed.value = true
try { return
mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, { }
params: {
title: mediaProps.title,
year: mediaProps.year,
type_name: mediaProps.type,
},
})
if (!hasMediaIdentity()) return
const supportsEpisodeGroups = getMediaSubscribeIdentity(mediaDetail.value)?.source === 'themoviedb' detailLoadFailed.value = false
selectedEpisodeGroup.value = supportsEpisodeGroups ? mediaDetail.value.episode_group || '' : '' isRefreshed.value = false
if (!supportsEpisodeGroups) { try {
episodeGroups.value = [] mediaDetail.value = await api.get(`media/${encodeURIComponent(mediaProps.mediaId)}`, {
episodeGroupSeasons.value = [] params: {
} media_source: mediaProps.mediaSource,
if (mediaDetail.value.type === '电视剧' && supportsEpisodeGroups && mediaDetail.value.tmdb_id) { type_name: mediaProps.type,
getEpisodeGroups() },
if (selectedEpisodeGroup.value) loadEpisodeGroupSeasons(selectedEpisodeGroup.value) })
} if (!hasMediaIdentity()) return
// const supportsEpisodeGroups = getMediaSubscribeIdentity(mediaDetail.value)?.source === 'themoviedb'
checkExists() selectedEpisodeGroup.value = supportsEpisodeGroups ? mediaDetail.value.episode_group || '' : ''
if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists() if (!supportsEpisodeGroups) {
// episodeGroups.value = []
if (mediaDetail.value.type === '电影') checkMovieSubscribed() episodeGroupSeasons.value = []
else checkSeasonsSubscribed()
} catch (error) {
console.error(error)
detailLoadFailed.value = true
} finally {
isRefreshed.value = true
} }
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 { try {
const result: ApiResponse<{ item: { id: string } }> = await api.get('mediaserver/exists', { const result: ApiResponse<{ item: { id: string } }> = await api.get('mediaserver/exists', {
params: { params: {
tmdbid: mediaDetail.value.tmdb_id, media_source: mediaDetail.value.media_source,
media_id: mediaDetail.value.media_id,
title: mediaDetail.value.title, title: mediaDetail.value.title,
year: mediaDetail.value.year, year: mediaDetail.value.year,
season: mediaDetail.value.season, season: mediaDetail.value.season,
@@ -324,19 +331,10 @@ async function checkSubscribe(season: number | null = null) {
// //
function isSameSubscribeMedia(subscribe: Subscribe) { function isSameSubscribeMedia(subscribe: Subscribe) {
const mediaId = getMediaId() const identity = getMediaSubscribeIdentity(mediaDetail.value)
if (subscribe.media_source && subscribe.media_id) { return Boolean(
const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source identity && subscribe.media_source === identity.source && String(subscribe.media_id || '') === identity.mediaId,
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
} }
// //
@@ -702,13 +700,15 @@ function joinArray(arr: string[]) {
// //
function handleSearch(resultType: 'torrent' | 'subtitle' = 'torrent', options: MediaSearchOptions = {}) { 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 season = options.season ?? mediaDetail.value.season
const episode = options.episode ?? null const episode = options.episode ?? null
router.push({ router.push({
path: '/resource', path: '/resource',
query: { query: {
keyword, media_source: identity.source,
media_id: identity.mediaId,
type: mediaDetail.value.type, type: mediaDetail.value.type,
area: searchType.value, area: searchType.value,
title: mediaDetail.value.title, title: mediaDetail.value.title,
+20 -18
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api' 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 MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue' import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue' import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
@@ -21,15 +21,12 @@ import {
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps<{
// ID // ID
mediaid: String, mediaId?: string
// //
mediaSource: { mediaSource?: MediaDataSource
type: String, }>()
default: 'musicbrainz',
},
})
const userStore = useUserStore() const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions)) const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
@@ -44,11 +41,12 @@ const isSubscribed = ref(false)
const artistLinks = computed(() => getMusicArtistLinks(album.value)) 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(() => const primaryArtistId = computed(() =>
supportsArtistBrowsing.value ? artistLinks.value.find(artist => artist.id)?.id : undefined, supportsArtistBrowsing.value ? artistLinks.value.find(artist => artist.id)?.id : undefined,
) )
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t)) const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
const encodedMediaSource = computed(() => (props.mediaSource ? encodeURIComponent(props.mediaSource) : ''))
// //
const albumMedia = computed<MediaInfo | undefined>(() => { const albumMedia = computed<MediaInfo | undefined>(() => {
@@ -87,10 +85,14 @@ const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
/** 加载专辑详情、曲目列表和发行版本。 */ /** 加载专辑详情、曲目列表和发行版本。 */
async function loadAlbumDetail() { async function loadAlbumDetail() {
if (!props.mediaSource || !props.mediaid) return if (!props.mediaSource || !props.mediaId) {
album.value = undefined
isRefreshed.value = true
return
}
isRefreshed.value = false isRefreshed.value = false
try { 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) { } catch (error) {
console.error(error) console.error(error)
album.value = undefined album.value = undefined
@@ -112,7 +114,7 @@ async function checkSubscribeStatus() {
/** 打开艺术家详情页。 */ /** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) { function goArtist(artistId?: string, name?: string) {
if (!artistId) return if (!artistId || !props.mediaSource) return
router.push(buildMusicArtistRoute(artistId, name, props.mediaSource)) 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(' · ') 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> </script>
<template> <template>
@@ -231,10 +233,10 @@ watch(() => [props.mediaSource, props.mediaid], loadAlbumDetail, { immediate: tr
</template> </template>
</template> </template>
<div v-if="primaryArtistId" class="music-section"> <div v-if="primaryArtistId && props.mediaSource" class="music-section">
<MediaCardSlideView <MediaCardSlideView
:apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}`" :apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodedMediaSource}`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(t('music.artistAlbums'))}`" :linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodedMediaSource}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
:title="t('music.artistAlbums')" :title="t('music.artistAlbums')"
/> />
</div> </div>
@@ -248,8 +250,8 @@ watch(() => [props.mediaSource, props.mediaid], loadAlbumDetail, { immediate: tr
<div v-if="props.mediaSource === 'doubanmusic'" class="music-section"> <div v-if="props.mediaSource === 'doubanmusic'" class="music-section">
<MediaCardSlideView <MediaCardSlideView
:apipath="`music/album/${props.mediaid}/related?media_source=doubanmusic`" :apipath="`music/album/${props.mediaId}/related?media_source=doubanmusic`"
:linkurl="`/browse/music/album/${props.mediaid}/related?media_source=doubanmusic&title=${encodeURIComponent(t('music.relatedAlbums'))}`" :linkurl="`/browse/music/album/${props.mediaId}/related?media_source=doubanmusic&title=${encodeURIComponent(t('music.relatedAlbums'))}`"
:title="t('music.relatedAlbums')" :title="t('music.relatedAlbums')"
/> />
</div> </div>
+17 -14
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api' 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 MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue' import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue' import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
@@ -12,15 +12,12 @@ import { getMusicSourceLabel } from '@/utils/music'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps({ const props = defineProps<{
// ID // ID
mediaid: String, mediaId?: string
// //
mediaSource: { mediaSource?: MediaDataSource
type: String, }>()
default: 'musicbrainz',
},
})
const userStore = useUserStore() const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions)) const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
@@ -29,6 +26,7 @@ const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const isRefreshed = ref(false) const isRefreshed = ref(false)
const artist = ref<MusicArtistInfo>() const artist = ref<MusicArtistInfo>()
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t)) const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
const encodedMediaSource = computed(() => (props.mediaSource ? encodeURIComponent(props.mediaSource) : ''))
const { openMusicSiteSearch } = useMusicSiteSearch(sites => { const { openMusicSiteSearch } = useMusicSiteSearch(sites => {
if (!artist.value?.name) return undefined if (!artist.value?.name) return undefined
@@ -63,10 +61,14 @@ const attributes = computed(() => {
/** 加载艺术家详情。 */ /** 加载艺术家详情。 */
async function loadArtistDetail() { async function loadArtistDetail() {
if (!props.mediaSource || !props.mediaid) return if (!props.mediaSource || !props.mediaId) {
artist.value = undefined
isRefreshed.value = true
return
}
isRefreshed.value = false isRefreshed.value = false
try { 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) { } catch (error) {
console.error(error) console.error(error)
artist.value = undefined artist.value = undefined
@@ -77,10 +79,11 @@ async function loadArtistDetail() {
/** 返回指定专辑类型的浏览列表路由。 */ /** 返回指定专辑类型的浏览列表路由。 */
function getAlbumsBrowseRoute(albumType: string, title: string) { 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> </script>
<template> <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"> <div v-for="section in albumSections" :key="section.type" class="music-section">
<MediaCardSlideView <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)" :linkurl="getAlbumsBrowseRoute(section.type, section.title)"
:title="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"> <div v-if="props.mediaSource === 'musicbrainz'" class="music-section">
<MusicArtistSlideView <MusicArtistSlideView
:apipath="`music/artist/${props.mediaid}/related?media_source=musicbrainz`" :apipath="`music/artist/${props.mediaId}/related?media_source=musicbrainz`"
:title="t('music.relatedArtists')" :title="t('music.relatedArtists')"
/> />
</div> </div>
+19 -16
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api' 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 MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue' import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue' import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
@@ -22,15 +22,12 @@ import {
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps<{
// ID // ID
mediaid: String, mediaId?: string
// //
mediaSource: { mediaSource?: MediaDataSource
type: String, }>()
default: 'musicbrainz',
},
})
const userStore = useUserStore() const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions)) const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
@@ -62,6 +59,7 @@ const attributes = computed(() => {
// 便 // 便
const albumTracks = computed(() => album.value?.tracks ?? []) const albumTracks = computed(() => album.value?.tracks ?? [])
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t)) const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
const encodedMediaSource = computed(() => (props.mediaSource ? encodeURIComponent(props.mediaSource) : ''))
function getSubscribeStatusKey() { function getSubscribeStatusKey() {
return `${getMediaSubscribeId(music.value)}::all` return `${getMediaSubscribeId(music.value)}::all`
@@ -80,13 +78,18 @@ const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
/** 加载单曲详情,并按所属专辑补全曲目列表。 */ /** 加载单曲详情,并按所属专辑补全曲目列表。 */
async function loadMusicDetail() { 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 isRefreshed.value = false
album.value = undefined album.value = undefined
try { try {
music.value = await api.post('music/recognize', { music.value = await api.post('music/recognize', {
media_source: props.mediaSource, media_source: props.mediaSource,
media_id: props.mediaid, media_id: props.mediaId,
music_type: 'recording', music_type: 'recording',
}) })
} catch (error) { } catch (error) {
@@ -126,17 +129,17 @@ async function checkSubscribeStatus() {
/** 打开所属专辑详情页。 */ /** 打开所属专辑详情页。 */
function goAlbum() { 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)) router.push(buildMusicAlbumRoute(music.value.album_id, music.value.album, props.mediaSource))
} }
/** 打开艺术家详情页。 */ /** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) { function goArtist(artistId?: string, name?: string) {
if (!artistId) return if (!artistId || !props.mediaSource) return
router.push(buildMusicArtistRoute(artistId, name, props.mediaSource)) router.push(buildMusicArtistRoute(artistId, name, props.mediaSource))
} }
watch(() => [props.mediaSource, props.mediaid], loadMusicDetail, { immediate: true }) watch(() => [props.mediaSource, props.mediaId], loadMusicDetail, { immediate: true })
</script> </script>
<template> <template>
@@ -247,10 +250,10 @@ watch(() => [props.mediaSource, props.mediaid], loadMusicDetail, { immediate: tr
/> />
</div> </div>
<div v-if="primaryArtistId" class="music-section"> <div v-if="primaryArtistId && props.mediaSource" class="music-section">
<MediaCardSlideView <MediaCardSlideView
:apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}`" :apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodedMediaSource}`"
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(t('music.artistAlbums'))}`" :linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodedMediaSource}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
:title="t('music.artistAlbums')" :title="t('music.artistAlbums')"
/> />
</div> </div>
+5 -3
View File
@@ -40,14 +40,16 @@ function appendData(items: Person[]) {
} }
async function loadPageData() { async function loadPageData() {
const params = getParams()
return api.get(props.apipath!, { return api.get(props.apipath!, {
params: getParams(), params,
...(Array.isArray(params.media_source) ? { paramsSerializer: { indexes: null } } : {}),
}) as Promise<Person[]> }) as Promise<Person[]>
} }
// //
function getParams() { function getParams(): Record<string, unknown> {
let params = { let params: Record<string, unknown> = {
page: page.value, page: page.value,
} }
if (props.params) params = { ...params, ...props.params } if (props.params) params = { ...params, ...props.params }
@@ -192,6 +192,23 @@ describe('MediaCardListView', () => {
expect(requests[0].searchParams.get('genre')).toBe('科幻') 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 () => { it('loads only one page when the viewport is already scrollable', async () => {
setScrollHeight(() => 900) setScrollHeight(() => 900)
const requestedPages: string[] = [] const requestedPages: string[] = []
@@ -115,18 +115,20 @@ function installLoadTriggerControls() {
animationFrameCallbacks.push(callback) animationFrameCallbacks.push(callback)
return animationFrameCallbacks.length return animationFrameCallbacks.length
}) })
vi.spyOn(window, 'setTimeout').mockImplementation( vi.spyOn(window, 'setTimeout').mockImplementation(((
((handler: TimerHandler, timeout?: number, ...args: unknown[]): TimeoutHandle => { handler: TimerHandler,
if (timeout === 600) { timeout?: number,
fallbackCallbacks.push(() => { ...args: unknown[]
if (typeof handler === 'function') handler(...args) ): TimeoutHandle => {
}) if (timeout === 600) {
return fallbackCallbacks.length as unknown as TimeoutHandle fallbackCallbacks.push(() => {
} if (typeof handler === 'function') handler(...args)
})
return fallbackCallbacks.length as unknown as TimeoutHandle
}
return nativeSetTimeout(handler, timeout, ...args) as unknown as TimeoutHandle return nativeSetTimeout(handler, timeout, ...args) as unknown as TimeoutHandle
}) as unknown as typeof window.setTimeout, }) as unknown as typeof window.setTimeout)
)
} }
async function renderSlide(props: { ready?: boolean } = {}) { async function renderSlide(props: { ready?: boolean } = {}) {
@@ -306,16 +308,30 @@ describe('MediaCardSlideView', () => {
expect(requested).toHaveBeenCalledTimes(2) 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 = [ const media = [
createMediaInfo({ douban_id: 'unused-douban', title: 'TMDB 媒体', tmdb_id: 101 }), createMediaInfo({ douban_id: 'unused-douban', title: 'TMDB 媒体', tmdb_id: 101 }),
createMediaInfo({ douban_id: 'douban-202', title: '豆瓣媒体', tmdb_id: undefined }), createMediaInfo({
createMediaInfo({ bangumi_id: 'bangumi-303', douban_id: undefined, title: 'Bangumi 媒体', tmdb_id: undefined }), 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({ createMediaInfo({
bangumi_id: undefined, bangumi_id: undefined,
douban_id: undefined, douban_id: undefined,
media_id: 'custom-404', media_id: 'bilibili-404',
title: '自定义媒体', media_source: 'bilibili',
title: 'Bilibili 媒体',
tmdb_id: undefined, tmdb_id: undefined,
}), }),
createMediaInfo({ createMediaInfo({
@@ -334,7 +350,7 @@ describe('MediaCardSlideView', () => {
await waitFor(() => expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-loading', 'false')) await waitFor(() => expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-loading', 'false'))
expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-item-count', '5') expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-item-count', '5')
expect(screen.getByLabelText('媒体横向列表键')).toHaveTextContent( 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) expect(screen.getAllByRole('article')).toHaveLength(5)
screen.getAllByRole('article').forEach(card => expect(card).toHaveAttribute('data-width', '9rem')) 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 type { MediaDataSource, MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
import vuetify from '@/plugins/vuetify' import vuetify from '@/plugins/vuetify'
import MediaDetailView from '@/views/discover/MediaDetailView.vue' import MediaDetailView from '@/views/discover/MediaDetailView.vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue' import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
@@ -123,6 +122,7 @@ interface RenderDetailOptions {
existsStatus?: number existsStatus?: number
media?: MediaInfo media?: MediaInfo
mediaId?: string mediaId?: string
mediaSource?: MediaDataSource | null
movieSubscribe?: Partial<Subscribe> movieSubscribe?: Partial<Subscribe>
notExists?: NotExistMediaInfo[] notExists?: NotExistMediaInfo[]
notExistsStatus?: number notExistsStatus?: number
@@ -144,7 +144,9 @@ function installSiteHandlers(sites: Site[] = [], selected: number[] = [], type =
async function renderDetail(options: RenderDetailOptions = {}) { async function renderDetail(options: RenderDetailOptions = {}) {
const media = options.media ?? createMediaInfo({ title: '详情测试电影', tmdb_id: 8101, type: '电影' }) 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 type = options.type ?? media.type ?? '电影'
const existsRequest = vi.fn() const existsRequest = vi.fn()
const subscribeRequest = vi.fn() const subscribeRequest = vi.fn()
@@ -157,7 +159,7 @@ async function renderDetail(options: RenderDetailOptions = {}) {
), ),
mediaNotExistsHandler(options.notExists ?? [], options.notExistsStatus), mediaNotExistsHandler(options.notExists ?? [], options.notExistsStatus),
subscribeListHandler(options.subscribes ?? [], options.subscribesStatus, subscribeRequest), subscribeListHandler(options.subscribes ?? [], options.subscribesStatus, subscribeRequest),
querySubscribeByMediaHandler(getMediaSubscribeId(media), options.movieSubscribe ?? {}, 200, subscribeRequest), querySubscribeByMediaHandler(mediaId, options.movieSubscribe ?? {}, 200, subscribeRequest),
) )
if (media.tmdb_id) { if (media.tmdb_id) {
server.use( server.use(
@@ -191,7 +193,8 @@ async function renderDetail(options: RenderDetailOptions = {}) {
}, },
}, },
props: { props: {
mediaid: mediaId, mediaId,
mediaSource,
title: media.title, title: media.title,
type, type,
year: media.year, 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) { if (recognized && (options.detailStatus ?? 200) < 400) {
await waitFor(() => { await waitFor(() => {
expect(existsRequest).toHaveBeenCalledOnce() expect(existsRequest).toHaveBeenCalledOnce()
@@ -234,14 +237,44 @@ describe('MediaDetailView detail and actions', () => {
}) })
it.each([ it.each([
['TMDB', 'tmdb:8201', createMediaInfo({ tmdb_id: 8201, type: '电影' })], ['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: '电视剧' })],
[ [
'extension', 'Douban',
'custom:item-8204', 'db-8202',
createMediaInfo({ media_id: 'item-8204', media_source: 'custom', tmdb_id: 8204, type: '电影' }), 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) => { ])('loads the exact %s media path and query', async (_source, mediaId, media) => {
const requested = vi.fn<(url: URL) => void>() const requested = vi.fn<(url: URL) => void>()
@@ -251,9 +284,8 @@ describe('MediaDetailView detail and actions', () => {
await waitFor(() => expect(requested).toHaveBeenCalledOnce()) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(requested.mock.calls[0][0].pathname).toBe(`/api/v1/media/${mediaId}`) expect(requested.mock.calls[0][0].pathname).toBe(`/api/v1/media/${mediaId}`)
expect(Object.fromEntries(requested.mock.calls[0][0].searchParams)).toEqual({ expect(Object.fromEntries(requested.mock.calls[0][0].searchParams)).toEqual({
title: media.title, media_source: media.media_source,
type_name: media.type, 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 () => { it('renders legal empty media separately from loading', async () => {
const empty = createEmptyMediaInfo() const empty = createEmptyMediaInfo()
await renderDetail({ media: empty, mediaId: 'tmdb:8301', type: '电影' }) await renderDetail({ media: empty, mediaId: '8301', type: '电影' })
expect(await screen.findByText('未识别到媒体信息。')).toBeInTheDocument() expect(await screen.findByText('未识别到媒体信息。')).toBeInTheDocument()
expect(screen.queryByText('加载中')).not.toBeInTheDocument() expect(screen.queryByText('加载中')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '重试' })).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 () => { it('shows a distinct retryable error when detail loading fails', async () => {
const requested = vi.fn() const requested = vi.fn()
await renderDetail({ await renderDetail({
detailRequest: requested, detailRequest: requested,
detailStatus: 500, detailStatus: 500,
media: createEmptyMediaInfo(), media: createEmptyMediaInfo(),
mediaId: 'tmdb:8302', mediaId: '8302',
type: '电影', type: '电影',
}) })
@@ -298,9 +345,9 @@ describe('MediaDetailView detail and actions', () => {
const existsRequested = vi.fn() const existsRequested = vi.fn()
const subscribeRequested = vi.fn() const subscribeRequested = vi.fn()
server.use( server.use(
mediaDetailsHandler('tmdb:8302', createMediaInfo({ title: '重试成功', tmdb_id: 8302 })), mediaDetailsHandler('8302', createMediaInfo({ title: '重试成功', tmdb_id: 8302 })),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequested), mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequested),
querySubscribeByMediaHandler('tmdb:8302', {}, 200, subscribeRequested), querySubscribeByMediaHandler('8302', {}, 200, subscribeRequested),
) )
await fireEvent.click(screen.getByRole('button', { name: '重试' })) await fireEvent.click(screen.getByRole('button', { name: '重试' }))
@@ -366,7 +413,7 @@ describe('MediaDetailView detail and actions', () => {
tvdb_slug: 'speed-and-love', tvdb_slug: 'speed-and-love',
type: '电视剧', type: '电视剧',
}) })
await renderDetail({ media: mediaWithSlug, mediaId: 'tmdb:1', type: '电视剧' }) await renderDetail({ media: mediaWithSlug, mediaId: '1', type: '电视剧' })
expect(screen.getByRole('link', { name: /TheTvDb/ })).toHaveAttribute( expect(screen.getByRole('link', { name: /TheTvDb/ })).toHaveAttribute(
'href', 'href',
'https://www.thetvdb.com/series/speed-and-love', 'https://www.thetvdb.com/series/speed-and-love',
@@ -377,6 +424,8 @@ describe('MediaDetailView detail and actions', () => {
const media = createMediaInfo({ const media = createMediaInfo({
backdrop_path: 'https://images.example.com/douban-backdrop.jpg', backdrop_path: 'https://images.example.com/douban-backdrop.jpg',
douban_id: 'db-8402', douban_id: 'db-8402',
media_id: 'db-8402',
media_source: 'douban',
original_title: 'Douban Original', original_title: 'Douban Original',
poster_path: 'https://images.example.com/douban-poster.jpg', poster_path: 'https://images.example.com/douban-poster.jpg',
production_countries: [{ name: '中国大陆' }], production_countries: [{ name: '中国大陆' }],
@@ -385,7 +434,7 @@ describe('MediaDetailView detail and actions', () => {
tmdb_id: undefined, tmdb_id: undefined,
type: '电影', 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(await screen.findByRole('heading', { name: /豆瓣独立电影/ })).toBeInTheDocument()
expect(screen.getByText('Douban Original')).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 () => { it('renders Bangumi-only facts, external link, credits, and recommendations', async () => {
const media = createMediaInfo({ const media = createMediaInfo({
bangumi_id: '8403', bangumi_id: '8403',
media_id: '8403',
media_source: 'bangumi',
original_title: 'Bangumi Original', original_title: 'Bangumi Original',
release_date: '2026-03-03', release_date: '2026-03-03',
title: 'Bangumi 独立条目', title: 'Bangumi 独立条目',
tmdb_id: undefined, tmdb_id: undefined,
type: '电视剧', type: '电视剧',
}) })
await renderDetail({ media, mediaId: 'bangumi:8403', type: '电视剧' }) await renderDetail({ media, mediaId: '8403', type: '电视剧' })
expect(await screen.findByRole('heading', { name: /Bangumi 独立条目/ })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: /Bangumi 独立条目/ })).toBeInTheDocument()
expect(screen.getByText('Bangumi Original')).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 () => { it('renders AniList-only facts, external link, credits, and recommendations', async () => {
const media = createMediaInfo({ const media = createMediaInfo({
anilist_id: 154587, anilist_id: 154587,
media_id: '154587',
media_source: 'anilist',
original_title: '葬送のフリーレン', original_title: '葬送のフリーレン',
release_date: '2023-09-29', release_date: '2023-09-29',
title: '葬送的芙莉莲', title: '葬送的芙莉莲',
tmdb_id: undefined, tmdb_id: undefined,
type: '电视剧', type: '电视剧',
}) })
await renderDetail({ media, mediaId: 'anilist:154587', type: '电视剧' }) await renderDetail({ media, mediaId: '154587', type: '电视剧' })
expect(await screen.findByRole('heading', { name: /葬送的芙莉莲/ })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: /葬送的芙莉莲/ })).toBeInTheDocument()
expect(screen.getByText('葬送のフリーレン')).toBeInTheDocument() expect(screen.getByText('葬送のフリーレン')).toBeInTheDocument()
@@ -463,7 +516,8 @@ describe('MediaDetailView detail and actions', () => {
query: { query: {
area: 'title', area: 'title',
episode: null, episode: null,
keyword: 'tmdb:8501', media_id: '8501',
media_source: 'themoviedb',
result_type: 'subtitle', result_type: 'subtitle',
season: 2, season: 2,
sites: '', sites: '',
@@ -488,7 +542,8 @@ describe('MediaDetailView detail and actions', () => {
query: { query: {
area: 'imdbid', area: 'imdbid',
episode: null, episode: null,
keyword: 'tmdb:8502', media_id: '8502',
media_source: 'themoviedb',
result_type: 'torrent', result_type: 'torrent',
season: 3, season: 3,
sites: '', sites: '',
@@ -611,6 +666,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
const media = createSubscribeTv({ const media = createSubscribeTv({
anilist_id: 154587, anilist_id: 154587,
episode_group: 'auxiliary-group', episode_group: 'auxiliary-group',
media_id: '154587',
media_source: 'anilist', media_source: 'anilist',
title: 'AniList 主来源剧集', title: 'AniList 主来源剧集',
tmdb_id: 8700, tmdb_id: 8700,
@@ -619,7 +675,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
await renderDetail({ await renderDetail({
episodeGroupsRequest, episodeGroupsRequest,
media, media,
mediaId: 'anilist:154587', mediaId: '154587',
setupHandlers: () => { setupHandlers: () => {
server.use(mediaGroupSeasonsHandler('auxiliary-group', [], 200, groupSeasonsRequest)) 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 () => { it('shows the current movie subscription state returned by the media endpoint', async () => {
const media = createMediaInfo({ title: '已订阅电影', tmdb_id: 8701, type: '电影' }) 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 }) await renderDetail({ media, movieSubscribe: subscribe })
@@ -648,8 +704,8 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
tmdb_id: 8702, tmdb_id: 8702,
}) })
const subscribes = [ const subscribes = [
createSubscribe({ season: 1, tmdbid: 8702, type: '电视剧' }), createSubscribe({ media_id: '8702', season: 1, type: '电视剧' }),
createSubscribe({ season: 99, tmdbid: 8702, type: '电视剧' }), createSubscribe({ media_id: '8702', season: 99, type: '电视剧' }),
] ]
await renderDetail({ media, subscribes, type: '电视剧' }) await renderDetail({ media, subscribes, type: '电视剧' })
@@ -668,7 +724,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
title: '全季订阅剧', title: '全季订阅剧',
tmdb_id: 8703, 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: '电视剧' }) await renderDetail({ media, subscribes, type: '电视剧' })
@@ -685,7 +741,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
title: '缺少特别篇订阅剧', title: '缺少特别篇订阅剧',
tmdb_id: 8709, 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: '电视剧' }) await renderDetail({ media, subscribes, type: '电视剧' })
@@ -794,7 +850,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
const refreshed = vi.fn() const refreshed = vi.fn()
await renderDetail({ media }) await renderDetail({ media })
server.use( server.use(
querySubscribeByMediaHandler('tmdb:8713', {}, 200, refreshed), querySubscribeByMediaHandler('8713', {}, 200, refreshed),
createSubscribeHandler({ data: { id: 18713 }, success: true }), createSubscribeHandler({ data: { id: 18713 }, success: true }),
defaultSubscribeConfigHandler('电影', { show_edit_dialog: true }), defaultSubscribeConfigHandler('电影', { show_edit_dialog: true }),
) )
@@ -87,9 +87,9 @@ const LoadingBannerStub = defineComponent({
template: '<div role="status">正在加载人物列表</div>', template: '<div role="status">正在加载人物列表</div>',
}) })
async function renderList() { async function renderList(params?: Record<string, unknown>) {
return renderWithProviders(PersonCardListView, { return renderWithProviders(PersonCardListView, {
props: { apipath: LIST_PATH }, props: { apipath: LIST_PATH, params },
global: { global: {
stubs: { stubs: {
LoadingBanner: LoadingBannerStub, LoadingBanner: LoadingBannerStub,
@@ -124,6 +124,22 @@ describe('PersonCardListView', () => {
expect(initialLoadMargins[0]).toBe(0) 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 () => { it('shows an inline retry and retries the same page after a request failure', async () => {
let requests = 0 let requests = 0
server.use( server.use(
+155 -139
View File
@@ -204,7 +204,10 @@ const mobileFilteredCalendarEvents = computed(() => {
return false 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 return false
} }
@@ -606,27 +609,32 @@ function getEpisodeTitle(episodeNumbers: number[], episodeTitles: string[]) {
// //
async function eventsHander(subscribe: Subscribe) { async function eventsHander(subscribe: Subscribe) {
if (!subscribe.media_source || !subscribe.media_id) return []
// //
if (subscribe.type === '电影') { if (subscribe.type === '电影') {
// APITMDB const media: MediaInfo = await api.get(`media/${encodeURIComponent(subscribe.media_id)}`, {
const movie: MediaInfo = await api.get(`media/tmdb:${subscribe.tmdbid}`, { params: { media_source: subscribe.media_source, type_name: subscribe.type },
params: { type_name: subscribe.type },
}) })
return buildCalendarEventInfo(subscribe, { return buildCalendarEventInfo(subscribe, {
subtitle: '', subtitle: '',
start: parseDate(movie.release_date || ''), start: parseDate(media.release_date || ''),
len: 1, len: 1,
runtime: movie.runtime, runtime: media.runtime,
episodeNumbers: [], episodeNumbers: [],
}) })
} else { } 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 // API
const params = subscribe.episode_group ? { episode_group: subscribe.episode_group } : undefined const params = subscribe.episode_group ? { episode_group: subscribe.episode_group } : undefined
const episodes: TmdbEpisode[] = await api.get( const episodes: TmdbEpisode[] = await api.get(`tmdb/${tmdbId}/${subscribe.season}`, params ? { params } : undefined)
`tmdb/${subscribe.tmdbid}/${subscribe.season}`,
params ? { params } : undefined,
)
// TMDB // TMDB
interface EpisodesDictionary { interface EpisodesDictionary {
@@ -702,7 +710,12 @@ onActivated(() => {
</script> </script>
<template> <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 <button
v-for="option in mediaTypeFilterOptions" v-for="option in mediaTypeFilterOptions"
:key="option.value" :key="option.value"
@@ -779,10 +792,7 @@ onActivated(() => {
{{ t('calendar.episode', { number: calendarEvent.subtitle }) }} {{ t('calendar.episode', { number: calendarEvent.subtitle }) }}
</div> </div>
<div v-if="calendarEvent.totalEpisode" class="calendar-event-library-row"> <div v-if="calendarEvent.totalEpisode" class="calendar-event-library-row">
<span <span class="calendar-event-status" :class="`calendar-event-status--${calendarEvent.libraryState}`">
class="calendar-event-status"
:class="`calendar-event-status--${calendarEvent.libraryState}`"
>
<VIcon :icon="getLibraryStateIcon(calendarEvent.libraryState)" size="13" /> <VIcon :icon="getLibraryStateIcon(calendarEvent.libraryState)" size="13" />
{{ getCompactLibraryProgressText(calendarEvent) }} {{ getCompactLibraryProgressText(calendarEvent) }}
</span> </span>
@@ -814,139 +824,145 @@ onActivated(() => {
<template v-else> <template v-else>
<section class="mobile-calendar-filter-card"> <section class="mobile-calendar-filter-card">
<div class="mobile-calendar-filter-head"> <div class="mobile-calendar-filter-head">
<div class="mobile-calendar-filter-copy"> <div class="mobile-calendar-filter-copy">
<h2>{{ t('calendar.mobileFilterTitle') }}</h2> <h2>{{ t('calendar.mobileFilterTitle') }}</h2>
<span>{{ t('calendar.itemCount', { count: mobileSeriesFilterOptions[0]?.count || 0 }) }}</span> <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> </div>
<button <div class="mobile-calendar-mediatype-list" role="group" :aria-label="t('calendar.mediaTypeFilterTitle')">
type="button" <button
class="mobile-calendar-expired-toggle" v-for="option in mediaTypeFilterOptions"
:class="{ 'mobile-calendar-expired-toggle--active': mobileHideExpired }" :key="option.value"
@click="mobileHideExpired = !mobileHideExpired" type="button"
> class="mobile-calendar-filter-chip mobile-calendar-mediatype-chip"
<VIcon :icon="mobileHideExpired ? 'mdi-eye-off-outline' : 'mdi-eye-outline'" size="18" /> :class="{ 'mobile-calendar-filter-chip--active': mediaTypeFilter === option.value }"
<span>{{ mobileHideExpired ? t('calendar.hideExpired') : t('calendar.showExpired') }}</span> :aria-pressed="mediaTypeFilter === option.value"
</button> @click="mediaTypeFilter = option.value"
</div> >
{{ option.label }}
</button>
</div>
<div class="mobile-calendar-mediatype-list" role="group" :aria-label="t('calendar.mediaTypeFilterTitle')"> <div class="mobile-calendar-filter-list" role="listbox" :aria-label="t('calendar.mobileFilterTitle')">
<button <button
v-for="option in mediaTypeFilterOptions" v-for="option in mobileSeriesFilterOptions"
:key="option.value" :key="option.value"
type="button" type="button"
class="mobile-calendar-filter-chip mobile-calendar-mediatype-chip" class="mobile-calendar-filter-chip"
:class="{ 'mobile-calendar-filter-chip--active': mediaTypeFilter === option.value }" :class="{ 'mobile-calendar-filter-chip--active': mobileSelectedFilterValue === option.value }"
:aria-pressed="mediaTypeFilter === option.value" role="option"
@click="mediaTypeFilter = option.value" :aria-selected="mobileSelectedFilterValue === option.value"
> @click="mobileSelectedFilterValue = option.value"
{{ option.label }} >
</button> {{ option.label }}
</div> </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> </section>
<div v-if="mobileCalendarDayGroups.length" class="mobile-calendar-timeline"> <div v-if="mobileCalendarDayGroups.length" class="mobile-calendar-timeline">
<section v-for="group in mobileCalendarDayGroups" :key="group.dateKey" class="mobile-calendar-day"> <section v-for="group in mobileCalendarDayGroups" :key="group.dateKey" class="mobile-calendar-day">
<div class="mobile-calendar-day-marker"> <div class="mobile-calendar-day-marker">
<span class="mobile-calendar-day-dot" :class="{ 'mobile-calendar-day-dot--today': isDateToday(group.date) }" /> <span
</div> class="mobile-calendar-day-dot"
:class="{ 'mobile-calendar-day-dot--today': isDateToday(group.date) }"
<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>
</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>
<div v-else class="mobile-calendar-empty"> <div v-else class="mobile-calendar-empty">
<VIcon icon="mdi-calendar-blank-outline" size="44" /> <VIcon icon="mdi-calendar-blank-outline" size="44" />
<h2>{{ t('common.noData') }}</h2> <h2>{{ t('common.noData') }}</h2>
<p>{{ t('calendar.noMatchingEvents') }}</p> <p>{{ t('calendar.noMatchingEvents') }}</p>
</div> </div>
</template> </template>
</div> </div>
+1 -15
View File
@@ -152,23 +152,9 @@ function getParams() {
return params 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 // DOM key
function getMediaIdentity(item: MediaInfo) { 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[] { function deduplicate(items: MediaInfo[]): MediaInfo[] {
+1 -3
View File
@@ -288,9 +288,7 @@ function removeData(id: number) {
v-if="dataList.length > 0" v-if="dataList.length > 0"
:items="dataList" :items="dataList"
:get-item-key=" :get-item-key="
item => item => item.id || `${item.media_source || 'unknown'}:${item.media_id || item.name}-${item.share_user}`
item.id ||
`${item.media_id || item.tmdbid || item.doubanid || item.bangumiid || item.anilistid || item.name}-${item.share_user}`
" "
:min-item-width="240" :min-item-width="240"
:estimated-item-height="260" :estimated-item-height="260"
@@ -92,7 +92,15 @@ function queryMobileCalendarEventCard(title: string) {
} }
function movieSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {}) { 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> = {}) { function tvSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {}) {
@@ -100,7 +108,8 @@ function tvSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {
id, id,
name, name,
season: 1, season: 1,
tmdbid: id, media_id: String(id),
media_source: 'themoviedb',
total_episode: 4, total_episode: 4,
type: '电视剧', type: '电视剧',
username: `user-${id}`, username: `user-${id}`,
@@ -216,7 +225,7 @@ describe('FullCalendarView', () => {
] ]
server.use( server.use(
subscribeListHandler(subscriptions), subscribeListHandler(subscriptions),
...subscriptions.map(subscribe => tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, episodes)), ...subscriptions.map(subscribe => tmdbSeasonEpisodesHandler(Number(subscribe.media_id), 1, episodes)),
) )
await renderCalendar() await renderCalendar()
@@ -264,7 +273,7 @@ describe('FullCalendarView', () => {
server.use( server.use(
subscribeListHandler(subscriptions), subscribeListHandler(subscriptions),
...sameDaySubscriptions.map(subscribe => ...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 })]), tmdbSeasonEpisodesHandler(3499, 1, [createTmdbEpisode({ air_date: '2026-08-02', episode_number: 1 })]),
) )
@@ -314,7 +323,7 @@ describe('FullCalendarView', () => {
] ]
server.use( server.use(
subscribeListHandler(details.map(([subscribe]) => subscribe)), 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() await renderCalendar()
@@ -461,7 +470,7 @@ describe('FullCalendarView', () => {
server.use( server.use(
subscribeListHandler(sameDaySubscriptions), subscribeListHandler(sameDaySubscriptions),
...sameDaySubscriptions.map(subscribe => ...sameDaySubscriptions.map(subscribe =>
tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, [sameDayEpisode]), tmdbSeasonEpisodesHandler(Number(subscribe.media_id), 1, [sameDayEpisode]),
), ),
) )
vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
@@ -288,6 +288,27 @@ describe('SubscribePopularView', () => {
expect(screen.queryByText('第一页页内重复项')).not.toBeInTheDocument() 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 () => { it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
const first = createSubscribeMovie({ title: '未满屏第一页' }) const first = createSubscribeMovie({ title: '未满屏第一页' })
const second = createSubscribeMovie({ title: '未满屏第二页' }) const second = createSubscribeMovie({ title: '未满屏第二页' })
@@ -443,25 +464,12 @@ describe('SubscribePopularView', () => {
expect(await screen.findByText('同剧第一季')).toBeInTheDocument() expect(await screen.findByText('同剧第一季')).toBeInTheDocument()
expect(screen.getByText('同剧第二季')).toBeInTheDocument() expect(screen.getByText('同剧第二季')).toBeInTheDocument()
// 渲染键与生产媒体身份字段保持一致,避免不同季条目发生键冲突 // 渲染键只使用统一媒体身份和季号,不再混入数据源专属辅助 ID
const identityFields = [
'media_source',
'type',
'season',
'tmdb_id',
'imdb_id',
'tvdb_id',
'douban_id',
'bangumi_id',
'anilist_id',
'media_id',
] as const
const item1 = createSubscribeTv({ season: 1, title: '同剧第一季', tmdb_id: 880 }) const item1 = createSubscribeTv({ season: 1, title: '同剧第一季', tmdb_id: 880 })
const item2 = createSubscribeTv({ season: 2, title: '同剧第二季', tmdb_id: 880 }) const item2 = createSubscribeTv({ season: 2, title: '同剧第二季', tmdb_id: 880 })
const expectedKeys = [ const expectedKeys = [item1, item2].map(item =>
JSON.stringify(identityFields.map(field => item1[field] ?? null)), JSON.stringify([item.media_source ?? null, item.media_id ?? null, item.type ?? null, item.season ?? null]),
JSON.stringify(identityFields.map(field => item2[field] ?? null)), )
]
const keysText = screen.getByRole('status', { name: '热门订阅渐进网格键' }).textContent ?? '' const keysText = screen.getByRole('status', { name: '热门订阅渐进网格键' }).textContent ?? ''
const keys = JSON.parse(keysText) const keys = JSON.parse(keysText)
+6 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import api from '@/api' 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 { useI18n } from 'vue-i18n'
import { formatFileSize, formatDateDifference } from '@core/utils/formatters' import { formatFileSize, formatDateDifference } from '@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
@@ -250,7 +250,7 @@ function openReidentifyDialog(item: TorrentCacheItem) {
/** 执行缓存项重新识别。 */ /** 执行缓存项重新识别。 */
async function performReidentify( async function performReidentify(
payload: { payload: {
mediaSource?: string mediaSource?: MediaDataSource
mediaId?: string mediaId?: string
musicType?: Exclude<MusicEntityType, 'artist'> musicType?: Exclude<MusicEntityType, 'artist'>
} = {}, } = {},
@@ -261,8 +261,10 @@ async function performReidentify(
loading.value = true loading.value = true
reidentifyDialogController?.updateProps({ loading: true }) reidentifyDialogController?.updateProps({ loading: true })
const params: any = {} const params: any = {}
if (payload.mediaSource) params.media_source = payload.mediaSource if (payload.mediaSource && payload.mediaId) {
if (payload.mediaId) params.media_id = payload.mediaId params.media_source = payload.mediaSource
params.media_id = payload.mediaId
}
if (payload.musicType) params.music_type = payload.musicType if (payload.musicType) params.music_type = payload.musicType
const res: any = await api.post( const res: any = await api.post(
+7 -12
View File
@@ -4,7 +4,7 @@ import { useToast } from 'vue-toastification'
import { requiredValidator } from '@/@validators' import { requiredValidator } from '@/@validators'
import api from '@/api' import api from '@/api'
import type { Context, MediaDataSource, MediaInfo } from '@/api/types' import type { Context, MediaDataSource, MediaInfo } from '@/api/types'
import { getMediaSubscribeId, getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe' import { getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
import router from '@/router' import router from '@/router'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { getLogoUrl } from '@/utils/imageUtils' import { getLogoUrl } from '@/utils/imageUtils'
@@ -159,15 +159,7 @@ const resourceChips = computed(() => {
].filter(Boolean) as string[] ].filter(Boolean) as string[]
}) })
// //
const canViewMediaDetail = computed(() => const canViewMediaDetail = computed(() => Boolean(getMediaSubscribeIdentity(mediaInfo.value)))
Boolean(
mediaInfo.value?.tmdb_id ||
mediaInfo.value?.douban_id ||
mediaInfo.value?.bangumi_id ||
mediaInfo.value?.anilist_id ||
mediaInfo.value?.media_id,
),
)
/** 生成媒体源官方详情页地址。 */ /** 生成媒体源官方详情页地址。 */
function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string) { 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 { function getMediaIdentity(media?: MediaInfo): MediaIdentity | undefined {
if (!media) return undefined if (!media) return undefined
@@ -271,11 +263,14 @@ function getPosterImage(url = '') {
/** 关闭识别测试弹窗后,跳转查看当前识别结果匹配到的媒体详情。 */ /** 关闭识别测试弹窗后,跳转查看当前识别结果匹配到的媒体详情。 */
async function viewMediaDetail() { async function viewMediaDetail() {
if (!canViewMediaDetail.value || !mediaInfo.value) return if (!canViewMediaDetail.value || !mediaInfo.value) return
const identity = getMediaSubscribeIdentity(mediaInfo.value)
if (!identity) return
const target = { const target = {
path: '/media', path: '/media',
query: { query: {
mediaid: getMediaSubscribeId(mediaInfo.value), media_source: identity.source,
media_id: identity.mediaId,
title: mediaInfo.value.title, title: mediaInfo.value.title,
year: mediaInfo.value.year, year: mediaInfo.value.year,
type: mediaInfo.value.type, type: mediaInfo.value.type,
@@ -282,11 +282,34 @@ describe('NameTestView media identity', () => {
expect(mocks.routerPush).toHaveBeenCalledWith({ expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media', path: '/media',
query: { query: {
mediaid: 'tmdb:271016', media_id: '271016',
media_source: 'themoviedb',
title: '测试剧集', title: '测试剧集',
type: '电视剧', type: '电视剧',
year: '2026', 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 { export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
mediaSeed += 1 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 { return {
backdrop_path: `/images/media-${mediaSeed}.jpg`, backdrop_path: `/images/media-${mediaSeed}.jpg`,
episode_run_time: [], episode_run_time: [],
genres: ['剧情', '冒险'], genres: ['剧情', '冒险'],
origin_country: [], origin_country: [],
media_source: 'themoviedb', media_source: mediaSource,
media_id: mediaId,
title: `测试媒体 ${mediaSeed}`, title: `测试媒体 ${mediaSeed}`,
tmdb_id: mediaSeed, tmdb_id: mediaSeed,
type: '电影', type: '电影',
+2 -1
View File
@@ -76,8 +76,9 @@ export function createTorrentInfo(overrides: Partial<TorrentInfo> = {}): Torrent
freedate_diff: '', freedate_diff: '',
grabs: 10, grabs: 10,
hit_and_run: false, hit_and_run: false,
imdbid: '',
labels: [], labels: [],
media_id: '',
media_source: 'themoviedb',
peers: 3, peers: 3,
pri_order: 0, pri_order: 0,
seeders: 12, seeders: 12,
+4 -2
View File
@@ -30,7 +30,8 @@ export function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
show_edit_dialog: false, show_edit_dialog: false,
sites: [], sites: [],
state: 'R', state: 'R',
tmdbid: subscribeSeed, media_id: String(subscribeSeed),
media_source: 'themoviedb',
type: '电影', type: '电影',
username: 'tester', username: 'tester',
year: '2026', year: '2026',
@@ -53,7 +54,8 @@ export function createSubscribeShare(overrides: Partial<SubscribeShare> = {}): S
share_uid: `share-user-${subscribeShareSeed}`, share_uid: `share-user-${subscribeShareSeed}`,
share_user: `分享用户 ${subscribeShareSeed}`, share_user: `分享用户 ${subscribeShareSeed}`,
subscribe_id: subscribeShareSeed + 1000, subscribe_id: subscribeShareSeed + 1000,
tmdbid: subscribeShareSeed, media_id: String(subscribeShareSeed),
media_source: 'themoviedb',
type: '电影', type: '电影',
vote: 8.2, vote: 8.2,
year: '2026', year: '2026',
+1 -2
View File
@@ -31,8 +31,7 @@ export function mediaDetailsHandler(
status = 200, status = 200,
onRequest: (url: URL) => void = () => {}, onRequest: (url: URL) => void = () => {},
) { ) {
const normalizedMediaId = typeof mediaId === 'number' ? `tmdb:${mediaId}` : mediaId return http.get(mediaApiUrls.details(String(mediaId)), ({ request }) => {
return http.get(mediaApiUrls.details(normalizedMediaId), ({ request }) => {
onRequest(new URL(request.url)) onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status }) return HttpResponse.json(response as unknown as JsonBodyType, { status })
}) })