mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bad07f7add | |||
| cb539afd2a |
@@ -231,12 +231,24 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 不一致时把结果过滤成空集。
|
||||
seasonIndex = nil
|
||||
}
|
||||
// Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于
|
||||
// TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远
|
||||
// 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500"))
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
if startIndex < 0 {
|
||||
startIndex = 0
|
||||
}
|
||||
params := service.ItemsParams{
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: parentID,
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
Limit: 500,
|
||||
Limit: limit,
|
||||
StartIndex: startIndex,
|
||||
SeasonIndex: seasonIndex,
|
||||
}
|
||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -21,6 +22,31 @@ import (
|
||||
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
|
||||
// real clients send.
|
||||
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
||||
t.Helper()
|
||||
return embySeriesRouter(t, []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "s1e1"},
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "s2e1"},
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// embySeriesRouter builds an Emby-compatible router with a single tv library and
|
||||
// one series made of the given episode rows. Callers omit LibraryID: it is filled
|
||||
// in here once the library exists.
|
||||
func embySeriesRouter(t *testing.T, rows []model.Media) (*gin.Engine, string, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -48,27 +74,9 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for _, m := range []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "s1e1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "s2e1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
} {
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
for i := range rows {
|
||||
rows[i].LibraryID = lib.ID
|
||||
if err := db.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +112,34 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
||||
return router, secret, seriesID
|
||||
}
|
||||
|
||||
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
|
||||
func fetchSeasonID(t *testing.T, router *gin.Engine, secret, seriesID string, index int) string {
|
||||
t.Helper()
|
||||
path := "/Shows/" + seriesID + "/Seasons"
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var seasons struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
|
||||
t.Fatalf("decode seasons: %v", err)
|
||||
}
|
||||
for _, s := range seasons.Items {
|
||||
if got, ok := s["IndexNumber"].(float64); ok && int(got) == index {
|
||||
if id, _ := s["Id"].(string); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("season %d not found in %#v", index, seasons.Items)
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchEpisodeItems(t *testing.T, router *gin.Engine, secret, path string) ([]map[string]any, int) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
@@ -120,12 +155,18 @@ func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]s
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
ids := make([]string, 0, len(payload.Items))
|
||||
for _, item := range payload.Items {
|
||||
return payload.Items, int(payload.TotalRecordCount)
|
||||
}
|
||||
|
||||
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
|
||||
t.Helper()
|
||||
items, total := fetchEpisodeItems(t, router, secret, path)
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
id, _ := item["Id"].(string)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, int(payload.TotalRecordCount)
|
||||
return ids, total
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
|
||||
@@ -174,29 +215,7 @@ func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
|
||||
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
|
||||
router, secret, seriesID := embySeasonEpisodesRouter(t)
|
||||
|
||||
seasonsPath := "/Shows/" + seriesID + "/Seasons"
|
||||
req := httptest.NewRequest(http.MethodGet, seasonsPath, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var seasons struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
|
||||
t.Fatalf("decode seasons: %v", err)
|
||||
}
|
||||
var season1ID string
|
||||
for _, s := range seasons.Items {
|
||||
if index, ok := s["IndexNumber"].(float64); ok && int(index) == 1 {
|
||||
season1ID, _ = s["Id"].(string)
|
||||
}
|
||||
}
|
||||
if season1ID == "" {
|
||||
t.Fatalf("season 1 not found in %#v", seasons.Items)
|
||||
}
|
||||
season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||
|
||||
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
|
||||
if len(ids) != 1 || ids[0] != "s1e1" {
|
||||
@@ -209,3 +228,145 @@ func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
|
||||
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// foldedDuplicateSeriesRows models a season where two files resolve to the same
|
||||
// episode number — 3月的狮子 S01E11 together with S01E11.5, which the episode
|
||||
// parser reads as S01E11 and stores as a second row for season 1 episode 2 here.
|
||||
func foldedDuplicateSeriesRows() []model.Media {
|
||||
return []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "e1"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e2"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e2half"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.5.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
Container: "mkv",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent is the regression test
|
||||
// for the "第一季分集加载不出来" lock-up: two rows sharing one season/episode are
|
||||
// folded into a single Emby item with two MediaSources. TotalRecordCount used to
|
||||
// be computed before that fold, so clients that page until they have
|
||||
// TotalRecordCount items kept re-requesting the same page and never rendered the
|
||||
// season.
|
||||
func TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent(t *testing.T) {
|
||||
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
|
||||
seasonID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||
|
||||
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+seasonID)
|
||||
if total != len(items) {
|
||||
t.Fatalf("TotalRecordCount = %d but %d items returned, want them equal", total, len(items))
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("TotalRecordCount = %d, want 2 distinct episodes", total)
|
||||
}
|
||||
// The folded episode must still carry both files so clients can switch version.
|
||||
versioned := 0
|
||||
for _, item := range items {
|
||||
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 1 {
|
||||
versioned++
|
||||
}
|
||||
}
|
||||
if versioned != 1 {
|
||||
t.Fatalf("items with multiple MediaSources = %d, want 1 (%#v)", versioned, items)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesPagingAdvances pins that the client's StartIndex/Limit reach
|
||||
// the episode list. Ignoring them made every page identical, so a client paging
|
||||
// until it has TotalRecordCount items could never finish loading the season.
|
||||
func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
|
||||
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
|
||||
base := "/Shows/" + seriesID + "/Episodes?Season=1"
|
||||
|
||||
first, total := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=0&Limit=1")
|
||||
if total != 2 || len(first) != 1 {
|
||||
t.Fatalf("page 1 = %#v (total=%d), want one item of two", first, total)
|
||||
}
|
||||
second, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=1&Limit=1")
|
||||
if len(second) != 1 || second[0] == first[0] {
|
||||
t.Fatalf("page 2 = %#v, want the remaining episode (page 1 = %#v)", second, first)
|
||||
}
|
||||
third, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=2&Limit=1")
|
||||
if len(third) != 0 {
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type Media struct {
|
||||
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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,15 @@ 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 内部保持同一步骤。
|
||||
// 若按未折叠的行数报 TotalRecordCount(例如 S01E11 与 S01E11.5 两行折叠成一条),
|
||||
// 客户端会认为还有一条没取到,反复请求下一页 —— 分集列表就会一直加载不出来。
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
total := len(rows)
|
||||
items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -29,6 +29,10 @@ import (
|
||||
|
||||
var (
|
||||
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 这类季集写法的捕获组,同时用于从标题里剔除季集残留
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,7 +68,11 @@ func applyLocalEpisodeMetadata(m *model.Media, local *LocalMetadata) {
|
||||
if local.EpisodeTitle != "" {
|
||||
m.EpisodeTitle = local.EpisodeTitle
|
||||
}
|
||||
if local.SeasonNum > 0 || local.EpisodeNum > 0 {
|
||||
// tvshow.nfo 里常带 scraper 写的 <season>-1</season> / <episode>-1</episode>
|
||||
// 哨兵(表示「整剧级、不适用」)。负季号不是有效季号:一旦让它覆盖,该集会被
|
||||
// 写成 -1,网页端按 seasonLabel(-1) 显示成「剧场版」。因此负值一律忽略,
|
||||
// 保留文件名解析出来的季号(S00Exx 会被解析成第 0 季 = 特别篇)。
|
||||
if local.SeasonNum >= 0 && (local.SeasonNum > 0 || local.EpisodeNum > 0) {
|
||||
m.SeasonNum = local.SeasonNum
|
||||
}
|
||||
if local.EpisodeNum > 0 {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
|
||||
@@ -436,3 +438,48 @@ func TestReadLocalMetadataFindsLegacyImgPoster(t *testing.T) {
|
||||
t.Fatalf("PosterURL = %q, want legacy .img poster %q", got.PosterURL, poster)
|
||||
}
|
||||
}
|
||||
|
||||
// tvshow.nfo 里 scraper 写的 <season>-1</season>(整剧级"不适用"哨兵)不能覆盖
|
||||
// 文件名解析出的季号:一旦覆盖,该集会被写成 -1,网页端按 seasonLabel(-1) 显示成
|
||||
// 「剧场版」,Emby 端也只能落进特别篇。S00Exx 这类特别篇命名应保留第 0 季。
|
||||
func TestApplyLocalEpisodeMetadataIgnoresNegativeSeasonSentinel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
parsed model.Media
|
||||
local LocalMetadata
|
||||
wantSeason int
|
||||
wantEpisode int
|
||||
}{
|
||||
{
|
||||
name: "show level sentinel keeps parsed season",
|
||||
parsed: model.Media{SeasonNum: 2, EpisodeNum: 5},
|
||||
local: LocalMetadata{SeasonNum: -1, EpisodeNum: 5, HasNFO: true},
|
||||
wantSeason: 2,
|
||||
wantEpisode: 5,
|
||||
},
|
||||
{
|
||||
name: "special naming keeps season zero",
|
||||
parsed: model.Media{SeasonNum: 0, EpisodeNum: 11},
|
||||
local: LocalMetadata{SeasonNum: -1, EpisodeNum: 11, HasNFO: true},
|
||||
wantSeason: 0,
|
||||
wantEpisode: 11,
|
||||
},
|
||||
{
|
||||
name: "positive episode season still applies",
|
||||
parsed: model.Media{SeasonNum: 0, EpisodeNum: 11},
|
||||
local: LocalMetadata{SeasonNum: 1, EpisodeNum: 11, HasNFO: true},
|
||||
wantSeason: 1,
|
||||
wantEpisode: 11,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
media := tc.parsed
|
||||
local := tc.local
|
||||
applyLocalMetadata(&media, &local)
|
||||
if media.SeasonNum != tc.wantSeason || media.EpisodeNum != tc.wantEpisode {
|
||||
t.Fatalf("season/episode = %d/%d, want %d/%d", media.SeasonNum, media.EpisodeNum, tc.wantSeason, tc.wantEpisode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ type MediaMetadataUpdate struct {
|
||||
Rating *float32 `json:"rating"`
|
||||
SeasonNum *int `json:"season_num"`
|
||||
EpisodeNum *int `json:"episode_num"`
|
||||
// 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"`
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}, "|")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -170,6 +170,9 @@ type existingLocalMedia struct {
|
||||
TheTVDBID string
|
||||
SeasonNum int
|
||||
EpisodeNum int
|
||||
// EpisodeFraction 是集号的小数部分(S01E11.5 → 0.5);变化同样要触发刷新,
|
||||
// 否则已有行上的「半集」标记不会被写回。
|
||||
EpisodeFraction float64
|
||||
Genres string
|
||||
Countries string
|
||||
Languages string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,7 +23,7 @@ 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,
|
||||
@@ -34,6 +34,7 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, roo
|
||||
size: size,
|
||||
parsedSeason: parsedSeason,
|
||||
parsedEpisode: parsedEpisode,
|
||||
parsedFraction: parsedFraction,
|
||||
localMeta: localMeta,
|
||||
})
|
||||
isNewMedia, skipUnchanged := s.localMediaScanState(localMediaScanStateInput{
|
||||
@@ -177,6 +178,7 @@ type localScanMediaInput struct {
|
||||
size int64
|
||||
parsedSeason int
|
||||
parsedEpisode int
|
||||
parsedFraction float64
|
||||
localMeta *LocalMetadata
|
||||
}
|
||||
|
||||
@@ -202,6 +204,7 @@ func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Medi
|
||||
FileID: in.fileID,
|
||||
SeasonNum: in.parsedSeason,
|
||||
EpisodeNum: in.parsedEpisode,
|
||||
EpisodeFraction: in.parsedFraction,
|
||||
}
|
||||
if in.ext == ".strm" {
|
||||
media.Container = "strm"
|
||||
|
||||
@@ -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 {
|
||||
@@ -107,6 +110,7 @@ func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *mod
|
||||
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
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface MediaMetadataUpdate {
|
||||
rating?: number
|
||||
season_num?: number
|
||||
episode_num?: number
|
||||
/** 集号的小数部分(11.5 的 0.5);与 episode_num 一起构成显示集号。 */
|
||||
episode_fraction?: number
|
||||
tmdb_id?: number
|
||||
bangumi_id?: number
|
||||
douban_id?: string
|
||||
|
||||
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI, type MediaMetadataUpdate } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
interface MetadataEditDialogProps {
|
||||
open: boolean
|
||||
@@ -58,7 +59,7 @@ export function MetadataEditDialog({
|
||||
release_date: media.release_date || '',
|
||||
rating: media.rating > 0 ? String(media.rating) : '',
|
||||
season_num: media.season_num > 0 || media.episode_num > 0 ? String(media.season_num || 0) : '',
|
||||
episode_num: media.episode_num > 0 ? String(media.episode_num) : '',
|
||||
episode_num: media.episode_num > 0 ? formatEpisodeNumber(media) : '',
|
||||
tmdb_id: media.tmdb_id > 0 ? String(media.tmdb_id) : '',
|
||||
bangumi_id: media.bangumi_id > 0 ? String(media.bangumi_id) : '',
|
||||
douban_id: media.douban_id || '',
|
||||
@@ -105,7 +106,11 @@ export function MetadataEditDialog({
|
||||
if (!isSeries) {
|
||||
payload.original_name = form.original_name
|
||||
payload.season_num = Math.trunc(toNumber(form.season_num))
|
||||
payload.episode_num = Math.trunc(toNumber(form.episode_num))
|
||||
// 「集」输入框接受 11.5 这种半集写法:整数部分进 episode_num,
|
||||
// 小数部分单独进 episode_fraction,避免保存后变成整集或残留小数。
|
||||
const episodeValue = toNumber(form.episode_num)
|
||||
payload.episode_num = Math.trunc(episodeValue)
|
||||
payload.episode_fraction = Number((episodeValue - Math.trunc(episodeValue)).toFixed(3))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { ChevronDown, Layers, ListVideo } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
|
||||
|
||||
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
|
||||
@@ -38,15 +39,17 @@ type PlayerMobileTheaterInfoProps = {
|
||||
}
|
||||
|
||||
function episodeShortLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) return `${ep.episode_num}`
|
||||
const number = formatEpisodeNumber(ep)
|
||||
if (number) return number
|
||||
const title = ep.episode_title?.trim() || ep.title?.trim() || ''
|
||||
return title ? title.slice(0, 4) : '·'
|
||||
}
|
||||
|
||||
function episodeFullLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) {
|
||||
const label = formatEpisodeLabel(ep)
|
||||
if (label) {
|
||||
const title = ep.episode_title?.trim()
|
||||
return title && title !== ep.title?.trim() ? `第 ${ep.episode_num} 集 · ${title}` : `第 ${ep.episode_num} 集`
|
||||
return title && title !== ep.title?.trim() ? `${label} · ${title}` : label
|
||||
}
|
||||
return ep.episode_title?.trim() || ep.title?.trim() || '未命名'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Cloud, Film, Layers, Play, Search, X } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber, episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import {
|
||||
isStrmMedia,
|
||||
@@ -72,7 +73,7 @@ export function PlayerPlaylistPanel({
|
||||
seasonsMap.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasonsMap) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasonsMap.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
@@ -118,7 +119,7 @@ export function PlayerPlaylistPanel({
|
||||
? listToDisplay.filter((ep) => {
|
||||
const query = filterText.trim().toLowerCase()
|
||||
const title = (ep.episode_title || ep.title || '').toLowerCase()
|
||||
const epNum = String(ep.episode_num)
|
||||
const epNum = formatEpisodeNumber(ep)
|
||||
return title.includes(query) || epNum === query || `e${epNum}`.includes(query) || `第${epNum}集`.includes(query)
|
||||
})
|
||||
: listToDisplay
|
||||
@@ -293,7 +294,7 @@ export function PlayerPlaylistPanel({
|
||||
const durationText =
|
||||
ep.duration_sec > 0 ? `${Math.round(ep.duration_sec / 60)} 分钟` : ''
|
||||
const cellTitle = [
|
||||
ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : '',
|
||||
formatEpisodeLabel(ep),
|
||||
displayTitle,
|
||||
durationText,
|
||||
versions.length > 0 ? `${versions.length} 个版本` : '',
|
||||
@@ -316,7 +317,7 @@ export function PlayerPlaylistPanel({
|
||||
{isPlaying ? (
|
||||
<Play size={12} className="fill-current" />
|
||||
) : ep.episode_num > 0 ? (
|
||||
ep.episode_num
|
||||
formatEpisodeNumber(ep)
|
||||
) : (
|
||||
<Film size={13} className="text-white/45" />
|
||||
)}
|
||||
@@ -429,7 +430,8 @@ function getEpisodeTitle(ep: Media, siblings: Media[]): string {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
return episodeLabel || mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { getLibraryArtworks } from './librariesPageModel'
|
||||
import { formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={18} />,
|
||||
@@ -773,7 +774,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
{media.year > 0 && <span>{media.year}</span>}
|
||||
{media.season_num !== undefined && media.episode_num !== undefined && (
|
||||
<span>
|
||||
S{media.season_num}E{media.episode_num}
|
||||
S{media.season_num}E{formatEpisodeNumber(media)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
|
||||
type LibrarySeriesDetailHeaderProps = {
|
||||
series: SeriesCard
|
||||
@@ -152,7 +153,7 @@ function firstPlayableEpisode(episodes: Media[]): Media | null {
|
||||
const sorted = [...episodes]
|
||||
sorted.sort((a, b) =>
|
||||
(a.season_num || 0) - (b.season_num || 0)
|
||||
|| (a.episode_num || 0) - (b.episode_num || 0),
|
||||
|| episodeNumberValue(a) - episodeNumberValue(b),
|
||||
)
|
||||
return sorted[0] ?? null
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
THEATRICAL_SEASON,
|
||||
} from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
|
||||
type SeasonGroup = {
|
||||
season: number
|
||||
@@ -95,14 +96,14 @@ export function LibrarySeriesEpisodes({
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-brand-600 font-bold text-sm">{ep.episode_num || '—'}</span>
|
||||
<span className="text-brand-600 font-bold text-sm">{formatEpisodeNumber(ep) || '—'}</span>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/25 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Play size={15} className="fill-white text-white drop-shadow-sm" />
|
||||
</div>
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="absolute bottom-0 right-0 rounded-tl bg-black/75 px-1 py-0.5 text-[9px] font-bold leading-none text-white backdrop-blur-[2px]">
|
||||
{ep.episode_num}
|
||||
{formatEpisodeNumber(ep)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,15 +154,18 @@ function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (ep.episode_num > 0) {
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
if (episodeLabel) {
|
||||
if (!mainTitle) {
|
||||
return `第 ${ep.episode_num} 集`
|
||||
return episodeLabel
|
||||
}
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${ep.episode_num}\\s*集|ep?\\.?\\s*0*${ep.episode_num}\\b)`, 'i')
|
||||
// 标题里已经以「第 11 集 / 第 11.5 集 / ep 11」开头时直接用它,避免重复前缀。
|
||||
const episodeNumber = formatEpisodeNumber(ep).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${episodeNumber}\\s*集|ep?\\.?\\s*0*${episodeNumber}\\b)`, 'i')
|
||||
if (prefixRegex.test(mainTitle)) {
|
||||
return mainTitle
|
||||
}
|
||||
return `第 ${ep.episode_num} 集 · ${mainTitle}`
|
||||
return `${episodeLabel} · ${mainTitle}`
|
||||
}
|
||||
|
||||
return mainTitle || '未命名'
|
||||
|
||||
@@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { formatEpisodeLabel } from '../utils/episodeNumber'
|
||||
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
|
||||
@@ -2347,7 +2348,8 @@ function formatEpisodeDisplay(ep: Media, siblings: Media[]): string {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
const episodeLabel = formatEpisodeLabel(ep)
|
||||
return episodeLabel || mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function findPlaybackQualityById(info: PlaybackInfo | null, id: string): PlaybackQuality | undefined {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SeriesCard,
|
||||
} from '../utils/groupSeries'
|
||||
import { resolveSeriesCardByKey } from '../utils/seriesCardResolve'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
|
||||
type SeasonEpisodes = {
|
||||
season: number
|
||||
@@ -72,7 +73,7 @@ export function useLibrarySeriesSelection({
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Media } from '../types'
|
||||
import { seasonSortOrder } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
type MediaDetailLocationState = { from?: string } | null
|
||||
@@ -81,7 +82,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
list.sort((a, b) => episodeNumberValue(a) - episodeNumberValue(b))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Media {
|
||||
release_date?: string
|
||||
season_num: number
|
||||
episode_num: number
|
||||
/** 集号的小数部分(S01E11.5 → 0.5);没有小数时缺省。 */
|
||||
episode_fraction?: number
|
||||
scrape_status: string
|
||||
tmdb_id: number
|
||||
bangumi_id: number
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Media } from '../types'
|
||||
|
||||
type EpisodeNumberSource = Pick<Media, 'episode_num' | 'episode_fraction'> | null | undefined
|
||||
|
||||
/**
|
||||
* 含小数的集号:S01E11.5 → 11.5。没有小数(绝大多数集)时就是 episode_num。
|
||||
*
|
||||
* 后端把整数集号和它的小数部分分开存(Emby 协议的 IndexNumber 只能是整数),
|
||||
* 网页端所有「显示集号 / 按集排序」的地方都应该用这个函数,而不是直接读
|
||||
* episode_num,否则 11 与 11.5 会显示、排序成同一集。
|
||||
*/
|
||||
export function episodeNumberValue(media: EpisodeNumberSource): number {
|
||||
const base = media?.episode_num ?? 0
|
||||
const fraction = media?.episode_fraction ?? 0
|
||||
if (!(fraction > 0 && fraction < 1)) return base
|
||||
return base + fraction
|
||||
}
|
||||
|
||||
/** 显示用集号文本:11 → "11",11.5 → "11.5";没有集号时返回空串。 */
|
||||
export function formatEpisodeNumber(media: EpisodeNumberSource): string {
|
||||
const value = episodeNumberValue(media)
|
||||
return value > 0 ? String(value) : ''
|
||||
}
|
||||
|
||||
/** "第 11 集" / "第 11.5 集";没有集号时返回空串。 */
|
||||
export function formatEpisodeLabel(media: EpisodeNumberSource): string {
|
||||
const value = formatEpisodeNumber(media)
|
||||
return value ? `第 ${value} 集` : ''
|
||||
}
|
||||
|
||||
/** 按 (季, 集, 小数部分) 升序比较,供剧集列表排序复用。 */
|
||||
export function compareEpisodeOrder(a: Media, b: Media): number {
|
||||
const seasonDiff = (a.season_num ?? 0) - (b.season_num ?? 0)
|
||||
if (seasonDiff !== 0) return seasonDiff
|
||||
return episodeNumberValue(a) - episodeNumberValue(b)
|
||||
}
|
||||
Reference in New Issue
Block a user