From 4d1fd2834b0ec0551af5fd947c5d7a8f13301f53 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 11:20:46 +0200 Subject: [PATCH] feat: migrate playback domain to modular architecture --- internal/domain/playback.go | 21 +++- internal/middleware/auth.go | 9 +- internal/playback/handler/handler.go | 58 +++++++++--- internal/playback/module.go | 13 ++- internal/playback/service/service.go | 137 ++++++++++++++++++++++++--- 5 files changed, 205 insertions(+), 33 deletions(-) diff --git a/internal/domain/playback.go b/internal/domain/playback.go index db47947..567e73b 100644 --- a/internal/domain/playback.go +++ b/internal/domain/playback.go @@ -10,9 +10,28 @@ type PlaybackService interface { SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error } +type ProviderStream struct { + Name string `json:"name"` + URL string `json:"url"` + Quality string `json:"quality"` + MalID int `json:"mal_id"` + IsCurrent bool `json:"is_current"` +} + +type ProviderData struct { + Streams []ProviderStream `json:"streams"` +} + +type EpisodeData struct { + MalID int `json:"mal_id"` + Title string `json:"title"` + IsFiller bool `json:"is_filler"` + IsRecap bool `json:"is_recap"` +} + type PlaybackRepository interface { GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) - GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.GetContinueWatchingEntryRow, error) + GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a31b16e..8a349b7 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,13 +4,12 @@ import ( "context" "net/http" - "mal/api/auth" + "mal/internal/domain" ctxpkg "mal/internal/context" - "mal/internal/db" ) // Auth middleware validates the session cookie and injects the user into context -func Auth(authService *auth.Service) func(http.Handler) http.Handler { +func Auth(authService domain.AuthService) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_id") @@ -32,8 +31,8 @@ func Auth(authService *auth.Service) func(http.Handler) http.Handler { } // GetUser retrieves the authenticated user from context, or nil if not authenticated -func GetUser(ctx context.Context) *db.User { - user, ok := ctx.Value(ctxpkg.UserKey).(*db.User) +func GetUser(ctx context.Context) *domain.User { + user, ok := ctx.Value(ctxpkg.UserKey).(*domain.User) if !ok { return nil } diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index d483dfa..9fb7a40 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -1,8 +1,8 @@ package handler import ( + "log" "mal/internal/domain" - "mal/internal/server" "net/http" "strconv" @@ -10,38 +10,70 @@ import ( ) type PlaybackHandler struct { - svc domain.PlaybackService + svc domain.PlaybackService + animeSvc domain.AnimeService } -func NewPlaybackHandler(svc domain.PlaybackService) *PlaybackHandler { - return &PlaybackHandler{svc: svc} +func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler { + return &PlaybackHandler{svc: svc, animeSvc: animeSvc} } func (h *PlaybackHandler) Register(r *gin.Engine) { + log.Println("Registering playback routes") r.GET("/watch/:id", h.HandleWatchPage) r.POST("/api/watch-progress", h.HandleSaveProgress) } func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) { + log.Printf("Route /watch/:id triggered for ID: %s", c.Param("id")) id, _ := strconv.Atoi(c.Param("id")) ep := c.DefaultQuery("ep", "1") mode := c.DefaultQuery("mode", "sub") - userID := "" // TODO: get from auth context - data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) - if err != nil { - c.Status(http.StatusNotFound) - return + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID } - c.HTML(http.StatusOK, "watch.gohtml", gin.H{ - "WatchData": data, + data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) + if err != nil { + log.Printf("BuildWatchData failed for ID %d: %v", id, err) + // Try to at least get anime info for the error page + anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id) + c.HTML(http.StatusOK, "watch.gohtml", gin.H{ + "Error": err.Error(), + "Anime": anime, + "Episodes": []domain.EpisodeData{}, + "CurrentPath": c.Request.URL.Path, + "User": user, + "CurrentEpID": ep, + "WatchData": map[string]any{"Episodes": []domain.EpisodeData{}, "Providers": []any{}}, + }) + return + } + log.Printf("BuildWatchData succeeded for ID %d", id) + + // Merge data from service with handler-specific context + responseData := gin.H{ + "User": user, "CurrentPath": c.Request.URL.Path, - }) + } + for k, v := range data { + responseData[k] = v + } + + c.HTML(http.StatusOK, "watch.gohtml", responseData) + log.Printf("c.HTML finished for ID %d", id) } func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { - userID := "" // TODO: get from auth context + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + var req struct { MalID int64 `json:"mal_id"` Episode int `json:"episode"` diff --git a/internal/playback/module.go b/internal/playback/module.go index 00efb69..5977504 100644 --- a/internal/playback/module.go +++ b/internal/playback/module.go @@ -1,6 +1,8 @@ package playback import ( + "mal/integrations/jikan" + "mal/integrations/playback/allanime" "mal/internal/domain" "mal/internal/playback/handler" "mal/internal/playback/repository" @@ -13,12 +15,19 @@ import ( var Module = fx.Options( fx.Provide( repository.NewPlaybackRepository, - service.NewPlaybackService, - handler.NewPlaybackHandler, + func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client) domain.PlaybackService { + return service.NewPlaybackService(repo, providers, jikan) + }, + func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler { + return handler.NewPlaybackHandler(svc, animeSvc) + }, ), fx.Provide( server.AsRouteRegister(func(h *handler.PlaybackHandler) server.RouteRegister { return h }), ), + fx.Provide(func(p *allanime.AllAnimeProvider) []domain.Provider { + return []domain.Provider{p} + }), ) diff --git a/internal/playback/service/service.go b/internal/playback/service/service.go index 283ba91..dec3d06 100644 --- a/internal/playback/service/service.go +++ b/internal/playback/service/service.go @@ -2,29 +2,40 @@ package service import ( "context" + "database/sql" "fmt" + "log" + "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" + "sort" "strconv" + "strings" ) type playbackService struct { repo domain.PlaybackRepository providers []domain.Provider + jikan *jikan.Client } -func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider) domain.PlaybackService { - return &playbackService{repo: repo, providers: providers} +func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client) domain.PlaybackService { + return &playbackService{repo: repo, providers: providers, jikan: jikan} } func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) { - // Minimal implementation for now to show the pattern - var result *domain.StreamResult - var err error + // 1. Get Anime details for total episodes and titles + anime, err := s.jikan.GetAnimeByID(ctx, animeID) + if err != nil { + return nil, fmt.Errorf("failed to fetch anime: %w", err) + } + // 2. Resolve streams from providers + var result *domain.StreamResult for _, p := range s.providers { - result, err = p.GetStreams(ctx, animeID, episode, mode) - if err == nil && result != nil { + res, err := p.GetStreams(ctx, animeID, episode, mode) + if err == nil && res != nil { + result = res break } } @@ -33,25 +44,127 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title return nil, fmt.Errorf("no streams found") } + // 3. Get start time from progress startTime := 0.0 + var watchlistStatus string if userID != "" { entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ UserID: userID, AnimeID: int64(animeID), }) if err == nil { + watchlistStatus = entry.Status if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode { startTime = entry.CurrentTimeSeconds } } } + // 4. Get Episodes list + jikanEpisodes, err := s.jikan.GetAllEpisodes(ctx, animeID) + if err != nil { + log.Printf("failed to fetch episodes from jikan: %v", err) + } + + // Fallback/Fill episodes if needed + totalCount := anime.Episodes + if len(jikanEpisodes) < totalCount { + epMap := make(map[int]jikan.Episode) + for _, ep := range jikanEpisodes { + epMap[ep.MalID] = ep + } + for i := 1; i <= totalCount; i++ { + if _, ok := epMap[i]; !ok { + jikanEpisodes = append(jikanEpisodes, jikan.Episode{ + MalID: i, + Episode: fmt.Sprintf("Episode %d", i), + Title: fmt.Sprintf("Episode %d", i), + }) + } + } + } + sort.Slice(jikanEpisodes, func(i, j int) bool { + return jikanEpisodes[i].MalID < jikanEpisodes[j].MalID + }) + + domainEpisodes := make([]domain.EpisodeData, len(jikanEpisodes)) + for i, ep := range jikanEpisodes { + domainEpisodes[i] = domain.EpisodeData{ + MalID: ep.MalID, + Title: ep.Title, + IsFiller: ep.Filler, + IsRecap: ep.Recap, + } + } + + // 5. Build provider data + // AllAnime currently returns one stream in result.URL + // We wrap it for the template + streams := []domain.ProviderStream{ + { + Name: "Primary", + URL: result.URL, + Quality: "Auto", + MalID: animeID, + IsCurrent: true, + }, + } + + modeSources := map[string]any{ + mode: map[string]any{ + "url": result.URL, + "referer": result.Referer, + "subtitles": result.Subtitles, + }, + } + + // 6. Resolve relations/seasons + relations, _ := s.jikan.GetFullRelations(ctx, animeID) + type SeasonEntry struct { + MalID int `json:"mal_id"` + Title string `json:"title"` + Prefix string `json:"prefix"` + IsCurrent bool `json:"is_current"` + } + var seasons []SeasonEntry + tvCounter := 1 + for _, rel := range relations { + if strings.ToLower(rel.Anime.Type) == "tv" || strings.ToLower(rel.Anime.Type) == "movie" { + seasons = append(seasons, SeasonEntry{ + MalID: rel.Anime.MalID, + Title: rel.Anime.DisplayTitle(), + Prefix: rel.Relation, + IsCurrent: rel.IsCurrent, + }) + if rel.Relation == "TV" { + seasons[len(seasons)-1].Prefix = fmt.Sprintf("S%d", tvCounter) + tvCounter++ + } + } + } + + // Final assembly + watchData := map[string]any{ + "MalID": animeID, + "Title": anime.DisplayTitle(), + "CurrentEpisode": episode, + "StartTimeSeconds": startTime, + "Episodes": domainEpisodes, + "Providers": []domain.ProviderData{ + {Streams: streams}, + }, + "ModeSources": modeSources, + "InitialMode": mode, + "AvailableModes": []string{"sub", "dub"}, + } + return map[string]any{ - "URL": result.URL, - "Referer": result.Referer, - "StartTime": startTime, - "Subtitles": result.Subtitles, - "Qualities": result.Qualities, + "WatchData": watchData, + "Anime": anime, + "Episodes": domainEpisodes, + "CurrentEpID": episode, + "WatchlistStatus": watchlistStatus, + "Seasons": seasons, }, nil }