Compare commits

..

10 Commits

29 changed files with 598 additions and 233 deletions

View File

@@ -29,6 +29,11 @@ type Client struct {
lastReqTime time.Time // rate limiting: last request timestamp lastReqTime time.Time // rate limiting: last request timestamp
sf singleflight.Group sf singleflight.Group
refreshSem chan struct{} refreshSem chan struct{}
// Random anime pool for DDoS-proof truly random "Surprise Me"
randomPool []Anime
poolMu sync.RWMutex
poolInitialized bool
} }
const jikanSlowLogThreshold = 750 * time.Millisecond const jikanSlowLogThreshold = 750 * time.Millisecond
@@ -48,6 +53,7 @@ func NewClient(queries *db.Queries) *Client {
db: queries, db: queries,
retrySignal: make(chan struct{}, 1), retrySignal: make(chan struct{}, 1),
refreshSem: make(chan struct{}, 4), refreshSem: make(chan struct{}, 4),
randomPool: make([]Anime, 0),
} }
} }

View File

@@ -2,7 +2,10 @@ package jikan
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"math/rand"
"time"
) )
type ScheduleResult struct { type ScheduleResult struct {
@@ -52,20 +55,148 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu
}, nil }, nil
} }
// seedRandomPool seeds the in-memory pool of random anime
func (c *Client) seedRandomPool(ctx context.Context) error {
c.poolMu.Lock()
if c.poolInitialized {
c.poolMu.Unlock()
return nil
}
c.poolInitialized = true
c.poolMu.Unlock()
// 1. Try to load all cached anime from the database
cachedJSONs, err := c.db.GetAllCachedAnime(ctx)
if err == nil && len(cachedJSONs) > 0 {
var loadedAnimes []Anime
for _, dataStr := range cachedJSONs {
var anime Anime
if err := json.Unmarshal([]byte(dataStr), &anime); err == nil && anime.MalID > 0 {
loadedAnimes = append(loadedAnimes, anime)
}
}
if len(loadedAnimes) > 0 {
c.poolMu.Lock()
c.randomPool = append(c.randomPool, loadedAnimes...)
c.poolMu.Unlock()
}
}
// 2. Fetch Top Anime page 1 & 2 to ensure we have a robust baseline of high-quality popular anime
go func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var fetchedAnimes []Anime
top, err := c.GetTopAnime(bgCtx, 1)
if err == nil && len(top.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, top.Animes...)
}
top2, err := c.GetTopAnime(bgCtx, 2)
if err == nil && len(top2.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, top2.Animes...)
}
now, err := c.GetSeasonsNow(bgCtx, 1)
if err == nil && len(now.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, now.Animes...)
}
if len(fetchedAnimes) > 0 {
c.poolMu.Lock()
// Use map to de-duplicate any anime
seen := make(map[int]bool)
for _, a := range c.randomPool {
seen[a.MalID] = true
}
for _, a := range fetchedAnimes {
if !seen[a.MalID] {
c.randomPool = append(c.randomPool, a)
seen[a.MalID] = true
}
}
c.poolMu.Unlock()
}
// Start background refresher once seeding completes
c.startPoolRefresher()
}()
return nil
}
// startPoolRefresher runs in the background to slowly mix in true random anime
func (c *Client) startPoolRefresher() {
ticker := time.NewTicker(30 * time.Second)
ctx := context.Background()
for range ticker.C {
var result struct {
Data Anime `json:"data"`
}
reqURL := fmt.Sprintf("%s/random/anime", c.baseURL)
err := c.fetchWithRetry(ctx, reqURL, &result)
if err != nil {
continue
}
if result.Data.MalID == 0 {
continue
}
c.poolMu.Lock()
if len(c.randomPool) >= 1000 {
idx := rand.Intn(len(c.randomPool))
c.randomPool[idx] = result.Data
} else {
duplicate := false
for _, a := range c.randomPool {
if a.MalID == result.Data.MalID {
duplicate = true
break
}
}
if !duplicate {
c.randomPool = append(c.randomPool, result.Data)
}
}
c.poolMu.Unlock()
}
}
// GetRandomAnime returns a random anime from the database. // GetRandomAnime returns a random anime from the database.
func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) { func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
var result struct { c.poolMu.Lock()
Data Anime `json:"data"` initialized := c.poolInitialized
c.poolMu.Unlock()
if !initialized {
_ = c.seedRandomPool(ctx)
} }
reqURL := fmt.Sprintf("%s/random/anime", c.baseURL) c.poolMu.RLock()
err := c.fetchWithRetry(ctx, reqURL, &result) defer c.poolMu.RUnlock()
if err != nil {
return Anime{}, err if len(c.randomPool) == 0 {
} var result struct {
if result.Data.MalID == 0 { Data Anime `json:"data"`
return Anime{}, fmt.Errorf("jikan: empty response for random/anime") }
reqURL := fmt.Sprintf("%s/random/anime", c.baseURL)
err := c.fetchWithRetry(ctx, reqURL, &result)
if err != nil {
return Anime{}, err
}
if result.Data.MalID == 0 {
return Anime{}, fmt.Errorf("jikan: empty response for random/anime")
}
return result.Data, nil
} }
return result.Data, nil idx := rand.Intn(len(c.randomPool))
return c.randomPool[idx], nil
} }

View File

