mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix: align scraped media naming and release sorting
This commit is contained in:
@@ -54,6 +54,7 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
|||||||
func ensurePerformanceIndexes(db *gorm.DB) error {
|
func ensurePerformanceIndexes(db *gorm.DB) error {
|
||||||
statements := []string{
|
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_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_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_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`,
|
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type Media struct {
|
|||||||
Overview string `gorm:"type:text" json:"overview,omitempty"`
|
Overview string `gorm:"type:text" json:"overview,omitempty"`
|
||||||
Rating float32 `json:"rating"`
|
Rating float32 `json:"rating"`
|
||||||
Year int `json:"year"`
|
Year int `json:"year"`
|
||||||
|
ReleaseDate string `gorm:"size:10;index" json:"release_date,omitempty"`
|
||||||
SeasonNum int `json:"season_num"`
|
SeasonNum int `json:"season_num"`
|
||||||
EpisodeNum int `json:"episode_num"`
|
EpisodeNum int `json:"episode_num"`
|
||||||
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
|
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
|
||||||
|
|||||||
@@ -103,12 +103,13 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
// 多级排序消除"随机"观感:
|
// 多级排序消除"随机"观感:
|
||||||
// 1. year desc — 上映年份新→旧(用户期望的上映时间维度)
|
// 1. release_date desc — 精确上映/首播日期新→旧
|
||||||
// 2. updated_at desc — 同年按最近更新(刮削/补集会刷新)
|
// 2. year desc — 老数据没有完整日期时仍按年份新→旧
|
||||||
// 3. created_at desc — 再按入库时间
|
// 3. updated_at desc — 同日期/同年按最近更新兜底
|
||||||
// 4. id desc — 稳定 tie-breaker:云盘批量扫描同批 created_at 相同时,
|
// 4. created_at desc — 再按入库时间
|
||||||
|
// 5. id desc — 稳定 tie-breaker:云盘批量扫描同批 created_at 相同时,
|
||||||
// 没有它 DB 返回顺序不确定,正是"随机排序"的根因。
|
// 没有它 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
|
Offset(offset).Limit(limit).Find(&items).Error
|
||||||
return items, total, err
|
return items, total, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ func addMediaTitleUpdates(updates map[string]any, existing, incoming model.Media
|
|||||||
if incoming.Year > 0 {
|
if incoming.Year > 0 {
|
||||||
setIfChanged(updates, "year", existing.Year, incoming.Year)
|
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) {
|
if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (titleChanged || yearChanged) {
|
||||||
updates["scrape_status"] = "pending"
|
updates["scrape_status"] = "pending"
|
||||||
}
|
}
|
||||||
@@ -119,6 +122,9 @@ func addMediaExternalIDUpdates(updates map[string]any, existing, incoming model.
|
|||||||
if incoming.Year > 0 && existing.Year <= 0 {
|
if incoming.Year > 0 && existing.Year <= 0 {
|
||||||
updates["year"] = incoming.Year
|
updates["year"] = incoming.Year
|
||||||
}
|
}
|
||||||
|
if incoming.ReleaseDate != "" && existing.ReleaseDate == "" {
|
||||||
|
updates["release_date"] = incoming.ReleaseDate
|
||||||
|
}
|
||||||
if changedExternalID && (status == "no_match" || status == "matched") && incoming.ScrapeStatus != "matched" {
|
if changedExternalID && (status == "no_match" || status == "matched") && incoming.ScrapeStatus != "matched" {
|
||||||
updates["scrape_status"] = "pending"
|
updates["scrape_status"] = "pending"
|
||||||
}
|
}
|
||||||
@@ -152,6 +158,9 @@ func addMatchedMediaDetailUpdates(updates map[string]any, existing, incoming mod
|
|||||||
if incoming.Year > 0 {
|
if incoming.Year > 0 {
|
||||||
setIfChanged(updates, "year", existing.Year, incoming.Year)
|
setIfChanged(updates, "year", existing.Year, incoming.Year)
|
||||||
}
|
}
|
||||||
|
if incoming.ReleaseDate != "" {
|
||||||
|
setIfChanged(updates, "release_date", existing.ReleaseDate, incoming.ReleaseDate)
|
||||||
|
}
|
||||||
if incoming.NSFW && !existing.NSFW {
|
if incoming.NSFW && !existing.NSFW {
|
||||||
updates["nsfw"] = true
|
updates["nsfw"] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestMediaUpsertScanDoesNotClearMatchedMetadata(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func metadataFromCloudJSON(body []byte) (*LocalMetadata, cloudJSONArtwork) {
|
|||||||
OriginalName: firstJSONString(obj, "original_title", "originaltitle", "original_name", "originalname", "sorttitle"),
|
OriginalName: firstJSONString(obj, "original_title", "originaltitle", "original_name", "originalname", "sorttitle"),
|
||||||
EpisodeTitle: firstJSONString(obj, "episode_title", "episodetitle", "episode_name", "episodename"),
|
EpisodeTitle: firstJSONString(obj, "episode_title", "episodetitle", "episode_name", "episodename"),
|
||||||
Year: firstJSONInt(obj, "year"),
|
Year: firstJSONInt(obj, "year"),
|
||||||
|
ReleaseDate: normalizeReleaseDate(firstJSONString(obj, "release_date", "releasedate", "premiered", "aired", "date")),
|
||||||
Overview: firstJSONString(obj, "overview", "plot", "outline", "summary", "description"),
|
Overview: firstJSONString(obj, "overview", "plot", "outline", "summary", "description"),
|
||||||
Rating: firstJSONFloat(obj, "rating", "vote_average", "score"),
|
Rating: firstJSONFloat(obj, "rating", "vote_average", "score"),
|
||||||
TMDbID: firstJSONInt(obj, "tmdb_id", "tmdbid", "tmdb"),
|
TMDbID: firstJSONInt(obj, "tmdb_id", "tmdbid", "tmdb"),
|
||||||
@@ -44,7 +45,7 @@ func metadataFromCloudJSON(body []byte) (*LocalMetadata, cloudJSONArtwork) {
|
|||||||
meta.Title = showTitle
|
meta.Title = showTitle
|
||||||
}
|
}
|
||||||
if meta.Year == 0 {
|
if meta.Year == 0 {
|
||||||
meta.Year = yearFromDate(firstJSONString(obj, "release_date", "releasedate", "premiered", "aired", "date"))
|
meta.Year = yearFromDate(meta.ReleaseDate)
|
||||||
}
|
}
|
||||||
artwork := cloudJSONArtwork{
|
artwork := cloudJSONArtwork{
|
||||||
posterValues: firstJSONStrings(obj,
|
posterValues: firstJSONStrings(obj,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata {
|
|||||||
if src.Year > 0 {
|
if src.Year > 0 {
|
||||||
dst.Year = src.Year
|
dst.Year = src.Year
|
||||||
}
|
}
|
||||||
|
if src.ReleaseDate != "" {
|
||||||
|
dst.ReleaseDate = src.ReleaseDate
|
||||||
|
}
|
||||||
if src.Overview != "" {
|
if src.Overview != "" {
|
||||||
dst.Overview = src.Overview
|
dst.Overview = src.Overview
|
||||||
}
|
}
|
||||||
@@ -86,6 +89,9 @@ func mergeCloudPathHintMetadata(dst, hint *LocalMetadata) *LocalMetadata {
|
|||||||
if dst.Year == 0 {
|
if dst.Year == 0 {
|
||||||
dst.Year = hint.Year
|
dst.Year = hint.Year
|
||||||
}
|
}
|
||||||
|
if dst.ReleaseDate == "" {
|
||||||
|
dst.ReleaseDate = hint.ReleaseDate
|
||||||
|
}
|
||||||
if dst.TMDbID == 0 {
|
if dst.TMDbID == 0 {
|
||||||
dst.TMDbID = hint.TMDbID
|
dst.TMDbID = hint.TMDbID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -24,6 +25,49 @@ func newTestEmbyService(t *testing.T) *EmbyService {
|
|||||||
return NewEmbyService(&config.Config{}, zap.NewNop(), repos)
|
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 {
|
type fakeCloudPlaybackResolver struct {
|
||||||
link *cloud.DirectLink
|
link *cloud.DirectLink
|
||||||
typ string
|
typ string
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
|||||||
rowLimit = 500
|
rowLimit = 500
|
||||||
}
|
}
|
||||||
var rows []model.Media
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
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))
|
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
|
||||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
var rows []model.Media
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(rows)
|
groups := e.seriesGroupsFromMedia(rows)
|
||||||
sortSeriesGroups(groups, ItemsParams{SortBy: "datecreated", SortOrder: "Descending"})
|
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
||||||
if len(groups) > limit {
|
if len(groups) > limit {
|
||||||
groups = 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
|
pct = float64(posMs) / float64(durationMs) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]any{
|
item := map[string]any{
|
||||||
"Id": m.ID,
|
"Id": m.ID,
|
||||||
"Name": name,
|
"Name": name,
|
||||||
"OriginalTitle": m.OriginalName,
|
"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),
|
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
|
||||||
}
|
}
|
||||||
|
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
|
||||||
|
item["PremiereDate"] = premiered
|
||||||
|
}
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,20 +66,26 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
|||||||
if err := q.Count(&total).Error; err != nil {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
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) {
|
switch primarySupportedEmbySort(p.SortBy, resumeFilter) {
|
||||||
case "sortname", "name":
|
case "sortname", "name":
|
||||||
order = "media.title"
|
order = "media.title"
|
||||||
|
orderIncludesDirection = false
|
||||||
case "premieredate", "productionyear":
|
case "premieredate", "productionyear":
|
||||||
order = "media.year"
|
order = mediaReleaseOrderSQL(desc)
|
||||||
case "datecreated":
|
case "datecreated":
|
||||||
order = "media.created_at"
|
order = "media.created_at"
|
||||||
|
orderIncludesDirection = false
|
||||||
case "dateplayed":
|
case "dateplayed":
|
||||||
order = "resume.watched_at"
|
order = "resume.watched_at"
|
||||||
|
orderIncludesDirection = false
|
||||||
case "communityrating":
|
case "communityrating":
|
||||||
order = "media.rating"
|
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") {
|
if !strings.HasSuffix(order, " desc") {
|
||||||
order = 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)
|
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
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(rows)
|
groups := e.seriesGroupsFromMedia(rows)
|
||||||
|
|||||||
@@ -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
|
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...).
|
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 {
|
if err := epQ.Find(&episodicRows).Error; err != nil {
|
||||||
return nil, err
|
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
|
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
|
||||||
}
|
}
|
||||||
movieQ = filterLikelyEpisodicPathsFromMovieQuery(movieQ).
|
movieQ = filterLikelyEpisodicPathsFromMovieQuery(movieQ).
|
||||||
Order("media.created_at desc").Limit(embySeriesGroupingLimit)
|
Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit)
|
||||||
var movieRows []model.Media
|
var movieRows []model.Media
|
||||||
if err := movieQ.Find(&movieRows).Error; err != nil {
|
if err := movieQ.Find(&movieRows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -83,20 +83,20 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并: Series 卡片 + Movie 项, 统一按 DateCreated 倒序。
|
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
|
||||||
type entry struct {
|
type entry struct {
|
||||||
createdAt time.Time
|
sortAt time.Time
|
||||||
payload map[string]any
|
payload map[string]any
|
||||||
}
|
}
|
||||||
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
|
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
|
||||||
for _, g := range seriesGroups {
|
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 {
|
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 {
|
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)
|
total := len(entries)
|
||||||
paged := pageSlice(entries, p.StartIndex, p.Limit)
|
paged := pageSlice(entries, p.StartIndex, p.Limit)
|
||||||
@@ -118,6 +118,19 @@ func embyPayloadCreatedAt(item map[string]any) time.Time {
|
|||||||
return 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) {
|
func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (bool, error) {
|
||||||
if strings.TrimSpace(libraryID) == "" {
|
if strings.TrimSpace(libraryID) == "" {
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type embySeriesGroup struct {
|
|||||||
Overview string
|
Overview string
|
||||||
Rating float32
|
Rating float32
|
||||||
Year int
|
Year int
|
||||||
|
ReleaseDate string
|
||||||
TMDbID int
|
TMDbID int
|
||||||
BangumiID int
|
BangumiID int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -117,13 +118,14 @@ func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGrou
|
|||||||
group, ok := byID[seriesID]
|
group, ok := byID[seriesID]
|
||||||
if !ok {
|
if !ok {
|
||||||
group = &embySeriesGroup{
|
group = &embySeriesGroup{
|
||||||
ID: seriesID,
|
ID: seriesID,
|
||||||
LibraryID: row.LibraryID,
|
LibraryID: row.LibraryID,
|
||||||
Name: e.seriesNameForMedia(&row),
|
Name: e.seriesNameForMedia(&row),
|
||||||
Year: row.Year,
|
Year: row.Year,
|
||||||
TMDbID: row.TMDbID,
|
ReleaseDate: row.ReleaseDate,
|
||||||
BangumiID: row.BangumiID,
|
TMDbID: row.TMDbID,
|
||||||
CreatedAt: row.CreatedAt,
|
BangumiID: row.BangumiID,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
}
|
}
|
||||||
byID[seriesID] = group
|
byID[seriesID] = group
|
||||||
order = append(order, seriesID)
|
order = append(order, seriesID)
|
||||||
@@ -131,6 +133,14 @@ func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGrou
|
|||||||
if row.CreatedAt.After(group.CreatedAt) {
|
if row.CreatedAt.After(group.CreatedAt) {
|
||||||
group.CreatedAt = row.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 != "" {
|
if group.PosterURL == "" && row.PosterURL != "" {
|
||||||
group.PosterURL = row.PosterURL
|
group.PosterURL = row.PosterURL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
)
|
)
|
||||||
@@ -87,7 +88,7 @@ func seasonName(seasonNum int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
|
func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
|
||||||
switch strings.ToLower(p.SortBy) {
|
switch primarySupportedEmbySort(p.SortBy, false) {
|
||||||
case "sortname", "name":
|
case "sortname", "name":
|
||||||
sort.SliceStable(groups, func(i, j int) bool {
|
sort.SliceStable(groups, func(i, j int) bool {
|
||||||
if strings.EqualFold(p.SortOrder, "Descending") {
|
if strings.EqualFold(p.SortOrder, "Descending") {
|
||||||
@@ -95,12 +96,30 @@ func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
|
|||||||
}
|
}
|
||||||
return groups[i].Name < groups[j].Name
|
return groups[i].Name < groups[j].Name
|
||||||
})
|
})
|
||||||
default:
|
case "datecreated":
|
||||||
sort.SliceStable(groups, func(i, j int) bool {
|
sort.SliceStable(groups, func(i, j int) bool {
|
||||||
if strings.EqualFold(p.SortOrder, "Ascending") {
|
if strings.EqualFold(p.SortOrder, "Ascending") {
|
||||||
return groups[i].CreatedAt.Before(groups[j].CreatedAt)
|
return groups[i].CreatedAt.Before(groups[j].CreatedAt)
|
||||||
}
|
}
|
||||||
return groups[i].CreatedAt.After(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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
|||||||
if group.BackdropURL != "" {
|
if group.BackdropURL != "" {
|
||||||
backdropTags = append(backdropTags, group.ID+"-bd")
|
backdropTags = append(backdropTags, group.ID+"-bd")
|
||||||
}
|
}
|
||||||
return map[string]any{
|
item := map[string]any{
|
||||||
"Id": group.ID,
|
"Id": group.ID,
|
||||||
"Name": group.Name,
|
"Name": group.Name,
|
||||||
"ServerId": embyServerID,
|
"ServerId": embyServerID,
|
||||||
@@ -32,6 +32,10 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
|||||||
},
|
},
|
||||||
"UserData": emptyUserData(),
|
"UserData": emptyUserData(),
|
||||||
}
|
}
|
||||||
|
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
|
||||||
|
item["PremiereDate"] = premiered
|
||||||
|
}
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type ExternalMediaResult struct {
|
|||||||
PosterURL string `json:"poster_url,omitempty"`
|
PosterURL string `json:"poster_url,omitempty"`
|
||||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||||
Year int `json:"year,omitempty"`
|
Year int `json:"year,omitempty"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
Rating float32 `json:"rating,omitempty"`
|
Rating float32 `json:"rating,omitempty"`
|
||||||
TMDbID int `json:"tmdb_id,omitempty"`
|
TMDbID int `json:"tmdb_id,omitempty"`
|
||||||
BangumiID int `json:"bangumi_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,
|
PosterURL: m.PosterURL,
|
||||||
BackdropURL: m.BackdropURL,
|
BackdropURL: m.BackdropURL,
|
||||||
Year: m.Year,
|
Year: m.Year,
|
||||||
|
ReleaseDate: m.ReleaseDate,
|
||||||
Rating: m.Rating,
|
Rating: m.Rating,
|
||||||
TMDbID: m.TMDbID,
|
TMDbID: m.TMDbID,
|
||||||
BangumiID: m.BangumiID,
|
BangumiID: m.BangumiID,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ func applyLocalIdentityMetadata(m *model.Media, local *LocalMetadata) {
|
|||||||
if local.Year > 0 {
|
if local.Year > 0 {
|
||||||
m.Year = local.Year
|
m.Year = local.Year
|
||||||
}
|
}
|
||||||
|
if local.ReleaseDate != "" {
|
||||||
|
m.ReleaseDate = local.ReleaseDate
|
||||||
|
}
|
||||||
if local.Overview != "" {
|
if local.Overview != "" {
|
||||||
m.Overview = local.Overview
|
m.Overview = local.Overview
|
||||||
}
|
}
|
||||||
@@ -98,6 +101,7 @@ func localHasDescriptiveMetadata(local *LocalMetadata) bool {
|
|||||||
local.EpisodeTitle != "" ||
|
local.EpisodeTitle != "" ||
|
||||||
local.AdultCode != "" ||
|
local.AdultCode != "" ||
|
||||||
local.Year > 0 ||
|
local.Year > 0 ||
|
||||||
|
local.ReleaseDate != "" ||
|
||||||
local.Overview != "" ||
|
local.Overview != "" ||
|
||||||
local.Rating > 0 ||
|
local.Rating > 0 ||
|
||||||
local.TMDbID > 0 ||
|
local.TMDbID > 0 ||
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe
|
|||||||
OriginalName: cleanXMLText(doc.OriginalTitle),
|
OriginalName: cleanXMLText(doc.OriginalTitle),
|
||||||
AdultCode: normalizeAdultCode(doc.Num),
|
AdultCode: normalizeAdultCode(doc.Num),
|
||||||
Year: int(doc.Year),
|
Year: int(doc.Year),
|
||||||
|
ReleaseDate: normalizeReleaseDate(firstText(doc.Premiered, doc.ReleaseDate, doc.Release, doc.Aired)),
|
||||||
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
|
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
|
||||||
Rating: float32(doc.Rating),
|
Rating: float32(doc.Rating),
|
||||||
PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
|
PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
|
||||||
@@ -48,7 +49,7 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if meta.Year == 0 {
|
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 {
|
if meta.TMDbID == 0 {
|
||||||
meta.TMDbID = tmdbIDFromUniqueIDs(doc.UniqueIDs)
|
meta.TMDbID = tmdbIDFromUniqueIDs(doc.UniqueIDs)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func firstRemoteURL(baseDir string, values ...string) string {
|
func firstRemoteURL(baseDir string, values ...string) string {
|
||||||
@@ -69,6 +70,18 @@ func yearFromDate(value string) int {
|
|||||||
return year
|
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 {
|
func samePath(a, b string) bool {
|
||||||
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
|
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type LocalMetadata struct {
|
|||||||
EpisodeTitle string
|
EpisodeTitle string
|
||||||
AdultCode string
|
AdultCode string
|
||||||
Year int
|
Year int
|
||||||
|
ReleaseDate string
|
||||||
Overview string
|
Overview string
|
||||||
Rating float32
|
Rating float32
|
||||||
PosterURL string
|
PosterURL string
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type ManualScrapeRequest struct {
|
|||||||
PosterURL string `json:"poster_url"`
|
PosterURL string `json:"poster_url"`
|
||||||
BackdropURL string `json:"backdrop_url"`
|
BackdropURL string `json:"backdrop_url"`
|
||||||
Year int `json:"year"`
|
Year int `json:"year"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
Rating float32 `json:"rating"`
|
Rating float32 `json:"rating"`
|
||||||
TMDbID int `json:"tmdb_id"`
|
TMDbID int `json:"tmdb_id"`
|
||||||
BangumiID int `json:"bangumi_id"`
|
BangumiID int `json:"bangumi_id"`
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func mergeManualRequestIntoMatch(match *Match, req ManualScrapeRequest) *Match {
|
|||||||
if req.Year > 0 {
|
if req.Year > 0 {
|
||||||
match.Year = req.Year
|
match.Year = req.Year
|
||||||
}
|
}
|
||||||
|
if value := normalizeReleaseDate(req.ReleaseDate); value != "" {
|
||||||
|
match.ReleaseDate = value
|
||||||
|
}
|
||||||
if req.Rating > 0 {
|
if req.Rating > 0 {
|
||||||
match.Rating = req.Rating
|
match.Rating = req.Rating
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
|||||||
PosterURL: match.PosterURL,
|
PosterURL: match.PosterURL,
|
||||||
BackdropURL: match.BackdropURL,
|
BackdropURL: match.BackdropURL,
|
||||||
Year: match.Year,
|
Year: match.Year,
|
||||||
|
ReleaseDate: match.ReleaseDate,
|
||||||
Rating: match.Rating,
|
Rating: match.Rating,
|
||||||
TMDbID: match.TMDbID,
|
TMDbID: match.TMDbID,
|
||||||
BangumiID: match.BangumiID,
|
BangumiID: match.BangumiID,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type MediaMetadataUpdate struct {
|
|||||||
PosterURL *string `json:"poster_url"`
|
PosterURL *string `json:"poster_url"`
|
||||||
BackdropURL *string `json:"backdrop_url"`
|
BackdropURL *string `json:"backdrop_url"`
|
||||||
Year *int `json:"year"`
|
Year *int `json:"year"`
|
||||||
|
ReleaseDate *string `json:"release_date"`
|
||||||
Rating *float32 `json:"rating"`
|
Rating *float32 `json:"rating"`
|
||||||
SeasonNum *int `json:"season_num"`
|
SeasonNum *int `json:"season_num"`
|
||||||
EpisodeNum *int `json:"episode_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 {
|
if req.Year != nil {
|
||||||
updates["year"] = clampNonNegativeInt(*req.Year)
|
updates["year"] = clampNonNegativeInt(*req.Year)
|
||||||
}
|
}
|
||||||
|
if req.ReleaseDate != nil {
|
||||||
|
updates["release_date"] = normalizeReleaseDate(*req.ReleaseDate)
|
||||||
|
}
|
||||||
if req.Rating != nil {
|
if req.Rating != nil {
|
||||||
updates["rating"] = clampRating(*req.Rating)
|
updates["rating"] = clampRating(*req.Rating)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,17 +24,19 @@ func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) {
|
|||||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||||
title := "手动标题"
|
title := "手动标题"
|
||||||
overview := "手动简介"
|
overview := "手动简介"
|
||||||
|
releaseDate := "2026-06-23"
|
||||||
season := 0
|
season := 0
|
||||||
episode := 1
|
episode := 1
|
||||||
tmdbID := 12345
|
tmdbID := 12345
|
||||||
nsfw := true
|
nsfw := true
|
||||||
updated, err := svc.UpdateMetadata(t.Context(), media.ID, MediaMetadataUpdate{
|
updated, err := svc.UpdateMetadata(t.Context(), media.ID, MediaMetadataUpdate{
|
||||||
Title: &title,
|
Title: &title,
|
||||||
Overview: &overview,
|
Overview: &overview,
|
||||||
SeasonNum: &season,
|
ReleaseDate: &releaseDate,
|
||||||
EpisodeNum: &episode,
|
SeasonNum: &season,
|
||||||
TMDbID: &tmdbID,
|
EpisodeNum: &episode,
|
||||||
NSFW: &nsfw,
|
TMDbID: &tmdbID,
|
||||||
|
NSFW: &nsfw,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("update metadata: %v", err)
|
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 {
|
if updated.SeasonNum != 0 || updated.EpisodeNum != 1 || updated.TMDbID != tmdbID || !updated.NSFW {
|
||||||
t.Fatalf("ids/episode metadata not saved: %#v", updated)
|
t.Fatalf("ids/episode metadata not saved: %#v", updated)
|
||||||
}
|
}
|
||||||
|
if updated.ReleaseDate != releaseDate {
|
||||||
|
t.Fatalf("release date = %q, want %q", updated.ReleaseDate, releaseDate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -146,6 +146,14 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seriesMediaTime(media model.Media) time.Time {
|
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) {
|
if media.UpdatedAt.After(media.CreatedAt) {
|
||||||
return media.UpdatedAt
|
return media.UpdatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,11 @@ func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineR
|
|||||||
scanRoot = filepath.Dir(path)
|
scanRoot = filepath.Dir(path)
|
||||||
}
|
}
|
||||||
preferredLibraryID := p.scanPreferredLibraryID(ctx, req)
|
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 {
|
} else if p.log != nil && res != nil && !req.DryRun {
|
||||||
p.log.Info("organize pipeline skipped scan; no destination changes",
|
p.log.Info("organize pipeline skipped scan; no destination changes",
|
||||||
zap.String("trigger", string(req.Trigger)),
|
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
|
||||||
|
}
|
||||||
@@ -102,6 +102,38 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
|
|||||||
return o.applyOrganizeMedia(ctx, req, dst)
|
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
|
// OrganizeLibrary organizes every media row in a library whose file is
|
||||||
// not already in the expected path structure.
|
// not already in the expected path structure.
|
||||||
func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string) (*OrganizeResult, error) {
|
func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string) (*OrganizeResult, error) {
|
||||||
|
|||||||
@@ -186,3 +186,21 @@ func (o *OrganizerService) applyOrganizeMedia(ctx context.Context, req organizeM
|
|||||||
)
|
)
|
||||||
return dst.path, nil
|
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 {
|
if media.Year > 0 {
|
||||||
updates["year"] = media.Year
|
updates["year"] = media.Year
|
||||||
}
|
}
|
||||||
|
setNonEmptyUpdate(updates, "release_date", media.ReleaseDate)
|
||||||
if media.Rating > 0 {
|
if media.Rating > 0 {
|
||||||
updates["rating"] = media.Rating
|
updates["rating"] = media.Rating
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
|
|||||||
PosterURL: local.PosterURL,
|
PosterURL: local.PosterURL,
|
||||||
BackdropURL: local.BackdropURL,
|
BackdropURL: local.BackdropURL,
|
||||||
Year: local.Year,
|
Year: local.Year,
|
||||||
|
ReleaseDate: local.ReleaseDate,
|
||||||
Rating: local.Rating,
|
Rating: local.Rating,
|
||||||
TMDbID: local.TMDbID,
|
TMDbID: local.TMDbID,
|
||||||
DoubanID: local.DoubanID,
|
DoubanID: local.DoubanID,
|
||||||
@@ -90,6 +91,7 @@ func organizeMatchFromMedia(media *model.Media) *Match {
|
|||||||
PosterURL: media.PosterURL,
|
PosterURL: media.PosterURL,
|
||||||
BackdropURL: media.BackdropURL,
|
BackdropURL: media.BackdropURL,
|
||||||
Year: media.Year,
|
Year: media.Year,
|
||||||
|
ReleaseDate: media.ReleaseDate,
|
||||||
Rating: media.Rating,
|
Rating: media.Rating,
|
||||||
Languages: parseCommaList(media.Languages),
|
Languages: parseCommaList(media.Languages),
|
||||||
Countries: parseCommaList(media.Countries),
|
Countries: parseCommaList(media.Countries),
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ func applyReclassifyMatchUpdates(updates map[string]any, match *Match) {
|
|||||||
if match.Year > 0 {
|
if match.Year > 0 {
|
||||||
updates["year"] = match.Year
|
updates["year"] = match.Year
|
||||||
}
|
}
|
||||||
|
if value := strings.TrimSpace(match.ReleaseDate); value != "" {
|
||||||
|
updates["release_date"] = value
|
||||||
|
}
|
||||||
if match.TMDbID > 0 {
|
if match.TMDbID > 0 {
|
||||||
updates["tm_db_id"] = match.TMDbID
|
updates["tm_db_id"] = match.TMDbID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ func mediaWithReclassifyMatch(media model.Media, match *Match) model.Media {
|
|||||||
if match.Year > 0 {
|
if match.Year > 0 {
|
||||||
media.Year = match.Year
|
media.Year = match.Year
|
||||||
}
|
}
|
||||||
|
if value := strings.TrimSpace(match.ReleaseDate); value != "" {
|
||||||
|
media.ReleaseDate = value
|
||||||
|
}
|
||||||
if match.TMDbID > 0 {
|
if match.TMDbID > 0 {
|
||||||
media.TMDbID = match.TMDbID
|
media.TMDbID = match.TMDbID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestOrganizeMediaRefreshesMetadataBeforeRename(t *testing.T) {
|
||||||
scraper, repos, closeServer := newTestScraper(t)
|
scraper, repos, closeServer := newTestScraper(t)
|
||||||
defer closeServer()
|
defer closeServer()
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ type existingCloudMedia struct {
|
|||||||
STRMURL string
|
STRMURL string
|
||||||
Overview string
|
Overview string
|
||||||
Year int
|
Year int
|
||||||
|
ReleaseDate string
|
||||||
Rating float32
|
Rating float32
|
||||||
TMDbID int
|
TMDbID int
|
||||||
BangumiID int
|
BangumiID int
|
||||||
@@ -286,6 +287,7 @@ type existingLocalMedia struct {
|
|||||||
BackdropURL string
|
BackdropURL string
|
||||||
Overview string
|
Overview string
|
||||||
Year int
|
Year int
|
||||||
|
ReleaseDate string
|
||||||
Rating float32
|
Rating float32
|
||||||
TMDbID int
|
TMDbID int
|
||||||
BangumiID int
|
BangumiID int
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func mergeMatchIntoLocalMetadata(meta *LocalMetadata, match *Match) {
|
|||||||
if match.Year > 0 {
|
if match.Year > 0 {
|
||||||
meta.Year = match.Year
|
meta.Year = match.Year
|
||||||
}
|
}
|
||||||
|
if match.ReleaseDate != "" {
|
||||||
|
meta.ReleaseDate = match.ReleaseDate
|
||||||
|
}
|
||||||
if match.Overview != "" {
|
if match.Overview != "" {
|
||||||
meta.Overview = match.Overview
|
meta.Overview = match.Overview
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Cont
|
|||||||
var rows []model.Media
|
var rows []model.Media
|
||||||
if err := s.repo.DB.WithContext(ctx).
|
if err := s.repo.DB.WithContext(ctx).
|
||||||
Model(&model.Media{}).
|
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://%").
|
Where("library_id IN ? AND path LIKE ?", libraryIDs, "cloud://%").
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -46,6 +46,7 @@ func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Cont
|
|||||||
STRMURL: row.STRMURL,
|
STRMURL: row.STRMURL,
|
||||||
Overview: row.Overview,
|
Overview: row.Overview,
|
||||||
Year: row.Year,
|
Year: row.Year,
|
||||||
|
ReleaseDate: row.ReleaseDate,
|
||||||
Rating: row.Rating,
|
Rating: row.Rating,
|
||||||
TMDbID: row.TMDbID,
|
TMDbID: row.TMDbID,
|
||||||
BangumiID: row.BangumiID,
|
BangumiID: row.BangumiID,
|
||||||
@@ -67,7 +68,7 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library
|
|||||||
var rows []model.Media
|
var rows []model.Media
|
||||||
if err := s.repo.DB.WithContext(ctx).
|
if err := s.repo.DB.WithContext(ctx).
|
||||||
Model(&model.Media{}).
|
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://%").
|
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%").
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -96,6 +97,7 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library
|
|||||||
BackdropURL: row.BackdropURL,
|
BackdropURL: row.BackdropURL,
|
||||||
Overview: row.Overview,
|
Overview: row.Overview,
|
||||||
Year: row.Year,
|
Year: row.Year,
|
||||||
|
ReleaseDate: row.ReleaseDate,
|
||||||
Rating: row.Rating,
|
Rating: row.Rating,
|
||||||
TMDbID: row.TMDbID,
|
TMDbID: row.TMDbID,
|
||||||
BangumiID: row.BangumiID,
|
BangumiID: row.BangumiID,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type scanDerivedMetadata struct {
|
|||||||
Title string
|
Title string
|
||||||
ScrapeStatus string
|
ScrapeStatus string
|
||||||
Year int
|
Year int
|
||||||
|
ReleaseDate string
|
||||||
TMDbID int
|
TMDbID int
|
||||||
BangumiID int
|
BangumiID int
|
||||||
DoubanID string
|
DoubanID string
|
||||||
@@ -43,6 +44,9 @@ func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMeta
|
|||||||
if localMeta.Year > 0 && existing.Year != localMeta.Year {
|
if localMeta.Year > 0 && existing.Year != localMeta.Year {
|
||||||
return true
|
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) {
|
if localMeta.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(localMeta.Overview) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -131,6 +135,9 @@ func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata
|
|||||||
if local.Year > 0 && existing.Year != local.Year {
|
if local.Year > 0 && existing.Year != local.Year {
|
||||||
return true
|
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) {
|
if local.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(local.Overview) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -181,6 +188,7 @@ func cloudDerivedMetadataNeedsRefresh(existing existingCloudMedia, incoming *mod
|
|||||||
Title: existing.Title,
|
Title: existing.Title,
|
||||||
ScrapeStatus: existing.ScrapeStatus,
|
ScrapeStatus: existing.ScrapeStatus,
|
||||||
Year: existing.Year,
|
Year: existing.Year,
|
||||||
|
ReleaseDate: existing.ReleaseDate,
|
||||||
TMDbID: existing.TMDbID,
|
TMDbID: existing.TMDbID,
|
||||||
BangumiID: existing.BangumiID,
|
BangumiID: existing.BangumiID,
|
||||||
DoubanID: existing.DoubanID,
|
DoubanID: existing.DoubanID,
|
||||||
@@ -204,6 +212,7 @@ func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *mod
|
|||||||
Title: existing.Title,
|
Title: existing.Title,
|
||||||
ScrapeStatus: existing.ScrapeStatus,
|
ScrapeStatus: existing.ScrapeStatus,
|
||||||
Year: existing.Year,
|
Year: existing.Year,
|
||||||
|
ReleaseDate: existing.ReleaseDate,
|
||||||
TMDbID: existing.TMDbID,
|
TMDbID: existing.TMDbID,
|
||||||
BangumiID: existing.BangumiID,
|
BangumiID: existing.BangumiID,
|
||||||
DoubanID: existing.DoubanID,
|
DoubanID: existing.DoubanID,
|
||||||
@@ -222,6 +231,9 @@ func scanDerivedMetadataNeedsRefresh(existing scanDerivedMetadata, incoming *mod
|
|||||||
if enrichable && incoming.Year > 0 && existing.Year != incoming.Year {
|
if enrichable && incoming.Year > 0 && existing.Year != incoming.Year {
|
||||||
return true
|
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 {
|
if (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
|||||||
"year": match.Year,
|
"year": match.Year,
|
||||||
"scrape_status": "matched",
|
"scrape_status": "matched",
|
||||||
}
|
}
|
||||||
|
if match.ReleaseDate != "" {
|
||||||
|
updates["release_date"] = match.ReleaseDate
|
||||||
|
}
|
||||||
if match.OriginalName != "" {
|
if match.OriginalName != "" {
|
||||||
updates["original_name"] = match.OriginalName
|
updates["original_name"] = match.OriginalName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) {
|
|||||||
if local.Year > 0 {
|
if local.Year > 0 {
|
||||||
match.Year = local.Year
|
match.Year = local.Year
|
||||||
}
|
}
|
||||||
|
if local.ReleaseDate != "" {
|
||||||
|
match.ReleaseDate = local.ReleaseDate
|
||||||
|
}
|
||||||
if local.TMDbID > 0 {
|
if local.TMDbID > 0 {
|
||||||
match.TMDbID = local.TMDbID
|
match.TMDbID = local.TMDbID
|
||||||
}
|
}
|
||||||
@@ -150,6 +153,9 @@ func mergeScrapePathHintMetadata(dst, src *LocalMetadata) *LocalMetadata {
|
|||||||
if dst.Year <= 0 && src.Year > 0 {
|
if dst.Year <= 0 && src.Year > 0 {
|
||||||
dst.Year = src.Year
|
dst.Year = src.Year
|
||||||
}
|
}
|
||||||
|
if dst.ReleaseDate == "" && src.ReleaseDate != "" {
|
||||||
|
dst.ReleaseDate = src.ReleaseDate
|
||||||
|
}
|
||||||
if src.TMDbID > 0 {
|
if src.TMDbID > 0 {
|
||||||
dst.TMDbID = src.TMDbID
|
dst.TMDbID = src.TMDbID
|
||||||
}
|
}
|
||||||
@@ -204,6 +210,9 @@ func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.M
|
|||||||
if next.Year > 0 {
|
if next.Year > 0 {
|
||||||
updates["year"] = next.Year
|
updates["year"] = next.Year
|
||||||
}
|
}
|
||||||
|
if next.ReleaseDate != "" {
|
||||||
|
updates["release_date"] = next.ReleaseDate
|
||||||
|
}
|
||||||
if next.TMDbID > 0 {
|
if next.TMDbID > 0 {
|
||||||
updates["tm_db_id"] = next.TMDbID
|
updates["tm_db_id"] = next.TMDbID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
|
|||||||
if r.BackdropPath != "" {
|
if r.BackdropPath != "" {
|
||||||
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
|
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
|
||||||
}
|
}
|
||||||
|
m.ReleaseDate = normalizeReleaseDate(r.ReleaseDate)
|
||||||
if len(r.ReleaseDate) >= 4 {
|
if len(r.ReleaseDate) >= 4 {
|
||||||
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
|
_, _ = 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 != "" {
|
if r.BackdropPath != "" {
|
||||||
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
|
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
|
||||||
}
|
}
|
||||||
|
m.ReleaseDate = normalizeReleaseDate(r.FirstAirDate)
|
||||||
if len(r.FirstAirDate) >= 4 {
|
if len(r.FirstAirDate) >= 4 {
|
||||||
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
|
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match
|
|||||||
if r.BackdropPath != "" {
|
if r.BackdropPath != "" {
|
||||||
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
|
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
|
||||||
}
|
}
|
||||||
|
m.ReleaseDate = normalizeReleaseDate(r.ReleaseDate)
|
||||||
if len(r.ReleaseDate) >= 4 {
|
if len(r.ReleaseDate) >= 4 {
|
||||||
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
|
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
|
||||||
}
|
}
|
||||||
@@ -184,6 +185,7 @@ func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match {
|
|||||||
if r.BackdropPath != "" {
|
if r.BackdropPath != "" {
|
||||||
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
|
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
|
||||||
}
|
}
|
||||||
|
m.ReleaseDate = normalizeReleaseDate(r.FirstAirDate)
|
||||||
if len(r.FirstAirDate) >= 4 {
|
if len(r.FirstAirDate) >= 4 {
|
||||||
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
|
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Match struct {
|
|||||||
PosterURL string `json:"poster_url"`
|
PosterURL string `json:"poster_url"`
|
||||||
BackdropURL string `json:"backdrop_url"`
|
BackdropURL string `json:"backdrop_url"`
|
||||||
Year int `json:"year"`
|
Year int `json:"year"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
Rating float32 `json:"rating"`
|
Rating float32 `json:"rating"`
|
||||||
Languages []string `json:"languages,omitempty"`
|
Languages []string `json:"languages,omitempty"`
|
||||||
Countries []string `json:"countries,omitempty"`
|
Countries []string `json:"countries,omitempty"`
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface ManualScrapeCandidate {
|
|||||||
poster_url?: string
|
poster_url?: string
|
||||||
backdrop_url?: string
|
backdrop_url?: string
|
||||||
year?: number
|
year?: number
|
||||||
|
release_date?: string
|
||||||
rating?: number
|
rating?: number
|
||||||
tmdb_id?: number
|
tmdb_id?: number
|
||||||
bangumi_id?: number
|
bangumi_id?: number
|
||||||
@@ -66,6 +67,7 @@ export interface MediaMetadataUpdate {
|
|||||||
poster_url?: string
|
poster_url?: string
|
||||||
backdrop_url?: string
|
backdrop_url?: string
|
||||||
year?: number
|
year?: number
|
||||||
|
release_date?: string
|
||||||
rating?: number
|
rating?: number
|
||||||
season_num?: number
|
season_num?: number
|
||||||
episode_num?: number
|
episode_num?: number
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function MetadataEditDialog({
|
|||||||
poster_url: '',
|
poster_url: '',
|
||||||
backdrop_url: '',
|
backdrop_url: '',
|
||||||
year: '',
|
year: '',
|
||||||
|
release_date: '',
|
||||||
rating: '',
|
rating: '',
|
||||||
season_num: '',
|
season_num: '',
|
||||||
episode_num: '',
|
episode_num: '',
|
||||||
@@ -54,6 +55,7 @@ export function MetadataEditDialog({
|
|||||||
poster_url: media.poster_url || '',
|
poster_url: media.poster_url || '',
|
||||||
backdrop_url: media.backdrop_url || '',
|
backdrop_url: media.backdrop_url || '',
|
||||||
year: media.year > 0 ? String(media.year) : '',
|
year: media.year > 0 ? String(media.year) : '',
|
||||||
|
release_date: media.release_date || '',
|
||||||
rating: media.rating > 0 ? String(media.rating) : '',
|
rating: media.rating > 0 ? String(media.rating) : '',
|
||||||
season_num: media.season_num > 0 || media.episode_num > 0 ? String(media.season_num || 0) : '',
|
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 ? String(media.episode_num) : '',
|
||||||
@@ -89,6 +91,7 @@ export function MetadataEditDialog({
|
|||||||
poster_url: form.poster_url,
|
poster_url: form.poster_url,
|
||||||
backdrop_url: form.backdrop_url,
|
backdrop_url: form.backdrop_url,
|
||||||
year: Math.trunc(toNumber(form.year)),
|
year: Math.trunc(toNumber(form.year)),
|
||||||
|
release_date: form.release_date,
|
||||||
rating: toNumber(form.rating),
|
rating: toNumber(form.rating),
|
||||||
tmdb_id: Math.trunc(toNumber(form.tmdb_id)),
|
tmdb_id: Math.trunc(toNumber(form.tmdb_id)),
|
||||||
bangumi_id: Math.trunc(toNumber(form.bangumi_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.poster_url} onChange={(value) => set('poster_url', value)} />
|
||||||
<Field label="背景 / 单集剧照 URL" value={form.backdrop_url} onChange={(value) => set('backdrop_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.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" />
|
<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.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" />}
|
{!isSeries && <Field label="集" value={form.episode_num} onChange={(value) => set('episode_num', value)} inputMode="numeric" />}
|
||||||
@@ -198,18 +202,21 @@ function Field({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
type = 'text',
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
inputMode?: 'numeric' | 'decimal'
|
inputMode?: 'numeric' | 'decimal'
|
||||||
|
type?: 'text' | 'date'
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label>
|
<label>
|
||||||
<span className="mb-1 block text-xs font-bold text-gray-500">{label}</span>
|
<span className="mb-1 block text-xs font-bold text-gray-500">{label}</span>
|
||||||
<input
|
<input
|
||||||
value={value}
|
value={value}
|
||||||
|
type={type}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
inputMode={inputMode}
|
inputMode={inputMode}
|
||||||
|
|||||||
@@ -19,5 +19,8 @@ export function latestLibraryCards(items: Media[]): SeriesCard[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mediaTime(media: Media): number {
|
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
|
return Date.parse(media.updated_at || media.created_at || '') || 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface Media {
|
|||||||
overview?: string
|
overview?: string
|
||||||
rating: number
|
rating: number
|
||||||
year: number
|
year: number
|
||||||
|
release_date?: string
|
||||||
season_num: number
|
season_num: number
|
||||||
episode_num: number
|
episode_num: number
|
||||||
scrape_status: string
|
scrape_status: string
|
||||||
|
|||||||
Reference in New Issue
Block a user