mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-04 20:43:03 +00:00
772 lines
26 KiB
Go
772 lines
26 KiB
Go
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),
|
|
}
|
|
}
|