mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
优化支持0.5集
This commit is contained in:
@@ -83,6 +83,8 @@ export interface MediaMetadataUpdate {
|
||||
rating?: number
|
||||
season_num?: number
|
||||
episode_num?: number
|
||||
/** 集号的小数部分(11.5 的 0.5);与 episode_num 一起构成显示集号。 */
|
||||
episode_fraction?: number
|
||||
tmdb_id?: number
|
||||
bangumi_id?: number
|
||||
douban_id?: string
|
||||
|
||||
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI, type MediaMetadataUpdate } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
interface MetadataEditDialogProps {
|
||||
open: boolean
|
||||
@@ -58,7 +59,7 @@ export function MetadataEditDialog({
|
||||
release_date: media.release_date || '',
|
||||
rating: media.rating > 0 ? String(media.rating) : '',
|
||||
season_num: media.season_num > 0 || media.episode_num > 0 ? String(media.season_num || 0) : '',
|
||||
episode_num: media.episode_num > 0 ? String(media.episode_num) : '',
|
||||
episode_num: media.episode_num > 0 ? formatEpisodeNumber(media) : '',
|
||||
tmdb_id: media.tmdb_id > 0 ? String(media.tmdb_id) : '',
|
||||
bangumi_id: media.bangumi_id > 0 ? String(media.bangumi_id) : '',
|
||||
douban_id: media.douban_id || '',
|
||||
@@ -105,7 +106,11 @@ export function MetadataEditDialog({
|
||||
if (!isSeries) {
|
||||
payload.original_name = form.original_name
|
||||
payload.season_num = Math.trunc(toNumber(form.season_num))
|
||||
payload.episode_num = Math.trunc(toNumber(form.episode_num))
|
||||
// 「集」输入框接受 11.5 这种半集写法:整数部分进 episode_num,
|
||||
// 小数部分单独进 episode_fraction,避免保存后变成整集或残留小数。
|
||||
const episodeValue = toNumber(form.episode_num)
|
||||
payload.episode_num = Math.trunc(episodeValue)
|
||||
payload.episode_fraction = Number((episodeValue - Math.trunc(episodeValue)).toFixed(3))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { ChevronDown, Layers, ListVideo } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
|
||||
|
||||
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
|
||||
@@ -38,15 +39,17 @@ type PlayerMobileTheaterInfoProps = {
|
||||
}
|
||||
|
||||
function episodeShortLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) return `${ep.episode_num}`
|
||||
const number = formatEpisodeNumber(ep)
|
||||
if (number) return number
|
||||
const title = ep.episode_title?.trim() || ep.title?.trim() || ''
|
||||
return title ? title.slice(0, 4) : '·'
|
||||
}
|
||||
|
||||
function episodeFullLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) {
|
||||
const label = formatEpisodeLabel(ep)
|
||||
if (label) {
|
||||
const title = ep.episode_title?.trim()
|
||||
return title && title !== ep.title?.trim() ? `第 ${ep.episode_num} 集 · ${title}` : `第 ${ep.episode_num} 集`
|
||||
return title && title !== ep.title?.trim() ? `${label} · ${title}` : label
|
||||
}
|
||||
return ep.episode_title?.trim() || ep.title?.trim() || '未命名'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber, episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import {
|
||||
isStrmMedia,
|
||||
@@ -72,7 +73,7 @@ export function PlayerPlaylistPanel({
|
||||
seasonsMap.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasonsMap) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasonsMap.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
@@ -118,7 +119,7 @@ export function PlayerPlaylistPanel({
|
||||
? listToDisplay.filter((ep) => {
|
||||
const query = filterText.trim().toLowerCase()
|
||||
const title = (ep.episode_title || ep.title || '').toLowerCase()
|
||||
const epNum = String(ep.episode_num)
|
||||
const epNum = formatEpisodeNumber(ep)
|
||||
return title.includes(query) || epNum === query || `e${epNum}`.includes(query) || `第${epNum}集`.includes(query)
|
||||
})
|
||||
: listToDisplay
|
||||
@@ -293,7 +294,7 @@ export function PlayerPlaylistPanel({
|
||||
const durationText =
|
||||
ep.duration_sec > 0 ? `${Math.round(ep.duration_sec / 60)} 分钟` : ''
|
||||
const cellTitle = [
|
||||
ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : '',
|
||||
formatEpisodeLabel(ep),
|
||||
displayTitle,
|
||||
durationText,
|
||||
versions.length > 0 ? `${versions.length} 个版本` : '',
|
||||
@@ -316,7 +317,7 @@ export function PlayerPlaylistPanel({
|
||||
{isPlaying ? (
|
||||
<Play size={12} className="fill-current" />
|
||||
) : ep.episode_num > 0 ? (
|
||||
ep.episode_num
|
||||
formatEpisodeNumber(ep)
|
||||
) : (
|
||||
<Film size={13} className="text-white/45" />
|
||||
)}
|
||||
@@ -429,7 +430,8 @@ function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
return episodeLabel || mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { getLibraryArtworks } from './librariesPageModel'
|
||||
import { formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={18} />,
|
||||
@@ -773,7 +774,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
{media.year > 0 && <span>{media.year}</span>}
|
||||
{media.season_num !== undefined && media.episode_num !== undefined && (
|
||||
<span>
|
||||
S{media.season_num}E{media.episode_num}
|
||||
S{media.season_num}E{formatEpisodeNumber(media)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
|
||||
type LibrarySeriesDetailHeaderProps = {
|
||||
series: SeriesCard
|
||||
@@ -152,7 +153,7 @@ function firstPlayableEpisode(episodes: Media[]): Media | null {
|
||||
const sorted = [...episodes]
|
||||
sorted.sort((a, b) =>
|
||||
(a.season_num || 0) - (b.season_num || 0)
|
||||
|| (a.episode_num || 0) - (b.episode_num || 0),
|
||||
|| episodeNumberValue(a) - episodeNumberValue(b),
|
||||
)
|
||||
return sorted[0] ?? null
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
THEATRICAL_SEASON,
|
||||
} from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
type SeasonGroup = {
|
||||
season: number
|
||||
@@ -95,14 +96,14 @@ export function LibrarySeriesEpisodes({
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-brand-600 font-bold text-sm">{ep.episode_num || '—'}</span>
|
||||
<span className="text-brand-600 font-bold text-sm">{formatEpisodeNumber(ep) || '—'}</span>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/25 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Play size={15} className="fill-white text-white drop-shadow-sm" />
|
||||
</div>
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="absolute bottom-0 right-0 rounded-tl bg-black/75 px-1 py-0.5 text-[9px] font-bold leading-none text-white backdrop-blur-[2px]">
|
||||
{ep.episode_num}
|
||||
{formatEpisodeNumber(ep)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,15 +154,18 @@ function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (ep.episode_num > 0) {
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
if (episodeLabel) {
|
||||
if (!mainTitle) {
|
||||
return `第 ${ep.episode_num} 集`
|
||||
return episodeLabel
|
||||
}
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${ep.episode_num}\\s*集|ep?\\.?\\s*0*${ep.episode_num}\\b)`, 'i')
|
||||
// 标题里已经以「第 11 集 / 第 11.5 集 / ep 11」开头时直接用它,避免重复前缀。
|
||||
const episodeNumber = formatEpisodeNumber(ep).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${episodeNumber}\\s*集|ep?\\.?\\s*0*${episodeNumber}\\b)`, 'i')
|
||||
if (prefixRegex.test(mainTitle)) {
|
||||
return mainTitle
|
||||
}
|
||||
return `第 ${ep.episode_num} 集 · ${mainTitle}`
|
||||
return `${episodeLabel} · ${mainTitle}`
|
||||
}
|
||||
|
||||
return mainTitle || '未命名'
|
||||
|
||||
@@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { formatEpisodeLabel } from '../utils/episodeNumber'
|
||||
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
|
||||
@@ -2347,7 +2348,8 @@ function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
return episodeLabel || mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SeriesCard,
|
||||
} from '../utils/groupSeries'
|
||||
import { resolveSeriesCardByKey } from '../utils/seriesCardResolve'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
|
||||
type SeasonEpisodes = {
|
||||
season: number
|
||||
@@ -72,7 +73,7 @@ export function useLibrarySeriesSelection({
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Media } from '../types'
|
||||
import { seasonSortOrder } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
type MediaDetailLocationState = { from?: string } | null
|
||||
@@ -81,7 +82,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Media {
|
||||
release_date?: string
|
||||
season_num: number
|
||||
episode_num: number
|
||||
/** 集号的小数部分(S01E11.5 → 0.5);没有小数时缺省。 */
|
||||
episode_fraction?: number
|
||||
scrape_status: string
|
||||
tmdb_id: number
|
||||
bangumi_id: number
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Media } from '../types'
|
||||
|
||||
type EpisodeNumberSource = Pick<Media, 'episode_num' | 'episode_fraction'> | null | undefined
|
||||
|
||||
/**
|
||||
* 含小数的集号:S01E11.5 → 11.5。没有小数(绝大多数集)时就是 episode_num。
|
||||
*
|
||||
* 后端把整数集号和它的小数部分分开存(Emby 协议的 IndexNumber 只能是整数),
|
||||
* 网页端所有「显示集号 / 按集排序」的地方都应该用这个函数,而不是直接读
|
||||
* episode_num,否则 11 与 11.5 会显示、排序成同一集。
|
||||
*/
|
||||
export function episodeNumberValue(media: EpisodeNumberSource): number {
|
||||
const base = media?.episode_num ?? 0
|
||||
const fraction = media?.episode_fraction ?? 0
|
||||
if (!(fraction > 0 && fraction < 1)) return base
|
||||
return base + fraction
|
||||
}
|
||||
|
||||
/** 显示用集号文本:11 → "11",11.5 → "11.5";没有集号时返回空串。 */
|
||||
export function formatEpisodeNumber(media: EpisodeNumberSource): string {
|
||||
const value = episodeNumberValue(media)
|
||||
return value > 0 ? String(value) : ''
|
||||
}
|
||||
|
||||
/** "第 11 集" / "第 11.5 集";没有集号时返回空串。 */
|
||||
export function formatEpisodeLabel(media: EpisodeNumberSource): string {
|
||||
const value = formatEpisodeNumber(media)
|
||||
return value ? `第 ${value} 集` : ''
|
||||
}
|
||||
|
||||
/** 按 (季, 集, 小数部分) 升序比较,供剧集列表排序复用。 */
|
||||
export function compareEpisodeOrder(a: Media, b: Media): number {
|
||||
const seasonDiff = (a.season_num ?? 0) - (b.season_num ?? 0)
|
||||
if (seasonDiff !== 0) return seasonDiff
|
||||
return episodeNumberValue(a) - episodeNumberValue(b)
|
||||
}
|
||||
Reference in New Issue
Block a user