@@ -27,7 +27,7 @@ func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistServi
} }
} }
func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int]bool { func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int64]bool {
animeIDs := make([]int64, 0, len(animes)) animeIDs := make([]int64, 0, len(animes))
for _, anime := range animes { for _, anime := range animes {
if anime.MalID > 0 { if anime.MalID > 0 {
@@ -37,14 +37,14 @@ func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string,
return h.watchlistMapForIDs(ctx, userID, animeIDs) return h.watchlistMapForIDs(ctx, userID, animeIDs)
} }
func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int]bool { func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int64]bool {
if userID == "" || len(animeIDs) == 0 { if userID == "" || len(animeIDs) == 0 {
return map[int]bool{} return map[int64]bool{}
} }
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs) watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
if err != nil { if err != nil {
return map[int]bool{} return map[int64]bool{}
} }
return watchlistMap return watchlistMap
} }
@@ -74,7 +74,7 @@ func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
c.HTML(http.StatusOK, "index.gohtml", gin.H{ c.HTML(http.StatusOK, "index.gohtml", gin.H{
"CurrentPath": "/", "CurrentPath": "/",
"User": user, "User": user,
"WatchlistMap": map[int]bool{}, "WatchlistMap": map[int64]bool{},
}) })
} }
@@ -101,14 +101,11 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
return return
} }
watchlistMap := map[int]bool{} watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
if animes, ok := data["Animes"].([]domain.Anime); ok {
watchlistMap = h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
}
data["Section"] = section data.Section = section
data["_fragment"] = "catalog_section" data.Fragment = "catalog_section"
data["WatchlistMap"] = watchlistMap data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "index.gohtml", data) c.HTML(http.StatusOK, "index.gohtml", data)
} }
@@ -143,14 +140,11 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
return return
} }
watchlistMap := map[int]bool{} watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
if animes, ok := data["Animes"].([]domain.Anime); ok {
watchlistMap = h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
}
data["Section"] = section data.Section = section
data["_fragment"] = "discover_section" data.Fragment = "discover_section"
data["WatchlistMap"] = watchlistMap data.WatchlistMap = watchlistMap
c.HTML(http.StatusOK, "discover.gohtml", data) c.HTML(http.StatusOK, "discover.gohtml", data)
} }
@@ -401,7 +395,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
Type: anime.Type, Type: anime.Type,
Year: anime.Year, Year: anime.Year,
Image: anime.ImageURL(), Image: anime.ImageURL(),
InWatchlist: watchlistMap[anime.MalID], InWatchlist: watchlistMap[int64(anime.MalID)],
} }
} }
c.JSON(http.StatusOK, output) c.JSON(http.StatusOK, output)
@@ -609,7 +603,7 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
inWatchlist := false inWatchlist := false
if u, ok := user.(*domain.User); ok { if u, ok := user.(*domain.User); ok {
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), u.ID, []int64{int64(anime.MalID)}) watchlistMap := h.watchlistMapForIDs(c.Request.Context(), u.ID, []int64{int64(anime.MalID)})
inWatchlist = watchlistMap[anime.MalID] inWatchlist = watchlistMap[int64(anime.MalID)]
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{

View File

@@ -20,7 +20,7 @@ func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.An
return &animeService{jikan: jikan, repo: repo} return &animeService{jikan: jikan, repo: repo}
} }
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) { func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
var ( var (
res jikan.TopAnimeResult res jikan.TopAnimeResult
cw []db.GetContinueWatchingEntriesRow cw []db.GetContinueWatchingEntriesRow
@@ -48,7 +48,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
} }
if err := g.Wait(); err != nil { if err := g.Wait(); err != nil {
return nil, err return domain.CatalogSectionData{}, err
} }
animes := res.Animes animes := res.Animes
@@ -56,13 +56,13 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
animes = animes[:6] animes = animes[:6]
} }
return map[string]any{ return domain.CatalogSectionData{
"Animes": animes, Animes: animes,
"ContinueWatching": cw, ContinueWatching: cw,
}, nil }, nil
} }
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) { func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
var res jikan.TopAnimeResult var res jikan.TopAnimeResult
g, gCtx := errgroup.WithContext(ctx) g, gCtx := errgroup.WithContext(ctx)
@@ -81,7 +81,7 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
}) })
if err := g.Wait(); err != nil { if err := g.Wait(); err != nil {
return nil, err return domain.DiscoverSectionData{}, err
} }
animes := res.Animes animes := res.Animes
@@ -89,8 +89,8 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
animes = animes[:8] animes = animes[:8]
} }
return map[string]any{ return domain.DiscoverSectionData{
"Animes": animes, Animes: animes,
}, nil }, nil
} }
@@ -160,7 +160,8 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
if fallbackErr != nil || len(res.Animes) == 0 { if fallbackErr != nil || len(res.Animes) == 0 {
continue continue
} }
return res.Animes[rand.Intn(len(res.Animes))], nil r := rand.New(rand.NewSource(time.Now().UnixNano()))
return res.Animes[r.Intn(len(res.Animes))], nil
} }
return domain.Anime{}, err return domain.Anime{}, err

View File

@@ -3,7 +3,6 @@ package handler
import ( import (
"mal/internal/domain" "mal/internal/domain"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -42,7 +41,7 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) {
return return
} }
c.SetCookie("session_id", session.ID, int(24*time.Hour.Seconds()), "/", "", false, true) c.SetCookie("session_id", session.ID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
if c.GetHeader("HX-Request") == "true" { if c.GetHeader("HX-Request") == "true" {
c.Header("HX-Redirect", "/") c.Header("HX-Redirect", "/")
c.Status(http.StatusOK) c.Status(http.StatusOK)

View File

@@ -23,6 +23,8 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
var user *domain.User var user *domain.User
var err error var err error
var sessionID string
var usesCookieSession bool
// API routes can authenticate via Bearer token OR cookie session. // API routes can authenticate via Bearer token OR cookie session.
if strings.HasPrefix(path, "/api/") { if strings.HasPrefix(path, "/api/") {
@@ -30,7 +32,9 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token := strings.TrimSpace(authHeader[7:]) token := strings.TrimSpace(authHeader[7:])
user, err = svc.ValidateAPIToken(c.Request.Context(), token) user, err = svc.ValidateAPIToken(c.Request.Context(), token)
} else if sessionID, cookieErr := c.Cookie("session_id"); cookieErr == nil { } else if cookieSessionID, cookieErr := c.Cookie("session_id"); cookieErr == nil {
sessionID = cookieSessionID
usesCookieSession = true
user, err = svc.ValidateSession(c.Request.Context(), sessionID) user, err = svc.ValidateSession(c.Request.Context(), sessionID)
} else { } else {
err = cookieErr err = cookieErr
@@ -43,13 +47,15 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
} }
} else { } else {
// Non-API routes only use cookie sessions and redirect to /login. // Non-API routes only use cookie sessions and redirect to /login.
sessionID, cookieErr := c.Cookie("session_id") cookieSessionID, cookieErr := c.Cookie("session_id")
if cookieErr != nil { if cookieErr != nil {
c.Redirect(http.StatusSeeOther, "/login") c.Redirect(http.StatusSeeOther, "/login")
c.Abort() c.Abort()
return return
} }
sessionID = cookieSessionID
usesCookieSession = true
user, err = svc.ValidateSession(c.Request.Context(), sessionID) user, err = svc.ValidateSession(c.Request.Context(), sessionID)
if err != nil || user == nil { if err != nil || user == nil {
c.Redirect(http.StatusSeeOther, "/login") c.Redirect(http.StatusSeeOther, "/login")
@@ -58,6 +64,12 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
} }
} }
if usesCookieSession {
if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil {
c.SetCookie("session_id", sessionID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
}
}
c.Set("User", user) c.Set("User", user)
c.Next() c.Next()
} }

