refactor: reorganize project structure following go standards
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
package anime
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrAnimePendingFetch = errors.New("anime pending fetch")
|
||||
@@ -1,429 +0,0 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mal/internal/database"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/shared/middleware"
|
||||
"mal/internal/templates"
|
||||
)
|
||||
|
||||
func deduplicateAnimes(animes []jikan.Anime) []jikan.Anime {
|
||||
seen := make(map[int]bool)
|
||||
var result []jikan.Anime
|
||||
for _, a := range animes {
|
||||
if !seen[a.MalID] {
|
||||
seen[a.MalID] = true
|
||||
result = append(result, a)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
jikanClient *jikan.Client
|
||||
db database.Querier
|
||||
}
|
||||
|
||||
type quickSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates.NotFoundPage().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func writeInlineLoadError(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<p style="color: var(--text-muted); font-size: var(--text-sm);">` + message + `</p>`))
|
||||
}
|
||||
|
||||
func parsePageParam(r *http.Request) int {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
func userIDFromRequest(r *http.Request) string {
|
||||
user, ok := r.Context().Value(middleware.UserContextKey).(*database.User)
|
||||
if !ok || user == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return user.ID
|
||||
}
|
||||
|
||||
func NewHandler(jikanClient *jikan.Client, db database.Querier) *Handler {
|
||||
return &Handler{jikanClient: jikanClient, db: db}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
templates.Catalog().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Vary", "HX-Request")
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
templates.Search("").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
res, err := h.jikanClient.Search(r.Context(), query, 1)
|
||||
if err != nil {
|
||||
log.Printf("search error: %v", err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
writeInlineLoadError(w, "Search is temporarily unavailable. Please retry in a few seconds.")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to search anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
templates.SearchResultsWrapper(query, res.Animes, 2, res.HasNextPage).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
templates.Search(query).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPISearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
page := parsePageParam(r)
|
||||
|
||||
res, err := h.jikanClient.Search(r.Context(), query, page)
|
||||
if err != nil {
|
||||
log.Printf("search pagination error: %v", err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
writeInlineLoadError(w, "Unable to load more results right now. Please retry shortly.")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to fetch search page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res.Animes = deduplicateAnimes(res.Animes)
|
||||
|
||||
templates.SearchItems(query, res.Animes, page+1, res.HasNextPage).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) {
|
||||
page := parsePageParam(r)
|
||||
|
||||
result, err := h.jikanClient.GetTopAnime(r.Context(), page)
|
||||
if err == nil {
|
||||
result.Animes = deduplicateAnimes(result.Animes)
|
||||
templates.CatalogItems(result.Animes, page+1, result.HasNextPage).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if jikan.IsRetryableError(err) {
|
||||
templates.CatalogPlaceholderItems(25).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("top anime error: %v", err)
|
||||
http.Error(w, "Failed to fetch top anime", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Path[len("/anime/"):]
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id <= 0 {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
|
||||
userID := userIDFromRequest(r)
|
||||
|
||||
anime, err := h.jikanClient.GetAnimeByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if jikan.IsNotFoundError(err) {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
|
||||
h.jikanClient.EnqueueAnimeFetchRetry(r.Context(), id, err)
|
||||
if jikan.IsRetryableError(err) {
|
||||
templates.AnimePending(id).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("anime fetch error for %d: %v", id, err)
|
||||
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
currentStatus := ""
|
||||
nextEpisode := 1
|
||||
if userID != "" {
|
||||
entry, err := h.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: int64(id),
|
||||
})
|
||||
if err == nil {
|
||||
currentStatus = entry.Status
|
||||
if entry.CurrentEpisode.Valid {
|
||||
value := int(entry.CurrentEpisode.Int64)
|
||||
if value > 0 {
|
||||
nextEpisode = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templates.AnimeDetails(anime, currentStatus, nextEpisode).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path[len("/api/anime/"):]
|
||||
|
||||
idPart, section, ok := strings.Cut(path, "/")
|
||||
if !ok || section == "" {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(idPart)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch section {
|
||||
case "relations":
|
||||
relations, err := h.jikanClient.GetFullRelations(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
log.Printf("relations error for %d: %v", id, err)
|
||||
writeInlineLoadError(w, "Failed to load relations.")
|
||||
return
|
||||
}
|
||||
templates.AnimeRelationsList(relations).Render(r.Context(), w)
|
||||
case "recommendations":
|
||||
recs, err := h.jikanClient.GetRecommendations(r.Context(), id, 12)
|
||||
if err != nil {
|
||||
log.Printf("recommendations error for %d: %v", id, err)
|
||||
writeInlineLoadError(w, "Failed to load recommendations.")
|
||||
return
|
||||
}
|
||||
templates.AnimeRecommendations(recs).Render(r.Context(), w)
|
||||
case "episodes":
|
||||
currentEpisode := r.URL.Query().Get("current")
|
||||
episodes, err := h.getEpisodes(r.Context(), id)
|
||||
if err != nil {
|
||||
log.Printf("episodes error for %d: %v", id, err)
|
||||
writeInlineLoadError(w, "Failed to load episodes.")
|
||||
return
|
||||
}
|
||||
templates.EpisodeList(episodes, currentEpisode, id).Render(r.Context(), w)
|
||||
default:
|
||||
renderNotFoundPage(r, w)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIEpisodes(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path[len("/api/episodes/"):]
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
id, err := strconv.Atoi(path)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
currentEpisode := r.URL.Query().Get("current")
|
||||
episodes, err := h.getEpisodes(r.Context(), id)
|
||||
if err != nil {
|
||||
log.Printf("episodes error: %v", err)
|
||||
writeInlineLoadError(w, "Failed to load episodes.")
|
||||
return
|
||||
}
|
||||
|
||||
templates.EpisodeList(episodes, currentEpisode, id).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) getEpisodes(ctx context.Context, animeID int) ([]jikan.Episode, error) {
|
||||
var allEpisodes []jikan.Episode
|
||||
page := 1
|
||||
|
||||
for page <= 20 {
|
||||
result, err := h.jikanClient.GetEpisodes(ctx, animeID, page)
|
||||
if err != nil {
|
||||
if jikan.IsRetryableError(err) && len(allEpisodes) > 0 {
|
||||
return allEpisodes, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allEpisodes = append(allEpisodes, result.Data...)
|
||||
|
||||
if !result.Pagination.HasNextPage {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allEpisodes, nil
|
||||
}
|
||||
|
||||
func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode([]quickSearchResult{})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.jikanClient.SearchWithLimit(r.Context(), query, 1, 5)
|
||||
if err != nil {
|
||||
log.Printf("quick search error: %v", err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode([]quickSearchResult{})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
results := res.Animes
|
||||
|
||||
output := make([]quickSearchResult, len(results))
|
||||
for i, anime := range results {
|
||||
output[i] = quickSearchResult{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
Type: anime.Type,
|
||||
Image: anime.ImageURL(),
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(output)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
templates.Discover().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIDiscoverAiring(w http.ResponseWriter, r *http.Request) {
|
||||
page := parsePageParam(r)
|
||||
|
||||
res, err := h.jikanClient.GetSeasonsNow(r.Context(), page)
|
||||
if err != nil {
|
||||
log.Printf("airing anime error: %v", err)
|
||||
http.Error(w, "Failed to fetch airing anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res.Animes = deduplicateAnimes(res.Animes)
|
||||
|
||||
templates.DiscoverItems(res.Animes, "airing", page+1, res.HasNextPage).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
page := parsePageParam(r)
|
||||
|
||||
res, err := h.jikanClient.GetSeasonsUpcoming(r.Context(), page)
|
||||
if err != nil {
|
||||
log.Printf("upcoming anime error: %v", err)
|
||||
http.Error(w, "Failed to fetch upcoming anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res.Animes = deduplicateAnimes(res.Animes)
|
||||
|
||||
templates.DiscoverItems(res.Animes, "upcoming", page+1, res.HasNextPage).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleStudioDetails(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Path[len("/studios/"):]
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id <= 0 {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
|
||||
producer, err := h.jikanClient.GetProducerByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if jikan.IsNotFoundError(err) {
|
||||
renderNotFoundPage(r, w)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("studio fetch error for %d: %v", id, err)
|
||||
http.Error(w, "Failed to fetch studio details", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.jikanClient.GetAnimeByProducer(r.Context(), id, 1)
|
||||
if err != nil {
|
||||
log.Printf("studio anime fetch error for %d: %v", id, err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
// Render page with empty anime list if API is rate limiting
|
||||
templates.StudioDetails(producer, []jikan.Anime{}, false, 2).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
templates.StudioDetails(producer, result.Animes, result.HasNextPage, 2).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIStudioAnime(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path[len("/api/studios/"):]
|
||||
|
||||
idPart, after, ok := strings.Cut(path, "/")
|
||||
if !ok || after != "anime" {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(idPart)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := parsePageParam(r)
|
||||
|
||||
result, err := h.jikanClient.GetAnimeByProducer(r.Context(), id, page)
|
||||
if err != nil {
|
||||
log.Printf("studio anime pagination error for %d page %d: %v", id, page, err)
|
||||
if jikan.IsRetryableError(err) || errors.Is(err, context.Canceled) {
|
||||
writeInlineLoadError(w, "Unable to load more results right now. Please retry shortly.")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to fetch studio anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result.Animes = deduplicateAnimes(result.Animes)
|
||||
|
||||
templates.StudioAnimeItems(result.Animes, result.HasNextPage, id, page+1).Render(r.Context(), w)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrNotAuthenticated = errors.New("not authenticated")
|
||||
)
|
||||
|
||||
const bcryptCost = 12
|
||||
|
||||
type Service struct {
|
||||
db database.Querier
|
||||
}
|
||||
|
||||
func NewService(db database.Querier) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
func generateToken(size int) (string, error) {
|
||||
b := make([]byte, size)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func generateSessionToken() (string, error) {
|
||||
return generateToken(32)
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password string) (*database.Session, error) {
|
||||
user, err := s.db.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, fmt.Errorf("failed to lookup user: %w", err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
token, err := generateSessionToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * 24 * time.Hour) // 30 days
|
||||
session, err := s.db.CreateSession(ctx, database.CreateSessionParams{
|
||||
ID: token,
|
||||
UserID: user.ID,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*database.User, error) {
|
||||
session, err := s.db.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
_ = s.db.DeleteSession(ctx, sessionID)
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
user, err := s.db.GetUser(ctx, session.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user for session: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func SetSessionCookie(w http.ResponseWriter, sessionID string, expiresAt time.Time) {
|
||||
secure := os.Getenv("ENV") == "production" || os.Getenv("FORCE_SECURE_COOKIES") == "true"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: sessionID,
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mal/internal/templates"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
authService *Service
|
||||
}
|
||||
|
||||
const rateLimitFormError = "Too many attempts in a short time. Please wait a minute and try again."
|
||||
|
||||
func NewHandler(authService *Service) *Handler {
|
||||
return &Handler{authService: authService}
|
||||
}
|
||||
|
||||
func rateLimitErrorFromQuery(r *http.Request) string {
|
||||
if r.URL.Query().Get("error") == "rate_limited" {
|
||||
return rateLimitFormError
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
templates.Login("Something went wrong. Please try again.", "").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
templates.Login("The email or password is wrong.", username).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.authService.Login(r.Context(), username, password)
|
||||
if err != nil {
|
||||
templates.Login("The email or password is wrong.", username).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
SetSessionCookie(w, session.ID, session.ExpiresAt)
|
||||
|
||||
// HTMX-friendly redirect to root or previous page
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
templates.Login(rateLimitErrorFromQuery(r), "").Render(r.Context(), w)
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
allAnimeBaseURL = "https://api.allanime.day"
|
||||
allAnimeReferer = "https://allmanga.to"
|
||||
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"
|
||||
allAnimeAESKey = "ALLANIME_AES_KEY"
|
||||
)
|
||||
|
||||
type searchResult struct {
|
||||
ID string
|
||||
MalID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type allAnimeClient struct {
|
||||
httpClient *http.Client
|
||||
extractor *providerExtractor
|
||||
}
|
||||
|
||||
func newAllAnimeClient() *allAnimeClient {
|
||||
return &allAnimeClient{
|
||||
httpClient: &http.Client{Timeout: 12 * time.Second},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *allAnimeClient) graphqlRequest(ctx context.Context, query string, variables map[string]interface{}) (map[string]interface{}, error) {
|
||||
payload := map[string]interface{}{
|
||||
"query": query,
|
||||
"variables": variables,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal graphql payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create graphql request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", allAnimeReferer)
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute graphql request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read graphql response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graphql status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("decode graphql response: %w", err)
|
||||
}
|
||||
|
||||
if errs, ok := parsed["errors"].([]interface{}); ok && len(errs) > 0 {
|
||||
return nil, fmt.Errorf("graphql error: %v", errs[0])
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (c *allAnimeClient) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
|
||||
graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) {
|
||||
shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) {
|
||||
edges {
|
||||
_id
|
||||
malId
|
||||
name
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"search": map[string]interface{}{
|
||||
"allowAdult": false,
|
||||
"allowUnknown": false,
|
||||
"query": query,
|
||||
},
|
||||
"limit": 40,
|
||||
"page": 1,
|
||||
"translationType": mode,
|
||||
"countryOrigin": "ALL",
|
||||
}
|
||||
|
||||
result, err := c.graphqlRequest(ctx, graphqlQuery, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid search response")
|
||||
}
|
||||
|
||||
shows, ok := data["shows"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid shows payload")
|
||||
}
|
||||
|
||||
edges, ok := shows["edges"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid search edges")
|
||||
}
|
||||
|
||||
out := make([]searchResult, 0, len(edges))
|
||||
for _, edge := range edges {
|
||||
item, ok := edge.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
id, _ := item["_id"].(string)
|
||||
malID, _ := item["malId"].(string)
|
||||
name, _ := item["name"].(string)
|
||||
name = strings.ReplaceAll(name, `\\"`, `"`)
|
||||
name = strings.ReplaceAll(name, `\"`, `"`)
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *allAnimeClient) GetEpisodes(ctx context.Context, showID string, mode string) ([]string, error) {
|
||||
graphqlQuery := `query($showId: String!) {
|
||||
show(_id: $showId) {
|
||||
availableEpisodesDetail
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]interface{}{"showId": showID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid episode response")
|
||||
}
|
||||
|
||||
show, ok := data["show"].(map[string]interface{})
|
||||
if !ok || show == nil {
|
||||
return nil, fmt.Errorf("show not found")
|
||||
}
|
||||
|
||||
detail, ok := show["availableEpisodesDetail"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid episodes detail")
|
||||
}
|
||||
|
||||
rawList, ok := detail[mode].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no episodes for mode %s", mode)
|
||||
}
|
||||
|
||||
episodes := make([]string, 0, len(rawList))
|
||||
for _, item := range rawList {
|
||||
episode, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
episode = strings.TrimSpace(episode)
|
||||
if episode == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
episodes = append(episodes, episode)
|
||||
}
|
||||
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
func buildStreamSource(url, sourceType, provider string) StreamSource {
|
||||
return StreamSource{
|
||||
URL: url,
|
||||
Provider: provider,
|
||||
Type: sourceType,
|
||||
Referer: allAnimeReferer,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *allAnimeClient) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
|
||||
graphqlQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
|
||||
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
|
||||
sourceUrls
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"showId": showID,
|
||||
"translationType": mode,
|
||||
"episodeString": episode,
|
||||
}
|
||||
|
||||
result, err := c.graphqlRequest(ctx, graphqlQuery, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid source response")
|
||||
}
|
||||
|
||||
episodeData, err := extractEpisodeData(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawSourceURLs, ok := episodeData["sourceUrls"].([]interface{})
|
||||
if !ok || len(rawSourceURLs) == 0 {
|
||||
return nil, fmt.Errorf("no source urls")
|
||||
}
|
||||
|
||||
references := buildSourceReferences(rawSourceURLs)
|
||||
if len(references) == 0 {
|
||||
return nil, fmt.Errorf("no source references")
|
||||
}
|
||||
|
||||
out := make([]StreamSource, 0, len(references))
|
||||
for _, ref := range references {
|
||||
target := strings.TrimSpace(ref.URL)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
||||
sourceType := detectStreamType(target)
|
||||
if sourceType == "unknown" {
|
||||
sourceType = detectEmbedType(target)
|
||||
}
|
||||
|
||||
out = append(out, buildStreamSource(target, sourceType, ref.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
decoded := decodeSourceURL(target)
|
||||
if decoded == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") {
|
||||
sourceType := detectStreamType(decoded)
|
||||
if sourceType == "unknown" {
|
||||
sourceType = detectEmbedType(decoded)
|
||||
}
|
||||
|
||||
out = append(out, buildStreamSource(decoded, sourceType, ref.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(decoded, "/") {
|
||||
decoded = "/" + decoded
|
||||
}
|
||||
|
||||
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, extracted...)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no playable sources extracted")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type sourceReference struct {
|
||||
URL string
|
||||
Name string
|
||||
}
|
||||
|
||||
func buildSourceReferences(rawSourceURLs []interface{}) []sourceReference {
|
||||
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
|
||||
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
|
||||
|
||||
prioritized := make(map[string]sourceReference)
|
||||
fallback := make([]sourceReference, 0, len(rawSourceURLs))
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, source := range rawSourceURLs {
|
||||
item, ok := source.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceURL, _ := item["sourceUrl"].(string)
|
||||
sourceName, _ := item["sourceName"].(string)
|
||||
sourceURL = strings.TrimSpace(sourceURL)
|
||||
sourceName = strings.TrimSpace(sourceName)
|
||||
if sourceURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := seen[sourceURL]; exists {
|
||||
continue
|
||||
}
|
||||
seen[sourceURL] = struct{}{}
|
||||
|
||||
ref := sourceReference{URL: sourceURL, Name: sourceName}
|
||||
normalized := strings.ToLower(sourceName)
|
||||
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
|
||||
if _, exists := prioritized[normalized]; !exists {
|
||||
prioritized[normalized] = ref
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fallback = append(fallback, ref)
|
||||
}
|
||||
|
||||
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
|
||||
for _, provider := range priorityOrder {
|
||||
if ref, ok := prioritized[provider]; ok {
|
||||
ordered = append(ordered, ref)
|
||||
}
|
||||
}
|
||||
|
||||
ordered = append(ordered, fallback...)
|
||||
return ordered
|
||||
}
|
||||
|
||||
func extractEpisodeData(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
episodeData, ok := data["episode"].(map[string]interface{})
|
||||
if ok && episodeData != nil {
|
||||
return episodeData, nil
|
||||
}
|
||||
|
||||
toBeParsed, ok := data["tobeparsed"].(string)
|
||||
if !ok || strings.TrimSpace(toBeParsed) == "" {
|
||||
return nil, fmt.Errorf("episode not found")
|
||||
}
|
||||
|
||||
decoded, err := decryptTobeparsed(toBeParsed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode episode payload: %w", err)
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parse decoded payload: %w", err)
|
||||
}
|
||||
|
||||
episodeData, ok = parsed["episode"].(map[string]interface{})
|
||||
if !ok || episodeData == nil {
|
||||
return nil, fmt.Errorf("decoded payload missing episode")
|
||||
}
|
||||
|
||||
return episodeData, nil
|
||||
}
|
||||
|
||||
func decryptTobeparsed(encoded string) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode failed: %w", err)
|
||||
}
|
||||
|
||||
if len(raw) < 29 {
|
||||
return nil, fmt.Errorf("encrypted payload too short")
|
||||
}
|
||||
|
||||
iv := raw[:12]
|
||||
cipherText := raw[12 : len(raw)-16]
|
||||
tag := raw[len(raw)-16:]
|
||||
|
||||
keyStr := os.Getenv(allAnimeAESKey)
|
||||
if keyStr == "" {
|
||||
keyStr = "SimtVuagFbGR2K7P"
|
||||
}
|
||||
if len(keyStr) < 16 {
|
||||
return nil, fmt.Errorf("ALLANIME_AES_KEY must be at least 16 characters")
|
||||
}
|
||||
key := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cipher init failed: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err == nil {
|
||||
combined := append(append([]byte{}, cipherText...), tag...)
|
||||
plainText, openErr := gcm.Open(nil, iv, combined, nil)
|
||||
if openErr == nil && json.Valid(plainText) {
|
||||
return plainText, nil
|
||||
}
|
||||
}
|
||||
|
||||
ctrIV := append([]byte{}, iv...)
|
||||
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
|
||||
ctr := cipher.NewCTR(block, ctrIV)
|
||||
plainText := make([]byte, len(cipherText))
|
||||
ctr.XORKeyStream(plainText, cipherText)
|
||||
if !json.Valid(plainText) {
|
||||
return nil, fmt.Errorf("decryption failed")
|
||||
}
|
||||
|
||||
return plainText, nil
|
||||
}
|
||||
|
||||
func decodeSourceURL(encoded string) string {
|
||||
if encoded == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(encoded, "--") {
|
||||
encoded = encoded[2:]
|
||||
}
|
||||
|
||||
substitutions := map[string]string{
|
||||
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
|
||||
"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
|
||||
"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
|
||||
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
|
||||
"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
|
||||
"62": "Z",
|
||||
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
|
||||
"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
|
||||
"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
|
||||
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
|
||||
"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
|
||||
"42": "z",
|
||||
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
|
||||
"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
|
||||
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
|
||||
"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
|
||||
"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
|
||||
"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
|
||||
"05": "=", "1d": "%",
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for idx := 0; idx < len(encoded); {
|
||||
if idx+2 <= len(encoded) {
|
||||
pair := encoded[idx : idx+2]
|
||||
if sub, ok := substitutions[pair]; ok {
|
||||
result.WriteString(sub)
|
||||
idx += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result.WriteByte(encoded[idx])
|
||||
idx++
|
||||
}
|
||||
|
||||
decoded := result.String()
|
||||
if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
|
||||
decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
func detectStreamType(sourceURL string) string {
|
||||
lower := strings.ToLower(sourceURL)
|
||||
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
|
||||
return "m3u8"
|
||||
}
|
||||
|
||||
if strings.Contains(lower, ".mp4") {
|
||||
return "mp4"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func detectEmbedType(rawURL string) string {
|
||||
lower := strings.ToLower(rawURL)
|
||||
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
|
||||
for _, host := range embedHosts {
|
||||
if strings.Contains(lower, host) {
|
||||
return "embed"
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/database"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/shared/middleware"
|
||||
"mal/internal/templates"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
jikanClient *jikan.Client
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
|
||||
return &Handler{svc: svc, jikanClient: jikanClient}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/watch/")
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" || strings.HasPrefix(path, "proxy/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
malID, err := strconv.Atoi(parts[0])
|
||||
if err != nil || malID <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get episode from path if provided, otherwise from query
|
||||
episode := ""
|
||||
if len(parts) >= 2 {
|
||||
episode = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if episode == "" {
|
||||
episode = strings.TrimSpace(r.URL.Query().Get("ep"))
|
||||
}
|
||||
if episode == "" {
|
||||
episode = "1"
|
||||
}
|
||||
|
||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Fetch anime details
|
||||
anime, err := h.jikanClient.GetAnimeByID(ctx, malID)
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch anime %d: %v", malID, err)
|
||||
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if anime.Episodes > 0 {
|
||||
episodeNumber, parseErr := strconv.Atoi(episode)
|
||||
if parseErr == nil && episodeNumber > anime.Episodes {
|
||||
http.Redirect(w, r, "/watch/"+strconv.Itoa(malID)+"/"+strconv.Itoa(anime.Episodes), http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
titleCandidates := playbackTitleCandidates(anime)
|
||||
userID := watchlistUserIDFromRequest(r)
|
||||
data, err := h.svc.BuildWatchPageData(ctx, malID, titleCandidates, episode, mode, userID)
|
||||
if err != nil {
|
||||
log.Printf("watch page error for mal_id=%d: %v", malID, err)
|
||||
http.Error(w, "Failed to load playback", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert playback.WatchPageData to templates.WatchPageData
|
||||
pageData := templates.WatchPageData{
|
||||
MalID: data.MalID,
|
||||
Title: data.Title,
|
||||
TitleEnglish: anime.TitleEnglish,
|
||||
TitleJapanese: anime.TitleJapanese,
|
||||
ImageURL: anime.ImageURL(),
|
||||
Airing: anime.Airing,
|
||||
CurrentEpisode: data.CurrentEpisode,
|
||||
TotalEpisodes: anime.Episodes,
|
||||
StartTimeSeconds: data.StartTimeSeconds,
|
||||
CurrentStatus: data.CurrentStatus,
|
||||
InitialMode: data.InitialMode,
|
||||
AvailableModes: data.AvailableModes,
|
||||
ModeSources: convertModeSources(data.ModeSources),
|
||||
Segments: convertSegments(data.Segments),
|
||||
}
|
||||
|
||||
templates.WatchPage(anime, pageData).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func watchlistUserIDFromRequest(r *http.Request) string {
|
||||
user, ok := r.Context().Value(middleware.UserContextKey).(*database.User)
|
||||
if !ok || user == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return user.ID
|
||||
}
|
||||
|
||||
func playbackTitleCandidates(anime jikan.Anime) []string {
|
||||
out := make([]string, 0, 3+len(anime.TitleSynonyms))
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
add := func(value string) {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.ToLower(normalized)
|
||||
if _, exists := seen[key]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, normalized)
|
||||
}
|
||||
|
||||
add(anime.Title)
|
||||
add(anime.TitleEnglish)
|
||||
add(anime.TitleJapanese)
|
||||
for _, synonym := range anime.TitleSynonyms {
|
||||
add(synonym)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func convertModeSources(sources map[string]ModeSource) map[string]templates.ModeSource {
|
||||
result := make(map[string]templates.ModeSource, len(sources))
|
||||
for k, v := range sources {
|
||||
subtitles := make([]templates.SubtitleItem, len(v.Subtitles))
|
||||
for i, s := range v.Subtitles {
|
||||
subtitles[i] = templates.SubtitleItem{
|
||||
Lang: s.Lang,
|
||||
Token: s.Token,
|
||||
}
|
||||
}
|
||||
result[k] = templates.ModeSource{
|
||||
Token: v.Token,
|
||||
Subtitles: subtitles,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertSegments(segments []SkipSegment) []templates.SkipSegment {
|
||||
result := make([]templates.SkipSegment, len(segments))
|
||||
for i, s := range segments {
|
||||
result[i] = templates.SkipSegment{
|
||||
Type: s.Type,
|
||||
Start: s.Start,
|
||||
End: s.End,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
if token == "" {
|
||||
http.Error(w, "missing playback token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
scope := proxyScope(strings.TrimPrefix(r.URL.Path, "/watch/proxy/"))
|
||||
scopeLabel := map[proxyScope]string{
|
||||
proxyScopeStream: "stream",
|
||||
proxyScopeSegment: "segment",
|
||||
proxyScopeSubtitle: "subtitle",
|
||||
}[scope]
|
||||
if scopeLabel == "" {
|
||||
http.Error(w, "invalid proxy scope", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL, referer, err := h.svc.resolveProxyToken(r.Context(), token, scope)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid %s token", scopeLabel), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.proxyUpstream(w, r, targetURL, referer)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
type saveProgressRequest struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Episode int `json:"episode"`
|
||||
TimeSecond float64 `json:"time_seconds"`
|
||||
}
|
||||
|
||||
var payload saveProgressRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.MalID <= 0 || payload.Episode <= 0 {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
timeSeconds := payload.TimeSecond
|
||||
if timeSeconds < 0 || timeSeconds != timeSeconds {
|
||||
timeSeconds = 0
|
||||
}
|
||||
|
||||
if h.svc.db == nil {
|
||||
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
animeID := int64(payload.MalID)
|
||||
|
||||
animeSeed, err := h.ensureAnimeSeed(r.Context(), payload.MalID)
|
||||
if err != nil {
|
||||
log.Printf("save progress failed to resolve anime user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||
http.Error(w, "failed to save progress", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SaveProgress(r.Context(), user.ID, animeID, payload.Episode, timeSeconds, animeSeed); err != nil {
|
||||
log.Printf("save progress failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||
http.Error(w, "failed to save progress", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
type completeAnimeRequest struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Episode int `json:"episode"`
|
||||
}
|
||||
|
||||
var payload completeAnimeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.MalID <= 0 || payload.Episode <= 0 {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
animeID := int64(payload.MalID)
|
||||
animeSeed, err := h.ensureAnimeSeed(r.Context(), payload.MalID)
|
||||
if err != nil {
|
||||
log.Printf("complete anime failed to resolve anime user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.CompleteAnime(r.Context(), user.ID, animeID, payload.Episode, animeSeed); err != nil {
|
||||
log.Printf("complete anime failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err)
|
||||
http.Error(w, "failed to mark anime completed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ensureAnimeSeed(ctx context.Context, malID int) (*database.UpsertAnimeParams, error) {
|
||||
animeID := int64(malID)
|
||||
if _, err := h.svc.db.GetAnime(ctx, animeID); err == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
anime, err := h.jikanClient.GetAnimeByID(ctx, malID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &database.UpsertAnimeParams{
|
||||
ID: animeID,
|
||||
TitleOriginal: anime.Title,
|
||||
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
|
||||
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
|
||||
ImageUrl: anime.ImageURL(),
|
||||
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) proxyUpstream(w http.ResponseWriter, r *http.Request, targetURL string, referer string) {
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
http.Error(w, "invalid upstream url", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
statusCode, headers, rewrittenBody, streamBody, err := h.svc.ProxyStream(ctx, targetURL, referer, r.Header.Get("Range"))
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) || errors.Is(r.Context().Err(), context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("proxy error for url=%s: %v", targetURL, err)
|
||||
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(statusCode)
|
||||
if len(rewrittenBody) > 0 {
|
||||
_, _ = w.Write(rewrittenBody)
|
||||
return
|
||||
}
|
||||
|
||||
if streamBody == nil {
|
||||
return
|
||||
}
|
||||
defer streamBody.Close()
|
||||
_, _ = io.Copy(w, streamBody)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func doProxiedRequest(ctx context.Context, client *http.Client, url string, referer string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
if referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
func (s *Service) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64, animeSeed *database.UpsertAnimeParams) error {
|
||||
if strings.TrimSpace(userID) == "" || animeID <= 0 || episode <= 0 {
|
||||
return errors.New("invalid save progress input")
|
||||
}
|
||||
|
||||
txQueries, tx, err := database.BeginTx(ctx, s.sqlDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer tx.Rollback()
|
||||
|
||||
if animeSeed != nil {
|
||||
if _, err := txQueries.UpsertAnime(ctx, *animeSeed); err != nil {
|
||||
return fmt.Errorf("failed to save anime reference: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
watchListEntry, watchListErr := txQueries.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
})
|
||||
if watchListErr != nil && !errors.Is(watchListErr, sql.ErrNoRows) {
|
||||
return fmt.Errorf("failed to load watchlist entry: %w", watchListErr)
|
||||
}
|
||||
|
||||
if watchListErr == nil && watchListEntry.Status == "completed" {
|
||||
if err := txQueries.DeleteContinueWatchingEntry(ctx, database.DeleteContinueWatchingEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to clear continue entry: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit save progress transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := txQueries.SaveWatchProgress(ctx, database.SaveWatchProgressParams{
|
||||
CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true},
|
||||
CurrentTimeSeconds: timeSeconds,
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("failed to save watchlist progress: %w", err)
|
||||
}
|
||||
|
||||
if _, err := txQueries.UpsertContinueWatchingEntry(ctx, database.UpsertContinueWatchingEntryParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true},
|
||||
CurrentTimeSeconds: timeSeconds,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to upsert continue entry: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit save progress transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CompleteAnime(ctx context.Context, userID string, animeID int64, episode int, animeSeed *database.UpsertAnimeParams) error {
|
||||
if strings.TrimSpace(userID) == "" || animeID <= 0 || episode <= 0 {
|
||||
return errors.New("invalid complete anime input")
|
||||
}
|
||||
|
||||
txQueries, tx, err := database.BeginTx(ctx, s.sqlDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer tx.Rollback()
|
||||
|
||||
watchListEntry, watchListErr := txQueries.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
})
|
||||
if watchListErr != nil && !errors.Is(watchListErr, sql.ErrNoRows) {
|
||||
return fmt.Errorf("failed to load watchlist entry: %w", watchListErr)
|
||||
}
|
||||
|
||||
alreadyCompleted := watchListErr == nil && watchListEntry.Status == "completed"
|
||||
|
||||
if !alreadyCompleted {
|
||||
if animeSeed != nil {
|
||||
if _, err := txQueries.UpsertAnime(ctx, *animeSeed); err != nil {
|
||||
return fmt.Errorf("failed to save anime reference: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := txQueries.UpsertWatchListEntry(ctx, database.UpsertWatchListEntryParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
Status: "completed",
|
||||
CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true},
|
||||
CurrentTimeSeconds: 0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to mark watchlist as completed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := txQueries.DeleteContinueWatchingEntry(ctx, database.DeleteContinueWatchingEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to clear continue entry: %w", err)
|
||||
}
|
||||
|
||||
if err := txQueries.SaveWatchProgress(ctx, database.SaveWatchProgressParams{
|
||||
CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true},
|
||||
CurrentTimeSeconds: 0,
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("failed to reset watch progress: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit complete anime transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type providerExtractor struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
referer string
|
||||
}
|
||||
|
||||
func newProviderExtractor() *providerExtractor {
|
||||
return &providerExtractor{
|
||||
httpClient: &http.Client{Timeout: 12 * time.Second},
|
||||
baseURL: "https://allanime.day",
|
||||
referer: allAnimeReferer,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath string) ([]StreamSource, error) {
|
||||
endpoint := e.baseURL + providerPath
|
||||
resp, err := doProxiedRequest(ctx, e.httpClient, endpoint, e.referer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch provider response: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read provider response: %w", err)
|
||||
}
|
||||
|
||||
return e.parseProviderResponse(ctx, string(body))
|
||||
}
|
||||
|
||||
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) ([]StreamSource, error) {
|
||||
sources := make([]StreamSource, 0)
|
||||
providerReferer := e.referer
|
||||
|
||||
refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`)
|
||||
if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 {
|
||||
providerReferer = strings.ReplaceAll(match[1], `\/`, "/")
|
||||
}
|
||||
if providerReferer == "" {
|
||||
providerReferer = e.referer
|
||||
}
|
||||
|
||||
linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`)
|
||||
for _, match := range linkPattern.FindAllStringSubmatch(response, -1) {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
link := strings.ReplaceAll(match[1], `\/`, "/")
|
||||
quality := strings.TrimSpace(match[2])
|
||||
sourceType := detectStreamType(link)
|
||||
if sourceType == "unknown" {
|
||||
sourceType = detectEmbedType(link)
|
||||
}
|
||||
|
||||
sources = append(sources, StreamSource{
|
||||
URL: link,
|
||||
Quality: quality,
|
||||
Provider: "wixmp",
|
||||
Type: sourceType,
|
||||
Referer: providerReferer,
|
||||
})
|
||||
}
|
||||
|
||||
hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`)
|
||||
for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
playlistURL := strings.ReplaceAll(match[1], `\/`, "/")
|
||||
if strings.Contains(playlistURL, "master.m3u8") {
|
||||
parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer)
|
||||
if err == nil {
|
||||
sources = append(sources, parsed...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
sources = append(sources, StreamSource{
|
||||
URL: playlistURL,
|
||||
Quality: "auto",
|
||||
Provider: "hls",
|
||||
Type: "m3u8",
|
||||
Referer: providerReferer,
|
||||
})
|
||||
}
|
||||
|
||||
subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`)
|
||||
if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 {
|
||||
subtitles := make([]Subtitle, 0)
|
||||
subtitleEntryPattern := regexp.MustCompile(`"lang":"([^"]+)".*?"src":"([^"]+)"`)
|
||||
for _, entry := range subtitleEntryPattern.FindAllStringSubmatch(subtitleMatch[1], -1) {
|
||||
if len(entry) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
subtitles = append(subtitles, Subtitle{
|
||||
Lang: strings.TrimSpace(entry[1]),
|
||||
URL: strings.ReplaceAll(entry[2], `\/`, "/"),
|
||||
})
|
||||
}
|
||||
|
||||
if len(subtitles) > 0 {
|
||||
for idx := range sources {
|
||||
sources[idx].Subtitles = subtitles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, referer string) ([]StreamSource, error) {
|
||||
resp, err := doProxiedRequest(ctx, e.httpClient, masterURL, referer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(body), "\n")
|
||||
baseURL := masterURL
|
||||
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
|
||||
baseURL = masterURL[:idx+1]
|
||||
}
|
||||
|
||||
currentBandwidth := 0
|
||||
sources := make([]StreamSource, 0)
|
||||
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") {
|
||||
match := bwPattern.FindStringSubmatch(trimmed)
|
||||
if len(match) >= 2 {
|
||||
value, convErr := strconv.Atoi(match[1])
|
||||
if convErr == nil {
|
||||
currentBandwidth = value
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
streamURL := trimmed
|
||||
if !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") {
|
||||
streamURL = baseURL + streamURL
|
||||
}
|
||||
|
||||
quality := "auto"
|
||||
kbps := currentBandwidth / 1000
|
||||
switch {
|
||||
case kbps >= 8000:
|
||||
quality = "1080p"
|
||||
case kbps >= 5000:
|
||||
quality = "720p"
|
||||
case kbps >= 2500:
|
||||
quality = "480p"
|
||||
case kbps > 0:
|
||||
quality = "360p"
|
||||
}
|
||||
|
||||
sources = append(sources, StreamSource{
|
||||
URL: streamURL,
|
||||
Quality: quality,
|
||||
Provider: "hls",
|
||||
Type: "m3u8",
|
||||
Referer: referer,
|
||||
})
|
||||
}
|
||||
|
||||
return sources, nil
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
proxyStreamTokenTTL = 2 * time.Hour
|
||||
proxySegmentTokenTTL = 6 * time.Hour
|
||||
proxySubtitleTokenTTL = 6 * time.Hour
|
||||
)
|
||||
const proxyHostCheckTTL = 2 * time.Minute
|
||||
|
||||
type proxyScope string
|
||||
|
||||
const (
|
||||
proxyScopeStream proxyScope = "stream"
|
||||
proxyScopeSegment proxyScope = "segment"
|
||||
proxyScopeSubtitle proxyScope = "subtitle"
|
||||
)
|
||||
|
||||
type proxyTokenPayload struct {
|
||||
TargetURL string `json:"u"`
|
||||
Referer string `json:"r,omitempty"`
|
||||
Scope string `json:"s"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type proxyTokenSigner struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
func newProxyTokenSigner(secret string) (*proxyTokenSigner, error) {
|
||||
trimmed := strings.TrimSpace(secret)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("proxy token secret is required")
|
||||
}
|
||||
|
||||
if len(trimmed) < 32 {
|
||||
return nil, errors.New("proxy token secret must be at least 32 characters")
|
||||
}
|
||||
|
||||
return &proxyTokenSigner{secret: []byte(trimmed)}, nil
|
||||
}
|
||||
|
||||
func (s *proxyTokenSigner) Sign(payload proxyTokenPayload) (string, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal proxy token payload: %w", err)
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write(body)
|
||||
signature := mac.Sum(nil)
|
||||
|
||||
encodedBody := base64.RawURLEncoding.EncodeToString(body)
|
||||
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
|
||||
return encodedBody + "." + encodedSignature, nil
|
||||
}
|
||||
|
||||
func (s *proxyTokenSigner) Verify(token string) (proxyTokenPayload, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return proxyTokenPayload{}, errors.New("invalid proxy token format")
|
||||
}
|
||||
|
||||
body, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return proxyTokenPayload{}, errors.New("invalid proxy token payload")
|
||||
}
|
||||
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return proxyTokenPayload{}, errors.New("invalid proxy token signature")
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write(body)
|
||||
expected := mac.Sum(nil)
|
||||
if !hmac.Equal(signature, expected) {
|
||||
return proxyTokenPayload{}, errors.New("invalid proxy token signature")
|
||||
}
|
||||
|
||||
var payload proxyTokenPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return proxyTokenPayload{}, errors.New("invalid proxy token payload")
|
||||
}
|
||||
|
||||
if payload.ExpiresAt <= time.Now().Unix() {
|
||||
return proxyTokenPayload{}, errors.New("proxy token expired")
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildClientModeSources(modeSources map[string]ModeSource) (map[string]ModeSource, error) {
|
||||
clientModeSources := make(map[string]ModeSource, len(modeSources))
|
||||
|
||||
for mode, source := range modeSources {
|
||||
streamToken, err := s.issueProxyToken(source.URL, source.Referer, proxyScopeStream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subtitles := make([]SubtitleItem, 0, len(source.Subtitles))
|
||||
for _, subtitle := range source.Subtitles {
|
||||
targetURL := strings.TrimSpace(subtitle.URL)
|
||||
if targetURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
token, err := s.issueProxyToken(targetURL, source.Referer, proxyScopeSubtitle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subtitles = append(subtitles, SubtitleItem{
|
||||
Lang: subtitle.Lang,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
clientModeSources[mode] = ModeSource{
|
||||
Token: streamToken,
|
||||
Subtitles: subtitles,
|
||||
}
|
||||
}
|
||||
|
||||
return clientModeSources, nil
|
||||
}
|
||||
|
||||
func (s *Service) issueProxyToken(targetURL string, referer string, scope proxyScope) (string, error) {
|
||||
normalizedTarget, err := normalizeProxyURL(targetURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalizedReferer := ""
|
||||
if strings.TrimSpace(referer) != "" {
|
||||
refererURL, refererErr := normalizeProxyURL(referer)
|
||||
if refererErr == nil {
|
||||
normalizedReferer = refererURL
|
||||
}
|
||||
}
|
||||
|
||||
return s.proxyTokens.Sign(proxyTokenPayload{
|
||||
TargetURL: normalizedTarget,
|
||||
Referer: normalizedReferer,
|
||||
Scope: string(scope),
|
||||
ExpiresAt: time.Now().Add(proxyTokenTTL(scope)).Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
var proxyTokenTTLs = map[proxyScope]time.Duration{
|
||||
proxyScopeStream: proxyStreamTokenTTL,
|
||||
proxyScopeSegment: proxySegmentTokenTTL,
|
||||
proxyScopeSubtitle: proxySubtitleTokenTTL,
|
||||
}
|
||||
|
||||
func proxyTokenTTL(scope proxyScope) time.Duration {
|
||||
if ttl, ok := proxyTokenTTLs[scope]; ok {
|
||||
return ttl
|
||||
}
|
||||
return proxyStreamTokenTTL
|
||||
}
|
||||
|
||||
func (s *Service) resolveProxyToken(ctx context.Context, token string, scope proxyScope) (string, string, error) {
|
||||
payload, err := s.proxyTokens.Verify(token)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if payload.Scope != string(scope) {
|
||||
return "", "", errors.New("proxy token scope mismatch")
|
||||
}
|
||||
|
||||
normalizedTarget, err := normalizeProxyURL(payload.TargetURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := s.ensurePublicProxyTarget(ctx, normalizedTarget); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
normalizedReferer := ""
|
||||
if strings.TrimSpace(payload.Referer) != "" {
|
||||
refererURL, refererErr := normalizeProxyURL(payload.Referer)
|
||||
if refererErr == nil {
|
||||
if ensureErr := s.ensurePublicProxyTarget(ctx, refererURL); ensureErr == nil {
|
||||
normalizedReferer = refererURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedTarget, normalizedReferer, nil
|
||||
}
|
||||
|
||||
func normalizeProxyURL(rawURL string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", errors.New("invalid proxy target")
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", errors.New("invalid proxy target scheme")
|
||||
}
|
||||
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
if host == "" {
|
||||
return "", errors.New("invalid proxy target host")
|
||||
}
|
||||
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
|
||||
return "", errors.New("localhost targets are not allowed")
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip != nil && isBlockedProxyIP(ip) {
|
||||
return "", errors.New("private proxy targets are not allowed")
|
||||
}
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func isBlockedProxyIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return errors.New("invalid proxy target")
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(parsed.Hostname())
|
||||
if host == "" {
|
||||
return errors.New("invalid proxy target host")
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isBlockedProxyIP(ip) {
|
||||
return errors.New("private proxy targets are not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
s.proxyHostMu.RLock()
|
||||
cached, ok := s.proxyHostCache[host]
|
||||
s.proxyHostMu.RUnlock()
|
||||
if ok && now.Before(cached.ExpiresAt) {
|
||||
if cached.Allowed {
|
||||
return nil
|
||||
}
|
||||
return errors.New("private proxy targets are not allowed")
|
||||
}
|
||||
|
||||
resolvedIPs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil || len(resolvedIPs) == 0 {
|
||||
return errors.New("proxy target lookup failed")
|
||||
}
|
||||
|
||||
allowed := true
|
||||
for _, resolved := range resolvedIPs {
|
||||
if isBlockedProxyIP(resolved.IP) {
|
||||
allowed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
s.proxyHostMu.Lock()
|
||||
s.proxyHostCache[host] = proxyHostCacheItem{
|
||||
Allowed: allowed,
|
||||
ExpiresAt: now.Add(proxyHostCheckTTL),
|
||||
}
|
||||
s.proxyHostMu.Unlock()
|
||||
|
||||
if !allowed {
|
||||
return errors.New("private proxy targets are not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) rewritePlaylistWithTokens(ctx context.Context, content string, baseURL string, referer string) (string, error) {
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
out.WriteString(line)
|
||||
out.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
|
||||
relativeURL, parseErr := url.Parse(trimmed)
|
||||
if parseErr != nil {
|
||||
out.WriteString(line)
|
||||
out.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
|
||||
absoluteURL := base.ResolveReference(relativeURL).String()
|
||||
token, tokenErr := s.issueProxyToken(absoluteURL, referer, proxyScopeSegment)
|
||||
if tokenErr != nil {
|
||||
return "", tokenErr
|
||||
}
|
||||
|
||||
proxied := "/watch/proxy/segment?token=" + url.QueryEscape(token)
|
||||
out.WriteString(proxied)
|
||||
out.WriteString("\n")
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return out.String(), nil
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
func TestNormalizeProxyURLRejectsLocalhost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := normalizeProxyURL("http://localhost:8080/private")
|
||||
if err == nil {
|
||||
t.Fatal("expected localhost URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProxyURLRejectsPrivateIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := normalizeProxyURL("http://192.168.1.10/stream")
|
||||
if err == nil {
|
||||
t.Fatal("expected private IP URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyTokenScopeValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := NewService(&fakeProxyQuerier{}, nil, Config{ProxyTokenSecret: "0123456789abcdef0123456789abcdef"})
|
||||
token, err := service.issueProxyToken("https://example.com/playlist.m3u8", "", proxyScopeStream)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to issue token: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = service.resolveProxyToken(context.Background(), token, proxyScopeSegment)
|
||||
if err == nil {
|
||||
t.Fatal("expected scope mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeProxyQuerier struct {
|
||||
database.Querier
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mal/internal/database"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
showResolutionCacheTTL = 12 * time.Hour
|
||||
playbackDataCacheTTL = 2 * time.Minute
|
||||
providerProbeTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
allAnimeClient *allAnimeClient
|
||||
httpClient *http.Client
|
||||
sqlDB *sql.DB
|
||||
db database.Querier
|
||||
proxyTokens *proxyTokenSigner
|
||||
proxyHostMu sync.RWMutex
|
||||
proxyHostCache map[string]proxyHostCacheItem
|
||||
|
||||
cacheMu sync.RWMutex
|
||||
showResolution map[int]showResolutionCacheItem
|
||||
playbackDataCache map[string]playbackDataCacheItem
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ProxyTokenSecret string
|
||||
}
|
||||
|
||||
type sourceScore struct {
|
||||
source StreamSource
|
||||
total int
|
||||
typeScore int
|
||||
providerScore int
|
||||
qualityScore int
|
||||
refererScore int
|
||||
}
|
||||
|
||||
type showResolutionCacheItem struct {
|
||||
ShowID string
|
||||
Title string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type playbackDataCacheItem struct {
|
||||
Data playbackBaseData
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type playbackBaseData struct {
|
||||
Title string
|
||||
AvailableModes []string
|
||||
ModeSources map[string]ModeSource
|
||||
Segments []SkipSegment
|
||||
}
|
||||
|
||||
type modeSourceResult struct {
|
||||
Mode string
|
||||
Source ModeSource
|
||||
OK bool
|
||||
}
|
||||
|
||||
type searchModeResult struct {
|
||||
Mode string
|
||||
Results []searchResult
|
||||
Err error
|
||||
}
|
||||
|
||||
type directProbeResult struct {
|
||||
Playable bool
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type proxyHostCacheItem struct {
|
||||
Allowed bool
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type userPlaybackState struct {
|
||||
CurrentStatus string
|
||||
StartTimeSeconds float64
|
||||
}
|
||||
|
||||
func NewService(db database.Querier, sqlDB *sql.DB, cfg Config) *Service {
|
||||
proxyTokens, err := newProxyTokenSigner(cfg.ProxyTokenSecret)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to initialize proxy token signer: %v", err))
|
||||
}
|
||||
|
||||
return &Service{
|
||||
allAnimeClient: newAllAnimeClient(),
|
||||
httpClient: &http.Client{Timeout: 12 * time.Second},
|
||||
sqlDB: sqlDB,
|
||||
db: db,
|
||||
proxyTokens: proxyTokens,
|
||||
proxyHostCache: make(map[string]proxyHostCacheItem),
|
||||
showResolution: make(map[int]showResolutionCacheItem),
|
||||
playbackDataCache: make(map[string]playbackDataCacheItem),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) BuildWatchPageData(ctx context.Context, malID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) {
|
||||
if malID <= 0 {
|
||||
return WatchPageData{}, errors.New("invalid mal id")
|
||||
}
|
||||
|
||||
normalizedMode := normalizeMode(mode)
|
||||
if normalizedMode == "" {
|
||||
normalizedMode = "dub"
|
||||
}
|
||||
|
||||
normalizedEpisode := strings.TrimSpace(episode)
|
||||
if normalizedEpisode == "" {
|
||||
normalizedEpisode = "1"
|
||||
}
|
||||
|
||||
userStateCh := s.fetchUserPlaybackStateAsync(ctx, userID, malID, normalizedEpisode)
|
||||
|
||||
cacheKey := playbackDataCacheKey(malID, normalizedEpisode)
|
||||
baseData, cacheHit := s.getPlaybackBaseDataCache(cacheKey)
|
||||
if !cacheHit {
|
||||
showID, resolvedTitle, err := s.resolveShowCached(ctx, malID, titleCandidates)
|
||||
if err != nil {
|
||||
return WatchPageData{}, err
|
||||
}
|
||||
|
||||
modeSources, segments := s.fetchPlaybackSourcesAndSegments(ctx, showID, malID, normalizedEpisode)
|
||||
if len(modeSources) == 0 {
|
||||
return WatchPageData{}, errors.New("no direct playable sources available")
|
||||
}
|
||||
|
||||
watchTitle := strings.TrimSpace(resolvedTitle)
|
||||
if watchTitle == "" {
|
||||
watchTitle = firstNonEmptyTitle(titleCandidates)
|
||||
}
|
||||
if watchTitle == "" {
|
||||
watchTitle = fmt.Sprintf("MAL #%d", malID)
|
||||
}
|
||||
|
||||
baseData = playbackBaseData{
|
||||
Title: watchTitle,
|
||||
AvailableModes: availableModes(modeSources),
|
||||
ModeSources: modeSources,
|
||||
Segments: segments,
|
||||
}
|
||||
|
||||
s.setPlaybackBaseDataCache(cacheKey, baseData)
|
||||
}
|
||||
|
||||
initialMode := selectInitialMode(normalizedMode, baseData.ModeSources)
|
||||
|
||||
clientModeSources, err := s.buildClientModeSources(baseData.ModeSources)
|
||||
if err != nil {
|
||||
return WatchPageData{}, err
|
||||
}
|
||||
|
||||
if _, ok := clientModeSources[initialMode]; !ok {
|
||||
return WatchPageData{}, errors.New("stream mode unavailable")
|
||||
}
|
||||
|
||||
userState := userPlaybackState{}
|
||||
if userStateCh != nil {
|
||||
userState = <-userStateCh
|
||||
}
|
||||
|
||||
return WatchPageData{
|
||||
MalID: malID,
|
||||
Title: baseData.Title,
|
||||
CurrentEpisode: normalizedEpisode,
|
||||
StartTimeSeconds: userState.StartTimeSeconds,
|
||||
CurrentStatus: userState.CurrentStatus,
|
||||
InitialMode: initialMode,
|
||||
AvailableModes: cloneSlice(baseData.AvailableModes),
|
||||
ModeSources: clientModeSources,
|
||||
Segments: cloneSlice(baseData.Segments),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func playbackDataCacheKey(malID int, episode string) string {
|
||||
return fmt.Sprintf("%d:%s", malID, episode)
|
||||
}
|
||||
|
||||
func (s *Service) fetchUserPlaybackStateAsync(ctx context.Context, userID string, malID int, episode string) <-chan userPlaybackState {
|
||||
if userID == "" || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resultCh := make(chan userPlaybackState, 1)
|
||||
go func() {
|
||||
state := userPlaybackState{}
|
||||
|
||||
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: int64(malID),
|
||||
})
|
||||
if err == nil {
|
||||
state.CurrentStatus = entry.Status
|
||||
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode && entry.CurrentTimeSeconds > 0 {
|
||||
state.StartTimeSeconds = entry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
if state.StartTimeSeconds <= 0 {
|
||||
continueEntry, continueErr := s.db.GetContinueWatchingEntry(ctx, database.GetContinueWatchingEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: int64(malID),
|
||||
})
|
||||
if continueErr == nil && continueEntry.CurrentEpisode.Valid && strconv.FormatInt(continueEntry.CurrentEpisode.Int64, 10) == episode && continueEntry.CurrentTimeSeconds > 0 {
|
||||
state.StartTimeSeconds = continueEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
resultCh <- state
|
||||
}()
|
||||
|
||||
return resultCh
|
||||
}
|
||||
|
||||
func (s *Service) getPlaybackBaseDataCache(key string) (playbackBaseData, bool) {
|
||||
now := time.Now()
|
||||
|
||||
s.cacheMu.RLock()
|
||||
item, ok := s.playbackDataCache[key]
|
||||
s.cacheMu.RUnlock()
|
||||
if !ok {
|
||||
return playbackBaseData{}, false
|
||||
}
|
||||
|
||||
if now.After(item.ExpiresAt) {
|
||||
s.cacheMu.Lock()
|
||||
current, exists := s.playbackDataCache[key]
|
||||
if exists && time.Now().After(current.ExpiresAt) {
|
||||
delete(s.playbackDataCache, key)
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
return playbackBaseData{}, false
|
||||
}
|
||||
|
||||
return clonePlaybackBaseData(item.Data), true
|
||||
}
|
||||
|
||||
func (s *Service) setPlaybackBaseDataCache(key string, data playbackBaseData) {
|
||||
s.cacheMu.Lock()
|
||||
s.playbackDataCache[key] = playbackDataCacheItem{
|
||||
Data: clonePlaybackBaseData(data),
|
||||
ExpiresAt: time.Now().Add(playbackDataCacheTTL),
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandidates []string) (string, string, error) {
|
||||
s.cacheMu.RLock()
|
||||
item, ok := s.showResolution[malID]
|
||||
s.cacheMu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
if ok && now.Before(item.ExpiresAt) && strings.TrimSpace(item.ShowID) != "" {
|
||||
return item.ShowID, item.Title, nil
|
||||
}
|
||||
|
||||
showID, resolvedTitle, err := s.resolveShow(ctx, malID, titleCandidates)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
s.cacheMu.Lock()
|
||||
s.showResolution[malID] = showResolutionCacheItem{
|
||||
ShowID: showID,
|
||||
Title: resolvedTitle,
|
||||
ExpiresAt: now.Add(showResolutionCacheTTL),
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
return showID, resolvedTitle, nil
|
||||
}
|
||||
|
||||
func (s *Service) fetchPlaybackSourcesAndSegments(ctx context.Context, showID string, malID int, episode string) (map[string]ModeSource, []SkipSegment) {
|
||||
modeCh := make(chan modeSourceResult, 2)
|
||||
probeCache := make(map[string]directProbeResult)
|
||||
probeCacheMu := sync.Mutex{}
|
||||
|
||||
for _, mode := range []string{"dub", "sub"} {
|
||||
modeValue := mode
|
||||
go func() {
|
||||
resolved, err := s.resolveModeSourceWithCache(ctx, showID, episode, modeValue, "best", probeCache, &probeCacheMu)
|
||||
if err != nil {
|
||||
modeCh <- modeSourceResult{Mode: modeValue, OK: false}
|
||||
return
|
||||
}
|
||||
|
||||
if strings.ToLower(resolved.Type) == "embed" {
|
||||
modeCh <- modeSourceResult{Mode: modeValue, OK: false}
|
||||
return
|
||||
}
|
||||
|
||||
modeCh <- modeSourceResult{
|
||||
Mode: modeValue,
|
||||
Source: ModeSource{
|
||||
URL: resolved.URL,
|
||||
Referer: resolved.Referer,
|
||||
Subtitles: toSubtitleItems(resolved),
|
||||
},
|
||||
OK: true,
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
segmentsCh := make(chan []SkipSegment, 1)
|
||||
go func() {
|
||||
segmentsCh <- s.fetchSkipSegments(ctx, malID, episode)
|
||||
}()
|
||||
|
||||
modeSources := make(map[string]ModeSource)
|
||||
for range 2 {
|
||||
result := <-modeCh
|
||||
if !result.OK {
|
||||
continue
|
||||
}
|
||||
modeSources[result.Mode] = result.Source
|
||||
}
|
||||
|
||||
segments := <-segmentsCh
|
||||
return modeSources, segments
|
||||
}
|
||||
|
||||
func clonePlaybackBaseData(data playbackBaseData) playbackBaseData {
|
||||
return playbackBaseData{
|
||||
Title: data.Title,
|
||||
AvailableModes: cloneSlice(data.AvailableModes),
|
||||
ModeSources: cloneModeSources(data.ModeSources),
|
||||
Segments: cloneSlice(data.Segments),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSlice[T any](items []T) []T {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]T, len(items))
|
||||
copy(cloned, items)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneModeSources(modeSources map[string]ModeSource) map[string]ModeSource {
|
||||
if len(modeSources) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]ModeSource, len(modeSources))
|
||||
for mode, source := range modeSources {
|
||||
cloned[mode] = ModeSource{
|
||||
URL: source.URL,
|
||||
Referer: source.Referer,
|
||||
Subtitles: cloneSlice(source.Subtitles),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Service) fetchSkipSegments(ctx context.Context, malID int, episode string) []SkipSegment {
|
||||
if malID <= 0 || strings.TrimSpace(episode) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
|
||||
resp, err := doProxiedRequest(ctx, s.httpClient, endpoint, "")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||
if err != nil {
|
||||
return 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"`
|
||||
}
|
||||
|
||||
var parsed apiResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
segments := make([]SkipSegment, 0, len(parsed.Result))
|
||||
for _, item := range parsed.Result {
|
||||
if item.Interval.EndTime <= item.Interval.StartTime {
|
||||
continue
|
||||
}
|
||||
|
||||
t := strings.ToLower(item.SkipType)
|
||||
if t != "op" && t != "ed" {
|
||||
continue
|
||||
}
|
||||
|
||||
segments = append(segments, SkipSegment{
|
||||
Type: t,
|
||||
Start: item.Interval.StartTime,
|
||||
End: item.Interval.EndTime,
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer string, rangeHeader string) (int, http.Header, []byte, io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return 0, nil, nil, nil, fmt.Errorf("invalid upstream url: %w", err)
|
||||
}
|
||||
|
||||
if referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
if rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, nil, nil, fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
|
||||
if isM3U8(targetURL, resp.Header.Get("Content-Type")) {
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
if readErr != nil {
|
||||
return 0, nil, nil, nil, fmt.Errorf("read playlist failed: %w", readErr)
|
||||
}
|
||||
|
||||
rewritten, rewriteErr := s.rewritePlaylistWithTokens(ctx, string(body), targetURL, referer)
|
||||
if rewriteErr != nil {
|
||||
return 0, nil, nil, nil, fmt.Errorf("rewrite playlist failed: %w", rewriteErr)
|
||||
}
|
||||
|
||||
headers := cloneHeaders(resp.Header)
|
||||
headers.Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
return resp.StatusCode, headers, []byte(rewritten), nil, nil
|
||||
}
|
||||
|
||||
headers := cloneHeaders(resp.Header)
|
||||
return resp.StatusCode, headers, nil, resp.Body, nil
|
||||
}
|
||||
|
||||
func isM3U8(targetURL string, contentType string) bool {
|
||||
if strings.Contains(strings.ToLower(targetURL), ".m3u8") {
|
||||
return true
|
||||
}
|
||||
lowerType := strings.ToLower(contentType)
|
||||
return strings.Contains(lowerType, "application/vnd.apple.mpegurl") || strings.Contains(lowerType, "application/x-mpegurl")
|
||||
}
|
||||
|
||||
var hopHeaders = map[string]struct{}{
|
||||
"connection": {},
|
||||
"transfer-encoding": {},
|
||||
"keep-alive": {},
|
||||
"proxy-authenticate": {},
|
||||
"proxy-authorization": {},
|
||||
"te": {},
|
||||
"trailers": {},
|
||||
"upgrade": {},
|
||||
}
|
||||
|
||||
func cloneHeaders(src http.Header) http.Header {
|
||||
dst := make(http.Header)
|
||||
for key, values := range src {
|
||||
if _, ok := hopHeaders[strings.ToLower(key)]; ok {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
dst.Add(key, value)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func rankSources(sources []StreamSource, quality string) ([]sourceScore, error) {
|
||||
filtered := make([]StreamSource, 0, len(sources))
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, source := range sources {
|
||||
if source.URL == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[source.URL]; exists {
|
||||
continue
|
||||
}
|
||||
seen[source.URL] = struct{}{}
|
||||
filtered = append(filtered, source)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return nil, errors.New("no playable sources available")
|
||||
}
|
||||
|
||||
targetQuality := normalizeQuality(quality)
|
||||
scored := make([]sourceScore, 0, len(filtered))
|
||||
for _, source := range filtered {
|
||||
typeScore := lookupPriority(sourceTypePriority, source.Type, 200)
|
||||
providerScore := lookupPriority(providerPriority, source.Provider, 60)
|
||||
qualityScore := sourceQualityPriority(source.Quality, targetQuality)
|
||||
refererScore := 0
|
||||
if source.Referer != "" {
|
||||
refererScore = 20
|
||||
}
|
||||
|
||||
total := typeScore + providerScore + qualityScore + refererScore
|
||||
scored = append(scored, sourceScore{
|
||||
source: source,
|
||||
total: total,
|
||||
typeScore: typeScore,
|
||||
providerScore: providerScore,
|
||||
qualityScore: qualityScore,
|
||||
refererScore: refererScore,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(scored, func(i int, j int) bool {
|
||||
return scored[i].total > scored[j].total
|
||||
})
|
||||
|
||||
return scored, nil
|
||||
}
|
||||
|
||||
func normalizeQuality(quality string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(quality))
|
||||
if lower == "" {
|
||||
return "best"
|
||||
}
|
||||
|
||||
return lower
|
||||
}
|
||||
|
||||
var sourceTypePriority = map[string]int{
|
||||
"mp4": 500,
|
||||
"m3u8": 450,
|
||||
"unknown": 300,
|
||||
"embed": 100,
|
||||
}
|
||||
|
||||
var providerPriority = map[string]int{
|
||||
"s-mp4": 120,
|
||||
"default": 115,
|
||||
"luf-mp4": 110,
|
||||
"vid-mp4": 105,
|
||||
"yt-mp4": 100,
|
||||
"mp4": 95,
|
||||
"uv-mp4": 90,
|
||||
"hls": 80,
|
||||
"sw": 40,
|
||||
"ok": 35,
|
||||
"ss-hls": 30,
|
||||
}
|
||||
|
||||
var sourceQualityDefaults = map[string]int{
|
||||
"auto": 240,
|
||||
}
|
||||
|
||||
func lookupPriority(m map[string]int, key string, fallback int) int {
|
||||
if p, ok := m[strings.ToLower(key)]; ok {
|
||||
return p
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func sourceQualityPriority(sourceQuality string, targetQuality string) int {
|
||||
qualityValue := parseQualityValue(sourceQuality)
|
||||
|
||||
switch targetQuality {
|
||||
case "best":
|
||||
return qualityValue
|
||||
case "worst":
|
||||
return -qualityValue
|
||||
default:
|
||||
if qualityMatches(sourceQuality, targetQuality) {
|
||||
return 2000 + qualityValue
|
||||
}
|
||||
|
||||
return -300 + qualityValue
|
||||
}
|
||||
}
|
||||
|
||||
func qualityMatches(sourceQuality string, targetQuality string) bool {
|
||||
sourceLower := strings.ToLower(sourceQuality)
|
||||
targetLower := strings.ToLower(targetQuality)
|
||||
|
||||
if sourceLower == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(sourceLower, targetLower) {
|
||||
return true
|
||||
}
|
||||
|
||||
return extractDigits(sourceLower) == extractDigits(targetLower)
|
||||
}
|
||||
|
||||
func parseQualityValue(rawQuality string) int {
|
||||
lower := strings.ToLower(rawQuality)
|
||||
if lower == "auto" {
|
||||
return 240
|
||||
}
|
||||
|
||||
digits := extractDigits(lower)
|
||||
if digits == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func extractDigits(value string) string {
|
||||
var digits []byte
|
||||
for _, char := range value {
|
||||
if char >= '0' && char <= '9' {
|
||||
digits = append(digits, byte(char))
|
||||
} else if len(digits) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
func normalizeSourceTypeFromProbe(source StreamSource, contentType string) StreamSource {
|
||||
lower := strings.ToLower(contentType)
|
||||
switch {
|
||||
case strings.Contains(lower, "video/mp4"):
|
||||
source.Type = "mp4"
|
||||
case strings.Contains(lower, "mpegurl"):
|
||||
source.Type = "m3u8"
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func isLikelyMP4(payload []byte) bool {
|
||||
if len(payload) < 12 {
|
||||
return false
|
||||
}
|
||||
|
||||
return bytes.Equal(payload[4:8], []byte("ftyp"))
|
||||
}
|
||||
|
||||
func isLikelyM3U8(payload []byte) bool {
|
||||
trimmed := strings.TrimSpace(string(payload))
|
||||
return strings.HasPrefix(trimmed, "#EXTM3U")
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []string) (string, string, error) {
|
||||
malText := strconv.Itoa(malID)
|
||||
modeCandidates := []string{"sub", "dub"}
|
||||
queries := buildTitleSearchQueries(titleCandidates)
|
||||
|
||||
for _, query := range queries {
|
||||
resultsByMode := s.searchShowResultsByMode(ctx, query, modeCandidates)
|
||||
|
||||
for _, mode := range modeCandidates {
|
||||
for _, result := range resultsByMode[mode] {
|
||||
if strings.TrimSpace(result.MalID) == malText && strings.TrimSpace(result.ID) != "" {
|
||||
return result.ID, result.Name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, mode := range modeCandidates {
|
||||
results := resultsByMode[mode]
|
||||
if len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
best := results[0]
|
||||
if strings.TrimSpace(best.ID) != "" {
|
||||
return best.ID, best.Name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", errors.New("unable to resolve allanime show")
|
||||
}
|
||||
|
||||
func (s *Service) searchShowResultsByMode(ctx context.Context, query string, modeCandidates []string) map[string][]searchResult {
|
||||
resultsByMode := make(map[string][]searchResult, len(modeCandidates))
|
||||
searchCh := make(chan searchModeResult, len(modeCandidates))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, mode := range modeCandidates {
|
||||
modeValue := mode
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results, err := s.allAnimeClient.Search(ctx, query, modeValue)
|
||||
searchCh <- searchModeResult{Mode: modeValue, Results: results, Err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(searchCh)
|
||||
|
||||
for result := range searchCh {
|
||||
if result.Err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resultsByMode[result.Mode] = result.Results
|
||||
}
|
||||
|
||||
return resultsByMode
|
||||
}
|
||||
|
||||
func buildTitleSearchQueries(titleCandidates []string) []string {
|
||||
queries := make([]string, 0, len(titleCandidates)*4)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
add := func(raw string) {
|
||||
normalized := normalizeSearchQuery(raw)
|
||||
if normalized == "" {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.ToLower(normalized)
|
||||
if _, exists := seen[key]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
seen[key] = struct{}{}
|
||||
queries = append(queries, normalized)
|
||||
}
|
||||
|
||||
for _, candidate := range titleCandidates {
|
||||
normalized := normalizeSearchQuery(candidate)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
add(normalized)
|
||||
add(strings.ReplaceAll(normalized, "+", " "))
|
||||
|
||||
withoutApostrophes := strings.NewReplacer("'", "", "’", "", "`", "").Replace(normalized)
|
||||
add(withoutApostrophes)
|
||||
add(strings.ReplaceAll(withoutApostrophes, "+", " "))
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func normalizeSearchQuery(raw string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
|
||||
}
|
||||
|
||||
func firstNonEmptyTitle(values []string) string {
|
||||
for _, value := range values {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeMode(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func availableModes(modeSources map[string]ModeSource) []string {
|
||||
preferred := []string{"dub", "sub"}
|
||||
ordered := make([]string, 0, len(modeSources))
|
||||
for _, mode := range preferred {
|
||||
if _, ok := modeSources[mode]; ok {
|
||||
ordered = append(ordered, mode)
|
||||
}
|
||||
}
|
||||
|
||||
extra := make([]string, 0)
|
||||
for mode := range modeSources {
|
||||
if mode == "dub" || mode == "sub" {
|
||||
continue
|
||||
}
|
||||
extra = append(extra, mode)
|
||||
}
|
||||
sort.Strings(extra)
|
||||
|
||||
return append(ordered, extra...)
|
||||
}
|
||||
|
||||
func selectInitialMode(requestedMode string, modeSources map[string]ModeSource) string {
|
||||
normalizedRequested := normalizeMode(requestedMode)
|
||||
if normalizedRequested != "" {
|
||||
if _, ok := modeSources[normalizedRequested]; ok {
|
||||
return normalizedRequested
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := modeSources["dub"]; ok {
|
||||
return "dub"
|
||||
}
|
||||
if _, ok := modeSources["sub"]; ok {
|
||||
return "sub"
|
||||
}
|
||||
|
||||
for mode := range modeSources {
|
||||
return mode
|
||||
}
|
||||
|
||||
return "dub"
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func (s *Service) resolveModeSource(ctx context.Context, showID string, episode string, mode string, quality string) (StreamSource, error) {
|
||||
sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode)
|
||||
if err != nil {
|
||||
return StreamSource{}, err
|
||||
}
|
||||
|
||||
ranked, err := rankSources(sources, quality)
|
||||
if err != nil {
|
||||
return StreamSource{}, err
|
||||
}
|
||||
|
||||
selected, _, err := s.choosePlaybackSource(ctx, ranked, s.probeDirectMedia)
|
||||
if err != nil {
|
||||
return StreamSource{}, err
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveModeSourceWithCache(
|
||||
ctx context.Context,
|
||||
showID string,
|
||||
episode string,
|
||||
mode string,
|
||||
quality string,
|
||||
probeCache map[string]directProbeResult,
|
||||
probeCacheMu *sync.Mutex,
|
||||
) (StreamSource, error) {
|
||||
sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode)
|
||||
if err != nil {
|
||||
return StreamSource{}, err
|
||||
}
|
||||
|
||||
ranked, err := rankSources(sources, quality)
|
||||
if err != nil {
|
||||
return StreamSource{}, err
|
||||
}
|
||||
|
||||
selected, _, err := s.choosePlaybackSourceWithCache(ctx, ranked, probeCache, probeCacheMu)
|
||||
if err != nil {
|
||||
return StreamSource{}, err
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func (s *Service) choosePlaybackSource(
|
||||
ctx context.Context,
|
||||
ranked []sourceScore,
|
||||
probeFn func(context.Context, StreamSource) (bool, string),
|
||||
) (StreamSource, string, error) {
|
||||
if len(ranked) == 0 {
|
||||
return StreamSource{}, "", errors.New("no ranked sources available")
|
||||
}
|
||||
|
||||
embedCandidates := make([]StreamSource, 0, len(ranked))
|
||||
for _, candidate := range ranked {
|
||||
source := candidate.source
|
||||
switch strings.ToLower(source.Type) {
|
||||
case "mp4", "m3u8":
|
||||
return source, "direct-media", nil
|
||||
case "embed":
|
||||
embedCandidates = append(embedCandidates, source)
|
||||
default:
|
||||
if playable, contentType := probeFn(ctx, source); playable {
|
||||
return normalizeSourceTypeFromProbe(source, contentType), "probed-media", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, embed := range embedCandidates {
|
||||
if s.probeEmbedSource(ctx, embed) {
|
||||
return embed, "embed-probed", nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(embedCandidates) > 0 {
|
||||
return embedCandidates[0], "embed-fallback", nil
|
||||
}
|
||||
|
||||
return ranked[0].source, "ranked-fallback", nil
|
||||
}
|
||||
|
||||
func (s *Service) choosePlaybackSourceWithCache(
|
||||
ctx context.Context,
|
||||
ranked []sourceScore,
|
||||
probeCache map[string]directProbeResult,
|
||||
probeCacheMu *sync.Mutex,
|
||||
) (StreamSource, string, error) {
|
||||
return s.choosePlaybackSource(ctx, ranked, func(ctx context.Context, source StreamSource) (bool, string) {
|
||||
return s.probeDirectMediaCached(ctx, source, probeCache, probeCacheMu)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) probeDirectMediaCached(
|
||||
ctx context.Context,
|
||||
source StreamSource,
|
||||
probeCache map[string]directProbeResult,
|
||||
probeCacheMu *sync.Mutex,
|
||||
) (bool, string) {
|
||||
cacheKey := strings.TrimSpace(source.URL)
|
||||
if cacheKey == "" {
|
||||
return s.probeDirectMedia(ctx, source)
|
||||
}
|
||||
|
||||
probeCacheMu.Lock()
|
||||
cached, ok := probeCache[cacheKey]
|
||||
probeCacheMu.Unlock()
|
||||
if ok {
|
||||
return cached.Playable, cached.ContentType
|
||||
}
|
||||
|
||||
playable, contentType := s.probeDirectMedia(ctx, source)
|
||||
|
||||
probeCacheMu.Lock()
|
||||
probeCache[cacheKey] = directProbeResult{Playable: playable, ContentType: contentType}
|
||||
probeCacheMu.Unlock()
|
||||
|
||||
return playable, contentType
|
||||
}
|
||||
|
||||
func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bool, string) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, source.URL, nil)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if source.Referer != "" {
|
||||
req.Header.Set("Referer", source.Referer)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Range", "bytes=0-4095")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
if strings.Contains(contentType, "video/") || strings.Contains(contentType, "mpegurl") {
|
||||
return true, contentType
|
||||
}
|
||||
|
||||
prefix, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if err == nil {
|
||||
if isLikelyM3U8(prefix) {
|
||||
return true, "application/vnd.apple.mpegurl"
|
||||
}
|
||||
if isLikelyMP4(prefix) {
|
||||
return true, "video/mp4"
|
||||
}
|
||||
}
|
||||
|
||||
finalURL := ""
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = strings.ToLower(resp.Request.URL.String())
|
||||
}
|
||||
|
||||
if strings.Contains(finalURL, ".mp4") || strings.Contains(finalURL, ".m3u8") {
|
||||
return true, contentType
|
||||
}
|
||||
|
||||
return false, contentType
|
||||
}
|
||||
|
||||
func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.URL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if source.Referer != "" {
|
||||
req.Header.Set("Referer", source.Referer)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return false
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
content := strings.ToLower(string(body))
|
||||
for _, marker := range []string{
|
||||
"file was deleted",
|
||||
"file has been deleted",
|
||||
"video was deleted",
|
||||
"video has been deleted",
|
||||
"video unavailable",
|
||||
"file not found",
|
||||
"this file does not exist",
|
||||
"resource unavailable",
|
||||
} {
|
||||
if strings.Contains(content, marker) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func toSubtitleItems(source StreamSource) []SubtitleItem {
|
||||
items := make([]SubtitleItem, 0, len(source.Subtitles))
|
||||
for _, subtitle := range source.Subtitles {
|
||||
targetURL := strings.TrimSpace(subtitle.URL)
|
||||
if targetURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, SubtitleItem{
|
||||
Lang: strings.TrimSpace(subtitle.Lang),
|
||||
URL: targetURL,
|
||||
Referer: source.Referer,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package playback
|
||||
|
||||
type StreamSource struct {
|
||||
URL string
|
||||
Quality string
|
||||
Provider string
|
||||
Type string
|
||||
Referer string
|
||||
Subtitles []Subtitle
|
||||
}
|
||||
|
||||
type Subtitle struct {
|
||||
Lang string
|
||||
URL string
|
||||
}
|
||||
|
||||
type ModeSource struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
Token string `json:"token"`
|
||||
Subtitles []SubtitleItem `json:"subtitles"`
|
||||
}
|
||||
|
||||
type SubtitleItem struct {
|
||||
Lang string `json:"lang"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type SkipSegment struct {
|
||||
Type string `json:"type"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
}
|
||||
|
||||
type WatchPageData struct {
|
||||
MalID int
|
||||
Title string
|
||||
CurrentEpisode string
|
||||
StartTimeSeconds float64
|
||||
CurrentStatus string
|
||||
InitialMode string
|
||||
AvailableModes []string
|
||||
ModeSources map[string]ModeSource
|
||||
Segments []SkipSegment
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"mal/internal/database"
|
||||
"mal/internal/shared/middleware"
|
||||
"mal/internal/templates"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
func requireMethod(w http.ResponseWriter, r *http.Request, method string) bool {
|
||||
if r.Method == method {
|
||||
return true
|
||||
}
|
||||
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
animeIDStr := r.FormValue("anime_id")
|
||||
animeTitle := r.FormValue("anime_title")
|
||||
animeTitleEnglish := r.FormValue("anime_title_english")
|
||||
animeTitleJapanese := r.FormValue("anime_title_japanese")
|
||||
animeImage := r.FormValue("anime_image")
|
||||
status := r.FormValue("status")
|
||||
airingStr := r.FormValue("airing")
|
||||
airing := airingStr == "true"
|
||||
|
||||
log.Printf("watchlist add: user_id=%s, anime_id=%s, title=%s", user.ID, animeIDStr, animeTitle)
|
||||
|
||||
animeID, err := strconv.ParseInt(animeIDStr, 10, 64)
|
||||
if err != nil || animeID <= 0 {
|
||||
http.Error(w, "invalid anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req := AddRequest{
|
||||
AnimeID: animeID,
|
||||
TitleOriginal: animeTitle,
|
||||
TitleEnglish: animeTitleEnglish,
|
||||
TitleJapanese: animeTitleJapanese,
|
||||
ImageURL: animeImage,
|
||||
Status: status,
|
||||
Airing: airing,
|
||||
}
|
||||
|
||||
if err := h.svc.AddEntry(r.Context(), user.ID, req); err != nil {
|
||||
if errors.Is(err, ErrInvalidAnimeID) || errors.Is(err, ErrInvalidStatus) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("watchlist add failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err)
|
||||
http.Error(w, "failed to update watchlist", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
templates.WatchlistDropdown(int(animeID), animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path[len("/api/watchlist/"):]
|
||||
animeID, err := strconv.ParseInt(path, 10, 64)
|
||||
if err != nil || animeID <= 0 {
|
||||
http.Error(w, "invalid anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.RemoveEntry(r.Context(), user.ID, animeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidAnimeID) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("watchlist delete failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err)
|
||||
http.Error(w, "failed to delete from watchlist", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("from") == "watchlist" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
title := database.DisplayTitle(anime.TitleEnglish, anime.TitleJapanese, anime.TitleOriginal)
|
||||
airing := false
|
||||
if anime.Airing.Valid {
|
||||
airing = anime.Airing.Bool
|
||||
}
|
||||
|
||||
templates.WatchlistDropdown(int(animeID), anime.TitleOriginal, title, "", anime.ImageUrl, "", airing).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
|
||||
layout := r.URL.Query().Get("view")
|
||||
if layout != "grid" && layout != "table" {
|
||||
layout = "grid"
|
||||
}
|
||||
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
sortBy := r.URL.Query().Get("sort")
|
||||
sortOrder := r.URL.Query().Get("order")
|
||||
|
||||
if sortBy != "title" {
|
||||
sortBy = "date"
|
||||
}
|
||||
if sortOrder != "asc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.svc.GetUserWatchlist(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Printf("watchlist fetch failed: user_id=%s err=%v", user.ID, err)
|
||||
http.Error(w, "failed to fetch watchlist", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var filteredEntries []database.GetUserWatchListRow
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
for _, entry := range entries {
|
||||
if entry.Status == statusFilter {
|
||||
filteredEntries = append(filteredEntries, entry)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
statusFilter = "all"
|
||||
filteredEntries = entries
|
||||
}
|
||||
|
||||
// Sort entries
|
||||
h.sortEntries(filteredEntries, sortBy, sortOrder)
|
||||
|
||||
templates.Watchlist(filteredEntries, layout, statusFilter, sortBy, sortOrder).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleContinueWatching(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.svc.GetContinueWatching(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Printf("continue watching fetch failed: user_id=%s err=%v", user.ID, err)
|
||||
http.Error(w, "failed to fetch continue watching", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
templates.ContinueWatching(entries).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path[len("/api/continue-watching/"):]
|
||||
animeID, err := strconv.ParseInt(path, 10, 64)
|
||||
if err != nil || animeID <= 0 {
|
||||
http.Error(w, "invalid anime ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteContinueWatching(r.Context(), user.ID, animeID); err != nil {
|
||||
log.Printf("continue watching delete failed: user_id=%s anime_id=%d err=%v", user.ID, animeID, err)
|
||||
http.Error(w, "failed to delete continue watching entry", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleExportWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
export, err := h.svc.Export(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Printf("watchlist export failed: user_id=%s err=%v", user.ID, err)
|
||||
http.Error(w, "failed to export", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=mal-watchlist.json")
|
||||
json.NewEncoder(w).Encode(export)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleImportWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "no file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var export ExportData
|
||||
if err := json.NewDecoder(file).Decode(&export); err != nil {
|
||||
http.Error(w, "invalid JSON format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.svc.Import(r.Context(), user.ID, export); err != nil {
|
||||
http.Error(w, "failed to import", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("HX-Redirect", "/watchlist")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *Handler) sortEntries(entries []database.GetUserWatchListRow, sortBy, sortOrder string) {
|
||||
isAsc := sortOrder == "asc"
|
||||
|
||||
switch sortBy {
|
||||
case "title":
|
||||
slices.SortFunc(entries, func(a, b database.GetUserWatchListRow) int {
|
||||
if a.TitleOriginal < b.TitleOriginal {
|
||||
return -1
|
||||
}
|
||||
if a.TitleOriginal > b.TitleOriginal {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
if !isAsc {
|
||||
slices.Reverse(entries)
|
||||
}
|
||||
case "date":
|
||||
slices.SortFunc(entries, func(a, b database.GetUserWatchListRow) int {
|
||||
if a.UpdatedAt.After(b.UpdatedAt) {
|
||||
return -1
|
||||
}
|
||||
if a.UpdatedAt.Before(b.UpdatedAt) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
if !isAsc {
|
||||
slices.Reverse(entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db database.Querier
|
||||
sqlDB *sql.DB
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidAnimeID = errors.New("invalid anime ID")
|
||||
ErrInvalidStatus = errors.New("invalid watchlist status")
|
||||
)
|
||||
|
||||
var validStatuses = map[string]struct{}{
|
||||
"watching": {},
|
||||
"completed": {},
|
||||
"on_hold": {},
|
||||
"dropped": {},
|
||||
"plan_to_watch": {},
|
||||
}
|
||||
|
||||
func NewService(db database.Querier, sqlDB *sql.DB) *Service {
|
||||
return &Service{db: db, sqlDB: sqlDB}
|
||||
}
|
||||
|
||||
type AddRequest struct {
|
||||
AnimeID int64
|
||||
TitleOriginal string
|
||||
TitleEnglish string
|
||||
TitleJapanese string
|
||||
ImageURL string
|
||||
Status string
|
||||
Airing bool
|
||||
}
|
||||
|
||||
func (s *Service) AddEntry(ctx context.Context, userID string, req AddRequest) error {
|
||||
if req.AnimeID <= 0 {
|
||||
return ErrInvalidAnimeID
|
||||
}
|
||||
|
||||
if _, ok := validStatuses[req.Status]; !ok {
|
||||
return ErrInvalidStatus
|
||||
}
|
||||
|
||||
_, err := s.db.UpsertAnime(ctx, database.UpsertAnimeParams{
|
||||
ID: req.AnimeID,
|
||||
TitleOriginal: req.TitleOriginal,
|
||||
TitleEnglish: sql.NullString{String: req.TitleEnglish, Valid: req.TitleEnglish != ""},
|
||||
TitleJapanese: sql.NullString{String: req.TitleJapanese, Valid: req.TitleJapanese != ""},
|
||||
ImageUrl: req.ImageURL,
|
||||
Airing: sql.NullBool{Bool: req.Airing, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save anime reference: %w", err)
|
||||
}
|
||||
|
||||
entryID := uuid.New().String()
|
||||
_, err = s.db.UpsertWatchListEntry(ctx, database.UpsertWatchListEntryParams{
|
||||
ID: entryID,
|
||||
UserID: userID,
|
||||
AnimeID: req.AnimeID,
|
||||
Status: req.Status,
|
||||
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
|
||||
CurrentTimeSeconds: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update watchlist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveEntry(ctx context.Context, userID string, animeID int64) (database.Anime, error) {
|
||||
if animeID <= 0 {
|
||||
return database.Anime{}, ErrInvalidAnimeID
|
||||
}
|
||||
|
||||
anime, err := s.db.GetAnime(ctx, animeID)
|
||||
if err != nil {
|
||||
return database.Anime{}, fmt.Errorf("anime not found: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.DeleteWatchListEntry(ctx, database.DeleteWatchListEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Anime{}, fmt.Errorf("failed to delete from watchlist: %w", err)
|
||||
}
|
||||
|
||||
return anime, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUserWatchlist(ctx context.Context, userID string) ([]database.GetUserWatchListRow, error) {
|
||||
entries, err := s.db.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch watchlist: %w", err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetContinueWatching(ctx context.Context, userID string) ([]database.GetContinueWatchingEntriesRow, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return nil, errors.New("invalid user id")
|
||||
}
|
||||
|
||||
entries, err := s.db.GetContinueWatchingEntries(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch continue watching: %w", err)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return errors.New("invalid user id")
|
||||
}
|
||||
|
||||
if animeID <= 0 {
|
||||
return ErrInvalidAnimeID
|
||||
}
|
||||
|
||||
params := database.DeleteContinueWatchingEntryParams{
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
}
|
||||
|
||||
clearProgress := database.SaveWatchProgressParams{
|
||||
CurrentEpisode: sql.NullInt64{Valid: false},
|
||||
CurrentTimeSeconds: 0,
|
||||
UserID: userID,
|
||||
AnimeID: animeID,
|
||||
}
|
||||
|
||||
if s.sqlDB == nil {
|
||||
if err := s.db.DeleteContinueWatchingEntry(ctx, params); err != nil {
|
||||
return fmt.Errorf("failed to delete continue watching entry: %w", err)
|
||||
}
|
||||
return s.db.SaveWatchProgress(ctx, clearProgress)
|
||||
}
|
||||
|
||||
txQueries, tx, err := database.BeginTx(ctx, s.sqlDB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := txQueries.DeleteContinueWatchingEntry(ctx, params); err != nil {
|
||||
return fmt.Errorf("failed to delete continue watching entry: %w", err)
|
||||
}
|
||||
if err := txQueries.SaveWatchProgress(ctx, clearProgress); err != nil {
|
||||
return fmt.Errorf("failed to clear watchlist progress: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type ExportEntry struct {
|
||||
AnimeID int64 `json:"anime_id"`
|
||||
Title string `json:"title"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ExportData struct {
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Entries []ExportEntry `json:"entries"`
|
||||
}
|
||||
|
||||
func (s *Service) Export(ctx context.Context, userID string) (ExportData, error) {
|
||||
entries, err := s.GetUserWatchlist(ctx, userID)
|
||||
if err != nil {
|
||||
return ExportData{}, err
|
||||
}
|
||||
|
||||
export := ExportData{
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Entries: make([]ExportEntry, len(entries)),
|
||||
}
|
||||
|
||||
for i, entry := range entries {
|
||||
export.Entries[i] = ExportEntry{
|
||||
AnimeID: entry.AnimeID,
|
||||
Title: database.DisplayTitle(entry.TitleEnglish, entry.TitleJapanese, entry.TitleOriginal),
|
||||
ImageURL: entry.ImageUrl,
|
||||
Status: entry.Status,
|
||||
UpdatedAt: entry.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
return export, nil
|
||||
}
|
||||
|
||||
func (s *Service) Import(ctx context.Context, userID string, export ExportData) (int, error) {
|
||||
imported := 0
|
||||
for _, entry := range export.Entries {
|
||||
_, err := s.db.UpsertAnime(ctx, database.UpsertAnimeParams{
|
||||
ID: entry.AnimeID,
|
||||
TitleOriginal: entry.Title,
|
||||
TitleEnglish: sql.NullString{},
|
||||
TitleJapanese: sql.NullString{},
|
||||
ImageUrl: entry.ImageURL,
|
||||
})
|
||||
if err != nil {
|
||||
continue // skip failures and keep going
|
||||
}
|
||||
|
||||
_, err = s.db.UpsertWatchListEntry(ctx, database.UpsertWatchListEntryParams{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
AnimeID: entry.AnimeID,
|
||||
Status: entry.Status,
|
||||
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
|
||||
CurrentTimeSeconds: 0,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
type fakeQuerier struct {
|
||||
database.Querier
|
||||
upsertAnimeCalled bool
|
||||
upsertEntryCalled bool
|
||||
addRows []database.GetUserWatchListRow
|
||||
}
|
||||
|
||||
func (f *fakeQuerier) UpsertAnime(ctx context.Context, arg database.UpsertAnimeParams) (database.Anime, error) {
|
||||
f.upsertAnimeCalled = true
|
||||
return database.Anime{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeQuerier) UpsertWatchListEntry(ctx context.Context, arg database.UpsertWatchListEntryParams) (database.WatchListEntry, error) {
|
||||
f.upsertEntryCalled = true
|
||||
return database.WatchListEntry{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeQuerier) GetUserWatchList(ctx context.Context, userID string) ([]database.GetUserWatchListRow, error) {
|
||||
return f.addRows, nil
|
||||
}
|
||||
|
||||
func TestAddEntry_RejectsInvalidAnimeID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q := &fakeQuerier{}
|
||||
svc := NewService(q, nil)
|
||||
|
||||
err := svc.AddEntry(context.Background(), "user-1", AddRequest{
|
||||
AnimeID: 0,
|
||||
Status: "watching",
|
||||
})
|
||||
|
||||
if err != ErrInvalidAnimeID {
|
||||
t.Fatalf("expected ErrInvalidAnimeID, got %v", err)
|
||||
}
|
||||
|
||||
if q.upsertAnimeCalled || q.upsertEntryCalled {
|
||||
t.Fatal("expected no database writes for invalid anime id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEntry_RejectsInvalidStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q := &fakeQuerier{}
|
||||
svc := NewService(q, nil)
|
||||
|
||||
err := svc.AddEntry(context.Background(), "user-1", AddRequest{
|
||||
AnimeID: 1,
|
||||
Status: "invalid",
|
||||
})
|
||||
|
||||
if err != ErrInvalidStatus {
|
||||
t.Fatalf("expected ErrInvalidStatus, got %v", err)
|
||||
}
|
||||
|
||||
if q.upsertAnimeCalled || q.upsertEntryCalled {
|
||||
t.Fatal("expected no database writes for invalid status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExport_UsesDisplayTitleFallbackOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q := &fakeQuerier{
|
||||
addRows: []database.GetUserWatchListRow{
|
||||
{
|
||||
AnimeID: 101,
|
||||
TitleOriginal: "Original",
|
||||
TitleEnglish: sql.NullString{String: "English", Valid: true},
|
||||
Status: "watching",
|
||||
ImageUrl: "https://img",
|
||||
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
AnimeID: 102,
|
||||
TitleOriginal: "Original 2",
|
||||
TitleJapanese: sql.NullString{String: "JP Title", Valid: true},
|
||||
Status: "completed",
|
||||
ImageUrl: "https://img2",
|
||||
UpdatedAt: time.Date(2026, 1, 3, 3, 4, 5, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
AnimeID: 103,
|
||||
TitleOriginal: "Original 3",
|
||||
Status: "on_hold",
|
||||
ImageUrl: "https://img3",
|
||||
UpdatedAt: time.Date(2026, 1, 4, 3, 4, 5, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewService(q, nil)
|
||||
export, err := svc.Export(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(export.Entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(export.Entries))
|
||||
}
|
||||
|
||||
if export.Entries[0].Title != "English" {
|
||||
t.Fatalf("expected english title first, got %q", export.Entries[0].Title)
|
||||
}
|
||||
|
||||
if export.Entries[1].Title != "JP Title" {
|
||||
t.Fatalf("expected japanese title fallback, got %q", export.Entries[1].Title)
|
||||
}
|
||||
|
||||
if export.Entries[2].Title != "Original 3" {
|
||||
t.Fatalf("expected original title fallback, got %q", export.Entries[2].Title)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
cacheKey := fmt.Sprintf("anime:%d", id)
|
||||
|
||||
var cached Anime
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
var result AnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/anime/%d/full", c.baseURL, id)
|
||||
|
||||
if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil {
|
||||
var stale Anime
|
||||
if c.getStaleCache(ctx, cacheKey, &stale) {
|
||||
return stale, nil
|
||||
}
|
||||
return Anime{}, err
|
||||
}
|
||||
|
||||
ttl := time.Hour * 24
|
||||
if result.Data.Status == "Finished Airing" {
|
||||
ttl = time.Hour * 24 * 30
|
||||
}
|
||||
|
||||
c.setCache(ctx, cacheKey, result.Data, ttl)
|
||||
return result.Data, nil
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
db database.Querier
|
||||
retrySignal chan struct{}
|
||||
mu sync.Mutex
|
||||
lastReqTime time.Time
|
||||
}
|
||||
|
||||
func NewClient(db database.Querier) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
baseURL: "https://api.jikan.moe/v4",
|
||||
db: db,
|
||||
retrySignal: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
|
||||
}
|
||||
|
||||
func IsNotFoundError(err error) bool {
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == http.StatusNotFound
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func IsRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return isRetryableStatus(apiErr.StatusCode)
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isRetryableStatus(statusCode int) bool {
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
|
||||
return statusCode >= 500 && statusCode <= 504
|
||||
}
|
||||
|
||||
func retryDelay(attempt int) time.Duration {
|
||||
base := 500 * time.Millisecond
|
||||
delay := base * time.Duration(1<<attempt)
|
||||
if delay > 8*time.Second {
|
||||
return 8 * time.Second
|
||||
}
|
||||
|
||||
return delay
|
||||
}
|
||||
|
||||
func parseRetryAfter(value string) (time.Duration, bool) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
seconds, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if seconds <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return time.Duration(seconds) * time.Second, true
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func truncateErrorMessage(message string) string {
|
||||
if len(message) <= 400 {
|
||||
return message
|
||||
}
|
||||
|
||||
return message[:400]
|
||||
}
|
||||
|
||||
func (c *Client) notifyRetryWorker() {
|
||||
select {
|
||||
case c.retrySignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) RetrySignal() <-chan struct{} {
|
||||
return c.retrySignal
|
||||
}
|
||||
|
||||
func (c *Client) EnqueueAnimeFetchRetry(parentCtx context.Context, animeID int, cause error) {
|
||||
if animeID <= 0 || !IsRetryableError(cause) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := c.db.EnqueueAnimeFetchRetry(ctx, database.EnqueueAnimeFetchRetryParams{
|
||||
AnimeID: int64(animeID),
|
||||
LastError: truncateErrorMessage(cause.Error()),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.notifyRetryWorker()
|
||||
}
|
||||
|
||||
func (c *Client) waitRateLimit(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
// Jikan has a 3 req/sec limit AND a 60 req/min limit.
|
||||
// 400ms base delay keeps us safely under the 3/sec limit.
|
||||
nextAllowed := c.lastReqTime.Add(400 * time.Millisecond)
|
||||
if now.Before(nextAllowed) {
|
||||
timer := time.NewTimer(nextAllowed.Sub(now))
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("request canceled while waiting for rate limit: %w", ctx.Err())
|
||||
}
|
||||
c.lastReqTime = time.Now()
|
||||
} else {
|
||||
c.lastReqTime = now
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := c.db.GetJikanCache(ctx, key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(data), out)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) bool {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := c.db.GetJikanCacheStale(ctx, key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(data), out)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Client) setCache(parentCtx context.Context, key string, data any, ttl time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
bytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.db.SetJikanCache(ctx, database.SetJikanCacheParams{
|
||||
Key: key,
|
||||
Data: string(bytes),
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
})
|
||||
}
|
||||
|
||||
type cacheResult struct {
|
||||
data any
|
||||
hasStale bool
|
||||
}
|
||||
|
||||
func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Duration, url string, out any) error {
|
||||
if c.getCache(ctx, cacheKey, out) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var stale any
|
||||
hasStale := c.getStaleCache(ctx, cacheKey, &stale)
|
||||
|
||||
if err := c.fetchWithRetry(ctx, url, out); err != nil {
|
||||
if hasStale {
|
||||
staleBytes, marshalErr := json.Marshal(stale)
|
||||
if marshalErr == nil {
|
||||
unmarshalErr := json.Unmarshal(staleBytes, out)
|
||||
if unmarshalErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
log.Printf("jikan: stale cache unmarshal failed, falling back to error: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
c.setCache(ctx, cacheKey, out, ttl)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
|
||||
maxRetries := 5
|
||||
for attempt := range maxRetries {
|
||||
if err := c.waitRateLimit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create jikan request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if attempt < maxRetries-1 && IsRetryableError(err) {
|
||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return fmt.Errorf("jikan api error: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
apiErr := &APIError{StatusCode: resp.StatusCode, URL: urlStr}
|
||||
retryable := isRetryableStatus(resp.StatusCode)
|
||||
|
||||
retryAfter := time.Duration(0)
|
||||
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
|
||||
retryAfter = parsed
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
|
||||
if retryable && attempt < maxRetries-1 {
|
||||
delay := retryDelay(attempt)
|
||||
if retryAfter > delay {
|
||||
delay = retryAfter
|
||||
}
|
||||
|
||||
if retryErr := waitForRetry(ctx, delay); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return apiErr
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(out)
|
||||
resp.Body.Close()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to decode jikan response: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("max retries exceeded for %s", urlStr)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import "time"
|
||||
|
||||
const shortCacheTTL = time.Hour
|
||||
@@ -1,20 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Client) GetEpisodes(ctx context.Context, animeID int, page int) (EpisodesResponse, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("anime:%d:episodes:%d", animeID, page)
|
||||
var result EpisodesResponse
|
||||
reqURL := fmt.Sprintf("%s/anime/%d/episodes?page=%d", c.baseURL, animeID, page)
|
||||
|
||||
err := c.getWithCache(ctx, cacheKey, 12*time.Hour, reqURL, &result)
|
||||
return result, err
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RecommendationEntry struct {
|
||||
Entry struct {
|
||||
MalID int `json:"mal_id"`
|
||||
URL string `json:"url"`
|
||||
Images struct {
|
||||
Webp struct {
|
||||
LargeImageURL string `json:"large_image_url"`
|
||||
} `json:"webp"`
|
||||
} `json:"images"`
|
||||
Title string `json:"title"`
|
||||
} `json:"entry"`
|
||||
Votes int `json:"votes"`
|
||||
}
|
||||
|
||||
type RecommendationsResponse struct {
|
||||
Data []RecommendationEntry `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) GetRecommendations(ctx context.Context, animeID int, limit int) ([]Anime, error) {
|
||||
cacheKey := fmt.Sprintf("recs:%d", animeID)
|
||||
|
||||
var cached []Anime
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
if limit > 0 && len(cached) > limit {
|
||||
return cached[:limit], nil
|
||||
}
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
var result RecommendationsResponse
|
||||
reqURL := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, animeID)
|
||||
|
||||
if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil {
|
||||
var stale []Anime
|
||||
if c.getStaleCache(ctx, cacheKey, &stale) {
|
||||
if limit > 0 && len(stale) > limit {
|
||||
return stale[:limit], nil
|
||||
}
|
||||
return stale, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
max := len(result.Data)
|
||||
if limit > 0 && max > limit {
|
||||
max = limit
|
||||
}
|
||||
|
||||
animes := make([]Anime, 0, max)
|
||||
for i := 0; i < max; i++ {
|
||||
rec := result.Data[i]
|
||||
|
||||
var fullAnime Anime
|
||||
animeCacheKey := fmt.Sprintf("anime:%d", rec.Entry.MalID)
|
||||
|
||||
if c.getCache(ctx, animeCacheKey, &fullAnime) {
|
||||
animes = append(animes, fullAnime)
|
||||
} else {
|
||||
anime := Anime{
|
||||
MalID: rec.Entry.MalID,
|
||||
Title: rec.Entry.Title,
|
||||
Images: struct {
|
||||
Jpg struct {
|
||||
LargeImageURL string `json:"large_image_url"`
|
||||
} `json:"jpg"`
|
||||
Webp struct {
|
||||
LargeImageURL string `json:"large_image_url"`
|
||||
} `json:"webp"`
|
||||
}{
|
||||
Webp: struct {
|
||||
LargeImageURL string `json:"large_image_url"`
|
||||
}{
|
||||
LargeImageURL: rec.Entry.Images.Webp.LargeImageURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
animes = append(animes, anime)
|
||||
}
|
||||
}
|
||||
|
||||
c.setCache(ctx, cacheKey, animes, time.Hour*24)
|
||||
return animes, nil
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/watchorder"
|
||||
)
|
||||
|
||||
const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
|
||||
const watchOrderCacheTTL = time.Hour * 24
|
||||
const maxWatchOrderEntries = 120
|
||||
|
||||
func watchOrderTypeLabel(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "tv":
|
||||
return "TV"
|
||||
case "movie":
|
||||
return "Movie"
|
||||
default:
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedWatchOrderType(value string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
return normalized == "tv" || normalized == "movie"
|
||||
}
|
||||
|
||||
func relationCacheKey(id int) string {
|
||||
return fmt.Sprintf("relations:watch-order:%d", id)
|
||||
}
|
||||
|
||||
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||
cacheKey := relationCacheKey(id)
|
||||
|
||||
var cached watchorder.WatchOrderResult
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
|
||||
if err != nil {
|
||||
var statusError *watchorder.HTTPStatusError
|
||||
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
|
||||
log.Printf("relations: watch-order markup missing for %d (%s): %v", id, watchOrderURL, err)
|
||||
} else if errors.As(err, &statusError) {
|
||||
log.Printf(
|
||||
"relations: watch-order http error for %d (%s): status=%d server=%q cf_ray=%q location=%q content_type=%q body=%q",
|
||||
id,
|
||||
watchOrderURL,
|
||||
statusError.StatusCode,
|
||||
statusError.Server,
|
||||
statusError.CFRay,
|
||||
statusError.Location,
|
||||
statusError.ContentType,
|
||||
statusError.BodyPreview,
|
||||
)
|
||||
} else {
|
||||
log.Printf("relations: watch-order fetch failed for %d (%s): %v", id, watchOrderURL, err)
|
||||
}
|
||||
return watchorder.WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
c.setCache(ctx, cacheKey, result, watchOrderCacheTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
currentAnime, err := c.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []RelationEntry{{
|
||||
Anime: currentAnime,
|
||||
Relation: "Current",
|
||||
IsCurrent: true,
|
||||
IsExtra: false,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
result, err := c.getWatchOrder(ctx, id)
|
||||
if err != nil {
|
||||
log.Printf("relations: using current-only fallback for %d: %v", id, err)
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
|
||||
seen := make(map[int]bool)
|
||||
relations := make([]RelationEntry, 0, len(result.WatchOrder)+1)
|
||||
|
||||
for _, watchOrderEntry := range result.WatchOrder {
|
||||
if len(relations) >= maxWatchOrderEntries {
|
||||
break
|
||||
}
|
||||
|
||||
if !isAllowedWatchOrderType(watchOrderEntry.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[watchOrderEntry.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
anime, err := c.GetAnimeByID(ctx, watchOrderEntry.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
continue
|
||||
}
|
||||
c.EnqueueAnimeFetchRetry(ctx, watchOrderEntry.ID, err)
|
||||
log.Printf("relations: skipping related anime %d for root %d: %v", watchOrderEntry.ID, id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
seen[watchOrderEntry.ID] = true
|
||||
relations = append(relations, RelationEntry{
|
||||
Anime: anime,
|
||||
Relation: watchOrderTypeLabel(watchOrderEntry.Type),
|
||||
IsCurrent: watchOrderEntry.ID == id,
|
||||
IsExtra: false,
|
||||
})
|
||||
if watchOrderEntry.ID == id {
|
||||
relations[len(relations)-1].Relation = "Current"
|
||||
}
|
||||
}
|
||||
|
||||
if !seen[id] {
|
||||
currentAnime, err := c.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
relations = append([]RelationEntry{{
|
||||
Anime: currentAnime,
|
||||
Relation: "Current",
|
||||
IsCurrent: true,
|
||||
IsExtra: false,
|
||||
}}, relations...)
|
||||
}
|
||||
|
||||
if len(relations) == 0 {
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
|
||||
return relations, nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsAllowedWatchOrderType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{name: "tv", input: "tv", want: true},
|
||||
{name: "movie", input: "movie", want: true},
|
||||
{name: "case and whitespace", input: " TV ", want: true},
|
||||
{name: "tv special", input: "tv special", want: false},
|
||||
{name: "ova", input: "ova", want: false},
|
||||
{name: "empty", input: "", want: false},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := isAllowedWatchOrderType(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchOrderTypeLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "tv", input: "tv", want: "TV"},
|
||||
{name: "movie", input: "movie", want: "Movie"},
|
||||
{name: "trimmed passthrough", input: " tv special ", want: "tv special"},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := watchOrderTypeLabel(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %q, got %q", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedWatchOrderTypeFromDataset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{name: "label tv", input: "TV", want: true},
|
||||
{name: "label movie", input: "Movie", want: true},
|
||||
{name: "label special", input: "Special", want: false},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := isAllowedWatchOrderType(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func (c *Client) Search(ctx context.Context, query string, page int) (SearchResult, error) {
|
||||
return c.search(ctx, query, page, 0)
|
||||
}
|
||||
|
||||
func (c *Client) SearchWithLimit(ctx context.Context, query string, page int, limit int) (SearchResult, error) {
|
||||
return c.search(ctx, query, page, limit)
|
||||
}
|
||||
|
||||
func (c *Client) search(ctx context.Context, query string, page int, limit int) (SearchResult, error) {
|
||||
if query == "" {
|
||||
return SearchResult{}, nil
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, limit)
|
||||
|
||||
var result SearchResponse
|
||||
reqURL := fmt.Sprintf("%s/anime?q=%s&page=%d", c.baseURL, url.QueryEscape(query), page)
|
||||
if limit > 0 {
|
||||
reqURL = fmt.Sprintf("%s&limit=%d", reqURL, limit)
|
||||
}
|
||||
|
||||
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
||||
if limit > 0 && IsRetryableError(err) {
|
||||
fallbackURL := fmt.Sprintf("%s/anime?q=%s&page=%d", c.baseURL, url.QueryEscape(query), page)
|
||||
if fallbackErr := c.fetchWithRetry(ctx, fallbackURL, &result); fallbackErr == nil {
|
||||
res := SearchResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}
|
||||
c.setCache(ctx, cacheKey, res, shortCacheTTL)
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
var stale SearchResult
|
||||
if c.getStaleCache(ctx, cacheKey, &stale) {
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
return SearchResult{}, err
|
||||
}
|
||||
|
||||
return SearchResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("top:%d", page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/top/anime?page=%d", c.baseURL, page)
|
||||
|
||||
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
||||
var stale TopAnimeResult
|
||||
if c.getStaleCache(ctx, cacheKey, &stale) {
|
||||
return stale, nil
|
||||
}
|
||||
return TopAnimeResult{}, err
|
||||
}
|
||||
|
||||
return TopAnimeResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ScheduleResult struct {
|
||||
Animes []Anime
|
||||
HasNextPage bool
|
||||
}
|
||||
|
||||
func (c *Client) GetSchedule(ctx context.Context, day string) (ScheduleResult, error) {
|
||||
day = strings.ToLower(day)
|
||||
cacheKey := fmt.Sprintf("schedule_%s", day)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/schedules?filter=%s&sfw=true", c.baseURL, day)
|
||||
|
||||
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
||||
if err != nil {
|
||||
return ScheduleResult{}, err
|
||||
}
|
||||
|
||||
return ScheduleResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetFullSchedule(ctx context.Context) (map[string][]Anime, error) {
|
||||
days := []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
|
||||
schedule := make(map[string][]Anime)
|
||||
|
||||
for _, day := range days {
|
||||
res, err := c.GetSchedule(ctx, day)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch %s schedule: %w", day, err)
|
||||
}
|
||||
schedule[day] = res.Animes
|
||||
}
|
||||
|
||||
return schedule, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("seasons_now:%d", page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/seasons/now?page=%d", c.baseURL, page)
|
||||
|
||||
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
||||
if err != nil {
|
||||
return TopAnimeResult{}, err
|
||||
}
|
||||
|
||||
return TopAnimeResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("seasons_upcoming:%d", page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
reqURL := fmt.Sprintf("%s/seasons/upcoming?page=%d", c.baseURL, page)
|
||||
|
||||
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
||||
if err != nil {
|
||||
return TopAnimeResult{}, err
|
||||
}
|
||||
|
||||
return TopAnimeResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ProducerResponse struct {
|
||||
Data struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Titles []struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
} `json:"titles"`
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
} `json:"jpg"`
|
||||
} `json:"images"`
|
||||
Favorites int `json:"favorites"`
|
||||
Established string `json:"established"`
|
||||
About string `json:"about"`
|
||||
Count int `json:"count"`
|
||||
External []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
} `json:"external"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) GetAnimeByProducer(ctx context.Context, producerID int, page int) (StudioAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("producer:%d:%d", producerID, page)
|
||||
|
||||
var cached StudioAnimeResult
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
var stale StudioAnimeResult
|
||||
hasStale := c.getStaleCache(ctx, cacheKey, &stale)
|
||||
|
||||
var result SearchResponse
|
||||
reqURL := fmt.Sprintf("%s/anime?producers=%d&page=%d", c.baseURL, producerID, page)
|
||||
|
||||
if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil {
|
||||
if hasStale {
|
||||
return stale, nil
|
||||
}
|
||||
return StudioAnimeResult{}, err
|
||||
}
|
||||
|
||||
producerName := ""
|
||||
var producerRes ProducerResponse
|
||||
producerURL := fmt.Sprintf("%s/producers/%d", c.baseURL, producerID)
|
||||
if err := c.fetchWithRetry(ctx, producerURL, &producerRes); err == nil {
|
||||
for _, title := range producerRes.Data.Titles {
|
||||
if title.Type == "Default" {
|
||||
producerName = title.Title
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res := StudioAnimeResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
StudioName: producerName,
|
||||
}
|
||||
|
||||
c.setCache(ctx, cacheKey, res, shortCacheTTL)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetProducerByID(ctx context.Context, producerID int) (ProducerResponse, error) {
|
||||
cacheKey := fmt.Sprintf("producer:info:%d", producerID)
|
||||
|
||||
var result ProducerResponse
|
||||
reqURL := fmt.Sprintf("%s/producers/%d/full", c.baseURL, producerID)
|
||||
|
||||
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
|
||||
return result, err
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
type staleCacheQuerier struct {
|
||||
database.Querier
|
||||
staleJSON string
|
||||
}
|
||||
|
||||
func (q *staleCacheQuerier) GetJikanCache(ctx context.Context, key string) (string, error) {
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *staleCacheQuerier) GetJikanCacheStale(ctx context.Context, key string) (string, error) {
|
||||
if q.staleJSON == "" {
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
|
||||
return q.staleJSON, nil
|
||||
}
|
||||
|
||||
func TestGetProducerByID_UsesStaleCacheOnFetchFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q := &staleCacheQuerier{
|
||||
staleJSON: `{"data":{"mal_id":7,"about":"stale about"}}`,
|
||||
}
|
||||
|
||||
client := NewClient(q)
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
client.baseURL = testServer.URL
|
||||
client.httpClient = testServer.Client()
|
||||
|
||||
result, err := client.GetProducerByID(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("expected stale cache result, got error: %v", err)
|
||||
}
|
||||
|
||||
if result.Data.MalID != 7 {
|
||||
t.Fatalf("expected stale mal_id 7, got %d", result.Data.MalID)
|
||||
}
|
||||
|
||||
if result.Data.About != "stale about" {
|
||||
t.Fatalf("expected stale about field, got %q", result.Data.About)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAnimeByProducer_UsesStaleCacheOnFetchFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q := &staleCacheQuerier{
|
||||
staleJSON: `{"Animes":[{"mal_id":42,"title":"Stale Anime"}],"HasNextPage":true,"StudioName":"Stale Studio"}`,
|
||||
}
|
||||
|
||||
client := NewClient(q)
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
client.baseURL = testServer.URL
|
||||
client.httpClient = testServer.Client()
|
||||
|
||||
result, err := client.GetAnimeByProducer(context.Background(), 9, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("expected stale cache result, got error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Animes) != 1 {
|
||||
t.Fatalf("expected one stale anime, got %d", len(result.Animes))
|
||||
}
|
||||
|
||||
if result.Animes[0].MalID != 42 {
|
||||
t.Fatalf("expected stale anime mal_id 42, got %d", result.Animes[0].MalID)
|
||||
}
|
||||
|
||||
if !result.HasNextPage {
|
||||
t.Fatal("expected stale has_next_page=true")
|
||||
}
|
||||
|
||||
if result.StudioName != "Stale Studio" {
|
||||
t.Fatalf("expected stale studio name, got %q", result.StudioName)
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SearchResult struct {
|
||||
Animes []Anime
|
||||
HasNextPage bool
|
||||
}
|
||||
|
||||
type TopAnimeResult struct {
|
||||
Animes []Anime
|
||||
HasNextPage bool
|
||||
}
|
||||
|
||||
type StudioAnimeResult struct {
|
||||
Animes []Anime
|
||||
HasNextPage bool
|
||||
StudioName string
|
||||
}
|
||||
|
||||
type NamedEntity struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Aired struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
String string `json:"string"`
|
||||
}
|
||||
|
||||
type Anime struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Title string `json:"title"`
|
||||
TitleEnglish string `json:"title_english"`
|
||||
TitleJapanese string `json:"title_japanese"`
|
||||
TitleSynonyms []string `json:"title_synonyms"`
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
LargeImageURL string `json:"large_image_url"`
|
||||
} `json:"jpg"`
|
||||
Webp struct {
|
||||
LargeImageURL string `json:"large_image_url"`
|
||||
} `json:"webp"`
|
||||
} `json:"images"`
|
||||
Synopsis string `json:"synopsis"`
|
||||
Rank int `json:"rank"`
|
||||
Popularity int `json:"popularity"`
|
||||
Status string `json:"status"`
|
||||
Airing bool `json:"airing"`
|
||||
Episodes int `json:"episodes"`
|
||||
Season string `json:"season"`
|
||||
Year int `json:"year"`
|
||||
Type string `json:"type"`
|
||||
Rating string `json:"rating"`
|
||||
Duration string `json:"duration"`
|
||||
Aired Aired `json:"aired"`
|
||||
Genres []NamedEntity `json:"genres"`
|
||||
Studios []NamedEntity `json:"studios"`
|
||||
Producers []NamedEntity `json:"producers"`
|
||||
Themes []NamedEntity `json:"themes"`
|
||||
Source string `json:"source"`
|
||||
Demographics []NamedEntity `json:"demographics"`
|
||||
Broadcast struct {
|
||||
Day string `json:"day"`
|
||||
Time string `json:"time"`
|
||||
Timezone string `json:"timezone"`
|
||||
String string `json:"string"`
|
||||
} `json:"broadcast"`
|
||||
Streaming []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
} `json:"streaming"`
|
||||
Relations []JikanRelationGroup `json:"relations"`
|
||||
}
|
||||
|
||||
func (a Anime) ImageURL() string {
|
||||
return a.Images.Webp.LargeImageURL
|
||||
}
|
||||
|
||||
func (a Anime) ShortRating() string {
|
||||
if a.Rating == "" {
|
||||
return ""
|
||||
}
|
||||
// Rating format: "PG-13 - Teens 13 or older"
|
||||
for i, c := range a.Rating {
|
||||
if c == ' ' && i > 0 {
|
||||
return a.Rating[:i]
|
||||
}
|
||||
}
|
||||
return a.Rating
|
||||
}
|
||||
|
||||
func (a Anime) ShortDuration() string {
|
||||
if a.Duration == "" {
|
||||
return ""
|
||||
}
|
||||
// Duration format: "23 min per ep" or "1 hr 30 min"
|
||||
var num string
|
||||
for _, c := range a.Duration {
|
||||
if c >= '0' && c <= '9' {
|
||||
num += string(c)
|
||||
} else if c == ' ' && num != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if num != "" {
|
||||
return num + "m"
|
||||
}
|
||||
return a.Duration
|
||||
}
|
||||
|
||||
func (a Anime) Premiered() string {
|
||||
if a.Season != "" && a.Year > 0 {
|
||||
return fmt.Sprintf("%s %d", seasonLabel(a.Season), a.Year)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func seasonLabel(season string) string {
|
||||
switch strings.ToLower(season) {
|
||||
case "winter":
|
||||
return "Winter"
|
||||
case "spring":
|
||||
return "Spring"
|
||||
case "summer":
|
||||
return "Summer"
|
||||
case "fall", "autumn":
|
||||
return "Fall"
|
||||
default:
|
||||
if season == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(season[:1]) + strings.ToLower(season[1:])
|
||||
}
|
||||
}
|
||||
|
||||
type AnimeResponse struct {
|
||||
Data Anime `json:"data"`
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
Data []Anime `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type Pagination struct {
|
||||
HasNextPage bool `json:"has_next_page"`
|
||||
}
|
||||
|
||||
type TopAnimeResponse struct {
|
||||
Data []Anime `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Title string `json:"title"`
|
||||
Filler bool `json:"filler"`
|
||||
Recap bool `json:"recap"`
|
||||
}
|
||||
|
||||
type EpisodesResponse struct {
|
||||
Data []Episode `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type JikanRelationEntry struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type JikanRelationGroup struct {
|
||||
Relation string `json:"relation"`
|
||||
Entry []JikanRelationEntry `json:"entry"`
|
||||
}
|
||||
|
||||
type JikanRelationsResponse struct {
|
||||
Data []JikanRelationGroup `json:"data"`
|
||||
}
|
||||
|
||||
type RelationEntry struct {
|
||||
Anime Anime
|
||||
Relation string
|
||||
IsCurrent bool
|
||||
IsExtra bool
|
||||
}
|
||||
|
||||
func (a Anime) DisplayTitle() string {
|
||||
if a.TitleEnglish != "" {
|
||||
return a.TitleEnglish
|
||||
}
|
||||
if a.TitleJapanese != "" {
|
||||
return a.TitleJapanese
|
||||
}
|
||||
return a.Title
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mal/internal/database"
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
type AccessPolicy struct {
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mal/internal/database"
|
||||
"mal/internal/features/auth"
|
||||
"mal/internal/db"
|
||||
"mal/api/auth"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -4,13 +4,14 @@ import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"mal/internal/database"
|
||||
"mal/internal/features/anime"
|
||||
"mal/internal/features/auth"
|
||||
"mal/internal/features/playback"
|
||||
"mal/internal/features/watchlist"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/shared/middleware"
|
||||
"mal/internal/db"
|
||||
"mal/api/anime"
|
||||
"mal/api/auth"
|
||||
"mal/api/playback"
|
||||
"mal/api/watchlist"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/middleware"
|
||||
pkgmiddleware "mal/pkg/middleware"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -70,7 +71,7 @@ func NewRouter(cfg Config) http.Handler {
|
||||
if r.Method == http.MethodGet {
|
||||
authHandler.HandleLoginPage(w, r)
|
||||
} else {
|
||||
middleware.RateLimitAuth(middleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogin))).ServeHTTP(w, r)
|
||||
pkgmiddleware.RateLimitAuth(pkgmiddleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogin))).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -84,7 +85,7 @@ func NewRouter(cfg Config) http.Handler {
|
||||
|
||||
// Wrap mux with global CSRF origin verification and auth checking,
|
||||
// THEN auth context parsing.
|
||||
protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(middleware.VerifyOrigin(mux))
|
||||
protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux))
|
||||
authenticatedHandler := middleware.Auth(cfg.AuthService)(protectedHandler)
|
||||
return middleware.RequestLogger(authenticatedHandler)
|
||||
return pkgmiddleware.RequestLogger(authenticatedHandler)
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
func TestAccessPolicy_IsPublicPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy := NewAccessPolicy()
|
||||
|
||||
if !policy.IsPublicPath("/") {
|
||||
t.Fatal("expected / to be public")
|
||||
}
|
||||
|
||||
if !policy.IsPublicPath("/api/search") {
|
||||
t.Fatal("expected /api/search to be public")
|
||||
}
|
||||
|
||||
if !policy.IsPublicPath("/static/app.css") {
|
||||
t.Fatal("expected /static/app.css to be public")
|
||||
}
|
||||
|
||||
if policy.IsPublicPath("/watchlist") {
|
||||
t.Fatal("expected /watchlist to be private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireGlobalAuthWithPolicy_ProtectedPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy := AccessPolicy{
|
||||
PublicPaths: map[string]struct{}{"/public": {}},
|
||||
}
|
||||
|
||||
h := RequireGlobalAuthWithPolicy(policy)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/private", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect status, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if location := rec.Header().Get("Location"); location != "/login" {
|
||||
t.Fatalf("expected redirect to /login, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireGlobalAuthWithPolicy_AllowsAuthenticatedUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy := AccessPolicy{
|
||||
PublicPaths: map[string]struct{}{},
|
||||
}
|
||||
|
||||
h := RequireGlobalAuthWithPolicy(policy)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/private", nil)
|
||||
ctx := context.WithValue(req.Context(), UserContextKey, &database.User{ID: "user-1"})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req.WithContext(ctx))
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"mal/internal/database"
|
||||
)
|
||||
|
||||
func TestRequireAuth_UnauthenticatedAPIRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/watchlist", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("HX-Redirect"); got != "/login" {
|
||||
t.Fatalf("expected HX-Redirect /login, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuth_AuthenticatedRequestPassesThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/watchlist", nil)
|
||||
ctx := context.WithValue(req.Context(), UserContextKey, &database.User{ID: "user-1"})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req.WithContext(ctx))
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireGlobalAuth_AllowsPublicRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := RequireGlobalAuthWithPolicy(NewAccessPolicy())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func VerifyOrigin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
// If neither is present, and it's a POST/PUT/DELETE, reject it (strict policy)
|
||||
http.Error(w, "Missing Origin or Referer header", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
refURL, err := url.Parse(referer)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid Referer header", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
origin = refURL.Scheme + "://" + refURL.Host
|
||||
}
|
||||
|
||||
host := r.Host
|
||||
// If origin doesn't match host (accounting for potential schema prefixes)
|
||||
expectedHTTP := "http://" + host
|
||||
expectedHTTPS := "https://" + host
|
||||
|
||||
if origin != expectedHTTP && origin != expectedHTTPS {
|
||||
http.Error(w, "Cross-Site Request Forgery (CSRF) origin mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func newStatusRecorder(w http.ResponseWriter) *statusRecorder {
|
||||
return &statusRecorder{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *statusRecorder) WriteHeader(code int) {
|
||||
if rw.wroteHeader {
|
||||
return
|
||||
}
|
||||
rw.statusCode = code
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *statusRecorder) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *statusRecorder) Flush() {
|
||||
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hijacker, ok := rw.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("response writer does not support hijacking")
|
||||
}
|
||||
return hijacker.Hijack()
|
||||
}
|
||||
|
||||
func (rw *statusRecorder) Push(target string, opts *http.PushOptions) error {
|
||||
pusher, ok := rw.ResponseWriter.(http.Pusher)
|
||||
if !ok {
|
||||
return http.ErrNotSupported
|
||||
}
|
||||
return pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
func (rw *statusRecorder) Unwrap() http.ResponseWriter {
|
||||
return rw.ResponseWriter
|
||||
}
|
||||
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
recorder := newStatusRecorder(w)
|
||||
|
||||
next.ServeHTTP(recorder, r)
|
||||
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, recorder.statusCode, time.Since(start))
|
||||
})
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type visitor struct {
|
||||
attempts int
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
visitors = make(map[string]*visitor)
|
||||
mu sync.Mutex
|
||||
quit = make(chan struct{})
|
||||
)
|
||||
|
||||
func init() {
|
||||
go cleanupVisitors()
|
||||
}
|
||||
|
||||
func StopCleanup() {
|
||||
close(quit)
|
||||
}
|
||||
|
||||
func cleanupVisitors() {
|
||||
for {
|
||||
select {
|
||||
case <-quit:
|
||||
return
|
||||
case <-time.After(time.Minute):
|
||||
mu.Lock()
|
||||
for ip, v := range visitors {
|
||||
if time.Since(v.lastSeen) > 3*time.Minute {
|
||||
delete(visitors, ip)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
ip := r.RemoteAddr
|
||||
if colonIdx := strings.LastIndex(ip, ":"); colonIdx != -1 {
|
||||
ip = ip[:colonIdx]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func RateLimitAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := getIP(r)
|
||||
|
||||
mu.Lock()
|
||||
v, exists := visitors[ip]
|
||||
if !exists {
|
||||
visitors[ip] = &visitor{1, time.Now()}
|
||||
} else {
|
||||
// Reset attempts if it's been more than a minute
|
||||
if time.Since(v.lastSeen) > time.Minute {
|
||||
v.attempts = 0
|
||||
}
|
||||
v.attempts++
|
||||
v.lastSeen = time.Now()
|
||||
}
|
||||
|
||||
// If more than 5 attempts within a minute, block
|
||||
if exists && v.attempts > 5 {
|
||||
mu.Unlock()
|
||||
if strings.HasPrefix(r.URL.Path, "/") {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s?error=rate_limited", r.URL.Path), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package ui
|
||||
|
||||
import "fmt"
|
||||
|
||||
type AnimeCardProps struct {
|
||||
ID int
|
||||
Title string
|
||||
ImageURL string
|
||||
Href string
|
||||
// Options to customize the card behavior
|
||||
Class string // override default wrapper class
|
||||
ImgClass string // override default image class
|
||||
TitleClass string // override default title class
|
||||
HideTitle bool // if true, do not render the default title block
|
||||
CurrentNode bool // if true, renders a div instead of an anchor tag (useful for graph nodes)
|
||||
}
|
||||
|
||||
templ AnimeCard(props AnimeCardProps) {
|
||||
if props.CurrentNode {
|
||||
<div class={ defaultString(props.Class, "min-w-0") }>
|
||||
@animeCardPoster(props.ImageURL, props.Title, props.ImgClass)
|
||||
<div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-sm leading-snug text-(--text)") }>
|
||||
{ props.Title }
|
||||
</div>
|
||||
{ children... }
|
||||
</div>
|
||||
} else {
|
||||
<a href={ templ.URL(cardHref(props)) } class={ defaultString(props.Class, "flex flex-col bg-transparent text-inherit no-underline") }>
|
||||
@animeCardPoster(props.ImageURL, props.Title, props.ImgClass)
|
||||
|
||||
if !props.HideTitle {
|
||||
<div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-sm leading-snug text-(--text)") }>
|
||||
{ props.Title }
|
||||
</div>
|
||||
}
|
||||
{ children... }
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
func cardHref(props AnimeCardProps) string {
|
||||
if props.Href != "" {
|
||||
return props.Href
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/anime/%d", props.ID)
|
||||
}
|
||||
|
||||
templ animeCardPoster(imageURL, title, imgClass string) {
|
||||
<div class="flex w-full aspect-2/3 justify-center overflow-hidden">
|
||||
if imageURL != "" {
|
||||
<img src={ imageURL } alt={ title } class={ defaultString(imgClass, "block w-full object-cover object-center") } loading="lazy"/>
|
||||
} else {
|
||||
<div class="flex w-full justify-center overflow-hidden text-transparent">No image</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
func defaultString(val, fallback string) string {
|
||||
if val == "" {
|
||||
return fallback
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/jikan"
|
||||
)
|
||||
|
||||
templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, containerID string) {
|
||||
for _, anime := range animes {
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@CatalogItem(anime)
|
||||
</div>
|
||||
}
|
||||
if hasNext {
|
||||
<div class="col-span-full h-px w-full" hx-get={ nextURL } hx-trigger="revealed" hx-swap="outerHTML"></div>
|
||||
}
|
||||
<script data-container={ containerID }>
|
||||
(function() {
|
||||
const scripts = document.querySelectorAll('script[data-container]');
|
||||
const currentScript = scripts[scripts.length - 1];
|
||||
const actualID = currentScript.getAttribute('data-container');
|
||||
const container = document.getElementById(actualID) || document;
|
||||
const items = container.querySelectorAll('[data-id]');
|
||||
const seen = new Set();
|
||||
items.forEach(item => {
|
||||
const id = item.getAttribute('data-id');
|
||||
if (id) {
|
||||
if (seen.has(id)) {
|
||||
item.remove();
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
templ CatalogItem(anime jikan.Anime) {
|
||||
@AnimeCard(AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
})
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package ui
|
||||
|
||||
templ EmptyState(title string) {
|
||||
<div class="py-4">
|
||||
<div class="mb-2 text-base">{ title }</div>
|
||||
<div class="text-sm text-(--text-muted)">
|
||||
{ children... }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package icons
|
||||
|
||||
templ LogoIcon(class string) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class={ class } viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="miter">
|
||||
<!-- clean, superminimal, abstract logo without border-radius -->
|
||||
<rect x="6" y="10" width="12" height="12"></rect>
|
||||
<rect x="14" y="6" width="12" height="12"></rect>
|
||||
</svg>
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package ui
|
||||
|
||||
templ LoadingIndicator(text string) {
|
||||
<div class="inline-flex items-center gap-2 text-sm text-(--text-muted)">
|
||||
<div class="h-1.5 w-1.5 bg-(--text-faint)"></div>
|
||||
<div class="h-1.5 w-1.5 bg-(--text-faint)"></div>
|
||||
<div class="h-1.5 w-1.5 bg-(--text-faint)"></div>
|
||||
<span>{ text }</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package ui
|
||||
|
||||
type SortFilterOptions struct {
|
||||
Sort string // "title", "date"
|
||||
Order string // "asc", "desc"
|
||||
View string // for watchlist: "grid", "table"
|
||||
Status string // for watchlist: "all", "watching", etc
|
||||
}
|
||||
|
||||
templ SortFilter(opts SortFilterOptions) {
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3 bg-(--panel) p-3 max-lg:flex-col max-lg:items-start max-lg:gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="sort-select" class="text-xs text-(--text-muted)">Sort by</label>
|
||||
<select id="sort-select" class="h-8 bg-(--surface-select) px-2 text-xs text-(--text)" onchange="document.getElementById('sort-input').value = this.value; document.getElementById('sort-form').submit()">
|
||||
<option value="date" selected?={ opts.Sort == "date" }>Date added</option>
|
||||
<option value="title" selected?={ opts.Sort == "title" }>Title</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="order-select" class="text-xs text-(--text-muted)">Order</label>
|
||||
<select id="order-select" class="h-8 bg-(--surface-select) px-2 text-xs text-(--text)" onchange="document.getElementById('order-input').value = this.value; document.getElementById('sort-form').submit()">
|
||||
<option value="desc" selected?={ opts.Order == "desc" }>Descending</option>
|
||||
<option value="asc" selected?={ opts.Order == "asc" }>Ascending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<form id="sort-form" method="get" class="hidden">
|
||||
<input type="hidden" name="sort" id="sort-input" value={ opts.Sort }/>
|
||||
<input type="hidden" name="order" id="order-input" value={ opts.Order }/>
|
||||
if opts.View != "" {
|
||||
<input type="hidden" name="view" value={ opts.View }/>
|
||||
}
|
||||
if opts.Status != "" {
|
||||
<input type="hidden" name="status" value={ opts.Status }/>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/shared/ui"
|
||||
import "fmt"
|
||||
import "strings"
|
||||
|
||||
templ AnimeDetails(anime jikan.Anime, currentStatus string, nextEpisode int) {
|
||||
@Layout("mal - " + anime.DisplayTitle(), true) {
|
||||
<div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div class="grid min-w-0 gap-8">
|
||||
<div class="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div class="w-56">
|
||||
if anime.ImageURL() != "" {
|
||||
<img class="w-full" src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
|
||||
} else {
|
||||
<div class="flex aspect-2/3 max-h-(--poster-max-height) w-full justify-center overflow-hidden text-transparent">No image</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h1>{ anime.DisplayTitle() }</h1>
|
||||
if anime.TitleJapanese != "" {
|
||||
<p class="my-2 mb-3 text-sm text-(--text-muted)">{ anime.TitleJapanese }</p>
|
||||
}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if anime.ShortRating() != "" {
|
||||
<span class="text-xs text-(--text-faint)">{ anime.ShortRating() }</span>
|
||||
}
|
||||
if anime.Type != "" {
|
||||
<span class="text-xs text-(--text-faint)">{ anime.Type }</span>
|
||||
}
|
||||
if anime.Episodes > 0 {
|
||||
<span class="text-xs text-(--text-faint)">{ fmt.Sprintf("%d ep", anime.Episodes) }</span>
|
||||
}
|
||||
if anime.ShortDuration() != "" {
|
||||
<span class="text-xs text-(--text-faint)">{ anime.ShortDuration() }</span>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing)
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", anime.MalID, watchTargetEpisode(currentStatus, nextEpisode))) }
|
||||
class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text) no-underline hover:text-(--text) hover:no-underline"
|
||||
>Watch</a>
|
||||
</div>
|
||||
</div>
|
||||
<section class="mt-4 max-w-4xl">
|
||||
if anime.Synopsis != "" {
|
||||
<p>{ anime.Synopsis }</p>
|
||||
} else {
|
||||
<p class="text-(--text-faint)">No synopsis available.</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<h3 class="mb-3 text-lg font-semibold tracking-wide text-(--text)">Related</h3>
|
||||
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/relations", anime.MalID))) } hx-trigger="load">
|
||||
@ui.LoadingIndicator("Loading relations")
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="mb-3 text-lg font-semibold tracking-wide text-(--text)">Recommendations</h3>
|
||||
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/recommendations", anime.MalID))) } hx-trigger="load">
|
||||
@ui.LoadingIndicator("Loading recommendations")
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="sticky top-20 grid gap-4 bg-(--panel) p-3 max-xl:static">
|
||||
<div class="grid gap-3">
|
||||
<h3 class="mb-2 text-base font-semibold tracking-wide text-(--text)">Details</h3>
|
||||
if anime.Aired.String != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Aired</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Aired.String }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Premiered() != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Premiered</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Premiered() }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Status != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Status</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Status }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Duration != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Duration</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Duration }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Genres) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Genres</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinNames(anime.Genres) }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if hasExtraSidebarDetails(anime) {
|
||||
<details class="grid gap-3">
|
||||
<summary class="cursor-pointer text-xs text-(--text-muted)">More metadata</summary>
|
||||
if anime.TitleJapanese != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Japanese</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.TitleJapanese }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.TitleSynonyms) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Synonyms</span>
|
||||
<span class="text-sm text-(--text-muted)">{ strings.Join(anime.TitleSynonyms, ", ") }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Studios) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Studios</span>
|
||||
<span class="text-sm text-(--text-muted)">
|
||||
@studioLinks(anime.Studios)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Producers) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Producers</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinNames(anime.Producers) }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Source != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Source</span>
|
||||
<span class="text-sm text-(--text-muted)">{ anime.Source }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Demographics) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Demographics</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinNames(anime.Demographics) }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Themes) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Themes</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinNames(anime.Themes) }</span>
|
||||
</div>
|
||||
}
|
||||
if anime.Broadcast.String != "" {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Broadcast</span>
|
||||
<span class="text-sm text-(--text-muted)" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span>
|
||||
</div>
|
||||
}
|
||||
if len(anime.Streaming) > 0 {
|
||||
<div class="mt-1 grid gap-1">
|
||||
<span class="mt-0.5 text-sm text-(--text-faint)">Streaming</span>
|
||||
<span class="text-sm text-(--text-muted)">{ joinStreamingNames(anime) }</span>
|
||||
</div>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
func watchTargetEpisode(currentStatus string, nextEpisode int) int {
|
||||
if currentStatus == "watching" && nextEpisode > 0 {
|
||||
return nextEpisode
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
templ AnimePending(id int) {
|
||||
@Layout("mal - anime pending", true) {
|
||||
<div class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div class="grid min-w-0 gap-8">
|
||||
<section>
|
||||
<h1>Anime data is being fetched</h1>
|
||||
<p class="text-sm text-(--text-muted)">We could not load this anime right now. A background worker is retrying data fetch for anime #{ fmt.Sprintf("%d", id) }.</p>
|
||||
<p class="text-sm text-(--text-muted)">Refresh this page in a few seconds.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
window.location.reload()
|
||||
}, 10000)
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
func joinNames(entities []jikan.NamedEntity) string {
|
||||
names := make([]string, len(entities))
|
||||
for i, e := range entities {
|
||||
names[i] = e.Name
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func joinStreamingNames(anime jikan.Anime) string {
|
||||
names := make([]string, len(anime.Streaming))
|
||||
for i, s := range anime.Streaming {
|
||||
names[i] = s.Name
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) {
|
||||
<div class="relative inline-block" id="watchlist-dropdown">
|
||||
<button class="inline-flex h-8 cursor-pointer items-center gap-2 bg-(--panel-soft) px-2 text-xs text-(--text)" onclick="toggleDropdown()" data-dropdown-trigger>
|
||||
if currentStatus != "" {
|
||||
{ formatStatus(currentStatus) }
|
||||
} else {
|
||||
Add to watchlist
|
||||
}
|
||||
<span class="text-xs">▾</span>
|
||||
</button>
|
||||
<div class="invisible absolute left-0 top-full mt-0.5 z-50 min-w-52 bg-(--panel) opacity-0 transition-opacity duration-150" data-dropdown-menu data-dropdown-open-classes="visible opacity-100" data-dropdown-closed-classes="invisible opacity-0">
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "dropped", currentStatus, airing)
|
||||
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "plan_to_watch", currentStatus, airing)
|
||||
if currentStatus != "" {
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center justify-between bg-transparent px-2.5 py-2 text-left text-xs text-(--text-muted) hover:bg-(--panel-soft) hover:text-(--danger)"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))) }
|
||||
hx-target="#watchlist-dropdown"
|
||||
hx-swap="outerHTML swap:150ms"
|
||||
>Remove from list</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ dropdownStatusOption(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, status string, currentStatus string, airing bool) {
|
||||
<button
|
||||
class={
|
||||
"flex w-full cursor-pointer items-center justify-between bg-transparent px-2.5 py-2 text-left text-xs text-(--text-muted) hover:bg-(--panel-soft) hover:text-(--text)",
|
||||
templ.KV("bg-(--panel-soft) text-(--text)", status == currentStatus),
|
||||
}
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing) }
|
||||
hx-target="#watchlist-dropdown"
|
||||
hx-swap="outerHTML swap:150ms"
|
||||
>
|
||||
{ formatStatus(status) }
|
||||
</button>
|
||||
}
|
||||
|
||||
func formatStatus(status string) string {
|
||||
switch status {
|
||||
case "watching":
|
||||
return "Watching"
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "on_hold":
|
||||
return "On hold"
|
||||
case "dropped":
|
||||
return "Dropped"
|
||||
case "plan_to_watch":
|
||||
return "Plan to watch"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
templ AnimeRelationsList(relations []jikan.RelationEntry) {
|
||||
if len(relations) > 1 {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="relations-grid">
|
||||
for _, rel := range relations {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: rel.Anime.MalID,
|
||||
Title: rel.Anime.DisplayTitle(),
|
||||
ImageURL: rel.Anime.ImageURL(),
|
||||
Class: relationCardClass(rel),
|
||||
ImgClass: "relation-thumb",
|
||||
TitleClass: "relation-title",
|
||||
CurrentNode: rel.IsCurrent,
|
||||
}) {
|
||||
if rel.IsCurrent {
|
||||
<div class="mt-2 h-0.5 w-10 bg-white"></div>
|
||||
}
|
||||
if rel.Relation != "" && rel.Relation != "Current" {
|
||||
<div class="mt-1 text-xs text-(--text-faint)">{ rel.Relation }</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="text-sm text-(--text-muted)">No related anime found.</p>
|
||||
}
|
||||
}
|
||||
|
||||
func relationCardClass(rel jikan.RelationEntry) string {
|
||||
return "relation-card min-w-0 flex flex-col bg-transparent text-inherit no-underline"
|
||||
}
|
||||
|
||||
templ AnimeRecommendations(recs []jikan.Anime) {
|
||||
if len(recs) > 0 {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
|
||||
for _, anime := range recs {
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
Class: "relation-card",
|
||||
ImgClass: "relation-thumb",
|
||||
TitleClass: "relation-title",
|
||||
})
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="text-sm text-(--text-muted)">No recommendations available.</p>
|
||||
}
|
||||
}
|
||||
|
||||
func hasExtraSidebarDetails(anime jikan.Anime) bool {
|
||||
return anime.TitleJapanese != "" || len(anime.TitleSynonyms) > 0 || len(anime.Studios) > 0 || len(anime.Producers) > 0 || anime.Source != "" || len(anime.Demographics) > 0 || len(anime.Themes) > 0 || anime.Broadcast.String != "" || len(anime.Streaming) > 0
|
||||
}
|
||||
|
||||
templ studioLinks(studios []jikan.NamedEntity) {
|
||||
for i, studio := range studios {
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/studios/%d", studio.MalID)) }
|
||||
class="hover:text-(--text) hover:underline"
|
||||
>{ studio.Name }</a>
|
||||
if i < len(studios)-1 {
|
||||
<span>, </span>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package templates
|
||||
|
||||
templ Login(formError string, username string) {
|
||||
@Layout("Login", false) {
|
||||
<div class="w-full max-w-xl">
|
||||
<div class="mx-auto w-full bg-(--panel) p-6">
|
||||
<h2 class="m-0 text-2xl">Sign in</h2>
|
||||
<p class="my-3 mb-5 text-sm text-(--text-muted)">Enter your credentials to continue.</p>
|
||||
<form action="/login" method="POST" class="grid gap-4">
|
||||
<div class="grid gap-1">
|
||||
<label for="username">Username / Email</label>
|
||||
<input class="h-10 border border-transparent bg-(--surface-search) px-3 text-(--text) transition-colors duration-120 focus:border-(--surface-search-focus-border) focus:outline-none" type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<label for="password">Password</label>
|
||||
<input class="h-10 border border-transparent bg-(--surface-search) px-3 text-(--text) transition-colors duration-120 focus:border-(--surface-search-focus-border) focus:outline-none" type="password" id="password" name="password" required placeholder="Your password"/>
|
||||
</div>
|
||||
<button type="submit" class="h-10 cursor-pointer border-0 bg-(--accent) text-sm font-semibold text-(--text-on-accent) hover:brightness-95">Sign in</button>
|
||||
if formError != "" {
|
||||
<p class="mt-2 text-xs text-(--danger)" role="alert" aria-live="polite">{ formError }</p>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/shared/ui"
|
||||
import "fmt"
|
||||
|
||||
templ Catalog() {
|
||||
@Layout("mal - catalog", true) {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="catalog-content">
|
||||
<div class="col-span-full" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML">
|
||||
@ui.LoadingIndicator("Loading catalog")
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ CatalogItems(animes []jikan.Anime, nextPage int, hasNext bool) {
|
||||
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/catalog?page=%d", nextPage))), "catalog-content")
|
||||
}
|
||||
|
||||
templ CatalogPlaceholderItems(count int) {
|
||||
for i := 0; i < count; i++ {
|
||||
<div class="pointer-events-none min-w-0" aria-hidden="true">
|
||||
<div class="aspect-2/3 max-h-(--poster-max-height) w-full animate-pulse bg-(--surface-search)"></div>
|
||||
<div class="mt-2 h-4 w-4/5 animate-pulse bg-(--surface-search)"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/database"
|
||||
"mal/internal/shared/ui"
|
||||
)
|
||||
|
||||
templ ContinueWatching(entries []database.GetContinueWatchingEntriesRow) {
|
||||
@Layout("mal - continue watching", true) {
|
||||
<div class="grid gap-4">
|
||||
<h1>Continue watching</h1>
|
||||
<p class="m-0 text-sm text-(--text-muted)">Pick up where you left off.</p>
|
||||
if len(entries) == 0 {
|
||||
@ui.EmptyState("Nothing to continue yet") {
|
||||
Start watching any anime and your progress will show up here.
|
||||
}
|
||||
} else {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
|
||||
for _, entry := range entries {
|
||||
<div class="group relative min-w-0" id={ fmt.Sprintf("continue-entry-%d", entry.AnimeID) }>
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: int(entry.AnimeID),
|
||||
Title: displayContinueWatchingTitle(entry),
|
||||
ImageURL: entry.ImageUrl,
|
||||
Href: continueWatchingURL(entry),
|
||||
Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
|
||||
HideTitle: true,
|
||||
}) {
|
||||
<div class="mt-2 grid gap-1 p-0">
|
||||
<div class="line-clamp-2 text-sm leading-snug text-(--text)">{ displayContinueWatchingTitle(entry) }</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||
<span class="text-xs text-(--text-faint)">Continue ep { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }</span>
|
||||
}
|
||||
if entry.CurrentTimeSeconds > 0 {
|
||||
<span class="text-xs text-(--text-faint)">{ formatProgressTime(entry.CurrentTimeSeconds) }</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
class="absolute right-2 top-2 h-6 w-6 cursor-pointer border-0 bg-(--overlay-subtle) text-(--text-muted) opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-(--danger)"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/continue-watching/%d", entry.AnimeID))) }
|
||||
hx-target={ fmt.Sprintf("#continue-entry-%d", entry.AnimeID) }
|
||||
hx-swap="delete"
|
||||
>×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
func continueWatchingURL(entry database.GetContinueWatchingEntriesRow) string {
|
||||
episode := 1
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||
episode = int(entry.CurrentEpisode.Int64)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/watch/%d/%d", entry.AnimeID, episode)
|
||||
}
|
||||
|
||||
func displayContinueWatchingTitle(entry database.GetContinueWatchingEntriesRow) string {
|
||||
return database.DisplayTitle(entry.TitleEnglish, entry.TitleJapanese, entry.TitleOriginal)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/shared/ui"
|
||||
import "fmt"
|
||||
|
||||
templ Discover() {
|
||||
@Layout("mal - discover", true) {
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-4">
|
||||
<h1>Discover</h1>
|
||||
<p class="m-0 text-sm text-(--text-muted)">Browse what's airing now and what is coming soon.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1" data-tab-group="discover">
|
||||
<button
|
||||
class="tab-trigger shrink-0 whitespace-nowrap bg-(--surface-tab-active) px-2 py-1 text-xs text-(--accent)"
|
||||
type="button"
|
||||
hx-get="/api/discover/airing?page=1"
|
||||
hx-target="#discover-content"
|
||||
hx-trigger="click"
|
||||
data-tab-trigger
|
||||
data-tab-active-classes="bg-(--surface-tab-active) text-(--accent)"
|
||||
data-tab-inactive-classes="bg-(--panel-soft) text-(--text-muted)"
|
||||
>
|
||||
airing now
|
||||
</button>
|
||||
<button
|
||||
class="tab-trigger shrink-0 whitespace-nowrap bg-(--panel-soft) px-2 py-1 text-xs text-(--text-muted) hover:bg-(--surface-tab-hover) hover:text-(--text)"
|
||||
type="button"
|
||||
hx-get="/api/discover/upcoming?page=1"
|
||||
hx-target="#discover-content"
|
||||
hx-trigger="click"
|
||||
data-tab-trigger
|
||||
data-tab-active-classes="bg-(--surface-tab-active) text-(--accent)"
|
||||
data-tab-inactive-classes="bg-(--panel-soft) text-(--text-muted)"
|
||||
>
|
||||
upcoming
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load">
|
||||
<div class="col-span-full">
|
||||
@ui.LoadingIndicator("Loading discover")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ DiscoverItems(animes []jikan.Anime, listType string, nextPage int, hasNext bool) {
|
||||
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/discover/%s?page=%d", listType, nextPage))), "discover-content")
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/shared/ui"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
templ Search(q string) {
|
||||
@Layout("mal - search", true) {
|
||||
if q != "" {
|
||||
<div id="loading" class="hidden htmx-request:inline-flex">
|
||||
@ui.LoadingIndicator("Searching...")
|
||||
</div>
|
||||
<div id="results" hx-get={ string(templ.URL("/search?q=" + url.QueryEscape(q))) } hx-trigger="load" hx-indicator="#loading"></div>
|
||||
} else {
|
||||
@ui.EmptyState("Search for anime") {
|
||||
Use the search bar above to find anime to add to your watchlist.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ SearchResultsWrapper(query string, animes []jikan.Anime, nextPage int, hasNext bool) {
|
||||
if len(animes) == 0 {
|
||||
@ui.EmptyState("No results found.") {
|
||||
Try a different search term.
|
||||
}
|
||||
} else {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
|
||||
@SearchItems(query, animes, nextPage, hasNext)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ SearchItems(query string, animes []jikan.Anime, nextPage int, hasNext bool) {
|
||||
@ui.InfiniteAnimeList(animes, hasNext, string(templ.URL(fmt.Sprintf("/api/search?q=%s&page=%d", url.QueryEscape(query), nextPage))), "results")
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/shared/ui/icons"
|
||||
|
||||
templ Layout(title string, showHeader bool) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ title }</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
<link rel="stylesheet" href="/dist/tailwind.css"/>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
|
||||
<script src="/dist/discover.js" defer></script>
|
||||
<script src="/dist/anime.js" defer></script>
|
||||
<script src="/dist/timezone.js" defer></script>
|
||||
<script src="/dist/player.js" defer></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-(--bg) text-(--text) font-(--font) text-sm leading-normal">
|
||||
if showHeader {
|
||||
<header class="sticky top-0 z-100 bg-(--header)">
|
||||
<div class="mx-auto flex w-full max-w-screen-2xl items-center gap-4 px-4 py-3 max-lg:flex-wrap max-lg:gap-3">
|
||||
<div class="flex min-w-0 items-center gap-5 max-lg:w-full max-lg:flex-wrap max-lg:gap-3" data-search-root>
|
||||
<a href="/" class="inline-flex items-center text-(--accent)" aria-label="mal logo">
|
||||
@icons.LogoIcon("h-7 w-7")
|
||||
</a>
|
||||
<div class="flex flex-wrap gap-3 text-sm max-lg:w-full max-lg:gap-2">
|
||||
<a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/discover">Discover</a>
|
||||
<a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/continue-watching">Continue watching</a>
|
||||
<a class="text-(--text-muted) no-underline hover:text-(--text) hover:no-underline" href="/watchlist">Watchlist</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative ml-auto min-w-60 w-full max-w-md max-lg:ml-0" data-search-root>
|
||||
<form action="/search" method="GET" class="w-full" id="search-form">
|
||||
<input type="text" id="search-input" name="q" class="h-9 w-full border border-transparent bg-(--surface-search) px-3 text-(--text) transition-colors duration-120 placeholder:text-(--text-faint) focus:border-(--surface-search-focus-border) focus:outline-none" placeholder="Search anime..." autocomplete="off"/>
|
||||
<div id="search-dropdown" class="absolute inset-x-0 top-full mt-0.5 z-50 max-h-screen overflow-y-auto bg-(--panel)" data-search-results-container></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
<main class={
|
||||
"mx-auto w-full max-w-screen-2xl px-4 pt-5 pb-8 max-lg:px-3 max-lg:pb-6",
|
||||
templ.KV("flex min-h-screen items-center justify-center px-4 py-0", !showHeader),
|
||||
}>
|
||||
{ children... }
|
||||
</main>
|
||||
<script src="/dist/search.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package templates
|
||||
|
||||
templ NotFoundPage() {
|
||||
@Layout("mal - not found", false) {
|
||||
<section class="w-full max-w-3xl min-h-dvh mx-auto grid content-center justify-items-center gap-3 px-7 py-8 text-center">
|
||||
<p class="m-0 text-6xl leading-none tracking-wider text-(--text-muted) sm:text-7xl md:text-8xl lg:text-9xl">404</p>
|
||||
<h1 class="m-0 text-3xl sm:text-4xl md:text-5xl">Page not found</h1>
|
||||
<p class="text-(--text-muted)">The page you requested does not exist, or it was moved.</p>
|
||||
<p><a href="/" class="text-base text-(--accent) no-underline hover:underline">Back to catalog</a></p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/shared/ui"
|
||||
import "fmt"
|
||||
|
||||
templ StudioDetails(producer jikan.ProducerResponse, animes []jikan.Anime, hasNext bool, nextPage int) {
|
||||
@Layout("mal - "+getProducerName(producer), true) {
|
||||
<div class="grid gap-5">
|
||||
<div class="grid gap-4 bg-(--panel) p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
if producer.Data.Images.Jpg.ImageURL != "" {
|
||||
<img
|
||||
src={ producer.Data.Images.Jpg.ImageURL }
|
||||
alt={ getProducerName(producer) }
|
||||
class="h-24 w-24 object-contain"
|
||||
/>
|
||||
}
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold">{ getProducerName(producer) }</h1>
|
||||
if producer.Data.Established != "" {
|
||||
<p class="mt-1 text-sm text-(--text-muted)">
|
||||
Established: { formatEstablishedDate(producer.Data.Established) }
|
||||
</p>
|
||||
}
|
||||
if producer.Data.Count > 0 {
|
||||
<p class="text-sm text-(--text-faint)">
|
||||
{ fmt.Sprintf("%d anime", producer.Data.Count) }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if producer.Data.About != "" {
|
||||
<p class="text-sm text-(--text-muted) line-clamp-3">{ producer.Data.About }</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-3 text-lg font-semibold">Anime</h2>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5" id="studio-anime-content">
|
||||
for _, anime := range animes {
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
})
|
||||
</div>
|
||||
}
|
||||
if hasNext {
|
||||
@StudioLoadMore(producer.Data.MalID, nextPage)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ StudioLoadMore(studioID int, nextPage int) {
|
||||
<div
|
||||
class="col-span-full h-px w-full"
|
||||
hx-get={ string(templ.URL(fmt.Sprintf("/api/studios/%d/anime?page=%d", studioID, nextPage))) }
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML"
|
||||
></div>
|
||||
}
|
||||
|
||||
templ StudioAnimeItems(animes []jikan.Anime, hasNext bool, studioID int, nextPage int) {
|
||||
for _, anime := range animes {
|
||||
<div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
|
||||
@ui.AnimeCard(ui.AnimeCardProps{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
ImageURL: anime.ImageURL(),
|
||||
})
|
||||
</div>
|
||||
}
|
||||
if hasNext {
|
||||
@StudioLoadMore(studioID, nextPage)
|
||||
}
|
||||
<script data-container="studio-anime-content">
|
||||
(function() {
|
||||
const scripts = document.querySelectorAll('script[data-container]');
|
||||
const currentScript = scripts[scripts.length - 1];
|
||||
const actualID = currentScript.getAttribute('data-container');
|
||||
const container = document.getElementById(actualID) || document;
|
||||
const items = container.querySelectorAll('[data-id]');
|
||||
const seen = new Set();
|
||||
items.forEach(item => {
|
||||
const id = item.getAttribute('data-id');
|
||||
if (id) {
|
||||
if (seen.has(id)) {
|
||||
item.remove();
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
func getProducerName(producer jikan.ProducerResponse) string {
|
||||
for _, title := range producer.Data.Titles {
|
||||
if title.Type == "Default" {
|
||||
return title.Title
|
||||
}
|
||||
}
|
||||
return "Studio"
|
||||
}
|
||||
|
||||
func formatEstablishedDate(date string) string {
|
||||
if len(date) >= 10 {
|
||||
return date[:10]
|
||||
}
|
||||
return date
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/shared/ui"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// WatchPageData holds the data needed for the watch page
|
||||
type WatchPageData struct {
|
||||
MalID int
|
||||
Title string
|
||||
TitleEnglish string
|
||||
TitleJapanese string
|
||||
ImageURL string
|
||||
Airing bool
|
||||
CurrentEpisode string
|
||||
TotalEpisodes int
|
||||
StartTimeSeconds float64
|
||||
CurrentStatus string
|
||||
InitialMode string
|
||||
AvailableModes []string
|
||||
ModeSources map[string]ModeSource
|
||||
Segments []SkipSegment
|
||||
}
|
||||
|
||||
// ModeSource represents a stream source for a specific mode (dub/sub)
|
||||
type ModeSource struct {
|
||||
Token string `json:"token"`
|
||||
Subtitles []SubtitleItem `json:"subtitles"`
|
||||
}
|
||||
|
||||
// SubtitleItem represents a subtitle track
|
||||
type SubtitleItem struct {
|
||||
Lang string `json:"lang"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// SkipSegment represents a skippable segment (intro/outro)
|
||||
type SkipSegment struct {
|
||||
Type string `json:"type"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
}
|
||||
|
||||
templ WatchPage(anime jikan.Anime, data WatchPageData) {
|
||||
@Layout(fmt.Sprintf("%s - episode %s", anime.DisplayTitle(), data.CurrentEpisode), true) {
|
||||
<div class="w-full overflow-x-clip">
|
||||
<div class="mx-auto grid w-full gap-4 lg:gap-5 lg:grid-cols-[220px_minmax(0,1fr)_250px] xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<!-- Left sidebar: Episodes -->
|
||||
<aside class="order-2 w-full min-w-0 lg:order-1">
|
||||
<div class="flex h-full max-h-[320px] flex-col sm:max-h-[420px] lg:max-h-[800px]">
|
||||
<div class="p-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-(--text)">Episodes</h3>
|
||||
</div>
|
||||
<div
|
||||
hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/episodes?current=%s", anime.MalID, data.CurrentEpisode))) }
|
||||
hx-trigger="load"
|
||||
class="overflow-y-auto flex-1 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
@LoadingIndicatorSmall()
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content: Video and Controls -->
|
||||
<div class="order-1 flex min-w-0 flex-1 flex-col gap-4 sm:gap-5 lg:order-2">
|
||||
@VideoPlayer(data)
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 sm:justify-end">
|
||||
if canGoPrevEpisode(data.CurrentEpisode) {
|
||||
<a
|
||||
href={ templ.URL(episodeWithOffsetURL(anime.MalID, data.CurrentEpisode, -1)) }
|
||||
class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text) no-underline hover:bg-(--panel) hover:text-(--text) hover:no-underline"
|
||||
>
|
||||
◀ Prev
|
||||
</a>
|
||||
} else {
|
||||
<span class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text-faint) opacity-50">◀ Prev</span>
|
||||
}
|
||||
if canGoNextEpisode(data.CurrentEpisode, anime.Episodes) {
|
||||
<a
|
||||
href={ templ.URL(episodeWithOffsetURL(anime.MalID, data.CurrentEpisode, 1)) }
|
||||
class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text) no-underline hover:bg-(--panel) hover:text-(--text) hover:no-underline"
|
||||
>
|
||||
Next ▶
|
||||
</a>
|
||||
} else {
|
||||
<span class="inline-flex h-8 items-center bg-(--panel-soft) px-2 text-xs text-(--text-faint) opacity-50">Next ▶</span>
|
||||
}
|
||||
<span id="watch-status-dropdown">
|
||||
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), data.CurrentStatus, anime.Airing)
|
||||
</span>
|
||||
</div>
|
||||
<section>
|
||||
<h3 class="mb-3 text-lg font-semibold tracking-wide text-(--text)">Watch more seasons of this anime</h3>
|
||||
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/relations", anime.MalID))) } hx-trigger="load">
|
||||
@ui.LoadingIndicator("Loading relations")
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right sidebar: Anime Info -->
|
||||
<aside class="order-3 w-full min-w-0 flex flex-col gap-4 lg:order-3">
|
||||
<img src={ anime.Images.Webp.LargeImageURL } alt={ anime.Title } class="mx-auto w-full max-w-sm object-cover shadow-lg lg:max-w-none" />
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-(--text)">{ anime.DisplayTitle() }</h2>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-(--text-muted)">
|
||||
if anime.ShortRating() != "" {
|
||||
<span>{ anime.ShortRating() }</span>
|
||||
<span>•</span>
|
||||
}
|
||||
<span>HD</span>
|
||||
<span>•</span>
|
||||
<span>{ anime.Type }</span>
|
||||
<span>•</span>
|
||||
if anime.ShortDuration() != "" {
|
||||
<span>{ anime.ShortDuration() }</span>
|
||||
} else {
|
||||
<span>{ anime.Duration }</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-(--text-muted) mt-4 line-clamp-6">
|
||||
{ anime.Synopsis }
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)) }
|
||||
class="inline-flex h-9 items-center justify-center bg-(--panel-soft) px-4 text-sm font-semibold text-(--text) no-underline hover:bg-(--panel) hover:text-(--text) transition-colors"
|
||||
>
|
||||
View detail
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ LoadingIndicatorSmall() {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="h-5 w-5 animate-spin border-2 border-(--panel-soft) border-t-(--accent)"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ EpisodeList(episodes []jikan.Episode, currentEpisode string, animeID int) {
|
||||
if len(episodes) == 0 {
|
||||
<p class="py-4 text-center text-sm text-(--text-muted)">No episodes available</p>
|
||||
} else {
|
||||
<div class="flex flex-col">
|
||||
for _, ep := range episodes {
|
||||
@EpisodeItem(ep, currentEpisode, animeID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ EpisodeItem(episode jikan.Episode, currentEpisode string, animeID int) {
|
||||
{{ isCurrent := fmt.Sprintf("%d", episode.MalID) == currentEpisode }}
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", animeID, episode.MalID)) }
|
||||
class={
|
||||
"flex items-center gap-3 px-3 py-2.5 text-sm no-underline transition-colors border-b border-(--panel-soft) last:border-0",
|
||||
templ.KV("bg-white/5 text-white", isCurrent),
|
||||
templ.KV("text-(--text-muted) hover:bg-white/5 hover:text-(--text)", !isCurrent),
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={
|
||||
"flex shrink-0 items-center justify-center font-medium w-6",
|
||||
templ.KV("text-(--text)", isCurrent),
|
||||
templ.KV("text-(--text-faint)", !isCurrent),
|
||||
}
|
||||
>
|
||||
{ fmt.Sprintf("%d", episode.MalID) }
|
||||
</span>
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
if episode.Title != "" {
|
||||
{ episode.Title }
|
||||
} else {
|
||||
Episode { fmt.Sprintf("%d", episode.MalID) }
|
||||
}
|
||||
</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
if episode.Filler {
|
||||
<span class="shrink-0 px-1.5 py-0.5 text-[9px] uppercase tracking-wider bg-yellow-900/50 text-yellow-400">Filler</span>
|
||||
}
|
||||
if episode.Recap {
|
||||
<span class="shrink-0 px-1.5 py-0.5 text-[9px] uppercase tracking-wider bg-blue-900/50 text-blue-400">Recap</span>
|
||||
}
|
||||
if isCurrent {
|
||||
<svg class="h-4 w-4 shrink-0 text-white" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
templ VideoPlayer(data WatchPageData) {
|
||||
{{ streamToken := modeToken(data.InitialMode, data.ModeSources) }}
|
||||
{{ hasDub := modeAvailable(data.AvailableModes, "dub") }}
|
||||
{{ hasSub := modeAvailable(data.AvailableModes, "sub") }}
|
||||
<div
|
||||
class="flex w-full flex-col gap-4"
|
||||
data-mal-id={ fmt.Sprintf("%d", data.MalID) }
|
||||
data-total-episodes={ fmt.Sprintf("%d", data.TotalEpisodes) }
|
||||
data-video-player
|
||||
data-stream-url="/watch/proxy/stream"
|
||||
data-current-episode={ data.CurrentEpisode }
|
||||
data-anime-title={ data.Title }
|
||||
data-anime-title-english={ data.TitleEnglish }
|
||||
data-anime-title-japanese={ data.TitleJapanese }
|
||||
data-anime-image={ data.ImageURL }
|
||||
data-anime-airing={ fmt.Sprintf("%v", data.Airing) }
|
||||
data-start-time-seconds={ fmt.Sprintf("%.3f", data.StartTimeSeconds) }
|
||||
data-initial-mode={ data.InitialMode }
|
||||
data-stream-token={ streamToken }
|
||||
data-available-modes={ toJSON(data.AvailableModes) }
|
||||
data-mode-sources={ toJSON(data.ModeSources) }
|
||||
data-segments={ toJSON(data.Segments) }
|
||||
>
|
||||
<div class="group relative aspect-video w-full overflow-hidden bg-black">
|
||||
<video
|
||||
class="h-full w-full"
|
||||
preload="metadata"
|
||||
crossorigin="anonymous"
|
||||
playsinline
|
||||
src={ buildStreamURL(data.InitialMode, streamToken) }
|
||||
></video>
|
||||
<div data-loading class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="h-8 w-8 animate-spin border-2 border-(--panel-soft) border-t-(--accent)"></div>
|
||||
</div>
|
||||
<div data-subtitle-text class="absolute bottom-16 left-1/2 z-20 hidden max-w-[92vw] -translate-x-1/2 px-3 text-center text-sm font-semibold text-white drop-shadow-lg sm:bottom-20 sm:max-w-[88vw] sm:px-4 sm:text-lg md:text-xl"></div>
|
||||
<button data-skip class="absolute bottom-20 right-3 z-20 hidden border border-white bg-transparent px-3 py-1.5 text-sm font-semibold text-white transition-opacity hover:opacity-85 sm:bottom-24 sm:right-5 sm:px-4 sm:py-2 sm:text-base">
|
||||
Skip intro
|
||||
</button>
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-3 pb-3 pt-10 opacity-100 transition-opacity sm:px-4 sm:pb-4 sm:pt-12 sm:opacity-0 sm:group-hover:opacity-100 sm:group-[.show-controls]:opacity-100 group-[.show-controls]:opacity-100">
|
||||
<div data-progress-wrap class="group/progress relative mb-3 h-1 cursor-pointer bg-white/30 sm:mb-5">
|
||||
<div data-preview-popover class="pointer-events-none absolute bottom-[calc(100%+10px)] left-0 z-40 hidden -translate-x-1/2">
|
||||
<div class="overflow-hidden border border-white/20 bg-black shadow-xl">
|
||||
<div data-preview-time class="bg-white px-2 py-1 text-center text-xs font-semibold text-black tabular-nums">00:00</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-segments class="pointer-events-none absolute inset-0 z-20"></div>
|
||||
<div data-progress class="pointer-events-none absolute inset-y-0 left-0 z-10 bg-blue-500"></div>
|
||||
<div data-scrubber class="pointer-events-none absolute -top-1.5 z-30 h-5 w-5 rounded-full -translate-x-1/2 bg-white opacity-0 transition-opacity group-hover/progress:opacity-100" style="left: 0%"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:flex-nowrap sm:gap-4">
|
||||
<div class="flex min-w-0 items-center gap-2 sm:gap-4">
|
||||
<button data-play-pause data-state="paused" class="flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10" title="Play">
|
||||
<svg data-icon-play class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="8 5 19 12 8 19" fill="white" stroke="none"></polygon>
|
||||
</svg>
|
||||
<svg data-icon-pause class="hidden h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<line x1="9" y1="6" x2="9" y2="18" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="15" y1="6" x2="15" y2="18" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div data-volume-wrap class="relative flex h-9 w-9 items-center justify-center overflow-visible sm:h-10 sm:w-10">
|
||||
<span data-volume-bridge class="volume-bridge-off absolute inset-x-0 bottom-full h-[calc(100%+26px)]"></span>
|
||||
<div data-volume-panel class="volume-panel pointer-events-none absolute bottom-[calc(100%+26px)] left-1/2 z-30 -translate-x-1/2 opacity-0 invisible transition-opacity">
|
||||
<input
|
||||
data-volume-range
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value="100"
|
||||
class="h-24 w-4 cursor-pointer appearance-none bg-transparent accent-white [&::-webkit-slider-runnable-track]:w-[4px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-white/55 [&::-webkit-slider-runnable-track]:[box-shadow:0_0_10px_rgba(0,0,0,0.45)] [&::-moz-range-track]:w-[4px] [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-white/55 [&::-moz-range-track]:[box-shadow:0_0_10px_rgba(0,0,0,0.45)] [&::-webkit-slider-thumb]:h-[14px] [&::-webkit-slider-thumb]:w-[14px] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:-ml-[5px] [&::-webkit-slider-thumb]:[box-shadow:0_0_10px_rgba(0,0,0,0.55)] [&::-moz-range-thumb]:h-[14px] [&::-moz-range-thumb]:w-[14px] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:[box-shadow:0_0_10px_rgba(0,0,0,0.55)] [writing-mode:vertical-lr] [direction:rtl]"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
<button data-mute class="relative flex h-9 w-9 items-center justify-center pb-1 text-white sm:h-10 sm:w-10" aria-label="Mute">
|
||||
<svg data-icon-volume class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="5 10 9 10 13 6 13 18 9 14 5 14" fill="none" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 9c1.3 1.3 1.3 4.7 0 6" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M18.8 6.5c3 2.9 3 8.1 0 11" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
<svg data-icon-muted class="hidden h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="5 10 9 10 13 6 13 18 9 14 5 14" fill="none" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="9" x2="20" y2="15" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
<line x1="20" y1="9" x2="16" y2="15" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="volume-underline pointer-events-none absolute bottom-0 left-1/2 h-0.5 w-6 -translate-x-1/2 bg-white opacity-0 transition-opacity"></span>
|
||||
</div>
|
||||
<span data-time class="min-w-0 text-sm text-white tabular-nums sm:text-base">00:00 / 00:00</span>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-1.5 sm:gap-3">
|
||||
<button
|
||||
data-mode-dub
|
||||
class={
|
||||
"flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10",
|
||||
templ.KV("opacity-50 cursor-not-allowed", !hasDub),
|
||||
}
|
||||
title={ modeButtonTitle("Dub", hasDub) }
|
||||
disabled?={ !hasDub }
|
||||
>
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 9h6M6 15h4M12 9v6M17 7.5c2.2 2 2.2 7 0 9M19.2 5.5c3.4 3.2 3.4 10 0 13" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
data-mode-sub
|
||||
class={
|
||||
"flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10",
|
||||
templ.KV("opacity-50 cursor-not-allowed", !hasSub),
|
||||
}
|
||||
title={ modeButtonTitle("Sub", hasSub) }
|
||||
disabled?={ !hasSub }
|
||||
>
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3.5" y="5.5" width="17" height="13" rx="2" stroke="white" stroke-width="1.85" fill="none"/>
|
||||
<path d="M8 11.5h8M8 14.5h5" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-backward class="flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10" title="-10s">
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 50 50" aria-hidden="true">
|
||||
<path d="M29.9199 45H25.2051V26.5391L20.6064 28.3154V24.3975L29.4219 20.7949H29.9199V45ZM48.1013 35.0059C48.1013 38.3483 47.4926 40.9049 46.2751 42.6758C45.0687 44.4466 43.3422 45.332 41.0954 45.332C38.8708 45.332 37.1498 44.4743 35.9323 42.7588C34.726 41.0322 34.1006 38.5641 34.0564 35.3545V30.7891C34.0564 27.4577 34.6596 24.9121 35.8659 23.1523C37.0723 21.3815 38.8044 20.4961 41.0622 20.4961C43.32 20.4961 45.0521 21.3704 46.2585 23.1191C47.4649 24.8678 48.0792 27.3636 48.1013 30.6064V35.0059ZM43.3864 30.1084C43.3864 28.2048 43.1983 26.777 42.822 25.8252C42.4457 24.8734 41.8591 24.3975 41.0622 24.3975C39.5681 24.3975 38.7933 26.1406 38.738 29.627V35.6533C38.738 37.6012 38.9262 39.0511 39.3025 40.0029C39.6898 40.9548 40.2875 41.4307 41.0954 41.4307C41.8591 41.4307 42.4236 40.988 42.7888 40.1025C43.1651 39.2061 43.3643 37.8392 43.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M40.0106 5.45398V0L50 7.79529L40.0106 15.5914V10.3033H4.9114V40.1506H18.7558V45H2.01875e-06V5.45398H40.0106Z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-forward class="flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10" title="+10s">
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 52 50" aria-hidden="true">
|
||||
<path d="M11.9199 45H7.20508V26.5391L2.60645 28.3154V24.3975L11.4219 20.7949H11.9199V45ZM30.1013 35.0059C30.1013 38.3483 29.4926 40.9049 28.2751 42.6758C27.0687 44.4466 25.3422 45.332 23.0954 45.332C20.8708 45.332 19.1498 44.4743 17.9323 42.7588C16.726 41.0322 16.1006 38.5641 16.0564 35.3545V30.7891C16.0564 27.4577 16.6596 24.9121 17.8659 23.1523C19.0723 21.3815 20.8044 20.4961 23.0622 20.4961C25.32 20.4961 27.0521 21.3704 28.2585 23.1191C29.4649 24.8678 30.0792 27.3636 30.1013 30.6064V35.0059ZM25.3864 30.1084C25.3864 28.2048 25.1983 26.777 24.822 25.8252C24.4457 24.8734 23.8591 24.3975 23.0622 24.3975C21.5681 24.3975 20.7933 26.1406 20.738 29.627V35.6533C20.738 37.6012 20.9262 39.0511 21.3025 40.0029C21.6898 40.9548 22.2875 41.4307 23.0954 41.4307C23.8591 41.4307 24.4236 40.988 24.7888 40.1025C25.1651 39.2061 25.3643 37.8392 25.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M11.9894 5.45398V0L2 7.79529L11.9894 15.5914V10.3033H47.0886V40.1506H33.2442V45H52V5.45398H11.9894Z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-fullscreen class="flex h-9 w-9 items-center justify-center text-white sm:h-10 sm:w-10" title="Fullscreen">
|
||||
<svg class="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 240 240" aria-hidden="true">
|
||||
<path d="M96.3,186.1c1.9,1.9,1.3,4-1.4,4.4l-50.6,8.4c-1.8,0.5-3.7-0.6-4.2-2.4c-0.2-0.6-0.2-1.2,0-1.7l8.4-50.6c0.4-2.7,2.4-3.4,4.4-1.4l14.5,14.5l28.2-28.2l14.3,14.3l-28.2,28.2L96.3,186.1z M195.8,39.1l-50.6,8.4c-2.7,0.4-3.4,2.4-1.4,4.4l14.5,14.5l-28.2,28.2l14.3,14.3l28.2-28.2l14.5,14.5c1.9,1.9,4,1.3,4.4-1.4l8.4-50.6c0.5-1.8-0.6-3.6-2.4-4.2C197,39,196.4,39,195.8,39.1L195.8,39.1z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func buildStreamURL(mode string, token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/watch/proxy/stream?mode=%s&token=%s", url.QueryEscape(mode), url.QueryEscape(token))
|
||||
}
|
||||
|
||||
func modeToken(mode string, modeSources map[string]ModeSource) string {
|
||||
normalizedMode := mode
|
||||
if _, ok := modeSources[normalizedMode]; !ok {
|
||||
if _, ok := modeSources["dub"]; ok {
|
||||
normalizedMode = "dub"
|
||||
} else if _, ok := modeSources["sub"]; ok {
|
||||
normalizedMode = "sub"
|
||||
} else {
|
||||
for key := range modeSources {
|
||||
normalizedMode = key
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source, ok := modeSources[normalizedMode]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return source.Token
|
||||
}
|
||||
|
||||
func toJSON(v interface{}) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func episodeWithOffsetURL(animeID int, currentEpisode string, offset int) string {
|
||||
episodeID, err := strconv.Atoi(currentEpisode)
|
||||
if err != nil {
|
||||
episodeID = 1
|
||||
}
|
||||
nextEpisode := episodeID + offset
|
||||
if nextEpisode < 1 {
|
||||
nextEpisode = 1
|
||||
}
|
||||
return fmt.Sprintf("/watch/%d/%d", animeID, nextEpisode)
|
||||
}
|
||||
|
||||
func canGoPrevEpisode(currentEpisode string) bool {
|
||||
episodeID, err := strconv.Atoi(currentEpisode)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return episodeID > 1
|
||||
}
|
||||
|
||||
func canGoNextEpisode(currentEpisode string, totalEpisodes int) bool {
|
||||
if totalEpisodes <= 0 {
|
||||
return true
|
||||
}
|
||||
episodeID, err := strconv.Atoi(currentEpisode)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return episodeID < totalEpisodes
|
||||
}
|
||||
|
||||
func modeAvailable(modes []string, mode string) bool {
|
||||
for _, value := range modes {
|
||||
if value == mode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func modeButtonTitle(label string, enabled bool) string {
|
||||
if enabled {
|
||||
return label
|
||||
}
|
||||
|
||||
return label + " unavailable for this episode"
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/database"
|
||||
"mal/internal/shared/ui"
|
||||
"math"
|
||||
)
|
||||
|
||||
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
|
||||
@Layout("mal - watchlist", true) {
|
||||
<div class="mb-4 flex items-end justify-between gap-4 max-lg:flex-col max-lg:items-start">
|
||||
<div class="grid gap-1">
|
||||
<h2>Watchlist</h2>
|
||||
<p class="m-0 text-sm text-(--text-muted)">Track what you're watching with less noise.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 max-lg:w-full max-lg:justify-start">
|
||||
<a href="/api/watchlist/export" class="inline-flex min-w-16 items-center justify-center px-2 py-1 text-center text-xs leading-tight text-(--text-muted) no-underline hover:text-(--accent) hover:no-underline">Export</a>
|
||||
<button class="inline-flex min-w-16 cursor-pointer items-center justify-center border-0 bg-transparent px-2 py-1 text-center text-xs leading-tight text-(--text-muted) hover:text-(--accent)" type="button" onclick="document.getElementById('import-file').click()">Import</button>
|
||||
<form id="import-form" hx-post="/api/watchlist/import" hx-encoding="multipart/form-data" class="hidden">
|
||||
<input type="file" id="import-file" name="file" accept=".json" onchange="htmx.trigger('#import-form', 'submit')"/>
|
||||
</form>
|
||||
<div class="flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1">
|
||||
<a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "grid") }>Grid</a>
|
||||
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "table") }>Table</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap gap-2 max-md:flex-nowrap max-md:overflow-x-auto max-md:pb-1">
|
||||
<a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ tabClass(currentStatus == "all") }>All</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "watching", sortBy, sortOrder)) } class={ tabClass(currentStatus == "watching") }>Watching</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "on_hold", sortBy, sortOrder)) } class={ tabClass(currentStatus == "on_hold") }>On hold</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "plan_to_watch", sortBy, sortOrder)) } class={ tabClass(currentStatus == "plan_to_watch") }>Plan to watch</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "dropped", sortBy, sortOrder)) } class={ tabClass(currentStatus == "dropped") }>Dropped</a>
|
||||
<a href={ templ.URL(watchlistURL(layout, "completed", sortBy, sortOrder)) } class={ tabClass(currentStatus == "completed") }>Completed</a>
|
||||
</div>
|
||||
@ui.SortFilter(ui.SortFilterOptions{Sort: sortBy, Order: sortOrder, View: layout, Status: currentStatus})
|
||||
if len(entries) == 0 {
|
||||
@ui.EmptyState("Nothing here yet") {
|
||||
if currentStatus == "all" {
|
||||
Your watchlist is empty. <a href="/">Search for anime</a> to get started.
|
||||
} else {
|
||||
No anime in this category.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if layout == "grid" {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4 xl:grid-cols-5">
|
||||
for _, entry := range entries {
|
||||
<div class="group relative min-w-0" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
||||
<a href={ templ.URL(watchURL(entry)) } class="flex flex-col bg-transparent text-inherit no-underline">
|
||||
<div class="flex w-full aspect-2/3 justify-center overflow-hidden">
|
||||
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="block w-full object-cover object-center" loading="lazy"/>
|
||||
</div>
|
||||
<div class="mt-2 line-clamp-2 text-sm leading-snug text-(--text)">
|
||||
{ entry.DisplayTitle() }
|
||||
</div>
|
||||
@ifHasProgress(entry)
|
||||
</a>
|
||||
<button
|
||||
class="absolute right-2 top-2 h-6 w-6 cursor-pointer border-0 bg-(--overlay-subtle) text-(--text-muted) opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-(--danger)"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
|
||||
hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
|
||||
hx-swap="delete"
|
||||
>×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<table class="block w-full overflow-x-auto whitespace-nowrap bg-(--panel) md:table md:overflow-visible md:whitespace-normal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2.5"></th>
|
||||
<th class="p-2.5 text-left text-xs text-(--text-faint)">Title</th>
|
||||
<th class="p-2.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, entry := range entries {
|
||||
<tr class="hover:bg-(--panel-soft)" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
|
||||
<td class="p-2.5">
|
||||
<a href={ templ.URL(watchURL(entry)) }>
|
||||
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="aspect-2/3 w-9 object-cover" loading="lazy"/>
|
||||
</a>
|
||||
</td>
|
||||
<td class="p-2.5 font-medium">
|
||||
<a href={ templ.URL(watchURL(entry)) }>
|
||||
{ entry.DisplayTitle() }
|
||||
</a>
|
||||
@ifHasProgress(entry)
|
||||
</td>
|
||||
<td class="w-24 p-2.5">
|
||||
<button
|
||||
class="cursor-pointer border-0 bg-transparent p-0 text-xs text-(--text-muted) hover:text-(--danger)"
|
||||
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
|
||||
hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
|
||||
hx-swap="delete"
|
||||
>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ ifHasProgress(entry database.GetUserWatchListRow) {
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 && entry.Status != "completed" {
|
||||
<p class="m-0 mt-1 text-xs text-(--text-faint)">
|
||||
Continue ep { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }
|
||||
if entry.CurrentTimeSeconds > 0 {
|
||||
{ fmt.Sprintf(" · %s", formatProgressTime(entry.CurrentTimeSeconds)) }
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
func watchURL(entry database.GetUserWatchListRow) string {
|
||||
return fmt.Sprintf("/anime/%d", entry.AnimeID)
|
||||
}
|
||||
|
||||
func formatProgressTime(seconds float64) string {
|
||||
total := int(math.Round(seconds))
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
|
||||
minutes := total / 60
|
||||
remainingSeconds := total % 60
|
||||
return fmt.Sprintf("%02d:%02d", minutes, remainingSeconds)
|
||||
}
|
||||
|
||||
func tabClass(active bool) string {
|
||||
base := "shrink-0 whitespace-nowrap bg-(--panel-soft) px-2 py-1 text-xs text-(--text-muted) no-underline hover:bg-(--surface-tab-hover) hover:text-(--text) hover:no-underline"
|
||||
if active {
|
||||
return "shrink-0 whitespace-nowrap bg-(--surface-tab-active) px-2 py-1 text-xs text-(--accent) no-underline hover:no-underline"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func watchlistURL(view string, status string, sortBy string, sortOrder string) string {
|
||||
return fmt.Sprintf("/watchlist?view=%s&status=%s&sort=%s&order=%s", view, status, sortBy, sortOrder)
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
package watchorder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
|
||||
|
||||
var idPattern = regexp.MustCompile(`/id/(\d+)`)
|
||||
var malLinkPattern = regexp.MustCompile(`myanimelist\.net/anime/(\d+)`)
|
||||
|
||||
var ErrInvalidWatchOrderURL = errors.New("invalid watch order url")
|
||||
var ErrWatchOrderMarkupNotFound = errors.New("watch order markup not found")
|
||||
|
||||
type HTTPStatusError struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
Server string
|
||||
CFRay string
|
||||
Location string
|
||||
ContentType string
|
||||
BodyPreview string
|
||||
}
|
||||
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"unexpected status code: %d (url=%s server=%s cf_ray=%s location=%s content_type=%s body=%q)",
|
||||
e.StatusCode,
|
||||
e.URL,
|
||||
e.Server,
|
||||
e.CFRay,
|
||||
e.Location,
|
||||
e.ContentType,
|
||||
e.BodyPreview,
|
||||
)
|
||||
}
|
||||
|
||||
type WatchOrderEntry struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
TitleAlt string `json:"title_alt,omitempty"`
|
||||
}
|
||||
|
||||
type WatchOrderResult struct {
|
||||
ID int `json:"id"`
|
||||
WatchOrder []WatchOrderEntry `json:"watch_order"`
|
||||
}
|
||||
|
||||
type watchOrderRow struct {
|
||||
id int
|
||||
typeID int
|
||||
title string
|
||||
alternativeTitle string
|
||||
}
|
||||
|
||||
func parseRootID(url string) (int, error) {
|
||||
match := idPattern.FindStringSubmatch(url)
|
||||
if len(match) != 2 {
|
||||
return 0, ErrInvalidWatchOrderURL
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(match[1])
|
||||
if err != nil {
|
||||
return 0, ErrInvalidWatchOrderURL
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func addCommonHeaders(request *http.Request) {
|
||||
request.Header.Set("User-Agent", defaultUserAgent)
|
||||
request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
request.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
request.Header.Set("Referer", "https://chiaki.site/")
|
||||
request.Header.Set("Cache-Control", "no-cache")
|
||||
}
|
||||
|
||||
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
addCommonHeaders(request)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 512))
|
||||
return nil, &HTTPStatusError{
|
||||
StatusCode: response.StatusCode,
|
||||
URL: url,
|
||||
Server: strings.TrimSpace(response.Header.Get("Server")),
|
||||
CFRay: strings.TrimSpace(response.Header.Get("CF-Ray")),
|
||||
Location: strings.TrimSpace(response.Header.Get("Location")),
|
||||
ContentType: strings.TrimSpace(response.Header.Get("Content-Type")),
|
||||
BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "),
|
||||
}
|
||||
}
|
||||
|
||||
document, err := goquery.NewDocumentFromReader(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse html: %w", err)
|
||||
}
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func extractTypeLabelsByID(doc *goquery.Document) map[int]string {
|
||||
typeLabels := make(map[int]string)
|
||||
|
||||
doc.Find("#wo_type_filter label").Each(func(_ int, selection *goquery.Selection) {
|
||||
input := selection.Find("input[type='checkbox']")
|
||||
rawID, exists := input.Attr("value")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
typeID, err := strconv.Atoi(strings.TrimSpace(rawID))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
label := strings.TrimSpace(selection.Text())
|
||||
if label == "" {
|
||||
return
|
||||
}
|
||||
|
||||
typeLabels[typeID] = label
|
||||
})
|
||||
|
||||
return typeLabels
|
||||
}
|
||||
|
||||
func parseAttrInt(selection *goquery.Selection, attrName string) (int, bool) {
|
||||
rawValue, exists := selection.Attr(attrName)
|
||||
if !exists {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(strings.TrimSpace(rawValue))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return value, true
|
||||
}
|
||||
|
||||
func extractRows(doc *goquery.Document) []watchOrderRow {
|
||||
rows := make([]watchOrderRow, 0)
|
||||
|
||||
doc.Find("tr[data-id]").Each(func(_ int, selection *goquery.Selection) {
|
||||
id, ok := parseAttrInt(selection, "data-id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
typeID, ok := parseAttrInt(selection, "data-type")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(selection.Find(".wo_title").First().Text())
|
||||
alternativeTitle := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
|
||||
|
||||
rows = append(rows, watchOrderRow{
|
||||
id: id,
|
||||
typeID: typeID,
|
||||
title: title,
|
||||
alternativeTitle: alternativeTitle,
|
||||
})
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func hasWatchOrderTable(doc *goquery.Document) bool {
|
||||
return doc.Find("#wo_list").Length() > 0
|
||||
}
|
||||
|
||||
func shouldTryProxy(err error) bool {
|
||||
var statusError *HTTPStatusError
|
||||
if errors.As(err, &statusError) {
|
||||
return statusError.StatusCode == http.StatusForbidden || statusError.StatusCode == http.StatusTooManyRequests || statusError.StatusCode == http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func toJinaProxyURL(url string) string {
|
||||
trimmed := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
|
||||
return "https://r.jina.ai/http://" + trimmed
|
||||
}
|
||||
|
||||
func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (string, error) {
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, toJinaProxyURL(url), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create proxy request: %w", err)
|
||||
}
|
||||
|
||||
addCommonHeaders(request)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("proxy request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("proxy status %d", response.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read proxy response: %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func parseJinaEntries(text string) []WatchOrderEntry {
|
||||
lines := strings.Split(text, "\n")
|
||||
entries := make([]WatchOrderEntry, 0)
|
||||
seen := make(map[int]bool)
|
||||
|
||||
for index, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(trimmed, "myanimelist.net/anime/") || !strings.Contains(trimmed, "|") {
|
||||
continue
|
||||
}
|
||||
|
||||
idMatch := malLinkPattern.FindStringSubmatch(trimmed)
|
||||
if len(idMatch) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(idMatch[1])
|
||||
if err != nil || seen[id] {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(trimmed, "|")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
typeName := strings.TrimSpace(parts[1])
|
||||
if typeName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
title, titleAlt := titleFromContext(lines, index)
|
||||
entries = append(entries, WatchOrderEntry{
|
||||
ID: id,
|
||||
Type: typeName,
|
||||
Title: title,
|
||||
TitleAlt: titleAlt,
|
||||
})
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func isNoiseTitleLine(value string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
if lower == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "title:") || strings.HasPrefix(lower, "url source:") || strings.HasPrefix(lower, "markdown content:") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(lower, "/ watch order") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func titleFromContext(lines []string, metaIndex int) (string, string) {
|
||||
collected := make([]string, 0, 2)
|
||||
|
||||
for idx := metaIndex - 1; idx >= 0 && len(collected) < 2; idx-- {
|
||||
candidate := strings.TrimSpace(lines[idx])
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if isNoiseTitleLine(candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(candidate, "myanimelist.net/anime/") {
|
||||
continue
|
||||
}
|
||||
|
||||
collected = append(collected, candidate)
|
||||
}
|
||||
|
||||
if len(collected) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if len(collected) == 1 {
|
||||
return collected[0], ""
|
||||
}
|
||||
|
||||
return collected[1], collected[0]
|
||||
}
|
||||
|
||||
func fetchViaProxy(ctx context.Context, httpClient *http.Client, url string, rootID int) (WatchOrderResult, error) {
|
||||
proxyText, err := fetchProxyText(ctx, httpClient, url)
|
||||
if err != nil {
|
||||
return WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
entries := parseJinaEntries(proxyText)
|
||||
if len(entries) == 0 {
|
||||
return WatchOrderResult{}, ErrWatchOrderMarkupNotFound
|
||||
}
|
||||
|
||||
return WatchOrderResult{ID: rootID, WatchOrder: entries}, nil
|
||||
}
|
||||
|
||||
func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (WatchOrderResult, error) {
|
||||
rootID, err := parseRootID(url)
|
||||
if err != nil {
|
||||
return WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
doc, err := fetchDocument(ctx, httpClient, url)
|
||||
if err != nil {
|
||||
if shouldTryProxy(err) {
|
||||
return fetchViaProxy(ctx, httpClient, url, rootID)
|
||||
}
|
||||
return WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
if !hasWatchOrderTable(doc) {
|
||||
return fetchViaProxy(ctx, httpClient, url, rootID)
|
||||
}
|
||||
|
||||
rows := extractRows(doc)
|
||||
if len(rows) == 0 {
|
||||
return WatchOrderResult{ID: rootID, WatchOrder: []WatchOrderEntry{}}, nil
|
||||
}
|
||||
|
||||
typeByID := extractTypeLabelsByID(doc)
|
||||
|
||||
entries := make([]WatchOrderEntry, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
typeName := strings.TrimSpace(typeByID[row.typeID])
|
||||
|
||||
entries = append(entries, WatchOrderEntry{
|
||||
ID: row.id,
|
||||
Type: typeName,
|
||||
Title: row.title,
|
||||
TitleAlt: row.alternativeTitle,
|
||||
})
|
||||
}
|
||||
|
||||
return WatchOrderResult{ID: rootID, WatchOrder: entries}, nil
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
package watchorder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testServer(body string) *httptest.Server {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func testHTMLWithMetadata() string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="wo_type_filter">
|
||||
<label><input type="checkbox" value="1" checked> TV</label>
|
||||
<label><input type="checkbox" value="3" checked> Movie</label>
|
||||
</div>
|
||||
<table id="wo_list">
|
||||
<tr data-id="442" data-anilist-id="442" data-type="3">
|
||||
<td>
|
||||
<span class="wo_title">Naruto Movie 1</span>
|
||||
<span class="uk-text-small">Naruto the Movie 1</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
func testHTMLEmptyRows() string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="wo_type_filter">
|
||||
<label><input type="checkbox" value="1" checked> TV</label>
|
||||
<label><input type="checkbox" value="3" checked> Movie</label>
|
||||
</div>
|
||||
<table id="wo_list"></table>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
func TestFetchWatchOrder_OutputShape(t *testing.T) {
|
||||
server := testServer(testHTMLWithMetadata())
|
||||
defer server.Close()
|
||||
|
||||
url := server.URL + "/?/tools/watch_order/id/442"
|
||||
result, err := FetchWatchOrder(context.Background(), &http.Client{Timeout: time.Second}, url)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.ID != 442 {
|
||||
t.Fatalf("expected root id 442, got %d", result.ID)
|
||||
}
|
||||
|
||||
if len(result.WatchOrder) != 1 {
|
||||
t.Fatalf("expected 1 watch_order entry, got %d", len(result.WatchOrder))
|
||||
}
|
||||
|
||||
entry := result.WatchOrder[0]
|
||||
if entry.ID != 442 {
|
||||
t.Fatalf("expected entry id 442, got %d", entry.ID)
|
||||
}
|
||||
if entry.Type != "Movie" {
|
||||
t.Fatalf("expected type Movie, got %q", entry.Type)
|
||||
}
|
||||
if entry.Title != "Naruto Movie 1" {
|
||||
t.Fatalf("expected title Naruto Movie 1, got %q", entry.Title)
|
||||
}
|
||||
if entry.TitleAlt != "Naruto the Movie 1" {
|
||||
t.Fatalf("expected title_alt Naruto the Movie 1, got %q", entry.TitleAlt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWatchOrder_NoRowsReturnsEmpty(t *testing.T) {
|
||||
server := testServer(testHTMLEmptyRows())
|
||||
defer server.Close()
|
||||
|
||||
url := server.URL + "/?/tools/watch_order/id/1535"
|
||||
result, err := FetchWatchOrder(context.Background(), &http.Client{Timeout: time.Second}, url)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.ID != 1535 {
|
||||
t.Fatalf("expected root id 1535, got %d", result.ID)
|
||||
}
|
||||
|
||||
if len(result.WatchOrder) != 0 {
|
||||
t.Fatalf("expected no entries, got %d", len(result.WatchOrder))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWatchOrder_MissingMarkupFallsBackToProxy(t *testing.T) {
|
||||
proxyPayload := `Title: Jujutsu Kaisen / Watch Order
|
||||
URL Source: https://chiaki.site/?/tools/watch_order/id/40748
|
||||
|
||||
Markdown Content:
|
||||
Jujutsu Kaisen
|
||||
|
||||
Oct 3, 2020 – Mar 27, 2021 | TV | 24ep × 23min. | ★8.51 | [](https://myanimelist.net/anime/40748)
|
||||
Jujutsu Kaisen 0 Movie
|
||||
|
||||
Jujutsu Kaisen 0
|
||||
|
||||
Dec 24, 2021 | Movie | 1ep × 1hr. 44min. | ★8.36 | [](https://myanimelist.net/anime/48561)
|
||||
`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/http/") {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(proxyPayload))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("blocked"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := http.DefaultTransport
|
||||
testClient := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if strings.HasPrefix(request.URL.Host, "r.jina.ai") {
|
||||
proxyURL := server.URL + "/http/" + strings.TrimPrefix(request.URL.Path, "/")
|
||||
proxyRequest, err := http.NewRequestWithContext(request.Context(), request.Method, proxyURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transport.RoundTrip(proxyRequest)
|
||||
}
|
||||
|
||||
blockedURL := server.URL + request.URL.Path
|
||||
blockedRequest, err := http.NewRequestWithContext(request.Context(), request.Method, blockedURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transport.RoundTrip(blockedRequest)
|
||||
}),
|
||||
}
|
||||
|
||||
result, err := FetchWatchOrder(context.Background(), testClient, "https://chiaki.site/?/tools/watch_order/id/40748")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.WatchOrder) != 2 {
|
||||
t.Fatalf("expected 2 proxy entries, got %d", len(result.WatchOrder))
|
||||
}
|
||||
|
||||
if result.WatchOrder[0].ID != 40748 || result.WatchOrder[0].Type != "TV" {
|
||||
t.Fatalf("unexpected first entry: %+v", result.WatchOrder[0])
|
||||
}
|
||||
|
||||
if result.WatchOrder[1].ID != 48561 || result.WatchOrder[1].Type != "Movie" {
|
||||
t.Fatalf("unexpected second entry: %+v", result.WatchOrder[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWatchOrder_HTTPStatusErrorIncludesContext(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Server", "cloudflare")
|
||||
w.Header().Set("CF-Ray", "abc123")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("<html><body>access denied</body></html>"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
url := server.URL + "/?/tools/watch_order/id/1"
|
||||
_, err := fetchDocument(context.Background(), &http.Client{Timeout: time.Second}, url)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
|
||||
var statusError *HTTPStatusError
|
||||
if !errors.As(err, &statusError) {
|
||||
t.Fatalf("expected HTTPStatusError, got %T", err)
|
||||
}
|
||||
|
||||
if statusError.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", statusError.StatusCode)
|
||||
}
|
||||
if statusError.CFRay != "abc123" {
|
||||
t.Fatalf("expected cf-ray abc123, got %q", statusError.CFRay)
|
||||
}
|
||||
if !strings.Contains(statusError.BodyPreview, "access denied") {
|
||||
t.Fatalf("expected body preview to include access denied, got %q", statusError.BodyPreview)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"mal/internal/database"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/integrations/jikan"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
|
||||
Reference in New Issue
Block a user