From e48d95cb4eb7addcdad2c5dc93ade60af8372492 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 10 May 2026 20:00:04 +0200 Subject: [PATCH] feat: add comments and cleanup unused imports across codebase --- api/anime/handler.go | 25 +++++++++++++++++++------ api/anime/service.go | 7 +++++++ api/auth/auth.go | 8 ++++++-- api/auth/handler.go | 4 ++++ api/playback/allanime_client.go | 7 +++++++ api/playback/handler.go | 24 +++++++++++++++++------- api/playback/http_utils.go | 1 + api/playback/progress.go | 2 ++ api/playback/provider_extractor.go | 12 ++++++++++-- api/playback/proxy_security.go | 16 +++++++++++++++- api/playback/service_base.go | 10 ++++++++-- api/playback/service_http.go | 3 +++ api/playback/service_proxy.go | 10 +++++++++- api/playback/service_ranking.go | 8 ++++++++ api/playback/service_resolution.go | 6 +++++- api/playback/service_sources.go | 22 +++++++++++++++++++--- api/playback/service_utils.go | 1 + api/playback/types.go | 4 +++- api/watchlist/handler.go | 8 ++++++++ api/watchlist/service.go | 12 +++++++++++- integrations/jikan/anime.go | 3 +++ integrations/jikan/client.go | 18 ++++++++++++++++-- integrations/jikan/constants.go | 4 ++-- integrations/jikan/episodes.go | 3 +++ integrations/jikan/relations.go | 8 +++++++- integrations/jikan/search.go | 4 ++++ integrations/jikan/seasons.go | 7 ++++++- integrations/jikan/studio.go | 2 ++ integrations/jikan/types.go | 11 +++++++++++ integrations/watchorder/watch_order.go | 21 +++++++++++++++++---- internal/context/context.go | 2 ++ internal/db/helpers.go | 4 ++++ internal/db/migrate.go | 13 +++++++------ internal/db/sqlite.go | 4 ++++ internal/middleware/access.go | 12 +++++++----- internal/middleware/auth.go | 6 ++++-- internal/server/routes.go | 5 +++++ internal/worker/relations.go | 8 +++++++- pkg/middleware/csrf.go | 4 +++- pkg/middleware/logging.go | 10 ++++++++++ pkg/middleware/ratelimit.go | 20 +++++++++++++++----- static/anime.ts | 1 + static/dedupe.ts | 5 +++-- static/discover.ts | 2 ++ static/dropdown.ts | 4 ++-- static/player/controls.ts | 21 ++++++++++++++++++++- static/player/episodes/complete.ts | 9 +++++++++ static/player/episodes/nav.ts | 16 ++++++++++++++-- static/player/episodes/ui.ts | 24 +++++++++++++++++++++--- static/player/keyboard.ts | 14 +++++++------- static/player/main.ts | 18 +++++++++++++++++- static/player/mode.ts | 18 +++++++++++++++--- static/player/progress.ts | 20 ++++++++++++++++++++ static/player/quality.ts | 16 +++++++++++++++- static/player/state.ts | 11 +++++++++++ static/player/subtitles/index.ts | 16 ++++++++++++++++ static/player/subtitles/vtt.ts | 10 ++++++++++ static/player/timeline.ts | 19 +++++++++++++++++++ static/player/types.ts | 9 ++++++++- static/q.ts | 9 +++++++++ static/search.ts | 5 +++-- static/sort_filter.ts | 1 + static/theme.ts | 3 ++- static/timezone.ts | 18 ++++++++++++++---- static/toast.ts | 7 +++++++ static/utils.ts | 3 +++ templates/base.gohtml | 6 +++++- templates/renderer.go | 4 ++++ 68 files changed, 560 insertions(+), 88 deletions(-) diff --git a/api/anime/handler.go b/api/anime/handler.go index ac3db0b..1091d11 100644 --- a/api/anime/handler.go +++ b/api/anime/handler.go @@ -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) } diff --git a/api/anime/service.go b/api/anime/service.go index e2d66af..c475e03 100644 --- a/api/anime/service.go +++ b/api/anime/service.go @@ -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 { diff --git a/api/auth/auth.go b/api/auth/auth.go index e464c70..e46697c 100644 --- a/api/auth/auth.go +++ b/api/auth/auth.go @@ -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: "/", diff --git a/api/auth/handler.go b/api/auth/handler.go index c327993..08d2e31 100644 --- a/api/auth/handler.go +++ b/api/auth/handler.go @@ -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 { diff --git a/api/playback/allanime_client.go b/api/playback/allanime_client.go index 255eb08..a7151ae 100644 --- a/api/playback/allanime_client.go +++ b/api/playback/allanime_client.go @@ -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) { diff --git a/api/playback/handler.go b/api/playback/handler.go index 5b59c40..3eb88fa 100644 --- a/api/playback/handler.go +++ b/api/playback/handler.go @@ -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 diff --git a/api/playback/http_utils.go b/api/playback/http_utils.go index a8ef073..ae771cc 100644 --- a/api/playback/http_utils.go +++ b/api/playback/http_utils.go @@ -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 { diff --git a/api/playback/progress.go b/api/playback/progress.go index 73f6e6a..24ab631 100644 --- a/api/playback/progress.go +++ b/api/playback/progress.go @@ -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") diff --git a/api/playback/provider_extractor.go b/api/playback/provider_extractor.go index 79ac2da..c2367d8 100644 --- a/api/playback/provider_extractor.go +++ b/api/playback/provider_extractor.go @@ -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 } diff --git a/api/playback/proxy_security.go b/api/playback/proxy_security.go index ff91535..07980a1 100644 --- a/api/playback/proxy_security.go +++ b/api/playback/proxy_security.go @@ -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") diff --git a/api/playback/service_base.go b/api/playback/service_base.go index 0149f41..5001b87 100644 --- a/api/playback/service_base.go +++ b/api/playback/service_base.go @@ -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 { diff --git a/api/playback/service_http.go b/api/playback/service_http.go index 63022b5..5fd4f84 100644 --- a/api/playback/service_http.go +++ b/api/playback/service_http.go @@ -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 { diff --git a/api/playback/service_proxy.go b/api/playback/service_proxy.go index 580ba08..b579735 100644 --- a/api/playback/service_proxy.go +++ b/api/playback/service_proxy.go @@ -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 { diff --git a/api/playback/service_ranking.go b/api/playback/service_ranking.go index ff28f0a..5142961 100644 --- a/api/playback/service_ranking.go +++ b/api/playback/service_ranking.go @@ -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") diff --git a/api/playback/service_resolution.go b/api/playback/service_resolution.go index bba1a59..f8b25e2 100644 --- a/api/playback/service_resolution.go +++ b/api/playback/service_resolution.go @@ -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 != "" { diff --git a/api/playback/service_sources.go b/api/playback/service_sources.go index 02d1d50..3eddf14 100644 --- a/api/playback/service_sources.go +++ b/api/playback/service_sources.go @@ -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 diff --git a/api/playback/service_utils.go b/api/playback/service_utils.go index 1e749d9..43c022a 100644 --- a/api/playback/service_utils.go +++ b/api/playback/service_utils.go @@ -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 { diff --git a/api/playback/types.go b/api/playback/types.go index 35175e8..92d6d5b 100644 --- a/api/playback/types.go +++ b/api/playback/types.go @@ -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 diff --git a/api/watchlist/handler.go b/api/watchlist/handler.go index 787ea0b..67e7fb5 100644 --- a/api/watchlist/handler.go +++ b/api/watchlist/handler.go @@ -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" diff --git a/api/watchlist/service.go b/api/watchlist/service.go index 7875baa..38d2026 100644 --- a/api/watchlist/service.go +++ b/api/watchlist/service.go @@ -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) diff --git a/integrations/jikan/anime.go b/integrations/jikan/anime.go index 589736a..54ee529 100644 --- a/integrations/jikan/anime.go +++ b/integrations/jikan/anime.go @@ -6,6 +6,7 @@ import ( "time" ) +// GetAnimeCharacters returns character list for an anime with voice actor info. func (c *Client) GetAnimeCharacters(ctx context.Context, id int) ([]CharacterEntry, error) { url := fmt.Sprintf("%s/anime/%d/characters", c.baseURL, id) cacheKey := fmt.Sprintf("anime:characters:%d", id) @@ -18,6 +19,7 @@ func (c *Client) GetAnimeCharacters(ctx context.Context, id int) ([]CharacterEnt return resp.Data, nil } +// GetAnimeRecommendations returns user-submitted recommendations for an anime. func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]RecommendationEntry, error) { url := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, id) cacheKey := fmt.Sprintf("anime:recommendations:%d", id) @@ -30,6 +32,7 @@ func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]Recomme return resp.Data, nil } +// GetAnimeByID returns full anime details; finished series cached 30 days, airing cached 1 day. func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) { cacheKey := fmt.Sprintf("anime:%d", id) diff --git a/integrations/jikan/client.go b/integrations/jikan/client.go index db8acb0..6c287a4 100644 --- a/integrations/jikan/client.go +++ b/integrations/jikan/client.go @@ -20,9 +20,9 @@ type Client struct { httpClient *http.Client baseURL string db db.Querier - retrySignal chan struct{} + retrySignal chan struct{} // signals retry worker to process queued retries mu sync.Mutex - lastReqTime time.Time + lastReqTime time.Time // rate limiting: last request timestamp } func NewClient(db db.Querier) *Client { @@ -51,6 +51,7 @@ func (e *APIError) Error() string { return fmt.Sprintf("jikan api returned status %d", e.StatusCode) } +// IsNotFoundError returns true if the error is an APIError with 404 status. func IsNotFoundError(err error) bool { var apiErr *APIError if errors.As(err, &apiErr) { @@ -60,6 +61,7 @@ func IsNotFoundError(err error) bool { return false } +// IsRetryableError returns true if the error should trigger a retry. func IsRetryableError(err error) bool { if err == nil { return false @@ -90,6 +92,7 @@ func isRetryableStatus(statusCode int) bool { return statusCode >= 500 && statusCode <= 504 } +// retryDelay returns exponential backoff delay: 500ms, 1s, 2s, 4s, 8s (capped). func retryDelay(attempt int) time.Duration { base := 500 * time.Millisecond delay := base * time.Duration(1< "1 234 567"). func formatNumber(n int) string { if n == 0 { return "" @@ -180,10 +184,12 @@ func formatNumber(n int) string { return strings.Join(res, " ") } +// ImageURL returns the webp large image URL for the anime. func (a Anime) ImageURL() string { return a.Images.Webp.LargeImageURL } +// ShortRating extracts just the rating code (e.g. "PG-13") from full rating string. func (a Anime) ShortRating() string { if a.Rating == "" { return "" @@ -197,6 +203,7 @@ func (a Anime) ShortRating() string { return a.Rating } +// ShortDuration extracts numeric duration in minutes (e.g. "23m" from "23 min per ep"). func (a Anime) ShortDuration() string { if a.Duration == "" { return "" @@ -216,6 +223,7 @@ func (a Anime) ShortDuration() string { return a.Duration } +// DurationSeconds converts duration string to total seconds (e.g. "1 hr 30 min" -> 5400). func (a Anime) DurationSeconds() float64 { if a.Duration == "" { return 0 @@ -256,6 +264,7 @@ func (a Anime) DurationSeconds() float64 { return float64(hours*60+minutes) * 60 } +// Premiered returns formatted premiere string (e.g. "Winter 2020"). func (a Anime) Premiered() string { if a.Season != "" && a.Year > 0 { return fmt.Sprintf("%s %d", seasonLabel(a.Season), a.Year) @@ -263,6 +272,7 @@ func (a Anime) Premiered() string { return "" } +// seasonLabel normalizes season string to title case (fall/autumn -> Fall). func seasonLabel(season string) string { switch strings.ToLower(season) { case "winter": @@ -356,6 +366,7 @@ type RelationEntry struct { IsExtra bool } +// DisplayTitle returns English title if available, otherwise Japanese, then default. func (a Anime) DisplayTitle() string { if a.TitleEnglish != "" { return a.TitleEnglish diff --git a/integrations/watchorder/watch_order.go b/integrations/watchorder/watch_order.go index d088393..c4df5e4 100644 --- a/integrations/watchorder/watch_order.go +++ b/integrations/watchorder/watch_order.go @@ -15,7 +15,9 @@ import ( const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" +// idPattern extracts the watch order ID from chiaki.site URLs var idPattern = regexp.MustCompile(`/id/(\d+)`) +// malLinkPattern extracts MAL IDs from watch order entries var malLinkPattern = regexp.MustCompile(`myanimelist\.net/anime/(\d+)`) var ErrInvalidWatchOrderURL = errors.New("invalid watch order url") @@ -46,10 +48,10 @@ func (e *HTTPStatusError) Error() string { } type WatchOrderEntry struct { - ID int `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - TitleAlt string `json:"title_alt,omitempty"` + ID int `json:"id"` // MAL anime ID + Type string `json:"type"` // anime type label (e.g. "TV", "Movie") + Title string `json:"title"` // primary title + TitleAlt string `json:"title_alt,omitempty"` // alternative title } type WatchOrderResult struct { @@ -106,6 +108,7 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g defer response.Body.Close() if response.StatusCode != http.StatusOK { + // limit body read for error context; avoid reading large error pages body, _ := io.ReadAll(io.LimitReader(response.Body, 512)) return nil, &HTTPStatusError{ StatusCode: response.StatusCode, @@ -198,6 +201,8 @@ func hasWatchOrderTable(doc *goquery.Document) bool { return doc.Find("#wo_list").Length() > 0 } +// shouldTryProxy returns true for transient errors where the Jina proxy may help +// (e.g. Cloudflare blocking, rate limits) func shouldTryProxy(err error) bool { var statusError *HTTPStatusError if errors.As(err, &statusError) { @@ -243,6 +248,8 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s return string(body), nil } +// parseJinaEntries parses Jina proxy output, which contains one line per entry +// in format: "title | type | https://myanimelist.net/anime/ID" func parseJinaEntries(text string) []WatchOrderEntry { lines := strings.Split(text, "\n") entries := make([]WatchOrderEntry, 0) @@ -312,6 +319,8 @@ func isNoiseTitleLine(value string) bool { return false } +// titleFromContext looks backward from metaIndex to find the actual title lines. +// It skips noise lines (URLs, metadata prefixes, etc.) and returns (primary, alt). func titleFromContext(lines []string, metaIndex int) (string, string) { collected := make([]string, 0, 2) @@ -340,6 +349,7 @@ func titleFromContext(lines []string, metaIndex int) (string, string) { return collected[0], "" } + // reversed order: older lines first -> title, newer -> alt return collected[1], collected[0] } @@ -357,6 +367,8 @@ func fetchViaProxy(ctx context.Context, httpClient *http.Client, url string, roo return WatchOrderResult{ID: rootID, WatchOrder: entries}, nil } +// FetchWatchOrder fetches the watch order from chiaki.site. +// Falls back to the Jina proxy if the site is blocked or returns an empty table. func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (WatchOrderResult, error) { rootID, err := parseRootID(url) if err != nil { @@ -371,6 +383,7 @@ func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) ( return WatchOrderResult{}, err } + // empty table indicates JS-rendered content; need proxy if !hasWatchOrderTable(doc) { return fetchViaProxy(ctx, httpClient, url, rootID) } diff --git a/internal/context/context.go b/internal/context/context.go index 8e4e793..18cb734 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -1,5 +1,7 @@ package context +// UserKey is the context key for storing the authenticated user. +// It is unexported to prevent collisions. type key int const ( diff --git a/internal/db/helpers.go b/internal/db/helpers.go index dda7e8c..221bfc9 100644 --- a/internal/db/helpers.go +++ b/internal/db/helpers.go @@ -7,6 +7,7 @@ import ( "fmt" ) +// NullStringOr returns n.String if valid and non-empty, otherwise fallback func NullStringOr(n sql.NullString, fallback string) string { if n.Valid && n.String != "" { return n.String @@ -14,6 +15,7 @@ func NullStringOr(n sql.NullString, fallback string) string { return fallback } +// DisplayTitle returns the English title, falling back to Japanese then original func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal string) string { return NullStringOr(titleEnglish, NullStringOr(titleJapanese, titleOriginal)) } @@ -22,6 +24,7 @@ func (r GetUserWatchListRow) DisplayTitle() string { return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal) } +// BoolPtr converts a nullable bool to a pointer; nil if not valid func BoolPtr(b sql.NullBool) *bool { if !b.Valid { return nil @@ -29,6 +32,7 @@ func BoolPtr(b sql.NullBool) *bool { return &b.Bool } +// BeginTx starts a transaction and returns the Queries wrapper bound to it func BeginTx(ctx context.Context, db *sql.DB) (*Queries, *sql.Tx, error) { if db == nil { return nil, nil, errors.New("database unavailable") diff --git a/internal/db/migrate.go b/internal/db/migrate.go index aa24e35..32cec18 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -10,6 +10,8 @@ import ( "strings" ) +// RunMigrations applies all *.sql files in migrationsDir in sorted order, +// skipping any already recorded in migration_version. func RunMigrations(db *sql.DB, migrationsDir string) error { if migrationsDir == "" { return fmt.Errorf("migrations directory is required") @@ -44,22 +46,19 @@ func RunMigrations(db *sql.DB, migrationsDir string) error { for _, migrationFile := range migrations { migrationName := filepath.Base(migrationFile) if migrationApplied(appliedNames, migrationName) { - // already applied, skipping silently - continue + continue // already applied } - // Read and execute migration migrationSQL, err := os.ReadFile(migrationFile) if err != nil { return err } - // Strict execution: if it fails, it halts. if _, err := db.Exec(string(migrationSQL)); err != nil { - return err + return err // stop on first failure } - // Mark as applied + // record applied migration _, err = db.Exec("INSERT INTO migration_version (name) VALUES (?)", migrationName) if err != nil { return err @@ -97,6 +96,8 @@ func loadAppliedMigrationNames(db *sql.DB) (map[string]struct{}, error) { return applied, nil } +// migrationApplied checks the applied names map for a match, +// including legacy paths and case-insensitive basename matches. func migrationApplied(appliedNames map[string]struct{}, migrationName string) bool { if _, exists := appliedNames[migrationName]; exists { return true diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index c4d5c27..bec1147 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -9,6 +9,7 @@ import ( _ "github.com/mattn/go-sqlite3" ) +// Open connects to a sqlite3 database with foreign keys enforced func Open(dbFile string) (*sql.DB, error) { db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", dbFile)) if err != nil { @@ -17,6 +18,7 @@ func Open(dbFile string) (*sql.DB, error) { return db, nil } +// GetDBFile returns the database file path, checking DATABASE_FILE env var first func GetDBFile() string { if f := os.Getenv("DATABASE_FILE"); f != "" { return f @@ -24,6 +26,7 @@ func GetDBFile() string { return "mal.db" } +// GetMigrationsDir returns the migrations directory, checking MIGRATIONS_DIR env var first func GetMigrationsDir() (string, error) { if dir := os.Getenv("MIGRATIONS_DIR"); dir != "" { return dir, nil @@ -35,6 +38,7 @@ func GetMigrationsDir() (string, error) { return filepath.Join(wd, "migrations"), nil } +// Init opens the database, runs migrations, and returns a Queries instance func Init(db *sql.DB) (*Queries, error) { migrationsDir, err := GetMigrationsDir() if err != nil { diff --git a/internal/middleware/access.go b/internal/middleware/access.go index 6abba55..58cefb2 100644 --- a/internal/middleware/access.go +++ b/internal/middleware/access.go @@ -6,18 +6,18 @@ import ( ) type AccessPolicy struct { - PublicPaths map[string]struct{} - PublicHeads []string + PublicPaths map[string]struct{} // exact match paths (e.g. /login) + PublicHeads []string // prefix match paths (e.g. /static/) } func NewAccessPolicy() AccessPolicy { return AccessPolicy{ PublicPaths: map[string]struct{}{ - "/login": {}, + "/login": {}, // login page is public }, PublicHeads: []string{ - "/static/", - "/dist/", + "/static/", // static assets + "/dist/", // bundled assets }, } } @@ -36,6 +36,8 @@ func (p AccessPolicy) IsPublicPath(path string) bool { return false } +// RequireGlobalAuthWithPolicy redirects unauthenticated users to /login +// uses HX-Redirect for HTMX requests, regular redirect otherwise func RequireGlobalAuthWithPolicy(policy AccessPolicy) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 6876dda..a31b16e 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -9,18 +9,19 @@ import ( "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 { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_id") if err != nil { - next.ServeHTTP(w, r) + next.ServeHTTP(w, r) // no cookie, proceed unauthenticated return } user, err := authService.ValidateSession(r.Context(), cookie.Value) if err != nil { - next.ServeHTTP(w, r) + next.ServeHTTP(w, r) // invalid session, proceed unauthenticated return } @@ -30,6 +31,7 @@ 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) if !ok { diff --git a/internal/server/routes.go b/internal/server/routes.go index f5a7ebd..c8d7dd5 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -27,6 +27,7 @@ type Config struct { PlaybackProxySecret string } +// withMimeTypes sets Content-Type for common static asset extensions func withMimeTypes(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ext := strings.ToLower(filepath.Ext(r.URL.Path)) @@ -44,6 +45,7 @@ func withMimeTypes(next http.Handler) http.Handler { }) } +// noCache sends headers to prevent caching of dynamic/static assets func noCache(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") @@ -53,6 +55,7 @@ func noCache(next http.Handler) http.Handler { }) } +// NewAuthLimiter returns a rate limiter for auth endpoints: 5 attempts per minute func NewAuthLimiter() *pkgmiddleware.Limiter { return pkgmiddleware.NewLimiter(pkgmiddleware.Config{ MaxAttempts: 5, @@ -60,6 +63,8 @@ func NewAuthLimiter() *pkgmiddleware.Limiter { }) } +// NewRouter wires up all HTTP handlers and middleware. +// Auth is enforced globally; public routes must opt-out via middleware policy. func NewRouter(cfg Config) http.Handler { mux := http.NewServeMux() diff --git a/internal/worker/relations.go b/internal/worker/relations.go index 8a096d9..5b6d13a 100644 --- a/internal/worker/relations.go +++ b/internal/worker/relations.go @@ -26,12 +26,13 @@ func New(db *db.Queries, client *jikan.Client) *Worker { func (w *Worker) Start(ctx context.Context) { log.Println("Starting relations sync worker...") + // ticker: regular sync; retryTicker: check for failed API retries ticker := time.NewTicker(1 * time.Minute) retryTicker := time.NewTicker(30 * time.Second) defer ticker.Stop() defer retryTicker.Stop() - // Run once immediately + // Run once immediately on startup w.runAllTasks(ctx) cleanupCounter := 0 @@ -78,6 +79,7 @@ func (w *Worker) runAllTasks(ctx context.Context) { wg.Wait() } +// retryBackoff calculates the next retry delay, doubling up to 30 minutes max func retryBackoff(attempts int64) string { if attempts < 1 { attempts = 1 @@ -97,6 +99,7 @@ func retryBackoff(attempts int64) string { return fmt.Sprintf("+%d minutes", minutes) } +// processAnimeFetchRetries retries failed Jikan API fetches for anime with pending entries func (w *Worker) processAnimeFetchRetries(ctx context.Context) { retries, err := w.db.GetDueAnimeFetchRetries(ctx, 20) if err != nil { @@ -139,6 +142,7 @@ func (w *Worker) cleanupCache(ctx context.Context) { } } +// syncRelations fetches relation data for anime that need syncing via a worker pool func (w *Worker) syncRelations(ctx context.Context) { animes, err := w.db.GetAnimeNeedingRelationSync(ctx) if err != nil { @@ -170,6 +174,8 @@ func (w *Worker) syncRelations(ctx context.Context) { wg.Wait() } +// syncSingleAnime fetches relations for one anime, inserts them, and marks it synced. +// For sequels, also ensures the related anime exists in the DB to enable linking. func (w *Worker) syncSingleAnime(ctx context.Context, id int64) { animeData, err := w.client.GetAnimeByID(ctx, int(id)) if err != nil { diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index 1afd853..faf73e1 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -5,6 +5,8 @@ import ( "net/url" ) +// VerifyOrigin validates that the request Origin/Referer matches the host +// skips validation for safe methods (GET, HEAD, OPTIONS) func VerifyOrigin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { @@ -36,7 +38,7 @@ func VerifyOrigin(next http.Handler) http.Handler { host := r.Host if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" { - host = forwardedHost + host = forwardedHost // support reverse proxies } expectedHTTP := "http://" + host diff --git a/pkg/middleware/logging.go b/pkg/middleware/logging.go index 4687485..76774a3 100644 --- a/pkg/middleware/logging.go +++ b/pkg/middleware/logging.go @@ -10,6 +10,8 @@ import ( "time" ) +// statusRecorder wraps ResponseWriter to capture the status code +// defaults to 200 if WriteHeader is never called before Write type statusRecorder struct { http.ResponseWriter statusCode int @@ -23,6 +25,7 @@ func newStatusRecorder(w http.ResponseWriter) *statusRecorder { } } +// WriteHeader records the status code and proxies to underlying writer func (rw *statusRecorder) WriteHeader(code int) { if rw.wroteHeader { return @@ -32,6 +35,7 @@ func (rw *statusRecorder) WriteHeader(code int) { rw.ResponseWriter.WriteHeader(code) } +// Write ensures a status code is set before writing the body func (rw *statusRecorder) Write(b []byte) (int, error) { if !rw.wroteHeader { rw.WriteHeader(http.StatusOK) @@ -39,12 +43,14 @@ func (rw *statusRecorder) Write(b []byte) (int, error) { return rw.ResponseWriter.Write(b) } +// Flush proxies the Flusher interface if supported func (rw *statusRecorder) Flush() { if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { flusher.Flush() } } +// Hijack proxies the Hijacker interface if supported func (rw *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { hijacker, ok := rw.ResponseWriter.(http.Hijacker) if !ok { @@ -53,6 +59,7 @@ func (rw *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { return hijacker.Hijack() } +// Push proxies the Pusher interface if supported func (rw *statusRecorder) Push(target string, opts *http.PushOptions) error { pusher, ok := rw.ResponseWriter.(http.Pusher) if !ok { @@ -61,10 +68,13 @@ func (rw *statusRecorder) Push(target string, opts *http.PushOptions) error { return pusher.Push(target, opts) } +// Unwrap returns the underlying ResponseWriter for middleware chaining func (rw *statusRecorder) Unwrap() http.ResponseWriter { return rw.ResponseWriter } +// RequestLogger logs requests that result in 4xx/5xx responses +// skips static assets, streaming, and common bot paths func RequestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() diff --git a/pkg/middleware/ratelimit.go b/pkg/middleware/ratelimit.go index 495c270..a624d37 100644 --- a/pkg/middleware/ratelimit.go +++ b/pkg/middleware/ratelimit.go @@ -8,16 +8,19 @@ import ( "time" ) +// visitor tracks request attempts and last access time per IP type visitor struct { attempts int lastSeen time.Time } +// Config holds rate limiter settings type Config struct { - MaxAttempts int - Window time.Duration + MaxAttempts int // max requests per window + Window time.Duration // sliding window duration } +// Limiter implements a simple in-memory IP-based rate limiter type Limiter struct { mu sync.Mutex visitors map[string]*visitor @@ -31,6 +34,7 @@ func NewLimiter(cfg Config) *Limiter { } } +// Cleanup removes stale visitor entries older than 3x the window func (l *Limiter) Cleanup(now time.Time) { l.mu.Lock() defer l.mu.Unlock() @@ -41,21 +45,23 @@ func (l *Limiter) Cleanup(now time.Time) { } } +// getIP extracts the client IP, checking X-Forwarded-For and X-Real-IP headers func getIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { ips := strings.Split(xff, ",") - return strings.TrimSpace(ips[0]) + return strings.TrimSpace(ips[0]) // first proxy IP } if realIP := r.Header.Get("X-Real-IP"); realIP != "" { return realIP } ip := r.RemoteAddr if colonIdx := strings.LastIndex(ip, ":"); colonIdx != -1 { - ip = ip[:colonIdx] + ip = ip[:colonIdx] // strip port for IPv4-mapped IPv6 } return ip } +// Middleware returns 429 for rate-limited API requests func (l *Limiter) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !l.allow(getIP(r)) { @@ -66,6 +72,8 @@ func (l *Limiter) Middleware(next http.Handler) http.Handler { }) } +// AuthMiddleware redirects rate-limited form submissions back to the page +// returns 429 for non-path requests (e.g. API calls) func (l *Limiter) AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !l.allow(getIP(r)) { @@ -80,6 +88,8 @@ func (l *Limiter) AuthMiddleware(next http.Handler) http.Handler { }) } +// allow checks and updates the visitor's attempt count; returns true if allowed +// resets counter if window has expired, otherwise increments and checks limit func (l *Limiter) allow(ip string) bool { l.mu.Lock() defer l.mu.Unlock() @@ -91,7 +101,7 @@ func (l *Limiter) allow(ip string) bool { } if time.Since(v.lastSeen) > l.config.Window { - v.attempts = 1 + v.attempts = 1 // reset counter on window expiry v.lastSeen = time.Now() return true } diff --git a/static/anime.ts b/static/anime.ts index 75622d6..4df914b 100644 --- a/static/anime.ts +++ b/static/anime.ts @@ -1,6 +1,7 @@ import { parseClassList } from './utils'; const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => { + // data attributes store the class lists to add/remove const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes')); const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes')); diff --git a/static/dedupe.ts b/static/dedupe.ts index 3f33d48..aa7b2ac 100644 --- a/static/dedupe.ts +++ b/static/dedupe.ts @@ -8,18 +8,19 @@ const dedupe = (): void => { return; } if (seen.has(id)) { - item.remove(); + item.remove(); // duplicate, remove it } else { seen.add(id); } }); }; +// run on DOM ready or immediately if already loaded if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', dedupe); } else { dedupe(); } -window.addEventListener('load', dedupe); +// also run on load as a fallback (e.g. htmx swaps) window.addEventListener('load', dedupe); diff --git a/static/discover.ts b/static/discover.ts index 239b016..fceeb18 100644 --- a/static/discover.ts +++ b/static/discover.ts @@ -6,6 +6,7 @@ const setActiveDiscoverTab = (clickedTab: Element): void => { return; } + // reset all tabs in group const triggers = group.querySelectorAll('[data-tab-trigger]'); triggers.forEach(tab => { const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes')); @@ -14,6 +15,7 @@ const setActiveDiscoverTab = (clickedTab: Element): void => { tab.classList.add(...inactiveClasses); }); + // mark clicked tab as active const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes')); const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes')); clickedTab.classList.remove(...inactiveClasses); diff --git a/static/dropdown.ts b/static/dropdown.ts index 42ce7a6..5fd41c3 100644 --- a/static/dropdown.ts +++ b/static/dropdown.ts @@ -1,7 +1,7 @@ class UIDropdown extends HTMLElement { isOpen: boolean = false; contentEl: HTMLElement | null = null; - isClosing: boolean = false; + isClosing: boolean = false; // debounce flag constructor() { super(); @@ -53,7 +53,7 @@ class UIDropdown extends HTMLElement { } setTimeout(() => { this.isClosing = false; - }, 100); + }, 100); // delay prevents rapid open/close flicker } handleClickOutside(event: MouseEvent): void { diff --git a/static/player/controls.ts b/static/player/controls.ts index 227f509..d228f22 100644 --- a/static/player/controls.ts +++ b/static/player/controls.ts @@ -7,6 +7,9 @@ export const formatTime = (seconds: number): string => { return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; +/** + * Shows the controls overlay and schedules auto-hide after 2s if playing. + */ export const showControls = (): void => { state.container.classList.add('show-controls'); window.clearTimeout(state.playerControlsTimeout); @@ -17,6 +20,7 @@ export const showControls = (): void => { }, 2000); }; +// seek relative to current position export const seekBy = (delta: number): void => { if (state.video.duration <= 0) return; state.video.currentTime = Math.max( @@ -34,6 +38,7 @@ export const togglePlayPause = (): void => { } }; +// toggle mute, restoring previous volume export const toggleMute = (): void => { if (state.video.muted || state.video.volume === 0) { const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1; @@ -45,6 +50,7 @@ export const toggleMute = (): void => { } }; +// set volume (0-1), auto-unmute export const setVolume = (value: number): void => { state.video.volume = Math.max(0, Math.min(1, value)); state.video.muted = value === 0; @@ -59,8 +65,9 @@ export const toggleFullscreen = (): void => { state.container.requestFullscreen?.(); }; +// syncs volume slider, underline, and mute icon export const syncVolumeUI = (): void => { - const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls(); + const { volumeRange, volumeUnderline } = getControls(); const value = state.video.muted ? 0 : Math.round(state.video.volume * 100); if (volumeRange) { volumeRange.value = String(value); @@ -125,6 +132,10 @@ const updateMuteIcons = (isMuted: boolean): void => { iconMuted?.classList.toggle('hidden', !isMuted); }; +/** + * Binds click handlers to player control buttons. + * Sets up video event listeners for icon sync. + */ export const setupControls = (): void => { const { playPause, @@ -137,6 +148,7 @@ export const setupControls = (): void => { skipSegmentBtn, } = getControls(); + // play/pause on button and video click playPause?.addEventListener('click', () => { togglePlayPause(); showControls(); @@ -151,11 +163,13 @@ export const setupControls = (): void => { showControls(); }); + // volume slider volumeRange?.addEventListener('input', () => { const value = Number(volumeRange.value) / 100; setVolume(value); showControls(); }); + // dragging class for visual feedback volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging')); window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging')); @@ -167,18 +181,21 @@ export const setupControls = (): void => { showControls(); }); + // skip intro/outro button skipSegmentBtn?.addEventListener('click', () => { if (!state.activeSkipSegment) return; state.video.currentTime = state.activeSkipSegment.end + 0.01; showControls(); }); + // fullscreen change handler document.addEventListener('fullscreenchange', () => { state.isFullscreen = !!document.fullscreenElement; state.container.classList.toggle('fullscreen', state.isFullscreen); if (state.isFullscreen) showControls(); }); + // icon sync on state changes state.video.addEventListener('play', () => { updatePlayPauseIcons(true); showControls(); @@ -189,8 +206,10 @@ export const setupControls = (): void => { }); state.video.addEventListener('volumechange', syncVolumeUI); + // mouse move in container shows controls state.container.addEventListener('mousemove', showControls); + // initial sync updatePlayPauseIcons(false); syncVolumeUI(); }; diff --git a/static/player/episodes/complete.ts b/static/player/episodes/complete.ts index 710dd2f..70636ef 100644 --- a/static/player/episodes/complete.ts +++ b/static/player/episodes/complete.ts @@ -1,6 +1,11 @@ import DOMPurify from 'dompurify'; import { state } from '../state'; +/** + * Marks anime as completed when final episode finishes. + * Calls completion API, updates dropdown UI, adds to watchlist. + * Retries up to 2 times on failure. + */ export const completeAnime = async (episodeNumber: number): Promise => { if (state.completionSent || !state.malID || !episodeNumber) return; state.completionSent = true; @@ -15,6 +20,7 @@ export const completeAnime = async (episodeNumber: number): Promise => { if (!res.ok) { state.completionSent = false; + // retry if (state.completionAttempts < 2) { state.completionAttempts++; setTimeout(() => completeAnime(episodeNumber), 1000); @@ -22,6 +28,7 @@ export const completeAnime = async (episodeNumber: number): Promise => { return; } + // update dropdown trigger text const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null; if (trigger) { trigger.textContent = 'Completed '; @@ -31,6 +38,7 @@ export const completeAnime = async (episodeNumber: number): Promise => { trigger.appendChild(caret); } + // add to watchlist with 'completed' status const dropdown = document.getElementById('watch-status-dropdown'); if (dropdown) { const payload = { @@ -51,6 +59,7 @@ export const completeAnime = async (episodeNumber: number): Promise => { }) .then(async res => { if (!res.ok) return; + // replace dropdown with HTMX response const html = await res.text(); const wrapper = document.createElement('span'); wrapper.id = 'watch-status-dropdown'; diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index 7482e90..94d2774 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -1,22 +1,27 @@ import { state } from '../state'; import { SkipSegment } from '../types'; -import { displayTimeFromAbsolute } from '../timeline'; import { resolveActiveSegments, renderSegments } from '../skip/segments'; import { updateSubtitleOptions } from '../subtitles'; import { updateQualityOptions } from '../quality'; import { updateModeButtons } from '../mode'; -import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui'; +import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui'; import { markEpisodeTransition } from '../progress'; +/** + * Handles video end: either marks complete or loads next episode. + * Fetches episode data from API, updates player state and URL. + */ export const goToNextEpisode = async (): Promise => { const currentEp = Number.parseInt(state.currentEpisode, 10); if (!currentEp) return; + // final episode: trigger completion flow if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) { import('./complete').then(m => m.completeAnime(currentEp)); return; } + // skip if autoplay disabled if (!isAutoplayEnabled()) return; const nextEp = currentEp + 1; @@ -25,6 +30,7 @@ export const goToNextEpisode = async (): Promise => { try { const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`); if (!res.ok) { + // fallback: full page navigation sessionStorage.setItem('mal:autoplay-next', 'true'); const url = new URL(window.location.href); url.searchParams.set('ep', String(nextEp)); @@ -34,6 +40,7 @@ export const goToNextEpisode = async (): Promise => { const data = await res.json(); + // update state with new episode data state.modeSources = data.mode_sources ?? {}; state.availableModes = data.available_modes ?? []; @@ -46,6 +53,7 @@ export const goToNextEpisode = async (): Promise => { return; } + // load new video state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`; state.video.load(); if (!state.video.paused) state.video.play().catch(() => {}); @@ -56,11 +64,13 @@ export const goToNextEpisode = async (): Promise => { state.completionAttempts = 0; state.activeSubtitles = []; + // update UI updateSubtitleOptions(); updateQualityOptions(); updateModeButtons(); updateOverlay(state.currentEpisode, data.episode_title ?? ''); + // update skip segments if (data.segments?.length) { state.parsedSegments = data.segments .map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 })) @@ -69,6 +79,7 @@ export const goToNextEpisode = async (): Promise => { renderSegments(); } + // highlight new episode in list/grid state.episodeList ?.querySelectorAll('[data-episode-id]') .forEach(el => el.classList.remove('bg-accent/20')); @@ -84,6 +95,7 @@ export const goToNextEpisode = async (): Promise => { newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent'); } + // update URL without reload const url = new URL(window.location.href); url.searchParams.set('ep', String(nextEp)); history.pushState(null, '', url.toString()); diff --git a/static/player/episodes/ui.ts b/static/player/episodes/ui.ts index ca9d999..7e442cf 100644 --- a/static/player/episodes/ui.ts +++ b/static/player/episodes/ui.ts @@ -1,8 +1,9 @@ import { state } from '../state'; -import { updateSubtitleOptions } from '../subtitles'; -import { updateQualityOptions } from '../quality'; -import { updateModeButtons } from '../mode'; +/** + * Syncs autoplay checkbox with localStorage on init. + * Default is enabled (not 'false'). + */ export const setupAutoplayButton = (): void => { const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null; if (!btn) return; @@ -12,12 +13,16 @@ export const setupAutoplayButton = (): void => { export const isAutoplayEnabled = (): boolean => localStorage.getItem('mal:autoplay-enabled') !== 'false'; +/** + * Updates video overlay text (shown briefly on episode change). + */ export const updateOverlay = (episode: string, title: string): void => { if (!state.videoOverlay) return; const p = state.videoOverlay.querySelector('p'); p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`); }; +// helper: get all episode elements from grid and list const getEpisodeEls = () => { const grid = state.episodeGrid; const list = state.episodeList; @@ -27,19 +32,30 @@ const getEpisodeEls = () => { }; }; +/** + * Highlights current episode in grid and list. + * Scrolls to episode into view. + */ export const updateEpisodeHighlight = (num: number): void => { const { gridEls, listEls } = getEpisodeEls(); + // clear old highlights [...gridEls, ...listEls].forEach(el => el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent') ); + // apply new highlight const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`); const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`); gridEl?.classList.add('ring-2', 'ring-accent'); listEl?.classList.add('ring-2', 'ring-accent'); + // scroll into view (gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; +/** + * Switches visible episode range in grid. + * Updates dropdown label and hides/shows episode cards. + */ export const switchEpisodeRange = (idx: number): void => { const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null; if (!dropdown) return; @@ -50,10 +66,12 @@ export const switchEpisodeRange = (idx: number): void => { const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10); const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10); + // update label (e.g., "01-100") const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null; if (label) label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`; + // show/hide episodes in range state.episodeGrid?.querySelectorAll('[data-episode-id]').forEach(el => { const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10); el.classList.toggle('hidden', n < start || n > end); diff --git a/static/player/keyboard.ts b/static/player/keyboard.ts index c522988..3f571d8 100644 --- a/static/player/keyboard.ts +++ b/static/player/keyboard.ts @@ -1,10 +1,5 @@ import { state } from './state'; -import { - displayTimeFromAbsolute, - absoluteTimeFromDisplay, - absoluteTimeFromRatio, - getBounds, -} from './timeline'; +import { absoluteTimeFromRatio, getBounds } from './timeline'; import { showControls, toggleMute, @@ -12,12 +7,16 @@ import { toggleFullscreen, seekBy, setVolume, - formatTime, } from './controls'; +/** + * Sets up keyboard shortcuts for player control. + * Ignores input/textarea to allow typing. + */ export const setupKeyboard = (): void => { document.addEventListener('keydown', e => { const target = e.target as HTMLElement; + // ignore when typing in form fields if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; @@ -59,6 +58,7 @@ export const setupKeyboard = (): void => { showControls(); break; default: + // number keys 0-9: jump to 0%-90% of video if (/^\d$/.test(e.key)) { const b = getBounds(); if (b.duration > 0) { diff --git a/static/player/main.ts b/static/player/main.ts index 1b8c13f..7f00bd1 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -14,7 +14,7 @@ import { markEpisodeTransition, setupProgress } from './progress'; import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline'; import { formatTime } from './controls'; -let initialized = false; +let initialized = false; // prevent double init on htmx swaps const hidePreviewPopover = (): void => { state.previewPopover?.classList.remove('block'); @@ -27,6 +27,7 @@ const showPreviewPopover = (): void => { state.previewPopover?.classList.add('block'); }; +// updates time preview on progress bar hover const updatePreviewUI = (ratio: number): void => { const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null; if (!progressWrap || !state.previewPopover || !state.previewTime) { @@ -39,6 +40,7 @@ const updatePreviewUI = (ratio: number): void => { return; } + // show time for hovered position state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration))); const barWidth = progressWrap.clientWidth; @@ -48,6 +50,7 @@ const updatePreviewUI = (ratio: number): void => { } showPreviewPopover(); + // clamp to stay within bar bounds const popoverWidth = state.previewPopover.offsetWidth || 72; state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`; }; @@ -62,6 +65,7 @@ const initPlayer = (): void => { const loading = container.querySelector('[data-loading]') as HTMLElement | null; const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null; + // build video src from mode, token, and saved quality preference const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'; const streamToken = state.modeSources[state.currentMode]?.token; if (streamToken) { @@ -90,10 +94,12 @@ const initPlayer = (): void => { resolveActiveSegments(); renderSegments(); + // resume from saved position const startTime = Number(container.dataset.startTimeSeconds ?? '0'); if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) { state.video.currentTime = startTime; } + // resume after mode switch if (state.pendingSeekTime !== null) { state.video.currentTime = state.pendingSeekTime; state.pendingSeekTime = null; @@ -110,10 +116,12 @@ const initPlayer = (): void => { state.video.addEventListener('playing', () => { loading && (loading.style.display = 'none'); }); + // update progress bar during buffering state.video.addEventListener('progress', () => { updateTimeline(state.video.currentTime); }); + // main loop: update progress, subtitles, skip buttons state.video.addEventListener('timeupdate', () => { updateTimeline(state.video.currentTime); updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime)); @@ -124,6 +132,7 @@ const initPlayer = (): void => { goToNextEpisode(); }); + // click to seek progressWrap?.addEventListener('mousedown', e => { state.isScrubbing = true; const rect = progressWrap.getBoundingClientRect(); @@ -135,6 +144,7 @@ const initPlayer = (): void => { showControls(); }); + // hover to preview time progressWrap?.addEventListener('mousemove', e => { const rect = progressWrap.getBoundingClientRect(); updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); @@ -142,6 +152,7 @@ const initPlayer = (): void => { progressWrap?.addEventListener('mouseleave', hidePreviewPopover); + // dragging outside progress bar while scrubbing window.addEventListener('mousemove', e => { if (!state.isScrubbing || !progressWrap) return; const rect = progressWrap.getBoundingClientRect(); @@ -152,6 +163,7 @@ const initPlayer = (): void => { updateSkipButton(state.video.currentTime); }); + // track episode transitions from external links container.addEventListener('click', e => { const anchor = (e.target as Node).parentElement?.closest('a[href]'); if (!(anchor instanceof HTMLAnchorElement)) return; @@ -170,9 +182,11 @@ const initPlayer = (): void => { if (searchInput) { searchInput.addEventListener('input', () => { clearTimeout(searchDebounce); + // debounce to avoid excessive range switches while typing searchDebounce = window.setTimeout(() => { const val = searchInput.value.replace(/\D/g, ''); if (!val) { + // clear: jump to current episode range const cur = Number.parseInt(state.currentEpisode, 10); switchEpisodeRange(Math.floor((cur - 1) / 100)); updateEpisodeHighlight(cur); @@ -191,6 +205,7 @@ const initPlayer = (): void => { }); } + // range buttons (100s of episodes) if (dropdown) { dropdown.querySelectorAll('.episode-range-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -200,6 +215,7 @@ const initPlayer = (): void => { }); } + // initial range for large episode lists if (state.episodeGrid && state.totalEpisodes > 100) { switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100)); } diff --git a/static/player/mode.ts b/static/player/mode.ts index 9911279..7a6e201 100644 --- a/static/player/mode.ts +++ b/static/player/mode.ts @@ -1,10 +1,10 @@ import { state } from './state'; -import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'; +import { displayTimeFromAbsolute } from './timeline'; import { showControls } from './controls'; import { updateSubtitleOptions } from './subtitles'; import { updateQualityOptions } from './quality'; -import { ModeSource } from './types'; +// builds stream URL with mode, token, and optional quality param const streamUrlForMode = (mode: string, quality?: string): string => { const src = state.modeSources[mode]; if (!src?.token) return ''; @@ -13,16 +13,21 @@ const streamUrlForMode = (mode: string, quality?: string): string => { return url; }; +// switches video src while preserving playback position const loadVideo = (url: string): void => { if (!url) return; const wasPlaying = !state.video.paused; const prevTime = displayTimeFromAbsolute(state.video.currentTime); state.video.src = url; state.video.load(); - state.pendingSeekTime = prevTime; + state.pendingSeekTime = prevTime; // restored in loadedmetadata handler if (wasPlaying) state.video.play().catch(() => {}); }; +/** + * Switches between sub/dub mode. + * Saves preference to localStorage, reloads video src. + */ export const switchMode = (mode: string): void => { if (!state.availableModes.includes(mode) || mode === state.currentMode) return; state.currentMode = mode; @@ -33,6 +38,10 @@ export const switchMode = (mode: string): void => { updateModeButtons(); }; +/** + * Updates dub/sub button styling based on current mode. + * Disables unavailable modes. + */ export const updateModeButtons = (): void => { const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null; const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null; @@ -51,6 +60,9 @@ export const updateModeButtons = (): void => { sub && (sub.disabled = !state.availableModes.includes('sub')); }; +/** + * Binds click handlers for mode buttons and autoplay toggle. + */ export const setupMode = (): void => { const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null; const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null; diff --git a/static/player/progress.ts b/static/player/progress.ts index 3c65827..b95ab42 100644 --- a/static/player/progress.ts +++ b/static/player/progress.ts @@ -1,6 +1,7 @@ import { state } from './state'; import { displayTimeFromAbsolute } from './timeline'; +// builds JSON payload for progress API const buildPayload = (episode: number, seconds: number) => JSON.stringify({ mal_id: state.malID, @@ -8,18 +9,24 @@ const buildPayload = (episode: number, seconds: number) => time_seconds: seconds, }); +// sends progress via beacon (survives page unload) const sendBeacon = (payload: string) => { if (!navigator.sendBeacon) return false; navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' })); return true; }; +/** + * Saves current progress to backend. + * Debounced: skips if within 5s of last save for same episode. + */ export const saveProgress = async (): Promise => { if (!state.malID || state.video.currentTime < 1) return; const episode = Number.parseInt(state.currentEpisode, 10); if (!episode) return; const safeTime = displayTimeFromAbsolute(state.video.currentTime); + // skip if recently saved if ( state.lastSavedProgress.episode === state.currentEpisode && Math.abs(state.lastSavedProgress.seconds - safeTime) < 5 @@ -38,6 +45,7 @@ export const saveProgress = async (): Promise => { } catch {} }; +// schedules periodic save every 30s during playback const scheduleProgressSave = (): void => { if (state.progressSaveTimer !== undefined) return; state.progressSaveTimer = window.setTimeout(() => { @@ -46,6 +54,10 @@ const scheduleProgressSave = (): void => { }, 30000); }; +/** + * Records episode transition (clicked external link to next episode). + * Uses beacon for reliability on page unload. + */ export const markEpisodeTransition = (episodeNumber: number): void => { if (!state.malID || !episodeNumber) return; if (state.progressSaveTimer !== undefined) { @@ -54,6 +66,7 @@ export const markEpisodeTransition = (episodeNumber: number): void => { } state.transitionEpisode = episodeNumber; const payload = buildPayload(episodeNumber, 0); + // beacon falls back to fetch with keepalive if (!sendBeacon(payload)) { fetch('/api/watch-progress', { method: 'POST', @@ -64,22 +77,29 @@ export const markEpisodeTransition = (episodeNumber: number): void => { } }; +/** + * Sets up progress save on timeupdate, pause, mouseup (scrub end), and beforeunload. + */ export const setupProgress = (): void => { + // periodic save during playback state.video.addEventListener('timeupdate', () => { scheduleProgressSave(); }); + // immediate save on pause state.video.addEventListener('pause', () => { window.clearTimeout(state.progressSaveTimer); state.progressSaveTimer = undefined; saveProgress(); }); + // save after scrubbing window.addEventListener('mouseup', () => { state.isScrubbing = false; saveProgress(); }); + // save on page close window.addEventListener('beforeunload', () => { if (state.transitionEpisode !== null || state.completionSent || !state.malID) return; const episode = Number.parseInt(state.currentEpisode, 10); diff --git a/static/player/quality.ts b/static/player/quality.ts index b3277b3..e41d612 100644 --- a/static/player/quality.ts +++ b/static/player/quality.ts @@ -1,6 +1,7 @@ import { state } from './state'; -import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'; +import { displayTimeFromAbsolute } from './timeline'; +// same as mode.ts - could be extracted to shared util const streamUrlForMode = (mode: string, quality?: string): string => { const src = state.modeSources[mode]; if (!src?.token) return ''; @@ -19,6 +20,10 @@ const loadVideo = (url: string): void => { if (wasPlaying) state.video.play().catch(() => {}); }; +/** + * Switches video quality (resolution). + * Persists preference to localStorage. + */ export const switchQuality = (quality: string): void => { const url = streamUrlForMode(state.currentMode, quality); if (!url) return; @@ -26,6 +31,10 @@ export const switchQuality = (quality: string): void => { loadVideo(url); }; +/** + * Rebuilds quality dropdown options from current mode's available qualities. + * Shows/hides dropdown based on availability. + */ export const updateQualityOptions = (): void => { const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null; if (!select) return; @@ -44,13 +53,18 @@ export const updateQualityOptions = (): void => { select.appendChild(opt); }); + // restore saved preference const preferred = localStorage.getItem('mal:preferred-quality') || 'best'; select.value = qualities.includes(preferred) ? preferred : 'best'; + // hide if no quality options const wrapper = select.parentElement; wrapper?.classList.toggle('hidden', qualities.length === 0); }; +/** + * Binds quality select change handler. + */ export const setupQuality = (): void => { const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null; select?.addEventListener('change', e => { diff --git a/static/player/state.ts b/static/player/state.ts index 9f0b167..4a59095 100644 --- a/static/player/state.ts +++ b/static/player/state.ts @@ -79,7 +79,12 @@ export const state: PlayerState = { videoOverlay: null, }; +/** + * Initializes player state from DOM data attributes. + * Called once on page load or htmx swap. + */ export const initState = (c: HTMLElement): void => { + // core elements state.container = c; state.video = q(c, 'video')!; state.progress = q(c, '[data-progress]'); @@ -91,14 +96,17 @@ export const initState = (c: HTMLElement): void => { state.previewTime = q(c, '[data-preview-time]'); state.videoOverlay = q(c, '[data-video-overlay]'); + // data attributes from server state.malID = Number.parseInt(dataset(c, 'malId'), 10); state.currentEpisode = dataset(c, 'currentEpisode') || '1'; state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10); state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream'; state.initialStreamToken = dataset(c, 'streamToken') || ''; + // from session: previous page set this when autoplay triggered state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true'; sessionStorage.removeItem('mal:autoplay-next'); + // global elements (not inside player container) state.episodeGrid = qs('[data-episode-grid]'); state.episodeList = qs('[data-episode-list]'); @@ -110,9 +118,11 @@ export const initState = (c: HTMLElement): void => { } }; + // mode sources = { sub: { token, subtitles, qualities }, dub: { ... } } state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record); state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[]); + // resolve initial mode: localStorage > backend default > first available > 'dub' const backendInitialMode = dataset(c, 'initialMode') || 'dub'; const storedMode = localStorage.getItem('player-audio-mode'); const initialMode = @@ -122,6 +132,7 @@ export const initState = (c: HTMLElement): void => { ? initialMode : (fallbackMode ?? state.availableModes[0] ?? 'dub'); + // parse skip segments from data attribute const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[]); state.parsedSegments = segments .map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 })) diff --git a/static/player/subtitles/index.ts b/static/player/subtitles/index.ts index b883277..5f994db 100644 --- a/static/player/subtitles/index.ts +++ b/static/player/subtitles/index.ts @@ -2,8 +2,10 @@ import { SubtitleCue, SubtitleTrack } from '../types'; import { state } from '../state'; import { parseVtt } from './vtt'; +// proxy subtitle URL through backend (avoids CORS) const proxyUrl = (token: string) => `/watch/proxy/subtitle?token=${encodeURIComponent(token)}`; +// builds subtitle track list from current mode's source const subtitlesForMode = (): SubtitleTrack[] => { const src = state.modeSources[state.currentMode]; if (!src?.subtitles) return []; @@ -24,6 +26,7 @@ const hideSubtitleText = (): void => { el.classList.add('hidden'); }; +// fetches and parses VTT from proxy URL const loadSubtitle = async (url: string): Promise => { try { const res = await fetch(url); @@ -34,6 +37,10 @@ const loadSubtitle = async (url: string): Promise => { } }; +/** + * Rebuilds subtitle dropdown from current mode's available tracks. + * Shows/hides dropdown based on availability. + */ export const updateSubtitleOptions = (): void => { const select = state.container.querySelector( '[data-subtitle-select]' @@ -61,6 +68,10 @@ export const updateSubtitleOptions = (): void => { hideSubtitleText(); }; +/** + * Updates subtitle text display based on current video time. + * Finds active cue and shows/hides overlay. + */ export const updateSubtitleRender = (time: number): void => { const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null; if (!el) return; @@ -69,6 +80,7 @@ export const updateSubtitleRender = (time: number): void => { return; } + // find cue containing current time const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end); if (!cue) { hideSubtitleText(); @@ -79,6 +91,10 @@ export const updateSubtitleRender = (time: number): void => { el.classList.remove('hidden'); }; +/** + * Binds subtitle select change handler. + * Loads and parses selected VTT track. + */ export const setupSubtitles = (): void => { const select = state.container.querySelector( '[data-subtitle-select]' diff --git a/static/player/subtitles/vtt.ts b/static/player/subtitles/vtt.ts index db296f9..97efece 100644 --- a/static/player/subtitles/vtt.ts +++ b/static/player/subtitles/vtt.ts @@ -1,3 +1,4 @@ +// parses VTT timestamp (mm:ss.ms or hh:mm:ss.ms) to seconds export const parseVttTime = (raw: string): number => { const parts = raw.trim().split(':'); if (parts.length < 2) return 0; @@ -7,15 +8,18 @@ export const parseVttTime = (raw: string): number => { return Number(hourPart) * 3600 + Number(minPart) * 60 + Number(secPart.replace(',', '.')); }; +// parses a single VTT cue: timestamp line + text lines export const parseVttCue = (line: string, lines: string[], i: number) => { if (!line.includes('-->')) return null; const [startRaw, endRaw] = line.split('-->'); const payload: string[] = []; let j = i + 1; + // collect text until blank line while (j < lines.length && lines[j].trim() !== '') { payload.push(lines[j]); j++; } + // strip tags, join lines const text = payload .join('\n') .replace(/<[^>]+>/g, '') @@ -24,17 +28,23 @@ export const parseVttCue = (line: string, lines: string[], i: number) => { return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text }; }; +/** + * Parses full VTT file into cue array. + * Handles both compact (timestamp on separate line) and standard formats. + */ export const parseVtt = (text: string) => { const lines = text.replace(/\r/g, '').split('\n'); const cues = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; + // compact: cue id on line i, timestamp on i+1 if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) { const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1); if (cue) cues.push(cue); i++; } else if (line.includes('-->')) { + // standard: timestamp on same line const cue = parseVttCue(line, lines, i); if (cue) cues.push(cue); } diff --git a/static/player/timeline.ts b/static/player/timeline.ts index 0eb1736..0cf57ab 100644 --- a/static/player/timeline.ts +++ b/static/player/timeline.ts @@ -1,6 +1,7 @@ import { TimelineBounds } from './types'; import { state } from './state'; +// mm:ss formatter const formatTime = (seconds: number): string => { if (!Number.isFinite(seconds) || seconds < 0) return '00:00'; const mins = Math.floor(seconds / 60); @@ -8,12 +9,18 @@ const formatTime = (seconds: number): string => { return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; +// cached to avoid recalc on every timeupdate let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 }; +/** + * Computes timeline bounds from video. + * Handles seekable ranges (live streams) and regular duration. + */ export const timelineBounds = (): TimelineBounds => { const duration = Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0; let start = 0; + // check seekable range for live streams if (state.video.seekable.length > 0) { const seekableStart = state.video.seekable.start(0); if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart; @@ -21,6 +28,7 @@ export const timelineBounds = (): TimelineBounds => { if (duration > start) { return { start, end: duration, duration: duration - start }; } + // fallback to full seekable range if (state.video.seekable.length > 0) { const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1); if (Number.isFinite(seekableEnd) && seekableEnd > start) { @@ -36,27 +44,32 @@ export const invalidateBounds = (): void => { export const getBounds = (): TimelineBounds => cachedBounds; +// converts video.currentTime to timeline-relative time (0-based for UI display) export const displayTimeFromAbsolute = (absoluteTime: number): number => { const b = getBounds(); if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0; return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start; }; +// converts timeline-relative time back to video time export const absoluteTimeFromDisplay = (displayTime: number): number => { const b = getBounds(); if (!Number.isFinite(displayTime) || b.duration <= 0) return 0; return b.start + Math.max(0, Math.min(b.duration, displayTime)); }; +// converts 0-1 ratio to absolute video time export const absoluteTimeFromRatio = (ratio: number): number => { const b = getBounds(); if (!Number.isFinite(ratio) || b.duration <= 0) return 0; return b.start + Math.max(0, Math.min(1, ratio)) * b.duration; }; +// finds the end of the buffered region containing currentTime export const getBufferedEnd = (): number => { const currentTime = state.video.currentTime; let end = 0; + // first: find buffered range that contains current time for (let i = 0; i < state.video.buffered.length; i++) { if ( state.video.buffered.start(i) <= currentTime && @@ -66,6 +79,7 @@ export const getBufferedEnd = (): number => { break; } } + // fallback: next buffered range after current time if (end === 0) { for (let i = 0; i < state.video.buffered.length; i++) { if (state.video.buffered.end(i) > currentTime) { @@ -76,6 +90,10 @@ export const getBufferedEnd = (): number => { return end; }; +/** + * Updates progress bar, scrubber position, and time displays. + * Called on timeupdate, progress events, and seek. + */ export const updateTimeline = (currentTime: number): void => { const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state; const b = getBounds(); @@ -95,6 +113,7 @@ export const updateTimeline = (currentTime: number): void => { timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime)); durationDisplay.textContent = formatTime(b.duration); + // buffered region const bufferedEnd = getBufferedEnd(); const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100; buffered.style.width = `${bufferedPct}%`; diff --git a/static/player/types.ts b/static/player/types.ts index 9d6b4cd..a264097 100644 --- a/static/player/types.ts +++ b/static/player/types.ts @@ -1,38 +1,45 @@ +// stream source for a single mode (sub/dub) export interface ModeSource { token: string; subtitles: SubtitleItem[]; qualities?: string[]; } +// subtitle track from backend export interface SubtitleItem { lang: string; token: string; } +// skip segment (intro/outro) from backend data attribute export interface SkipSegment { - type: string; + type: string; // 'op' or 'ed' start: number; end: number; } +// parsed subtitle cue from VTT export interface SubtitleCue { start: number; end: number; text: string; } +// loaded subtitle track for UI export interface SubtitleTrack { lang: string; label: string; url: string; } +// validated skip segment within video bounds export interface ActiveSegment { type: string; start: number; end: number; } +// timeline range (handles seekable ranges in live streams) export interface TimelineBounds { start: number; end: number; diff --git a/static/q.ts b/static/q.ts index 75c6699..3989724 100644 --- a/static/q.ts +++ b/static/q.ts @@ -1,7 +1,16 @@ +/** + * querySelector on a container element, scoped to that element. + */ export const q = (container: HTMLElement, selector: string): T | null => container.querySelector(selector) as T | null; +/** + * querySelector on the document. + */ export const qs = (selector: string): T | null => document.querySelector(selector) as T | null; +/** + * Get a data attribute value from an element, defaults to empty string. + */ export const dataset = (el: HTMLElement, key: string): string => el.dataset[key] ?? ''; diff --git a/static/search.ts b/static/search.ts index c85ce5b..be2ea01 100644 --- a/static/search.ts +++ b/static/search.ts @@ -1,5 +1,3 @@ -export {}; - type QuickSearchResult = { id?: number; image?: string; @@ -7,6 +5,7 @@ type QuickSearchResult = { type?: string; }; +// singleton flag to prevent double init (e.g. htmx swaps) const searchInitializedKey = Symbol('searchInitialized'); const globalWindow = window as Window & { [searchInitializedKey]?: boolean }; @@ -23,6 +22,7 @@ const isSafeImageUrl = (rawUrl?: string): boolean => { try { const parsed = new URL(rawUrl, window.location.origin); + // block data: URIs and other potentially dangerous protocols return parsed.protocol === 'https:' || parsed.protocol === 'http:'; } catch { return false; @@ -139,6 +139,7 @@ const onSearchInput = (event: Event): void => { }; const onSearchBlur = (): void => { + // delay to allow clicking on results before clearing window.setTimeout(() => { clearSearchResults(); }, 200); diff --git a/static/sort_filter.ts b/static/sort_filter.ts index 9c80a57..c41017f 100644 --- a/static/sort_filter.ts +++ b/static/sort_filter.ts @@ -7,6 +7,7 @@ const initSortFilter = (): void => { if (form) form.submit(); }; + // sync select values to hidden inputs, then submit form sortSelect?.addEventListener('change', () => { const input = document.getElementById('sort-input') as HTMLInputElement | null; if (input) input.value = sortSelect.value; diff --git a/static/theme.ts b/static/theme.ts index 9298075..61bbc6c 100644 --- a/static/theme.ts +++ b/static/theme.ts @@ -7,7 +7,7 @@ const getSavedTheme = (): Theme => { if (raw === 'light' || raw === 'dark') { return raw; } - return 'dark'; + return 'dark'; // default to dark }; const applyTheme = (theme: Theme): void => { @@ -25,6 +25,7 @@ const initTheme = (): void => { const saved = getSavedTheme(); applyTheme(saved); + // delegated click handler on theme buttons document.addEventListener('click', e => { const target = e.target as HTMLElement; const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null; diff --git a/static/timezone.ts b/static/timezone.ts index 60f7f28..6acd2c7 100644 --- a/static/timezone.ts +++ b/static/timezone.ts @@ -1,5 +1,6 @@ export {}; +// JST offset from UTC in minutes (UTC+9) const jstOffsetMinutes = 9 * 60; type ParsedBroadcast = { @@ -20,6 +21,7 @@ const parseBroadcastTime = (value: string | null): { hour: number; minute: numbe const hour = Number.parseInt(match[1], 10); const minute = Number.parseInt(match[2], 10); + // validate ranges if ( Number.isNaN(hour) || Number.isNaN(minute) || @@ -36,7 +38,7 @@ const parseBroadcastTime = (value: string | null): { hour: number; minute: numbe const isJstTimezone = (timezone: string | null): boolean => { if (!timezone) { - return true; + return true; // treat missing timezone as JST (anime default) } const normalized = timezone.trim().toLowerCase(); @@ -65,6 +67,7 @@ const parseBroadcast = (text: string | null): ParsedBroadcast | null => { return null; } + // matches "Monday at 00:00 (JST)" format const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i); if (!match) { return null; @@ -86,6 +89,7 @@ const parseBroadcast = (text: string | null): ParsedBroadcast | null => { }; const normalizeDay = (day: string): number | null => { + // strip trailing 's' for plural forms, then lookup const key = day.trim().toLowerCase().replace(/s$/, ''); const days: Record = { mon: 1, @@ -116,11 +120,11 @@ const normalizeDay = (day: string): number | null => { const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): string | null => { const sourceMinutes = parsed.hour * 60 + parsed.minute; - const diff = jstOffsetMinutes - localOffsetMinutes; + const diff = jstOffsetMinutes - localOffsetMinutes; // JST ahead of local const localTotal = sourceMinutes - diff; const dayShift = Math.floor(localTotal / 1440); - const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440; + const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440; // handle negatives const localHour = Math.floor(normalizedMinutes / 60); const localMinute = normalizedMinutes % 60; @@ -129,7 +133,7 @@ const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): st return null; } - const localDayIndex = (((sourceDayIndex + dayShift) % 7) + 7) % 7; + const localDayIndex = (((sourceDayIndex + dayShift) % 7) + 7) % 7; // proper modulo const localDay = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][ localDayIndex ]; @@ -144,6 +148,7 @@ const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => { return null; } + // convert local time to JST to compare against JST now const now = new Date(); const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000); @@ -152,6 +157,7 @@ const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => { const targetMinuteOfDay = parsed.hour * 60 + parsed.minute; let dayDelta = (targetDay - currentDay + 7) % 7; + // if same day but time has passed, schedule for next week if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) { dayDelta = 7; } @@ -161,11 +167,13 @@ const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => { }; const formatRelative = (value: number, unit: Intl.RelativeTimeFormatUnit): string => { + // Intl.RelativeTimeFormat not available in all environments if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') { const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); return formatter.format(value, unit); } + // fallback: "in X minutes/hours/days" const suffix = value === 1 ? unit : `${unit}s`; return `in ${value} ${suffix}`; }; @@ -223,6 +231,7 @@ const updateNode = (node: Element, localOffsetMinutes: number): void => { const card = node.closest('[data-notification-content]'); const nextNode = card ? card.querySelector('[data-next-airing]') : null; + // try structured attrs first, fall back to text parsing const structured = parseFromStructuredAttrs(node); const source = node.getAttribute('data-jst-text'); const parsed = structured || parseBroadcast(source); @@ -252,6 +261,7 @@ const updateAll = (): void => { }; const initTimezoneConversion = (): void => { + // run on initial load and after htmx swaps (content may change) document.addEventListener('DOMContentLoaded', updateAll); document.body.addEventListener('htmx:afterSwap', updateAll); }; diff --git a/static/toast.ts b/static/toast.ts index f7e0b55..9043356 100644 --- a/static/toast.ts +++ b/static/toast.ts @@ -5,6 +5,7 @@ interface ToastOptions { duration?: number; } +/** Lazily create and return the toast container element. */ const toastContainer = (): HTMLElement => { let container = document.getElementById('toast-container'); if (!container) { @@ -16,6 +17,10 @@ const toastContainer = (): HTMLElement => { return container; }; +/** + * Show a toast notification with optional auto-dismiss. + * Exposed globally via window.showToast. + */ const showToast = ({ message, duration = 3000 }: ToastOptions): void => { const container = toastContainer(); const template = document.getElementById('toast-template') as HTMLTemplateElement | null; @@ -36,10 +41,12 @@ const showToast = ({ message, duration = 3000 }: ToastOptions): void => { container.appendChild(toast); + // trigger entrance animation requestAnimationFrame(() => { toast.classList.remove('translate-y-2', 'opacity-0'); }); + // auto-dismiss with exit animation setTimeout(() => { toast.classList.add('translate-y-2', 'opacity-0'); setTimeout(() => toast.remove(), 300); diff --git a/static/utils.ts b/static/utils.ts index d4f3e88..ce7ca15 100644 --- a/static/utils.ts +++ b/static/utils.ts @@ -1,3 +1,6 @@ +/** + * Parse a space-separated class list string into an array, filtering empty entries. + */ export const parseClassList = (value: string | null): string[] => { if (!value) { return []; diff --git a/templates/base.gohtml b/templates/base.gohtml index 0d8a314..7af7995 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -3,6 +3,7 @@ + {{/* page title injected from child template */}} MyAnimeList: {{template "title" .}} @@ -64,9 +65,10 @@ });