Compare commits
10 Commits
0e16f9f720
...
23246e2326
| Author | SHA1 | Date | |
|---|---|---|---|
| 23246e2326 | |||
| 51355a4dbc | |||
| c5c15cdabc | |||
| 836c67f202 | |||
| 812dcd2448 | |||
| d94f1516ce | |||
| 68396c591e | |||
| 066305403b | |||
| eed0649569 | |||
| d7fee6d518 |
@@ -29,6 +29,11 @@ type Client struct {
|
||||
lastReqTime time.Time // rate limiting: last request timestamp
|
||||
sf singleflight.Group
|
||||
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
|
||||
@@ -48,6 +53,7 @@ func NewClient(queries *db.Queries) *Client {
|
||||
db: queries,
|
||||
retrySignal: make(chan struct{}, 1),
|
||||
refreshSem: make(chan struct{}, 4),
|
||||
randomPool: make([]Anime, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ScheduleResult struct {
|
||||
@@ -52,20 +55,148 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu
|
||||
}, 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.
|
||||
func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
|
||||
var result struct {
|
||||
Data Anime `json:"data"`
|
||||
c.poolMu.Lock()
|
||||
initialized := c.poolInitialized
|
||||
c.poolMu.Unlock()
|
||||
|
||||
if !initialized {
|
||||
_ = c.seedRandomPool(ctx)
|
||||
}
|
||||
|
||||
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")
|
||||
c.poolMu.RLock()
|
||||
defer c.poolMu.RUnlock()
|
||||
|
||||
if len(c.randomPool) == 0 {
|
||||
var result struct {
|
||||
Data Anime `json:"data"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
for _, anime := range animes {
|
||||
if anime.MalID > 0 {
|
||||
@@ -37,14 +37,14 @@ func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string,
|
||||
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 {
|
||||
return map[int]bool{}
|
||||
return map[int64]bool{}
|
||||
}
|
||||
|
||||
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
|
||||
if err != nil {
|
||||
return map[int]bool{}
|
||||
return map[int64]bool{}
|
||||
}
|
||||
return watchlistMap
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||
"CurrentPath": "/",
|
||||
"User": user,
|
||||
"WatchlistMap": map[int]bool{},
|
||||
"WatchlistMap": map[int64]bool{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,14 +101,11 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := map[int]bool{}
|
||||
if animes, ok := data["Animes"].([]domain.Anime); ok {
|
||||
watchlistMap = h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
}
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data["Section"] = section
|
||||
data["_fragment"] = "catalog_section"
|
||||
data["WatchlistMap"] = watchlistMap
|
||||
data.Section = section
|
||||
data.Fragment = "catalog_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
@@ -143,14 +140,11 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := map[int]bool{}
|
||||
if animes, ok := data["Animes"].([]domain.Anime); ok {
|
||||
watchlistMap = h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
}
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data["Section"] = section
|
||||
data["_fragment"] = "discover_section"
|
||||
data["WatchlistMap"] = watchlistMap
|
||||
data.Section = section
|
||||
data.Fragment = "discover_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "discover.gohtml", data)
|
||||
}
|
||||
|
||||
@@ -401,7 +395,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||
Type: anime.Type,
|
||||
Year: anime.Year,
|
||||
Image: anime.ImageURL(),
|
||||
InWatchlist: watchlistMap[anime.MalID],
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, output)
|
||||
@@ -609,7 +603,7 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||
inWatchlist := false
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
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{
|
||||
|
||||
@@ -20,7 +20,7 @@ func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.An
|
||||
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 (
|
||||
res jikan.TopAnimeResult
|
||||
cw []db.GetContinueWatchingEntriesRow
|
||||
@@ -48,7 +48,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
animes := res.Animes
|
||||
@@ -56,13 +56,13 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
||||
animes = animes[:6]
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"Animes": animes,
|
||||
"ContinueWatching": cw,
|
||||
return domain.CatalogSectionData{
|
||||
Animes: animes,
|
||||
ContinueWatching: cw,
|
||||
}, 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
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
return domain.DiscoverSectionData{}, err
|
||||
}
|
||||
|
||||
animes := res.Animes
|
||||
@@ -89,8 +89,8 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
|
||||
animes = animes[:8]
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"Animes": animes,
|
||||
return domain.DiscoverSectionData{
|
||||
Animes: animes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -160,7 +160,8 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
|
||||
if fallbackErr != nil || len(res.Animes) == 0 {
|
||||
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
|
||||
|
||||
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"mal/internal/domain"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -42,7 +41,7 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) {
|
||||
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" {
|
||||
c.Header("HX-Redirect", "/")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -23,6 +23,8 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
|
||||
var user *domain.User
|
||||
var err error
|
||||
var sessionID string
|
||||
var usesCookieSession bool
|
||||
|
||||
// API routes can authenticate via Bearer token OR cookie session.
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
@@ -30,7 +32,9 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token := strings.TrimSpace(authHeader[7:])
|
||||
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)
|
||||
} else {
|
||||
err = cookieErr
|
||||
@@ -43,13 +47,15 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
} else {
|
||||
// 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 {
|
||||
c.Redirect(http.StatusSeeOther, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
sessionID = cookieSessionID
|
||||
usesCookieSession = true
|
||||
user, err = svc.ValidateSession(c.Request.Context(), sessionID)
|
||||
if err != nil || user == nil {
|
||||
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.Next()
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (r *authRepository) CreateSession(ctx context.Context, userID string, sessi
|
||||
s, err := r.queries.CreateSession(ctx, db.CreateSessionParams{
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(domain.SessionLifetime),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -64,6 +64,13 @@ func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*dom
|
||||
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 {
|
||||
return r.queries.DeleteSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,14 @@ func (s *authService) ValidateSession(ctx context.Context, sessionID string) (*d
|
||||
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) {
|
||||
trimmed := strings.TrimSpace(token)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -14,7 +14,12 @@ ON continue_watching_entry(anime_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at_datetime
|
||||
ON jikan_cache(datetime(expires_at));
|
||||
|
||||
DROP INDEX IF EXISTS idx_jikan_cache_expires_at;
|
||||
|
||||
-- +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_continue_watching_anime_id;
|
||||
DROP INDEX IF EXISTS idx_watch_list_entry_status_updated_at_anime_id;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
@@ -93,6 +93,18 @@ type Session struct {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
@@ -19,6 +19,7 @@ type Querier interface {
|
||||
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
|
||||
EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error
|
||||
GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiToken, error)
|
||||
GetAllCachedAnime(ctx context.Context) ([]string, error)
|
||||
GetAnime(ctx context.Context, id int64) (Anime, error)
|
||||
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
|
||||
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
||||
|
||||
@@ -15,6 +15,11 @@ SELECT * FROM session WHERE id = ? LIMIT 1;
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM session WHERE id = ?;
|
||||
|
||||
-- name: RefreshSession :exec
|
||||
UPDATE session
|
||||
SET expires_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: CreateAPIToken :one
|
||||
INSERT INTO api_token (id, user_id, token_hash, name)
|
||||
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
|
||||
ORDER BY tracked.anime_id
|
||||
LIMIT ?;
|
||||
|
||||
-- name: GetAllCachedAnime :many
|
||||
SELECT data FROM jikan_cache
|
||||
WHERE key LIKE 'anime:%' LIMIT 1000;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
// source: queries.sql
|
||||
|
||||
package db
|
||||
@@ -124,6 +124,22 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
|
||||
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
|
||||
DELETE FROM watch_list_entry
|
||||
WHERE user_id = ? AND anime_id = ?
|
||||
@@ -182,6 +198,34 @@ func (q *Queries) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiT
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
@@ -17,8 +17,8 @@ type ThemesData = jikan.ThemesData
|
||||
type ReviewEntry = jikan.ReviewEntry
|
||||
|
||||
type AnimeService interface {
|
||||
GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error)
|
||||
GetDiscoverSection(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) (DiscoverSectionData, 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)
|
||||
GetGenres(ctx context.Context) ([]Genre, error)
|
||||
@@ -34,6 +34,29 @@ type AnimeService interface {
|
||||
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 {
|
||||
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
|
||||
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
|
||||
|
||||
@@ -3,16 +3,20 @@ package domain
|
||||
import (
|
||||
"context"
|
||||
"mal/internal/db"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User = db.User
|
||||
type Session = db.Session
|
||||
type APIToken = db.ApiToken
|
||||
|
||||
const SessionLifetime = 90 * 24 * time.Hour
|
||||
|
||||
type AuthService interface {
|
||||
Login(ctx context.Context, username, password string) (*Session, error)
|
||||
LoginForAPIToken(ctx context.Context, username, password, name string) (token string, user *User, err error)
|
||||
ValidateSession(ctx context.Context, sessionID string) (*User, error)
|
||||
RefreshSession(ctx context.Context, sessionID string) error
|
||||
ValidateAPIToken(ctx context.Context, token string) (*User, error)
|
||||
Logout(ctx context.Context, sessionID string) error
|
||||
RevokeAllAPITokensForUser(ctx context.Context, userID string) error
|
||||
@@ -23,6 +27,7 @@ type AuthRepository interface {
|
||||
GetUserByID(ctx context.Context, id string) (*User, error)
|
||||
CreateSession(ctx context.Context, userID string, 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
|
||||
CreateAPIToken(ctx context.Context, userID, tokenHash, name string) (*APIToken, error)
|
||||
GetAPITokenByHash(ctx context.Context, tokenHash string) (*APIToken, error)
|
||||
|
||||
@@ -6,13 +6,69 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
CompleteAnime(ctx context.Context, userID string, animeID int64) error
|
||||
ResolveProxyToken(token string) (string, string, 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 {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
|
||||
@@ -12,7 +12,7 @@ type WatchlistService interface {
|
||||
UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error
|
||||
RemoveEntry(ctx context.Context, userID string, animeID int64) 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)
|
||||
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
|
||||
GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"mal/pkg/net/limits"
|
||||
"mal/pkg/net/proxytransport"
|
||||
"mal/pkg/net/useragent"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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)
|
||||
if err != nil {
|
||||
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
|
||||
c.HTML(http.StatusOK, "watch.gohtml", gin.H{
|
||||
"Error": err.Error(),
|
||||
"Anime": anime,
|
||||
"Episodes": []domain.EpisodeData{},
|
||||
"CurrentPath": c.Request.URL.Path,
|
||||
"User": user,
|
||||
"CurrentEpID": ep,
|
||||
"WatchData": map[string]any{"Episodes": []domain.EpisodeData{}, "Providers": []any{}},
|
||||
c.HTML(http.StatusOK, "watch.gohtml", domain.WatchPageData{
|
||||
Error: err.Error(),
|
||||
Anime: anime,
|
||||
Episodes: []domain.CanonicalEpisode{},
|
||||
CurrentPath: c.Request.URL.Path,
|
||||
User: currentUser(user),
|
||||
CurrentEpID: ep,
|
||||
WatchData: domain.WatchData{
|
||||
Episodes: []domain.CanonicalEpisode{},
|
||||
Providers: []domain.ProviderData{},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Merge data from service with handler-specific context
|
||||
responseData := gin.H{
|
||||
"User": user,
|
||||
"CurrentPath": c.Request.URL.Path,
|
||||
}
|
||||
maps.Copy(responseData, data)
|
||||
data.User = currentUser(user)
|
||||
data.CurrentPath = c.Request.URL.Path
|
||||
|
||||
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
|
||||
@@ -112,45 +110,47 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
watchData, _ := data["WatchData"].(map[string]any)
|
||||
if watchData == nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
modeSources := watchData["ModeSources"]
|
||||
availableModes, _ := watchData["AvailableModes"].([]string)
|
||||
segments := watchData["Segments"]
|
||||
watchData := data.WatchData
|
||||
|
||||
// Try to resolve a title for this episode from the episode list.
|
||||
episodeTitle := ""
|
||||
if eps, ok := watchData["Episodes"].([]domain.CanonicalEpisode); ok {
|
||||
epNum, _ := strconv.Atoi(episode)
|
||||
for _, e := range eps {
|
||||
if e.Number == epNum {
|
||||
episodeTitle = e.Title
|
||||
break
|
||||
}
|
||||
epNum, _ := strconv.Atoi(episode)
|
||||
for _, e := range watchData.Episodes {
|
||||
if e.Number == epNum {
|
||||
episodeTitle = e.Title
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"mode_sources": modeSources,
|
||||
"available_modes": availableModes,
|
||||
"initial_mode": watchData["InitialMode"],
|
||||
"start_time_seconds": watchData["StartTimeSeconds"],
|
||||
"segments": segments,
|
||||
"mode_sources": watchData.ModeSources,
|
||||
"available_modes": watchData.AvailableModes,
|
||||
"initial_mode": watchData.InitialMode,
|
||||
"start_time_seconds": watchData.StartTimeSeconds,
|
||||
"segments": watchData.Segments,
|
||||
"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) {
|
||||
user, _ := c.Get("User")
|
||||
userID := ""
|
||||
if u, ok := user.(*domain.User); ok {
|
||||
userID = u.ID
|
||||
}
|
||||
if userID == "" {
|
||||
// Avoid spamming 500s for anonymous playback; progress is user-scoped.
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
MalID int64 `json:"mal_id"`
|
||||
@@ -205,7 +205,7 @@ func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
|
||||
userID = u.ID
|
||||
}
|
||||
if userID == "" {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "login required"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,12 +217,12 @@ func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
|
||||
EndTime float64 `json:"end_time"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.Status(http.StatusBadRequest)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,6 @@ type playbackService struct {
|
||||
proxyTokenKey string
|
||||
}
|
||||
|
||||
type SkipSegment struct {
|
||||
Type string `json:"type"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
}
|
||||
|
||||
type proxyTokenPayload struct {
|
||||
TargetURL string `json:"u"`
|
||||
Referer string `json:"r,omitempty"`
|
||||
@@ -109,11 +103,11 @@ func (s *playbackService) ResolveProxyToken(token string) (string, string, error
|
||||
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
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
|
||||
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
|
||||
@@ -132,7 +126,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
|
||||
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, anime, false)
|
||||
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
|
||||
@@ -147,22 +141,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
modeSources := map[string]ModeSource{}
|
||||
modeSources := map[string]domain.ModeSource{}
|
||||
var result *domain.StreamResult
|
||||
|
||||
for _, m := range []string{"sub", "dub"} {
|
||||
@@ -172,17 +151,17 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
continue
|
||||
}
|
||||
|
||||
var subItems []SubtitleItem
|
||||
var subItems []domain.SubtitleItem
|
||||
for _, sub := range res.Subtitles {
|
||||
subToken, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
|
||||
subItems = append(subItems, SubtitleItem{
|
||||
subItems = append(subItems, domain.SubtitleItem{
|
||||
Lang: sub.Label,
|
||||
Token: subToken,
|
||||
})
|
||||
}
|
||||
|
||||
streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream")
|
||||
modeSources[m] = ModeSource{
|
||||
modeSources[m] = domain.ModeSource{
|
||||
URL: res.URL,
|
||||
Referer: res.Referer,
|
||||
Token: streamToken,
|
||||
@@ -197,11 +176,11 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
}
|
||||
|
||||
if len(modeSources) == 0 {
|
||||
return nil, fmt.Errorf("no streams found")
|
||||
return domain.WatchPageData{}, fmt.Errorf("no streams found")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -248,17 +227,11 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
|
||||
// 6. Resolve relations/seasons
|
||||
relations, _ := s.jikan.GetFullRelations(ctx, animeID)
|
||||
type SeasonEntry struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Title string `json:"title"`
|
||||
Prefix string `json:"prefix"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
var seasons []SeasonEntry
|
||||
var seasons []domain.SeasonEntry
|
||||
tvCounter := 1
|
||||
for _, rel := range relations {
|
||||
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,
|
||||
Title: rel.Anime.DisplayTitle(),
|
||||
Prefix: rel.Relation,
|
||||
@@ -274,19 +247,19 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
// Final assembly
|
||||
segments := s.fetchSkipSegments(ctx, userID, animeID, episode)
|
||||
|
||||
watchData := map[string]any{
|
||||
"MalID": animeID,
|
||||
"Title": anime.DisplayTitle(),
|
||||
"CurrentEpisode": episode,
|
||||
"StartTimeSeconds": startTime,
|
||||
"Episodes": canonicalEpisodes.Episodes,
|
||||
"Providers": []domain.ProviderData{
|
||||
watchData := domain.WatchData{
|
||||
MalID: animeID,
|
||||
Title: anime.DisplayTitle(),
|
||||
CurrentEpisode: episode,
|
||||
StartTimeSeconds: startTime,
|
||||
Episodes: canonicalEpisodes.Episodes,
|
||||
Providers: []domain.ProviderData{
|
||||
{Streams: streams},
|
||||
},
|
||||
"ModeSources": modeSources,
|
||||
"InitialMode": mode,
|
||||
"ModeSwitchedFrom": modeSwitchedFrom,
|
||||
"AvailableModes": func() []string {
|
||||
ModeSources: modeSources,
|
||||
InitialMode: mode,
|
||||
ModeSwitchedFrom: modeSwitchedFrom,
|
||||
AvailableModes: func() []string {
|
||||
var modes []string
|
||||
for m := range modeSources {
|
||||
modes = append(modes, m)
|
||||
@@ -294,17 +267,17 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
sort.Strings(modes)
|
||||
return modes
|
||||
}(),
|
||||
"Segments": segments,
|
||||
Segments: segments,
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"WatchData": watchData,
|
||||
"Anime": anime,
|
||||
"Episodes": canonicalEpisodes.Episodes,
|
||||
"CurrentEpID": episode,
|
||||
"WatchlistStatus": watchlistStatus,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"Seasons": seasons,
|
||||
return domain.WatchPageData{
|
||||
WatchData: watchData,
|
||||
Anime: anime,
|
||||
Episodes: canonicalEpisodes.Episodes,
|
||||
CurrentEpID: episode,
|
||||
WatchlistStatus: watchlistStatus,
|
||||
WatchlistIDs: watchlistIDs,
|
||||
Seasons: seasons,
|
||||
}, 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) == "" {
|
||||
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))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return []SkipSegment{}
|
||||
}
|
||||
req.Header.Set("User-Agent", useragent.Generic)
|
||||
if err == nil {
|
||||
req.Header.Set("User-Agent", useragent.Generic)
|
||||
if resp, err := s.httpClient.Do(req); err == nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return []SkipSegment{}
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return []SkipSegment{}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512))
|
||||
if err != nil {
|
||||
return []SkipSegment{}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"
|
||||
var parsed apiResponse
|
||||
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Found && len(parsed.Result) > 0 {
|
||||
segments = make([]domain.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, domain.SkipSegment{
|
||||
Type: skipType,
|
||||
Start: r.Interval.StartTime,
|
||||
End: r.Interval.EndTime,
|
||||
Source: "aniskip",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
segments = append(segments, SkipSegment{
|
||||
Type: skipType,
|
||||
Start: r.Interval.StartTime,
|
||||
End: r.Interval.EndTime,
|
||||
})
|
||||
}
|
||||
|
||||
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 overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum); err == nil {
|
||||
// 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 {
|
||||
t := strings.ToLower(strings.TrimSpace(o.SkipType))
|
||||
switch t {
|
||||
@@ -465,10 +425,15 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
|
||||
default:
|
||||
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 {
|
||||
merged := make([]SkipSegment, 0, len(segments)+len(overrideByType))
|
||||
merged := make([]domain.SkipSegment, 0, len(segments)+len(overrideByType))
|
||||
seen := map[string]bool{}
|
||||
for _, seg := range segments {
|
||||
if o, ok := overrideByType[seg.Type]; ok {
|
||||
|
||||
@@ -205,6 +205,10 @@ type HTMLRender struct {
|
||||
Data any
|
||||
}
|
||||
|
||||
type templateFragmentData interface {
|
||||
TemplateFragment() string
|
||||
}
|
||||
|
||||
func (h HTMLRender) Render(w http.ResponseWriter) error {
|
||||
tmpl, ok := h.Renderer.templates[h.Name]
|
||||
if !ok {
|
||||
@@ -219,6 +223,8 @@ func (h HTMLRender) Render(w http.ResponseWriter) error {
|
||||
block = dataMap["_fragment"]
|
||||
} else if ginH, ok := h.Data.(gin.H); ok {
|
||||
block = ginH["_fragment"]
|
||||
} else if fragmentData, ok := h.Data.(templateFragmentData); ok {
|
||||
block = fragmentData.TemplateFragment()
|
||||
}
|
||||
|
||||
if blockStr, ok := block.(string); ok && blockStr != "" {
|
||||
|
||||
@@ -55,8 +55,8 @@ func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]d
|
||||
return s.repo.GetUserWatchList(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *watchlistService) GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int]bool, error) {
|
||||
watchlistMap := make(map[int]bool)
|
||||
func (s *watchlistService) GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int64]bool, error) {
|
||||
watchlistMap := make(map[int64]bool)
|
||||
if userID == "" || len(animeIDs) == 0 {
|
||||
return watchlistMap, nil
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func (s *watchlistService) GetWatchlistMap(ctx context.Context, userID string, a
|
||||
}
|
||||
|
||||
for _, animeID := range matches {
|
||||
watchlistMap[int(animeID)] = true
|
||||
watchlistMap[animeID] = true
|
||||
}
|
||||
|
||||
return watchlistMap, nil
|
||||
|
||||
@@ -66,9 +66,12 @@ const updatePreviewUI = (ratio: number): void => {
|
||||
const initPlayer = (): void => {
|
||||
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
|
||||
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 progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
||||
|
||||
@@ -22,6 +22,8 @@ const sendBeacon = (payload: string) => {
|
||||
*/
|
||||
export const saveProgress = async (): Promise<void> => {
|
||||
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);
|
||||
if (!episode) return;
|
||||
|
||||
@@ -60,6 +62,8 @@ const scheduleProgressSave = (): void => {
|
||||
*/
|
||||
export const markEpisodeTransition = (episodeNumber: number): void => {
|
||||
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) {
|
||||
window.clearTimeout(state.progressSaveTimer);
|
||||
state.progressSaveTimer = undefined;
|
||||
@@ -102,6 +106,7 @@ export const setupProgress = (): void => {
|
||||
// save on page close
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
|
||||
if (!document.cookie.includes('mal_session=')) return;
|
||||
const episode = Number.parseInt(state.currentEpisode, 10);
|
||||
if (!episode) return;
|
||||
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));
|
||||
|
||||
@@ -140,7 +140,12 @@ export const setupSegmentEditor = (): void => {
|
||||
}),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -151,10 +156,16 @@ export const setupSegmentEditor = (): void => {
|
||||
if (normalizedType === 'ending') return t !== 'ed' && t !== 'ending' && t !== 'outro';
|
||||
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();
|
||||
renderSegments();
|
||||
|
||||
window.showToast?.({ message: 'Segment saved.' });
|
||||
close();
|
||||
} catch {
|
||||
setError('Failed to save segment.');
|
||||
|
||||
@@ -27,6 +27,7 @@ export const resolveActiveSegments = (): void => {
|
||||
state.activeSegments = state.parsedSegments.filter(s => {
|
||||
const t = normalizeType(s.type);
|
||||
if (!t) return false;
|
||||
const isOverride = (s.source || '').toLowerCase() === 'override';
|
||||
|
||||
const len = s.end - s.start;
|
||||
// duration filter
|
||||
@@ -34,6 +35,9 @@ export const resolveActiveSegments = (): void => {
|
||||
// bounds check
|
||||
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
|
||||
if (t === 'op') {
|
||||
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5;
|
||||
|
||||
@@ -42,14 +42,14 @@ export interface PlayerState {
|
||||
videoOverlay: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const state: PlayerState = {
|
||||
container: null as unknown as HTMLElement,
|
||||
video: null as unknown as HTMLVideoElement,
|
||||
progress: null as unknown as HTMLElement,
|
||||
scrubber: null as unknown as HTMLElement,
|
||||
buffered: null as unknown as HTMLElement,
|
||||
timeDisplay: null as unknown as HTMLElement,
|
||||
durationDisplay: null as unknown as HTMLElement,
|
||||
const createInitialState = (): PlayerState => ({
|
||||
container: document.createElement('div'),
|
||||
video: document.createElement('video'),
|
||||
progress: document.createElement('div'),
|
||||
scrubber: document.createElement('div'),
|
||||
buffered: document.createElement('div'),
|
||||
timeDisplay: document.createElement('div'),
|
||||
durationDisplay: document.createElement('div'),
|
||||
modeSources: {},
|
||||
availableModes: [],
|
||||
currentMode: 'dub',
|
||||
@@ -81,21 +81,69 @@ export const state: PlayerState = {
|
||||
previewPopover: null,
|
||||
previewTime: 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.
|
||||
* 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
|
||||
state.container = c;
|
||||
state.video = q<HTMLVideoElement>(c, 'video')!;
|
||||
state.progress = q<HTMLElement>(c, '[data-progress]');
|
||||
state.scrubber = q<HTMLElement>(c, '[data-scrubber]');
|
||||
state.buffered = q<HTMLElement>(c, '[data-buffered]');
|
||||
state.timeDisplay = q<HTMLElement>(c, '[data-time]');
|
||||
state.durationDisplay = q<HTMLElement>(c, '[data-duration]');
|
||||
state.video = elements.video;
|
||||
state.progress = elements.progress;
|
||||
state.scrubber = elements.scrubber;
|
||||
state.buffered = elements.buffered;
|
||||
state.timeDisplay = elements.timeDisplay;
|
||||
state.durationDisplay = elements.durationDisplay;
|
||||
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]');
|
||||
state.previewTime = q<HTMLElement>(c, '[data-preview-time]');
|
||||
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]');
|
||||
@@ -143,4 +191,6 @@ export const initState = (c: HTMLElement): void => {
|
||||
state.parsedSegments = segments
|
||||
.map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
||||
.filter(s => s.end > s.start);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface SkipSegment {
|
||||
type: string; // 'op' or 'ed'
|
||||
start: number;
|
||||
end: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// parsed subtitle cue from VTT
|
||||
@@ -37,6 +38,7 @@ export interface ActiveSegment {
|
||||
type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// timeline range (handles seekable ranges in live streams)
|
||||
|
||||
@@ -388,6 +388,12 @@
|
||||
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||
dialog.addEventListener('click', (e) => { if (e.target === dialog) 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user