mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 22:06:38 +08:00
Optimize home/library loading and SQLite query performance
- Replace home preview N+1 queries with a single window-function batch query - Cache library previews and series card lists (15s TTL, shared with media cache) - Tune SQLite: larger page cache, mmap, wal_autocheckpoint, ANALYZE on migrate - Defer history fetch on library page until last_played sort is selected - Yield to browser between paginated library loads to keep UI responsive Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,8 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -87,6 +89,18 @@ func (r *MediaRepository) ListByLibraryFiltered(ctx context.Context, libraryID s
|
||||
}
|
||||
|
||||
func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter) ([]model.Media, int64, error) {
|
||||
items, total, err := r.listByLibrariesFiltered(ctx, libraryIDs, offset, limit, filter, true)
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// ListByLibrariesFilteredNoCount skips the COUNT query when the caller already
|
||||
// knows totals or only needs a bounded slice (e.g. home-page previews).
|
||||
func (r *MediaRepository) ListByLibrariesFilteredNoCount(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter) ([]model.Media, error) {
|
||||
items, _, err := r.listByLibrariesFiltered(ctx, libraryIDs, offset, limit, filter, false)
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) {
|
||||
var items []model.Media
|
||||
var total int64
|
||||
if len(libraryIDs) == 0 {
|
||||
@@ -99,8 +113,10 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
|
||||
q = q.Where("library_id IN ?", libraryIDs)
|
||||
}
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
if withCount {
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
// 多级排序消除"随机"观感:
|
||||
// 1. release_date desc — 精确上映/首播日期新→旧
|
||||
@@ -114,6 +130,75 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
type rankedMediaRow struct {
|
||||
model.Media
|
||||
MmtlRN int `gorm:"column:mmtl_rn"`
|
||||
}
|
||||
|
||||
// ListRecentByLibraries returns up to perLibrary recent items for each library
|
||||
// in a single query using a window function (avoids N+1 on home preview).
|
||||
func (r *MediaRepository) ListRecentByLibraries(ctx context.Context, libraryIDs []string, perLibrary int, filter MediaQueryFilter) (map[string][]model.Media, error) {
|
||||
out := make(map[string][]model.Media, len(libraryIDs))
|
||||
if len(libraryIDs) == 0 || perLibrary <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var libClause string
|
||||
var args []interface{}
|
||||
if len(libraryIDs) == 1 {
|
||||
libClause = "library_id = ?"
|
||||
args = append(args, libraryIDs[0])
|
||||
} else {
|
||||
libClause = "library_id IN ?"
|
||||
args = append(args, libraryIDs)
|
||||
}
|
||||
where := "deleted_at IS NULL AND " + libClause
|
||||
if filterSQL, filterArgs := mediaQueryFilterSQL(filter); filterSQL != "" {
|
||||
where += " AND " + filterSQL
|
||||
args = append(args, filterArgs...)
|
||||
}
|
||||
args = append(args, perLibrary)
|
||||
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT * FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (
|
||||
PARTITION BY library_id
|
||||
ORDER BY release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC
|
||||
) AS mmtl_rn
|
||||
FROM media
|
||||
WHERE %s
|
||||
) ranked
|
||||
WHERE mmtl_rn <= ?
|
||||
`, where)
|
||||
|
||||
var rows []rankedMediaRow
|
||||
if err := r.db.WithContext(ctx).Raw(sql, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.LibraryID] = append(out[row.LibraryID], row.Media)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mediaQueryFilterSQL(filter MediaQueryFilter) (string, []interface{}) {
|
||||
var parts []string
|
||||
var args []interface{}
|
||||
if !filter.IncludeNSFW {
|
||||
parts = append(parts, "nsfw = ?")
|
||||
args = append(args, false)
|
||||
}
|
||||
if len(filter.HiddenLibraryIDs) > 0 {
|
||||
parts = append(parts, "library_id NOT IN ?")
|
||||
args = append(args, filter.HiddenLibraryIDs)
|
||||
}
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
parts = append(parts, "library_id IN ?")
|
||||
args = append(args, filter.AllowedLibraryIDs)
|
||||
}
|
||||
return strings.Join(parts, " AND "), args
|
||||
}
|
||||
|
||||
type libraryCountRow struct {
|
||||
LibraryID string `gorm:"column:library_id"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/database"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func TestListRecentByLibraries(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)
|
||||
|
||||
lib1 := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib2 := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||
var rows []model.Media
|
||||
for i := 1; i <= 5; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("movie-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Hour)},
|
||||
LibraryID: lib1.ID,
|
||||
Title: fmt.Sprintf("电影%d", i),
|
||||
Path: fmt.Sprintf("/media/movies/电影%d/movie%d.mp4", i, i),
|
||||
})
|
||||
}
|
||||
for i := 1; i <= 8; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("anime-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
|
||||
LibraryID: lib2.ID,
|
||||
Title: fmt.Sprintf("某动漫 第%d集", i),
|
||||
Path: fmt.Sprintf("/media/anime/某动漫/Season 01/某动漫.S01E%02d.mp4", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
})
|
||||
}
|
||||
if err := repos.DB.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
filter := MediaQueryFilter{IncludeNSFW: true}
|
||||
got, err := repos.Media.ListRecentByLibraries(t.Context(), []string{lib1.ID, lib2.ID}, 3, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRecentByLibraries failed: %v", err)
|
||||
}
|
||||
if len(got[lib1.ID]) != 3 {
|
||||
t.Fatalf("lib1 recent count = %d, want 3", len(got[lib1.ID]))
|
||||
}
|
||||
if len(got[lib2.ID]) != 3 {
|
||||
t.Fatalf("lib2 recent count = %d, want 3", len(got[lib2.ID]))
|
||||
}
|
||||
if got[lib1.ID][0].ID != "movie-05" {
|
||||
t.Fatalf("lib1 newest = %q, want movie-05", got[lib1.ID][0].ID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user