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
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user