diff --git a/package.json b/package.json index 64efad09..f7ccd42d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moviepilot", - "version": "2.13.9", + "version": "2.13.10", "private": true, "type": "module", "bin": "dist/service.js", diff --git a/src/api/types.ts b/src/api/types.ts index b58251d5..38f131b9 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -49,7 +49,7 @@ export interface Subscribe { // 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数) completed_episode?: number // 附加信息 - note?: string + note?: string | number[] // 状态:N-新建 R-订阅中 P-待定 S-暂停 state: string // 最后更新时间 diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index e56b4a26..ae8eb42d 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -1160,6 +1160,12 @@ export default { }, calendar: { episode: 'Episode {number}', + libraryProgress: 'In library {completed}/{total}', + currentEpisodeInLibrary: 'Current in library', + currentEpisodePartiallyInLibrary: 'Partially in library', + currentEpisodeNotInLibrary: 'Current not in library', + libraryUpdatedAt: 'Updated {time}', + libraryUpdatedAtShort: '{time}', }, storage: { name: 'Name', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 08c7166c..074d328b 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1155,6 +1155,12 @@ export default { }, calendar: { episode: '第{number}集', + libraryProgress: '已入库 {completed}/{total}', + currentEpisodeInLibrary: '本集已入库', + currentEpisodePartiallyInLibrary: '本集部分入库', + currentEpisodeNotInLibrary: '本集未入库', + libraryUpdatedAt: '最近更新 {time}', + libraryUpdatedAtShort: '{time}', }, storage: { name: '名称', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 141a416f..c7b84680 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1155,6 +1155,12 @@ export default { }, calendar: { episode: '第{number}集', + libraryProgress: '已入庫 {completed}/{total}', + currentEpisodeInLibrary: '本集已入庫', + currentEpisodePartiallyInLibrary: '本集部分入庫', + currentEpisodeNotInLibrary: '本集未入庫', + libraryUpdatedAt: '最近更新 {time}', + libraryUpdatedAtShort: '{time}', }, storage: { name: '名稱', diff --git a/src/views/subscribe/FullCalendarView.vue b/src/views/subscribe/FullCalendarView.vue index ae306f14..0db0acd9 100644 --- a/src/views/subscribe/FullCalendarView.vue +++ b/src/views/subscribe/FullCalendarView.vue @@ -7,8 +7,9 @@ import FullCalendar from '@fullcalendar/vue3' import type { Ref } from 'vue' import type { MediaInfo, Subscribe, TmdbEpisode } from '@/api/types' import api from '@/api' -import { formatEp, parseDate } from '@/@core/utils/formatters' +import { formatDateDifference, formatEp, parseDate } from '@/@core/utils/formatters' import { useI18n } from 'vue-i18n' +import { useDisplay } from 'vuetify' import { getCurrentLocale } from '@/plugins/i18n' import { openSharedDialog } from '@/composables/useSharedDialog' @@ -17,6 +18,9 @@ const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/Pr // 国际化 const { t } = useI18n() +// 跟随 Vuetify 断点,MD 及以下使用小屏海报模式。 +const display = useDisplay() + // 加载中 const loading = ref(false) @@ -28,6 +32,25 @@ const currentLocale = getCurrentLocale().split('-')[0] let progressDialogController: ReturnType | null = null +type CalendarLibraryState = 'none' | 'partial' | 'complete' + +interface CalendarEventInfo { + title: string + subtitle: string + start: Date | null + allDay: boolean + posterPath: string | undefined + mediaType: string + len: number + episodeNumbers: number[] + libraryEpisode: number + lackEpisode: number + totalEpisode: number + libraryEpisodeNumbers: number[] + libraryState: CalendarLibraryState + libraryUpdateText: string +} + // 打开订阅日历共享进度弹窗。 function openProgressDialog() { progressDialogController?.close() @@ -62,6 +85,10 @@ const calendarOptions: Ref = ref({ center: 'title', right: 'next', }, + // 日历页需要完整展示每天所有订阅条目,避免折叠成 "+ more" 后隐藏关键信息。 + dayMaxEvents: false, + dayMaxEventRows: false, + eventDisplay: 'block', views: { week: { titleFormat: { day: 'numeric' }, @@ -70,6 +97,116 @@ const calendarOptions: Ref = ref({ events: [], }) +function clampEpisodeCount(value: number, total: number) { + return Math.min(Math.max(value, 0), total) +} + +function getLibraryEpisodeCount(subscribe: Subscribe) { + const totalEpisode = subscribe.total_episode || 0 + if (!totalEpisode) return 0 + + const libraryEpisode = + typeof subscribe.lack_episode === 'number' + ? totalEpisode - subscribe.lack_episode + : subscribe.completed_episode ?? 0 + + return clampEpisodeCount(libraryEpisode, totalEpisode) +} + +function getLackEpisodeCount(subscribe: Subscribe) { + const totalEpisode = subscribe.total_episode || 0 + if (!totalEpisode) return 0 + + return clampEpisodeCount(subscribe.lack_episode ?? totalEpisode - getLibraryEpisodeCount(subscribe), totalEpisode) +} + +function normalizeEpisodeNumbers(value: unknown) { + if (!Array.isArray(value)) return [] + + return value + .map(number => Number(number)) + .filter(number => Number.isFinite(number) && number > 0) +} + +function isEnabledFlag(value: unknown) { + return value === true || value === 1 || value === '1' +} + +function getLibraryEpisodeNumbers(subscribe: Subscribe) { + if (isEnabledFlag(subscribe.best_version)) { + return Object.entries(subscribe.episode_priority || {}) + .filter(([episode, priority]) => Number.isFinite(Number(episode)) && priority === 100) + .map(([episode]) => Number(episode)) + } + + return normalizeEpisodeNumbers(subscribe.note) +} + +function getLibraryState( + episodeNumbers: number[], + libraryEpisode: number, + libraryEpisodeNumbers: number[], +): CalendarLibraryState { + const validEpisodeNumbers = episodeNumbers.filter(number => Number.isFinite(number) && number > 0) + if (!validEpisodeNumbers.length || !libraryEpisode) return 'none' + + // 后端存在具体集号时优先精确匹配;缺少明细时才按聚合进度做保守降级展示。 + const matchedEpisodeCount = libraryEpisodeNumbers.length + ? validEpisodeNumbers.filter(number => libraryEpisodeNumbers.includes(number)).length + : validEpisodeNumbers.filter(number => number <= libraryEpisode).length + if (!matchedEpisodeCount) return 'none' + + return matchedEpisodeCount === validEpisodeNumbers.length ? 'complete' : 'partial' +} + +function buildCalendarEventInfo( + subscribe: Subscribe, + payload: Pick, +): CalendarEventInfo { + const totalEpisode = subscribe.total_episode || 0 + const libraryEpisode = getLibraryEpisodeCount(subscribe) + const lackEpisode = getLackEpisodeCount(subscribe) + const libraryEpisodeNumbers = getLibraryEpisodeNumbers(subscribe) + + return { + title: subscribe.name || '', + allDay: false, + posterPath: subscribe.poster, + mediaType: subscribe.type || '', + totalEpisode, + libraryEpisode, + lackEpisode, + libraryEpisodeNumbers, + libraryState: getLibraryState(payload.episodeNumbers, libraryEpisode, libraryEpisodeNumbers), + libraryUpdateText: libraryEpisode > 0 && subscribe.last_update ? formatDateDifference(subscribe.last_update) : '', + ...payload, + } +} + +function getCalendarEventTooltip(event: any) { + const props = event.extendedProps as CalendarEventInfo + const parts = [event.title] + + if (props.subtitle) parts.push(t('calendar.episode', { number: props.subtitle })) + if (props.totalEpisode) { + parts.push(t('calendar.libraryProgress', { completed: props.libraryEpisode, total: props.totalEpisode })) + } + if (props.libraryUpdateText) parts.push(t('calendar.libraryUpdatedAt', { time: props.libraryUpdateText })) + + return parts.filter(Boolean).join(' · ') +} + +function getLibraryStateText(state: CalendarLibraryState) { + if (state === 'complete') return t('calendar.currentEpisodeInLibrary') + if (state === 'partial') return t('calendar.currentEpisodePartiallyInLibrary') + return t('calendar.currentEpisodeNotInLibrary') +} + +function getLibraryStateIcon(state: CalendarLibraryState) { + if (state === 'none') return 'mdi-minus-circle-outline' + return 'mdi-check-circle-outline' +} + async function eventsHander(subscribe: Subscribe) { // 如果是电影直接返回 if (subscribe.type === '电影') { @@ -78,15 +215,12 @@ async function eventsHander(subscribe: Subscribe) { params: { type_name: subscribe.type }, }) - return { - title: subscribe.name, + return buildCalendarEventInfo(subscribe, { subtitle: '', start: parseDate(movie.release_date || ''), - allDay: false, - posterPath: subscribe.poster, - mediaType: subscribe.type, len: 1, - } + episodeNumbers: [], + }) } else { // 调用API查询集信息 const params = subscribe.episode_group ? { episode_group: subscribe.episode_group } : undefined @@ -95,40 +229,35 @@ async function eventsHander(subscribe: Subscribe) { params ? { params } : undefined, ) - interface EpisodeInfo { - title: string - subtitle: string - start: Date | null - allDay: boolean - posterPath: string | undefined - mediaType: string - len: number - } - interface EpisodesDictionary { - [key: string]: EpisodeInfo + [key: string]: CalendarEventInfo } const dictEpisode: EpisodesDictionary = {} episodes.forEach((episode: TmdbEpisode) => { const air_date = episode.air_date ?? '' + const episodeNumber = episode.episode_number || 0 if (dictEpisode[air_date]) { - dictEpisode[air_date].subtitle += `,${episode.episode_number}` + dictEpisode[air_date].episodeNumbers.push(episodeNumber) dictEpisode[air_date].len++ } else { - dictEpisode[air_date] = { - title: subscribe.name, - subtitle: `${episode.episode_number}`, + dictEpisode[air_date] = buildCalendarEventInfo(subscribe, { + subtitle: '', start: parseDate(episode.air_date || ''), - allDay: false, - posterPath: subscribe.poster, - mediaType: subscribe.type, len: 1, - } + episodeNumbers: [episodeNumber], + }) } }) - for (const key in dictEpisode) - dictEpisode[key].subtitle = formatEp(dictEpisode[key].subtitle.split(',').map(Number)) + for (const key in dictEpisode) { + const episodeNumbers = dictEpisode[key].episodeNumbers.filter(number => Number.isFinite(number) && number > 0) + dictEpisode[key].subtitle = formatEp(episodeNumbers) + dictEpisode[key].libraryState = getLibraryState( + episodeNumbers, + dictEpisode[key].libraryEpisode, + dictEpisode[key].libraryEpisodeNumbers, + ) + } return Object.values(dictEpisode) } @@ -168,46 +297,19 @@ onActivated(() => {