feat: add comments and cleanup unused imports across codebase

This commit is contained in:
2026-05-10 20:00:04 +02:00
parent b152e246ff
commit e48d95cb4e
68 changed files with 560 additions and 88 deletions

View File

@@ -28,10 +28,10 @@ func NewHandler(service *Service) *Handler {
}
type quickSearchResult struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Image string `json:"image"`
ID int `json:"id"` // anime mal id
Title string `json:"title"` // display title
Type string `json:"type"` // anime type (tv, movie, etc)
Image string `json:"image"` // cover image url
}
func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
@@ -63,6 +63,7 @@ func (h *Handler) HandleCatalogContinue(w http.ResponseWriter, r *http.Request)
h.renderCatalogSection(w, r, "Continue")
}
// renderCatalogSection fetches catalog data (airing/popular/continue) and renders as htmx fragment
func (h *Handler) renderCatalogSection(w http.ResponseWriter, r *http.Request, section string) {
user := middleware.GetUser(r.Context())
userID := ""
@@ -82,6 +83,7 @@ func (h *Handler) renderCatalogSection(w http.ResponseWriter, r *http.Request, s
data["User"] = user
data["Section"] = section
// render section as htmx partial, not full page
if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "index.gohtml", "catalog_section", data); err != nil {
log.Printf("fragment render error: %v", err)
}
@@ -133,15 +135,17 @@ func (h *Handler) renderDiscoverSection(w http.ResponseWriter, r *http.Request,
}
}
// HandleBrowse handles anime search/browse with filters. supports htmx partial loading.
func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
// parse query params for search/filter
q := r.URL.Query().Get("q")
animeType := r.URL.Query().Get("type")
status := r.URL.Query().Get("status")
orderBy := r.URL.Query().Get("order_by")
sort := r.URL.Query().Get("sort")
sfw := r.URL.Query().Get("sfw") != "false"
sfw := r.URL.Query().Get("sfw") != "false" // default to safe
var genres []int
for _, g := range r.URL.Query()["genres"] {
@@ -165,6 +169,7 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
}
if r.Header.Get("HX-Request") == "true" {
// htmx: return just the card scroll fragment with watchlist state
watchlistMap := make(map[int]bool)
if user != nil {
watchlist, _ := h.service.db.GetUserWatchList(ctx, user.ID)
@@ -195,6 +200,7 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
return
}
// full page load: fetch genres list and full watchlist
genresList, err := h.service.jikanClient.GetAnimeGenres(ctx)
if err != nil {
if !errors.Is(err, context.Canceled) {
@@ -237,6 +243,7 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
}
}
// HandleAnimeDetails renders anime detail page. handles htmx requests for characters/recommendations sections.
func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/anime/")
idStr = strings.TrimSuffix(idStr, "/")
@@ -248,7 +255,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
// If it's an HTMX request for a specific section, handle it
// htmx: return just the section (characters or recommendations)
section := r.URL.Query().Get("section")
if section != "" && r.Header.Get("HX-Request") == "true" {
h.renderAnimeDetailsSection(w, r, id, section)
@@ -264,10 +271,12 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
g, gCtx := errgroup.WithContext(r.Context())
// fetch anime details + episode count if airing
g.Go(func() error {
var err error
anime, err = h.service.jikanClient.GetAnimeByID(gCtx, id)
if err == nil && anime.Airing {
// get episode count for airing anime (may span multiple pages)
eps, err := h.service.jikanClient.GetEpisodes(gCtx, id, 1)
if err == nil {
if eps.Pagination.LastVisiblePage > 1 {
@@ -288,6 +297,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
})
if user != nil {
// fetch user's watchlist status for this anime
g.Go(func() error {
entry, err := h.service.db.GetWatchListEntry(gCtx, db.GetWatchListEntryParams{
UserID: user.ID,
@@ -298,6 +308,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
}
return nil
})
// fetch all watchlist ids for nav state
g.Go(func() error {
watchlist, err := h.service.db.GetUserWatchList(gCtx, user.ID)
if err == nil {
@@ -329,6 +340,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
}
}
// renderAnimeDetailsSection fetches and renders htmx partial for character/recommendation sections
func (h *Handler) renderAnimeDetailsSection(w http.ResponseWriter, r *http.Request, id int, section string) {
ctx := r.Context()
var data any
@@ -355,6 +367,7 @@ func (h *Handler) renderAnimeDetailsSection(w http.ResponseWriter, r *http.Reque
tplName = "anime_recommendations"
}
// render htmx partial for the section
if err := templates.GetRenderer().ExecuteFragment(ctx, w, "anime.gohtml", tplName, data); err != nil {
log.Printf("fragment render error: %v", err)
}

View File

@@ -17,6 +17,7 @@ func NewService(jikanClient *jikan.Client, db db.Querier) *Service {
return &Service{jikanClient: jikanClient, db: db}
}
// GetCatalogSection fetches homepage catalog sections (Airing, Popular, Continue) from jikan and db.
func (s *Service) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) {
var (
res jikan.TopAnimeResult
@@ -27,6 +28,7 @@ func (s *Service) GetCatalogSection(ctx context.Context, userID string, section
g, gCtx := errgroup.WithContext(ctx)
// fetch jikan data (season now or top anime)
g.Go(func() error {
switch section {
case "Airing":
@@ -37,6 +39,7 @@ func (s *Service) GetCatalogSection(ctx context.Context, userID string, section
return err
})
// fetch user-specific data if logged in
if userID != "" {
g.Go(func() error {
if section == "Continue" {
@@ -57,6 +60,7 @@ func (s *Service) GetCatalogSection(ctx context.Context, userID string, section
return nil, err
}
// limit to 6 items for homepage grid
animes := res.Animes
if len(animes) > 6 {
animes = animes[:6]
@@ -74,6 +78,7 @@ func (s *Service) GetCatalogSection(ctx context.Context, userID string, section
}, nil
}
// GetDiscoverSection fetches discover page sections (Trending, Upcoming, Top) from jikan.
func (s *Service) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) {
var (
res jikan.TopAnimeResult
@@ -107,6 +112,7 @@ func (s *Service) GetDiscoverSection(ctx context.Context, userID string, section
return nil, err
}
// limit to 8 items for discover grid
animes := res.Animes
if len(animes) > 8 {
animes = animes[:8]
@@ -123,6 +129,7 @@ func (s *Service) GetDiscoverSection(ctx context.Context, userID string, section
}, nil
}
// filterUnique deduplicates anime list by mal id, respecting limit.
func (s *Service) filterUnique(animes []jikan.Anime, seen map[int]bool, limit int) []jikan.Anime {
unique := make([]jikan.Anime, 0)
for _, a := range animes {

View File

@@ -31,6 +31,7 @@ func NewService(db db.Querier) *Service {
return &Service{db: db}
}
// generateToken creates a cryptographically random base64-encoded token
func generateToken(size int) (string, error) {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
@@ -39,6 +40,7 @@ func generateToken(size int) (string, error) {
return base64.URLEncoding.EncodeToString(b), nil
}
// generateSessionToken creates a 32-byte session token
func generateSessionToken() (string, error) {
return generateToken(32)
}
@@ -84,7 +86,7 @@ func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*db.Us
}
if time.Now().After(session.ExpiresAt) {
_ = s.db.DeleteSession(ctx, sessionID)
_ = s.db.DeleteSession(ctx, sessionID) // clean up expired session
return nil, ErrNotAuthenticated
}
@@ -96,6 +98,7 @@ func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*db.Us
return &user, nil
}
// SetSessionCookie sets an http-only, secure session cookie
func SetSessionCookie(w http.ResponseWriter, sessionID string, expiresAt time.Time) {
secure := os.Getenv("ENV") == "production" || os.Getenv("FORCE_SECURE_COOKIES") == "true"
http.SetCookie(w, &http.Cookie{
@@ -113,11 +116,12 @@ func (s *Service) Logout(ctx context.Context, sessionID string) error {
return s.db.DeleteSession(ctx, sessionID)
}
// ClearSessionCookie invalidates the session cookie
func ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "",
Expires: time.Unix(0, 0),
Expires: time.Unix(0, 0), // epoch to expire immediately
MaxAge: -1,
HttpOnly: true,
Path: "/",

View File

@@ -17,6 +17,7 @@ func NewHandler(authService *Service) *Handler {
return &Handler{authService: authService}
}
// rateLimitErrorFromQuery checks for rate limit errors in the query string
func rateLimitErrorFromQuery(r *http.Request) string {
if r.URL.Query().Get("error") == "rate_limited" {
return rateLimitFormError
@@ -24,6 +25,7 @@ func rateLimitErrorFromQuery(r *http.Request) string {
return ""
}
// HandleLoginPage renders the login form
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
@@ -32,6 +34,7 @@ func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
}
}
// HandleLogin validates credentials and creates a session on success
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
@@ -69,6 +72,7 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleLogout destroys the session and clears the cookie
func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err == nil {

View File

@@ -215,6 +215,7 @@ func (c *allAnimeClient) graphqlRequestWithHash(ctx context.Context, showID, epi
return nil, fmt.Errorf("no usable data in response")
}
// GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub).
func (c *allAnimeClient) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
@@ -387,6 +388,7 @@ type sourceReference struct {
Name string
}
// buildSourceReferences orders source URLs by provider priority, deduplicating entries.
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
@@ -416,6 +418,7 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
ref := sourceReference{URL: sourceURL, Name: sourceName}
normalized := strings.ToLower(sourceName)
// separate prioritized providers from fallback
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
if _, exists := prioritized[normalized]; !exists {
prioritized[normalized] = ref
@@ -426,6 +429,7 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
fallback = append(fallback, ref)
}
// output: prioritized in order, then fallback
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
for _, provider := range priorityOrder {
if ref, ok := prioritized[provider]; ok {
@@ -489,6 +493,7 @@ func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) ([]byte, er
return plainText, nil
}
// Search queries AllAnime for shows matching the given search term.
func (c *allAnimeClient) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) {
shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) {
@@ -557,6 +562,7 @@ func (c *allAnimeClient) Search(ctx context.Context, query string, mode string)
return out, nil
}
// GetEpisodes returns the list of available episode strings for a show and mode.
func (c *allAnimeClient) GetEpisodes(ctx context.Context, showID string, mode string) ([]string, error) {
graphqlQuery := `query($showId: String!) {
show(_id: $showId) {
@@ -607,6 +613,7 @@ func (c *allAnimeClient) GetEpisodes(ctx context.Context, showID string, mode st
return episodes, nil
}
// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show.
func (c *allAnimeClient) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
graphqlQuery := `query($showId: String!) {
show(_id: $showId) {

View File

@@ -20,13 +20,14 @@ import (
type Handler struct {
svc *Service
jikanClient *jikan.Client
jikanClient *jikan.Client // client for Jikan API (MyAnimeList)
}
func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
return &Handler{svc: svc, jikanClient: jikanClient}
}
// renderNotFoundPage renders the 404 page.
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{
@@ -36,8 +37,9 @@ func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
}
}
// HandleWatchPage serves the anime watch page.
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
// Path is like /anime/123/watch
// path format: /anime/123/watch
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
renderNotFoundPage(r, w)
@@ -63,6 +65,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
// fetch user's watchlist to highlight episodes and show status
var watchlistIDs []int64
var watchlistStatus string
if user != nil {
@@ -76,7 +79,8 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
}
}
currentEpID := r.URL.Query().Get("ep")
// resolve current episode: query param > saved progress > first episode
currentEpID := r.URL.Query().Get("ep")
if currentEpID == "" {
if user != nil {
entry, err := h.svc.db.GetWatchListEntry(r.Context(), db.GetWatchListEntryParams{
@@ -85,7 +89,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
})
if err == nil && entry.CurrentEpisode.Valid {
currentEpID = strconv.FormatInt(entry.CurrentEpisode.Int64, 10)
// Redirect to the correct episode URL to keep state consistent
// redirect to include ep param for consistent URLs
http.Redirect(w, r, fmt.Sprintf("/anime/%d/watch?ep=%s", id, currentEpID), http.StatusFound)
return
}
@@ -147,7 +151,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
return allEpisodes[i].MalID < allEpisodes[j].MalID
})
// Fetch seasons/relations
// fetch relations to build season/movie list
relations, err := h.jikanClient.GetFullRelations(r.Context(), id)
if err != nil {
log.Printf("failed to fetch relations: %v", err)
@@ -204,6 +208,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
}
}
// HandleProxy proxies media requests through the backend to avoid CORS and hide source URLs.
func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
@@ -211,6 +216,7 @@ func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
return
}
// determine proxy scope based on URL suffix
scope := proxyScopeStream
if strings.HasSuffix(r.URL.Path, "/segment") {
scope = proxyScopeSegment
@@ -244,6 +250,7 @@ func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
}
}
// HandleSaveProgress saves playback progress for a user.
func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -291,6 +298,7 @@ func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// HandleCompleteAnime marks an anime as completed for a user.
func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -337,9 +345,10 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// HandleEpisodeData returns episode streaming data for the player.
func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) {
// path: /api/watch/episode/{animeId}/{episodeId}
parts := strings.Split(r.URL.Path, "/")
// /api/watch/episode/{animeId}/{episodeId}
if len(parts) < 6 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
@@ -394,9 +403,10 @@ func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) {
})
}
// HandleEpisodeThumbnails returns episode list for the thumbnail strip.
func (h *Handler) HandleEpisodeThumbnails(w http.ResponseWriter, r *http.Request) {
// path: /api/watch/thumbnails/{animeId}
parts := strings.Split(r.URL.Path, "/")
// /api/watch/thumbnails/{animeId}
if len(parts) < 5 {
http.Error(w, "invalid path", http.StatusBadRequest)
return

View File

@@ -5,6 +5,7 @@ import (
"net/http"
)
// doProxiedRequest performs an HTTP GET with standard playback headers.
func doProxiedRequest(ctx context.Context, client *http.Client, url string, referer string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {

View File

@@ -12,6 +12,7 @@ import (
"mal/internal/db"
)
// SaveProgress updates watch progress and continue-watching state in a transaction.
func (s *Service) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64, animeSeed *db.UpsertAnimeParams) error {
if strings.TrimSpace(userID) == "" || animeID <= 0 || episode <= 0 {
return errors.New("invalid save progress input")
@@ -77,6 +78,7 @@ func (s *Service) SaveProgress(ctx context.Context, userID string, animeID int64
return nil
}
// CompleteAnime marks an anime as completed in the watchlist and clears continue-watching.
func (s *Service) CompleteAnime(ctx context.Context, userID string, animeID int64, episode int, animeSeed *db.UpsertAnimeParams) error {
if strings.TrimSpace(userID) == "" || animeID <= 0 || episode <= 0 {
return errors.New("invalid complete anime input")

View File

@@ -25,6 +25,7 @@ func newProviderExtractor() *providerExtractor {
}
}
// ExtractVideoLinks fetches provider page and returns stream sources.
func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath string) ([]StreamSource, error) {
endpoint := e.baseURL + providerPath
@@ -52,7 +53,7 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) // 2MB limit
if err != nil {
return nil, fmt.Errorf("read provider response: %w", err)
}
@@ -60,10 +61,12 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
return e.parseProviderResponse(ctx, string(body))
}
// parseProviderResponse extracts stream sources from provider JSON response.
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) ([]StreamSource, error) {
sources := make([]StreamSource, 0)
providerReferer := e.referer
// extract per-source referer if present
refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`)
if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 {
providerReferer = strings.ReplaceAll(match[1], `\/`, "/")
@@ -72,6 +75,7 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
providerReferer = e.referer
}
// extract direct link sources (mp4/embed)
linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`)
for _, match := range linkPattern.FindAllStringSubmatch(response, -1) {
if len(match) < 3 {
@@ -94,6 +98,7 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
})
}
// extract HLS playlist sources
hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`)
for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) {
if len(match) < 2 {
@@ -118,6 +123,7 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
})
}
// extract subtitles and attach to all sources
subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`)
if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 {
subtitles := make([]Subtitle, 0)
@@ -143,6 +149,7 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
return sources, nil
}
// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality.
func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, referer string) ([]StreamSource, error) {
resp, err := doProxiedRequest(ctx, e.httpClient, masterURL, referer)
if err != nil {
@@ -150,7 +157,7 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) // 512KB limit
if err != nil {
return nil, err
}
@@ -178,6 +185,7 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
continue
}
// skip empty lines and non-stream lines
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}

View File

@@ -63,6 +63,7 @@ func (s *proxyTokenSigner) Sign(payload proxyTokenPayload) (string, error) {
mac.Write(body)
signature := mac.Sum(nil)
// format: payload.signature (both base64url encoded)
encodedBody := base64.RawURLEncoding.EncodeToString(body)
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
return encodedBody + "." + encodedSignature, nil
@@ -87,7 +88,7 @@ func (s *proxyTokenSigner) Verify(token string) (proxyTokenPayload, error) {
mac := hmac.New(sha256.New, s.secret)
mac.Write(body)
expected := mac.Sum(nil)
if !hmac.Equal(signature, expected) {
if !hmac.Equal(signature, expected) { // constant-time comparison
return proxyTokenPayload{}, errors.New("invalid proxy token signature")
}
@@ -107,6 +108,7 @@ func (s *Service) buildClientModeSources(modeSources map[string]ModeSource) (map
clientModeSources := make(map[string]ModeSource, len(modeSources))
for mode, source := range modeSources {
// wrap stream url with proxy token
streamToken, err := s.issueProxyToken(source.URL, source.Referer, proxyScopeStream)
if err != nil {
return nil, err
@@ -162,6 +164,7 @@ func (s *Service) issueProxyToken(targetURL string, referer string, scope proxyS
})
}
// proxyTokenTTLs defines ttl per scope type.
var proxyTokenTTLs = map[proxyScope]time.Duration{
proxyScopeStream: proxyStreamTokenTTL,
proxyScopeSegment: proxySegmentTokenTTL,
@@ -194,6 +197,7 @@ func (s *Service) resolveProxyToken(ctx context.Context, token string, scope pro
return "", "", err
}
// resolve referer only if it passes public target check
normalizedReferer := ""
if strings.TrimSpace(payload.Referer) != "" {
refererURL, refererErr := normalizeProxyURL(payload.Referer)
@@ -207,6 +211,7 @@ func (s *Service) resolveProxyToken(ctx context.Context, token string, scope pro
return normalizedTarget, normalizedReferer, nil
}
// normalizeProxyURL validates and canonicalizes a proxy target URL.
func normalizeProxyURL(rawURL string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
@@ -222,6 +227,7 @@ func normalizeProxyURL(rawURL string) (string, error) {
return "", errors.New("invalid proxy target host")
}
// block localhost and .local TLD
if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
return "", errors.New("localhost targets are not allowed")
}
@@ -234,6 +240,7 @@ func normalizeProxyURL(rawURL string) (string, error) {
return parsed.String(), nil
}
// isBlockedProxyIP checks for loopback, private, multicast, and unspecified addresses.
func isBlockedProxyIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
@@ -243,6 +250,8 @@ func isBlockedProxyIP(ip net.IP) bool {
ip.IsUnspecified()
}
// ensurePublicProxyTarget validates that the target host resolves to a public IP.
// results are cached to avoid repeated DNS lookups.
func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
@@ -254,6 +263,7 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
return errors.New("invalid proxy target host")
}
// direct IP already checked by normalizeProxyURL
if ip := net.ParseIP(host); ip != nil {
if isBlockedProxyIP(ip) {
return errors.New("private proxy targets are not allowed")
@@ -261,6 +271,7 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
return nil
}
// check cache first
cached, ok := s.proxyHostCache.Get(host)
if ok {
if cached.Allowed {
@@ -269,6 +280,7 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
return errors.New("private proxy targets are not allowed")
}
// DNS resolution for hostname
resolvedIPs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(resolvedIPs) == 0 {
return errors.New("proxy target lookup failed")
@@ -293,6 +305,7 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
return nil
}
// rewritePlaylistWithTokens replaces segment URLs with proxy tokens for HLS playlists.
func (s *Service) rewritePlaylistWithTokens(ctx context.Context, content string, baseURL string, referer string) (string, error) {
base, err := url.Parse(baseURL)
if err != nil {
@@ -310,6 +323,7 @@ func (s *Service) rewritePlaylistWithTokens(ctx context.Context, content string,
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// preserve comments and empty lines
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
out.WriteString(line)
out.WriteString("\n")

View File

@@ -89,6 +89,7 @@ type userPlaybackState struct {
StartTimeSeconds float64
}
// NewService initializes the playback service with db and sql connections.
func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) (*Service, error) {
proxyTokens, err := newProxyTokenSigner(cfg.ProxyTokenSecret)
if err != nil {
@@ -120,6 +121,7 @@ func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) (*Service, error) {
}, nil
}
// BuildWatchPageData resolves show metadata and sources for a given MAL ID and episode.
func (s *Service) BuildWatchPageData(ctx context.Context, malID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) {
if malID <= 0 {
return WatchPageData{}, errors.New("invalid mal id")
@@ -283,11 +285,13 @@ func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandida
return showID, resolvedTitle, nil
}
// fetchPlaybackSourcesAndSegments resolves sources for both dub and sub modes concurrently.
func (s *Service) fetchPlaybackSourcesAndSegments(ctx context.Context, showID string, malID int, episode string) (map[string]ModeSource, []SkipSegment) {
modeCh := make(chan modeSourceResult, 2)
probeCache := make(map[string]directProbeResult)
probeCacheMu := sync.Mutex{}
// parallel fetch for both modes
for _, mode := range []string{"dub", "sub"} {
modeValue := mode
go func() {
@@ -321,8 +325,9 @@ func (s *Service) fetchPlaybackSourcesAndSegments(ctx context.Context, showID st
segmentsCh <- s.fetchSkipSegments(ctx, malID, episode)
}()
modeSources := make(map[string]ModeSource)
for range 2 {
modeSources := make(map[string]ModeSource)
// collect results from both mode goroutines
for range 2 {
result := <-modeCh
if !result.OK {
continue
@@ -344,6 +349,7 @@ func clonePlaybackBaseData(data playbackBaseData) playbackBaseData {
}
}
// GetEpisodeMetadata fetches episode notes and thumbnails from AllAnime.
func (s *Service) GetEpisodeMetadata(ctx context.Context, malID int, episode string) (map[string]any, error) {
showID, _, err := s.resolveShowCached(ctx, malID, nil)
if err != nil {

View File

@@ -11,6 +11,8 @@ import (
"strings"
)
// fetchSkipSegments queries aniskip API for OP/ED skip times.
// returns nil if the API is unavailable or has no data.
func (s *Service) fetchSkipSegments(ctx context.Context, malID int, episode string) []SkipSegment {
if malID <= 0 || strings.TrimSpace(episode) == "" {
return nil
@@ -49,6 +51,7 @@ func (s *Service) fetchSkipSegments(ctx context.Context, malID int, episode stri
return nil
}
// filter to valid OP/ED segments
segments := make([]SkipSegment, 0, len(parsed.Result))
for _, item := range parsed.Result {
if item.Interval.EndTime <= item.Interval.StartTime {

View File

@@ -11,6 +11,8 @@ import (
"time"
)
// ProxyStream fetches a stream URL and returns the response.
// retries on failure, rewrites m3u8 playlists to include auth tokens.
func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer string, rangeHeader string) (int, http.Header, []byte, io.ReadCloser, error) {
const maxRetries = 2
const retryDelay = 500 * time.Millisecond
@@ -51,8 +53,11 @@ func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer str
return 0, nil, nil, nil, fmt.Errorf("upstream request failed after %d retries: %w", maxRetries+1, lastErr)
}
// handleProxyResponse processes the upstream response.
// rewrites m3u8 playlists to proxy through our backend.
func (s *Service) handleProxyResponse(ctx context.Context, resp *http.Response, targetURL string, referer string, rangeHeader string) (int, http.Header, []byte, io.ReadCloser, error) {
// check if response is an m3u8 playlist that needs rewriting
if isM3U8(targetURL, resp.Header.Get("Content-Type")) {
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
@@ -73,12 +78,13 @@ func (s *Service) handleProxyResponse(ctx context.Context, resp *http.Response,
return resp.StatusCode, headers, []byte(rewritten), nil, nil
}
// for binary streams, remove chunked encoding and return body reader
headers := cloneHeaders(resp.Header)
// Some upstream servers send transfer-encoding chunked, we should let go's http server handle it
headers.Del("Transfer-Encoding")
return resp.StatusCode, headers, nil, resp.Body, nil
}
// isM3U8 checks if the response is an m3u8 playlist by URL or content-type.
func isM3U8(targetURL string, contentType string) bool {
if strings.Contains(strings.ToLower(targetURL), ".m3u8") {
return true
@@ -97,6 +103,8 @@ var hopHeaders = map[string]struct{}{
"upgrade": {},
}
// cloneHeaders copies headers, filtering out hop-by-hop headers.
// hop-by-hop headers are specific to a single transport connection.
func cloneHeaders(src http.Header) http.Header {
dst := make(http.Header)
for key, values := range src {

View File

@@ -49,6 +49,7 @@ func rankSources(sources []StreamSource, quality string) ([]sourceScore, error)
})
}
// stable sort to preserve insertion order for equal scores
sort.SliceStable(scored, func(i int, j int) bool {
return scored[i].total > scored[j].total
})
@@ -97,6 +98,7 @@ func lookupPriority(m map[string]int, key string, fallback int) int {
return fallback
}
// sourceQualityPriority scores quality match: exact match gets boost, mismatch gets penalty.
func sourceQualityPriority(sourceQuality string, targetQuality string) int {
qualityValue := parseQualityValue(sourceQuality)
@@ -114,6 +116,7 @@ func sourceQualityPriority(sourceQuality string, targetQuality string) int {
}
}
// qualityMatches checks if source matches target by substring or extracted digits.
func qualityMatches(sourceQuality string, targetQuality string) bool {
sourceLower := strings.ToLower(sourceQuality)
targetLower := strings.ToLower(targetQuality)
@@ -129,6 +132,7 @@ func qualityMatches(sourceQuality string, targetQuality string) bool {
return extractDigits(sourceLower) == extractDigits(targetLower)
}
// parseQualityValue extracts numeric value from quality string.
func parseQualityValue(rawQuality string) int {
lower := strings.ToLower(rawQuality)
if lower == "auto" {
@@ -147,6 +151,7 @@ func parseQualityValue(rawQuality string) int {
return value
}
// extractDigits reads leading digits until a non-digit or break condition.
func extractDigits(value string) string {
var digits []byte
for _, char := range value {
@@ -159,6 +164,7 @@ func extractDigits(value string) string {
return string(digits)
}
// normalizeSourceTypeFromProbe overrides source type based on Content-Type header.
func normalizeSourceTypeFromProbe(source StreamSource, contentType string) StreamSource {
lower := strings.ToLower(contentType)
switch {
@@ -170,6 +176,7 @@ func normalizeSourceTypeFromProbe(source StreamSource, contentType string) Strea
return source
}
// isLikelyMP4 checks ftyp box header (bytes 4-8 of mp4 files).
func isLikelyMP4(payload []byte) bool {
if len(payload) < 12 {
return false
@@ -178,6 +185,7 @@ func isLikelyMP4(payload []byte) bool {
return bytes.Equal(payload[4:8], []byte("ftyp"))
}
// isLikelyM3U8 checks for m3u8 file header.
func isLikelyM3U8(payload []byte) bool {
trimmed := strings.TrimSpace(string(payload))
return strings.HasPrefix(trimmed, "#EXTM3U")

View File

@@ -19,6 +19,7 @@ func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []
for _, mode := range modeCandidates {
for _, result := range resultsByMode[mode] {
// exact mal id match
if strings.TrimSpace(result.MalID) == malText && strings.TrimSpace(result.ID) != "" {
return result.ID, result.Name, nil
}
@@ -31,6 +32,7 @@ func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []
continue
}
// fallback to first result if no exact match
best := results[0]
if strings.TrimSpace(best.ID) != "" {
return best.ID, best.Name, nil
@@ -47,7 +49,7 @@ func (s *Service) searchShowResultsByMode(ctx context.Context, query string, mod
var wg sync.WaitGroup
for _, mode := range modeCandidates {
modeValue := mode
modeValue := mode // capture loop variable
wg.Go(func() {
results, err := s.allAnimeClient.Search(ctx, query, modeValue)
searchCh <- searchModeResult{Mode: modeValue, Results: results, Err: err}
@@ -96,6 +98,7 @@ func buildTitleSearchQueries(titleCandidates []string) []string {
add(normalized)
add(strings.ReplaceAll(normalized, "+", " "))
// strip apostrophes to improve match rate
withoutApostrophes := strings.NewReplacer("'", "", "", "", "`", "").Replace(normalized)
add(withoutApostrophes)
add(strings.ReplaceAll(withoutApostrophes, "+", " "))
@@ -144,6 +147,7 @@ func availableModes(modeSources map[string]ModeSource) []string {
return append(ordered, extra...)
}
// selectInitialMode picks a mode prioritizing: requested mode > dub > sub > first available.
func selectInitialMode(requestedMode string, modeSources map[string]ModeSource) string {
normalizedRequested := normalizeMode(requestedMode)
if normalizedRequested != "" {

View File

@@ -9,6 +9,7 @@ import (
"sync"
)
// resolveModeSource fetches sources for a given mode and selects the best one.
func (s *Service) resolveModeSource(ctx context.Context, showID string, episode string, mode string, quality string) (StreamSource, error) {
sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode)
if err != nil {
@@ -28,6 +29,7 @@ func (s *Service) resolveModeSource(ctx context.Context, showID string, episode
return selected, nil
}
// resolveModeSourceWithCache is like resolveModeSource but caches probe results.
func (s *Service) resolveModeSourceWithCache(
ctx context.Context,
showID string,
@@ -56,6 +58,8 @@ func (s *Service) resolveModeSourceWithCache(
return selected, nil
}
// choosePlaybackSource selects the best playable source from ranked candidates.
// priority: direct media > probed media > embed sources > ranked fallback.
func (s *Service) choosePlaybackSource(
ctx context.Context,
ranked []sourceScore,
@@ -70,22 +74,25 @@ func (s *Service) choosePlaybackSource(
source := candidate.source
switch strings.ToLower(source.Type) {
case "mp4", "m3u8":
return source, "direct-media", nil
return source, "direct-media", nil // known playable types
case "embed":
embedCandidates = append(embedCandidates, source)
embedCandidates = append(embedCandidates, source) // need probing
default:
// probe unknown types
if playable, contentType := probeFn(ctx, source); playable {
return normalizeSourceTypeFromProbe(source, contentType), "probed-media", nil
}
}
}
// check embed sources for playability
for _, embed := range embedCandidates {
if s.probeEmbedSource(ctx, embed) {
return embed, "embed-probed", nil
}
}
// fallback to first embed or first ranked
if len(embedCandidates) > 0 {
return embedCandidates[0], "embed-fallback", nil
}
@@ -93,6 +100,7 @@ func (s *Service) choosePlaybackSource(
return ranked[0].source, "ranked-fallback", nil
}
// choosePlaybackSourceWithCache wraps choosePlaybackSource with cached probing.
func (s *Service) choosePlaybackSourceWithCache(
ctx context.Context,
ranked []sourceScore,
@@ -131,6 +139,8 @@ func (s *Service) probeDirectMediaCached(
return playable, contentType
}
// probeDirectMedia checks if a direct media URL is playable.
// checks content-type header, reads prefix for magic bytes, falls back to URL extension.
func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bool, string) {
probeCtx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
defer cancel()
@@ -144,7 +154,7 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
req.Header.Set("Referer", source.Referer)
}
req.Header.Set("User-Agent", defaultUserAgent)
req.Header.Set("Range", "bytes=0-4095")
req.Header.Set("Range", "bytes=0-4095") // small range to detect playable content
resp, err := s.httpClient.Do(req)
if err != nil {
@@ -152,11 +162,13 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
}
defer resp.Body.Close()
// check content-type header first
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if strings.Contains(contentType, "video/") || strings.Contains(contentType, "mpegurl") {
return true, contentType
}
// check magic bytes in prefix
prefix, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err == nil {
if isLikelyM3U8(prefix) {
@@ -167,6 +179,7 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
}
}
// fallback to URL extension
finalURL := ""
if resp.Request != nil && resp.Request.URL != nil {
finalURL = strings.ToLower(resp.Request.URL.String())
@@ -179,6 +192,8 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
return false, contentType
}
// probeEmbedSource checks if an embed page is still available.
// returns false if the page contains deletion markers.
func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) bool {
ctx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
defer cancel()
@@ -203,6 +218,7 @@ func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) boo
return false
}
// check for common deletion messages
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil {
return false

View File

@@ -4,6 +4,7 @@ import (
"strings"
)
// toSubtitleItems converts raw subtitle entries into client-safe items.
func toSubtitleItems(source StreamSource) []SubtitleItem {
items := make([]SubtitleItem, 0, len(source.Subtitles))
for _, subtitle := range source.Subtitles {

View File

@@ -1,10 +1,11 @@
package playback
// StreamSource represents a video stream from a provider.
type StreamSource struct {
URL string
Quality string
Provider string
Type string
Type string // m3u8, mp4, embed, unknown
Referer string
Subtitles []Subtitle
AvailableQualities []StreamSource
@@ -36,6 +37,7 @@ type SkipSegment struct {
End float64 `json:"end"`
}
// WatchPageData is the response payload for the watch page frontend.
type WatchPageData struct {
MalID int
Title string

View File

@@ -19,6 +19,7 @@ func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// HandleUpdateWatchlist adds or updates anime in user's watchlist. accepts json {animeId, status}.
func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -40,6 +41,7 @@ func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request)
return
}
// default status if not provided
if body.Status == "" {
body.Status = "plan_to_watch"
}
@@ -53,6 +55,7 @@ func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK)
}
// HandleDeleteWatchlist removes anime from user's watchlist. expects /api/watchlist/{animeId}.
func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
if user == nil {
@@ -73,10 +76,12 @@ func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request)
return
}
// htmx: redirect to watchlist page after delete
w.Header().Set("HX-Redirect", "/watchlist")
w.WriteHeader(http.StatusOK)
}
// HandleDeleteContinueWatching removes entry from user's continue watching. expects /api/continue-watching/{animeId}.
func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
if user == nil {
@@ -101,6 +106,7 @@ func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Re
w.WriteHeader(http.StatusOK)
}
// HandleGetWatchlist renders user's watchlist page, grouped by status.
func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
if user == nil {
@@ -119,6 +125,7 @@ func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
return
}
// group entries by status for display
watchlistByStatus := make(map[string][]db.GetUserWatchListRow)
allEntries := make([]db.GetUserWatchListRow, 0)
watchlistIDs := make([]int64, len(entries))
@@ -149,6 +156,7 @@ func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
},
}
// use partial template for htmx requests
templateName := "watchlist.gohtml"
if r.Header.Get("HX-Request") == "true" {
templateName = "watchlist_partial.gohtml"

View File

@@ -36,12 +36,14 @@ func NewService(db db.Querier, sqlDB *sql.DB, jikanClient *jikan.Client) *Servic
return &Service{db: db, sqlDB: sqlDB, jikanClient: jikanClient}
}
// ensureAnimeExists checks if anime exists in db, fetches from jikan if not, then upserts.
func (s *Service) ensureAnimeExists(ctx context.Context, animeID int64) error {
_, err := s.db.GetAnime(ctx, animeID)
if err == nil {
return nil
return nil // already exists
}
// fetch from jikan and store locally
anime, err := s.jikanClient.GetAnimeByID(ctx, int(animeID))
if err != nil {
return fmt.Errorf("failed to fetch anime from jikan: %w", err)
@@ -72,6 +74,7 @@ type AddRequest struct {
Airing bool
}
// AddToWatchlist adds or updates an anime entry in user's watchlist.
func (s *Service) AddToWatchlist(ctx context.Context, userID string, animeID int64, status string) error {
if animeID <= 0 {
return ErrInvalidAnimeID
@@ -81,6 +84,7 @@ func (s *Service) AddToWatchlist(ctx context.Context, userID string, animeID int
return ErrInvalidStatus
}
// ensure anime exists in local db before linking
if err := s.ensureAnimeExists(ctx, animeID); err != nil {
return err
}
@@ -101,6 +105,7 @@ func (s *Service) AddToWatchlist(ctx context.Context, userID string, animeID int
return nil
}
// RemoveEntry deletes a watchlist entry and returns the anime for potential use.
func (s *Service) RemoveEntry(ctx context.Context, userID string, animeID int64) (db.Anime, error) {
if animeID <= 0 {
return db.Anime{}, ErrInvalidAnimeID
@@ -122,6 +127,7 @@ func (s *Service) RemoveEntry(ctx context.Context, userID string, animeID int64)
return anime, nil
}
// GetUserWatchlist retrieves all watchlist entries for a user.
func (s *Service) GetUserWatchlist(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
entries, err := s.db.GetUserWatchList(ctx, userID)
if err != nil {
@@ -130,6 +136,7 @@ func (s *Service) GetUserWatchlist(ctx context.Context, userID string) ([]db.Get
return entries, nil
}
// GetContinueWatching retrieves entries for continue watching section.
func (s *Service) GetContinueWatching(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
if strings.TrimSpace(userID) == "" {
return nil, errors.New("invalid user id")
@@ -143,6 +150,8 @@ func (s *Service) GetContinueWatching(ctx context.Context, userID string) ([]db.
return entries, nil
}
// DeleteContinueWatching removes entry and clears associated watch progress.
// uses transaction when sqlDB is available.
func (s *Service) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error {
if strings.TrimSpace(userID) == "" {
return errors.New("invalid user id")
@@ -164,6 +173,7 @@ func (s *Service) DeleteContinueWatching(ctx context.Context, userID string, ani
AnimeID: animeID,
}
// use transaction when sqlDB available for consistency
if s.sqlDB == nil {
if err := s.db.DeleteContinueWatchingEntry(ctx, params); err != nil {
return fmt.Errorf("failed to delete continue watching entry: %w", err)