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
+256
View File
@@ -0,0 +1,256 @@
package auth
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/tdvorak/seen/backend/internal/config"
"github.com/tdvorak/seen/backend/internal/domain"
"github.com/tdvorak/seen/backend/internal/repositories/postgres"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidInput = errors.New("invalid input")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrEmailTaken = errors.New("email already exists")
ErrInvalidSession = errors.New("invalid session")
ErrInvalidToken = errors.New("invalid token")
)
type Repository interface {
CreateUser(ctx context.Context, user domain.User) error
FindUserByEmail(ctx context.Context, email string) (*domain.User, error)
FindUserByID(ctx context.Context, userID uuid.UUID) (*domain.User, error)
CreateSession(ctx context.Context, session domain.Session) error
FindSessionByRefreshToken(ctx context.Context, refreshToken string) (*domain.Session, error)
RevokeSession(ctx context.Context, sessionID uuid.UUID) error
}
type Service struct {
repo Repository
cfg config.AuthConfig
log *zap.Logger
}
type RegisterInput struct {
Email string
Password string
DisplayName string
}
type LoginInput struct {
Email string
Password string
UserAgent string
IP string
}
type RefreshInput struct {
RefreshToken string
UserAgent string
IP string
}
type AuthResult struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt time.Time `json:"expiresAt"`
User domain.User `json:"user"`
}
func NewService(repo Repository, cfg config.AuthConfig, log *zap.Logger) *Service {
return &Service{repo: repo, cfg: cfg, log: log}
}
func (s *Service) Register(ctx context.Context, input RegisterInput) (*AuthResult, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" || len(input.Password) < 8 {
return nil, ErrInvalidInput
}
displayName := strings.TrimSpace(input.DisplayName)
if displayName == "" {
displayName = strings.Split(email, "@")[0]
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
now := time.Now().UTC()
user := domain.User{
ID: uuid.New(),
Email: email,
DisplayName: displayName,
Role: domain.RoleUser,
PasswordHash: string(passwordHash),
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.CreateUser(ctx, user); err != nil {
if errors.Is(err, postgres.ErrUserAlreadyExists) {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return s.createTokens(ctx, user, "", "")
}
func (s *Service) Login(ctx context.Context, input LoginInput) (*AuthResult, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" || input.Password == "" {
return nil, ErrInvalidInput
}
user, err := s.repo.FindUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
if user == nil {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(input.Password)); err != nil {
return nil, ErrInvalidCredentials
}
return s.createTokens(ctx, *user, input.UserAgent, input.IP)
}
func (s *Service) Refresh(ctx context.Context, input RefreshInput) (*AuthResult, error) {
if strings.TrimSpace(input.RefreshToken) == "" {
return nil, ErrInvalidInput
}
session, err := s.repo.FindSessionByRefreshToken(ctx, input.RefreshToken)
if err != nil {
return nil, fmt.Errorf("find session: %w", err)
}
if session == nil || session.RevokedAt != nil || session.ExpiresAt.Before(time.Now().UTC()) {
return nil, ErrInvalidSession
}
if err := s.repo.RevokeSession(ctx, session.ID); err != nil {
return nil, fmt.Errorf("revoke session: %w", err)
}
user, err := s.repo.FindUserByID(ctx, session.UserID)
if err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
if user == nil {
return nil, ErrInvalidSession
}
user.PasswordHash = ""
return s.createTokens(ctx, *user, input.UserAgent, input.IP)
}
func (s *Service) UserFromAccessToken(ctx context.Context, accessToken string) (*domain.User, error) {
token := strings.TrimSpace(accessToken)
if token == "" {
return nil, ErrInvalidToken
}
parsed, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, ErrInvalidToken
}
return []byte(s.cfg.JWTSecret), nil
})
if err != nil || !parsed.Valid {
return nil, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrInvalidToken
}
subject, ok := claims["sub"].(string)
if !ok || strings.TrimSpace(subject) == "" {
return nil, ErrInvalidToken
}
userID, err := uuid.Parse(subject)
if err != nil {
return nil, ErrInvalidToken
}
user, err := s.repo.FindUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("find user by token: %w", err)
}
if user == nil {
return nil, ErrInvalidToken
}
user.PasswordHash = ""
return user, nil
}
func (s *Service) createTokens(
ctx context.Context,
user domain.User,
userAgent string,
ip string,
) (*AuthResult, error) {
accessToken, expiresAt, err := s.signAccessToken(user)
if err != nil {
return nil, err
}
session := domain.Session{
ID: uuid.New(),
UserID: user.ID,
RefreshToken: uuid.NewString(),
UserAgent: strings.TrimSpace(userAgent),
IP: strings.TrimSpace(ip),
ExpiresAt: time.Now().UTC().Add(time.Duration(s.cfg.RefreshTokenTTLHours) * time.Hour),
CreatedAt: time.Now().UTC(),
}
if err := s.repo.CreateSession(ctx, session); err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
user.PasswordHash = ""
return &AuthResult{
AccessToken: accessToken,
RefreshToken: session.RefreshToken,
ExpiresAt: expiresAt,
User: user,
}, nil
}
func (s *Service) signAccessToken(user domain.User) (string, time.Time, error) {
expiresAt := time.Now().UTC().Add(time.Duration(s.cfg.AccessTokenTTLMinutes) * time.Minute)
claims := jwt.MapClaims{
"sub": user.ID.String(),
"role": string(user.Role),
"exp": expiresAt.Unix(),
"iat": time.Now().UTC().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(s.cfg.JWTSecret))
if err != nil {
return "", time.Time{}, fmt.Errorf("sign access token: %w", err)
}
return signed, expiresAt, nil
}
@@ -0,0 +1,165 @@
package auth
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/tdvorak/seen/backend/internal/config"
"github.com/tdvorak/seen/backend/internal/domain"
"github.com/tdvorak/seen/backend/internal/repositories/postgres"
"go.uber.org/zap"
)
type inMemoryRepo struct {
usersByEmail map[string]domain.User
sessions map[string]domain.Session
}
func newInMemoryRepo() *inMemoryRepo {
return &inMemoryRepo{
usersByEmail: make(map[string]domain.User),
sessions: make(map[string]domain.Session),
}
}
func (r *inMemoryRepo) CreateUser(_ context.Context, user domain.User) error {
if _, exists := r.usersByEmail[user.Email]; exists {
return postgres.ErrUserAlreadyExists
}
r.usersByEmail[user.Email] = user
return nil
}
func (r *inMemoryRepo) FindUserByEmail(_ context.Context, email string) (*domain.User, error) {
user, exists := r.usersByEmail[email]
if !exists {
return nil, nil
}
copy := user
return &copy, nil
}
func (r *inMemoryRepo) FindUserByID(_ context.Context, userID uuid.UUID) (*domain.User, error) {
for _, user := range r.usersByEmail {
if user.ID == userID {
copy := user
return &copy, nil
}
}
return nil, nil
}
func (r *inMemoryRepo) CreateSession(_ context.Context, session domain.Session) error {
r.sessions[session.RefreshToken] = session
return nil
}
func (r *inMemoryRepo) FindSessionByRefreshToken(_ context.Context, refreshToken string) (*domain.Session, error) {
session, exists := r.sessions[refreshToken]
if !exists {
return nil, nil
}
copy := session
return &copy, nil
}
func (r *inMemoryRepo) RevokeSession(_ context.Context, sessionID uuid.UUID) error {
now := time.Now().UTC()
for token, session := range r.sessions {
if session.ID == sessionID {
session.RevokedAt = &now
r.sessions[token] = session
return nil
}
}
return errors.New("session not found")
}
func TestRegisterValidation(t *testing.T) {
svc := NewService(newInMemoryRepo(), config.AuthConfig{AccessTokenTTLMinutes: 10, RefreshTokenTTLHours: 1, JWTSecret: "test"}, zap.NewNop())
_, err := svc.Register(context.Background(), RegisterInput{
Email: "",
Password: "short",
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected invalid input error, got %v", err)
}
}
func TestRegisterAndLoginFlow(t *testing.T) {
repo := newInMemoryRepo()
svc := NewService(repo, config.AuthConfig{AccessTokenTTLMinutes: 10, RefreshTokenTTLHours: 1, JWTSecret: "test"}, zap.NewNop())
registered, err := svc.Register(context.Background(), RegisterInput{
Email: "user@example.com",
Password: "password123",
DisplayName: "Seen User",
})
if err != nil {
t.Fatalf("register failed: %v", err)
}
if registered.AccessToken == "" || registered.RefreshToken == "" {
t.Fatalf("expected issued tokens")
}
loggedIn, err := svc.Login(context.Background(), LoginInput{
Email: "user@example.com",
Password: "password123",
})
if err != nil {
t.Fatalf("login failed: %v", err)
}
if loggedIn.AccessToken == "" {
t.Fatalf("expected login access token")
}
}
func TestLoginWrongPassword(t *testing.T) {
repo := newInMemoryRepo()
svc := NewService(repo, config.AuthConfig{AccessTokenTTLMinutes: 10, RefreshTokenTTLHours: 1, JWTSecret: "test"}, zap.NewNop())
_, err := svc.Register(context.Background(), RegisterInput{
Email: "user@example.com",
Password: "password123",
})
if err != nil {
t.Fatalf("register failed: %v", err)
}
_, err = svc.Login(context.Background(), LoginInput{
Email: "user@example.com",
Password: "wrongpass",
})
if !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials error, got %v", err)
}
}
func TestUserFromAccessToken(t *testing.T) {
repo := newInMemoryRepo()
svc := NewService(repo, config.AuthConfig{AccessTokenTTLMinutes: 10, RefreshTokenTTLHours: 1, JWTSecret: "test"}, zap.NewNop())
authResult, err := svc.Register(context.Background(), RegisterInput{
Email: "user@example.com",
Password: "password123",
})
if err != nil {
t.Fatalf("register failed: %v", err)
}
user, err := svc.UserFromAccessToken(context.Background(), authResult.AccessToken)
if err != nil {
t.Fatalf("user from access token failed: %v", err)
}
if user.Email != "user@example.com" {
t.Fatalf("expected user email, got %s", user.Email)
}
}
@@ -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)
}
}
}
+108
View File
@@ -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
}
@@ -0,0 +1,339 @@
package download
import (
"context"
"errors"
"slices"
"strings"
"time"
"github.com/google/uuid"
)
type Status string
const (
StatusQueued Status = "queued"
StatusPreparing Status = "preparing"
StatusDownloading Status = "downloading"
StatusStalled Status = "stalled"
StatusRetrying Status = "retrying"
StatusCompleted Status = "completed"
StatusFailed Status = "failed"
StatusCancelled Status = "cancelled"
)
var (
ErrInvalidInput = errors.New("invalid input")
ErrNotFound = errors.New("download job not found")
)
type Job struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
SourceType string `json:"sourceType"`
Source string `json:"source,omitempty"`
Title string `json:"title"`
Status string `json:"status"`
QueuePosition int `json:"queuePosition,omitempty"`
ProgressPercent int `json:"progressPercent"`
BytesTotal int64 `json:"bytesTotal"`
BytesDownloaded int64 `json:"bytesDownloaded"`
DownloadSpeedMbps float64 `json:"downloadSpeedMbps"`
EtaSeconds int `json:"etaSeconds,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
RetryCount int `json:"retryCount"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
CancelledAt time.Time `json:"cancelledAt,omitempty"`
}
type Event struct {
ID int64 `json:"id"`
JobID string `json:"jobId"`
Status string `json:"status"`
Message string `json:"message"`
ProgressPercent int `json:"progressPercent"`
Payload string `json:"payload"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateInput struct {
SourceType string
Source string
Title string
}
type ListParams struct {
Status string
Limit int
Offset int
}
type EventParams struct {
Limit int
After string
}
type UpdateInput struct {
Status string
ProgressPercent int
BytesTotal int64
BytesDownloaded int64
DownloadSpeedMbps float64
EtaSeconds int
ErrorMessage string
RetryCount int
}
type Repository interface {
CreateJob(ctx context.Context, userID uuid.UUID, input CreateInput) (Job, error)
ListJobs(ctx context.Context, userID uuid.UUID, params ListParams) ([]Job, error)
GetJobByID(ctx context.Context, userID uuid.UUID, jobID string) (Job, error)
CancelJob(ctx context.Context, userID uuid.UUID, jobID string) (Job, error)
ListEvents(ctx context.Context, userID uuid.UUID, jobID string, params EventParams) ([]Event, error)
AppendEvent(ctx context.Context, jobID string, event Event) error
UpdateJob(ctx context.Context, userID uuid.UUID, jobID string, input UpdateInput) (Job, error)
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Create(ctx context.Context, userID uuid.UUID, input CreateInput) (Job, error) {
if userID == uuid.Nil {
return Job{}, ErrInvalidInput
}
sourceType := strings.TrimSpace(strings.ToLower(input.SourceType))
source := strings.TrimSpace(input.Source)
title := strings.TrimSpace(input.Title)
if sourceType == "" || source == "" {
return Job{}, ErrInvalidInput
}
if !isAllowedSourceType(sourceType) {
return Job{}, ErrInvalidInput
}
return s.repo.CreateJob(ctx, userID, CreateInput{
SourceType: sourceType,
Source: source,
Title: title,
})
}
func (s *Service) List(ctx context.Context, userID uuid.UUID, params ListParams) ([]Job, error) {
if userID == uuid.Nil {
return nil, ErrInvalidInput
}
limit := params.Limit
if limit < 1 || limit > 100 {
limit = 20
}
offset := params.Offset
if offset < 0 {
offset = 0
}
status := strings.TrimSpace(strings.ToLower(params.Status))
if status != "" && !isAllowedStatus(status) {
return nil, ErrInvalidInput
}
return s.repo.ListJobs(ctx, userID, ListParams{
Status: status,
Limit: limit,
Offset: offset,
})
}
func (s *Service) Cancel(ctx context.Context, userID uuid.UUID, jobID string) (Job, error) {
if userID == uuid.Nil || strings.TrimSpace(jobID) == "" {
return Job{}, ErrInvalidInput
}
job, err := s.repo.GetJobByID(ctx, userID, jobID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Job{}, ErrNotFound
}
return Job{}, err
}
if isTerminalStatus(job.Status) {
return job, nil
}
updated, err := s.repo.CancelJob(ctx, userID, jobID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Job{}, ErrNotFound
}
return Job{}, err
}
_ = s.repo.AppendEvent(ctx, updated.ID, Event{
Status: StatusCancelled.String(),
Message: "job cancelled by user",
ProgressPercent: updated.ProgressPercent,
Payload: "{}",
})
return updated, nil
}
func (s *Service) Events(ctx context.Context, userID uuid.UUID, jobID string, params EventParams) ([]Event, error) {
if userID == uuid.Nil || strings.TrimSpace(jobID) == "" {
return nil, ErrInvalidInput
}
limit := params.Limit
if limit < 1 || limit > 200 {
limit = 100
}
_, err := s.repo.GetJobByID(ctx, userID, jobID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return s.repo.ListEvents(ctx, userID, jobID, EventParams{
Limit: limit,
After: strings.TrimSpace(params.After),
})
}
func (s *Service) Transition(ctx context.Context, userID uuid.UUID, jobID string, input UpdateInput) (Job, error) {
if userID == uuid.Nil || strings.TrimSpace(jobID) == "" {
return Job{}, ErrInvalidInput
}
nextStatus := strings.TrimSpace(strings.ToLower(input.Status))
if !isAllowedStatus(nextStatus) {
return Job{}, ErrInvalidInput
}
current, err := s.repo.GetJobByID(ctx, userID, jobID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Job{}, ErrNotFound
}
return Job{}, err
}
if !canTransition(current.Status, nextStatus) {
return Job{}, ErrInvalidInput
}
if input.ProgressPercent < current.ProgressPercent && !isTerminalStatus(nextStatus) {
return Job{}, ErrInvalidInput
}
if nextStatus == StatusCompleted.String() {
input.ProgressPercent = 100
}
updated, err := s.repo.UpdateJob(ctx, userID, jobID, input)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Job{}, ErrNotFound
}
return Job{}, err
}
_ = s.repo.AppendEvent(ctx, updated.ID, Event{
Status: updated.Status,
Message: eventMessageForStatus(updated.Status),
ProgressPercent: updated.ProgressPercent,
Payload: "{}",
})
return updated, nil
}
func isAllowedSourceType(value string) bool {
return slices.Contains([]string{"magnet", "torrent", "direct", "http"}, value)
}
func isAllowedStatus(value string) bool {
return slices.Contains([]string{
StatusQueued.String(),
StatusPreparing.String(),
StatusDownloading.String(),
StatusStalled.String(),
StatusRetrying.String(),
StatusCompleted.String(),
StatusFailed.String(),
StatusCancelled.String(),
}, value)
}
func isTerminalStatus(value string) bool {
return slices.Contains([]string{
StatusCompleted.String(),
StatusFailed.String(),
StatusCancelled.String(),
}, strings.TrimSpace(strings.ToLower(value)))
}
func canTransition(from string, to string) bool {
current := strings.TrimSpace(strings.ToLower(from))
next := strings.TrimSpace(strings.ToLower(to))
if current == next {
return true
}
switch current {
case StatusQueued.String():
return next == StatusPreparing.String() || next == StatusCancelled.String()
case StatusPreparing.String():
return next == StatusDownloading.String() || next == StatusCancelled.String()
case StatusDownloading.String():
return next == StatusStalled.String() || next == StatusCompleted.String() || next == StatusFailed.String() || next == StatusCancelled.String()
case StatusStalled.String():
return next == StatusRetrying.String() || next == StatusCancelled.String()
case StatusRetrying.String():
return next == StatusPreparing.String() || next == StatusCancelled.String()
default:
return false
}
}
func eventMessageForStatus(status string) string {
switch strings.TrimSpace(strings.ToLower(status)) {
case StatusPreparing.String():
return "job preparing"
case StatusDownloading.String():
return "download started"
case StatusStalled.String():
return "download stalled"
case StatusRetrying.String():
return "retrying stalled download"
case StatusCompleted.String():
return "download completed"
case StatusFailed.String():
return "download failed"
case StatusCancelled.String():
return "download cancelled"
default:
return "download updated"
}
}
func (s Status) String() string {
return string(s)
}