添加新功能,完善项目

This commit is contained in:
truewhile
2026-09-22 16:22:29 +08:00
parent 47899284a5
commit b5ce4646cf
85 changed files with 7103 additions and 982 deletions
+174
View File
@@ -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)
}
}
+116
View File
@@ -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 {