package catalog import ( "context" "errors" "fmt" "slices" "strings" "time" "github.com/google/uuid" ) type sectionSeed struct { Kind string Title string Subtitle string ItemIDs []int } type Repository interface { Discover(ctx context.Context, params DiscoverParams) ([]DiscoverSection, error) SectionItems(ctx context.Context, kind string, limit int) ([]MediaItem, error) SearchMedia(ctx context.Context, params SearchParams) ([]MediaItem, error) ListWatchLater(ctx context.Context, userID uuid.UUID) ([]MediaItem, error) AddWatchLater(ctx context.Context, userID uuid.UUID, mediaID int) error RemoveWatchLater(ctx context.Context, userID uuid.UUID, mediaID int) error ListContinueWatching(ctx context.Context, userID uuid.UUID, limit int) ([]ContinueWatchingItem, error) UpsertProgress(ctx context.Context, userID uuid.UUID, input ProgressUpdateInput) error } type GameLookup interface { SearchGames(ctx context.Context, query string, limit int) ([]MediaItem, error) } var ( ErrInvalidInput = errors.New("invalid input") ErrMediaNotFound = errors.New("media not found") ) type Service struct { repo Repository gameLookup GameLookup media map[int]MediaItem allMedia []MediaItem sections []sectionSeed dashboard DashboardPayload continueWatching []ContinueWatchingItem } func NewService(repo ...Repository) *Service { var selected Repository if len(repo) > 0 { selected = repo[0] } allMedia := []MediaItem{ newMedia(1, MediaProviderTMDB, 10001, "Neon Divide", MediaTypeMovie, []string{"Sci-Fi", "Thriller"}, nil, "2025-05-14", 8.5, 118), newMedia(2, MediaProviderTMDB, 10002, "Last Light Harbor", MediaTypeShow, []string{"Drama", "Mystery"}, nil, "2024-09-02", 8.1, 52), newMedia(3, MediaProviderTMDB, 10003, "Orbitline", MediaTypeMovie, []string{"Sci-Fi", "Adventure"}, nil, "2025-02-21", 7.9, 131), newMedia(4, MediaProviderTMDB, 10004, "Static Bloom", MediaTypeShow, []string{"Comedy", "Drama"}, nil, "2023-11-12", 7.8, 42), newMedia(5, MediaProviderTMDB, 10005, "Kingdom Ash", MediaTypeMovie, []string{"Fantasy", "Action"}, nil, "2024-12-08", 8.7, 143), newMedia(6, MediaProviderTMDB, 10006, "Pulse District", MediaTypeShow, []string{"Crime", "Thriller"}, nil, "2025-03-18", 8.0, 48), newMedia(7, MediaProviderTMDB, 10007, "The Glass Relay", MediaTypeMovie, []string{"Action", "Thriller"}, nil, "2024-07-01", 7.6, 109), newMedia(8, MediaProviderTMDB, 10008, "Summer in Vanta", MediaTypeShow, []string{"Romance", "Drama"}, nil, "2025-06-30", 7.5, 44), newMedia(9, MediaProviderTMDB, 10009, "Zero Meridian", MediaTypeMovie, []string{"Sci-Fi", "Action"}, nil, "2026-01-10", 8.9, 127), newMedia(10, MediaProviderTMDB, 10010, "Northline 13", MediaTypeShow, []string{"Mystery", "Crime"}, nil, "2025-10-19", 8.3, 50), newMedia(11, MediaProviderTMDB, 10011, "Paper Falcons", MediaTypeMovie, []string{"Adventure", "Family"}, nil, "2024-03-05", 7.4, 101), newMedia(12, MediaProviderTMDB, 10012, "Hollow Anthem", MediaTypeMovie, []string{"Drama", "Music"}, nil, "2025-08-22", 8.2, 114), newMedia(13, MediaProviderTMDB, 10013, "Riptide Avenue", MediaTypeShow, []string{"Action", "Drama"}, nil, "2023-04-09", 7.7, 55), newMedia(14, MediaProviderTMDB, 10014, "Night Air Index", MediaTypeMovie, []string{"Mystery", "Thriller"}, nil, "2026-04-03", 8.4, 122), newMedia(15, MediaProviderTMDB, 10015, "Shoreline Math", MediaTypeShow, []string{"Comedy", "Family"}, nil, "2024-06-17", 7.3, 37), newMedia(16, MediaProviderTMDB, 10016, "Arcadia Wire", MediaTypeMovie, []string{"Fantasy", "Drama"}, nil, "2025-12-02", 8.6, 136), newMedia(17, MediaProviderTMDB, 10017, "Abyss Echo", MediaTypeShow, []string{"Sci-Fi", "Mystery"}, nil, "2025-01-27", 8.8, 53), newMedia(18, MediaProviderTMDB, 10018, "Delta Murmur", MediaTypeMovie, []string{"Horror", "Thriller"}, nil, "2024-10-29", 7.2, 96), newMedia(19, MediaProviderTMDB, 10019, "Pine Weather", MediaTypeShow, []string{"Drama", "Romance"}, nil, "2025-04-11", 7.9, 46), newMedia(20, MediaProviderTMDB, 10020, "Copper Atlas", MediaTypeMovie, []string{"Adventure", "Action"}, nil, "2025-09-09", 8.0, 111), newMedia(21, MediaProviderTMDB, 10021, "Moonset Terminal", MediaTypeMovie, []string{"Sci-Fi", "Drama"}, nil, "2026-02-14", 8.4, 124), newMedia(22, MediaProviderTMDB, 10022, "Marble Sea", MediaTypeShow, []string{"Fantasy", "Adventure"}, nil, "2024-01-22", 7.6, 49), newMedia(23, MediaProviderTMDB, 10023, "Tangent Room", MediaTypeMovie, []string{"Mystery", "Drama"}, nil, "2025-11-03", 8.1, 119), newMedia(24, MediaProviderTMDB, 10024, "Signal Orchard", MediaTypeShow, []string{"Thriller", "Drama"}, nil, "2025-07-18", 8.2, 51), newMedia(25, MediaProviderIGDB, 20025, "Star Circuit Zero", MediaTypeGame, []string{"Action", "Racing"}, []string{"PC", "PS5", "Xbox Series X|S"}, "2026-09-18", 8.8, 900), newMedia(26, MediaProviderIGDB, 20026, "Verdant Protocol", MediaTypeGame, []string{"Strategy", "Simulation"}, []string{"PC"}, "2026-11-06", 8.4, 1260), newMedia(27, MediaProviderIGDB, 20027, "Mythic Drift", MediaTypeGame, []string{"Racing", "Adventure"}, []string{"PS5", "Xbox Series X|S"}, "2026-07-24", 8.2, 720), newMedia(28, MediaProviderIGDB, 20028, "Ashen Vale", MediaTypeGame, []string{"RPG", "Adventure"}, []string{"PC", "PS5"}, "2026-05-15", 9.0, 1680), newMedia(29, MediaProviderIGDB, 20029, "Signal Breaker", MediaTypeGame, []string{"Shooter", "Sci-Fi"}, []string{"PC", "Xbox Series X|S"}, "2026-02-28", 8.1, 840), newMedia(30, MediaProviderIGDB, 20030, "Luma Forge", MediaTypeGame, []string{"Indie", "Puzzle"}, []string{"Nintendo Switch", "PC"}, "2026-01-16", 8.3, 360), newMedia(31, MediaProviderIGDB, 20031, "Citadel Dawn", MediaTypeGame, []string{"Strategy", "RPG"}, []string{"PC"}, "2026-03-05", 8.6, 1500), newMedia(32, MediaProviderIGDB, 20032, "Harbor Tactics", MediaTypeGame, []string{"Strategy", "Simulation"}, []string{"PC"}, "2025-11-21", 7.8, 1080), newMedia(33, MediaProviderIGDB, 20033, "Ghostline Kyoto", MediaTypeGame, []string{"Action", "Adventure"}, []string{"PS5", "PC"}, "2026-02-14", 8.7, 1020), newMedia(34, MediaProviderIGDB, 20034, "Snowfall County", MediaTypeGame, []string{"Simulation", "Adventure"}, []string{"Nintendo Switch", "PC"}, "2025-12-12", 7.9, 540), newMedia(35, MediaProviderIGDB, 20035, "Titan Relay", MediaTypeGame, []string{"Shooter", "Action"}, []string{"PC", "PS5"}, "2026-04-22", 8.5, 780), newMedia(36, MediaProviderIGDB, 20036, "Wild Circuit Stories", MediaTypeGame, []string{"Racing", "Indie"}, []string{"Nintendo Switch", "Xbox Series X|S"}, "2026-08-07", 8.0, 420), } mediaByID := make(map[int]MediaItem, len(allMedia)) for _, item := range allMedia { mediaByID[item.ID] = item } sections := []sectionSeed{ {Kind: "trending", Title: "Trending", Subtitle: "Hot picks across screens and launchers", ItemIDs: []int{9, 25, 33, 21, 6, 17, 28, 23}}, {Kind: "popular", Title: "Popular", Subtitle: "High-signal releases people keep returning to", ItemIDs: []int{1, 2, 33, 5, 28, 8, 10, 31}}, {Kind: "top-rated", Title: "Top Rated", Subtitle: "Highest community ratings across all media", ItemIDs: []int{9, 17, 25, 5, 33, 16, 28, 21}}, {Kind: "upcoming", Title: "Upcoming", Subtitle: "Near-term drops on your radar", ItemIDs: []int{21, 25, 26, 14, 27, 23, 35, 36}}, {Kind: "now-playing", Title: "Now Playing", Subtitle: "Freshly added movies, episodes, and game launches", ItemIDs: []int{3, 6, 12, 29, 33, 30, 2, 17}}, {Kind: "airing-today", Title: "Airing Today", Subtitle: "Episodes and drops available now", ItemIDs: []int{6, 10, 17, 19, 24, 22, 4, 15}}, {Kind: "recently-released-games", Title: "Recently Released Games", Subtitle: "New launches worth checking this month", ItemIDs: []int{33, 31, 29, 30, 34, 32}}, {Kind: "most-anticipated-games", Title: "Most Anticipated Games", Subtitle: "Upcoming releases with strong momentum", ItemIDs: []int{25, 26, 27, 28, 35, 36, 31, 29}}, {Kind: "indie-highlights", Title: "Indie Highlights", Subtitle: "Smaller teams shipping sharper ideas", ItemIDs: []int{30, 36, 34, 32, 28, 26}}, } dashboard := DashboardPayload{ WatchLater: mustPick(mediaByID, []int{25, 9, 14, 33, 21, 28}), GameBacklog: mustPick(mediaByID, []int{25, 33, 28, 31}), ActiveDownloads: []DownloadJob{ {ID: "dl-1024", Title: "Zero Meridian (2160p HDR)", Status: "downloading", ProgressPercent: 47, DownloadSpeedMbps: 23.8, EtaMinutes: 19, SourceType: "magnet"}, {ID: "dl-1025", Title: "Star Circuit Zero preload", Status: "queued", ProgressPercent: 0, DownloadSpeedMbps: 0, EtaMinutes: 34, SourceType: "http"}, {ID: "dl-1026", Title: "Arcadia Wire (1080p)", Status: "stalled", ProgressPercent: 74, DownloadSpeedMbps: 0.2, EtaMinutes: 120, SourceType: "torrent"}, }, Recommendations: []RecommendationItem{ {ID: 28, Reason: "Because your queue trends toward expansive fantasy worlds", Score: 93, Media: mustGet(mediaByID, 28)}, {ID: 24, Reason: "Because you finish serial thrillers quickly", Score: 89, Media: mustGet(mediaByID, 24)}, {ID: 33, Reason: "Because cinematic action games align with your recent picks", Score: 91, Media: mustGet(mediaByID, 33)}, {ID: 1, Reason: "Because your recent watches trend sci-fi", Score: 88, Media: mustGet(mediaByID, 1)}, }, Trending: mustPick(mediaByID, []int{9, 25, 33, 21, 16, 14}), Upcoming: mustPick(mediaByID, []int{21, 25, 26, 14, 35}), RecentlyWatched: mustPick(mediaByID, []int{2, 6, 17, 33, 29}), } continueWatching := []ContinueWatchingItem{ {Item: mustGet(mediaByID, 2), Progress: EpisodeProgress{ItemID: 2, SeasonNumber: 1, EpisodeNumber: 7, ProgressPercent: 63, LastWatchedAt: "2026-03-09T21:40:00Z"}}, {Item: mustGet(mediaByID, 17), Progress: EpisodeProgress{ItemID: 17, SeasonNumber: 2, EpisodeNumber: 2, ProgressPercent: 28, LastWatchedAt: "2026-03-08T23:16:00Z"}}, {Item: mustGet(mediaByID, 6), Progress: EpisodeProgress{ItemID: 6, SeasonNumber: 1, EpisodeNumber: 11, ProgressPercent: 82, LastWatchedAt: "2026-03-07T18:10:00Z"}}, } return &Service{ repo: selected, media: mediaByID, allMedia: slices.Clone(allMedia), sections: slices.Clone(sections), dashboard: dashboard, continueWatching: slices.Clone(continueWatching), } } func (s *Service) SetGameLookup(lookup GameLookup) { s.gameLookup = lookup } func (s *Service) Dashboard() DashboardPayload { if s.repo != nil { ctx := context.Background() watchLater, err := s.repo.SectionItems(ctx, "top-rated", 5) if err == nil { gameBacklog, gameBacklogErr := s.repo.SectionItems(ctx, "most-anticipated-games", 4) trending, trendingErr := s.repo.SectionItems(ctx, "trending", 6) upcoming, upcomingErr := s.repo.SectionItems(ctx, "upcoming", 5) recentlyWatched, recentlyErr := s.repo.SectionItems(ctx, "now-playing", 5) if gameBacklogErr == nil && trendingErr == nil && upcomingErr == nil && recentlyErr == nil { payload := DashboardPayload{ WatchLater: cloneMediaItems(watchLater), GameBacklog: cloneMediaItems(gameBacklog), ActiveDownloads: slices.Clone(s.dashboard.ActiveDownloads), Recommendations: slices.Clone(s.dashboard.Recommendations), Trending: cloneMediaItems(trending), Upcoming: cloneMediaItems(upcoming), RecentlyWatched: cloneMediaItems(recentlyWatched), } return payload } } } return DashboardPayload{ WatchLater: slices.Clone(s.dashboard.WatchLater), GameBacklog: slices.Clone(s.dashboard.GameBacklog), ActiveDownloads: slices.Clone(s.dashboard.ActiveDownloads), Recommendations: slices.Clone(s.dashboard.Recommendations), Trending: slices.Clone(s.dashboard.Trending), Upcoming: slices.Clone(s.dashboard.Upcoming), RecentlyWatched: slices.Clone(s.dashboard.RecentlyWatched), } } func (s *Service) WatchLater(userID uuid.UUID) ([]MediaItem, error) { if userID == uuid.Nil { return nil, ErrInvalidInput } if s.repo != nil { items, err := s.repo.ListWatchLater(context.Background(), userID) if err == nil { return cloneMediaItems(items), nil } } return cloneMediaItems(s.dashboard.WatchLater), nil } func (s *Service) AddWatchLater(userID uuid.UUID, mediaID int) ([]MediaItem, error) { if userID == uuid.Nil || mediaID < 1 { return nil, ErrInvalidInput } if s.repo != nil { if err := s.repo.AddWatchLater(context.Background(), userID, mediaID); err != nil { if errors.Is(err, ErrMediaNotFound) { return nil, ErrMediaNotFound } return nil, err } items, err := s.repo.ListWatchLater(context.Background(), userID) if err != nil { return nil, err } return cloneMediaItems(items), nil } updated, err := addToWatchLater(s.dashboard.WatchLater, s.media, mediaID) if err != nil { return nil, err } s.dashboard.WatchLater = updated return cloneMediaItems(s.dashboard.WatchLater), nil } func (s *Service) RemoveWatchLater(userID uuid.UUID, mediaID int) ([]MediaItem, error) { if userID == uuid.Nil || mediaID < 1 { return nil, ErrInvalidInput } if s.repo != nil { if err := s.repo.RemoveWatchLater(context.Background(), userID, mediaID); err != nil { return nil, err } items, err := s.repo.ListWatchLater(context.Background(), userID) if err != nil { return nil, err } return cloneMediaItems(items), nil } s.dashboard.WatchLater = removeFromWatchLater(s.dashboard.WatchLater, mediaID) return cloneMediaItems(s.dashboard.WatchLater), nil } func (s *Service) ContinueWatching(userID uuid.UUID) ([]ContinueWatchingItem, error) { if userID == uuid.Nil { return nil, ErrInvalidInput } if s.repo != nil { items, err := s.repo.ListContinueWatching(context.Background(), userID, 12) if err != nil { return nil, err } return cloneContinueWatching(items), nil } return cloneContinueWatching(s.continueWatching), nil } func (s *Service) UpdateProgress(userID uuid.UUID, input ProgressUpdateInput) ([]ContinueWatchingItem, error) { if userID == uuid.Nil || input.MediaID < 1 { return nil, ErrInvalidInput } normalized := normalizeProgressInput(input) if normalized.ProgressPercent < 0 || normalized.ProgressPercent > 100 { return nil, ErrInvalidInput } if s.repo != nil { if err := s.repo.UpsertProgress(context.Background(), userID, normalized); err != nil { if errors.Is(err, ErrMediaNotFound) { return nil, ErrMediaNotFound } return nil, err } items, err := s.repo.ListContinueWatching(context.Background(), userID, 12) if err != nil { return nil, err } return cloneContinueWatching(items), nil } updated, err := upsertContinueWatchingInMemory(s.continueWatching, s.media, normalized) if err != nil { return nil, err } s.continueWatching = updated return cloneContinueWatching(s.continueWatching), nil } func (s *Service) Discover(params DiscoverParams) []DiscoverSection { sanitized := sanitizeDiscoverParams(params) if s.repo != nil { sections, err := s.repo.Discover(context.Background(), sanitized) if err == nil { return withGenreInSectionTitle(sections, sanitized.Genre) } } sections := make([]DiscoverSection, 0, len(s.sections)) for _, section := range s.sections { candidates := mustPick(s.media, section.ItemIDs) filtered := filterMedia(candidates, sanitized.Query, sanitized.Genre, sanitized.MediaType) paged := page(filtered, sanitized.Page, sanitized.PageSize) if len(paged) == 0 { continue } title := section.Title if sanitized.Genre != "" { title = fmt.Sprintf("%s · %s", title, sanitized.Genre) } sections = append(sections, DiscoverSection{ Kind: section.Kind, Title: title, Subtitle: section.Subtitle, Items: paged, }) } return sections } func (s *Service) Search(params SearchParams) []SearchResult { query := strings.TrimSpace(strings.ToLower(params.Query)) if query == "" { return []SearchResult{} } if s.repo != nil { filtered, err := s.repo.SearchMedia(context.Background(), params) if err == nil { return buildSearchResults(filtered, query) } } filtered := filterMedia(s.allMedia, query, strings.TrimSpace(params.Genre), strings.TrimSpace(params.MediaType)) if s.gameLookup != nil && shouldIncludeGameLookup(params.MediaType) { remote, err := s.gameLookup.SearchGames(context.Background(), params.Query, 12) if err == nil { filtered = mergeMediaItems( filtered, filterMedia(remote, query, strings.TrimSpace(params.Genre), string(MediaTypeGame)), ) } } return buildSearchResults(filtered, query) } func buildSearchResults(items []MediaItem, query string) []SearchResult { results := make([]SearchResult, 0, len(items)) for _, item := range items { subtitle := fmt.Sprintf("%s · %s", strings.Join(item.Genres, " • "), releaseYear(item.ReleaseDate)) if item.Type == MediaTypeGame && len(item.Platforms) > 0 { subtitle = fmt.Sprintf("%s · %s · %s", strings.Join(item.Genres, " • "), strings.Join(item.Platforms, " • "), releaseYear(item.ReleaseDate)) } results = append(results, SearchResult{ ID: item.ID, MediaType: string(item.Type), Title: item.Title, Subtitle: subtitle, Genres: slices.Clone(item.Genres), Score: score(item.Title, query, item.Rating), }) } slices.SortFunc(results, func(left SearchResult, right SearchResult) int { return right.Score - left.Score }) if len(results) > 12 { return slices.Clone(results[:12]) } return results } func releaseYear(releaseDate string) string { if len(releaseDate) >= 4 { return releaseDate[:4] } return releaseDate } func shouldIncludeGameLookup(mediaType string) bool { cleanType := strings.ToLower(strings.TrimSpace(mediaType)) return cleanType == "" || cleanType == "all" || cleanType == string(MediaTypeGame) } func mergeMediaItems(existing []MediaItem, incoming []MediaItem) []MediaItem { if len(incoming) == 0 { return existing } seen := make(map[string]struct{}, len(existing)+len(incoming)) merged := make([]MediaItem, 0, len(existing)+len(incoming)) for _, item := range existing { key := fmt.Sprintf("%s:%d", item.Provider, item.ProviderID) seen[key] = struct{}{} merged = append(merged, item) } for _, item := range incoming { key := fmt.Sprintf("%s:%d", item.Provider, item.ProviderID) if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} merged = append(merged, item) } return merged } func withGenreInSectionTitle(sections []DiscoverSection, genre string) []DiscoverSection { cleanGenre := strings.TrimSpace(genre) if cleanGenre == "" { return sections } updated := make([]DiscoverSection, 0, len(sections)) for _, section := range sections { updated = append(updated, DiscoverSection{ Kind: section.Kind, Title: fmt.Sprintf("%s · %s", section.Title, cleanGenre), Subtitle: section.Subtitle, Items: cloneMediaItems(section.Items), }) } return updated } func cloneMediaItems(items []MediaItem) []MediaItem { if len(items) == 0 { return []MediaItem{} } cloned := make([]MediaItem, 0, len(items)) for _, item := range items { cloned = append(cloned, MediaItem{ ID: item.ID, Provider: item.Provider, ProviderID: item.ProviderID, Title: item.Title, Overview: item.Overview, Type: item.Type, ReleaseDate: item.ReleaseDate, Genres: cloneStringSlice(item.Genres), Platforms: cloneStringSlice(item.Platforms), Rating: item.Rating, RuntimeMinutes: item.RuntimeMinutes, ArtworkKey: item.ArtworkKey, }) } return cloned } func cloneContinueWatching(items []ContinueWatchingItem) []ContinueWatchingItem { if len(items) == 0 { return []ContinueWatchingItem{} } cloned := make([]ContinueWatchingItem, 0, len(items)) for _, item := range items { cloned = append(cloned, ContinueWatchingItem{ Item: MediaItem{ ID: item.Item.ID, Provider: item.Item.Provider, ProviderID: item.Item.ProviderID, Title: item.Item.Title, Overview: item.Item.Overview, Type: item.Item.Type, ReleaseDate: item.Item.ReleaseDate, Genres: cloneStringSlice(item.Item.Genres), Platforms: cloneStringSlice(item.Item.Platforms), Rating: item.Item.Rating, RuntimeMinutes: item.Item.RuntimeMinutes, ArtworkKey: item.Item.ArtworkKey, }, Progress: EpisodeProgress{ ItemID: item.Progress.ItemID, SeasonNumber: item.Progress.SeasonNumber, EpisodeNumber: item.Progress.EpisodeNumber, ProgressPercent: item.Progress.ProgressPercent, LastWatchedAt: item.Progress.LastWatchedAt, }, }) } return cloned } func normalizeProgressInput(input ProgressUpdateInput) ProgressUpdateInput { season := input.SeasonNumber if season < 1 { season = 1 } episode := input.EpisodeNumber if episode < 1 { episode = 1 } return ProgressUpdateInput{ MediaID: input.MediaID, SeasonNumber: season, EpisodeNumber: episode, ProgressPercent: input.ProgressPercent, } } func upsertContinueWatchingInMemory( existing []ContinueWatchingItem, catalogMap map[int]MediaItem, input ProgressUpdateInput, ) ([]ContinueWatchingItem, error) { next := make([]ContinueWatchingItem, 0, len(existing)+1) for _, entry := range existing { if entry.Item.ID == input.MediaID && entry.Progress.SeasonNumber == input.SeasonNumber && entry.Progress.EpisodeNumber == input.EpisodeNumber { continue } next = append(next, entry) } if input.ProgressPercent <= 0 || input.ProgressPercent >= 100 { return next, nil } media, ok := catalogMap[input.MediaID] if !ok { return nil, ErrMediaNotFound } newEntry := ContinueWatchingItem{ Item: media, Progress: EpisodeProgress{ ItemID: input.MediaID, SeasonNumber: input.SeasonNumber, EpisodeNumber: input.EpisodeNumber, ProgressPercent: input.ProgressPercent, LastWatchedAt: time.Now().UTC().Format(time.RFC3339), }, } next = append([]ContinueWatchingItem{newEntry}, next...) if len(next) > 12 { return next[:12], nil } return next, nil } func addToWatchLater(existing []MediaItem, catalogMap map[int]MediaItem, mediaID int) ([]MediaItem, error) { for _, item := range existing { if item.ID == mediaID { return existing, nil } } media, ok := catalogMap[mediaID] if !ok { return nil, ErrMediaNotFound } next := make([]MediaItem, 0, len(existing)+1) next = append(next, media) next = append(next, cloneMediaItems(existing)...) return next, nil } func removeFromWatchLater(existing []MediaItem, mediaID int) []MediaItem { next := make([]MediaItem, 0, len(existing)) for _, item := range existing { if item.ID == mediaID { continue } next = append(next, item) } return next } func sanitizeDiscoverParams(params DiscoverParams) DiscoverParams { page := params.Page if page < 1 { page = 1 } pageSize := params.PageSize if pageSize < 1 { pageSize = 6 } return DiscoverParams{ Page: page, PageSize: pageSize, Query: strings.TrimSpace(params.Query), Genre: strings.TrimSpace(params.Genre), MediaType: strings.TrimSpace(params.MediaType), } } func filterMedia(items []MediaItem, query string, genre string, mediaType string) []MediaItem { queryLower := strings.ToLower(strings.TrimSpace(query)) genreLower := strings.ToLower(strings.TrimSpace(genre)) mediaTypeLower := strings.ToLower(strings.TrimSpace(mediaType)) result := make([]MediaItem, 0, len(items)) for _, item := range items { if mediaTypeLower != "" && mediaTypeLower != "all" && mediaTypeLower != string(item.Type) { continue } if genreLower != "" && !hasGenre(item, genreLower) { continue } if queryLower != "" && !matchesQuery(item, queryLower) { continue } result = append(result, item) } return result } func hasGenre(item MediaItem, genre string) bool { for _, existing := range item.Genres { if strings.ToLower(existing) == genre { return true } } return false } func matchesQuery(item MediaItem, query string) bool { if strings.Contains(strings.ToLower(item.Title), query) { return true } if strings.Contains(strings.ToLower(item.Overview), query) { return true } for _, genre := range item.Genres { if strings.Contains(strings.ToLower(genre), query) { return true } } return false } func page(items []MediaItem, pageNumber int, pageSize int) []MediaItem { start := (pageNumber - 1) * pageSize if start >= len(items) { return []MediaItem{} } end := start + pageSize if end > len(items) { end = len(items) } return slices.Clone(items[start:end]) } func cloneStringSlice(values []string) []string { if len(values) == 0 { return []string{} } return slices.Clone(values) } func score(title string, query string, rating float64) int { cleanTitle := strings.ToLower(strings.TrimSpace(title)) if cleanTitle == query { return min(100, int(70+rating*3)) } if strings.HasPrefix(cleanTitle, query) { return min(98, int(60+rating*3)) } return min(95, int(45+rating*4)) } func min(a int, b int) int { if a < b { return a } return b } func mustPick(media map[int]MediaItem, ids []int) []MediaItem { items := make([]MediaItem, 0, len(ids)) for _, id := range ids { items = append(items, mustGet(media, id)) } return items } func mustGet(media map[int]MediaItem, id int) MediaItem { item, ok := media[id] if !ok { panic(fmt.Sprintf("missing media item %d", id)) } return item } func newMedia( id int, provider MediaProvider, providerID int, title string, mediaType MediaType, genres []string, platforms []string, releaseDate string, rating float64, runtimeMinutes int, ) MediaItem { overview := fmt.Sprintf("%s is a premium catalog title in your Seen library.", title) if mediaType == MediaTypeGame { overview = fmt.Sprintf("%s is a high-signal game release tracked through your IGDB-powered backlog.", title) } return MediaItem{ ID: id, Provider: provider, ProviderID: providerID, Title: title, Overview: overview, Type: mediaType, ReleaseDate: releaseDate, Genres: cloneStringSlice(genres), Platforms: cloneStringSlice(platforms), Rating: rating, RuntimeMinutes: runtimeMinutes, ArtworkKey: fmt.Sprintf("%s-%d", title, id), } }