Compare commits

...

2 Commits

Author SHA1 Message Date
github-actions[bot] 165eee7b36 chore: bump version to 0.0.62 [skip ci] 2026-09-01 13:25:14 +00:00
truewhile 78526afc9c 优化 2026-09-01 21:24:55 +08:00
6 changed files with 217 additions and 26 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.61
0.0.62
+25
View File
@@ -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
}
@@ -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)
}
}
+50 -13
View File
@@ -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
}
+67
View File
@@ -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)
}
}
+12 -12
View File
@@ -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() {
<div
key={m.id}
onDragOver={(e) => handleDragOver(e, m.id)}
onDrop={() => handleDrop(m.id, acct.id)}
onDrop={(e) => handleDrop(e, m.id, acct.id)}
className={`flex items-center gap-3 rounded-xl border px-3 py-3 transition-colors ${
draggingId === m.id
? 'border-dashed border-brand-400 bg-brand-50/20 opacity-50'