feat: migrate playback domain to modular architecture

This commit is contained in:
2026-05-13 11:20:46 +02:00
parent ab31cf4c4c
commit 4d1fd2834b
5 changed files with 205 additions and 33 deletions

View File

@@ -10,9 +10,28 @@ type PlaybackService interface {
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error 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 { type PlaybackRepository interface {
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) 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 SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error
UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
} }

View File

@@ -4,13 +4,12 @@ import (
"context" "context"
"net/http" "net/http"
"mal/api/auth" "mal/internal/domain"
ctxpkg "mal/internal/context" ctxpkg "mal/internal/context"
"mal/internal/db"
) )
// Auth middleware validates the session cookie and injects the user into context // 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id") 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 // GetUser retrieves the authenticated user from context, or nil if not authenticated
func GetUser(ctx context.Context) *db.User { func GetUser(ctx context.Context) *domain.User {
user, ok := ctx.Value(ctxpkg.UserKey).(*db.User) user, ok := ctx.Value(ctxpkg.UserKey).(*domain.User)
if !ok { if !ok {
return nil return nil
} }

View File

@@ -1,8 +1,8 @@
package handler package handler
import ( import (
"log"
"mal/internal/domain" "mal/internal/domain"
"mal/internal/server"
"net/http" "net/http"
"strconv" "strconv"
@@ -10,38 +10,70 @@ import (
) )
type PlaybackHandler struct { type PlaybackHandler struct {
svc domain.PlaybackService svc domain.PlaybackService
animeSvc domain.AnimeService
} }
func NewPlaybackHandler(svc domain.PlaybackService) *PlaybackHandler { func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler {
return &PlaybackHandler{svc: svc} return &PlaybackHandler{svc: svc, animeSvc: animeSvc}
} }
func (h *PlaybackHandler) Register(r *gin.Engine) { func (h *PlaybackHandler) Register(r *gin.Engine) {
log.Println("Registering playback routes")
r.GET("/watch/:id", h.HandleWatchPage) r.GET("/watch/:id", h.HandleWatchPage)
r.POST("/api/watch-progress", h.HandleSaveProgress) r.POST("/api/watch-progress", h.HandleSaveProgress)
} }
func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) { 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")) id, _ := strconv.Atoi(c.Param("id"))
ep := c.DefaultQuery("ep", "1") ep := c.DefaultQuery("ep", "1")
mode := c.DefaultQuery("mode", "sub") mode := c.DefaultQuery("mode", "sub")
userID := "" // TODO: get from auth context user, _ := c.Get("User")
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) userID := ""
if err != nil { if u, ok := user.(*domain.User); ok {
c.Status(http.StatusNotFound) userID = u.ID
return
} }
c.HTML(http.StatusOK, "watch.gohtml", gin.H{ data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID)
"WatchData": data, 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, "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) { 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 { var req struct {
MalID int64 `json:"mal_id"` MalID int64 `json:"mal_id"`
Episode int `json:"episode"` Episode int `json:"episode"`

View File

@@ -1,6 +1,8 @@
package playback package playback
import ( import (
"mal/integrations/jikan"
"mal/integrations/playback/allanime"
"mal/internal/domain" "mal/internal/domain"
"mal/internal/playback/handler" "mal/internal/playback/handler"
"mal/internal/playback/repository" "mal/internal/playback/repository"
@@ -13,12 +15,19 @@ import (
var Module = fx.Options( var Module = fx.Options(
fx.Provide( fx.Provide(
repository.NewPlaybackRepository, repository.NewPlaybackRepository,
service.NewPlaybackService, func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client) domain.PlaybackService {
handler.NewPlaybackHandler, return service.NewPlaybackService(repo, providers, jikan)
},
func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler {
return handler.NewPlaybackHandler(svc, animeSvc)
},
), ),
fx.Provide( fx.Provide(
server.AsRouteRegister(func(h *handler.PlaybackHandler) server.RouteRegister { server.AsRouteRegister(func(h *handler.PlaybackHandler) server.RouteRegister {
return h return h
}), }),
), ),
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.Provider {
return []domain.Provider{p}
}),
) )

View File

@@ -2,29 +2,40 @@ package service
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"log"
"mal/integrations/jikan"
"mal/internal/db" "mal/internal/db"
"mal/internal/domain" "mal/internal/domain"
"sort"
"strconv" "strconv"
"strings"
) )
type playbackService struct { type playbackService struct {
repo domain.PlaybackRepository repo domain.PlaybackRepository
providers []domain.Provider providers []domain.Provider
jikan *jikan.Client
} }
func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider) domain.PlaybackService { func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client) domain.PlaybackService {
return &playbackService{repo: repo, providers: providers} 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) { 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 // 1. Get Anime details for total episodes and titles
var result *domain.StreamResult anime, err := s.jikan.GetAnimeByID(ctx, animeID)
var err error 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 { for _, p := range s.providers {
result, err = p.GetStreams(ctx, animeID, episode, mode) res, err := p.GetStreams(ctx, animeID, episode, mode)
if err == nil && result != nil { if err == nil && res != nil {
result = res
break break
} }
} }
@@ -33,25 +44,127 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
return nil, fmt.Errorf("no streams found") return nil, fmt.Errorf("no streams found")
} }
// 3. Get start time from progress
startTime := 0.0 startTime := 0.0
var watchlistStatus string
if userID != "" { if userID != "" {
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID, UserID: userID,
AnimeID: int64(animeID), AnimeID: int64(animeID),
}) })
if err == nil { if err == nil {
watchlistStatus = entry.Status
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode { if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode {
startTime = entry.CurrentTimeSeconds 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{ return map[string]any{
"URL": result.URL, "WatchData": watchData,
"Referer": result.Referer, "Anime": anime,
"StartTime": startTime, "Episodes": domainEpisodes,
"Subtitles": result.Subtitles, "CurrentEpID": episode,
"Qualities": result.Qualities, "WatchlistStatus": watchlistStatus,
"Seasons": seasons,
}, nil }, nil
} }