small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:24 +02:00
commit 5c500a72b0
243 changed files with 44176 additions and 0 deletions
@@ -0,0 +1,771 @@
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),
}
}