fix: align scraped media naming and release sorting

This commit is contained in:
ShukeBta
2026-07-03 02:55:33 +08:00
parent 78de029ef3
commit b610dda098
48 changed files with 544 additions and 42 deletions
+1
View File
@@ -54,6 +54,7 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_release_active ON media(library_id, release_date DESC, year DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
+1
View File
@@ -43,6 +43,7 @@ type Media struct {
Overview string `gorm:"type:text" json:"overview,omitempty"`
Rating float32 `json:"rating"`
Year int `json:"year"`
ReleaseDate string `gorm:"size:10;index" json:"release_date,omitempty"`
SeasonNum int `json:"season_num"`
EpisodeNum int `json:"episode_num"`
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
+6 -5
View File
@@ -103,12 +103,13 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
return nil, 0, err
}
// 多级排序消除"随机"观感:
// 1. year desc — 上映年份新→旧(用户期望的上映时间维度)
// 2. updated_at desc — 同年按最近更新(刮削/补集会刷新)
// 3. created_at desc — 再按入库时间
// 4. id desc — 稳定 tie-breaker:云盘批量扫描同批 created_at 相同时,
// 1. release_date desc — 精确上映/首播日期新→旧
// 2. year desc — 老数据没有完整日期时仍按年份新→旧
// 3. updated_at desc — 同日期/同年按最近更新兜底
// 4. created_at desc — 再按入库时间
// 5. id desc — 稳定 tie-breaker:云盘批量扫描同批 created_at 相同时,
// 没有它 DB 返回顺序不确定,正是"随机排序"的根因。
err := q.Order("year DESC, updated_at DESC, created_at DESC, id DESC").
err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").
Offset(offset).Limit(limit).Find(&items).Error
return items, total, err
}
@@ -103,6 +103,9 @@ func addMediaTitleUpdates(updates map[string]any, existing, incoming model.Media
if incoming.Year > 0 {
setIfChanged(updates, "year", existing.Year, incoming.Year)
}
if incoming.ReleaseDate != "" {
setIfChanged(updates, "release_date", existing.ReleaseDate, incoming.ReleaseDate)
}
if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (titleChanged || yearChanged) {
updates["scrape_status"] = "pending"
}
@@ -119,6 +122,9 @@ func addMediaExternalIDUpdates(updates map[string]any, existing, incoming model.
if incoming.Year > 0 && existing.Year <= 0 {
updates["year"] = incoming.Year
}
if incoming.ReleaseDate != "" && existing.ReleaseDate == "" {
updates["release_date"] = incoming.ReleaseDate
}
if changedExternalID && (status == "no_match" || status == "matched") && incoming.ScrapeStatus != "matched" {
updates["scrape_status"] = "pending"
}
@@ -152,6 +158,9 @@ func addMatchedMediaDetailUpdates(updates map[string]any, existing, incoming mod
if incoming.Year > 0 {
setIfChanged(updates, "year", existing.Year, incoming.Year)
}
if incoming.ReleaseDate != "" {
setIfChanged(updates, "release_date", existing.ReleaseDate, incoming.ReleaseDate)
}
if incoming.NSFW && !existing.NSFW {
updates["nsfw"] = true
}
+36
View File
@@ -186,6 +186,42 @@ func TestMediaUpsertMatchedIncomingRefreshesScrapedMetadata(t *testing.T) {
}
}
func TestListByLibraryOrdersByReleaseDate(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
lib := model.Library{Name: "国产剧", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{LibraryID: lib.ID, Title: "旧片但最近更新", Path: "/media/tv/old.mkv", Year: 2026, ReleaseDate: "2026-01-10", ScrapeStatus: "matched"},
{LibraryID: lib.ID, Title: "最新首播", Path: "/media/tv/newest.mkv", Year: 2026, ReleaseDate: "2026-06-23", ScrapeStatus: "matched"},
{LibraryID: lib.ID, Title: "无完整日期", Path: "/media/tv/year-only.mkv", Year: 2025, ScrapeStatus: "matched"},
}
for i := range rows {
if err := repos.Media.Upsert(t.Context(), &rows[i]); err != nil {
t.Fatal(err)
}
}
items, total, err := repos.Media.ListByLibrary(t.Context(), lib.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
if total != 3 || len(items) != 3 {
t.Fatalf("list total=%d len=%d", total, len(items))
}
if got := []string{items[0].Title, items[1].Title, items[2].Title}; got[0] != "最新首播" || got[1] != "旧片但最近更新" || got[2] != "无完整日期" {
t.Fatalf("release-date order = %#v", got)
}
}
func TestMediaUpsertScanDoesNotClearMatchedMetadata(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
+2 -1
View File
@@ -25,6 +25,7 @@ func metadataFromCloudJSON(body []byte) (*LocalMetadata, cloudJSONArtwork) {
OriginalName: firstJSONString(obj, "original_title", "originaltitle", "original_name", "originalname", "sorttitle"),
EpisodeTitle: firstJSONString(obj, "episode_title", "episodetitle", "episode_name", "episodename"),
Year: firstJSONInt(obj, "year"),
ReleaseDate: normalizeReleaseDate(firstJSONString(obj, "release_date", "releasedate", "premiered", "aired", "date")),
Overview: firstJSONString(obj, "overview", "plot", "outline", "summary", "description"),
Rating: firstJSONFloat(obj, "rating", "vote_average", "score"),
TMDbID: firstJSONInt(obj, "tmdb_id", "tmdbid", "tmdb"),
@@ -44,7 +45,7 @@ func metadataFromCloudJSON(body []byte) (*LocalMetadata, cloudJSONArtwork) {
meta.Title = showTitle
}
if meta.Year == 0 {
meta.Year = yearFromDate(firstJSONString(obj, "release_date", "releasedate", "premiered", "aired", "date"))
meta.Year = yearFromDate(meta.ReleaseDate)
}
artwork := cloudJSONArtwork{
posterValues: firstJSONStrings(obj,
+6
View File
@@ -24,6 +24,9 @@ func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata {
if src.Year > 0 {
dst.Year = src.Year
}
if src.ReleaseDate != "" {
dst.ReleaseDate = src.ReleaseDate
}
if src.Overview != "" {
dst.Overview = src.Overview
}
@@ -86,6 +89,9 @@ func mergeCloudPathHintMetadata(dst, hint *LocalMetadata) *LocalMetadata {
if dst.Year == 0 {
dst.Year = hint.Year
}
if dst.ReleaseDate == "" {
dst.ReleaseDate = hint.ReleaseDate
}
if dst.TMDbID == 0 {
dst.TMDbID = hint.TMDbID
}
+44
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"testing"
"time"
"go.uber.org/zap"
@@ -24,6 +25,49 @@ func newTestEmbyService(t *testing.T) *EmbyService {
return NewEmbyService(&config.Config{}, zap.NewNop(), repos)
}
func TestEmbyLatestItemsOrderByReleaseDate(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
base := time.Now()
rows := []model.Media{
{
Base: model.Base{ID: "older-release-newer-scan", CreatedAt: base.Add(2 * time.Hour)},
LibraryID: lib.ID,
Title: "旧上映新入库",
Path: `/media/movies/old.mkv`,
Year: 2026,
ReleaseDate: "2026-01-10",
},
{
Base: model.Base{ID: "newer-release-older-scan", CreatedAt: base},
LibraryID: lib.ID,
Title: "新上映",
Path: `/media/movies/new.mkv`,
Year: 2026,
ReleaseDate: "2026-06-23",
},
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
items, err := svc.LatestItems(t.Context(), "", lib.ID, 10)
if err != nil {
t.Fatalf("latest items: %v", err)
}
if len(items) != 2 || items[0]["Id"] != "newer-release-older-scan" {
t.Fatalf("latest items should prefer release date over created_at, got %#v", items)
}
if _, ok := items[0]["PremiereDate"].(time.Time); !ok {
t.Fatalf("latest item should expose PremiereDate for Emby clients: %#v", items[0])
}
}
type fakeCloudPlaybackResolver struct {
link *cloud.DirectLink
typ string
+8 -4
View File
@@ -101,7 +101,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
rowLimit = 500
}
var rows []model.Media
if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil {
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
return nil, err
}
rows = e.collapseMediaVersionRows(ctx, rows)
@@ -126,11 +126,11 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
q = e.applyUserMediaVisibility(ctx, q, userID)
var rows []model.Media
if err := q.Order("media.created_at desc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "datecreated", SortOrder: "Descending"})
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
if len(groups) > limit {
groups = groups[:limit]
}
@@ -219,7 +219,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
pct = float64(posMs) / float64(durationMs) * 100
}
return map[string]any{
item := map[string]any{
"Id": m.ID,
"Name": name,
"OriginalTitle": m.OriginalName,
@@ -259,4 +259,8 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
},
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
}
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
item["PremiereDate"] = premiered
}
return item
}
+10 -4
View File
@@ -66,20 +66,26 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
if err := q.Count(&total).Error; err != nil {
return nil, err
}
order := "media.created_at desc"
desc := !strings.EqualFold(firstCSVValue(p.SortOrder), "Ascending")
order := mediaReleaseOrderSQL(true)
orderIncludesDirection := true
switch primarySupportedEmbySort(p.SortBy, resumeFilter) {
case "sortname", "name":
order = "media.title"
orderIncludesDirection = false
case "premieredate", "productionyear":
order = "media.year"
order = mediaReleaseOrderSQL(desc)
case "datecreated":
order = "media.created_at"
orderIncludesDirection = false
case "dateplayed":
order = "resume.watched_at"
orderIncludesDirection = false
case "communityrating":
order = "media.rating"
orderIncludesDirection = false
}
if strings.EqualFold(firstCSVValue(p.SortOrder), "Descending") {
if !orderIncludesDirection && strings.EqualFold(firstCSVValue(p.SortOrder), "Descending") {
if !strings.HasSuffix(order, " desc") {
order = order + " desc"
}
@@ -231,7 +237,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID)
}
var rows []model.Media
if err := q.Order("media.created_at desc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
+21 -8
View File
@@ -60,7 +60,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
epQ = epQ.Where("(season_num > 0 OR episode_num > 0) AND ("+clause+")", args...).
Order("media.created_at desc").Limit(embySeriesGroupingLimit)
Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit)
if err := epQ.Find(&episodicRows).Error; err != nil {
return nil, err
}
@@ -73,7 +73,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
movieQ = filterLikelyEpisodicPathsFromMovieQuery(movieQ).
Order("media.created_at desc").Limit(embySeriesGroupingLimit)
Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit)
var movieRows []model.Media
if err := movieQ.Find(&movieRows).Error; err != nil {
return nil, err
@@ -83,20 +83,20 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
return nil, err
}
// 合并: Series 卡片 + Movie 项, 统一按 DateCreated 倒序。
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
type entry struct {
createdAt time.Time
payload map[string]any
sortAt time.Time
payload map[string]any
}
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
for _, g := range seriesGroups {
entries = append(entries, entry{createdAt: g.CreatedAt, payload: e.seriesPayload(g)})
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(g), payload: e.seriesPayload(g)})
}
for _, item := range movieItems {
entries = append(entries, entry{createdAt: embyPayloadCreatedAt(item), payload: item})
entries = append(entries, entry{sortAt: embyPayloadReleaseSortTime(item), payload: item})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].createdAt.After(entries[j].createdAt)
return entries[i].sortAt.After(entries[j].sortAt)
})
total := len(entries)
paged := pageSlice(entries, p.StartIndex, p.Limit)
@@ -118,6 +118,19 @@ func embyPayloadCreatedAt(item map[string]any) time.Time {
return time.Time{}
}
func embyPayloadReleaseSortTime(item map[string]any) time.Time {
if item == nil {
return time.Time{}
}
if v, ok := item["PremiereDate"].(time.Time); ok {
return v
}
if year, ok := item["ProductionYear"].(int); ok && year > 0 {
return time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC)
}
return embyPayloadCreatedAt(item)
}
func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (bool, error) {
if strings.TrimSpace(libraryID) == "" {
return false, nil
+17 -7
View File
@@ -18,6 +18,7 @@ type embySeriesGroup struct {
Overview string
Rating float32
Year int
ReleaseDate string
TMDbID int
BangumiID int
CreatedAt time.Time
@@ -117,13 +118,14 @@ func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGrou
group, ok := byID[seriesID]
if !ok {
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(&row),
Year: row.Year,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(&row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
}
byID[seriesID] = group
order = append(order, seriesID)
@@ -131,6 +133,14 @@ func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGrou
if row.CreatedAt.After(group.CreatedAt) {
group.CreatedAt = row.CreatedAt
}
if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) {
group.ReleaseDate = row.ReleaseDate
if row.Year > 0 {
group.Year = row.Year
}
} else if group.ReleaseDate == "" && group.Year == 0 && row.Year > 0 {
group.Year = row.Year
}
if group.PosterURL == "" && row.PosterURL != "" {
group.PosterURL = row.PosterURL
}
+21 -2
View File
@@ -9,6 +9,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
@@ -87,7 +88,7 @@ func seasonName(seasonNum int) string {
}
func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
switch strings.ToLower(p.SortBy) {
switch primarySupportedEmbySort(p.SortBy, false) {
case "sortname", "name":
sort.SliceStable(groups, func(i, j int) bool {
if strings.EqualFold(p.SortOrder, "Descending") {
@@ -95,12 +96,30 @@ func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
}
return groups[i].Name < groups[j].Name
})
default:
case "datecreated":
sort.SliceStable(groups, func(i, j int) bool {
if strings.EqualFold(p.SortOrder, "Ascending") {
return groups[i].CreatedAt.Before(groups[j].CreatedAt)
}
return groups[i].CreatedAt.After(groups[j].CreatedAt)
})
default:
sort.SliceStable(groups, func(i, j int) bool {
if strings.EqualFold(p.SortOrder, "Ascending") {
return embySeriesReleaseSortTime(groups[i]).Before(embySeriesReleaseSortTime(groups[j]))
}
return embySeriesReleaseSortTime(groups[i]).After(embySeriesReleaseSortTime(groups[j]))
})
}
}
func embySeriesReleaseSortTime(group embySeriesGroup) time.Time {
return mediaReleaseSortTime(model.Media{
ReleaseDate: group.ReleaseDate,
Year: group.Year,
Base: model.Base{
CreatedAt: group.CreatedAt,
UpdatedAt: group.CreatedAt,
},
})
}
+5 -1
View File
@@ -10,7 +10,7 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
if group.BackdropURL != "" {
backdropTags = append(backdropTags, group.ID+"-bd")
}
return map[string]any{
item := map[string]any{
"Id": group.ID,
"Name": group.Name,
"ServerId": embyServerID,
@@ -32,6 +32,10 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
},
"UserData": emptyUserData(),
}
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
item["PremiereDate"] = premiered
}
return item
}
func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
+2
View File
@@ -19,6 +19,7 @@ type ExternalMediaResult struct {
PosterURL string `json:"poster_url,omitempty"`
BackdropURL string `json:"backdrop_url,omitempty"`
Year int `json:"year,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
Rating float32 `json:"rating,omitempty"`
TMDbID int `json:"tmdb_id,omitempty"`
BangumiID int `json:"bangumi_id,omitempty"`
@@ -64,6 +65,7 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType
PosterURL: m.PosterURL,
BackdropURL: m.BackdropURL,
Year: m.Year,
ReleaseDate: m.ReleaseDate,
Rating: m.Rating,
TMDbID: m.TMDbID,
BangumiID: m.BangumiID,
+4
View File
@@ -29,6 +29,9 @@ func applyLocalIdentityMetadata(m *model.Media, local *LocalMetadata) {
if local.Year > 0 {
m.Year = local.Year
}
if local.ReleaseDate != "" {
m.ReleaseDate = local.ReleaseDate
}
if local.Overview != "" {
m.Overview = local.Overview
}
@@ -98,6 +101,7 @@ func localHasDescriptiveMetadata(local *LocalMetadata) bool {
local.EpisodeTitle != "" ||
local.AdultCode != "" ||
local.Year > 0 ||
local.ReleaseDate != "" ||
local.Overview != "" ||
local.Rating > 0 ||
local.TMDbID > 0 ||
+2 -1
View File
@@ -14,6 +14,7 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe
OriginalName: cleanXMLText(doc.OriginalTitle),
AdultCode: normalizeAdultCode(doc.Num),
Year: int(doc.Year),
ReleaseDate: normalizeReleaseDate(firstText(doc.Premiered, doc.ReleaseDate, doc.Release, doc.Aired)),
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
Rating: float32(doc.Rating),
PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
@@ -48,7 +49,7 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe
}
}
if meta.Year == 0 {
meta.Year = yearFromDate(firstText(doc.Premiered, doc.ReleaseDate, doc.Release, doc.Aired))
meta.Year = yearFromDate(meta.ReleaseDate)
}
if meta.TMDbID == 0 {
meta.TMDbID = tmdbIDFromUniqueIDs(doc.UniqueIDs)
@@ -4,6 +4,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
)
func firstRemoteURL(baseDir string, values ...string) string {
@@ -69,6 +70,18 @@ func yearFromDate(value string) int {
return year
}
func normalizeReleaseDate(value string) string {
value = strings.TrimSpace(value)
if len(value) < len("2006-01-02") {
return ""
}
value = value[:len("2006-01-02")]
if parsed, err := time.Parse("2006-01-02", value); err == nil {
return parsed.Format("2006-01-02")
}
return ""
}
func samePath(a, b string) bool {
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
}
+1
View File
@@ -13,6 +13,7 @@ type LocalMetadata struct {
EpisodeTitle string
AdultCode string
Year int
ReleaseDate string
Overview string
Rating float32
PosterURL string
+1
View File
@@ -17,6 +17,7 @@ type ManualScrapeRequest struct {
PosterURL string `json:"poster_url"`
BackdropURL string `json:"backdrop_url"`
Year int `json:"year"`
ReleaseDate string `json:"release_date"`
Rating float32 `json:"rating"`
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
@@ -31,6 +31,9 @@ func mergeManualRequestIntoMatch(match *Match, req ManualScrapeRequest) *Match {
if req.Year > 0 {
match.Year = req.Year
}
if value := normalizeReleaseDate(req.ReleaseDate); value != "" {
match.ReleaseDate = value
}
if req.Rating > 0 {
match.Rating = req.Rating
}
+1
View File
@@ -45,6 +45,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
PosterURL: match.PosterURL,
BackdropURL: match.BackdropURL,
Year: match.Year,
ReleaseDate: match.ReleaseDate,
Rating: match.Rating,
TMDbID: match.TMDbID,
BangumiID: match.BangumiID,
+4
View File
@@ -15,6 +15,7 @@ type MediaMetadataUpdate struct {
PosterURL *string `json:"poster_url"`
BackdropURL *string `json:"backdrop_url"`
Year *int `json:"year"`
ReleaseDate *string `json:"release_date"`
Rating *float32 `json:"rating"`
SeasonNum *int `json:"season_num"`
EpisodeNum *int `json:"episode_num"`
@@ -64,6 +65,9 @@ func (s *MediaService) UpdateMetadata(ctx context.Context, id string, req MediaM
if req.Year != nil {
updates["year"] = clampNonNegativeInt(*req.Year)
}
if req.ReleaseDate != nil {
updates["release_date"] = normalizeReleaseDate(*req.ReleaseDate)
}
if req.Rating != nil {
updates["rating"] = clampRating(*req.Rating)
}
+11 -6
View File
@@ -24,17 +24,19 @@ func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) {
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
title := "手动标题"
overview := "手动简介"
releaseDate := "2026-06-23"
season := 0
episode := 1
tmdbID := 12345
nsfw := true
updated, err := svc.UpdateMetadata(t.Context(), media.ID, MediaMetadataUpdate{
Title: &title,
Overview: &overview,
SeasonNum: &season,
EpisodeNum: &episode,
TMDbID: &tmdbID,
NSFW: &nsfw,
Title: &title,
Overview: &overview,
ReleaseDate: &releaseDate,
SeasonNum: &season,
EpisodeNum: &episode,
TMDbID: &tmdbID,
NSFW: &nsfw,
})
if err != nil {
t.Fatalf("update metadata: %v", err)
@@ -45,4 +47,7 @@ func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) {
if updated.SeasonNum != 0 || updated.EpisodeNum != 1 || updated.TMDbID != tmdbID || !updated.NSFW {
t.Fatalf("ids/episode metadata not saved: %#v", updated)
}
if updated.ReleaseDate != releaseDate {
t.Fatalf("release date = %q, want %q", updated.ReleaseDate, releaseDate)
}
}
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"fmt"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func mediaReleaseSortTime(media model.Media) time.Time {
if value := normalizeReleaseDate(media.ReleaseDate); value != "" {
if t, err := time.Parse("2006-01-02", value); err == nil {
return t
}
}
if media.Year > 0 {
if t, err := time.Parse("2006-01-02", fmt.Sprintf("%04d-12-31", media.Year)); err == nil {
return t
}
}
if !media.UpdatedAt.IsZero() {
return media.UpdatedAt
}
return media.CreatedAt
}
func mediaReleaseOrderSQL(desc bool) string {
dir := "ASC"
if desc {
dir = "DESC"
}
return fmt.Sprintf("media.release_date %s, media.year %s, media.created_at %s, media.id %s", dir, dir, dir, dir)
}
func embyPremiereDate(value string) (time.Time, bool) {
value = normalizeReleaseDate(value)
if value == "" {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", value)
return t, err == nil
}
+8
View File
@@ -146,6 +146,14 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
}
func seriesMediaTime(media model.Media) time.Time {
if releaseDate := strings.TrimSpace(media.ReleaseDate); releaseDate != "" {
if parsed, err := time.Parse("2006-01-02", releaseDate); err == nil {
return parsed
}
}
if media.Year > 0 {
return time.Date(media.Year, time.December, 31, 0, 0, 0, 0, time.UTC)
}
if media.UpdatedAt.After(media.CreatedAt) {
return media.UpdatedAt
}
+5 -1
View File
@@ -135,7 +135,11 @@ func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineR
scanRoot = filepath.Dir(path)
}
preferredLibraryID := p.scanPreferredLibraryID(ctx, req)
res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, p.scrapeAfter(ctx, req))
scrapeAfter := p.scrapeAfter(ctx, req)
res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, scrapeAfter)
if scrapeAfter {
p.syncScrapedOrganizedNames(ctx, opts, res, path)
}
} else if p.log != nil && res != nil && !req.DryRun {
p.log.Info("organize pipeline skipped scan; no destination changes",
zap.String("trigger", string(req.Trigger)),
@@ -0,0 +1,94 @@
package service
import (
"context"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func (p *OrganizePipelineService) syncScrapedOrganizedNames(ctx context.Context, opts OrganizeOptions, res *OrganizeResult, path string) {
if p == nil || p.organizer == nil || p.repo == nil || p.repo.DB == nil || res == nil || res.DryRun {
return
}
for _, target := range organizeNameSyncTargets(res, path) {
media := p.mediaByPath(ctx, target)
if media == nil {
continue
}
before := media.Path
syncOpts := opts
syncOpts.SourcePath = ""
syncOpts.TransferMode = TransferMove
after, err := p.organizer.SyncMediaPathWithMetadata(ctx, media.ID, syncOpts)
if err != nil {
res.Errors = append(res.Errors, filepath.Base(before)+": "+err.Error())
if p.log != nil {
p.log.Warn("organize pipeline scrape rename failed",
zap.String("media", media.ID),
zap.String("path", before),
zap.Error(err))
}
continue
}
if samePath(before, after) {
continue
}
res.Items = append(res.Items, OrganizePreviewItem{
Source: before,
Target: after,
Action: "organize",
Reason: "scraped metadata rename",
Title: media.Title,
})
if p.log != nil {
p.log.Info("organize pipeline synced scraped media name",
zap.String("media", media.ID),
zap.String("from", before),
zap.String("to", after))
}
}
}
func organizeNameSyncTargets(res *OrganizeResult, path string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(res.Items)+1)
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
key := strings.ToLower(filepath.Clean(value))
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, value)
}
for _, item := range res.Items {
switch item.Action {
case "organize", "replace", "reclassify":
add(item.Target)
}
}
add(path)
return out
}
func (p *OrganizePipelineService) mediaByPath(ctx context.Context, path string) *model.Media {
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
return nil
}
var media model.Media
if err := p.repo.DB.WithContext(ctx).
Where("path = ? AND deleted_at IS NULL", path).
Limit(1).
Take(&media).Error; err != nil {
return nil
}
return &media
}
+32
View File
@@ -102,6 +102,38 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
return o.applyOrganizeMedia(ctx, req, dst)
}
// SyncMediaPathWithMetadata renames an already-organized media file after
// metadata scraping has finalized the display title/year. It always moves the
// organized target itself, even when the ingest transfer mode was copy/link.
func (o *OrganizerService) SyncMediaPathWithMetadata(ctx context.Context, mediaID string, opts OrganizeOptions) (string, error) {
req, err := o.resolveOrganizeMediaRequest(ctx, mediaID, opts)
if err != nil {
return "", err
}
dst, err := o.buildOrganizeMediaDestination(ctx, req)
if err != nil {
return "", err
}
if samePath(req.media.Path, dst.path) {
if !req.dryRun {
if err := o.persistOrganizedMediaMetadata(ctx, req.media); err != nil {
return "", err
}
}
return dst.path, nil
}
if req.dryRun {
return dst.path, nil
}
req.transferMode = TransferMove
before := req.media.Path
after, err := o.applyOrganizeMedia(ctx, req, dst)
if err == nil {
cleanupEmptyOrganizeParents(before, req.baseRoot)
}
return after, err
}
// OrganizeLibrary organizes every media row in a library whose file is
// not already in the expected path structure.
func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string) (*OrganizeResult, error) {
+18
View File
@@ -186,3 +186,21 @@ func (o *OrganizerService) applyOrganizeMedia(ctx context.Context, req organizeM
)
return dst.path, nil
}
func cleanupEmptyOrganizeParents(oldPath, stopRoot string) {
dir := filepath.Clean(filepath.Dir(strings.TrimSpace(oldPath)))
root := filepath.Clean(strings.TrimSpace(stopRoot))
if dir == "" || root == "" || dir == "." || root == "." || !pathWithin(dir, root) {
return
}
for !samePath(dir, root) {
if err := os.Remove(dir); err != nil {
return
}
next := filepath.Dir(dir)
if samePath(next, dir) {
return
}
dir = next
}
}
@@ -81,6 +81,7 @@ func addOrganizedMediaMetadataUpdates(updates map[string]any, media model.Media)
if media.Year > 0 {
updates["year"] = media.Year
}
setNonEmptyUpdate(updates, "release_date", media.ReleaseDate)
if media.Rating > 0 {
updates["rating"] = media.Rating
}
@@ -39,6 +39,7 @@ func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
PosterURL: local.PosterURL,
BackdropURL: local.BackdropURL,
Year: local.Year,
ReleaseDate: local.ReleaseDate,
Rating: local.Rating,
TMDbID: local.TMDbID,
DoubanID: local.DoubanID,
@@ -90,6 +91,7 @@ func organizeMatchFromMedia(media *model.Media) *Match {
PosterURL: media.PosterURL,
BackdropURL: media.BackdropURL,
Year: media.Year,
ReleaseDate: media.ReleaseDate,
Rating: media.Rating,
Languages: parseCommaList(media.Languages),
Countries: parseCommaList(media.Countries),
@@ -63,6 +63,9 @@ func applyReclassifyMatchUpdates(updates map[string]any, match *Match) {
if match.Year > 0 {
updates["year"] = match.Year
}
if value := strings.TrimSpace(match.ReleaseDate); value != "" {
updates["release_date"] = value
}
if match.TMDbID > 0 {
updates["tm_db_id"] = match.TMDbID
}
@@ -86,6 +86,9 @@ func mediaWithReclassifyMatch(media model.Media, match *Match) model.Media {
if match.Year > 0 {
media.Year = match.Year
}
if value := strings.TrimSpace(match.ReleaseDate); value != "" {
media.ReleaseDate = value
}
if match.TMDbID > 0 {
media.TMDbID = match.TMDbID
}
+56
View File
@@ -109,6 +109,62 @@ func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) {
}
}
func TestOrganizePipelineRenamesAfterScrape(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(src, "Spy.x.Family.S01E01.2022.1080p.mkv")
writeOrgFile(t, sourceFile, "episode")
lib := model.Library{
Name: "剧集",
Path: filepath.Join(dest, "电视剧"),
Type: "tv",
Enabled: true,
}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, scraper)
pipeline := NewOrganizePipelineService(zap.NewNop(), repos, organizer, scanner, nil)
_, err := pipeline.Run(t.Context(), OrganizePipelineRequest{
Scope: OrganizeScopeDirectory,
Trigger: OrganizeTriggerManual,
SourcePath: src,
DestPath: dest,
TransferMode: string(TransferCopy),
MediaType: "tv",
})
if err != nil {
t.Fatalf("pipeline organize: %v", err)
}
englishPath := filepath.Join(dest, "电视剧", "Spy Family", "Season 01", "Spy Family - S01E01.mkv")
if _, err := os.Stat(englishPath); !os.IsNotExist(err) {
t.Fatalf("english pre-scrape path should be renamed away, stat err=%v", err)
}
englishDir := filepath.Join(dest, "电视剧", "Spy Family")
if _, err := os.Stat(englishDir); !os.IsNotExist(err) {
t.Fatalf("empty english pre-scrape directory should be cleaned up, stat err=%v", err)
}
want := filepath.Join(dest, "电视剧", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Fatalf("scraped rename target missing %q: %v", want, err)
}
var got model.Media
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
t.Fatal(err)
}
if got.Title != "间谍过家家" || got.ReleaseDate != "2022-04-09" || got.ScrapeStatus != "matched" {
t.Fatalf("media after scrape rename = title=%q release=%q status=%q", got.Title, got.ReleaseDate, got.ScrapeStatus)
}
}
func TestOrganizeMediaRefreshesMetadataBeforeRename(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
+2
View File
@@ -253,6 +253,7 @@ type existingCloudMedia struct {
STRMURL string
Overview string
Year int
ReleaseDate string
Rating float32
TMDbID int
BangumiID int
@@ -286,6 +287,7 @@ type existingLocalMedia struct {
BackdropURL string
Overview string
Year int
ReleaseDate string
Rating float32
TMDbID int
BangumiID int
+3
View File
@@ -101,6 +101,9 @@ func mergeMatchIntoLocalMetadata(meta *LocalMetadata, match *Match) {
if match.Year > 0 {
meta.Year = match.Year
}
if match.ReleaseDate != "" {
meta.ReleaseDate = match.ReleaseDate
}
if match.Overview != "" {
meta.Overview = match.Overview
}
+4 -2
View File
@@ -19,7 +19,7 @@ func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Cont
var rows []model.Media
if err := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("library_id", "path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "poster_url", "backdrop_url", "strm_url", "overview", "year", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status").
Select("library_id", "path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "poster_url", "backdrop_url", "strm_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").
Where("library_id IN ? AND path LIKE ?", libraryIDs, "cloud://%").
Find(&rows).Error; err != nil {
return nil, err
@@ -46,6 +46,7 @@ func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Cont
STRMURL: row.STRMURL,
Overview: row.Overview,
Year: row.Year,
ReleaseDate: row.ReleaseDate,
Rating: row.Rating,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
@@ -67,7 +68,7 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library
var rows []model.Media
if err := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
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", "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", "genres", "countries", "languages", "nsfw", "scrape_status").
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%").
Find(&rows).Error; err != nil {
return nil, err
@@ -96,6 +97,7 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library
BackdropURL: row.BackdropURL,
Overview: row.Overview,
Year: row.Year,
ReleaseDate: row.ReleaseDate,
Rating: row.Rating,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
@@ -10,6 +10,7 @@ type scanDerivedMetadata struct {
Title string
ScrapeStatus string
Year int
ReleaseDate string
TMDbID int
BangumiID int
DoubanID string
@@ -43,6 +44,9 @@ func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMeta
if localMeta.Year > 0 && existing.Year != localMeta.Year {
return true
}
if localMeta.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(localMeta.ReleaseDate) {
return true
}
if localMeta.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(localMeta.Overview) {
return true
}
@@ -131,6 +135,9 @@ func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata
if local.Year > 0 && existing.Year != local.Year {
return true
}
if local.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(local.ReleaseDate) {
return true
}
if local.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(local.Overview) {
return true
}
@@ -181,6 +188,7 @@ func cloudDerivedMetadataNeedsRefresh(existing existingCloudMedia, incoming *mod
Title: existing.Title,
ScrapeStatus: existing.ScrapeStatus,
Year: existing.Year,
ReleaseDate: existing.ReleaseDate,
TMDbID: existing.TMDbID,
BangumiID: existing.BangumiID,
DoubanID: existing.DoubanID,
@@ -204,6 +212,7 @@ func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *mod
Title: existing.Title,
ScrapeStatus: existing.ScrapeStatus,
Year: existing.Year,
ReleaseDate: existing.ReleaseDate,
TMDbID: existing.TMDbID,
BangumiID: existing.BangumiID,
DoubanID: existing.DoubanID,
@@ -222,6 +231,9 @@ func scanDerivedMetadataNeedsRefresh(existing scanDerivedMetadata, incoming *mod
if enrichable && incoming.Year > 0 && existing.Year != incoming.Year {
return true
}
if enrichable && incoming.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(incoming.ReleaseDate) {
return true
}
if (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum {
return true
}
+3
View File
@@ -126,6 +126,9 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
"year": match.Year,
"scrape_status": "matched",
}
if match.ReleaseDate != "" {
updates["release_date"] = match.ReleaseDate
}
if match.OriginalName != "" {
updates["original_name"] = match.OriginalName
}
@@ -89,6 +89,9 @@ func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) {
if local.Year > 0 {
match.Year = local.Year
}
if local.ReleaseDate != "" {
match.ReleaseDate = local.ReleaseDate
}
if local.TMDbID > 0 {
match.TMDbID = local.TMDbID
}
@@ -150,6 +153,9 @@ func mergeScrapePathHintMetadata(dst, src *LocalMetadata) *LocalMetadata {
if dst.Year <= 0 && src.Year > 0 {
dst.Year = src.Year
}
if dst.ReleaseDate == "" && src.ReleaseDate != "" {
dst.ReleaseDate = src.ReleaseDate
}
if src.TMDbID > 0 {
dst.TMDbID = src.TMDbID
}
@@ -204,6 +210,9 @@ func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.M
if next.Year > 0 {
updates["year"] = next.Year
}
if next.ReleaseDate != "" {
updates["release_date"] = next.ReleaseDate
}
if next.TMDbID > 0 {
updates["tm_db_id"] = next.TMDbID
}
+2
View File
@@ -60,6 +60,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
m.ReleaseDate = normalizeReleaseDate(r.ReleaseDate)
if len(r.ReleaseDate) >= 4 {
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
}
@@ -131,6 +132,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
m.ReleaseDate = normalizeReleaseDate(r.FirstAirDate)
if len(r.FirstAirDate) >= 4 {
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
}
+2
View File
@@ -105,6 +105,7 @@ func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
m.ReleaseDate = normalizeReleaseDate(r.ReleaseDate)
if len(r.ReleaseDate) >= 4 {
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
}
@@ -184,6 +185,7 @@ func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match {
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
m.ReleaseDate = normalizeReleaseDate(r.FirstAirDate)
if len(r.FirstAirDate) >= 4 {
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
}
+1
View File
@@ -15,6 +15,7 @@ type Match struct {
PosterURL string `json:"poster_url"`
BackdropURL string `json:"backdrop_url"`
Year int `json:"year"`
ReleaseDate string `json:"release_date,omitempty"`
Rating float32 `json:"rating"`
Languages []string `json:"languages,omitempty"`
Countries []string `json:"countries,omitempty"`
+2
View File
@@ -39,6 +39,7 @@ export interface ManualScrapeCandidate {
poster_url?: string
backdrop_url?: string
year?: number
release_date?: string
rating?: number
tmdb_id?: number
bangumi_id?: number
@@ -66,6 +67,7 @@ export interface MediaMetadataUpdate {
poster_url?: string
backdrop_url?: string
year?: number
release_date?: string
rating?: number
season_num?: number
episode_num?: number
@@ -31,6 +31,7 @@ export function MetadataEditDialog({
poster_url: '',
backdrop_url: '',
year: '',
release_date: '',
rating: '',
season_num: '',
episode_num: '',
@@ -54,6 +55,7 @@ export function MetadataEditDialog({
poster_url: media.poster_url || '',
backdrop_url: media.backdrop_url || '',
year: media.year > 0 ? String(media.year) : '',
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) : '',
@@ -89,6 +91,7 @@ export function MetadataEditDialog({
poster_url: form.poster_url,
backdrop_url: form.backdrop_url,
year: Math.trunc(toNumber(form.year)),
release_date: form.release_date,
rating: toNumber(form.rating),
tmdb_id: Math.trunc(toNumber(form.tmdb_id)),
bangumi_id: Math.trunc(toNumber(form.bangumi_id)),
@@ -151,6 +154,7 @@ export function MetadataEditDialog({
<Field label="海报 URL" value={form.poster_url} onChange={(value) => set('poster_url', value)} />
<Field label="背景 / 单集剧照 URL" value={form.backdrop_url} onChange={(value) => set('backdrop_url', value)} />
<Field label="年份" value={form.year} onChange={(value) => set('year', value)} inputMode="numeric" />
<Field label="上映日期" value={form.release_date} onChange={(value) => set('release_date', value)} type="date" />
<Field label="评分" value={form.rating} onChange={(value) => set('rating', value)} inputMode="decimal" />
{!isSeries && <Field label="季" value={form.season_num} onChange={(value) => set('season_num', value)} inputMode="numeric" />}
{!isSeries && <Field label="集" value={form.episode_num} onChange={(value) => set('episode_num', value)} inputMode="numeric" />}
@@ -198,18 +202,21 @@ function Field({
onChange,
placeholder,
inputMode,
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
inputMode?: 'numeric' | 'decimal'
type?: 'text' | 'date'
}) {
return (
<label>
<span className="mb-1 block text-xs font-bold text-gray-500">{label}</span>
<input
value={value}
type={type}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
inputMode={inputMode}
+3
View File
@@ -19,5 +19,8 @@ export function latestLibraryCards(items: Media[]): SeriesCard[] {
}
export function mediaTime(media: Media): number {
const releaseTime = Date.parse(media.release_date || '')
if (releaseTime) return releaseTime
if (media.year > 0) return Date.UTC(media.year, 11, 31)
return Date.parse(media.updated_at || media.created_at || '') || 0
}
+1
View File
@@ -25,6 +25,7 @@ export interface Media {
overview?: string
rating: number
year: number
release_date?: string
season_num: number
episode_num: number
scrape_status: string