mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
223 lines
7.2 KiB
Go
223 lines
7.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
"github.com/truewhile/MeBox/internal/repository"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type LibraryPreviewItem struct {
|
|
model.Library
|
|
Total int64 `json:"total"`
|
|
Cards []SeriesCard `json:"cards"`
|
|
}
|
|
|
|
// ListLibraries returns every library configured on the server.
|
|
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
|
|
return s.repo.Library.List(ctx)
|
|
}
|
|
|
|
// CountLibrariesCached returns per-library media totals. The homepage metadata
|
|
// request asks for every library, and the underlying COUNT is repeated on each
|
|
// refresh. Writes already drop the media: prefix, so a longer TTL is safe.
|
|
func (s *MediaService) CountLibrariesCached(ctx context.Context, libraryIDs []string, filter repository.MediaQueryFilter) (map[string]int64, error) {
|
|
if len(libraryIDs) == 0 {
|
|
return map[string]int64{}, nil
|
|
}
|
|
cacheKey := s.libraryCountCacheKey(libraryIDs, filter)
|
|
var cached map[string]int64
|
|
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) && cached != nil {
|
|
return cached, nil
|
|
}
|
|
counts, err := s.repo.Media.CountByLibraries(ctx, libraryIDs, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if counts == nil {
|
|
counts = map[string]int64{}
|
|
}
|
|
if s.cache != nil {
|
|
s.cache.SetJSON(ctx, cacheKey, counts, s.derivedReadCacheTTL())
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
|
|
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
|
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true)
|
|
}
|
|
|
|
// ListLibraryPreviews returns only the latest preview cards. The metadata
|
|
// endpoint already returns totals, so preview batches used by the home and
|
|
// library pages can skip an otherwise repeated COUNT(*) over every library.
|
|
func (s *MediaService) ListLibraryPreviews(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
|
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, false)
|
|
}
|
|
|
|
func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int, includeCounts bool) ([]LibraryPreviewItem, error) {
|
|
if cardLimit <= 0 {
|
|
cardLimit = 10
|
|
}
|
|
out := make([]LibraryPreviewItem, len(libraries))
|
|
if len(libraries) == 0 {
|
|
return out, nil
|
|
}
|
|
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
|
filter := repository.MediaQueryFilter{
|
|
IncludeNSFW: visibility.IncludeNSFW,
|
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
|
}
|
|
pending := make([]model.Library, 0, len(libraries))
|
|
pendingSet := make(map[string]struct{}, len(libraries))
|
|
for i, lib := range libraries {
|
|
out[i] = LibraryPreviewItem{
|
|
Library: lib,
|
|
Total: 0,
|
|
Cards: []SeriesCard{},
|
|
}
|
|
itemKey := s.libraryPreviewCacheKey([]model.Library{lib}, cardLimit, filter, includeCounts)
|
|
if s.cache != nil && s.cache.GetJSON(ctx, itemKey, &out[i]) {
|
|
continue
|
|
}
|
|
pending = append(pending, lib)
|
|
pendingSet[lib.ID] = struct{}{}
|
|
}
|
|
if len(pending) == 0 {
|
|
return out, nil
|
|
}
|
|
|
|
libIDs := make([]string, len(pending))
|
|
for i := range pending {
|
|
libIDs[i] = pending[i].ID
|
|
}
|
|
|
|
if includeCounts {
|
|
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out {
|
|
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
|
|
continue
|
|
}
|
|
out[i].Total = counts[out[i].ID]
|
|
}
|
|
}
|
|
|
|
// Preview rows are only an internal candidate window. Keep it bounded so a
|
|
// library with a very long series cannot turn a homepage request into a full
|
|
// 50k-row scan merely to find another distinct card.
|
|
fetchCount := cardLimit * 12
|
|
minFetchCount := 120
|
|
if cardLimit <= 2 {
|
|
minFetchCount = 24
|
|
} else if cardLimit <= 4 {
|
|
// 首页/媒体库入口的马赛克只需要少量代表图,没必要为暂时不会
|
|
// 展示的横向货架扫描一整批 120 行候选。
|
|
minFetchCount = 48
|
|
}
|
|
if fetchCount < minFetchCount {
|
|
fetchCount = minFetchCount
|
|
} else if fetchCount > 400 {
|
|
fetchCount = 400
|
|
}
|
|
|
|
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 _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
|
|
continue
|
|
}
|
|
items := recentByLibrary[out[i].ID]
|
|
if len(items) == 0 {
|
|
continue
|
|
}
|
|
allPreviewItems = append(allPreviewItems, items...)
|
|
}
|
|
s.attachLibraryMetadata(ctx, allPreviewItems)
|
|
|
|
for i := range out {
|
|
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
|
|
continue
|
|
}
|
|
items := recentByLibrary[out[i].ID]
|
|
if len(items) == 0 {
|
|
if s.cache != nil {
|
|
itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts)
|
|
s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL())
|
|
}
|
|
continue
|
|
}
|
|
cards := groupMediaSeriesCards(items)
|
|
if len(cards) > cardLimit {
|
|
cards = cards[:cardLimit]
|
|
}
|
|
if cards == nil {
|
|
cards = []SeriesCard{}
|
|
}
|
|
out[i].Cards = cards
|
|
if s.cache != nil {
|
|
itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts)
|
|
s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL())
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteLibrary removes a library and its media rows. The on-disk files are
|
|
// left untouched.
|
|
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
|
lib, err := s.repo.Library.FindByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if lib != nil {
|
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
|
err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
|
|
return err
|
|
}
|
|
return tx.Unscoped().Where("id = ?", id).Delete(&model.Library{}).Error
|
|
})
|
|
if err == nil {
|
|
s.invalidateMediaCache(ctx)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
// 本地库媒体行统一硬删除(与云库分支一致):删库即彻底清空该库的
|
|
// media 行,避免重扫时旧行被 upsert 复活却仍挂在已删库下,导致重建
|
|
// 同名库后条目始终为 0。只作用于数据库表行,磁盘文件不受影响。
|
|
if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil {
|
|
return err
|
|
}
|
|
return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error
|
|
})
|
|
if err == nil {
|
|
s.invalidateMediaCache(ctx)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func hardDeleteLibraryRoots(ctx context.Context, tx *gorm.DB, libraryID string) error {
|
|
if tx == nil || !tx.Migrator().HasTable(&model.LibraryRoot{}) {
|
|
return nil
|
|
}
|
|
return tx.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}).Error
|
|
}
|