View File

@@ -45,7 +45,7 @@ func (r *authRepository) CreateSession(ctx context.Context, userID string, sessi
s, err := r.queries.CreateSession(ctx, db.CreateSessionParams{ s, err := r.queries.CreateSession(ctx, db.CreateSessionParams{
ID: sessionID, ID: sessionID,
UserID: userID, UserID: userID,
ExpiresAt: time.Now().Add(24 * time.Hour), ExpiresAt: time.Now().Add(domain.SessionLifetime),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -64,6 +64,13 @@ func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*dom
return &s, nil return &s, nil
} }
func (r *authRepository) RefreshSession(ctx context.Context, sessionID string, expiresAt time.Time) error {
return r.queries.RefreshSession(ctx, db.RefreshSessionParams{
ExpiresAt: expiresAt,
ID: sessionID,
})
}
func (r *authRepository) DeleteSession(ctx context.Context, sessionID string) error { func (r *authRepository) DeleteSession(ctx context.Context, sessionID string) error {
return r.queries.DeleteSession(ctx, sessionID) return r.queries.DeleteSession(ctx, sessionID)
} }

View File

@@ -83,6 +83,14 @@ func (s *authService) ValidateSession(ctx context.Context, sessionID string) (*d
return s.repo.GetUserByID(ctx, session.UserID) return s.repo.GetUserByID(ctx, session.UserID)
} }
func (s *authService) RefreshSession(ctx context.Context, sessionID string) error {
if strings.TrimSpace(sessionID) == "" {
return errors.New("session id missing")
}
return s.repo.RefreshSession(ctx, sessionID, time.Now().Add(domain.SessionLifetime))
}
func (s *authService) ValidateAPIToken(ctx context.Context, token string) (*domain.User, error) { func (s *authService) ValidateAPIToken(ctx context.Context, token string) (*domain.User, error) {
trimmed := strings.TrimSpace(token) trimmed := strings.TrimSpace(token)
if trimmed == "" { if trimmed == "" {

View File

@@ -14,7 +14,12 @@ ON continue_watching_entry(anime_id);
CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at_datetime CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at_datetime
ON jikan_cache(datetime(expires_at)); ON jikan_cache(datetime(expires_at));
DROP INDEX IF EXISTS idx_jikan_cache_expires_at;
-- +goose Down -- +goose Down
CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at
ON jikan_cache(expires_at);
DROP INDEX IF EXISTS idx_jikan_cache_expires_at_datetime; DROP INDEX IF EXISTS idx_jikan_cache_expires_at_datetime;
DROP INDEX IF EXISTS idx_continue_watching_anime_id; DROP INDEX IF EXISTS idx_continue_watching_anime_id;
DROP INDEX IF EXISTS idx_watch_list_entry_status_updated_at_anime_id; DROP INDEX IF EXISTS idx_watch_list_entry_status_updated_at_anime_id;

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
package db package db

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
package db package db
@@ -93,6 +93,18 @@ type Session struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type SkipSegmentOverride struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Episode int64 `json:"episode"`
SkipType string `json:"skip_type"`
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
package db package db
@@ -19,6 +19,7 @@ type Querier interface {
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error
GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiToken, error) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiToken, error)
GetAllCachedAnime(ctx context.Context) ([]string, error)
GetAnime(ctx context.Context, id int64) (Anime, error) GetAnime(ctx context.Context, id int64) (Anime, error)
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error) GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)

View File

@@ -15,6 +15,11 @@ SELECT * FROM session WHERE id = ? LIMIT 1;
-- name: DeleteSession :exec -- name: DeleteSession :exec
DELETE FROM session WHERE id = ?; DELETE FROM session WHERE id = ?;
-- name: RefreshSession :exec
UPDATE session
SET expires_at = ?
WHERE id = ?;
-- name: CreateAPIToken :one -- name: CreateAPIToken :one
INSERT INTO api_token (id, user_id, token_hash, name) INSERT INTO api_token (id, user_id, token_hash, name)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
@@ -337,3 +342,7 @@ LEFT JOIN episode_availability_cache e ON e.anime_id = tracked.anime_id
WHERE e.anime_id IS NULL OR e.next_refresh_at IS NULL OR e.next_refresh_at <= CURRENT_TIMESTAMP WHERE e.anime_id IS NULL OR e.next_refresh_at IS NULL OR e.next_refresh_at <= CURRENT_TIMESTAMP
ORDER BY tracked.anime_id ORDER BY tracked.anime_id
LIMIT ?; LIMIT ?;
-- name: GetAllCachedAnime :many
SELECT data FROM jikan_cache
WHERE key LIKE 'anime:%' LIMIT 1000;

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
// source: queries.sql // source: queries.sql
package db package db
@@ -124,6 +124,22 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
return err return err
} }
const refreshSession = `-- name: RefreshSession :exec
UPDATE session
SET expires_at = ?
WHERE id = ?
`
type RefreshSessionParams struct {
ExpiresAt time.Time `json:"expires_at"`
ID string `json:"id"`
}
func (q *Queries) RefreshSession(ctx context.Context, arg RefreshSessionParams) error {
_, err := q.db.ExecContext(ctx, refreshSession, arg.ExpiresAt, arg.ID)
return err
}
const deleteWatchListEntry = `-- name: DeleteWatchListEntry :exec const deleteWatchListEntry = `-- name: DeleteWatchListEntry :exec
DELETE FROM watch_list_entry DELETE FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? WHERE user_id = ? AND anime_id = ?
@@ -182,6 +198,34 @@ func (q *Queries) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiT
return i, err return i, err
} }
const getAllCachedAnime = `-- name: GetAllCachedAnime :many
SELECT data FROM jikan_cache
WHERE key LIKE 'anime:%' LIMIT 1000
`
func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getAllCachedAnime)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var data string
if err := rows.Scan(&data); err != nil {
return nil, err
}
items = append(items, data)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAnime = `-- name: GetAnime :one const getAnime = `-- name: GetAnime :one
SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing, status, relations_synced_at, duration_seconds FROM anime WHERE id = ? LIMIT 1 SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing, status, relations_synced_at, duration_seconds FROM anime WHERE id = ? LIMIT 1
` `

