mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-05 13:03:01 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestDiscoverFilters(t *testing.T) {
|
||||
svc := NewService()
|
||||
|
||||
sections := svc.Discover(DiscoverParams{
|
||||
Page: 1,
|
||||
PageSize: 3,
|
||||
Genre: "Sci-Fi",
|
||||
MediaType: "movie",
|
||||
})
|
||||
|
||||
if len(sections) == 0 {
|
||||
t.Fatalf("expected non-empty discover sections")
|
||||
}
|
||||
|
||||
for _, section := range sections {
|
||||
for _, item := range section.Items {
|
||||
if item.Type != MediaTypeMovie {
|
||||
t.Fatalf("expected movie item, got %s", item.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverSupportsGames(t *testing.T) {
|
||||
svc := NewService()
|
||||
|
||||
sections := svc.Discover(DiscoverParams{
|
||||
Page: 1,
|
||||
PageSize: 4,
|
||||
MediaType: "game",
|
||||
})
|
||||
|
||||
if len(sections) == 0 {
|
||||
t.Fatalf("expected game sections")
|
||||
}
|
||||
|
||||
for _, section := range sections {
|
||||
for _, item := range section.Items {
|
||||
if item.Type != MediaTypeGame {
|
||||
t.Fatalf("expected game item, got %s", item.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
svc := NewService()
|
||||
results := svc.Search(SearchParams{Query: "zero", MediaType: "all"})
|
||||
|
||||
if len(results) == 0 {
|
||||
t.Fatalf("expected search results")
|
||||
}
|
||||
|
||||
if results[0].Title != "Zero Meridian" {
|
||||
t.Fatalf("expected top result Zero Meridian, got %s", results[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchGames(t *testing.T) {
|
||||
svc := NewService()
|
||||
results := svc.Search(SearchParams{Query: "ghostline", MediaType: "game"})
|
||||
|
||||
if len(results) == 0 {
|
||||
t.Fatalf("expected game search results")
|
||||
}
|
||||
|
||||
if results[0].MediaType != string(MediaTypeGame) {
|
||||
t.Fatalf("expected game result, got %s", results[0].MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProgressInMemory(t *testing.T) {
|
||||
svc := NewService()
|
||||
userID := uuid.New()
|
||||
|
||||
updated, err := svc.UpdateProgress(userID, ProgressUpdateInput{
|
||||
MediaID: 2,
|
||||
SeasonNumber: 1,
|
||||
EpisodeNumber: 7,
|
||||
ProgressPercent: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected update to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
for _, entry := range updated {
|
||||
if entry.Item.ID == 2 &&
|
||||
entry.Progress.SeasonNumber == 1 &&
|
||||
entry.Progress.EpisodeNumber == 7 {
|
||||
t.Fatalf("expected completed progress to be excluded from continue watching")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardIncludesGameBacklog(t *testing.T) {
|
||||
svc := NewService()
|
||||
|
||||
payload := svc.Dashboard()
|
||||
if len(payload.GameBacklog) == 0 {
|
||||
t.Fatalf("expected dashboard game backlog")
|
||||
}
|
||||
|
||||
for _, item := range payload.GameBacklog {
|
||||
if item.Type != MediaTypeGame {
|
||||
t.Fatalf("expected game backlog item, got %s", item.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package catalog
|
||||
|
||||
type MediaType string
|
||||
|
||||
const (
|
||||
MediaTypeMovie MediaType = "movie"
|
||||
MediaTypeShow MediaType = "show"
|
||||
MediaTypeGame MediaType = "game"
|
||||
)
|
||||
|
||||
type MediaProvider string
|
||||
|
||||
const (
|
||||
MediaProviderTMDB MediaProvider = "tmdb"
|
||||
MediaProviderIGDB MediaProvider = "igdb"
|
||||
)
|
||||
|
||||
type MediaItem struct {
|
||||
ID int `json:"id"`
|
||||
Provider MediaProvider `json:"provider"`
|
||||
ProviderID int `json:"providerId"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
Type MediaType `json:"type"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Genres []string `json:"genres"`
|
||||
Platforms []string `json:"platforms"`
|
||||
Rating float64 `json:"rating"`
|
||||
RuntimeMinutes int `json:"runtimeMinutes"`
|
||||
ArtworkKey string `json:"artworkKey"`
|
||||
}
|
||||
|
||||
type EpisodeProgress struct {
|
||||
ItemID int `json:"itemId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
ProgressPercent int `json:"progressPercent"`
|
||||
LastWatchedAt string `json:"lastWatchedAt"`
|
||||
}
|
||||
|
||||
type ContinueWatchingItem struct {
|
||||
Item MediaItem `json:"item"`
|
||||
Progress EpisodeProgress `json:"progress"`
|
||||
}
|
||||
|
||||
type DownloadJob struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ProgressPercent int `json:"progressPercent"`
|
||||
DownloadSpeedMbps float64 `json:"downloadSpeedMbps"`
|
||||
EtaMinutes int `json:"etaMinutes"`
|
||||
SourceType string `json:"sourceType"`
|
||||
}
|
||||
|
||||
type RecommendationItem struct {
|
||||
ID int `json:"id"`
|
||||
Reason string `json:"reason"`
|
||||
Score int `json:"score"`
|
||||
Media MediaItem `json:"media"`
|
||||
}
|
||||
|
||||
type DashboardPayload struct {
|
||||
WatchLater []MediaItem `json:"watchLater"`
|
||||
GameBacklog []MediaItem `json:"gameBacklog"`
|
||||
ActiveDownloads []DownloadJob `json:"activeDownloads"`
|
||||
Recommendations []RecommendationItem `json:"recommendations"`
|
||||
Trending []MediaItem `json:"trending"`
|
||||
Upcoming []MediaItem `json:"upcoming"`
|
||||
RecentlyWatched []MediaItem `json:"recentlyWatched"`
|
||||
}
|
||||
|
||||
type DiscoverSection struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Items []MediaItem `json:"items"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
ID int `json:"id"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Genres []string `json:"genres"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
type DiscoverParams struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Query string
|
||||
Genre string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
type SearchParams struct {
|
||||
Query string
|
||||
Genre string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
type ProgressUpdateInput struct {
|
||||
MediaID int
|
||||
SeasonNumber int
|
||||
EpisodeNumber int
|
||||
ProgressPercent int
|
||||
}
|
||||
Reference in New Issue
Block a user