Files
MeBox/internal/repository/repository_test.go
T
truewhile 44ca451cd4 优化
优化
2026-08-24 17:40:43 +08:00

536 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"context"
"errors"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestMediaUpsertSkipsUnchangedExistingRow(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/movie", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "已有影片",
Path: "/media/movie/existing.mkv",
SizeBytes: 1024,
DurationSec: 60,
Width: 1920,
Height: 1080,
VideoCodec: "h264",
AudioCodec: "aac",
Container: "matroska,webm",
ScrapeStatus: "pending",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
var before model.Media
if err := repos.DB.Where("path = ?", media.Path).First(&before).Error; err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
again := model.Media{
LibraryID: lib.ID,
Title: before.Title,
Path: before.Path,
SizeBytes: before.SizeBytes,
DurationSec: before.DurationSec,
Width: before.Width,
Height: before.Height,
VideoCodec: before.VideoCodec,
AudioCodec: before.AudioCodec,
Container: before.Container,
ScrapeStatus: before.ScrapeStatus,
}
if err := repos.Media.Upsert(t.Context(), &again); err != nil {
t.Fatal(err)
}
var after model.Media
if err := repos.DB.Where("path = ?", media.Path).First(&after).Error; err != nil {
t.Fatal(err)
}
if !after.UpdatedAt.Equal(before.UpdatedAt) {
t.Fatalf("unchanged upsert touched updated_at: before=%s after=%s", before.UpdatedAt, after.UpdatedAt)
}
}
func TestMediaUpsertRefreshesCloudExternalIDFromPathHint(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: "OpenList · 国产剧", Path: "cloud://openlist/国产剧", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv"
existing := model.Media{
LibraryID: lib.ID,
Title: "折腰",
Path: path,
SeasonNum: 1,
EpisodeNum: 1,
TMDbID: 220269,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &existing); err != nil {
t.Fatal(err)
}
next := model.Media{
LibraryID: lib.ID,
Title: "折腰",
Path: path,
SeasonNum: 1,
EpisodeNum: 1,
TMDbID: 296753,
}
if err := repos.Media.Upsert(t.Context(), &next); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.Where("path = ?", path).First(&got).Error; err != nil {
t.Fatal(err)
}
if got.TMDbID != 296753 || got.ScrapeStatus != "pending" {
t.Fatalf("cloud path hint should refresh tmdb and retry scrape, got tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus)
}
}
func TestMediaUpsertMatchedIncomingRefreshesScrapedMetadata(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)
}
path := "/media/tv/show/S01E01.mkv"
existing := model.Media{
LibraryID: lib.ID,
Title: "扫描标题",
Path: path,
ScrapeStatus: "no_match",
PosterURL: "/old-poster.jpg",
}
if err := repos.Media.Upsert(t.Context(), &existing); err != nil {
t.Fatal(err)
}
incoming := model.Media{
LibraryID: lib.ID,
Title: "中文剧名",
OriginalName: "Original Show",
EpisodeTitle: "第一集",
Path: path,
PosterURL: "/poster.jpg",
BackdropURL: "/backdrop.jpg",
Overview: "剧情简介",
Rating: 8.6,
Year: 2026,
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "matched",
TMDbID: 123,
BangumiID: 456,
DoubanID: "db-1",
TheTVDBID: "tvdb-1",
Languages: "zh,en",
Countries: "CN",
Genres: "剧情,悬疑",
NSFW: true,
}
if err := repos.Media.Upsert(t.Context(), &incoming); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.Where("path = ?", path).First(&got).Error; err != nil {
t.Fatal(err)
}
if got.Title != "中文剧名" || got.OriginalName != "Original Show" || got.EpisodeTitle != "第一集" {
t.Fatalf("matched names not refreshed: %#v", got)
}
if got.PosterURL != "/poster.jpg" || got.BackdropURL != "/backdrop.jpg" || got.Overview != "剧情简介" {
t.Fatalf("matched artwork/overview not refreshed: %#v", got)
}
if got.ScrapeStatus != "matched" || got.TMDbID != 123 || got.BangumiID != 456 || got.DoubanID != "db-1" || got.TheTVDBID != "tvdb-1" {
t.Fatalf("matched provider metadata not refreshed: %#v", got)
}
if got.Year != 2026 || got.SeasonNum != 1 || got.EpisodeNum != 1 || got.Rating != 8.6 || got.Languages != "zh,en" || got.Countries != "CN" || got.Genres != "剧情,悬疑" || !got.NSFW {
t.Fatalf("matched detail metadata not refreshed: %#v", got)
}
}
func TestListByLibraryOrdersByReleaseDate(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
lib := model.Library{Name: "国产剧", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{LibraryID: lib.ID, Title: "旧片但最近更新", Path: "/media/tv/old.mkv", Year: 2026, ReleaseDate: "2026-01-10", ScrapeStatus: "matched"},
{LibraryID: lib.ID, Title: "最新首播", Path: "/media/tv/newest.mkv", Year: 2026, ReleaseDate: "2026-06-23", ScrapeStatus: "matched"},
{LibraryID: lib.ID, Title: "无完整日期", Path: "/media/tv/year-only.mkv", Year: 2025, ScrapeStatus: "matched"},
}
for i := range rows {
if err := repos.Media.Upsert(t.Context(), &rows[i]); err != nil {
t.Fatal(err)
}
}
items, total, err := repos.Media.ListByLibrary(t.Context(), lib.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
if total != 3 || len(items) != 3 {
t.Fatalf("list total=%d len=%d", total, len(items))
}
if got := []string{items[0].Title, items[1].Title, items[2].Title}; got[0] != "最新首播" || got[1] != "旧片但最近更新" || got[2] != "无完整日期" {
t.Fatalf("release-date order = %#v", got)
}
}
func TestMediaUpsertScanDoesNotClearMatchedMetadata(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
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)
}
path := "/media/tv/间谍过家家/Season 01/间谍过家家 - S01E01.mkv"
existing := model.Media{
LibraryID: lib.ID,
Title: "间谍过家家",
OriginalName: "SPY×FAMILY",
Path: path,
PosterURL: "/poster.jpg",
BackdropURL: "/backdrop.jpg",
Overview: "剧情简介",
Year: 2022,
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "matched",
TMDbID: 12345,
BangumiID: 67890,
DoubanID: "db-spy",
TheTVDBID: "tvdb-spy",
}
if err := repos.Media.Upsert(t.Context(), &existing); err != nil {
t.Fatal(err)
}
scan := model.Media{
LibraryID: lib.ID,
Title: "Spy.x.Family.S01E01.2022.1080p.WEB-DL",
Path: path,
SizeBytes: 2048,
DurationSec: 1500,
Width: 1920,
Height: 1080,
VideoCodec: "h264",
AudioCodec: "aac",
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 1,
}
if err := repos.Media.Upsert(t.Context(), &scan); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.Where("path = ?", path).First(&got).Error; err != nil {
t.Fatal(err)
}
if got.Title != "间谍过家家" || got.OriginalName != "SPY×FAMILY" || got.ScrapeStatus != "matched" {
t.Fatalf("matched names/status were overwritten by scan: %#v", got)
}
if got.TMDbID != 12345 || got.BangumiID != 67890 || got.DoubanID != "db-spy" || got.TheTVDBID != "tvdb-spy" {
t.Fatalf("matched provider ids were cleared by scan: %#v", got)
}
if got.PosterURL != "/poster.jpg" || got.BackdropURL != "/backdrop.jpg" || got.Overview != "剧情简介" {
t.Fatalf("matched artwork/overview were overwritten by scan: %#v", got)
}
if got.SizeBytes != 2048 || got.DurationSec != 1500 || got.Width != 1920 || got.Height != 1080 || got.Container != "mkv" {
t.Fatalf("file scan fields were not refreshed: %#v", got)
}
if scan.ID != got.ID || scan.Title != got.Title || scan.TMDbID != got.TMDbID || scan.ScrapeStatus != "matched" {
t.Fatalf("upsert caller did not receive fresh matched row: %#v want %#v", scan, got)
}
}
// TestMediaUpsertMigratesCloudLibraryIDOnRescan 复现"一键挂载子目录后媒体消失"的
// 回归:同一 cloud:// 文件先被父目录库扫描入库,之后用户按二级分类重新挂载到更
// 精确的分类库并扫描,library_id 必须迁移到新分类库,否则媒体被钉死在旧库、新库
// 视图里看不到。本地媒体物理位置固定,不参与迁移。
func TestMediaUpsertMigratesCloudLibraryIDOnRescan(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)
parent := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &parent); err != nil {
t.Fatal(err)
}
category := model.Library{Name: "国漫", Path: "cloud://openlist/国漫", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &category); err != nil {
t.Fatal(err)
}
path := "cloud://openlist/国漫/成何体统 (2024) {tmdb-256783}/Season 1/成何体统.S01E01.mkv"
// 第一次:被父目录库扫描入库。
first := model.Media{LibraryID: parent.ID, Title: "成何体统", Path: path, SeasonNum: 1, EpisodeNum: 1}
if err := repos.Media.Upsert(t.Context(), &first); err != nil {
t.Fatal(err)
}
// 第二次:按二级分类重新挂载并扫描,归入更精确的「国漫」库。
second := model.Media{LibraryID: category.ID, Title: "成何体统", Path: path, SeasonNum: 1, EpisodeNum: 1}
if err := repos.Media.Upsert(t.Context(), &second); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.Where("path = ?", path).First(&got).Error; err != nil {
t.Fatal(err)
}
if got.LibraryID != category.ID {
t.Fatalf("cloud media library_id should migrate to category library %q, got %q", category.ID, got.LibraryID)
}
// 本地媒体不迁移:同一物理路径不应改库归属。
localA := model.Library{Name: "Movies A", Path: "/media/a", Type: "movie", Enabled: true}
localB := model.Library{Name: "Movies B", Path: "/media/b", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &localA); err != nil {
t.Fatal(err)
}
if err := repos.Library.Create(t.Context(), &localB); err != nil {
t.Fatal(err)
}
localPath := "/media/a/Inception (2010)/Inception.mkv"
if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: localA.ID, Title: "Inception", Path: localPath}); err != nil {
t.Fatal(err)
}
if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: localB.ID, Title: "Inception", Path: localPath}); err != nil {
t.Fatal(err)
}
var localGot model.Media
if err := repos.DB.Where("path = ?", localPath).First(&localGot).Error; err != nil {
t.Fatal(err)
}
if localGot.LibraryID != localA.ID {
t.Fatalf("local media library_id must not migrate, want %q got %q", localA.ID, localGot.LibraryID)
}
}
type fakeMediaSearchBackend struct {
ids []string
err error
}
func (f fakeMediaSearchBackend) SearchMediaIDs(context.Context, string, int, int, MediaQueryFilter) ([]string, int64, error) {
if f.err != nil {
return nil, 0, f.err
}
return append([]string(nil), f.ids...), int64(len(f.ids)), nil
}
func TestMediaSearchUsesExternalBackendAndFallsBack(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: "Movies", Path: "/media/movie", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
for _, row := range []model.Media{
{Base: model.Base{ID: "m-1"}, LibraryID: lib.ID, Title: "Alpha", Path: "/media/a.mkv"},
{Base: model.Base{ID: "m-2"}, LibraryID: lib.ID, Title: "Beta", Path: "/media/b.mkv"},
} {
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
repos.Media.SetSearchBackend(fakeMediaSearchBackend{ids: []string{"m-2", "m-1"}})
items, total, err := repos.Media.SearchFilteredPage(t.Context(), "anything", 0, 10, MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 2 || len(items) != 2 || items[0].ID != "m-2" || items[1].ID != "m-1" {
t.Fatalf("external search result total=%d items=%#v", total, items)
}
repos.Media.SetSearchBackend(fakeMediaSearchBackend{err: errors.New("opensearch down")})
items, total, err = repos.Media.SearchFilteredPage(t.Context(), "Alpha", 0, 10, MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != "m-1" {
t.Fatalf("fallback result total=%d items=%#v", total, items)
}
}
func TestMediaSearchFilteredSupportsChineseFuzzyTerms(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/国产剧", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
rows := []model.Media{
{
Base: model.Base{ID: "m-ferry"},
LibraryID: lib.ID,
Title: "灵魂摆渡·十年",
OriginalName: "The Ferry Man 10th Anniversary",
Path: "/media/国产剧/灵魂摆渡·十年/S01E01.mkv",
Genres: "悬疑,奇幻",
},
{
Base: model.Base{ID: "m-ashes"},
LibraryID: lib.ID,
Title: "翘楚",
OriginalName: "Ashes to Crown",
Path: "/media/国产剧/翘楚/S01E01.mkv",
Genres: "剧情",
},
}
for i := range rows {
if err := repos.Media.Upsert(t.Context(), &rows[i]); err != nil {
t.Fatalf("upsert media: %v", err)
}
}
items, err := repos.Media.SearchFiltered(t.Context(), "灵魂 十年", 10, MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatalf("search chinese terms: %v", err)
}
if len(items) == 0 || items[0].ID != "m-ferry" {
t.Fatalf("chinese fuzzy search missed target: %#v", items)
}
items, err = repos.Media.SearchFiltered(t.Context(), "Ferry", 10, MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatalf("search original name: %v", err)
}
if len(items) == 0 || items[0].ID != "m-ferry" {
t.Fatalf("original-name search missed target: %#v", items)
}
items, err = repos.Media.SearchFiltered(t.Context(), "悬疑", 10, MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatalf("search genre: %v", err)
}
if len(items) == 0 || items[0].ID != "m-ferry" {
t.Fatalf("genre search missed target: %#v", items)
}
}
func TestMediaSearchIndexBackfillRunsInBatches(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/movie", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "m-backfill"},
LibraryID: lib.ID,
Title: "后台索引",
Path: "/media/movie/后台索引.mkv",
}).Error; err != nil {
t.Fatal(err)
}
// 插入触发器应当同步维护 FTS 行。
var indexed int64
if err := repos.DB.Raw(`SELECT COUNT(*) FROM media_search_fts`).Scan(&indexed).Error; err != nil {
t.Fatal(err)
}
if indexed != 1 {
t.Fatalf("insert trigger should index new media, got %d rows", indexed)
}
// 清空 FTS 模拟旧库升级后索引缺失,回填应按批补齐且 rowid 对齐。
if err := repos.DB.Exec(`DELETE FROM media_search_fts`).Error; err != nil {
t.Fatal(err)
}
n, err := repos.Media.BackfillSearchIndex(t.Context(), 1)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("backfilled rows = %d, want 1", n)
}
var aligned int64
if err := repos.DB.Raw(`SELECT COUNT(*) FROM media_search_fts f JOIN media m ON f.rowid = m.rowid AND f.media_id = m.id`).Scan(&aligned).Error; err != nil {
t.Fatal(err)
}
if aligned != 1 {
t.Fatalf("fts rows aligned with media rowid = %d, want 1", aligned)
}
// 软删除后触发器应清理对应 FTS 行,避免搜索命中已删媒体。
if err := repos.DB.Delete(&model.Media{}, "id = ?", "m-backfill").Error; err != nil {
t.Fatal(err)
}
var after int64
if err := repos.DB.Raw(`SELECT COUNT(*) FROM media_search_fts`).Scan(&after).Error; err != nil {
t.Fatal(err)
}
if after != 0 {
t.Fatalf("soft delete should drop fts row, got %d", after)
}
}