View File

@@ -17,8 +17,8 @@ type ThemesData = jikan.ThemesData
type ReviewEntry = jikan.ReviewEntry type ReviewEntry = jikan.ReviewEntry
type AnimeService interface { type AnimeService interface {
GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
GetAnimeByID(ctx context.Context, id int) (Anime, error) GetAnimeByID(ctx context.Context, id int) (Anime, error)
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetGenres(ctx context.Context) ([]Genre, error) GetGenres(ctx context.Context) ([]Genre, error)
@@ -34,6 +34,29 @@ type AnimeService interface {
GetReviews(ctx context.Context, id int, page int) ([]ReviewEntry, bool, error) GetReviews(ctx context.Context, id int, page int) ([]ReviewEntry, bool, error)
} }
type CatalogSectionData struct {
Animes []Anime
ContinueWatching []db.GetContinueWatchingEntriesRow
Section string
WatchlistMap map[int64]bool
Fragment string
}
func (d CatalogSectionData) TemplateFragment() string {
return d.Fragment
}
type DiscoverSectionData struct {
Animes []Anime
Section string
WatchlistMap map[int64]bool
Fragment string
}
func (d DiscoverSectionData) TemplateFragment() string {
return d.Fragment
}
type AnimeRepository interface { type AnimeRepository interface {
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)

View File

@@ -3,16 +3,20 @@ package domain
import ( import (
"context" "context"
"mal/internal/db" "mal/internal/db"
"time"
) )
type User = db.User type User = db.User
type Session = db.Session type Session = db.Session
type APIToken = db.ApiToken type APIToken = db.ApiToken
const SessionLifetime = 90 * 24 * time.Hour
type AuthService interface { type AuthService interface {
Login(ctx context.Context, username, password string) (*Session, error) Login(ctx context.Context, username, password string) (*Session, error)
LoginForAPIToken(ctx context.Context, username, password, name string) (token string, user *User, err error) LoginForAPIToken(ctx context.Context, username, password, name string) (token string, user *User, err error)
ValidateSession(ctx context.Context, sessionID string) (*User, error) ValidateSession(ctx context.Context, sessionID string) (*User, error)
RefreshSession(ctx context.Context, sessionID string) error
ValidateAPIToken(ctx context.Context, token string) (*User, error) ValidateAPIToken(ctx context.Context, token string) (*User, error)
Logout(ctx context.Context, sessionID string) error Logout(ctx context.Context, sessionID string) error
RevokeAllAPITokensForUser(ctx context.Context, userID string) error RevokeAllAPITokensForUser(ctx context.Context, userID string) error
@@ -23,6 +27,7 @@ type AuthRepository interface {
GetUserByID(ctx context.Context, id string) (*User, error) GetUserByID(ctx context.Context, id string) (*User, error)
CreateSession(ctx context.Context, userID string, sessionID string) (*Session, error) CreateSession(ctx context.Context, userID string, sessionID string) (*Session, error)
GetSession(ctx context.Context, sessionID string) (*Session, error) GetSession(ctx context.Context, sessionID string) (*Session, error)
RefreshSession(ctx context.Context, sessionID string, expiresAt time.Time) error
DeleteSession(ctx context.Context, sessionID string) error DeleteSession(ctx context.Context, sessionID string) error
CreateAPIToken(ctx context.Context, userID, tokenHash, name string) (*APIToken, error) CreateAPIToken(ctx context.Context, userID, tokenHash, name string) (*APIToken, error)
GetAPITokenByHash(ctx context.Context, tokenHash string) (*APIToken, error) GetAPITokenByHash(ctx context.Context, tokenHash string) (*APIToken, error)

View File

@@ -6,13 +6,69 @@ import (
) )
type PlaybackService interface { type PlaybackService interface {
BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error)
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error
CompleteAnime(ctx context.Context, userID string, animeID int64) error CompleteAnime(ctx context.Context, userID string, animeID int64) error
ResolveProxyToken(token string) (string, string, error) ResolveProxyToken(token string) (string, string, error)
UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error
} }
type WatchPageData struct {
WatchData WatchData
Anime Anime
Episodes []CanonicalEpisode
CurrentEpID string
WatchlistStatus string
WatchlistIDs []int64
Seasons []SeasonEntry
User *User
CurrentPath string
Error string
}
type WatchData struct {
MalID int
Title string
CurrentEpisode string
StartTimeSeconds float64
Episodes []CanonicalEpisode
Providers []ProviderData
ModeSources map[string]ModeSource
InitialMode string
ModeSwitchedFrom string
AvailableModes []string
Segments []SkipSegment
}
type SubtitleItem struct {
Lang string `json:"lang"`
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
}
type ModeSource struct {
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
Subtitles []SubtitleItem `json:"subtitles"`
Qualities []string `json:"qualities,omitempty"`
}
type SeasonEntry struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
Prefix string `json:"prefix"`
IsCurrent bool `json:"is_current"`
}
type SkipSegment struct {
Type string `json:"type"`
Start float64 `json:"start"`
End float64 `json:"end"`
Source string `json:"source,omitempty"`
}
type ProviderStream struct { type ProviderStream struct {
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`

View File

@@ -12,7 +12,7 @@ type WatchlistService interface {
UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error
RemoveEntry(ctx context.Context, userID string, animeID int64) error RemoveEntry(ctx context.Context, userID string, animeID int64) error
GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error) GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error)
GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int]bool, error) GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int64]bool, error)
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]UserWatchListRow, error) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]UserWatchListRow, error)
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error) GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error)

View File

@@ -7,7 +7,6 @@ import (
"mal/pkg/net/limits" "mal/pkg/net/limits"
"mal/pkg/net/proxytransport" "mal/pkg/net/proxytransport"
"mal/pkg/net/useragent" "mal/pkg/net/useragent"
"maps"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -61,26 +60,25 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID)
if err != nil { if err != nil {
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id) anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
c.HTML(http.StatusOK, "watch.gohtml", gin.H{ c.HTML(http.StatusOK, "watch.gohtml", domain.WatchPageData{
"Error": err.Error(), Error: err.Error(),
"Anime": anime, Anime: anime,
"Episodes": []domain.EpisodeData{}, Episodes: []domain.CanonicalEpisode{},
"CurrentPath": c.Request.URL.Path, CurrentPath: c.Request.URL.Path,
"User": user, User: currentUser(user),
"CurrentEpID": ep, CurrentEpID: ep,
"WatchData": map[string]any{"Episodes": []domain.EpisodeData{}, "Providers": []any{}}, WatchData: domain.WatchData{
Episodes: []domain.CanonicalEpisode{},
Providers: []domain.ProviderData{},
},
}) })
return return
} }
// Merge data from service with handler-specific context data.User = currentUser(user)
responseData := gin.H{ data.CurrentPath = c.Request.URL.Path
"User": user,
"CurrentPath": c.Request.URL.Path,
}
maps.Copy(responseData, data)
c.HTML(http.StatusOK, "watch.gohtml", responseData) c.HTML(http.StatusOK, "watch.gohtml", data)
} }
// HandleEpisodeData returns the minimal payload needed to advance to the next // HandleEpisodeData returns the minimal payload needed to advance to the next
@@ -112,45 +110,47 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
return return
} }
watchData, _ := data["WatchData"].(map[string]any) watchData := data.WatchData
if watchData == nil {
c.Status(http.StatusInternalServerError)
return
}
modeSources := watchData["ModeSources"]
availableModes, _ := watchData["AvailableModes"].([]string)
segments := watchData["Segments"]
// Try to resolve a title for this episode from the episode list. // Try to resolve a title for this episode from the episode list.
episodeTitle := "" episodeTitle := ""
if eps, ok := watchData["Episodes"].([]domain.CanonicalEpisode); ok { epNum, _ := strconv.Atoi(episode)
epNum, _ := strconv.Atoi(episode) for _, e := range watchData.Episodes {
for _, e := range eps { if e.Number == epNum {
if e.Number == epNum { episodeTitle = e.Title
episodeTitle = e.Title break
break
}
} }
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"mode_sources": modeSources, "mode_sources": watchData.ModeSources,
"available_modes": availableModes, "available_modes": watchData.AvailableModes,
"initial_mode": watchData["InitialMode"], "initial_mode": watchData.InitialMode,
"start_time_seconds": watchData["StartTimeSeconds"], "start_time_seconds": watchData.StartTimeSeconds,
"segments": segments, "segments": watchData.Segments,
"episode_title": episodeTitle, "episode_title": episodeTitle,
"mode_switched_from": watchData["ModeSwitchedFrom"], "mode_switched_from": watchData.ModeSwitchedFrom,
}) })
} }
func currentUser(value any) *domain.User {
if user, ok := value.(*domain.User); ok {
return user
}
return nil
}
func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
user, _ := c.Get("User") user, _ := c.Get("User")
userID := "" userID := ""
if u, ok := user.(*domain.User); ok { if u, ok := user.(*domain.User); ok {
userID = u.ID userID = u.ID
} }
if userID == "" {
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
c.Status(http.StatusUnauthorized)
return
}
var req struct { var req struct {
MalID int64 `json:"mal_id"` MalID int64 `json:"mal_id"`
@@ -205,7 +205,7 @@ func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
userID = u.ID userID = u.ID
} }
if userID == "" { if userID == "" {
c.Status(http.StatusUnauthorized) c.JSON(http.StatusUnauthorized, gin.H{"error": "login required"})
return return
} }
@@ -217,12 +217,12 @@ func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
EndTime float64 `json:"end_time"` EndTime float64 `json:"end_time"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.Status(http.StatusBadRequest) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return return
} }
if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil { if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil {
c.Status(http.StatusBadRequest) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }

View File

@@ -33,12 +33,6 @@ type playbackService struct {
proxyTokenKey string proxyTokenKey string
} }
type SkipSegment struct {
Type string `json:"type"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
type proxyTokenPayload struct { type proxyTokenPayload struct {
TargetURL string `json:"u"` TargetURL string `json:"u"`
Referer string `json:"r,omitempty"` Referer string `json:"r,omitempty"`
@@ -109,11 +103,11 @@ func (s *playbackService) ResolveProxyToken(token string) (string, string, error
return payload.TargetURL, payload.Referer, nil return payload.TargetURL, payload.Referer, nil
} }
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) { func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) {
// 1. Get Anime details for total episodes and titles // 1. Get Anime details for total episodes and titles
anime, err := s.jikan.GetAnimeByID(ctx, animeID) anime, err := s.jikan.GetAnimeByID(ctx, animeID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch anime: %w", err) return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err)
} }
// 2. Resolve streams from providers // 2. Resolve streams from providers
@@ -132,7 +126,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, anime, false) canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, anime, false)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch episodes: %w", err) return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err)
} }
requestedMode := mode requestedMode := mode
@@ -147,22 +141,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
} }
} }
type SubtitleItem struct { modeSources := map[string]domain.ModeSource{}
Lang string `json:"lang"`
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
}
type ModeSource struct {
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
Subtitles []SubtitleItem `json:"subtitles"`
Qualities []string `json:"qualities,omitempty"`
}
modeSources := map[string]ModeSource{}
var result *domain.StreamResult var result *domain.StreamResult
for _, m := range []string{"sub", "dub"} { for _, m := range []string{"sub", "dub"} {
@@ -172,17 +151,17 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
continue continue
} }
var subItems []SubtitleItem var subItems []domain.SubtitleItem
for _, sub := range res.Subtitles { for _, sub := range res.Subtitles {
subToken, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle") subToken, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
subItems = append(subItems, SubtitleItem{ subItems = append(subItems, domain.SubtitleItem{
Lang: sub.Label, Lang: sub.Label,
Token: subToken, Token: subToken,
}) })
} }
streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream") streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream")
modeSources[m] = ModeSource{ modeSources[m] = domain.ModeSource{
URL: res.URL, URL: res.URL,
Referer: res.Referer, Referer: res.Referer,
Token: streamToken, Token: streamToken,
@@ -197,11 +176,11 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
} }
if len(modeSources) == 0 { if len(modeSources) == 0 {
return nil, fmt.Errorf("no streams found") return domain.WatchPageData{}, fmt.Errorf("no streams found")
} }
if result == nil { if result == nil {
return nil, fmt.Errorf("no streams found for mode %s", mode) return domain.WatchPageData{}, fmt.Errorf("no streams found for mode %s", mode)
} }
// 3. Get start time from progress // 3. Get start time from progress
@@ -248,17 +227,11 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
// 6. Resolve relations/seasons // 6. Resolve relations/seasons
relations, _ := s.jikan.GetFullRelations(ctx, animeID) relations, _ := s.jikan.GetFullRelations(ctx, animeID)
type SeasonEntry struct { var seasons []domain.SeasonEntry
MalID int `json:"mal_id"`
Title string `json:"title"`
Prefix string `json:"prefix"`
IsCurrent bool `json:"is_current"`
}
var seasons []SeasonEntry
tvCounter := 1 tvCounter := 1
for _, rel := range relations { for _, rel := range relations {
if strings.ToLower(rel.Anime.Type) == "tv" || strings.ToLower(rel.Anime.Type) == "movie" { if strings.ToLower(rel.Anime.Type) == "tv" || strings.ToLower(rel.Anime.Type) == "movie" {
seasons = append(seasons, SeasonEntry{ seasons = append(seasons, domain.SeasonEntry{
MalID: rel.Anime.MalID, MalID: rel.Anime.MalID,
Title: rel.Anime.DisplayTitle(), Title: rel.Anime.DisplayTitle(),
Prefix: rel.Relation, Prefix: rel.Relation,
@@ -274,19 +247,19 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
// Final assembly // Final assembly
segments := s.fetchSkipSegments(ctx, userID, animeID, episode) segments := s.fetchSkipSegments(ctx, userID, animeID, episode)
watchData := map[string]any{ watchData := domain.WatchData{
"MalID": animeID, MalID: animeID,
"Title": anime.DisplayTitle(), Title: anime.DisplayTitle(),
"CurrentEpisode": episode, CurrentEpisode: episode,
"StartTimeSeconds": startTime, StartTimeSeconds: startTime,
"Episodes": canonicalEpisodes.Episodes, Episodes: canonicalEpisodes.Episodes,
"Providers": []domain.ProviderData{ Providers: []domain.ProviderData{
{Streams: streams}, {Streams: streams},
}, },
"ModeSources": modeSources, ModeSources: modeSources,
"InitialMode": mode, InitialMode: mode,
"ModeSwitchedFrom": modeSwitchedFrom, ModeSwitchedFrom: modeSwitchedFrom,
"AvailableModes": func() []string { AvailableModes: func() []string {
var modes []string var modes []string
for m := range modeSources { for m := range modeSources {
modes = append(modes, m) modes = append(modes, m)
@@ -294,17 +267,17 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
sort.Strings(modes) sort.Strings(modes)
return modes return modes
}(), }(),
"Segments": segments, Segments: segments,
} }
return map[string]any{ return domain.WatchPageData{
"WatchData": watchData, WatchData: watchData,
"Anime": anime, Anime: anime,
"Episodes": canonicalEpisodes.Episodes, Episodes: canonicalEpisodes.Episodes,
"CurrentEpID": episode, CurrentEpID: episode,
"WatchlistStatus": watchlistStatus, WatchlistStatus: watchlistStatus,
"WatchlistIDs": watchlistIDs, WatchlistIDs: watchlistIDs,
"Seasons": seasons, Seasons: seasons,
}, nil }, nil
} }
@@ -385,68 +358,55 @@ func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID
}) })
} }
func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string, malID int, episode string) []SkipSegment { func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string, malID int, episode string) []domain.SkipSegment {
if malID <= 0 || strings.TrimSpace(episode) == "" { if malID <= 0 || strings.TrimSpace(episode) == "" {
return []SkipSegment{} return []domain.SkipSegment{}
} }
segments := []domain.SkipSegment{}
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode)) endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err == nil {
return []SkipSegment{} req.Header.Set("User-Agent", useragent.Generic)
} if resp, err := s.httpClient.Do(req); err == nil {
req.Header.Set("User-Agent", useragent.Generic) defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusOK {
if body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512)); err == nil {
type resultItem struct {
SkipType string `json:"skip_type"`
Interval struct {
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
} `json:"interval"`
}
type apiResponse struct {
Found bool `json:"found"`
Result []resultItem `json:"results"`
}
resp, err := s.httpClient.Do(req) var parsed apiResponse
if err != nil { if err := json.Unmarshal(body, &parsed); err == nil && parsed.Found && len(parsed.Result) > 0 {
return []SkipSegment{} segments = make([]domain.SkipSegment, 0, len(parsed.Result))
} for _, r := range parsed.Result {
defer func() { _ = resp.Body.Close() }() skipType := strings.ToLower(r.SkipType)
switch skipType {
if resp.StatusCode != http.StatusOK { case "op":
return []SkipSegment{} skipType = "opening"
} case "ed":
skipType = "ending"
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512)) }
if err != nil { segments = append(segments, domain.SkipSegment{
return []SkipSegment{} Type: skipType,
} Start: r.Interval.StartTime,
End: r.Interval.EndTime,
type resultItem struct { Source: "aniskip",
SkipType string `json:"skip_type"` })
Interval struct { }
StartTime float64 `json:"start_time"` }
EndTime float64 `json:"end_time"` }
} `json:"interval"` }
}
type apiResponse struct {
Found bool `json:"found"`
Result []resultItem `json:"results"`
}
var parsed apiResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return []SkipSegment{}
}
if !parsed.Found || len(parsed.Result) == 0 {
return []SkipSegment{}
}
segments := make([]SkipSegment, 0, len(parsed.Result))
for _, r := range parsed.Result {
skipType := strings.ToLower(r.SkipType)
switch skipType {
case "op":
skipType = "opening"
case "ed":
skipType = "ending"
} }
segments = append(segments, SkipSegment{
Type: skipType,
Start: r.Interval.StartTime,
End: r.Interval.EndTime,
})
} }
epNum, _ := strconv.ParseInt(strings.TrimSpace(episode), 10, 64) epNum, _ := strconv.ParseInt(strings.TrimSpace(episode), 10, 64)
@@ -454,7 +414,7 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
if ok, err := s.repo.HasSkipSegmentOverrideTable(ctx); err == nil && ok { if ok, err := s.repo.HasSkipSegmentOverrideTable(ctx); err == nil && ok {
if overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum); err == nil { if overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum); err == nil {
// Build map keyed by normalized type ("opening"/"ending") // Build map keyed by normalized type ("opening"/"ending")
overrideByType := make(map[string]SkipSegment, len(overrides)) overrideByType := make(map[string]domain.SkipSegment, len(overrides))
for _, o := range overrides { for _, o := range overrides {
t := strings.ToLower(strings.TrimSpace(o.SkipType)) t := strings.ToLower(strings.TrimSpace(o.SkipType))
switch t { switch t {
@@ -465,10 +425,15 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
default: default:
continue continue
} }
overrideByType[t] = SkipSegment{Type: t, Start: o.StartTime, End: o.EndTime} overrideByType[t] = domain.SkipSegment{
Type: t,
Start: o.StartTime,
End: o.EndTime,
Source: "override",
}
} }
if len(overrideByType) > 0 { if len(overrideByType) > 0 {
merged := make([]SkipSegment, 0, len(segments)+len(overrideByType)) merged := make([]domain.SkipSegment, 0, len(segments)+len(overrideByType))
seen := map[string]bool{} seen := map[string]bool{}
for _, seg := range segments { for _, seg := range segments {
if o, ok := overrideByType[seg.Type]; ok { if o, ok := overrideByType[seg.Type]; ok {

View File

@@ -205,6 +205,10 @@ type HTMLRender struct {
Data any Data any
} }
type templateFragmentData interface {
TemplateFragment() string
}
func (h HTMLRender) Render(w http.ResponseWriter) error { func (h HTMLRender) Render(w http.ResponseWriter) error {
tmpl, ok := h.Renderer.templates[h.Name] tmpl, ok := h.Renderer.templates[h.Name]
if !ok { if !ok {
@@ -219,6 +223,8 @@ func (h HTMLRender) Render(w http.ResponseWriter) error {
block = dataMap["_fragment"] block = dataMap["_fragment"]
} else if ginH, ok := h.Data.(gin.H); ok { } else if ginH, ok := h.Data.(gin.H); ok {
block = ginH["_fragment"] block = ginH["_fragment"]
} else if fragmentData, ok := h.Data.(templateFragmentData); ok {
block = fragmentData.TemplateFragment()
} }
if blockStr, ok := block.(string); ok && blockStr != "" { if blockStr, ok := block.(string); ok && blockStr != "" {

View File

@@ -55,8 +55,8 @@ func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]d
return s.repo.GetUserWatchList(ctx, userID) return s.repo.GetUserWatchList(ctx, userID)
} }
func (s *watchlistService) GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int]bool, error) { func (s *watchlistService) GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int64]bool, error) {
watchlistMap := make(map[int]bool) watchlistMap := make(map[int64]bool)
if userID == "" || len(animeIDs) == 0 { if userID == "" || len(animeIDs) == 0 {
return watchlistMap, nil return watchlistMap, nil
} }
@@ -67,7 +67,7 @@ func (s *watchlistService) GetWatchlistMap(ctx context.Context, userID string, a
} }
for _, animeID := range matches { for _, animeID := range matches {
watchlistMap[int(animeID)] = true watchlistMap[animeID] = true
} }
return watchlistMap, nil return watchlistMap, nil

View File

@@ -66,9 +66,12 @@ const updatePreviewUI = (ratio: number): void => {
const initPlayer = (): void => { const initPlayer = (): void => {
const container = document.querySelector('[data-video-player]') as HTMLElement | null; const container = document.querySelector('[data-video-player]') as HTMLElement | null;
if (!container || initialized) return; if (!container || initialized) return;
initialized = true;
initState(container); if (!initState(container)) {
console.error('Video player markup is missing required controls.');
return;
}
initialized = true;
const loading = container.querySelector('[data-loading]') as HTMLElement | null; const loading = container.querySelector('[data-loading]') as HTMLElement | null;
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null; const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;

View File

@@ -22,6 +22,8 @@ const sendBeacon = (payload: string) => {
*/ */
export const saveProgress = async (): Promise<void> => { export const saveProgress = async (): Promise<void> => {
if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return; if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return;
// progress is user-scoped; avoid spamming 401s for anonymous sessions
if (!document.cookie.includes('mal_session=')) return;
const episode = Number.parseInt(state.currentEpisode, 10); const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return; if (!episode) return;
@@ -60,6 +62,8 @@ const scheduleProgressSave = (): void => {
*/ */
export const markEpisodeTransition = (episodeNumber: number): void => { export const markEpisodeTransition = (episodeNumber: number): void => {
if (!state.malID || !episodeNumber) return; if (!state.malID || !episodeNumber) return;
// progress is user-scoped; avoid sending beacons for anonymous sessions
if (!document.cookie.includes('mal_session=')) return;
if (state.progressSaveTimer !== undefined) { if (state.progressSaveTimer !== undefined) {
window.clearTimeout(state.progressSaveTimer); window.clearTimeout(state.progressSaveTimer);
state.progressSaveTimer = undefined; state.progressSaveTimer = undefined;
@@ -102,6 +106,7 @@ export const setupProgress = (): void => {
// save on page close // save on page close
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return; if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
if (!document.cookie.includes('mal_session=')) return;
const episode = Number.parseInt(state.currentEpisode, 10); const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return; if (!episode) return;
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime))); sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));

View File

@@ -140,7 +140,12 @@ export const setupSegmentEditor = (): void => {
}), }),
}); });
if (!res.ok) { if (!res.ok) {
setError(res.status === 401 ? 'Login required.' : 'Failed to save segment.'); let message = res.status === 401 ? 'Login required.' : 'Failed to save segment.';
try {
const payload = (await res.json()) as { error?: string };
if (payload?.error) message = payload.error;
} catch {}
setError(message);
return; return;
} }
@@ -151,10 +156,16 @@ export const setupSegmentEditor = (): void => {
if (normalizedType === 'ending') return t !== 'ed' && t !== 'ending' && t !== 'outro'; if (normalizedType === 'ending') return t !== 'ed' && t !== 'ending' && t !== 'outro';
return t !== 'op' && t !== 'opening' && t !== 'intro'; return t !== 'op' && t !== 'opening' && t !== 'intro';
}); });
state.parsedSegments.push({ type: normalizedType, start: startTime, end: endTime }); state.parsedSegments.push({
type: normalizedType,
start: startTime,
end: endTime,
source: 'override',
});
resolveActiveSegments(); resolveActiveSegments();
renderSegments(); renderSegments();
window.showToast?.({ message: 'Segment saved.' });
close(); close();
} catch { } catch {
setError('Failed to save segment.'); setError('Failed to save segment.');

View File

@@ -27,6 +27,7 @@ export const resolveActiveSegments = (): void => {
state.activeSegments = state.parsedSegments.filter(s => { state.activeSegments = state.parsedSegments.filter(s => {
const t = normalizeType(s.type); const t = normalizeType(s.type);
if (!t) return false; if (!t) return false;
const isOverride = (s.source || '').toLowerCase() === 'override';
const len = s.end - s.start; const len = s.end - s.start;
// duration filter // duration filter
@@ -34,6 +35,9 @@ export const resolveActiveSegments = (): void => {
// bounds check // bounds check
if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false; if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false;
// User overrides should render even if they don't fit AniSkip's usual OP/ED heuristics.
if (isOverride) return true;
// intro: starts early, before 50% of video // intro: starts early, before 50% of video
if (t === 'op') { if (t === 'op') {
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5; return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5;

View File

@@ -42,14 +42,14 @@ export interface PlayerState {
videoOverlay: HTMLElement | null; videoOverlay: HTMLElement | null;
} }
export const state: PlayerState = { const createInitialState = (): PlayerState => ({
container: null as unknown as HTMLElement, container: document.createElement('div'),
video: null as unknown as HTMLVideoElement, video: document.createElement('video'),
progress: null as unknown as HTMLElement, progress: document.createElement('div'),
scrubber: null as unknown as HTMLElement, scrubber: document.createElement('div'),
buffered: null as unknown as HTMLElement, buffered: document.createElement('div'),
timeDisplay: null as unknown as HTMLElement, timeDisplay: document.createElement('div'),
durationDisplay: null as unknown as HTMLElement, durationDisplay: document.createElement('div'),
modeSources: {}, modeSources: {},
availableModes: [], availableModes: [],
currentMode: 'dub', currentMode: 'dub',
@@ -81,21 +81,69 @@ export const state: PlayerState = {
previewPopover: null, previewPopover: null,
previewTime: null, previewTime: null,
videoOverlay: null, videoOverlay: null,
});
export const state: PlayerState = createInitialState();
interface RequiredPlayerElements {
video: HTMLVideoElement;
progress: HTMLElement;
scrubber: HTMLElement;
buffered: HTMLElement;
timeDisplay: HTMLElement;
durationDisplay: HTMLElement;
}
const findElement = <T extends Element>(
container: HTMLElement,
selector: string,
elementType: new () => T
): T | null => {
const element = container.querySelector(selector);
if (element instanceof elementType) return element;
return null;
};
const requiredPlayerElements = (container: HTMLElement): RequiredPlayerElements | null => {
const elements = {
video: findElement(container, 'video', HTMLVideoElement),
progress: findElement(container, '[data-progress]', HTMLElement),
scrubber: findElement(container, '[data-scrubber]', HTMLElement),
buffered: findElement(container, '[data-buffered]', HTMLElement),
timeDisplay: findElement(container, '[data-time]', HTMLElement),
durationDisplay: findElement(container, '[data-duration]', HTMLElement),
};
if (
!elements.video ||
!elements.progress ||
!elements.scrubber ||
!elements.buffered ||
!elements.timeDisplay ||
!elements.durationDisplay
) {
return null;
}
return elements;
}; };
/** /**
* Initializes player state from DOM data attributes. * Initializes player state from DOM data attributes.
* Called once on page load or htmx swap. * Called once on page load or htmx swap.
*/ */
export const initState = (c: HTMLElement): void => { export const initState = (c: HTMLElement): boolean => {
const elements = requiredPlayerElements(c);
if (!elements) return false;
// core elements // core elements
state.container = c; state.container = c;
state.video = q<HTMLVideoElement>(c, 'video')!; state.video = elements.video;
state.progress = q<HTMLElement>(c, '[data-progress]'); state.progress = elements.progress;
state.scrubber = q<HTMLElement>(c, '[data-scrubber]'); state.scrubber = elements.scrubber;
state.buffered = q<HTMLElement>(c, '[data-buffered]'); state.buffered = elements.buffered;
state.timeDisplay = q<HTMLElement>(c, '[data-time]'); state.timeDisplay = elements.timeDisplay;
state.durationDisplay = q<HTMLElement>(c, '[data-duration]'); state.durationDisplay = elements.durationDisplay;
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]'); state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]');
state.previewTime = q<HTMLElement>(c, '[data-preview-time]'); state.previewTime = q<HTMLElement>(c, '[data-preview-time]');
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]'); state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]');
@@ -143,4 +191,6 @@ export const initState = (c: HTMLElement): void => {
state.parsedSegments = segments state.parsedSegments = segments
.map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 })) .map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
.filter(s => s.end > s.start); .filter(s => s.end > s.start);
return true;
}; };

View File

@@ -16,6 +16,7 @@ export interface SkipSegment {
type: string; // 'op' or 'ed' type: string; // 'op' or 'ed'
start: number; start: number;
end: number; end: number;
source?: string;
} }
// parsed subtitle cue from VTT // parsed subtitle cue from VTT
@@ -37,6 +38,7 @@ export interface ActiveSegment {
type: string; type: string;
start: number; start: number;
end: number; end: number;
source?: string;
} }
// timeline range (handles seekable ranges in live streams) // timeline range (handles seekable ranges in live streams)

View File

@@ -388,6 +388,12 @@
if (closeBtn) closeBtn.addEventListener('click', close); if (closeBtn) closeBtn.addEventListener('click', close);
dialog.addEventListener('click', (e) => { if (e.target === dialog) close(); }); dialog.addEventListener('click', (e) => { if (e.target === dialog) close(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
const loader = document.querySelector('[data-themes-loader]');
if (loader) {
loader.addEventListener('htmx:responseError', () => { themesRequested = false; });
loader.addEventListener('htmx:sendError', () => { themesRequested = false; });
}
})(); })();
</script> </script>