From 0d6c7613a974b48f0e6b0dd14a3fd083678ae52e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:34:01 +0200 Subject: [PATCH] feat: migrate playback module to modular domain pattern --- internal/app/app.go | 2 + internal/domain/playback.go | 18 ++++++ internal/domain/provider.go | 27 +++++++++ internal/playback/handler/handler.go | 63 +++++++++++++++++++++ internal/playback/module.go | 24 ++++++++ internal/playback/repository/repository.go | 31 ++++++++++ internal/playback/service/service.go | 66 ++++++++++++++++++++++ 7 files changed, 231 insertions(+) create mode 100644 internal/domain/playback.go create mode 100644 internal/domain/provider.go create mode 100644 internal/playback/handler/handler.go create mode 100644 internal/playback/module.go create mode 100644 internal/playback/repository/repository.go create mode 100644 internal/playback/service/service.go diff --git a/internal/app/app.go b/internal/app/app.go index d59ee9d..4672d5b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ import ( "mal/internal/auth" "mal/internal/anime" "mal/internal/watchlist" + "mal/internal/playback" "mal/internal/server" "mal/internal/templates" @@ -20,6 +21,7 @@ func NewApp() *fx.App { auth.Module, anime.Module, watchlist.Module, + playback.Module, templates.Module, server.Module, fx.Decorate(func(r *templates.Renderer) render.HTMLRender { diff --git a/internal/domain/playback.go b/internal/domain/playback.go new file mode 100644 index 0000000..db47947 --- /dev/null +++ b/internal/domain/playback.go @@ -0,0 +1,18 @@ +package domain + +import ( + "context" + "mal/internal/db" +) + +type PlaybackService interface { + BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) + SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error +} + +type PlaybackRepository interface { + GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) + GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.GetContinueWatchingEntryRow, error) + SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error + UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) +} diff --git a/internal/domain/provider.go b/internal/domain/provider.go new file mode 100644 index 0000000..c29b19c --- /dev/null +++ b/internal/domain/provider.go @@ -0,0 +1,27 @@ +package domain + +import ( + "context" +) + +type StreamSource struct { + URL string + Quality string +} + +type StreamResult struct { + URL string + Referer string + Subtitles []Subtitle + Qualities []StreamSource +} + +type Subtitle struct { + URL string + Label string +} + +type Provider interface { + Name() string + GetStreams(ctx context.Context, animeID int, episode string, mode string) (*StreamResult, error) +} diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go new file mode 100644 index 0000000..d483dfa --- /dev/null +++ b/internal/playback/handler/handler.go @@ -0,0 +1,63 @@ +package handler + +import ( + "mal/internal/domain" + "mal/internal/server" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type PlaybackHandler struct { + svc domain.PlaybackService +} + +func NewPlaybackHandler(svc domain.PlaybackService) *PlaybackHandler { + return &PlaybackHandler{svc: svc} +} + +func (h *PlaybackHandler) Register(r *gin.Engine) { + r.GET("/watch/:id", h.HandleWatchPage) + r.POST("/api/watch-progress", h.HandleSaveProgress) +} + +func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) { + 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 + } + + c.HTML(http.StatusOK, "watch.gohtml", gin.H{ + "WatchData": data, + "CurrentPath": c.Request.URL.Path, + }) +} + +func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { + userID := "" // TODO: get from auth context + var req struct { + MalID int64 `json:"mal_id"` + Episode int `json:"episode"` + TimeSeconds float64 `json:"time_seconds"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.Status(http.StatusBadRequest) + return + } + + err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} diff --git a/internal/playback/module.go b/internal/playback/module.go new file mode 100644 index 0000000..00efb69 --- /dev/null +++ b/internal/playback/module.go @@ -0,0 +1,24 @@ +package playback + +import ( + "mal/internal/domain" + "mal/internal/playback/handler" + "mal/internal/playback/repository" + "mal/internal/playback/service" + "mal/internal/server" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + repository.NewPlaybackRepository, + service.NewPlaybackService, + handler.NewPlaybackHandler, + ), + fx.Provide( + server.AsRouteRegister(func(h *handler.PlaybackHandler) server.RouteRegister { + return h + }), + ), +) diff --git a/internal/playback/repository/repository.go b/internal/playback/repository/repository.go new file mode 100644 index 0000000..80c2798 --- /dev/null +++ b/internal/playback/repository/repository.go @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + "mal/internal/db" + "mal/internal/domain" +) + +type playbackRepository struct { + queries *db.Queries +} + +func NewPlaybackRepository(queries *db.Queries) domain.PlaybackRepository { + return &playbackRepository{queries: queries} +} + +func (r *playbackRepository) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) { + return r.queries.GetWatchListEntry(ctx, params) +} + +func (r *playbackRepository) GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.GetContinueWatchingEntryRow, error) { + return r.queries.GetContinueWatchingEntry(ctx, params) +} + +func (r *playbackRepository) SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error { + return r.queries.SaveWatchProgress(ctx, params) +} + +func (r *playbackRepository) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) { + return r.queries.UpsertContinueWatchingEntry(ctx, params) +} diff --git a/internal/playback/service/service.go b/internal/playback/service/service.go new file mode 100644 index 0000000..283ba91 --- /dev/null +++ b/internal/playback/service/service.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + "fmt" + "mal/internal/db" + "mal/internal/domain" + "strconv" +) + +type playbackService struct { + repo domain.PlaybackRepository + providers []domain.Provider +} + +func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider) domain.PlaybackService { + return &playbackService{repo: repo, providers: providers} +} + +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 + + for _, p := range s.providers { + result, err = p.GetStreams(ctx, animeID, episode, mode) + if err == nil && result != nil { + break + } + } + + if result == nil { + return nil, fmt.Errorf("no streams found") + } + + startTime := 0.0 + if userID != "" { + entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ + UserID: userID, + AnimeID: int64(animeID), + }) + if err == nil { + if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode { + startTime = entry.CurrentTimeSeconds + } + } + } + + return map[string]any{ + "URL": result.URL, + "Referer": result.Referer, + "StartTime": startTime, + "Subtitles": result.Subtitles, + "Qualities": result.Qualities, + }, nil +} + +func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error { + params := db.SaveWatchProgressParams{ + UserID: userID, + AnimeID: animeID, + CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true}, + CurrentTimeSeconds: timeSeconds, + } + return s.repo.SaveWatchProgress(ctx, params) +}