mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
添加新功能,完善项目
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func newMediaFilterTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}, &model.PlaybackHistory{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func seedFilterMedia(t *testing.T, db *gorm.DB, rows ...*model.Media) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := db.WithContext(context.Background()).Create(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listFiltered(t *testing.T, db *gorm.DB, filter MediaQueryFilter) []string {
|
||||
t.Helper()
|
||||
var rows []model.Media
|
||||
q := db.WithContext(context.Background()).Model(&model.Media{})
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Order("title asc").Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.Title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasTitle(items []string, want string) bool {
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 多个类型之间是「或」:勾选 Action 与 Comedy 应同时命中两类。
|
||||
func TestFilterByGenreOR(t *testing.T) {
|
||||
db := newMediaFilterTestDB(t)
|
||||
seedFilterMedia(t, db,
|
||||
&model.Media{Title: "动作", Genres: "Action", Path: "/a.mkv", LibraryID: "lib-1"},
|
||||
&model.Media{Title: "喜剧", Genres: "Comedy", Path: "/b.mkv", LibraryID: "lib-1"},
|
||||
&model.Media{Title: "剧情", Genres: "Drama", Path: "/c.mkv", LibraryID: "lib-1"},
|
||||
)
|
||||
|
||||
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Action", "Comedy"}})
|
||||
if !hasTitle(got, "动作") || !hasTitle(got, "喜剧") {
|
||||
t.Fatalf("result = %v, want both 动作 and 喜剧", got)
|
||||
}
|
||||
if hasTitle(got, "剧情") {
|
||||
t.Fatalf("result = %v, must not contain 剧情", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 类型匹配必须是整词匹配:搜 "Action" 不能命中 "ActionComedy" 这类拼接值。
|
||||
func TestFilterByGenreDoesNotMatchSubstring(t *testing.T) {
|
||||
db := newMediaFilterTestDB(t)
|
||||
seedFilterMedia(t, db,
|
||||
&model.Media{Title: "精确", Genres: "Action,Drama", Path: "/a.mkv", LibraryID: "lib-1"},
|
||||
&model.Media{Title: "拼接", Genres: "ActionComedy", Path: "/b.mkv", LibraryID: "lib-1"},
|
||||
)
|
||||
|
||||
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Action"}})
|
||||
if !hasTitle(got, "精确") {
|
||||
t.Fatalf("result = %v, want 精确", got)
|
||||
}
|
||||
if hasTitle(got, "拼接") {
|
||||
t.Fatalf("result = %v, must not match ActionComedy for Action", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterYearAndRating(t *testing.T) {
|
||||
db := newMediaFilterTestDB(t)
|
||||
seedFilterMedia(t, db,
|
||||
&model.Media{Title: "老片", Year: 1995, Rating: 9, Path: "/a.mkv", LibraryID: "lib-1"},
|
||||
&model.Media{Title: "中年", Year: 2010, Rating: 5, Path: "/b.mkv", LibraryID: "lib-1"},
|
||||
&model.Media{Title: "新片", Year: 2023, Rating: 8, Path: "/c.mkv", LibraryID: "lib-1"},
|
||||
)
|
||||
|
||||
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, YearMin: 2000, YearMax: 2020})
|
||||
if len(got) != 1 || got[0] != "中年" {
|
||||
t.Fatalf("year filter result = %v, want [中年]", got)
|
||||
}
|
||||
|
||||
got = listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, RatingMin: 8})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("rating filter result = %v, want 2 entries", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 「未观看」的语义是「没有标记看完的记录」:看了一半的仍应出现。
|
||||
func TestFilterUnwatchedExcludesCompleted(t *testing.T) {
|
||||
db := newMediaFilterTestDB(t)
|
||||
seedFilterMedia(t, db,
|
||||
&model.Media{Base: model.Base{ID: "m-done"}, Title: "看完", Path: "/a.mkv", LibraryID: "lib-1"},
|
||||
&model.Media{Base: model.Base{ID: "m-half"}, Title: "看一半", Path: "/b.mkv", LibraryID: "lib-1"},
|
||||
&model.Media{Base: model.Base{ID: "m-new"}, Title: "没看过", Path: "/c.mkv", LibraryID: "lib-1"},
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
for _, h := range []*model.PlaybackHistory{
|
||||
{UserID: "u1", MediaID: "m-done", Completed: true},
|
||||
{UserID: "u1", MediaID: "m-half", Completed: false},
|
||||
// 别人的完播记录不应影响本人筛选。
|
||||
{UserID: "u2", MediaID: "m-new", Completed: true},
|
||||
} {
|
||||
if err := db.WithContext(ctx).Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got := listFiltered(t, db, MediaQueryFilter{
|
||||
IncludeNSFW: true, UnwatchedOnly: true, UnwatchedUserID: "u1",
|
||||
})
|
||||
if hasTitle(got, "看完") {
|
||||
t.Fatalf("result = %v, must exclude completed media", got)
|
||||
}
|
||||
if !hasTitle(got, "看一半") || !hasTitle(got, "没看过") {
|
||||
t.Fatalf("result = %v, want both 看一半 and 没看过", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 多词类型(如 "Science Fiction"):列侧 SQL 会 REPLACE 掉空格,参数侧也必须同步
|
||||
// 去掉空格,两侧对称才能命中。
|
||||
func TestFilterByGenreMultiWordStripsSpaces(t *testing.T) {
|
||||
db := newMediaFilterTestDB(t)
|
||||
seedFilterMedia(t, db,
|
||||
&model.Media{Title: "科幻", Genres: "Science Fiction,Drama", Path: "/a.mkv", LibraryID: "lib-1"},
|
||||
&model.Media{Title: "动作", Genres: "Action", Path: "/b.mkv", LibraryID: "lib-1"},
|
||||
)
|
||||
|
||||
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Science Fiction"}})
|
||||
if !hasTitle(got, "科幻") {
|
||||
t.Fatalf("result = %v, want 科幻 (multi-word genre must match after space stripping)", got)
|
||||
}
|
||||
if hasTitle(got, "动作") {
|
||||
t.Fatalf("result = %v, must not contain 动作", got)
|
||||
}
|
||||
}
|
||||
|
||||
// UnwatchedOnly 缺省 userID 时必须忽略该条件,而不是返回空结果。
|
||||
func TestFilterUnwatchedWithoutUserIsIgnored(t *testing.T) {
|
||||
db := newMediaFilterTestDB(t)
|
||||
seedFilterMedia(t, db, &model.Media{Title: "片", Path: "/a.mkv", LibraryID: "lib-1"})
|
||||
|
||||
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, UnwatchedOnly: true})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("result = %v, want the row to be returned", got)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,21 @@ type MediaQueryFilter struct {
|
||||
AllowedLibraryIDs []string
|
||||
HiddenLibraryIDs []string
|
||||
SeriesID string
|
||||
// LibraryID 是精确匹配的单个库过滤,用于库内场景(例如媒体库页筛选)。
|
||||
// 它与 AllowedLibraryIDs 是「与」关系:可见性仍由后者兜底,避免越权。
|
||||
LibraryID string
|
||||
// Genres 是类型多选,之间为「或」。按整词匹配(见 genreMatchClause)。
|
||||
Genres []string
|
||||
// YearMin / YearMax 为 0 表示该端不限。
|
||||
YearMin int
|
||||
YearMax int
|
||||
// RatingMin 为 0 表示不限。
|
||||
RatingMin float64
|
||||
// UnwatchedOnly 排除 UnwatchedUserID 已标记看完的条目。
|
||||
// 「未观看」定义为「没有 completed=true 的记录」:看到一半的仍会出现,
|
||||
// 与「继续观看」互补而不是重复。
|
||||
UnwatchedOnly bool
|
||||
UnwatchedUserID string
|
||||
}
|
||||
|
||||
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||
@@ -56,12 +71,113 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
|
||||
q = q.Where("library_id = ?", libraryID)
|
||||
}
|
||||
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
|
||||
q = q.Where("series_id = ?", seriesID)
|
||||
}
|
||||
if len(filter.Genres) > 0 {
|
||||
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
|
||||
}
|
||||
if filter.YearMin > 0 {
|
||||
q = q.Where("year >= ?", filter.YearMin)
|
||||
}
|
||||
if filter.YearMax > 0 {
|
||||
q = q.Where("year <= ?", filter.YearMax)
|
||||
}
|
||||
if filter.RatingMin > 0 {
|
||||
q = q.Where("rating >= ?", filter.RatingMin)
|
||||
}
|
||||
if filter.UnwatchedOnly {
|
||||
userID := strings.TrimSpace(filter.UnwatchedUserID)
|
||||
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
|
||||
if userID != "" {
|
||||
q = q.Where(
|
||||
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
|
||||
userID, true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// genreMatchClause 生成类型整词匹配条件。
|
||||
//
|
||||
// genres 列是逗号分隔字符串,直接 LIKE '%Action%' 会把 "ActionComedy" 也命中。
|
||||
// 这里统一补上首尾逗号(并用空格容错)后再按 "%,Action,%" 匹配,实现整词语义;
|
||||
// 该写法在 SQLite 与 PostgreSQL 上行为一致,因此不需要方言分支。
|
||||
//
|
||||
// 注意写法:参数本身带上首尾逗号,SQL 里只做一次 REPLACE 来保证列值两端也有
|
||||
// 分隔符,避免 OR 链里重复拼接列表达式。
|
||||
func genreMatchClause(genres []string) string {
|
||||
clauses := make([]string, 0, len(genres))
|
||||
for range genres {
|
||||
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?")
|
||||
}
|
||||
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
|
||||
// genreMatchArgs 生成与 genreMatchClause 对应的参数,形如 "%,Action,%"。
|
||||
//
|
||||
// 必须与 genreMatchClause 的列端处理完全对称:
|
||||
// - TRIM → TrimSpace
|
||||
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
|
||||
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
|
||||
func genreMatchArgs(genres []string) []any {
|
||||
args := make([]any, 0, len(genres))
|
||||
for _, genre := range genres {
|
||||
name := strings.ReplaceAll(strings.TrimSpace(genre), ",", ",")
|
||||
name = strings.ReplaceAll(name, " ", "") // mirror REPLACE(…,' ','') in genreMatchClause
|
||||
if name == "" {
|
||||
name = "\x00" // 空类型不会命中任何行
|
||||
}
|
||||
args = append(args, "%,"+name+",%")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// ListGenreValues 返回符合过滤条件的 media.genres 原始值(逗号分隔字符串)。
|
||||
//
|
||||
// 只取单列:类型聚合不需要整行 media,而一台大库的整行扫描会把海报 URL、
|
||||
// 简介等大字段一起读进内存。切分与去重交给调用方,SQL 层保持方言无关。
|
||||
func (r *MediaRepository) ListGenreValues(ctx context.Context, filter MediaQueryFilter) ([]string, error) {
|
||||
var values []string
|
||||
q := r.db.WithContext(ctx).
|
||||
Model(&model.Media{}).
|
||||
Where("genres IS NOT NULL AND genres <> ''")
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Pluck("genres", &values).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// YearRange 返回符合过滤条件的年份区间(两端都为 0 表示没有可用年份)。
|
||||
// 供媒体库筛选面板生成年份上下限,避免前端硬编码或先取全量再自己算。
|
||||
func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter) (int, int, error) {
|
||||
var bounds struct {
|
||||
MinYear *int
|
||||
MaxYear *int
|
||||
}
|
||||
q := r.db.WithContext(ctx).
|
||||
Model(&model.Media{}).
|
||||
Where("year > 0").
|
||||
Select("MIN(year) AS min_year, MAX(year) AS max_year")
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Scan(&bounds).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
min, max := 0, 0
|
||||
if bounds.MinYear != nil {
|
||||
min = *bounds.MinYear
|
||||
}
|
||||
if bounds.MaxYear != nil {
|
||||
max = *bounds.MaxYear
|
||||
}
|
||||
return min, max, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
|
||||
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user