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) +}