From 78526afc9c05735eb128fbdcc9e57b2c4044324e Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:24:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/database/schema_migration.go | 25 +++++++ .../schema_migration_emby_mount_test.go | 62 +++++++++++++++++ internal/service/emby_remote_web.go | 63 +++++++++++++---- internal/service/emby_remote_web_test.go | 67 +++++++++++++++++++ web/src/pages/EmbyMountPage.tsx | 24 +++---- 5 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 internal/database/schema_migration_emby_mount_test.go create mode 100644 internal/service/emby_remote_web_test.go diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index c335b78..dd5b108 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -20,6 +20,9 @@ func AutoMigrate(db *gorm.DB) error { if err := ensureLibraryRootsCompatibility(db); err != nil { return err } + if err := ensureEmbyMountsCompatibility(db); err != nil { + return err + } if isSQLite(db) { return ensureMediaSearchIndex(db) } @@ -77,3 +80,25 @@ func ensurePerformanceIndexes(db *gorm.DB) error { } return nil } + +func ensureEmbyMountsCompatibility(db *gorm.DB) error { + if !db.Migrator().HasTable(&model.EmbyMount{}) { + return nil + } + if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") { + if err := db.Migrator().AddColumn(&model.EmbyMount{}, "sort_order"); err != nil { + return err + } + } + // 针对已有数据:如果存在多个 sort_order=0/NULL 的记录,按创建时间顺序赋予稳定递增的序号 + var zeroCount int64 + if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 1 { + var mounts []model.EmbyMount + if err := db.Order("created_at asc, id asc").Find(&mounts).Error; err == nil { + for i, m := range mounts { + _ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", i, m.ID).Error + } + } + } + return nil +} diff --git a/internal/database/schema_migration_emby_mount_test.go b/internal/database/schema_migration_emby_mount_test.go new file mode 100644 index 0000000..84b6e12 --- /dev/null +++ b/internal/database/schema_migration_emby_mount_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/ShukeBta/MMTL/internal/model" +) + +func TestEnsureEmbyMountsCompatibility(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + + // Create a table without sort_order simulating an older schema + if err := db.Exec(`CREATE TABLE emby_mounts ( + id varchar(36) PRIMARY KEY, + created_at datetime, + updated_at datetime, + deleted_at datetime, + account_id text, + remote_view_id text, + remote_view_name text, + collection_type text, + name text, + proxy_play numeric DEFAULT false, + enabled numeric DEFAULT true + )`).Error; err != nil { + t.Fatal(err) + } + + // Insert older rows + now := time.Now() + _ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m1", "Mount 1", now.Add(-2*time.Hour)).Error + _ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m2", "Mount 2", now.Add(-1*time.Hour)).Error + + // Run compatibility migration + if err := ensureEmbyMountsCompatibility(db); err != nil { + t.Fatalf("ensureEmbyMountsCompatibility failed: %v", err) + } + + // Verify column sort_order exists and values are initialized sequentially + if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") { + t.Fatal("expected sort_order column to be added") + } + + var m1, m2 model.EmbyMount + if err := db.Where("id = ?", "m1").First(&m1).Error; err != nil { + t.Fatal(err) + } + if err := db.Where("id = ?", "m2").First(&m2).Error; err != nil { + t.Fatal(err) + } + + if m1.SortOrder != 0 || m2.SortOrder != 1 { + t.Fatalf("unexpected sort orders: m1=%d, m2=%d", m1.SortOrder, m2.SortOrder) + } +} diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 27e0a07..8d4750b 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "time" "go.uber.org/zap" @@ -146,13 +147,13 @@ func (r *EmbyRemoteService) mapRemoteMountToLibrary(mount *model.EmbyMount, acct case "music": libType = "music" } - lib := &model.Library{ - Base: model.Base{ID: EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)}, - Name: name, - Type: libType, - Enabled: true, - SortOrder: 1000 + mount.SortOrder, // 远程库排在本地库之后,且保持挂载库排序 - } + lib := &model.Library{ + Base: model.Base{ID: EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)}, + Name: name, + Type: libType, + Enabled: true, + SortOrder: 1000 + mount.SortOrder, // 远程库排在本地库之后,且保持挂载库排序 + } // 远程媒体库封面只有真实存在图片标签才下发。 if remoteItemHasImageTag(item, "Primary") { lib.CoverURL = r.remoteItemImageURL(cfg, mount.RemoteViewID, "Primary") @@ -177,17 +178,25 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok { seriesID = rid } + rating := remoteItemFloat(item, "CommunityRating") + if rating == 0 { + rating = remoteItemFloat(item, "CriticRating") + } media := model.Media{ Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)}, Title: remoteItemString(item, "Name"), OriginalName: remoteItemString(item, "OriginalTitle"), Overview: remoteItemString(item, "Overview"), Year: remoteItemInt(item, "ProductionYear"), - Rating: float32(remoteItemFloat(item, "CommunityRating")), + Rating: float32(rating), Path: remoteItemString(item, "Path"), Genres: remoteItemGenres(item), ScrapeStatus: "done", } + if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok { + media.CreatedAt = date + media.UpdatedAt = date + } // 只有远程明确存在图片标签才下发图片 URL。 if remoteItemHasImageTag(item, "Primary") { media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary") @@ -198,8 +207,16 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod if ticks := remoteItemInt64(item, "RunTimeTicks"); ticks > 0 { media.DurationSec = int(ticks / 10_000_000) } - if date, ok := embyPremiereDate(remoteItemString(item, "PremiereDate")); ok { + if date, ok := parseEmbyRemoteDate(remoteItemString(item, "PremiereDate")); ok { media.ReleaseDate = date.Format("2006-01-02") + if media.Year == 0 { + media.Year = date.Year() + } + } else if date, ok := embyPremiereDate(remoteItemString(item, "PremiereDate")); ok { + media.ReleaseDate = date.Format("2006-01-02") + if media.Year == 0 { + media.Year = date.Year() + } } if providerIDs, ok := item["ProviderIds"].(map[string]any); ok { if v := anyString(providerIDs["Tmdb"]); v != "" { @@ -309,7 +326,7 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model q.Set("Recursive", "false") q.Set("StartIndex", strconv.Itoa(offset)) q.Set("Limit", strconv.Itoa(limit)) - q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { Items []map[string]any `json:"Items"` TotalRecordCount int64 `json:"TotalRecordCount"` @@ -338,7 +355,7 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model. return nil, err } path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID) - path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources" + path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating" var out map[string]any if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil { return nil, err @@ -399,7 +416,7 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E q.Set("Recursive", "true") q.Set("StartIndex", "0") q.Set("Limit", "500") - q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { Items []map[string]any `json:"Items"` TotalRecordCount int64 `json:"TotalRecordCount"` @@ -433,7 +450,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. q.Set("Recursive", "false") q.Set("StartIndex", "0") q.Set("Limit", "1000") - q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { Items []map[string]any `json:"Items"` } @@ -696,3 +713,23 @@ func RemoteItemCollectionType(item map[string]any) string { // RemoteItemChildCount 提取远程 View 条目的 ChildCount。 func RemoteItemChildCount(item map[string]any) int { return remoteItemInt(item, "ChildCount") } + +func parseEmbyRemoteDate(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false + } + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.9999999Z", + "2006-01-02T15:04:05.9999999", + "2006-01-02T15:04:05", + "2006-01-02", + } { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/internal/service/emby_remote_web_test.go b/internal/service/emby_remote_web_test.go new file mode 100644 index 0000000..7b7c450 --- /dev/null +++ b/internal/service/emby_remote_web_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/ShukeBta/MMTL/internal/model" +) + +func TestMapRemoteItemToMediaSortingFields(t *testing.T) { + svc := &EmbyRemoteService{} + mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}} + acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}} + cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"} + + item := map[string]any{ + "Id": "item-1", + "Name": "测试电影", + "OriginalTitle": "Test Movie", + "ProductionYear": 2023, + "CommunityRating": 8.5, + "PremiereDate": "2023-05-12T00:00:00.0000000Z", + "DateCreated": "2024-01-15T08:30:00.0000000Z", + } + + media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item) + + if media.ReleaseDate != "2023-05-12" { + t.Fatalf("ReleaseDate = %q, want %q", media.ReleaseDate, "2023-05-12") + } + if media.Year != 2023 { + t.Fatalf("Year = %d, want 2023", media.Year) + } + if media.Rating != 8.5 { + t.Fatalf("Rating = %f, want 8.5", media.Rating) + } + expectedCreated, _ := time.Parse(time.RFC3339, "2024-01-15T08:30:00Z") + if !media.CreatedAt.Equal(expectedCreated) { + t.Fatalf("CreatedAt = %v, want %v", media.CreatedAt, expectedCreated) + } + if !media.UpdatedAt.Equal(expectedCreated) { + t.Fatalf("UpdatedAt = %v, want %v", media.UpdatedAt, expectedCreated) + } +} + +func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) { + svc := &EmbyRemoteService{} + mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}} + acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}} + cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"} + + item := map[string]any{ + "Id": "item-2", + "Name": "评分测试", + "CriticRating": 9.2, + "PremiereDate": "2022-10-01", + } + + media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item) + if media.Rating != 9.2 { + t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating) + } + if media.Year != 2022 { + t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year) + } +} diff --git a/web/src/pages/EmbyMountPage.tsx b/web/src/pages/EmbyMountPage.tsx index a803a69..0081759 100644 --- a/web/src/pages/EmbyMountPage.tsx +++ b/web/src/pages/EmbyMountPage.tsx @@ -310,10 +310,11 @@ export function EmbyMountPage() { const applyReorder = async (nextMounts: EmbyMount[]) => { const prevMounts = mounts - setMounts(nextMounts) + const updated = nextMounts.map((m, idx) => ({ ...m, sort_order: idx })) + setMounts(updated) setReordering(true) try { - await embyAPI.reorderMounts(nextMounts.map((m) => m.id)) + await embyAPI.reorderMounts(updated.map((m) => m.id)) toast.success('媒体库顺序已更新') } catch (err) { setMounts(prevMounts) @@ -347,6 +348,7 @@ export function EmbyMountPage() { const handleDragStart = (e: React.DragEvent, id: string) => { e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', id) setDraggingId(id) } @@ -358,18 +360,18 @@ export function EmbyMountPage() { } } - const handleDrop = (overId: string, accountId: string) => { - if (!draggingId || draggingId === overId) { - setDraggingId(null) - setDragOverId(null) + const handleDrop = (e: React.DragEvent, overId: string, accountId: string) => { + e.preventDefault() + const fromId = draggingId || e.dataTransfer.getData('text/plain') + setDraggingId(null) + setDragOverId(null) + if (!fromId || fromId === overId) { return } const acctMounts = mounts.filter((item) => item.account_id === accountId) - const fromIdx = acctMounts.findIndex((item) => item.id === draggingId) + const fromIdx = acctMounts.findIndex((item) => item.id === fromId) const toIdx = acctMounts.findIndex((item) => item.id === overId) if (fromIdx < 0 || toIdx < 0) { - setDraggingId(null) - setDragOverId(null) return } const newAcctMounts = [...acctMounts] @@ -383,8 +385,6 @@ export function EmbyMountPage() { } return item }) - setDraggingId(null) - setDragOverId(null) applyReorder(nextMounts) } @@ -544,7 +544,7 @@ export function EmbyMountPage() {