From bad07f7adda02dc3c11121bad48e7d97f59d7fed Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:52:17 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=94=AF=E6=8C=810.5?=
=?UTF-8?q?=E9=9B=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../handler/emby_show_episodes_season_test.go | 61 ++++++++++++++
internal/model/library_media.go | 65 +++++++--------
.../repository/media_repository_upsert.go | 5 ++
internal/service/emby_items_detail.go | 16 +++-
internal/service/emby_items_list.go | 3 +
internal/service/emby_media_sources.go | 16 ++--
internal/service/emby_series.go | 75 ++++++++---------
internal/service/episode_parser.go | 81 ++++++++++++++++++-
internal/service/episode_parser_test.go | 57 +++++++++++++
internal/service/local_availability.go | 2 +-
internal/service/manual_scrape.go | 4 +
internal/service/media_metadata.go | 17 +++-
internal/service/media_series.go | 8 +-
internal/service/media_versions.go | 23 ++++--
internal/service/media_versions_test.go | 50 ++++++++++++
internal/service/nfo.go | 4 +-
internal/service/scanner.go | 13 +--
internal/service/scanner_existing_media.go | 3 +-
internal/service/scanner_local_ingest.go | 63 ++++++++-------
internal/service/scanner_metadata_refresh.go | 27 ++++---
web/src/api/library.ts | 2 +
web/src/components/MetadataEditDialog.tsx | 9 ++-
.../components/PlayerMobileTheaterInfo.tsx | 9 ++-
web/src/components/PlayerPlaylistPanel.tsx | 12 +--
web/src/pages/HomePageSections.tsx | 3 +-
web/src/pages/LibrarySeriesDetailHeader.tsx | 3 +-
web/src/pages/LibrarySeriesEpisodes.tsx | 16 ++--
web/src/pages/PlayerPage.tsx | 4 +-
web/src/pages/useLibrarySeriesSelection.ts | 3 +-
web/src/pages/useMediaDetailPageState.ts | 3 +-
web/src/types/media.ts | 2 +
web/src/utils/episodeNumber.ts | 36 +++++++++
32 files changed, 537 insertions(+), 158 deletions(-)
create mode 100644 web/src/utils/episodeNumber.ts
diff --git a/internal/handler/emby_show_episodes_season_test.go b/internal/handler/emby_show_episodes_season_test.go
index 4c2d24c..8e699f4 100644
--- a/internal/handler/emby_show_episodes_season_test.go
+++ b/internal/handler/emby_show_episodes_season_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"github.com/gin-gonic/gin"
@@ -309,3 +310,63 @@ func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
t.Fatalf("page 3 = %#v, want an empty page so clients stop paging", third)
}
}
+
+// 半集(S01E11.5)在 Emby 侧必须和第 11 集并列返回:一旦被折成一个条目,
+// TotalRecordCount 就会比 Items 多一条,按总数翻页的客户端会一直重发同一页
+// (这就是「第一季分集加载不出来」的根因)。Emby 的 IndexNumber 只能是整数,
+// 所以集号仍占 11,小数通过条目名体现。
+func TestEmbyShowEpisodesKeepsHalfEpisodeVisible(t *testing.T) {
+ router, secret, seriesID := embySeriesRouter(t, []model.Media{
+ {
+ Base: model.Base{ID: "e11"},
+ Title: "三月的狮子",
+ Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.mkv",
+ SeasonNum: 1,
+ EpisodeNum: 11,
+ Container: "mkv",
+ },
+ {
+ Base: model.Base{ID: "e11half"},
+ Title: "三月的狮子",
+ Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.5.mkv",
+ SeasonNum: 1,
+ EpisodeNum: 11,
+ EpisodeFraction: 0.5,
+ Container: "mkv",
+ },
+ {
+ Base: model.Base{ID: "e12"},
+ Title: "三月的狮子",
+ Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E12.mkv",
+ SeasonNum: 1,
+ EpisodeNum: 12,
+ Container: "mkv",
+ },
+ })
+
+ items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?Season=1")
+ if total != 3 || len(items) != 3 {
+ t.Fatalf("TotalRecordCount = %d, items = %d, want 3/3", total, len(items))
+ }
+ wantOrder := []string{"e11", "e11half", "e12"}
+ for i, want := range wantOrder {
+ id, _ := items[i]["Id"].(string)
+ if id != want {
+ t.Fatalf("items[%d] = %q, want %q (order %#v)", i, id, want, items)
+ }
+ }
+ for _, item := range items {
+ sources, _ := item["MediaSources"].([]any)
+ if len(sources) != 1 {
+ t.Fatalf("item %v folded %d sources, want 1", item["Id"], len(sources))
+ }
+ }
+
+ halfName, _ := items[1]["Name"].(string)
+ if !strings.HasPrefix(halfName, "第 11.5 集") {
+ t.Fatalf("half episode name = %q, want prefix 第 11.5 集", halfName)
+ }
+ if idx, _ := items[1]["IndexNumber"].(float64); int(idx) != 11 {
+ t.Fatalf("half episode IndexNumber = %v, want 11 (Emby only supports integers)", items[1]["IndexNumber"])
+ }
+}
diff --git a/internal/model/library_media.go b/internal/model/library_media.go
index a7a558f..e1493de 100644
--- a/internal/model/library_media.go
+++ b/internal/model/library_media.go
@@ -26,38 +26,39 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
- LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
- LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
- SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
- Title string `gorm:"size:255;not null" json:"title"`
- OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
- EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
- Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
- RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
- SizeBytes int64 `json:"size_bytes"`
- DurationSec int `json:"duration_sec"`
- Width int `json:"width"`
- Height int `json:"height"`
- VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
- AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
- Container string `gorm:"size:128" json:"container,omitempty"`
- PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
- BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
- Overview string `gorm:"type:text" json:"overview,omitempty"`
- Rating float32 `json:"rating"`
- Year int `json:"year"`
- ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
- SeasonNum int `json:"season_num"`
- EpisodeNum int `json:"episode_num"`
- ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
- TMDbID int `json:"tmdb_id"`
- BangumiID int `json:"bangumi_id"`
- DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
- TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
- Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
- Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
- Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
- NSFW bool `gorm:"default:false" json:"nsfw"`
+ LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
+ LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
+ SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
+ Title string `gorm:"size:255;not null" json:"title"`
+ OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
+ EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
+ Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
+ RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
+ SizeBytes int64 `json:"size_bytes"`
+ DurationSec int `json:"duration_sec"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
+ AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
+ Container string `gorm:"size:128" json:"container,omitempty"`
+ PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
+ BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
+ Overview string `gorm:"type:text" json:"overview,omitempty"`
+ Rating float32 `json:"rating"`
+ Year int `json:"year"`
+ ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
+ SeasonNum int `json:"season_num"`
+ EpisodeNum int `json:"episode_num"`
+ EpisodeFraction float64 `gorm:"default:0" json:"episode_fraction,omitempty"` // 集号小数部分(S01E11.5 → 0.5);Emby 的 IndexNumber 只能是整数,小数单独存放
+ ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
+ TMDbID int `json:"tmdb_id"`
+ BangumiID int `json:"bangumi_id"`
+ DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
+ TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
+ Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
+ Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
+ Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
+ NSFW bool `gorm:"default:false" json:"nsfw"`
// STRMURL is the indirection target for .strm files: when present the
// stream handler redirects to it instead of opening the local file.
diff --git a/internal/repository/media_repository_upsert.go b/internal/repository/media_repository_upsert.go
index 6ce2c76..b87b15e 100644
--- a/internal/repository/media_repository_upsert.go
+++ b/internal/repository/media_repository_upsert.go
@@ -332,6 +332,11 @@ func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.M
if episodeChanged {
updates["episode_num"] = incoming.EpisodeNum
}
+ // 半集的小数部分独立于整数集号比较:S01E11.5 重命名/新增/删除时都要落库,
+ // 否则「11」与「11.5」会被当成同一集折叠。
+ if existing.EpisodeFraction != incoming.EpisodeFraction {
+ updates["episode_fraction"] = incoming.EpisodeFraction
+ }
if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (seasonChanged || episodeChanged) {
updates["scrape_status"] = "pending"
}
diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go
index 3fbf7ef..16b56d4 100644
--- a/internal/service/emby_items_detail.go
+++ b/internal/service/emby_items_detail.go
@@ -682,9 +682,21 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seasonID = e.seasonIDForMedia(ctx, m)
parentID = seasonID
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
- if episodeTitle != "" {
+ // 半集(S01E11.5)在 Emby 协议里只能占用整数 IndexNumber 11,标题里
+ // 不再带上小数就会和真正的第 11 集完全分不清,所以显式补上集号。
+ halfLabel := ""
+ if m.EpisodeFraction > 0 && m.EpisodeNum > 0 {
+ halfLabel = fmt.Sprintf("第 %s 集", FormatEpisodeNumber(m.EpisodeNum, m.EpisodeFraction))
+ }
+ switch {
+ case episodeTitle != "":
name = episodeTitle
- } else if m.EpisodeNum > 0 {
+ if halfLabel != "" && !strings.HasPrefix(episodeTitle, halfLabel) {
+ name = halfLabel + " · " + episodeTitle
+ }
+ case halfLabel != "":
+ name = halfLabel
+ case m.EpisodeNum > 0:
name = fmt.Sprintf("第 %d 集", m.EpisodeNum)
}
}
diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go
index df4435f..9a74e77 100644
--- a/internal/service/emby_items_list.go
+++ b/internal/service/emby_items_list.go
@@ -153,6 +153,9 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
if rows[i].EpisodeNum != rows[j].EpisodeNum {
return rows[i].EpisodeNum < rows[j].EpisodeNum
}
+ if rows[i].EpisodeFraction != rows[j].EpisodeFraction {
+ return rows[i].EpisodeFraction < rows[j].EpisodeFraction
+ }
return rows[i].CreatedAt.Before(rows[j].CreatedAt)
})
// 先折叠同集的多个版本再统计总数与分页,与 payloadsForMedia 内部保持同一步骤。
diff --git a/internal/service/emby_media_sources.go b/internal/service/emby_media_sources.go
index 2717a7a..932384c 100644
--- a/internal/service/emby_media_sources.go
+++ b/internal/service/emby_media_sources.go
@@ -33,7 +33,7 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ?", libraryIDs).
- Where("season_num = ? AND episode_num = ?", m.SeasonNum, m.EpisodeNum)
+ Where("season_num = ? AND episode_num = ? AND episode_fraction = ?", m.SeasonNum, m.EpisodeNum, m.EpisodeFraction)
if m.TMDbID > 0 {
q = q.Where("tm_db_id = ?", m.TMDbID)
} else if m.BangumiID > 0 {
@@ -110,11 +110,15 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
}
kind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
+ fraction := m.EpisodeFraction
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
- if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
- season, episode = parsedSeason, parsedEpisode
+ if parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(m.Path); parsedEpisode > 0 {
+ season, episode, fraction = parsedSeason, parsedEpisode, parsedFraction
}
}
+ // 集号后缀让 S01E11.5 与 S01E11 属于不同版本组;没有小数时后缀为空,
+ // 键与历史完全一致。
+ fractionSuffix := episodeFractionSuffix(fraction)
kindKey := ""
if kind != "" {
kindKey = "|kind:" + kind
@@ -126,10 +130,10 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
}
if m.TMDbID > 0 {
- return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
+ return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s%s", libraryGroup, m.TMDbID, season, episode, fractionSuffix, kindKey)
}
if m.BangumiID > 0 {
- return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
+ return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s%s", libraryGroup, m.BangumiID, season, episode, fractionSuffix, kindKey)
}
title := strings.ToLower(strings.TrimSpace(m.Title))
if title == "" {
@@ -138,7 +142,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if title == "" {
return ""
}
- return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey)
+ return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s%s", libraryGroup, title, m.Year, season, episode, fractionSuffix, kindKey)
}
func preferMediaVersion(candidate, current model.Media) bool {
diff --git a/internal/service/emby_series.go b/internal/service/emby_series.go
index 7a7b15d..8e8e537 100644
--- a/internal/service/emby_series.go
+++ b/internal/service/emby_series.go
@@ -55,7 +55,7 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
q = q.Where("series_id IS NULL OR series_id = ''")
}
- if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
+ if err := q.Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
@@ -68,20 +68,20 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
return embySeriesGroup{}, false, err
} else if series != nil {
- return embySeriesGroup{
- ID: series.ID,
- LibraryID: series.LibraryID,
- Name: series.Title,
- PosterURL: series.PosterURL,
- BackdropURL: series.BackdropURL,
- Overview: series.Overview,
- Rating: series.Rating,
- Year: series.Year,
- TMDbID: series.TMDbID,
- BangumiID: series.BangumiID,
- CreatedAt: series.CreatedAt,
- DateLastMediaAdded: series.CreatedAt,
- }, true, nil
+ return embySeriesGroup{
+ ID: series.ID,
+ LibraryID: series.LibraryID,
+ Name: series.Title,
+ PosterURL: series.PosterURL,
+ BackdropURL: series.BackdropURL,
+ Overview: series.Overview,
+ Rating: series.Rating,
+ Year: series.Year,
+ TMDbID: series.TMDbID,
+ BangumiID: series.BangumiID,
+ CreatedAt: series.CreatedAt,
+ DateLastMediaAdded: series.CreatedAt,
+ }, true, nil
}
}
return embySeriesGroup{}, false, nil
@@ -108,7 +108,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
- Order("media.season_num asc, media.episode_num asc, media.created_at asc").
+ Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
@@ -167,7 +167,7 @@ func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, us
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
rq = e.applyUserMediaVisibility(ctx, rq, userID)
if err := rq.
- Order("media.season_num asc, media.episode_num asc, media.created_at asc").
+ Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
@@ -194,26 +194,26 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
seriesID := e.seriesIDForMedia(ctx, &row)
group, ok := byID[seriesID]
if !ok {
- group = &embySeriesGroup{
- ID: seriesID,
- LibraryID: row.LibraryID,
- Name: e.seriesNameForMedia(ctx, &row),
- Year: row.Year,
- ReleaseDate: row.ReleaseDate,
- TMDbID: row.TMDbID,
- BangumiID: row.BangumiID,
- CreatedAt: row.CreatedAt,
- DateLastMediaAdded: row.CreatedAt,
- }
- byID[seriesID] = group
- order = append(order, seriesID)
- }
- if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
- group.CreatedAt = row.CreatedAt
- }
- if row.CreatedAt.After(group.DateLastMediaAdded) {
- group.DateLastMediaAdded = row.CreatedAt
+ group = &embySeriesGroup{
+ ID: seriesID,
+ LibraryID: row.LibraryID,
+ Name: e.seriesNameForMedia(ctx, &row),
+ Year: row.Year,
+ ReleaseDate: row.ReleaseDate,
+ TMDbID: row.TMDbID,
+ BangumiID: row.BangumiID,
+ CreatedAt: row.CreatedAt,
+ DateLastMediaAdded: row.CreatedAt,
}
+ byID[seriesID] = group
+ order = append(order, seriesID)
+ }
+ if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
+ group.CreatedAt = row.CreatedAt
+ }
+ if row.CreatedAt.After(group.DateLastMediaAdded) {
+ group.DateLastMediaAdded = row.CreatedAt
+ }
if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) {
group.ReleaseDate = row.ReleaseDate
if row.Year > 0 {
@@ -249,6 +249,9 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
if group.Episodes[i].EpisodeNum != group.Episodes[j].EpisodeNum {
return group.Episodes[i].EpisodeNum < group.Episodes[j].EpisodeNum
}
+ if group.Episodes[i].EpisodeFraction != group.Episodes[j].EpisodeFraction {
+ return group.Episodes[i].EpisodeFraction < group.Episodes[j].EpisodeFraction
+ }
return group.Episodes[i].CreatedAt.Before(group.Episodes[j].CreatedAt)
})
groups = append(groups, group)
diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go
index 733da41..f8bb66d 100644
--- a/internal/service/episode_parser.go
+++ b/internal/service/episode_parser.go
@@ -28,9 +28,13 @@ import (
)
var (
- patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
- patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
- patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
+ patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
+ // patSEnEFraction 捕获 S01E11.5 这类「半集」写法的小数位:只在 SxxEyy 后
+ // 紧跟一个点加一位数字时成立;解析时还要确认该数字后面不是另一个数字,
+ // 否则 S01E11.1080p 的分辨率会被误读成小数集号。
+ patSEnEFraction = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\.(\d)`)
+ patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
+ patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
// patNxE 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留
// (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded,
// 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。
@@ -123,6 +127,62 @@ func ParseEpisode(path string) (season, episode int) {
return 0, 0
}
+// ParseEpisodeParts 在 ParseEpisode 的基础上返回集号的小数部分:
+// S01E11.5 → (1, 11, 0.5)。小数部分用来把「半集」和同季的整集区分开
+// (折叠/身份键、排序、网页端显示集号都带上它),解析不出来时为 0。
+func ParseEpisodeParts(path string) (season, episode int, fraction float64) {
+ name := mediaSidecarBase(path)
+ if name == "" {
+ name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
+ }
+ if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
+ return mustAtoi(m[1]), mustAtoi(m[2]), episodeFractionFromName(name)
+ }
+ season, episode = ParseEpisode(path)
+ return season, episode, 0
+}
+
+// episodeFractionFromName 从 "S01E11.5" 这类名字里取出小数部分(0.5)。
+// 只在 SxxEyy 后紧跟「点 + 一位数字」、且该数字之后不是数字时才成立,
+// 否则 S01E11.1080p / S01E05.10bit 会被读成小数集号。
+func episodeFractionFromName(name string) float64 {
+ lower := strings.ToLower(name)
+ m := patSEnEFraction.FindStringSubmatchIndex(lower)
+ if len(m) != 8 {
+ return 0
+ }
+ digitsStart, digitsEnd := m[6], m[7]
+ if digitsStart < 0 || digitsEnd <= digitsStart {
+ return 0
+ }
+ if digitsEnd < len(lower) && lower[digitsEnd] >= '0' && lower[digitsEnd] <= '9' {
+ return 0
+ }
+ value, err := strconv.ParseFloat("0."+lower[digitsStart:digitsEnd], 64)
+ if err != nil || value <= 0 || value >= 1 {
+ return 0
+ }
+ return value
+}
+
+// episodeFractionSuffix 把小数部分渲染成身份/显示后缀:0.5 → ".5",无小数 → ""。
+// 折叠键用它拼接,所以没有小数的集(绝大多数)产生的键与历史完全一致。
+func episodeFractionSuffix(fraction float64) string {
+ if fraction <= 0 || fraction >= 1 {
+ return ""
+ }
+ formatted := strconv.FormatFloat(fraction, 'f', -1, 64)
+ if !strings.HasPrefix(formatted, "0.") {
+ return ""
+ }
+ return formatted[1:]
+}
+
+// FormatEpisodeNumber 把整数集号与小数部分拼成显示集号:11 → "11",11.5 → "11.5"。
+func FormatEpisodeNumber(episode int, fraction float64) string {
+ return strconv.Itoa(episode) + episodeFractionSuffix(fraction)
+}
+
// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy
// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token
// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false
@@ -215,6 +275,21 @@ func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
return season, episode
}
+// onlineEpisodeFractionFromPath 返回按在线身份规则得到的集号小数部分:
+// S01E11.5 → 0.5。只有在线身份未被重映射(SxxE00 特别篇那种改写)时才给出,
+// 避免把重映射后的特别篇又带上原季的小数。
+func onlineEpisodeFractionFromPath(path string) float64 {
+ season, episode, fraction := ParseEpisodeParts(path)
+ if fraction <= 0 || episode <= 0 {
+ return 0
+ }
+ onlineSeason, onlineEpisode := onlineEpisodeIdentityFromPath(path)
+ if onlineSeason != season || onlineEpisode != episode {
+ return 0
+ }
+ return fraction
+}
+
type episodeRef struct {
Season int
Episode int
diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go
index 8f23156..e9b477e 100644
--- a/internal/service/episode_parser_test.go
+++ b/internal/service/episode_parser_test.go
@@ -152,3 +152,60 @@ func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) {
t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit)
}
}
+
+// S01E11.5 这类「半集」要保留小数部分,才能和真正的第 11 集区分开;
+// 同时不能把 S01E11.1080p / S01E05.10bit 这类分辨率、位深读成小数集号。
+func TestParseEpisodePartsReadsHalfEpisodes(t *testing.T) {
+ cases := []struct {
+ name string
+ path string
+ wantSeason int
+ wantEpisode int
+ wantFraction float64
+ }{
+ {"half episode in anime folder", `动漫/三月的狮子/3月的狮子 S01E11.5.mkv.strm`, 1, 11, 0.5},
+ {"half episode bare", `三月的狮子 S01E07.5.mkv`, 1, 7, 0.5},
+ {"plain episode has no fraction", `三月的狮子 S01E11.mkv`, 1, 11, 0},
+ {"resolution is not a fraction", `Show S01E11.1080p.WEB-DL.mkv`, 1, 11, 0},
+ {"bit depth is not a fraction", `Show S01E05.10bit.mkv`, 1, 5, 0},
+ {"special naming keeps integral episode", `三月的狮子 S00E11.mkv`, 0, 11, 0},
+ {"no episode marker", `movie.mkv`, 0, 0, 0},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ season, episode, fraction := ParseEpisodeParts(tc.path)
+ if season != tc.wantSeason || episode != tc.wantEpisode || fraction != tc.wantFraction {
+ t.Fatalf("ParseEpisodeParts(%q) = (%d, %d, %v), want (%d, %d, %v)",
+ tc.path, season, episode, fraction, tc.wantSeason, tc.wantEpisode, tc.wantFraction)
+ }
+ // ParseEpisode 必须保持原行为(只返回整数季集号)。
+ plainSeason, plainEpisode := ParseEpisode(tc.path)
+ if plainSeason != tc.wantSeason || plainEpisode != tc.wantEpisode {
+ t.Fatalf("ParseEpisode(%q) = (%d, %d), want (%d, %d)",
+ tc.path, plainSeason, plainEpisode, tc.wantSeason, tc.wantEpisode)
+ }
+ })
+ }
+}
+
+func TestOnlineEpisodeFractionFromPath(t *testing.T) {
+ if got := onlineEpisodeFractionFromPath(`动漫/三月的狮子/3月的狮子 S01E11.5.mkv.strm`); got != 0.5 {
+ t.Fatalf("half episode fraction = %v, want 0.5", got)
+ }
+ if got := onlineEpisodeFractionFromPath(`三月的狮子 S01E11.mkv`); got != 0 {
+ t.Fatalf("plain episode fraction = %v, want 0", got)
+ }
+ // S01E00 会被重映射成特别篇 S00E01,此时不能再带上原季的小数规则。
+ if got := onlineEpisodeFractionFromPath(`三月的狮子 S01E00.mkv`); got != 0 {
+ t.Fatalf("remapped special fraction = %v, want 0", got)
+ }
+}
+
+func TestFormatEpisodeNumber(t *testing.T) {
+ if got := FormatEpisodeNumber(11, 0.5); got != "11.5" {
+ t.Fatalf("FormatEpisodeNumber(11, 0.5) = %q, want 11.5", got)
+ }
+ if got := FormatEpisodeNumber(11, 0); got != "11" {
+ t.Fatalf("FormatEpisodeNumber(11, 0) = %q, want 11", got)
+ }
+}
diff --git a/internal/service/local_availability.go b/internal/service/local_availability.go
index fc31e89..98931a6 100644
--- a/internal/service/local_availability.go
+++ b/internal/service/local_availability.go
@@ -53,7 +53,7 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti
var rows []model.Media
if err := repo.DB.WithContext(ctx).
Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like).
- Order("season_num asc, episode_num asc, created_at desc").
+ Order("season_num asc, episode_num asc, episode_fraction asc, created_at desc").
Limit(2000).
Find(&rows).Error; err != nil {
return out
diff --git a/internal/service/manual_scrape.go b/internal/service/manual_scrape.go
index b88a21f..faef155 100644
--- a/internal/service/manual_scrape.go
+++ b/internal/service/manual_scrape.go
@@ -82,10 +82,14 @@ func rebuildManualScrapeIdentity(media *model.Media, lib *model.Library, match *
switch mediaType {
case "tv", "anime", "variety":
media.SeasonNum, media.EpisodeNum = onlineEpisodeIdentityFromPath(media.Path)
+ // 半集(S01E11.5)的小数部分同样按路径重建,否则重新刮削会把 11.5
+ // 变回普通的第 11 集。
+ media.EpisodeFraction = onlineEpisodeFractionFromPath(media.Path)
media.EpisodeTitle = ""
case "movie", "adult":
media.SeasonNum = 0
media.EpisodeNum = 0
+ media.EpisodeFraction = 0
media.EpisodeTitle = ""
}
}
diff --git a/internal/service/media_metadata.go b/internal/service/media_metadata.go
index 3a95a51..5e3742e 100644
--- a/internal/service/media_metadata.go
+++ b/internal/service/media_metadata.go
@@ -19,7 +19,10 @@ type MediaMetadataUpdate struct {
Rating *float32 `json:"rating"`
SeasonNum *int `json:"season_num"`
EpisodeNum *int `json:"episode_num"`
- TMDbID *int `json:"tmdb_id"`
+ // EpisodeFraction 是集号的小数部分(11.5 的 0.5)。单独提供是为了让编辑
+ // 「集」时不会把半集写成整数集号,也不会把整集写成小数。
+ EpisodeFraction *float64 `json:"episode_fraction"`
+ TMDbID *int `json:"tmdb_id"`
BangumiID *int `json:"bangumi_id"`
DoubanID *string `json:"douban_id"`
TheTVDBID *string `json:"thetvdb_id"`
@@ -77,6 +80,9 @@ func (s *MediaService) UpdateMetadata(ctx context.Context, id string, req MediaM
if req.EpisodeNum != nil {
updates["episode_num"] = clampNonNegativeInt(*req.EpisodeNum)
}
+ if req.EpisodeFraction != nil {
+ updates["episode_fraction"] = clampEpisodeFraction(*req.EpisodeFraction)
+ }
if req.TMDbID != nil {
updates["tm_db_id"] = clampNonNegativeInt(*req.TMDbID)
}
@@ -141,6 +147,15 @@ func clampNonNegativeInt(value int) int {
return value
}
+// clampEpisodeFraction 把集号小数部分夹到 [0,1):小于 0 或大于等于 1 都视为
+// 「没有小数」,避免把非法值写进库。
+func clampEpisodeFraction(value float64) float64 {
+ if value <= 0 || value >= 1 {
+ return 0
+ }
+ return value
+}
+
func clampRating(value float32) float32 {
if value < 0 {
return 0
diff --git a/internal/service/media_series.go b/internal/service/media_series.go
index 376116c..f976100 100644
--- a/internal/service/media_series.go
+++ b/internal/service/media_series.go
@@ -287,7 +287,7 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID,
return out, nil
}
-// sortEpisodesForDisplay 与历史行为一致:季/集号升序,再按入库时间兜底。
+// sortEpisodesForDisplay 与历史行为一致:季/集号升序(含半集的小数部分),再按入库时间兜底。
func sortEpisodesForDisplay(out []model.Media) {
sort.SliceStable(out, func(i, j int) bool {
if out[i].SeasonNum != out[j].SeasonNum {
@@ -296,6 +296,9 @@ func sortEpisodesForDisplay(out []model.Media) {
if out[i].EpisodeNum != out[j].EpisodeNum {
return out[i].EpisodeNum < out[j].EpisodeNum
}
+ if out[i].EpisodeFraction != out[j].EpisodeFraction {
+ return out[i].EpisodeFraction < out[j].EpisodeFraction
+ }
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
}
@@ -403,6 +406,9 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
if out[i].EpisodeNum != out[j].EpisodeNum {
return out[i].EpisodeNum < out[j].EpisodeNum
}
+ if out[i].EpisodeFraction != out[j].EpisodeFraction {
+ return out[i].EpisodeFraction < out[j].EpisodeFraction
+ }
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go
index c0bdbb8..6427504 100644
--- a/internal/service/media_versions.go
+++ b/internal/service/media_versions.go
@@ -134,6 +134,9 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
if grouped[i].EpisodeNum != grouped[j].EpisodeNum {
return grouped[i].EpisodeNum < grouped[j].EpisodeNum
}
+ if grouped[i].EpisodeFraction != grouped[j].EpisodeFraction {
+ return grouped[i].EpisodeFraction < grouped[j].EpisodeFraction
+ }
return grouped[i].CreatedAt.Before(grouped[j].CreatedAt)
})
return grouped
@@ -192,7 +195,7 @@ func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
return fmt.Sprintf("adult:%s:%s", libKey, code)
}
-func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
+func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
@@ -200,11 +203,15 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
libKey := mediaVersionLibraryKey(m)
specialKind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
+ fraction := m.EpisodeFraction
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
- if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
- season, episode = parsedSeason, parsedEpisode
+ if parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(m.Path); parsedEpisode > 0 {
+ season, episode, fraction = parsedSeason, parsedEpisode, parsedFraction
}
}
+ // 集号后缀:S01E11.5 记成 "11.5",没有小数的集后缀为空,
+ // 因此绝大多数条目的版本身份键与历史完全一致。
+ episodeTag := fmt.Sprintf("%d:%d%s", season, episode, episodeFractionSuffix(fraction))
if season > 0 || episode > 0 {
kind := specialKind
@@ -213,13 +220,13 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
}
switch {
case m.TMDbID > 0:
- return fmt.Sprintf("episode:%s:tmdb:%d:%d:%d", kind, m.TMDbID, season, episode)
+ return fmt.Sprintf("episode:%s:tmdb:%d:%s", kind, m.TMDbID, episodeTag)
case m.BangumiID > 0:
- return fmt.Sprintf("episode:%s:bangumi:%d:%d:%d", kind, m.BangumiID, season, episode)
+ return fmt.Sprintf("episode:%s:bangumi:%d:%s", kind, m.BangumiID, episodeTag)
case strings.TrimSpace(m.DoubanID) != "":
- return fmt.Sprintf("episode:%s:douban:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), season, episode)
+ return fmt.Sprintf("episode:%s:douban:%s:%s", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), episodeTag)
case strings.TrimSpace(m.TheTVDBID) != "":
- return fmt.Sprintf("episode:%s:thetvdb:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), season, episode)
+ return fmt.Sprintf("episode:%s:thetvdb:%s:%s", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), episodeTag)
}
title := firstNonEmpty(m.OriginalName, m.Title)
if title == "" {
@@ -234,7 +241,7 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
kind,
libKey,
title,
- fmt.Sprintf("%d:%d", season, episode),
+ episodeTag,
}, "|")
}
diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go
index 752b121..2ed6228 100644
--- a/internal/service/media_versions_test.go
+++ b/internal/service/media_versions_test.go
@@ -737,3 +737,53 @@ func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
})
}
}
+
+// S01E11 与 S01E11.5 必须折叠成两条不同记录(既不合并,也不丢集),
+// 且 11.5 排在 11 之后、12 之前。这是「第一季分集加载不出来」那次的根因:
+// 两条同季同集的记录被折成一条,计数与返回条目对不上。
+func TestGroupEpisodeVersionsForDisplayKeepsHalfEpisodeSeparate(t *testing.T) {
+ rows := []model.Media{
+ {Base: model.Base{ID: "ep12"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 12, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E12.mkv"},
+ {Base: model.Base{ID: "ep11"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E11.mkv"},
+ {Base: model.Base{ID: "ep11half"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11, EpisodeFraction: 0.5, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E11.5.mkv"},
+ }
+
+ grouped := GroupEpisodeVersionsForDisplay(rows)
+ if len(grouped) != 3 {
+ t.Fatalf("grouped len = %d, want 3 (11 / 11.5 / 12): %#v", len(grouped), grouped)
+ }
+ wantOrder := []string{"ep11", "ep11half", "ep12"}
+ for i, want := range wantOrder {
+ if grouped[i].ID != want {
+ t.Fatalf("grouped[%d] = %q, want %q (order %#v)", i, grouped[i].ID, want, grouped)
+ }
+ }
+ for _, item := range grouped {
+ if len(item.Versions) != 0 {
+ t.Fatalf("half episode must not fold with the integral one: %#v", grouped)
+ }
+ }
+}
+
+// 版本身份键必须区分 11 与 11.5,同时保持没有小数时的历史键不变。
+func TestMediaVersionGroupKeyDistinguishesHalfEpisode(t *testing.T) {
+ base := model.Media{LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11}
+ plain := base
+ half := base
+ half.EpisodeFraction = 0.5
+
+ plainKey := mediaVersionGroupKey(plain)
+ halfKey := mediaVersionGroupKey(half)
+ if plainKey == "" || halfKey == "" {
+ t.Fatalf("version keys must not be empty: %q %q", plainKey, halfKey)
+ }
+ if plainKey == halfKey {
+ t.Fatalf("11 and 11.5 must have different version keys, both = %q", plainKey)
+ }
+ if !strings.HasSuffix(halfKey, "1:11.5") {
+ t.Fatalf("half episode key = %q, want suffix 1:11.5", halfKey)
+ }
+ if !strings.HasSuffix(plainKey, "1:11") {
+ t.Fatalf("plain episode key = %q, want suffix 1:11", plainKey)
+ }
+}
diff --git a/internal/service/nfo.go b/internal/service/nfo.go
index 23fd291..7d7fdf3 100644
--- a/internal/service/nfo.go
+++ b/internal/service/nfo.go
@@ -142,7 +142,9 @@ func WriteMediaNFO(m *model.Media) (string, error) {
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
title := strings.TrimSpace(m.EpisodeTitle)
if title == "" && m.EpisodeNum > 0 {
- title = fmt.Sprintf("第 %d 集", m.EpisodeNum)
+ // 兜底标题也要带上小数:S01E11.5 导出成「第 11.5 集」,
+ // 否则回写 NFO 后重新入库会把半集写成普通的第 11 集。
+ title = fmt.Sprintf("第 %s 集", FormatEpisodeNumber(m.EpisodeNum, m.EpisodeFraction))
}
if title == "" {
title = strings.TrimSpace(m.Title)
diff --git a/internal/service/scanner.go b/internal/service/scanner.go
index 95c62ba..4a56cf7 100644
--- a/internal/service/scanner.go
+++ b/internal/service/scanner.go
@@ -170,9 +170,12 @@ type existingLocalMedia struct {
TheTVDBID string
SeasonNum int
EpisodeNum int
- Genres string
- Countries string
- Languages string
- NSFW bool
- ScrapeStatus string
+ // EpisodeFraction 是集号的小数部分(S01E11.5 → 0.5);变化同样要触发刷新,
+ // 否则已有行上的「半集」标记不会被写回。
+ EpisodeFraction float64
+ Genres string
+ Countries string
+ Languages string
+ NSFW bool
+ ScrapeStatus string
}
diff --git a/internal/service/scanner_existing_media.go b/internal/service/scanner_existing_media.go
index c76eeb1..0cacfa8 100644
--- a/internal/service/scanner_existing_media.go
+++ b/internal/service/scanner_existing_media.go
@@ -33,7 +33,7 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID)
}
rows, err := query.
- Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status").
+ Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "episode_fraction", "genres", "countries", "languages", "nsfw", "scrape_status").
Rows()
if err != nil {
return nil, err
@@ -74,6 +74,7 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
&row.TheTVDBID,
&row.SeasonNum,
&row.EpisodeNum,
+ &row.EpisodeFraction,
&row.Genres,
&row.Countries,
&row.Languages,
diff --git a/internal/service/scanner_local_ingest.go b/internal/service/scanner_local_ingest.go
index b95700d..6dbc092 100644
--- a/internal/service/scanner_local_ingest.go
+++ b/internal/service/scanner_local_ingest.go
@@ -23,18 +23,19 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, roo
return
}
- parsedSeason, parsedEpisode := ParseEpisode(path)
+ parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(path)
localMeta := s.readLocalScanMetadata(lib, root, path, parsedSeason, parsedEpisode)
media := s.buildLocalScanMedia(localScanMediaInput{
- lib: lib,
- root: root,
- path: path,
- ext: ext,
- fileID: fileID,
- size: size,
- parsedSeason: parsedSeason,
- parsedEpisode: parsedEpisode,
- localMeta: localMeta,
+ lib: lib,
+ root: root,
+ path: path,
+ ext: ext,
+ fileID: fileID,
+ size: size,
+ parsedSeason: parsedSeason,
+ parsedEpisode: parsedEpisode,
+ parsedFraction: parsedFraction,
+ localMeta: localMeta,
})
isNewMedia, skipUnchanged := s.localMediaScanState(localMediaScanStateInput{
ctx: ctx,
@@ -169,15 +170,16 @@ func localMediaProbeDataMissing(existing existingLocalMedia) bool {
}
type localScanMediaInput struct {
- lib *model.Library
- root *model.LibraryRoot
- path string
- ext string
- fileID string
- size int64
- parsedSeason int
- parsedEpisode int
- localMeta *LocalMetadata
+ lib *model.Library
+ root *model.LibraryRoot
+ path string
+ ext string
+ fileID string
+ size int64
+ parsedSeason int
+ parsedEpisode int
+ parsedFraction float64
+ localMeta *LocalMetadata
}
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
@@ -191,17 +193,18 @@ func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Medi
title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year)
media := &model.Media{
- LibraryID: in.lib.ID,
- LibraryRootID: libraryRootID(in.root),
- RelativePath: localRelativePath(in.path, in.root),
- Title: title,
- Year: year,
- Path: in.path,
- SizeBytes: in.size,
- Container: strings.TrimPrefix(in.ext, "."),
- FileID: in.fileID,
- SeasonNum: in.parsedSeason,
- EpisodeNum: in.parsedEpisode,
+ LibraryID: in.lib.ID,
+ LibraryRootID: libraryRootID(in.root),
+ RelativePath: localRelativePath(in.path, in.root),
+ Title: title,
+ Year: year,
+ Path: in.path,
+ SizeBytes: in.size,
+ Container: strings.TrimPrefix(in.ext, "."),
+ FileID: in.fileID,
+ SeasonNum: in.parsedSeason,
+ EpisodeNum: in.parsedEpisode,
+ EpisodeFraction: in.parsedFraction,
}
if in.ext == ".strm" {
media.Container = "strm"
diff --git a/internal/service/scanner_metadata_refresh.go b/internal/service/scanner_metadata_refresh.go
index 31751ab..671d561 100644
--- a/internal/service/scanner_metadata_refresh.go
+++ b/internal/service/scanner_metadata_refresh.go
@@ -17,6 +17,9 @@ type scanDerivedMetadata struct {
TheTVDBID string
SeasonNum int
EpisodeNum int
+ // EpisodeFraction 是集号的小数部分(S01E11.5 → 0.5):它变化时同样要刷新,
+ // 否则已有行上的半集标记永远写不进去。
+ EpisodeFraction float64
}
func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata) bool {
@@ -97,16 +100,17 @@ func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *mod
return true
}
return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{
- Title: existing.Title,
- ScrapeStatus: existing.ScrapeStatus,
- Year: existing.Year,
- ReleaseDate: existing.ReleaseDate,
- TMDbID: existing.TMDbID,
- BangumiID: existing.BangumiID,
- DoubanID: existing.DoubanID,
- TheTVDBID: existing.TheTVDBID,
- SeasonNum: existing.SeasonNum,
- EpisodeNum: existing.EpisodeNum,
+ Title: existing.Title,
+ ScrapeStatus: existing.ScrapeStatus,
+ Year: existing.Year,
+ ReleaseDate: existing.ReleaseDate,
+ TMDbID: existing.TMDbID,
+ BangumiID: existing.BangumiID,
+ DoubanID: existing.DoubanID,
+ TheTVDBID: existing.TheTVDBID,
+ SeasonNum: existing.SeasonNum,
+ EpisodeNum: existing.EpisodeNum,
+ EpisodeFraction: existing.EpisodeFraction,
}, incoming)
}
@@ -128,6 +132,9 @@ func scanDerivedMetadataNeedsRefresh(existing scanDerivedMetadata, incoming *mod
if incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum {
return true
}
+ if (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.EpisodeFraction != incoming.EpisodeFraction {
+ return true
+ }
if incoming.TMDbID > 0 && existing.TMDbID != incoming.TMDbID {
return true
}
diff --git a/web/src/api/library.ts b/web/src/api/library.ts
index 3455aa1..e91fc73 100644
--- a/web/src/api/library.ts
+++ b/web/src/api/library.ts
@@ -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
diff --git a/web/src/components/MetadataEditDialog.tsx b/web/src/components/MetadataEditDialog.tsx
index 5c7a5cd..0fc9d0c 100644
--- a/web/src/components/MetadataEditDialog.tsx
+++ b/web/src/components/MetadataEditDialog.tsx
@@ -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
}
diff --git a/web/src/components/PlayerMobileTheaterInfo.tsx b/web/src/components/PlayerMobileTheaterInfo.tsx
index 82b1605..9dfa54d 100644
--- a/web/src/components/PlayerMobileTheaterInfo.tsx
+++ b/web/src/components/PlayerMobileTheaterInfo.tsx
@@ -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() || '未命名'
}
diff --git a/web/src/components/PlayerPlaylistPanel.tsx b/web/src/components/PlayerPlaylistPanel.tsx
index 63f0e3e..c77cefb 100644
--- a/web/src/components/PlayerPlaylistPanel.tsx
+++ b/web/src/components/PlayerPlaylistPanel.tsx
@@ -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 ? (
) : ep.episode_num > 0 ? (
- ep.episode_num
+ formatEpisodeNumber(ep)
) : (
)}
@@ -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 {
diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx
index 3e17eba..9a5f0fa 100644
--- a/web/src/pages/HomePageSections.tsx
+++ b/web/src/pages/HomePageSections.tsx
@@ -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 = {
movie: ,
@@ -773,7 +774,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
{media.year > 0 && {media.year}}
{media.season_num !== undefined && media.episode_num !== undefined && (
- S{media.season_num}E{media.episode_num}
+ S{media.season_num}E{formatEpisodeNumber(media)}
)}
diff --git a/web/src/pages/LibrarySeriesDetailHeader.tsx b/web/src/pages/LibrarySeriesDetailHeader.tsx
index 07d7afa..0592f82 100644
--- a/web/src/pages/LibrarySeriesDetailHeader.tsx
+++ b/web/src/pages/LibrarySeriesDetailHeader.tsx
@@ -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
}
diff --git a/web/src/pages/LibrarySeriesEpisodes.tsx b/web/src/pages/LibrarySeriesEpisodes.tsx
index f8a2f46..598a32d 100644
--- a/web/src/pages/LibrarySeriesEpisodes.tsx
+++ b/web/src/pages/LibrarySeriesEpisodes.tsx
@@ -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"
/>
) : (
- {ep.episode_num || '—'}
+ {formatEpisodeNumber(ep) || '—'}
)}
{ep.episode_num > 0 && (
- {ep.episode_num}
+ {formatEpisodeNumber(ep)}
)}
@@ -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 || '未命名'
diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx
index ef18612..5ac9020 100644
--- a/web/src/pages/PlayerPage.tsx
+++ b/web/src/pages/PlayerPage.tsx
@@ -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 {
diff --git a/web/src/pages/useLibrarySeriesSelection.ts b/web/src/pages/useLibrarySeriesSelection.ts
index 662abee..a7264f3 100644
--- a/web/src/pages/useLibrarySeriesSelection.ts
+++ b/web/src/pages/useLibrarySeriesSelection.ts
@@ -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))
diff --git a/web/src/pages/useMediaDetailPageState.ts b/web/src/pages/useMediaDetailPageState.ts
index e8c2b48..6c2874a 100644
--- a/web/src/pages/useMediaDetailPageState.ts
+++ b/web/src/pages/useMediaDetailPageState.ts
@@ -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))
diff --git a/web/src/types/media.ts b/web/src/types/media.ts
index 8d6dca5..eb08c74 100644
--- a/web/src/types/media.ts
+++ b/web/src/types/media.ts
@@ -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
diff --git a/web/src/utils/episodeNumber.ts b/web/src/utils/episodeNumber.ts
new file mode 100644
index 0000000..0f3c9fa
--- /dev/null
+++ b/web/src/utils/episodeNumber.ts
@@ -0,0 +1,36 @@
+import type { Media } from '../types'
+
+type EpisodeNumberSource = Pick | 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)
+}