mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
feat: propagate media source identities (#6129)
This commit is contained in:
+2
-2
@@ -70,7 +70,7 @@ function registerJinja2Mode() {
|
|||||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||||
const operators = 'and|in|is|not|or'
|
const operators = 'and|in|is|not|or'
|
||||||
const contextVariables =
|
const contextVariables =
|
||||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||||
|
|
||||||
const keywordMapper = this.createKeywordMapper(
|
const keywordMapper = this.createKeywordMapper(
|
||||||
{
|
{
|
||||||
@@ -282,7 +282,7 @@ function registerJinja2Mode() {
|
|||||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||||
const operators = 'and|in|is|not|or'
|
const operators = 'and|in|is|not|or'
|
||||||
const contextVariables =
|
const contextVariables =
|
||||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||||
|
|
||||||
const keywordMapper = this.createKeywordMapper(
|
const keywordMapper = this.createKeywordMapper(
|
||||||
{
|
{
|
||||||
|
|||||||
+22
-2
@@ -1,4 +1,4 @@
|
|||||||
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
|
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | (string & {})
|
||||||
|
|
||||||
// 手动刮削选项
|
// 手动刮削选项
|
||||||
export interface ManualScrapeOptions {
|
export interface ManualScrapeOptions {
|
||||||
@@ -27,7 +27,13 @@ export interface Subscribe {
|
|||||||
// 豆瓣ID
|
// 豆瓣ID
|
||||||
doubanid?: string
|
doubanid?: string
|
||||||
// Bangumi ID
|
// Bangumi ID
|
||||||
bangumiid?: string
|
bangumiid?: number
|
||||||
|
// AniList ID
|
||||||
|
anilistid?: number
|
||||||
|
// 主媒体数据源
|
||||||
|
media_source?: MediaDataSource
|
||||||
|
// 数据源原生ID
|
||||||
|
media_id?: string
|
||||||
// 其它媒体ID
|
// 其它媒体ID
|
||||||
mediaid?: string
|
mediaid?: string
|
||||||
// 季号
|
// 季号
|
||||||
@@ -128,6 +134,12 @@ export interface SubscribeShare {
|
|||||||
doubanid?: string
|
doubanid?: string
|
||||||
// Bangumi ID
|
// Bangumi ID
|
||||||
bangumiid?: number
|
bangumiid?: number
|
||||||
|
// AniList ID
|
||||||
|
anilistid?: number
|
||||||
|
// 主媒体数据源
|
||||||
|
media_source?: MediaDataSource
|
||||||
|
// 数据源原生ID
|
||||||
|
media_id?: string
|
||||||
// 季号
|
// 季号
|
||||||
season?: number
|
season?: number
|
||||||
// 海报
|
// 海报
|
||||||
@@ -230,6 +242,10 @@ export interface TransferHistory {
|
|||||||
tvdbid?: number
|
tvdbid?: number
|
||||||
// 豆瓣ID
|
// 豆瓣ID
|
||||||
doubanid?: string
|
doubanid?: string
|
||||||
|
// Bangumi ID
|
||||||
|
bangumiid?: number
|
||||||
|
// AniList ID
|
||||||
|
anilistid?: number
|
||||||
// 媒体数据源
|
// 媒体数据源
|
||||||
media_source?: MediaDataSource
|
media_source?: MediaDataSource
|
||||||
// 数据源原生ID
|
// 数据源原生ID
|
||||||
@@ -1535,6 +1551,10 @@ export interface TransferForm {
|
|||||||
tmdbid?: number
|
tmdbid?: number
|
||||||
// 豆瓣 ID
|
// 豆瓣 ID
|
||||||
doubanid?: string
|
doubanid?: string
|
||||||
|
// Bangumi ID
|
||||||
|
bangumiid?: number
|
||||||
|
// AniList ID
|
||||||
|
anilistid?: number
|
||||||
// 媒体数据源
|
// 媒体数据源
|
||||||
media_source?: MediaDataSource
|
media_source?: MediaDataSource
|
||||||
// 数据源原生ID
|
// 数据源原生ID
|
||||||
|
|||||||
@@ -166,12 +166,17 @@ function getExistsStatusKey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||||
|
const mediaId = getMediaId()
|
||||||
|
if (subscribe.media_source && subscribe.media_id) {
|
||||||
|
const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source
|
||||||
|
return mediaId === `${prefix}:${subscribe.media_id}`
|
||||||
|
}
|
||||||
|
if (subscribe.mediaid) return mediaId === subscribe.mediaid
|
||||||
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
|
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?.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?.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
|
||||||
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
|
return false
|
||||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角标颜色
|
// 角标颜色
|
||||||
|
|||||||
@@ -264,9 +264,14 @@ async function editSubscribeDialog() {
|
|||||||
|
|
||||||
// 获得mediaid
|
// 获得mediaid
|
||||||
function getMediaId() {
|
function getMediaId() {
|
||||||
|
if (props.media?.media_source && props.media?.media_id) {
|
||||||
|
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
|
||||||
|
return `${prefix}:${props.media.media_id}`
|
||||||
|
}
|
||||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
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
|
else return props.media?.mediaid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,13 +485,7 @@ function handleCardClick() {
|
|||||||
|
|
||||||
<template v-if="display.xs.value">
|
<template v-if="display.xs.value">
|
||||||
<div class="subscribe-card-mobile-media">
|
<div class="subscribe-card-mobile-media">
|
||||||
<VImg
|
<VImg :src="backdropUrl || posterUrl" :aspect-ratio="2" cover position="top" @load="imageLoadHandler">
|
||||||
:src="backdropUrl || posterUrl"
|
|
||||||
:aspect-ratio="2"
|
|
||||||
cover
|
|
||||||
position="top"
|
|
||||||
@load="imageLoadHandler"
|
|
||||||
>
|
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<VSkeletonLoader class="h-full w-full" />
|
<VSkeletonLoader class="h-full w-full" />
|
||||||
</template>
|
</template>
|
||||||
@@ -535,12 +534,7 @@ function handleCardClick() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconBtn
|
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
|
||||||
v-if="!props.sortable"
|
|
||||||
class="subscribe-card-mobile-menu"
|
|
||||||
size="small"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-dots-horizontal" size="20" />
|
<VIcon icon="mdi-dots-horizontal" size="20" />
|
||||||
<VMenu activator="parent" close-on-content-click>
|
<VMenu activator="parent" close-on-content-click>
|
||||||
<VList>
|
<VList>
|
||||||
@@ -576,112 +570,114 @@ function handleCardClick() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<VCardText class="flex items-center pt-3 pb-2">
|
<VCardText class="flex items-center pt-3 pb-2">
|
||||||
<div
|
<div
|
||||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||||
v-if="imageLoaded"
|
v-if="imageLoaded"
|
||||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||||
>
|
|
||||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
|
||||||
<template #placeholder>
|
|
||||||
<div class="w-full h-full">
|
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VImg>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
|
||||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
|
||||||
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
|
|
||||||
{{ props.media?.name }}
|
|
||||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCardText>
|
|
||||||
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
|
|
||||||
<div class="flex min-w-0 max-w-full align-center">
|
|
||||||
<VIcon
|
|
||||||
v-if="props.media?.total_episode && props.sortable"
|
|
||||||
icon="mdi-progress-download"
|
|
||||||
size="small"
|
|
||||||
color="white"
|
|
||||||
class="me-1"
|
|
||||||
/>
|
|
||||||
<IconBtn
|
|
||||||
v-else-if="props.media?.total_episode"
|
|
||||||
size="small"
|
|
||||||
v-bind="props"
|
|
||||||
icon="mdi-progress-download"
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
<!-- 守卫改用 total_episode:电视剧订阅可能不带 season 字段(旧数据或自定义来源),仍应展示集数进度 -->
|
|
||||||
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
|
||||||
{{ subscribeProgressText }}
|
|
||||||
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
|
|
||||||
{{ subscribeProgressTooltip }}
|
|
||||||
</VTooltip>
|
|
||||||
</div>
|
|
||||||
<VIcon
|
|
||||||
v-if="props.media?.username && props.sortable"
|
|
||||||
icon="mdi-account"
|
|
||||||
size="small"
|
|
||||||
color="white"
|
|
||||||
class="flex-shrink-0 me-1"
|
|
||||||
/>
|
|
||||||
<IconBtn
|
|
||||||
v-else-if="props.media?.username"
|
|
||||||
icon="mdi-account"
|
|
||||||
size="small"
|
|
||||||
color="white"
|
|
||||||
class="flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
|
||||||
<span
|
|
||||||
v-if="props.media?.username"
|
|
||||||
class="min-w-0 truncate text-subtitle-2 text-white"
|
|
||||||
:title="props.media?.username"
|
|
||||||
>
|
>
|
||||||
{{ props.media?.username }}
|
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||||
</span>
|
<template #placeholder>
|
||||||
</div>
|
<div class="w-full h-full">
|
||||||
</VCardText>
|
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||||
<!-- 右下角元数据:暂停 / 待定时替换"x 天前"为状态文案 -->
|
</div>
|
||||||
<VCardText
|
</template>
|
||||||
v-if="rightBottomStateDisplay"
|
</VImg>
|
||||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
</div>
|
||||||
>
|
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||||
{{ rightBottomStateDisplay.label }}
|
<div
|
||||||
</VCardText>
|
class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ..."
|
||||||
<VCardText
|
>
|
||||||
v-else-if="lastUpdateText"
|
{{ props.media?.name }}
|
||||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||||
>
|
</div>
|
||||||
<VIcon icon="mdi-download" class="me-1" />
|
</div>
|
||||||
{{ lastUpdateText }}
|
</VCardText>
|
||||||
</VCardText>
|
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
|
||||||
<div class="w-full absolute bottom-0">
|
<div class="flex min-w-0 max-w-full align-center">
|
||||||
<!--
|
<VIcon
|
||||||
|
v-if="props.media?.total_episode && props.sortable"
|
||||||
|
icon="mdi-progress-download"
|
||||||
|
size="small"
|
||||||
|
color="white"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
|
<IconBtn
|
||||||
|
v-else-if="props.media?.total_episode"
|
||||||
|
size="small"
|
||||||
|
v-bind="props"
|
||||||
|
icon="mdi-progress-download"
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
<!-- 守卫改用 total_episode:电视剧订阅可能不带 season 字段(旧数据或自定义来源),仍应展示集数进度 -->
|
||||||
|
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||||
|
{{ subscribeProgressText }}
|
||||||
|
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
|
||||||
|
{{ subscribeProgressTooltip }}
|
||||||
|
</VTooltip>
|
||||||
|
</div>
|
||||||
|
<VIcon
|
||||||
|
v-if="props.media?.username && props.sortable"
|
||||||
|
icon="mdi-account"
|
||||||
|
size="small"
|
||||||
|
color="white"
|
||||||
|
class="flex-shrink-0 me-1"
|
||||||
|
/>
|
||||||
|
<IconBtn
|
||||||
|
v-else-if="props.media?.username"
|
||||||
|
icon="mdi-account"
|
||||||
|
size="small"
|
||||||
|
color="white"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||||
|
<span
|
||||||
|
v-if="props.media?.username"
|
||||||
|
class="min-w-0 truncate text-subtitle-2 text-white"
|
||||||
|
:title="props.media?.username"
|
||||||
|
>
|
||||||
|
{{ props.media?.username }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
<!-- 右下角元数据:暂停 / 待定时替换"x 天前"为状态文案 -->
|
||||||
|
<VCardText
|
||||||
|
v-if="rightBottomStateDisplay"
|
||||||
|
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||||
|
>
|
||||||
|
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||||
|
{{ rightBottomStateDisplay.label }}
|
||||||
|
</VCardText>
|
||||||
|
<VCardText
|
||||||
|
v-else-if="lastUpdateText"
|
||||||
|
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-download" class="me-1" />
|
||||||
|
{{ lastUpdateText }}
|
||||||
|
</VCardText>
|
||||||
|
<div class="w-full absolute bottom-0">
|
||||||
|
<!--
|
||||||
分集洗版模式:底色保持深绿、buffer 段显示"已下载未洗版"为浅绿、model 段显示"已洗版完成"为亮绿,
|
分集洗版模式:底色保持深绿、buffer 段显示"已下载未洗版"为浅绿、model 段显示"已洗版完成"为亮绿,
|
||||||
形成两段语义;其余订阅维持原有单段进度条
|
形成两段语义;其余订阅维持原有单段进度条
|
||||||
-->
|
-->
|
||||||
<VProgressLinear
|
<VProgressLinear
|
||||||
v-if="isBestVersion && getBufferPercentage() > 0"
|
v-if="isBestVersion && getBufferPercentage() > 0"
|
||||||
:model-value="getPercentage()"
|
:model-value="getPercentage()"
|
||||||
:buffer-value="getBufferPercentage()"
|
:buffer-value="getBufferPercentage()"
|
||||||
bg-color="success"
|
bg-color="success"
|
||||||
bg-opacity="0.25"
|
bg-opacity="0.25"
|
||||||
color="success"
|
color="success"
|
||||||
buffer-color="success"
|
buffer-color="success"
|
||||||
buffer-opacity="0.55"
|
buffer-opacity="0.55"
|
||||||
/>
|
/>
|
||||||
<VProgressLinear
|
<VProgressLinear
|
||||||
v-else-if="getPercentage() > 0"
|
v-else-if="getPercentage() > 0"
|
||||||
:model-value="getPercentage()"
|
:model-value="getPercentage()"
|
||||||
bg-color="success"
|
bg-color="success"
|
||||||
color="success"
|
color="success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,9 +47,14 @@ const posterUrl = computed(() => {
|
|||||||
|
|
||||||
// 获得mediaid
|
// 获得mediaid
|
||||||
function getMediaId() {
|
function getMediaId() {
|
||||||
|
if (props.media?.media_source && props.media?.media_id) {
|
||||||
|
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
|
||||||
|
return `${prefix}:${props.media.media_id}`
|
||||||
|
}
|
||||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||||
|
else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看媒体详情
|
// 查看媒体详情
|
||||||
@@ -102,61 +107,65 @@ function doDelete() {
|
|||||||
'app-hover-lift-card--hovering': hover.isHovering,
|
'app-hover-lift-card--hovering': hover.isHovering,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<VCard
|
<VCard
|
||||||
:key="props.media?.id"
|
:key="props.media?.id"
|
||||||
class="app-hover-lift-card flex flex-col h-full"
|
class="app-hover-lift-card flex flex-col h-full"
|
||||||
min-height="150"
|
min-height="150"
|
||||||
@click="showForkSubscribe"
|
@click="showForkSubscribe"
|
||||||
>
|
>
|
||||||
<template #image>
|
<template #image>
|
||||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||||
|
</template>
|
||||||
|
</VImg>
|
||||||
|
</template>
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||||
|
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
|
||||||
|
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||||
|
<template #placeholder>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VImg>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
||||||
<template #default>
|
<div
|
||||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||||
</template>
|
>
|
||||||
</VImg>
|
{{ props.media?.share_title }}
|
||||||
</template>
|
</div>
|
||||||
<div class="h-full flex flex-col">
|
<div
|
||||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ..."
|
||||||
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
|
>
|
||||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
{{ props.media?.share_comment }}
|
||||||
<template #placeholder>
|
</div>
|
||||||
<div class="w-full h-full">
|
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VImg>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
|
||||||
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
|
|
||||||
{{ props.media?.share_title }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
|
</VCardText>
|
||||||
{{ props.media?.share_comment }}
|
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
|
||||||
|
<div class="flex align-center">
|
||||||
|
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
||||||
|
<div class="text-subtitle-2 me-4 text-white">
|
||||||
|
{{ props.media?.share_user }}
|
||||||
|
</div>
|
||||||
|
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
|
||||||
|
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||||
|
{{ props.media?.count.toLocaleString() }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</VCardText>
|
||||||
</VCardText>
|
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||||
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
|
<VIcon icon="mdi-calendar" class="me-1" />
|
||||||
<div class="flex align-center">
|
{{ dateText }}
|
||||||
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
</VCardText>
|
||||||
<div class="text-subtitle-2 me-4 text-white">
|
</div>
|
||||||
{{ props.media?.share_user }}
|
|
||||||
</div>
|
|
||||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
|
|
||||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
|
||||||
{{ props.media?.count.toLocaleString() }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</VCardText>
|
|
||||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
|
||||||
<VIcon icon="mdi-calendar" class="me-1" />
|
|
||||||
{{ dateText }}
|
|
||||||
</VCardText>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,7 +164,16 @@ describe('SubscribeCard display and progress', () => {
|
|||||||
['disabled flag', false, true, 3, '电视剧', 80, false, false],
|
['disabled flag', false, true, 3, '电视剧', 80, false, false],
|
||||||
])(
|
])(
|
||||||
'normalizes %s for wash progress and badges',
|
'normalizes %s for wash progress and badges',
|
||||||
async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => {
|
async (
|
||||||
|
_case,
|
||||||
|
bestVersion,
|
||||||
|
bestVersionFull,
|
||||||
|
completedEpisode,
|
||||||
|
type,
|
||||||
|
expectedProgress,
|
||||||
|
expectedWash,
|
||||||
|
expectedFull,
|
||||||
|
) => {
|
||||||
const { container } = await renderCard({
|
const { container } = await renderCard({
|
||||||
best_version: bestVersion,
|
best_version: bestVersion,
|
||||||
best_version_full: bestVersionFull,
|
best_version_full: bestVersionFull,
|
||||||
@@ -291,10 +300,20 @@ describe('SubscribeCard interaction boundaries', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
|
['TMDB before all fallbacks', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
|
||||||
['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
|
['Douban before Bangumi', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
|
||||||
['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
|
['Bangumi before custom', { bangumiid: 33, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
|
||||||
['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'],
|
[
|
||||||
|
'AniList before legacy custom',
|
||||||
|
{ anilistid: 55, bangumiid: undefined, mediaid: 'custom:44', tmdbid: 0 },
|
||||||
|
'anilist:55',
|
||||||
|
],
|
||||||
|
['selected primary identity', { media_id: '66', media_source: 'anilist', tmdbid: 11 }, 'anilist:66'],
|
||||||
|
[
|
||||||
|
'custom media ID last',
|
||||||
|
{ bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 },
|
||||||
|
'custom:44',
|
||||||
|
],
|
||||||
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
|
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
|
||||||
const { container, media } = await renderCard(identifiers)
|
const { container, media } = await renderCard(identifiers)
|
||||||
|
|
||||||
@@ -364,30 +383,34 @@ describe('SubscribeCard item operations', () => {
|
|||||||
['confirmation cancellation', false, 200, { success: true }, null],
|
['confirmation cancellation', false, 200, { success: true }, null],
|
||||||
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'],
|
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'],
|
||||||
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
|
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
|
||||||
] as const)(
|
] as const)('keeps status unchanged after %s', async (_case, confirmed, status, response, expectedError) => {
|
||||||
'keeps status unchanged after %s',
|
const requested = vi.fn()
|
||||||
async (_case, confirmed, status, response, expectedError) => {
|
mocks.confirm.mockResolvedValue(confirmed)
|
||||||
const requested = vi.fn()
|
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||||
mocks.confirm.mockResolvedValue(confirmed)
|
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
|
||||||
const { container, emitted, media } = await renderCard({ state: 'R' })
|
|
||||||
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
|
|
||||||
|
|
||||||
await chooseMenuItem(container, '暂停')
|
await chooseMenuItem(container, '暂停')
|
||||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
else expect(requested).not.toHaveBeenCalled()
|
else expect(requested).not.toHaveBeenCalled()
|
||||||
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
|
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
|
||||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||||
expect(emitted('save') ?? []).toHaveLength(0)
|
expect(emitted('save') ?? []).toHaveLength(0)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
|
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
|
||||||
['confirmation cancellation', false, 200, { success: true }, null, null],
|
['confirmation cancellation', false, 200, { success: true }, null, null],
|
||||||
['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败:rejected'],
|
[
|
||||||
|
'business failure',
|
||||||
|
true,
|
||||||
|
200,
|
||||||
|
{ message: 'rejected', success: false },
|
||||||
|
'error',
|
||||||
|
'卡片测试媒体 重置失败:rejected',
|
||||||
|
],
|
||||||
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||||
] as const)(
|
] as const)(
|
||||||
'handles reset %s without speculative state',
|
'handles reset %s without speculative state',
|
||||||
@@ -423,16 +446,19 @@ describe('SubscribeCard item operations', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
['success', 200, { success: true }, true, null],
|
['success', 200, { success: true }, true, null],
|
||||||
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
|
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
|
||||||
] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => {
|
] as const)(
|
||||||
const requested = vi.fn()
|
'handles delete %s without a synthetic business-failure branch',
|
||||||
const { container, emitted, media } = await renderCard()
|
async (_case, status, response, removed, error) => {
|
||||||
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
|
const requested = vi.fn()
|
||||||
|
const { container, emitted, media } = await renderCard()
|
||||||
|
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
|
||||||
|
|
||||||
await chooseMenuItem(container, '取消订阅')
|
await chooseMenuItem(container, '取消订阅')
|
||||||
|
|
||||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||||
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
|
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
|
||||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ describe('SubscribeShareCard', () => {
|
|||||||
['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'],
|
['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'],
|
||||||
['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'],
|
['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'],
|
||||||
['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'],
|
['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'],
|
||||||
|
['AniList without other IDs', { anilistid: 4404, bangumiid: undefined, tmdbid: undefined }, 'anilist:4404'],
|
||||||
] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => {
|
] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => {
|
||||||
const { container, media } = await renderCard(ids)
|
const { container, media } = await renderCard(ids)
|
||||||
const poster = await loadPoster(container)
|
const poster = await loadPoster(container)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { MediaDataSource } from '@/api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -20,12 +21,28 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
(event: 'confirm', payload: { doubanId?: string; tmdbId?: number }): void
|
(event: 'confirm', payload: { mediaSource?: MediaDataSource; mediaId?: string }): void
|
||||||
(event: 'update:modelValue', value: boolean): void
|
(event: 'update:modelValue', value: boolean): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tmdbId = ref<number | undefined>()
|
const mediaSource = ref<MediaDataSource>((props.recognizeSource as MediaDataSource) || 'themoviedb')
|
||||||
const doubanId = ref<string | undefined>()
|
const mediaId = ref<string>()
|
||||||
|
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||||
|
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||||
|
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||||
|
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||||
|
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const mediaIdLabel = computed(() => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
themoviedb: t('setting.cache.reidentifyDialog.tmdbId'),
|
||||||
|
douban: t('setting.cache.reidentifyDialog.doubanId'),
|
||||||
|
bangumi: t('setting.cache.reidentifyDialog.bangumiId'),
|
||||||
|
anilist: t('setting.cache.reidentifyDialog.anilistId'),
|
||||||
|
}
|
||||||
|
return labels[mediaSource.value] || t('setting.cache.reidentifyDialog.mediaId')
|
||||||
|
})
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
@@ -38,8 +55,8 @@ const visible = computed({
|
|||||||
// 提交重新识别参数给缓存页执行接口调用。
|
// 提交重新识别参数给缓存页执行接口调用。
|
||||||
function submitReidentify() {
|
function submitReidentify() {
|
||||||
emit('confirm', {
|
emit('confirm', {
|
||||||
doubanId: doubanId.value,
|
mediaSource: mediaSource.value,
|
||||||
tmdbId: tmdbId.value,
|
mediaId: mediaId.value?.trim() || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -59,20 +76,20 @@ function submitReidentify() {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VTextField
|
<VSelect
|
||||||
v-if="props.recognizeSource === 'themoviedb'"
|
v-model="mediaSource"
|
||||||
v-model="tmdbId"
|
:items="mediaSourceItems"
|
||||||
:label="t('setting.cache.reidentifyDialog.tmdbId')"
|
:label="t('setting.cache.reidentifyDialog.mediaSource')"
|
||||||
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
|
:hint="t('setting.cache.reidentifyDialog.mediaSourceHint')"
|
||||||
clearable
|
prepend-inner-icon="mdi-database-search"
|
||||||
prepend-inner-icon="mdi-id-card"
|
|
||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-else
|
v-model="mediaId"
|
||||||
v-model="doubanId"
|
:label="mediaIdLabel"
|
||||||
:label="t('setting.cache.reidentifyDialog.doubanId')"
|
:hint="t('setting.cache.reidentifyDialog.mediaIdHint')"
|
||||||
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
|
|
||||||
clearable
|
clearable
|
||||||
prepend-inner-icon="mdi-id-card"
|
prepend-inner-icon="mdi-id-card"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
|
|||||||
@@ -97,9 +97,14 @@ const posterUrl = computed(() => {
|
|||||||
|
|
||||||
// 获得mediaid
|
// 获得mediaid
|
||||||
function getMediaId() {
|
function getMediaId() {
|
||||||
|
if (props.media?.media_source && props.media?.media_id) {
|
||||||
|
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
|
||||||
|
return `${prefix}:${props.media.media_id}`
|
||||||
|
}
|
||||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||||
|
else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看媒体详情
|
// 查看媒体详情
|
||||||
|
|||||||
@@ -42,17 +42,17 @@ const props = defineProps({
|
|||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
const globalSettings = globalSettingsStore.globalSettings
|
const globalSettings = globalSettingsStore.globalSettings
|
||||||
|
|
||||||
const mediaSourceItems: { title: string; value: MediaDataSource }[] = [
|
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||||
{ title: 'TheMovieDb', value: 'themoviedb' },
|
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||||
{ title: '豆瓣', value: 'douban' },
|
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||||
{ title: 'Bangumi', value: 'bangumi' },
|
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||||
{ title: 'AniList', value: 'anilist' },
|
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||||
]
|
])
|
||||||
|
|
||||||
// 获取后台设置中的默认识别数据源,未知值兼容回退到TheMovieDb。
|
// 获取后台设置中的默认识别数据源,未知值兼容回退到TheMovieDb。
|
||||||
function getDefaultMediaSource(): MediaDataSource {
|
function getDefaultMediaSource(): MediaDataSource {
|
||||||
const configuredSource = globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
const configuredSource = globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
||||||
return mediaSourceItems.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
return mediaSourceItems.value.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义事件
|
// 定义事件
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:modelValue', value: boolean): void
|
(event: 'update:modelValue', value: boolean): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mediaSourceItems: { title: string; value: MediaDataSource }[] = [
|
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||||
{ title: 'TheMovieDb', value: 'themoviedb' },
|
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||||
{ title: '豆瓣', value: 'douban' },
|
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||||
{ title: 'Bangumi', value: 'bangumi' },
|
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||||
{ title: 'AniList', value: 'anilist' },
|
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||||
]
|
])
|
||||||
|
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
const mediaType = ref('')
|
const mediaType = ref('')
|
||||||
@@ -67,7 +67,7 @@ const canSubmit = computed(() => {
|
|||||||
// 获取后台设置中的默认识别数据源,未知值兼容回退到 TheMovieDb。
|
// 获取后台设置中的默认识别数据源,未知值兼容回退到 TheMovieDb。
|
||||||
function getDefaultMediaSource(): MediaDataSource {
|
function getDefaultMediaSource(): MediaDataSource {
|
||||||
const configuredSource = globalSettingsStore.globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
const configuredSource = globalSettingsStore.globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
||||||
return mediaSourceItems.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
return mediaSourceItems.value.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将搜索结果媒体类型映射为手动刮削接口接受的类型名。
|
// 将搜索结果媒体类型映射为手动刮削接口接受的类型名。
|
||||||
|
|||||||
@@ -164,10 +164,15 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
|
|||||||
|
|
||||||
// 获得mediaid
|
// 获得mediaid
|
||||||
function getMediaId() {
|
function getMediaId() {
|
||||||
|
if (props.media?.media_id && (props.media?.source || props.media?.mediaid_prefix)) {
|
||||||
|
const source = props.media.mediaid_prefix || props.media.source
|
||||||
|
return `${source === 'themoviedb' ? 'tmdb' : source}:${props.media.media_id}`
|
||||||
|
}
|
||||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
else if (props.media?.anilist_id) return `anilist:${props.media?.anilist_id}`
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询所有剧集组
|
// 查询所有剧集组
|
||||||
@@ -355,7 +360,8 @@ function getDefaultSeasonMode(season: number) {
|
|||||||
|
|
||||||
// 确保指定季已初始化订阅模式。
|
// 确保指定季已初始化订阅模式。
|
||||||
function ensureSeasonMode(season: number) {
|
function ensureSeasonMode(season: number) {
|
||||||
if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
|
if (!seasonModes.value[season])
|
||||||
|
setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在入库状态刷新后同步尚未手动修改的默认模式。
|
// 在入库状态刷新后同步尚未手动修改的默认模式。
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const PosterStub = defineComponent({
|
|||||||
})
|
})
|
||||||
|
|
||||||
interface MediaIdentifiers {
|
interface MediaIdentifiers {
|
||||||
|
anilistid?: number
|
||||||
bangumiid?: number
|
bangumiid?: number
|
||||||
doubanid?: string
|
doubanid?: string
|
||||||
tmdbid?: number
|
tmdbid?: number
|
||||||
@@ -72,6 +73,7 @@ const mediaDetailCases: Array<[string, MediaIdentifiers, string]> = [
|
|||||||
['TMDB', { tmdbid: 6301 }, 'tmdb:6301'],
|
['TMDB', { tmdbid: 6301 }, 'tmdb:6301'],
|
||||||
['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'],
|
['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'],
|
||||||
['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'],
|
['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'],
|
||||||
|
['AniList', { anilistid: 6304, bangumiid: undefined, tmdbid: undefined }, 'anilist:6304'],
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDeferred() {
|
function createDeferred() {
|
||||||
@@ -82,10 +84,7 @@ function createDeferred() {
|
|||||||
return { promise, resolve }
|
return { promise, resolve }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderDialog(
|
async function renderDialog(media: SubscribeShare = createSubscribeShare(), settings: Record<string, unknown> = {}) {
|
||||||
media: SubscribeShare = createSubscribeShare(),
|
|
||||||
settings: Record<string, unknown> = {},
|
|
||||||
) {
|
|
||||||
const events = {
|
const events = {
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -149,10 +148,7 @@ describe('ForkSubscribeDialog follow behavior', () => {
|
|||||||
const writeRequest = vi.fn((url: URL) => {
|
const writeRequest = vi.fn((url: URL) => {
|
||||||
users.push(url.searchParams.get('share_uid') || '')
|
users.push(url.searchParams.get('share_uid') || '')
|
||||||
})
|
})
|
||||||
server.use(
|
server.use(followSubscribersSettingHandler(users), followSubscriberHandler({ success: true }, 200, writeRequest))
|
||||||
followSubscribersSettingHandler(users),
|
|
||||||
followSubscriberHandler({ success: true }, 200, writeRequest),
|
|
||||||
)
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderDialog(media)
|
await renderDialog(media)
|
||||||
|
|
||||||
@@ -170,10 +166,7 @@ describe('ForkSubscribeDialog follow behavior', () => {
|
|||||||
const writeRequest = vi.fn((url: URL) => {
|
const writeRequest = vi.fn((url: URL) => {
|
||||||
users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1)
|
users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1)
|
||||||
})
|
})
|
||||||
server.use(
|
server.use(followSubscribersSettingHandler(users), unfollowSubscriberHandler({ success: true }, 200, writeRequest))
|
||||||
followSubscribersSettingHandler(users),
|
|
||||||
unfollowSubscriberHandler({ success: true }, 200, writeRequest),
|
|
||||||
)
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderDialog(media)
|
await renderDialog(media)
|
||||||
|
|
||||||
@@ -263,10 +256,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('reports a fork business failure and does not emit', async () => {
|
it('reports a fork business failure and does not emit', async () => {
|
||||||
server.use(
|
server.use(followSubscribersSettingHandler([]), forkSubscribeHandler({ message: '订阅已存在', success: false }))
|
||||||
followSubscribersSettingHandler([]),
|
|
||||||
forkSubscribeHandler({ message: '订阅已存在', success: false }),
|
|
||||||
)
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' }))
|
const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' }))
|
||||||
|
|
||||||
@@ -368,28 +358,32 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
|
|||||||
expect(events.close).toHaveBeenCalledOnce()
|
expect(events.close).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(mediaDetailCases)('routes %s shares to their media details', async (_source, identifiers, expectedMediaId) => {
|
it.each(mediaDetailCases)(
|
||||||
const media: SubscribeShare = {
|
'routes %s shares to their media details',
|
||||||
...createSubscribeShare({
|
async (_source, identifiers, expectedMediaId) => {
|
||||||
doubanid: identifiers.doubanid,
|
const media: SubscribeShare = {
|
||||||
tmdbid: identifiers.tmdbid,
|
...createSubscribeShare({
|
||||||
}),
|
anilistid: identifiers.anilistid,
|
||||||
bangumiid: identifiers.bangumiid,
|
doubanid: identifiers.doubanid,
|
||||||
}
|
tmdbid: identifiers.tmdbid,
|
||||||
server.use(followSubscribersSettingHandler([]))
|
}),
|
||||||
const user = userEvent.setup()
|
bangumiid: identifiers.bangumiid,
|
||||||
await renderDialog(media)
|
}
|
||||||
|
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,
|
mediaid: expectedMediaId,
|
||||||
title: media.name,
|
title: media.name,
|
||||||
type: media.type,
|
type: media.type,
|
||||||
year: media.year,
|
year: media.year,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import SubscribeSeasonDialog from '@/components/dialog/SubscribeSeasonDialog.vue
|
|||||||
import type { SubscribeMode } from '@/composables/useMediaSubscribe'
|
import type { SubscribeMode } from '@/composables/useMediaSubscribe'
|
||||||
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import {
|
import { createMediaInfo, createMediaSeason, createNotExistMediaInfo } from '@tests/support/factories/media'
|
||||||
createMediaInfo,
|
|
||||||
createMediaSeason,
|
|
||||||
createNotExistMediaInfo,
|
|
||||||
} from '@tests/support/factories/media'
|
|
||||||
import {
|
import {
|
||||||
mediaEpisodeGroupsHandler,
|
mediaEpisodeGroupsHandler,
|
||||||
mediaGroupSeasonsHandler,
|
mediaGroupSeasonsHandler,
|
||||||
@@ -141,15 +137,21 @@ describe('SubscribeSeasonDialog', () => {
|
|||||||
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')
|
||||||
expect(missingPayloads[0]).toMatchObject({ episode_group: '', season: 0, tmdb_id: media.tmdb_id })
|
expect(missingPayloads[0]).toMatchObject({ episode_group: '', season: 0, tmdb_id: media.tmdb_id })
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['Douban', { douban_id: 'db-7303', tmdb_id: undefined }, 'douban:db-7303'],
|
['Douban', { douban_id: 'db-7303', tmdb_id: undefined }, 'douban:db-7303'],
|
||||||
['Bangumi', { bangumi_id: 'bgm-7304', douban_id: undefined, tmdb_id: undefined }, 'bangumi:bgm-7304'],
|
['Bangumi', { bangumi_id: 'bgm-7304', douban_id: undefined, tmdb_id: undefined }, 'bangumi:bgm-7304'],
|
||||||
|
['AniList', { anilist_id: 154587, bangumi_id: undefined, tmdb_id: undefined }, 'anilist:154587'],
|
||||||
[
|
[
|
||||||
'custom source',
|
'custom source',
|
||||||
{ bangumi_id: undefined, douban_id: undefined, media_id: 'custom-7305', mediaid_prefix: 'custom', tmdb_id: undefined },
|
{
|
||||||
|
bangumi_id: undefined,
|
||||||
|
douban_id: undefined,
|
||||||
|
media_id: 'custom-7305',
|
||||||
|
mediaid_prefix: 'custom',
|
||||||
|
tmdb_id: undefined,
|
||||||
|
},
|
||||||
'custom:custom-7305',
|
'custom:custom-7305',
|
||||||
],
|
],
|
||||||
] 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) => {
|
||||||
@@ -269,14 +271,10 @@ describe('SubscribeSeasonDialog', () => {
|
|||||||
{ episode_count: 8, group_count: 1, id: 'group-a', name: '自定义排序 A' },
|
{ episode_count: 8, group_count: 1, id: 'group-a', name: '自定义排序 A' },
|
||||||
]),
|
]),
|
||||||
mediaGroupSeasonsHandler('group-a', [createMediaSeason({ season_number: 5 })]),
|
mediaGroupSeasonsHandler('group-a', [createMediaSeason({ season_number: 5 })]),
|
||||||
mediaSeasonsHandler(
|
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })], 200, async () => {
|
||||||
[createMediaSeason({ season_number: 1 })],
|
defaultRequestStarted.resolve()
|
||||||
200,
|
await defaultResponseGate.promise
|
||||||
async () => {
|
}),
|
||||||
defaultRequestStarted.resolve()
|
|
||||||
await defaultResponseGate.promise
|
|
||||||
},
|
|
||||||
),
|
|
||||||
mediaNotExistsHandler([]),
|
mediaNotExistsHandler([]),
|
||||||
)
|
)
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
@@ -355,11 +353,7 @@ describe('SubscribeSeasonDialog', () => {
|
|||||||
|
|
||||||
it('renders the successful empty state and emits close without submitting', async () => {
|
it('renders the successful empty state and emits close without submitting', async () => {
|
||||||
const media = createTvMedia({ tmdb_id: 7311 })
|
const media = createTvMedia({ tmdb_id: 7311 })
|
||||||
server.use(
|
server.use(mediaSeasonsHandler([]), mediaNotExistsHandler([]), mediaEpisodeGroupsHandler(media.tmdb_id!, []))
|
||||||
mediaSeasonsHandler([]),
|
|
||||||
mediaNotExistsHandler([]),
|
|
||||||
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
|
|
||||||
)
|
|
||||||
const { events } = await renderDialog({ media })
|
const { events } = await renderDialog({ media })
|
||||||
|
|
||||||
expect(await screen.findByText(`${media.title} 未查询到季集信息`)).toBeInTheDocument()
|
expect(await screen.findByText(`${media.title} 未查询到季集信息`)).toBeInTheDocument()
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import {
|
|||||||
useMediaSubscribe,
|
useMediaSubscribe,
|
||||||
} from '@/composables/useMediaSubscribe'
|
} from '@/composables/useMediaSubscribe'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import {
|
import { createSubscribe, createSubscribeMovie, createSubscribeTv } from '@tests/support/factories/subscribe'
|
||||||
createSubscribe,
|
|
||||||
createSubscribeMovie,
|
|
||||||
createSubscribeTv,
|
|
||||||
} from '@tests/support/factories/subscribe'
|
|
||||||
import {
|
import {
|
||||||
createSubscribeHandler,
|
createSubscribeHandler,
|
||||||
defaultSubscribeConfigHandler,
|
defaultSubscribeConfigHandler,
|
||||||
@@ -78,7 +74,7 @@ interface HarnessOptions {
|
|||||||
|
|
||||||
async function renderSubscribeHarness(options: HarnessOptions = {}) {
|
async function renderSubscribeHarness(options: HarnessOptions = {}) {
|
||||||
const media = options.media
|
const media = options.media
|
||||||
const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? media.season ?? 1 : null)
|
const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? (media.season ?? 1) : null)
|
||||||
const Harness = defineComponent({
|
const Harness = defineComponent({
|
||||||
name: 'MediaSubscribeHarness',
|
name: 'MediaSubscribeHarness',
|
||||||
setup() {
|
setup() {
|
||||||
@@ -174,8 +170,17 @@ 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 before all fallback identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: 10 }, 'tmdb:10'],
|
||||||
['Douban before Bangumi and generic identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, 'douban:20'],
|
[
|
||||||
['Bangumi before a generic identifier', { bangumi_id: '30', douban_id: undefined, tmdb_id: undefined }, 'bangumi:30'],
|
'Douban before Bangumi and generic identifiers',
|
||||||
|
{ bangumi_id: '30', douban_id: '20', tmdb_id: undefined },
|
||||||
|
'douban:20',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Bangumi before a generic identifier',
|
||||||
|
{ bangumi_id: '30', douban_id: undefined, tmdb_id: undefined },
|
||||||
|
'bangumi:30',
|
||||||
|
],
|
||||||
|
['AniList after Bangumi', { anilist_id: 40, bangumi_id: undefined, tmdb_id: undefined }, 'anilist:40'],
|
||||||
[
|
[
|
||||||
'generic identifiers when provider ids are absent',
|
'generic identifiers when provider ids are absent',
|
||||||
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined },
|
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined },
|
||||||
@@ -217,9 +222,8 @@ describe('useMediaSubscribe entry flows', () => {
|
|||||||
await waitFor(() => expect(created).toHaveBeenCalledOnce())
|
await waitFor(() => expect(created).toHaveBeenCalledOnce())
|
||||||
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
|
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
|
||||||
expect(created).toHaveBeenCalledWith({
|
expect(created).toHaveBeenCalledWith({
|
||||||
bangumiid: undefined,
|
|
||||||
doubanid: undefined,
|
|
||||||
episode_group: '',
|
episode_group: '',
|
||||||
|
media_source: 'themoviedb',
|
||||||
mediaid: '',
|
mediaid: '',
|
||||||
name: '普通电影',
|
name: '普通电影',
|
||||||
season: null,
|
season: null,
|
||||||
@@ -367,7 +371,13 @@ describe('useMediaSubscribe entry flows', () => {
|
|||||||
label: 'Bangumi',
|
label: 'Bangumi',
|
||||||
media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }),
|
media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }),
|
||||||
mediaId: 'bangumi:42',
|
mediaId: 'bangumi:42',
|
||||||
record: createSubscribe({ bangumiid: 42 as unknown as string, season: 2, tmdbid: 0, type: '电视剧' }),
|
record: createSubscribe({ bangumiid: 42, season: 2, tmdbid: 0, type: '电视剧' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AniList',
|
||||||
|
media: createSubscribeTv({ anilist_id: 154587, tmdb_id: undefined }),
|
||||||
|
mediaId: 'anilist:154587',
|
||||||
|
record: createSubscribe({ anilistid: 154587, season: 2, tmdbid: 0, type: '电视剧' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'generic provider',
|
label: 'generic provider',
|
||||||
@@ -474,7 +484,9 @@ describe('useMediaSubscribe entry flows', () => {
|
|||||||
const editDialog = getDialogCall()
|
const editDialog = getDialogCall()
|
||||||
expect(editDialog.props).toEqual({ subid: 701 })
|
expect(editDialog.props).toEqual({ subid: 701 })
|
||||||
|
|
||||||
editDialog.events.save(createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' }))
|
editDialog.events.save(
|
||||||
|
createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' }),
|
||||||
|
)
|
||||||
await waitFor(() => expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"'))
|
await waitFor(() => expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"'))
|
||||||
|
|
||||||
editDialog.events.remove()
|
editDialog.events.remove()
|
||||||
@@ -542,10 +554,7 @@ 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(
|
querySubscribeByMediaHandler('tmdb:110', createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' })),
|
||||||
'tmdb:110',
|
|
||||||
createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' }),
|
|
||||||
),
|
|
||||||
updateSubscribeHandler(response, status, updated),
|
updateSubscribeHandler(response, status, updated),
|
||||||
)
|
)
|
||||||
await renderSubscribeHarness({
|
await renderSubscribeHarness({
|
||||||
|
|||||||
@@ -51,11 +51,15 @@ export type SeasonSubscribeModes = Record<number, SubscribeMode>
|
|||||||
|
|
||||||
// 生成跨媒体源稳定的订阅媒体标识。
|
// 生成跨媒体源稳定的订阅媒体标识。
|
||||||
export function getMediaSubscribeId(media?: MediaInfo) {
|
export function getMediaSubscribeId(media?: MediaInfo) {
|
||||||
|
if (media?.media_id && (media.source || media.mediaid_prefix)) {
|
||||||
|
const source = media.mediaid_prefix || media.source
|
||||||
|
return `${source === 'themoviedb' ? 'tmdb' : source}:${media.media_id}`
|
||||||
|
}
|
||||||
if (media?.tmdb_id) return `tmdb:${media.tmdb_id}`
|
if (media?.tmdb_id) return `tmdb:${media.tmdb_id}`
|
||||||
if (media?.douban_id) return `douban:${media.douban_id}`
|
if (media?.douban_id) return `douban:${media.douban_id}`
|
||||||
if (media?.bangumi_id) return `bangumi:${media.bangumi_id}`
|
if (media?.bangumi_id) return `bangumi:${media.bangumi_id}`
|
||||||
if (media?.anilist_id) return `anilist:${media.anilist_id}`
|
if (media?.anilist_id) return `anilist:${media.anilist_id}`
|
||||||
return `${media?.mediaid_prefix}:${media?.media_id}`
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将订阅模式转换为后端订阅字段。
|
// 将订阅模式转换为后端订阅字段。
|
||||||
@@ -277,7 +281,12 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
tmdbid: media.tmdb_id,
|
tmdbid: media.tmdb_id,
|
||||||
doubanid: media.douban_id,
|
doubanid: media.douban_id,
|
||||||
bangumiid: media.bangumi_id,
|
bangumiid: media.bangumi_id,
|
||||||
mediaid: media.media_id ? `${media.mediaid_prefix}:${media.media_id}` : '',
|
anilistid: media.anilist_id,
|
||||||
|
media_source: media.source || media.mediaid_prefix,
|
||||||
|
media_id: media.media_id,
|
||||||
|
mediaid: media.media_id
|
||||||
|
? `${(media.mediaid_prefix || media.source) === 'themoviedb' ? 'tmdb' : media.mediaid_prefix || media.source}:${media.media_id}`
|
||||||
|
: '',
|
||||||
season: media.type === '电影' ? null : season,
|
season: media.type === '电影' ? null : season,
|
||||||
...payload,
|
...payload,
|
||||||
episode_group: episodeGroup.value,
|
episode_group: episodeGroup.value,
|
||||||
@@ -342,7 +351,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
||||||
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
||||||
} else {
|
} else {
|
||||||
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`)
|
$toast.error(
|
||||||
|
`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -492,7 +503,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
episodeGroup.value = groupId
|
episodeGroup.value = groupId
|
||||||
const subscribedSeasonSet = new Set(options.subscribedSeasons?.value ?? [])
|
const subscribedSeasonSet = new Set(options.subscribedSeasons?.value ?? [])
|
||||||
const selectedSeasonSet = new Set(
|
const selectedSeasonSet = new Set(
|
||||||
seasons.map(season => season.season_number).filter((season): season is number => season !== null && season !== undefined),
|
seasons
|
||||||
|
.map(season => season.season_number)
|
||||||
|
.filter((season): season is number => season !== null && season !== undefined),
|
||||||
)
|
)
|
||||||
const visibleSeasonSet = new Set(visibleSeasonNumbers)
|
const visibleSeasonSet = new Set(visibleSeasonNumbers)
|
||||||
const seasonsToSubscribe = seasons.filter(season => {
|
const seasonsToSubscribe = seasons.filter(season => {
|
||||||
@@ -506,7 +519,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
const seasonNumber = season.season_number ?? null
|
const seasonNumber = season.season_number ?? null
|
||||||
if (seasonNumber === null || !subscribedSeasonSet.has(seasonNumber)) return false
|
if (seasonNumber === null || !subscribedSeasonSet.has(seasonNumber)) return false
|
||||||
|
|
||||||
const nextMode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
const nextMode = typeof seasonModes === 'string' ? seasonModes : (seasonModes[seasonNumber] ?? 'normal')
|
||||||
return (options.subscribedSeasonModes?.value[seasonNumber] ?? 'normal') !== nextMode
|
return (options.subscribedSeasonModes?.value[seasonNumber] ?? 'normal') !== nextMode
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -518,7 +531,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
const seasonNumber = season.season_number ?? null
|
const seasonNumber = season.season_number ?? null
|
||||||
if (seasonNumber === null) return
|
if (seasonNumber === null) return
|
||||||
|
|
||||||
const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
const mode = typeof seasonModes === 'string' ? seasonModes : (seasonModes[seasonNumber] ?? 'normal')
|
||||||
updateSubscribeMode(seasonNumber, mode)
|
updateSubscribeMode(seasonNumber, mode)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -526,15 +539,11 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
|||||||
const seasonNumber = season.season_number ?? null
|
const seasonNumber = season.season_number ?? null
|
||||||
if (seasonNumber === null) return
|
if (seasonNumber === null) return
|
||||||
|
|
||||||
const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
const mode = typeof seasonModes === 'string' ? seasonModes : (seasonModes[seasonNumber] ?? 'normal')
|
||||||
const payload = getSubscribePayload(mode)
|
const payload = getSubscribePayload(mode)
|
||||||
addSubscribe(
|
addSubscribe(seasonNumber, payload, {
|
||||||
seasonNumber,
|
openEditDialog: seasonsToSubscribe.length === 1 && seasonsToUnsubscribe.length === 0,
|
||||||
payload,
|
})
|
||||||
{
|
|
||||||
openEditDialog: seasonsToSubscribe.length === 1 && seasonsToUnsubscribe.length === 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2523,6 +2523,12 @@ export default {
|
|||||||
tmdbIdHint: 'Optional, manually specify TMDB ID for recognition',
|
tmdbIdHint: 'Optional, manually specify TMDB ID for recognition',
|
||||||
doubanId: 'Douban ID',
|
doubanId: 'Douban ID',
|
||||||
doubanIdHint: 'Optional, manually specify Douban ID for recognition',
|
doubanIdHint: 'Optional, manually specify Douban ID for recognition',
|
||||||
|
bangumiId: 'Bangumi ID',
|
||||||
|
anilistId: 'AniList ID',
|
||||||
|
mediaSource: 'Recognition source',
|
||||||
|
mediaSourceHint: 'Applies only to this re-identification and does not change the system default',
|
||||||
|
mediaId: 'Media ID',
|
||||||
|
mediaIdHint: 'Optional source-native media ID; leave blank to recognize by title',
|
||||||
autoHint: 'If no ID is specified, the torrent will be automatically re-identified',
|
autoHint: 'If no ID is specified, the torrent will be automatically re-identified',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
confirm: 'Re-identify',
|
confirm: 'Re-identify',
|
||||||
@@ -2538,6 +2544,8 @@ export default {
|
|||||||
recognitionSource: {
|
recognitionSource: {
|
||||||
themoviedb: 'TheMovieDb',
|
themoviedb: 'TheMovieDb',
|
||||||
douban: 'Douban',
|
douban: 'Douban',
|
||||||
|
bangumi: 'Bangumi',
|
||||||
|
anilist: 'AniList',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2477,6 +2477,12 @@ export default {
|
|||||||
tmdbIdHint: '可选,手动指定TMDB ID进行识别',
|
tmdbIdHint: '可选,手动指定TMDB ID进行识别',
|
||||||
doubanId: '豆瓣 ID',
|
doubanId: '豆瓣 ID',
|
||||||
doubanIdHint: '可选,手动指定豆瓣ID进行识别',
|
doubanIdHint: '可选,手动指定豆瓣ID进行识别',
|
||||||
|
bangumiId: 'Bangumi ID',
|
||||||
|
anilistId: 'AniList ID',
|
||||||
|
mediaSource: '识别数据源',
|
||||||
|
mediaSourceHint: '仅用于本次重新识别,不修改系统默认数据源',
|
||||||
|
mediaId: '媒体 ID',
|
||||||
|
mediaIdHint: '可选,填写所选数据源中的原生媒体 ID;留空时按标题识别',
|
||||||
autoHint: '如果不指定ID,将自动重新识别该种子',
|
autoHint: '如果不指定ID,将自动重新识别该种子',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
confirm: '重新识别',
|
confirm: '重新识别',
|
||||||
@@ -2492,6 +2498,8 @@ export default {
|
|||||||
recognitionSource: {
|
recognitionSource: {
|
||||||
themoviedb: 'TheMovieDb',
|
themoviedb: 'TheMovieDb',
|
||||||
douban: '豆瓣',
|
douban: '豆瓣',
|
||||||
|
bangumi: 'Bangumi',
|
||||||
|
anilist: 'AniList',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2476,6 +2476,12 @@ export default {
|
|||||||
tmdbIdHint: '可選,手動指定TMDB ID進行識別',
|
tmdbIdHint: '可選,手動指定TMDB ID進行識別',
|
||||||
doubanId: '豆瓣 ID',
|
doubanId: '豆瓣 ID',
|
||||||
doubanIdHint: '可選,手動指定豆瓣ID進行識別',
|
doubanIdHint: '可選,手動指定豆瓣ID進行識別',
|
||||||
|
bangumiId: 'Bangumi ID',
|
||||||
|
anilistId: 'AniList ID',
|
||||||
|
mediaSource: '識別資料源',
|
||||||
|
mediaSourceHint: '僅用於本次重新識別,不修改系統預設資料源',
|
||||||
|
mediaId: '媒體 ID',
|
||||||
|
mediaIdHint: '可選,填寫所選資料源中的原生媒體 ID;留空時按標題識別',
|
||||||
autoHint: '如果不指定ID,將自動重新識別該種子',
|
autoHint: '如果不指定ID,將自動重新識別該種子',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
confirm: '重新識別',
|
confirm: '重新識別',
|
||||||
@@ -2491,6 +2497,8 @@ export default {
|
|||||||
recognitionSource: {
|
recognitionSource: {
|
||||||
themoviedb: 'TheMovieDb',
|
themoviedb: 'TheMovieDb',
|
||||||
douban: '豆瓣',
|
douban: '豆瓣',
|
||||||
|
bangumi: 'Bangumi',
|
||||||
|
anilist: 'AniList',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const dedupFields = [
|
|||||||
'tvdb_id',
|
'tvdb_id',
|
||||||
'douban_id',
|
'douban_id',
|
||||||
'bangumi_id',
|
'bangumi_id',
|
||||||
|
'anilist_id',
|
||||||
'mediaid_prefix',
|
'mediaid_prefix',
|
||||||
'media_id',
|
'media_id',
|
||||||
] as const
|
] as const
|
||||||
@@ -100,11 +101,7 @@ async function loadPageData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取列表数据
|
// 获取列表数据
|
||||||
async function fetchData({
|
async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'loading' | 'ok') => void }) {
|
||||||
done,
|
|
||||||
}: {
|
|
||||||
done: (status: 'empty' | 'error' | 'loading' | 'ok') => void
|
|
||||||
}) {
|
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
done('ok')
|
done('ok')
|
||||||
return
|
return
|
||||||
@@ -172,10 +169,6 @@ async function fetchData({
|
|||||||
<MediaCard :media="item" />
|
<MediaCard :media="item" />
|
||||||
</template>
|
</template>
|
||||||
</ProgressiveCardGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound v-if="dataList.length === 0 && isRefreshed" error-code="404" :error-title="t('common.noData')" />
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
|
||||||
error-code="404"
|
|
||||||
:error-title="t('common.noData')"
|
|
||||||
/>
|
|
||||||
</VInfiniteScroll>
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ onActivated(() => {
|
|||||||
<VirtualSlideView
|
<VirtualSlideView
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
:loading="!componentLoaded"
|
:loading="!componentLoaded"
|
||||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
:get-item-key="
|
||||||
|
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" />
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ function getMediaId() {
|
|||||||
return getMediaSubscribeId(mediaDetail.value)
|
return getMediaSubscribeId(mediaDetail.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断详情是否包含可用于后续操作的稳定媒体身份。
|
||||||
|
function hasMediaIdentity() {
|
||||||
|
return Boolean(getMediaId())
|
||||||
|
}
|
||||||
|
|
||||||
// 生成当前媒体指定季的订阅状态缓存键
|
// 生成当前媒体指定季的订阅状态缓存键
|
||||||
function getSubscribeStatusKey(season: number | null = mediaDetail.value?.season ?? null) {
|
function getSubscribeStatusKey(season: number | null = mediaDetail.value?.season ?? null) {
|
||||||
return `${getMediaId()}::${season ?? 'all'}`
|
return `${getMediaId()}::${season ?? 'all'}`
|
||||||
@@ -216,7 +221,7 @@ async function getMediaDetail() {
|
|||||||
type_name: mediaProps.type,
|
type_name: mediaProps.type,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id) return
|
if (!hasMediaIdentity()) return
|
||||||
|
|
||||||
selectedEpisodeGroup.value = mediaDetail.value.episode_group || ''
|
selectedEpisodeGroup.value = mediaDetail.value.episode_group || ''
|
||||||
if (mediaDetail.value.type === '电视剧' && mediaDetail.value.tmdb_id) {
|
if (mediaDetail.value.type === '电视剧' && mediaDetail.value.tmdb_id) {
|
||||||
@@ -307,12 +312,19 @@ async function checkSubscribe(season: number | null = null) {
|
|||||||
|
|
||||||
// 判断订阅记录是否属于当前媒体
|
// 判断订阅记录是否属于当前媒体
|
||||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||||
|
const mediaId = getMediaId()
|
||||||
|
if (subscribe.media_source && subscribe.media_id) {
|
||||||
|
const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source
|
||||||
|
return mediaId === `${prefix}:${subscribe.media_id}`
|
||||||
|
}
|
||||||
|
if (subscribe.mediaid) return mediaId === subscribe.mediaid
|
||||||
if (mediaDetail.value?.tmdb_id && subscribe.tmdbid) return mediaDetail.value.tmdb_id === subscribe.tmdbid
|
if (mediaDetail.value?.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?.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?.bangumi_id && subscribe.bangumiid) return mediaDetail.value.bangumi_id === subscribe.bangumiid
|
||||||
|
if (mediaDetail.value?.anilist_id && subscribe.anilistid) {
|
||||||
const mediaId = mediaDetail.value?.media_id ? `${mediaDetail.value.mediaid_prefix}:${mediaDetail.value.media_id}` : ''
|
return mediaDetail.value.anilist_id === subscribe.anilistid
|
||||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查所有季的缺失状态
|
// 检查所有季的缺失状态
|
||||||
@@ -777,7 +789,7 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<div
|
<div
|
||||||
v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id"
|
v-if="hasMediaIdentity()"
|
||||||
class="max-w-8xl mx-auto px-4"
|
class="max-w-8xl mx-auto px-4"
|
||||||
:class="{ 'media-detail-transparent': isTransparentTheme }"
|
:class="{ 'media-detail-transparent': isTransparentTheme }"
|
||||||
>
|
>
|
||||||
@@ -831,12 +843,7 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-actions">
|
<div class="media-actions">
|
||||||
<VBtn
|
<VBtn v-if="hasMediaIdentity() && canSearch" variant="tonal" color="primary" class="media-action-button">
|
||||||
v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && canSearch"
|
|
||||||
variant="tonal"
|
|
||||||
color="primary"
|
|
||||||
class="media-action-button"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-magnify" />
|
<VIcon icon="mdi-magnify" />
|
||||||
</template>
|
</template>
|
||||||
@@ -853,7 +860,7 @@ onUnmounted(() => {
|
|||||||
</VMenu>
|
</VMenu>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && canSearch"
|
v-if="hasMediaIdentity() && canSearch"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
color="info"
|
color="info"
|
||||||
class="media-action-button"
|
class="media-action-button"
|
||||||
@@ -865,10 +872,7 @@ onUnmounted(() => {
|
|||||||
{{ t('media.actions.searchSubtitle') }}
|
{{ t('media.actions.searchSubtitle') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="
|
v-if="canSubscribe && (mediaDetail.type === '电影' || hasMediaIdentity())"
|
||||||
canSubscribe &&
|
|
||||||
(mediaDetail.type === '电影' || mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id)
|
|
||||||
"
|
|
||||||
class="media-action-button"
|
class="media-action-button"
|
||||||
:color="getSubscribeColor"
|
:color="getSubscribeColor"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@@ -1324,7 +1328,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</NoDataFound>
|
</NoDataFound>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-else-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
|
v-else-if="!hasMediaIdentity() && isRefreshed"
|
||||||
error-code="500"
|
error-code="500"
|
||||||
:error-title="t('media.error.title')"
|
:error-title="t('media.error.title')"
|
||||||
:error-description="t('media.error.noMediaInfo')"
|
:error-description="t('media.error.noMediaInfo')"
|
||||||
|
|||||||
@@ -207,9 +207,7 @@ describe('MediaCardListView', () => {
|
|||||||
http.get(LIST_URL, ({ request }) => {
|
http.get(LIST_URL, ({ request }) => {
|
||||||
const page = new URL(request.url).searchParams.get('page') ?? ''
|
const page = new URL(request.url).searchParams.get('page') ?? ''
|
||||||
requestedPages.push(page)
|
requestedPages.push(page)
|
||||||
return HttpResponse.json([
|
return HttpResponse.json([createMediaInfo({ title: page === '1' ? '未满屏第一页' : '未满屏第二页' })])
|
||||||
createMediaInfo({ title: page === '1' ? '未满屏第一页' : '未满屏第二页' }),
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -223,6 +221,7 @@ describe('MediaCardListView', () => {
|
|||||||
it('deduplicates the complete composite identity and uses it for stable render keys', async () => {
|
it('deduplicates the complete composite identity and uses it for stable render keys', async () => {
|
||||||
setScrollHeight(() => 900)
|
setScrollHeight(() => 900)
|
||||||
const base = createMediaInfo({
|
const base = createMediaInfo({
|
||||||
|
anilist_id: 4200,
|
||||||
bangumi_id: 'bangumi-42',
|
bangumi_id: 'bangumi-42',
|
||||||
douban_id: 'douban-42',
|
douban_id: 'douban-42',
|
||||||
imdb_id: 'tt0000042',
|
imdb_id: 'tt0000042',
|
||||||
@@ -244,14 +243,11 @@ describe('MediaCardListView', () => {
|
|||||||
{ tvdb_id: 'tvdb-43', title: '不同 tvdb_id' },
|
{ tvdb_id: 'tvdb-43', title: '不同 tvdb_id' },
|
||||||
{ douban_id: 'douban-43', title: '不同 douban_id' },
|
{ douban_id: 'douban-43', title: '不同 douban_id' },
|
||||||
{ bangumi_id: 'bangumi-43', title: '不同 bangumi_id' },
|
{ bangumi_id: 'bangumi-43', title: '不同 bangumi_id' },
|
||||||
|
{ anilist_id: 4300, title: '不同 anilist_id' },
|
||||||
{ mediaid_prefix: 'fixture-v2', title: '不同 mediaid_prefix' },
|
{ mediaid_prefix: 'fixture-v2', title: '不同 mediaid_prefix' },
|
||||||
{ media_id: 'media-43', title: '不同 media_id' },
|
{ media_id: 'media-43', title: '不同 media_id' },
|
||||||
]
|
]
|
||||||
const response = [
|
const response = [base, { ...base, title: '完全重复项' }, ...variants.map(variant => ({ ...base, ...variant }))]
|
||||||
base,
|
|
||||||
{ ...base, title: '完全重复项' },
|
|
||||||
...variants.map(variant => ({ ...base, ...variant })),
|
|
||||||
]
|
|
||||||
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
|
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
|
||||||
|
|
||||||
await renderList()
|
await renderList()
|
||||||
@@ -268,6 +264,7 @@ describe('MediaCardListView', () => {
|
|||||||
'tvdb_id',
|
'tvdb_id',
|
||||||
'douban_id',
|
'douban_id',
|
||||||
'bangumi_id',
|
'bangumi_id',
|
||||||
|
'anilist_id',
|
||||||
'mediaid_prefix',
|
'mediaid_prefix',
|
||||||
'media_id',
|
'media_id',
|
||||||
] as const
|
] as const
|
||||||
@@ -321,9 +318,7 @@ describe('MediaCardListView', () => {
|
|||||||
await renderList()
|
await renderList()
|
||||||
|
|
||||||
expect(await screen.findByRole('article', { name: '媒体卡片 中间签名 C' })).toBeInTheDocument()
|
expect(await screen.findByRole('article', { name: '媒体卡片 中间签名 C' })).toBeInTheDocument()
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByRole('status', { name: '媒体无限列表状态' })).toHaveTextContent('empty'))
|
||||||
expect(screen.getByRole('status', { name: '媒体无限列表状态' })).toHaveTextContent('empty'),
|
|
||||||
)
|
|
||||||
expect(requestedPages).toEqual(['1', '2', '3'])
|
expect(requestedPages).toEqual(['1', '2', '3'])
|
||||||
expect(screen.getAllByRole('article')).toHaveLength(3)
|
expect(screen.getAllByRole('article')).toHaveLength(3)
|
||||||
})
|
})
|
||||||
@@ -334,9 +329,7 @@ describe('MediaCardListView', () => {
|
|||||||
|
|
||||||
await renderList()
|
await renderList()
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.getByRole('status', { name: '媒体无限列表状态' })).toHaveTextContent('empty'))
|
||||||
expect(screen.getByRole('status', { name: '媒体无限列表状态' })).toHaveTextContent('empty'),
|
|
||||||
)
|
|
||||||
expect(screen.queryByText('正在加载媒体列表')).not.toBeInTheDocument()
|
expect(screen.queryByText('正在加载媒体列表')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ async function renderDetail(options: RenderDetailOptions = {}) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const recognized = Boolean(media.tmdb_id || media.douban_id || media.bangumi_id)
|
const recognized = Boolean(media.media_id || media.tmdb_id || media.douban_id || media.bangumi_id || media.anilist_id)
|
||||||
if (recognized && (options.detailStatus ?? 200) < 400) {
|
if (recognized && (options.detailStatus ?? 200) < 400) {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(existsRequest).toHaveBeenCalledOnce()
|
expect(existsRequest).toHaveBeenCalledOnce()
|
||||||
@@ -227,6 +227,7 @@ describe('MediaDetailView detail and actions', () => {
|
|||||||
['TMDB', 'tmdb:8201', createMediaInfo({ tmdb_id: 8201, type: '电影' })],
|
['TMDB', 'tmdb:8201', createMediaInfo({ tmdb_id: 8201, type: '电影' })],
|
||||||
['Douban', 'douban:db-8202', createMediaInfo({ douban_id: 'db-8202', tmdb_id: undefined, 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: '电视剧' })],
|
['Bangumi', 'bangumi:8203', createMediaInfo({ bangumi_id: '8203', tmdb_id: undefined, type: '电视剧' })],
|
||||||
|
['AniList', 'anilist:154587', createMediaInfo({ anilist_id: 154587, tmdb_id: undefined, type: '电视剧' })],
|
||||||
[
|
[
|
||||||
'extension',
|
'extension',
|
||||||
'custom:item-8204',
|
'custom:item-8204',
|
||||||
|
|||||||
@@ -191,13 +191,18 @@ async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') =>
|
|||||||
|
|
||||||
/** 使用媒体来源、稳定 ID 与季号区分热门条目。 */
|
/** 使用媒体来源、稳定 ID 与季号区分热门条目。 */
|
||||||
function getMediaItemKey(item: MediaInfo) {
|
function getMediaItemKey(item: MediaInfo) {
|
||||||
const mediaId = item.tmdb_id
|
const mediaId =
|
||||||
? `tmdb:${item.tmdb_id}`
|
item.media_id && (item.source || item.mediaid_prefix)
|
||||||
: item.douban_id
|
? `${(item.mediaid_prefix || item.source) === 'themoviedb' ? 'tmdb' : item.mediaid_prefix || item.source}:${item.media_id}`
|
||||||
? `douban:${item.douban_id}`
|
: item.tmdb_id
|
||||||
: item.bangumi_id
|
? `tmdb:${item.tmdb_id}`
|
||||||
? `bangumi:${item.bangumi_id}`
|
: item.douban_id
|
||||||
: `${item.mediaid_prefix ?? 'media'}:${item.media_id ?? item.title ?? ''}`
|
? `douban:${item.douban_id}`
|
||||||
|
: item.bangumi_id
|
||||||
|
? `bangumi:${item.bangumi_id}`
|
||||||
|
: item.anilist_id
|
||||||
|
? `anilist:${item.anilist_id}`
|
||||||
|
: `${item.mediaid_prefix ?? 'media'}:${item.title ?? ''}`
|
||||||
|
|
||||||
return `${item.source ?? 'unknown'}:${mediaId}:season:${item.season ?? 'all'}`
|
return `${item.source ?? 'unknown'}:${mediaId}:season:${item.season ?? 'all'}`
|
||||||
}
|
}
|
||||||
@@ -228,12 +233,7 @@ function getMediaItemKey(item: MediaInfo) {
|
|||||||
<VLabel>{{ t('tmdb.genre') }}</VLabel>
|
<VLabel>{{ t('tmdb.genre') }}</VLabel>
|
||||||
</div>
|
</div>
|
||||||
<VChipGroup v-model="filterParams.genre_id">
|
<VChipGroup v-model="filterParams.genre_id">
|
||||||
<VChip
|
<VChip :color="filterParams.genre_id == '' ? 'primary' : ''" filter tile value="">
|
||||||
:color="filterParams.genre_id == '' ? 'primary' : ''"
|
|
||||||
filter
|
|
||||||
tile
|
|
||||||
value=""
|
|
||||||
>
|
|
||||||
{{ t('common.all') }}
|
{{ t('common.all') }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
|
|||||||
@@ -230,12 +230,7 @@ function removeData(id: number) {
|
|||||||
<VLabel>{{ t('tmdb.genre') }}</VLabel>
|
<VLabel>{{ t('tmdb.genre') }}</VLabel>
|
||||||
</div>
|
</div>
|
||||||
<VChipGroup v-model="filterParams.genre_id">
|
<VChipGroup v-model="filterParams.genre_id">
|
||||||
<VChip
|
<VChip :color="filterParams.genre_id == '' ? 'primary' : ''" filter tile value="">
|
||||||
:color="filterParams.genre_id == '' ? 'primary' : ''"
|
|
||||||
filter
|
|
||||||
tile
|
|
||||||
value=""
|
|
||||||
>
|
|
||||||
{{ t('common.all') }}
|
{{ t('common.all') }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
@@ -291,7 +286,11 @@ function removeData(id: number) {
|
|||||||
<ProgressiveCardGrid
|
<ProgressiveCardGrid
|
||||||
v-if="dataList.length > 0"
|
v-if="dataList.length > 0"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
:get-item-key="item => item.id || `${item.tmdbid || item.doubanid || item.name}-${item.share_user}`"
|
:get-item-key="
|
||||||
|
item =>
|
||||||
|
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"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|||||||
@@ -247,15 +247,15 @@ function openReidentifyDialog(item: TorrentCacheItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 执行缓存项重新识别。 */
|
/** 执行缓存项重新识别。 */
|
||||||
async function performReidentify(payload: { doubanId?: string; tmdbId?: number } = {}) {
|
async function performReidentify(payload: { mediaSource?: string; mediaId?: string } = {}) {
|
||||||
if (!currentReidentifyItem.value) return
|
if (!currentReidentifyItem.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
reidentifyDialogController?.updateProps({ loading: true })
|
reidentifyDialogController?.updateProps({ loading: true })
|
||||||
const params: any = {}
|
const params: any = {}
|
||||||
if (payload.tmdbId) params.tmdbid = payload.tmdbId
|
if (payload.mediaSource) params.media_source = payload.mediaSource
|
||||||
if (payload.doubanId) params.doubanid = payload.doubanId
|
if (payload.mediaId) params.media_id = payload.mediaId
|
||||||
|
|
||||||
const res: any = await api.post(
|
const res: any = await api.post(
|
||||||
`torrent/cache/reidentify/${currentReidentifyItem.value.domain}/${currentReidentifyItem.value.hash}`,
|
`torrent/cache/reidentify/${currentReidentifyItem.value.domain}/${currentReidentifyItem.value.hash}`,
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ interface PipelineStep {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
const mediaSourceItems: { title: string; value: MediaDataSource }[] = [
|
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||||
{ title: 'TheMovieDb', value: 'themoviedb' },
|
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||||
{ title: '豆瓣', value: 'douban' },
|
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||||
{ title: 'Bangumi', value: 'bangumi' },
|
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||||
{ title: 'AniList', value: 'anilist' },
|
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||||
]
|
])
|
||||||
|
|
||||||
// 获取后台默认识别数据源,未知值兼容回退到TheMovieDb。
|
// 获取后台默认识别数据源,未知值兼容回退到TheMovieDb。
|
||||||
function getDefaultMediaSource(): MediaDataSource {
|
function getDefaultMediaSource(): MediaDataSource {
|
||||||
const configuredSource = globalSettingsStore.globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
const configuredSource = globalSettingsStore.globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
||||||
return mediaSourceItems.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
return mediaSourceItems.value.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提示
|
// 提示
|
||||||
@@ -84,10 +84,10 @@ const resourceChips = computed(() => {
|
|||||||
const canViewMediaDetail = computed(() =>
|
const canViewMediaDetail = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
mediaInfo.value?.tmdb_id ||
|
mediaInfo.value?.tmdb_id ||
|
||||||
mediaInfo.value?.douban_id ||
|
mediaInfo.value?.douban_id ||
|
||||||
mediaInfo.value?.bangumi_id ||
|
mediaInfo.value?.bangumi_id ||
|
||||||
mediaInfo.value?.anilist_id ||
|
mediaInfo.value?.anilist_id ||
|
||||||
mediaInfo.value?.media_id,
|
mediaInfo.value?.media_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user