diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml index 266a925..5506b2a 100644 --- a/.github/workflows/Auto-docker-publish.yml +++ b/.github/workflows/Auto-docker-publish.yml @@ -4,9 +4,7 @@ name: AuTo Docker Image on: push: branches: [main] - pull_request: - branches: [main] - + # 保留手动触发作为备选 workflow_dispatch: inputs: diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 1d99a9a..5d8edb0 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -29,7 +29,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("database.dsn", "") v.SetDefault("database.wal_mode", true) v.SetDefault("database.busy_timeout", 5000) - v.SetDefault("database.cache_size", -20000) + v.SetDefault("database.cache_size", -40000) v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns) v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns) diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index dd5b108..5c11595 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -24,11 +24,19 @@ func AutoMigrate(db *gorm.DB) error { return err } if isSQLite(db) { - return ensureMediaSearchIndex(db) + if err := ensureMediaSearchIndex(db); err != nil { + return err + } + return ensureSQLiteQueryOptimizer(db) } return nil } +func ensureSQLiteQueryOptimizer(db *gorm.DB) error { + // Refresh planner statistics so indexes on large media tables are used. + return db.Exec("ANALYZE").Error +} + func ensurePostgresColumnCompatibility(db *gorm.DB) error { if !isPostgres(db) { return nil diff --git a/internal/database/sqlite_runtime.go b/internal/database/sqlite_runtime.go index a0ce354..a90d1b1 100644 --- a/internal/database/sqlite_runtime.go +++ b/internal/database/sqlite_runtime.go @@ -114,7 +114,10 @@ func buildSQLiteDSN(cfg *config.Config) string { if cfg.Database.CacheSize != 0 { dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize) } - dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)" + dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(536870912)" + if cfg.Database.WALMode { + dsn += "&_pragma=wal_autocheckpoint(1000)" + } return dsn } diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index d3905c2..d816e44 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -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"` diff --git a/internal/repository/media_repository_preview_test.go b/internal/repository/media_repository_preview_test.go new file mode 100644 index 0000000..1120aaa --- /dev/null +++ b/internal/repository/media_repository_preview_test.go @@ -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) + } +} diff --git a/internal/service/media_cache.go b/internal/service/media_cache.go index 32d69b2..437b6fb 100644 --- a/internal/service/media_cache.go +++ b/internal/service/media_cache.go @@ -34,6 +34,40 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string, return "media:list:" + hex.EncodeToString(sum[:]) } +func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter) string { + libIDs := make([]string, len(libraries)) + for i, lib := range libraries { + libIDs[i] = lib.ID + } + sort.Strings(libIDs) + allowed := append([]string(nil), filter.AllowedLibraryIDs...) + hidden := append([]string(nil), filter.HiddenLibraryIDs...) + sort.Strings(allowed) + sort.Strings(hidden) + sum := sha1.Sum([]byte(strings.Join([]string{ + "preview", + strings.Join(libIDs, ","), + fmt.Sprintf("%d:%t", cardLimit, filter.IncludeNSFW), + strings.Join(allowed, ","), + strings.Join(hidden, ","), + }, "|"))) + return "media:preview:" + hex.EncodeToString(sum[:]) +} + +func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVisibility) string { + allowed := append([]string(nil), visibility.AllowedLibraryIDs...) + hidden := append([]string(nil), visibility.HiddenLibraryIDs...) + sort.Strings(allowed) + sort.Strings(hidden) + sum := sha1.Sum([]byte(strings.Join([]string{ + libraryID, + fmt.Sprintf("%t", visibility.IncludeNSFW), + strings.Join(allowed, ","), + strings.Join(hidden, ","), + }, "|"))) + return "media:series-cards:" + hex.EncodeToString(sum[:]) +} + func (s *MediaService) mediaCacheTTLSeconds() int { if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 { return 15 diff --git a/internal/service/media_library.go b/internal/service/media_library.go index 39f9f56..5bc691a 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -2,6 +2,7 @@ package service import ( "context" + "time" "github.com/ShukeBta/MMTL/internal/model" "github.com/ShukeBta/MMTL/internal/repository" @@ -14,6 +15,10 @@ type LibraryPreviewItem struct { Cards []SeriesCard `json:"cards"` } +type libraryPreviewCacheValue struct { + Items []LibraryPreviewItem `json:"items"` +} + // ListLibraries returns every library configured on the server. func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) { return s.repo.Library.List(ctx) @@ -29,6 +34,18 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ return out, nil } + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + filter := repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + } + cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter) + var cached libraryPreviewCacheValue + if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) { + return cached.Items, nil + } + libIDs := make([]string, 0, len(libraries)) for i, lib := range libraries { out[i] = LibraryPreviewItem{ @@ -39,13 +56,6 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ libIDs = append(libIDs, lib.ID) } - visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) - filter := repository.MediaQueryFilter{ - IncludeNSFW: visibility.IncludeNSFW, - AllowedLibraryIDs: visibility.AllowedLibraryIDs, - HiddenLibraryIDs: visibility.HiddenLibraryIDs, - } - counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter) if err != nil { return nil, err @@ -64,15 +74,32 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ fetchCount = 200 } + recentByLibrary, err := s.repo.Media.ListRecentByLibraries(ctx, libIDs, fetchCount, filter) + if err != nil { + return nil, err + } + + allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount) for i := range out { if out[i].Total == 0 { continue } - items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, fetchCount, filter) - if err != nil { + items := recentByLibrary[out[i].ID] + if len(items) == 0 { + continue + } + allPreviewItems = append(allPreviewItems, items...) + } + s.attachLibraryMetadata(ctx, allPreviewItems) + + for i := range out { + if out[i].Total == 0 { + continue + } + items := recentByLibrary[out[i].ID] + if len(items) == 0 { continue } - s.attachLibraryMetadata(ctx, items) cards := groupMediaSeriesCards(items) if len(cards) > cardLimit { cards = cards[:cardLimit] @@ -83,6 +110,10 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ out[i].Cards = cards } + if s.cache != nil { + s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, time.Duration(s.mediaCacheTTLSeconds())*time.Second) + } + return out, nil } diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 2065b07..7499772 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -12,6 +12,11 @@ import ( "github.com/ShukeBta/MMTL/internal/model" ) +type seriesCardsCacheValue struct { + Cards []SeriesCard `json:"cards"` + Total int64 `json:"total"` +} + type SeriesCard struct { Key string `json:"key"` Rep model.Media `json:"rep"` @@ -25,12 +30,25 @@ type seriesCardGroup struct { } func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) { + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + cacheKey := s.seriesCardsCacheKey(libraryID, visibility) + var cached seriesCardsCacheValue + if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) { + return cached.Cards, cached.Total, nil + } rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility) if err != nil { return nil, 0, err } cards := groupMediaSeriesCards(rows) - return cards, int64(len(cards)), nil + if cards == nil { + cards = []SeriesCard{} + } + total := int64(len(cards)) + if s.cache != nil { + s.cache.SetJSON(ctx, cacheKey, seriesCardsCacheValue{Cards: cards, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second) + } + return cards, total, nil } func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) { diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index bda920f..209639a 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -45,9 +45,12 @@ export function LibraryPage() { const [historyMap, setHistoryMap] = useState>(new Map()) useEffect(() => { + if (sortField !== 'last_played') return + let cancelled = false historyAPI .list(1000) .then((historyItems) => { + if (cancelled) return const map = new Map() for (const item of historyItems ?? []) { if (item.media_id && item.watched_at) { @@ -59,7 +62,10 @@ export function LibraryPage() { setHistoryMap(map) }) .catch(() => {}) - }, []) + return () => { + cancelled = true + } + }, [sortField]) const handleSortChange = (field: SortField, order: SortOrder) => { setSortField(field) diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts index 7ae9315..ed3706a 100644 --- a/web/src/pages/useLibraryData.ts +++ b/web/src/pages/useLibraryData.ts @@ -145,6 +145,16 @@ function isSeriesLibraryType(type?: string) { return type === 'tv' || type === 'anime' || type === 'variety' } +function yieldToBrowser(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(() => resolve(), { timeout: 48 }) + } else { + setTimeout(resolve, 0) + } + }) +} + async function loadAllSeriesCards( libraryID: string, isRemoteEmby: boolean | undefined, @@ -161,6 +171,7 @@ async function loadAllSeriesCards( onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 }) if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break page += 1 + await yieldToBrowser() } return { items: collected } } @@ -181,6 +192,7 @@ async function loadAllMedia( onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 }) if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break page += 1 + await yieldToBrowser() } return { items: collected } }