fix(subscribe): improve season metadata display (#584)

This commit is contained in:
jxxghp
2026-07-25 08:01:43 +08:00
committed by GitHub
parent 5325d813b4
commit 0ee9e466c3
3 changed files with 159 additions and 38 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.15.0",
"version": "2.15.1",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -2,10 +2,12 @@
import api from '@/api'
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
import { PropType } from 'vue'
import noImage from '@images/no-image.jpeg'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import type { SeasonSubscribeModes, SubscribeMode } from '@/composables/useMediaSubscribe'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { useDisplay } from 'vuetify'
type SubscribeModeOption = {
@@ -22,7 +24,7 @@ type EpisodeGroupOption = {
}
// 国际化
const { t } = useI18n()
const { locale, t } = useI18n()
const { mdAndUp } = useDisplay()
// 定义事件
@@ -264,24 +266,60 @@ function getExistText(season: number) {
else return t('dialog.subscribeSeason.status.exists')
}
// 拼装季图片地址
function getSeasonPoster(posterPath: string) {
if (!posterPath) return props.media?.poster_path
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
// 获取季海报地址,数据源未提供季海报时使用主海报。
function getSeasonPoster(posterPath?: string) {
const resolvedPosterPath = posterPath?.trim() || props.media?.poster_path?.trim()
if (!resolvedPosterPath) return noImage
const posterUrl = resolvedPosterPath.startsWith('/')
? `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${resolvedPosterPath}`
: resolvedPosterPath.replace('/t/p/original/', '/t/p/w500/')
return getDisplayImageUrl(posterUrl, globalSettings.GLOBAL_IMAGE_CACHE)
}
// 将yyyy-mm-dd转换为yyyy年mm月dd日
// 按当前界面语言格式化数据源返回的首播日期。
function formatAirDate(airDate: string) {
if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/'))
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
const dateParts = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(airDate)
if (!dateParts) return airDate
const date = new Date(Number(dateParts[1]), Number(dateParts[2]) - 1, Number(dateParts[3]))
if (Number.isNaN(date.getTime())) return airDate
return new Intl.DateTimeFormat(locale.value, {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date)
}
// 从yyyy-mm-dd中提取年份
function getYear(airDate: string) {
if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/'))
return date.getFullYear()
// 获取本地化季号标题,确保数据源未返回名称时仍有稳定文本。
function getSeasonTitle(item: MediaSeason) {
return t('dialog.subscribeSeason.seasonNumber', { number: item.season_number ?? 0 })
}
// 获取数据源提供的非重复季名称,保留特别篇等来源语义。
function getSeasonName(item: MediaSeason) {
const name = item.name?.trim()
if (!name || item.season_number === undefined) return ''
const number = item.season_number
const normalizedName = name.toLocaleLowerCase().replaceAll(/\s/g, '')
const genericNames = new Set([
`${number}`,
`season${number}`,
`season${String(number).padStart(2, '0')}`,
`s${number}`,
`s${String(number).padStart(2, '0')}`,
])
return genericNames.has(normalizedName) ? '' : name
}
// 获取同时包含季号和来源季名称的海报替代文本。
function getSeasonPosterAlt(item: MediaSeason) {
return [getSeasonTitle(item), getSeasonName(item)].filter(Boolean).join(' - ')
}
// 切换当前剧集组并清空上一组的派生数据。
@@ -518,19 +556,20 @@ onBeforeUnmount(() => {
<VList lines="three" class="subscribe-season-list">
<VListItem
v-for="(item, i) in seasonInfos"
:key="i"
:active="isSeasonSelected(item.season_number || 0)"
:key="item.season_number ?? i"
:active="isSeasonSelected(item.season_number ?? 0)"
rounded="lg"
class="subscribe-season-list-item"
@click="toggleSeasonSelected(item.season_number || 0)"
@click="toggleSeasonSelected(item.season_number ?? 0)"
>
<template #prepend>
<VImg
height="90"
width="60"
:src="getSeasonPoster(item.poster_path || '')"
:src="getSeasonPoster(item.poster_path)"
:alt="getSeasonPosterAlt(item)"
aspect-ratio="2/3"
class="object-cover rounded ring-gray-500 me-3"
class="subscribe-season-poster object-cover rounded ring-gray-500 me-3"
cover
>
<template #placeholder>
@@ -541,49 +580,59 @@ onBeforeUnmount(() => {
</VImg>
</template>
<VListItemTitle>
{{ t('dialog.subscribeSeason.seasonNumber', { number: item.season_number }) }}
<span>{{ getSeasonTitle(item) }}</span>
<span v-if="getSeasonName(item)" class="subscribe-season-name"> · {{ getSeasonName(item) }}</span>
</VListItemTitle>
<VListItemSubtitle class="mt-1 me-2">
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
<VIcon icon="mdi-star" /> {{ t('dialog.subscribeSeason.voteAverage', { score: item.vote_average }) }}
</VChip>
{{ getYear(item.air_date || '') }}
{{ t('dialog.subscribeSeason.episodeCount', { count: item.episode_count }) }}
</VListItemSubtitle>
<VListItemSubtitle>
{{ t('dialog.subscribeSeason.airDate', { date: formatAirDate(item.air_date || '') }) }}
<VListItemSubtitle
v-if="item.vote_average || item.air_date || typeof item.episode_count === 'number'"
class="mt-1 me-2"
>
<div class="subscribe-season-meta">
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
<VIcon icon="mdi-star" />
{{ t('dialog.subscribeSeason.voteAverage', { score: item.vote_average }) }}
</VChip>
<span v-if="item.air_date" class="subscribe-season-meta-item">
<VIcon icon="mdi-calendar-blank-outline" size="x-small" />
{{ t('dialog.subscribeSeason.airDate', { date: formatAirDate(item.air_date) }) }}
</span>
<span v-if="typeof item.episode_count === 'number'" class="subscribe-season-meta-item">
<VIcon icon="mdi-play-box-multiple-outline" size="x-small" />
{{ t('dialog.subscribeSeason.episodeCount', { count: item.episode_count }) }}
</span>
</div>
</VListItemSubtitle>
<VListItemSubtitle>
<VChip
v-if="seasonsNotExisted"
class="mt-2"
size="small"
:color="getExistColor(item.season_number || 0)"
:color="getExistColor(item.season_number ?? 0)"
>
{{ getExistText(item.season_number || 0) }}
{{ getExistText(item.season_number ?? 0) }}
</VChip>
<VChip v-if="isSeasonSubscribed(item.season_number || 0)" class="mt-2 ms-2" size="small" color="error">
<VChip v-if="isSeasonSubscribed(item.season_number ?? 0)" class="mt-2 ms-2" size="small" color="error">
{{ t('media.status.subscribed') }}
</VChip>
</VListItemSubtitle>
<template #append>
<VListItemAction start class="subscribe-season-actions">
<VSwitch
:model-value="isSeasonSelected(item.season_number || 0)"
:model-value="isSeasonSelected(item.season_number ?? 0)"
hide-details
@click.stop
@update:model-value="setSeasonSelected(item.season_number || 0, $event)"
@update:model-value="setSeasonSelected(item.season_number ?? 0, $event)"
/>
<VBtnToggle
v-if="isSeasonSelected(item.season_number || 0)"
:model-value="seasonModes[item.season_number || 0] || 'normal'"
v-if="isSeasonSelected(item.season_number ?? 0)"
:model-value="seasonModes[item.season_number ?? 0] || 'normal'"
density="compact"
divided
mandatory
variant="outlined"
class="subscribe-season-mode-toggle"
@click.stop
@update:model-value="updateSeasonMode(item.season_number || 0, $event)"
@update:model-value="updateSeasonMode(item.season_number ?? 0, $event)"
>
<VBtn
v-for="mode in subscribeModeOptions"
@@ -784,6 +833,29 @@ onBeforeUnmount(() => {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.subscribe-season-poster {
flex: 0 0 3.75rem;
}
.subscribe-season-name {
color: rgba(var(--v-theme-on-surface), 0.68);
font-size: 0.875rem;
font-weight: 400;
}
.subscribe-season-meta {
display: flex;
align-items: center;
gap: 0.375rem 0.75rem;
flex-wrap: wrap;
}
.subscribe-season-meta-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.subscribe-season-list-item :deep(.v-list-item__append) {
align-items: stretch;
align-self: stretch;

View File

@@ -16,6 +16,7 @@ import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
/** 季订阅弹窗测试属性。 */
interface SeasonDialogProps {
defaultSubscribeMode?: SubscribeMode
initialEpisodeGroup?: string
@@ -26,6 +27,7 @@ interface SeasonDialogProps {
subscribedSeasons?: number[]
}
/** 创建可由测试主动完成的异步门闩。 */
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
@@ -34,6 +36,7 @@ function createDeferred() {
return { promise, resolve }
}
/** 创建季订阅测试使用的电视剧媒体。 */
function createTvMedia(overrides: Partial<MediaInfo> = {}) {
return createMediaInfo({
poster_path: '/images/fallback-poster.jpg',
@@ -46,6 +49,7 @@ function createTvMedia(overrides: Partial<MediaInfo> = {}) {
})
}
/** 使用完整应用依赖渲染季订阅弹窗。 */
async function renderDialog(props: SeasonDialogProps = {}) {
const events = {
close: vi.fn(),
@@ -56,6 +60,12 @@ async function renderDialog(props: SeasonDialogProps = {}) {
components: {
VDialogCloseBtn: DialogCloseBtn,
},
stubs: {
VImg: {
props: ['alt', 'src'],
template: '<img :alt="alt" :src="src" />',
},
},
},
initialState: {
globalSettings: {
@@ -77,6 +87,7 @@ async function renderDialog(props: SeasonDialogProps = {}) {
return { ...result, events }
}
/** 获取指定季号对应的列表行。 */
function seasonRow(number: number) {
const title = screen.getByText(`${number}`)
const row = title.closest('.v-list-item')
@@ -84,6 +95,7 @@ function seasonRow(number: number) {
return row as HTMLElement
}
/** 等待弹窗发起的并行请求全部结算。 */
async function settleRequests() {
await flushPromises()
await flushPromises()
@@ -98,7 +110,7 @@ describe('SubscribeSeasonDialog', () => {
it('loads the default TMDB seasons once with exact requests and renders season states', async () => {
const media = createTvMedia({ season: 0, tmdb_id: 7302 })
const seasons = [
createMediaSeason({ air_date: '2020-01-02', episode_count: 3, poster_path: '', season_number: 0 }),
createMediaSeason({ air_date: '2020-01-02', episode_count: 3, name: '', poster_path: '', season_number: 0 }),
createMediaSeason({ air_date: '2021-02-03', episode_count: 4, season_number: 1 }),
createMediaSeason({ air_date: '2022-03-04', episode_count: 5, season_number: 2 }),
]
@@ -127,6 +139,10 @@ describe('SubscribeSeasonDialog', () => {
expect(screen.getByText('缺失')).toBeInTheDocument()
expect(screen.getByText('首播于 2020年1月2日')).toBeInTheDocument()
expect(seasonRow(0)).toHaveTextContent('3 集')
expect(within(seasonRow(0)).getByAltText('第 0 季')).toHaveAttribute(
'src',
'https://image.tmdb.org/t/p/w500/images/fallback-poster.jpg',
)
await settleRequests()
expect(seasonRequests).toHaveLength(1)
@@ -139,6 +155,39 @@ describe('SubscribeSeasonDialog', () => {
expect(missingPayloads[0]).toMatchObject({ episode_group: '', season: 0, tmdb_id: media.tmdb_id })
})
it('preserves source image URLs and hides unavailable season metadata', async () => {
const media = createTvMedia({
poster_path: 'https://images.example.com/main-poster.jpg',
source: 'anilist',
tmdb_id: undefined,
})
server.use(
mediaSeasonsHandler([
createMediaSeason({
air_date: undefined,
episode_count: undefined,
name: '葬送的芙莉莲 第一部分',
poster_path: 'https://images.example.com/original/season-poster.jpg',
season_number: 1,
vote_average: undefined,
}),
]),
mediaNotExistsHandler([]),
)
await renderDialog({ media })
await screen.findByText('第 1 季')
const row = seasonRow(1)
expect(row).toHaveTextContent('葬送的芙莉莲 第一部分')
expect(within(row).getByAltText('第 1 季 - 葬送的芙莉莲 第一部分')).toHaveAttribute(
'src',
'https://images.example.com/original/season-poster.jpg',
)
expect(row).not.toHaveTextContent('undefined')
expect(within(row).queryByText(/首播于/)).not.toBeInTheDocument()
})
it.each([
['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'],