mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
refactor(media): unify frontend media identity
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 Mini(2019)')).toBeInTheDocument()
|
expect(await screen.findByText('Hello Mini(2019)')).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',
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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 ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 为插件联邦组件创建主程序原生订阅入口。 */
|
/** 为插件联邦组件创建主程序原生订阅入口。 */
|
||||||
|
|||||||
@@ -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: {[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 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: {[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 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: {[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 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: {[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 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?',
|
||||||
|
|||||||
@@ -2434,7 +2434,7 @@ export default {
|
|||||||
'被替换词 => 替换词\n' +
|
'被替换词 => 替换词\n' +
|
||||||
'前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
'前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
||||||
'被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
'被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
||||||
'其中替换词支持格式:{[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
'其中替换词支持格式:{[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒体数据源和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其中替换词支持格式:{[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP)\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:{[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒体数据源和ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
||||||
cancelSubscribe: '取消订阅',
|
cancelSubscribe: '取消订阅',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
cancelSubscribeConfirm: '是否确认取消订阅?',
|
cancelSubscribeConfirm: '是否确认取消订阅?',
|
||||||
|
|||||||
@@ -2433,7 +2433,7 @@ export default {
|
|||||||
'被替換詞 => 替換詞\n' +
|
'被替換詞 => 替換詞\n' +
|
||||||
'前定位詞 <> 後定位詞 >> 集偏移量(EP)\n' +
|
'前定位詞 <> 後定位詞 >> 集偏移量(EP)\n' +
|
||||||
'被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n' +
|
'被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n' +
|
||||||
'其中替換詞支持格式:{[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒體數據源ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
|
'其中替換詞支持格式:{[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒體數據源和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其中替換詞支援格式:{[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒體數據源ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
|
'屏蔽詞\n被替換詞 => 替換詞\n前定位詞 <> 後定位詞 >> 集偏移量(EP)\n被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n其中替換詞支援格式:{[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒體數據源和ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
|
||||||
cancelSubscribe: '取消訂閱',
|
cancelSubscribe: '取消訂閱',
|
||||||
save: '儲存',
|
save: '儲存',
|
||||||
cancelSubscribeConfirm: '是否確認取消訂閱?',
|
cancelSubscribeConfirm: '是否確認取消訂閱?',
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: '电影' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: '音乐',
|
||||||
|
|||||||
@@ -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 } },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: '音乐',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: '音乐',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:xxx、tmdb: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 且:前面的xxxx为字符,则按照媒体ID格式搜索
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 === '电影') {
|
||||||
// 调用API查询TMDB详情
|
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>
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: '电影',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user