From c08460baa7add24b2c4cf0701aea432ab1c872bf Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:58:19 +0800 Subject: [PATCH] refactor media service helpers --- internal/service/media.go | 249 ----------------------------- internal/service/media_cache.go | 49 ++++++ internal/service/media_versions.go | 217 +++++++++++++++++++++++++ 3 files changed, 266 insertions(+), 249 deletions(-) create mode 100644 internal/service/media_cache.go create mode 100644 internal/service/media_versions.go diff --git a/internal/service/media.go b/internal/service/media.go index 6a12e7f..cad94b8 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -3,12 +3,7 @@ package service import ( "context" - "crypto/sha1" - "encoding/hex" "errors" - "fmt" - "sort" - "strings" "time" "go.uber.org/zap" @@ -33,11 +28,6 @@ type MediaVisibility struct { HiddenLibraryIDs []string } -type MediaItem struct { - model.Media - Versions []model.Media `json:"versions,omitempty"` -} - const maxMediaSearchLimit = 50000 const maxMediaSearchPageSize = 2000 @@ -180,42 +170,6 @@ func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID st return paginateMediaItems(grouped, page, pageSize), int64(len(grouped)), nil } -type mediaListCacheValue struct { - Items []model.Media `json:"items"` - Total int64 `json:"total"` -} - -func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string, page, pageSize int, filter repository.MediaQueryFilter) string { - allowed := append([]string(nil), filter.AllowedLibraryIDs...) - hidden := append([]string(nil), filter.HiddenLibraryIDs...) - libs := append([]string(nil), libraryIDs...) - sort.Strings(allowed) - sort.Strings(hidden) - sort.Strings(libs) - sum := sha1.Sum([]byte(strings.Join([]string{ - libraryID, - strings.Join(libs, ","), - fmt.Sprintf("%d:%d:%t", page, pageSize, filter.IncludeNSFW), - strings.Join(allowed, ","), - strings.Join(hidden, ","), - }, "|"))) - return "media:list:" + hex.EncodeToString(sum[:]) -} - -func (s *MediaService) mediaCacheTTLSeconds() int { - if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 { - return 15 - } - return s.cfg.Cache.MediaTTLSeconds -} - -func (s *MediaService) invalidateMediaCache(ctx context.Context) { - if s != nil && s.cache != nil { - s.cache.DeletePrefix(ctx, "media:") - s.cache.DeletePrefix(ctx, "stats:") - } -} - func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) { visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID) @@ -250,209 +204,6 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI return items, nil } -func normalizeGroupedMediaPage(page, pageSize int) (int, int) { - if page < 1 { - page = 1 - } - if pageSize <= 0 { - pageSize = 50 - } - if pageSize > maxMediaSearchPageSize { - pageSize = maxMediaSearchPageSize - } - return page, pageSize -} - -func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem { - page, pageSize = normalizeGroupedMediaPage(page, pageSize) - if len(items) == 0 { - return nil - } - start := (page - 1) * pageSize - if start >= len(items) { - return []MediaItem{} - } - end := start + pageSize - if end > len(items) { - end = len(items) - } - return items[start:end] -} - -func firstMediaItems(items []MediaItem, limit int) []MediaItem { - if len(items) == 0 { - return nil - } - if limit <= 0 { - limit = 50 - } - if limit > maxMediaSearchLimit { - limit = maxMediaSearchLimit - } - if limit > len(items) { - limit = len(items) - } - return items[:limit] -} - -func groupMediaVersions(items []model.Media) []MediaItem { - if len(items) == 0 { - return nil - } - type group struct { - key string - primary model.Media - rows []model.Media - } - groups := make([]group, 0, len(items)) - byKey := make(map[string]int, len(items)) - for _, item := range items { - key := mediaVersionGroupKey(item) - if key == "" { - groups = append(groups, group{primary: item, rows: []model.Media{item}}) - continue - } - if idx, ok := byKey[key]; ok { - groups[idx].rows = append(groups[idx].rows, item) - if betterMediaVersion(item, groups[idx].primary) { - groups[idx].primary = item - } - continue - } - byKey[key] = len(groups) - groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}}) - } - out := make([]MediaItem, 0, len(groups)) - for _, g := range groups { - sort.SliceStable(g.rows, func(i, j int) bool { - return betterMediaVersion(g.rows[i], g.rows[j]) - }) - item := MediaItem{Media: g.primary} - if len(g.rows) > 1 { - item.Versions = g.rows - } - out = append(out, item) - } - sort.SliceStable(out, func(i, j int) bool { - return out[i].CreatedAt.After(out[j].CreatedAt) - }) - return out -} - -func mediaVersionGroupKey(m model.Media) string { - if m.SeasonNum > 0 || m.EpisodeNum > 0 { - switch { - case m.TMDbID > 0: - return fmt.Sprintf("episode:tmdb:%d:%d:%d", m.TMDbID, m.SeasonNum, m.EpisodeNum) - case m.BangumiID > 0: - return fmt.Sprintf("episode:bangumi:%d:%d:%d", m.BangumiID, m.SeasonNum, m.EpisodeNum) - case strings.TrimSpace(m.DoubanID) != "": - return fmt.Sprintf("episode:douban:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.DoubanID)), m.SeasonNum, m.EpisodeNum) - case strings.TrimSpace(m.TheTVDBID) != "": - return fmt.Sprintf("episode:thetvdb:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.TheTVDBID)), m.SeasonNum, m.EpisodeNum) - } - title := firstNonEmpty(m.OriginalName, m.Title) - if title == "" { - title, _ = CleanQuery(m.Path) - } - title, _ = mediaVersionTitleKey(title) - if title == "" { - return "" - } - return strings.Join([]string{ - "episode", - strings.ToLower(strings.TrimSpace(m.LibraryID)), - title, - fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum), - }, "|") - } - switch { - case m.TMDbID > 0: - return fmt.Sprintf("tmdb:%d", m.TMDbID) - case m.BangumiID > 0: - return fmt.Sprintf("bangumi:%d", m.BangumiID) - case strings.TrimSpace(m.DoubanID) != "": - return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID)) - case strings.TrimSpace(m.TheTVDBID) != "": - return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID)) - } - title := firstNonEmpty(m.OriginalName, m.Title) - titleYear := 0 - if title == "" { - title, _ = CleanQuery(m.Path) - } else { - title, titleYear = mediaVersionTitleKey(title) - } - if title == "" { - return "" - } - year := m.Year - if year <= 0 { - year = titleYear - } - if year <= 0 { - _, year = CleanQuery(m.Path) - } - return fmt.Sprintf("movie:%s:%d", title, year) -} - -func mediaVersionTitleKey(value string) (string, int) { - cleaned, year := CleanQuery(value) - if strings.TrimSpace(cleaned) == "" { - cleaned = value - } - return normalizeMediaVersionText(cleaned), year -} - -func normalizeMediaVersionText(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "" - } - fields := strings.FieldsFunc(value, func(r rune) bool { - switch r { - case '.', '_', '-', ' ', '\t', '/', '\\', '[', ']', '(', ')', '(', ')', '【', '】': - return true - default: - return false - } - }) - out := fields[:0] - for _, field := range fields { - field = strings.TrimSpace(field) - if field == "" { - continue - } - if _, noise := noiseTokenSet[field]; noise { - continue - } - out = append(out, field) - } - return strings.Join(out, " ") -} - -func betterMediaVersion(candidate, current model.Media) bool { - candidateCloud := isCloudMediaVersion(candidate) - currentCloud := isCloudMediaVersion(current) - if candidateCloud != currentCloud { - return !candidateCloud - } - candidatePixels := candidate.Width * candidate.Height - currentPixels := current.Width * current.Height - if candidatePixels != currentPixels { - return candidatePixels > currentPixels - } - if candidate.SizeBytes != current.SizeBytes { - return candidate.SizeBytes > current.SizeBytes - } - return candidate.CreatedAt.After(current.CreatedAt) -} - -func isCloudMediaVersion(media model.Media) bool { - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") || - strings.Contains(strings.ToLower(strings.TrimSpace(media.STRMURL)), "/api/cloud/play/") -} - // SearchMedia performs a simple LIKE search across titles. func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) { return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true}) diff --git a/internal/service/media_cache.go b/internal/service/media_cache.go new file mode 100644 index 0000000..c5cef9f --- /dev/null +++ b/internal/service/media_cache.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +type mediaListCacheValue struct { + Items []model.Media `json:"items"` + Total int64 `json:"total"` +} + +func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string, page, pageSize int, filter repository.MediaQueryFilter) string { + allowed := append([]string(nil), filter.AllowedLibraryIDs...) + hidden := append([]string(nil), filter.HiddenLibraryIDs...) + libs := append([]string(nil), libraryIDs...) + sort.Strings(allowed) + sort.Strings(hidden) + sort.Strings(libs) + sum := sha1.Sum([]byte(strings.Join([]string{ + libraryID, + strings.Join(libs, ","), + fmt.Sprintf("%d:%d:%t", page, pageSize, filter.IncludeNSFW), + strings.Join(allowed, ","), + strings.Join(hidden, ","), + }, "|"))) + return "media:list:" + hex.EncodeToString(sum[:]) +} + +func (s *MediaService) mediaCacheTTLSeconds() int { + if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 { + return 15 + } + return s.cfg.Cache.MediaTTLSeconds +} + +func (s *MediaService) invalidateMediaCache(ctx context.Context) { + if s != nil && s.cache != nil { + s.cache.DeletePrefix(ctx, "media:") + s.cache.DeletePrefix(ctx, "stats:") + } +} diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go new file mode 100644 index 0000000..4bab068 --- /dev/null +++ b/internal/service/media_versions.go @@ -0,0 +1,217 @@ +package service + +import ( + "fmt" + "sort" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +type MediaItem struct { + model.Media + Versions []model.Media `json:"versions,omitempty"` +} + +func normalizeGroupedMediaPage(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > maxMediaSearchPageSize { + pageSize = maxMediaSearchPageSize + } + return page, pageSize +} + +func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem { + page, pageSize = normalizeGroupedMediaPage(page, pageSize) + if len(items) == 0 { + return nil + } + start := (page - 1) * pageSize + if start >= len(items) { + return []MediaItem{} + } + end := start + pageSize + if end > len(items) { + end = len(items) + } + return items[start:end] +} + +func firstMediaItems(items []MediaItem, limit int) []MediaItem { + if len(items) == 0 { + return nil + } + if limit <= 0 { + limit = 50 + } + if limit > maxMediaSearchLimit { + limit = maxMediaSearchLimit + } + if limit > len(items) { + limit = len(items) + } + return items[:limit] +} + +func groupMediaVersions(items []model.Media) []MediaItem { + if len(items) == 0 { + return nil + } + type group struct { + key string + primary model.Media + rows []model.Media + } + groups := make([]group, 0, len(items)) + byKey := make(map[string]int, len(items)) + for _, item := range items { + key := mediaVersionGroupKey(item) + if key == "" { + groups = append(groups, group{primary: item, rows: []model.Media{item}}) + continue + } + if idx, ok := byKey[key]; ok { + groups[idx].rows = append(groups[idx].rows, item) + if betterMediaVersion(item, groups[idx].primary) { + groups[idx].primary = item + } + continue + } + byKey[key] = len(groups) + groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}}) + } + out := make([]MediaItem, 0, len(groups)) + for _, g := range groups { + sort.SliceStable(g.rows, func(i, j int) bool { + return betterMediaVersion(g.rows[i], g.rows[j]) + }) + item := MediaItem{Media: g.primary} + if len(g.rows) > 1 { + item.Versions = g.rows + } + out = append(out, item) + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].CreatedAt.After(out[j].CreatedAt) + }) + return out +} + +func mediaVersionGroupKey(m model.Media) string { + if m.SeasonNum > 0 || m.EpisodeNum > 0 { + switch { + case m.TMDbID > 0: + return fmt.Sprintf("episode:tmdb:%d:%d:%d", m.TMDbID, m.SeasonNum, m.EpisodeNum) + case m.BangumiID > 0: + return fmt.Sprintf("episode:bangumi:%d:%d:%d", m.BangumiID, m.SeasonNum, m.EpisodeNum) + case strings.TrimSpace(m.DoubanID) != "": + return fmt.Sprintf("episode:douban:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.DoubanID)), m.SeasonNum, m.EpisodeNum) + case strings.TrimSpace(m.TheTVDBID) != "": + return fmt.Sprintf("episode:thetvdb:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.TheTVDBID)), m.SeasonNum, m.EpisodeNum) + } + title := firstNonEmpty(m.OriginalName, m.Title) + if title == "" { + title, _ = CleanQuery(m.Path) + } + title, _ = mediaVersionTitleKey(title) + if title == "" { + return "" + } + return strings.Join([]string{ + "episode", + strings.ToLower(strings.TrimSpace(m.LibraryID)), + title, + fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum), + }, "|") + } + switch { + case m.TMDbID > 0: + return fmt.Sprintf("tmdb:%d", m.TMDbID) + case m.BangumiID > 0: + return fmt.Sprintf("bangumi:%d", m.BangumiID) + case strings.TrimSpace(m.DoubanID) != "": + return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID)) + case strings.TrimSpace(m.TheTVDBID) != "": + return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID)) + } + title := firstNonEmpty(m.OriginalName, m.Title) + titleYear := 0 + if title == "" { + title, _ = CleanQuery(m.Path) + } else { + title, titleYear = mediaVersionTitleKey(title) + } + if title == "" { + return "" + } + year := m.Year + if year <= 0 { + year = titleYear + } + if year <= 0 { + _, year = CleanQuery(m.Path) + } + return fmt.Sprintf("movie:%s:%d", title, year) +} + +func mediaVersionTitleKey(value string) (string, int) { + cleaned, year := CleanQuery(value) + if strings.TrimSpace(cleaned) == "" { + cleaned = value + } + return normalizeMediaVersionText(cleaned), year +} + +func normalizeMediaVersionText(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + fields := strings.FieldsFunc(value, func(r rune) bool { + switch r { + case '.', '_', '-', ' ', '\t', '/', '\\', '[', ']', '(', ')', '(', ')', '【', '】': + return true + default: + return false + } + }) + out := fields[:0] + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + if _, noise := noiseTokenSet[field]; noise { + continue + } + out = append(out, field) + } + return strings.Join(out, " ") +} + +func betterMediaVersion(candidate, current model.Media) bool { + candidateCloud := isCloudMediaVersion(candidate) + currentCloud := isCloudMediaVersion(current) + if candidateCloud != currentCloud { + return !candidateCloud + } + candidatePixels := candidate.Width * candidate.Height + currentPixels := current.Width * current.Height + if candidatePixels != currentPixels { + return candidatePixels > currentPixels + } + if candidate.SizeBytes != current.SizeBytes { + return candidate.SizeBytes > current.SizeBytes + } + return candidate.CreatedAt.After(current.CreatedAt) +} + +func isCloudMediaVersion(media model.Media) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") || + strings.Contains(strings.ToLower(strings.TrimSpace(media.STRMURL)), "/api/cloud/play/") +}