refactor: reorganize project structure following go standards
This commit is contained in:
5
api/anime/errors.go
Normal file
5
api/anime/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package anime
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrAnimePendingFetch = errors.New("anime pending fetch")
|
||||
429
api/anime/handler.go
Normal file
429
api/anime/handler.go
Normal file
@@ -0,0 +1,429 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/middleware"
|
||||
"mal/web/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)
|
||||
}
|
||||
110
api/auth/auth.go
Normal file
110
api/auth/auth.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
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: "/",
|
||||
})
|
||||
}
|
||||
56
api/auth/handler.go
Normal file
56
api/auth/handler.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mal/web/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)
|
||||
}
|
||||
516
api/playback/allanime_client.go
Normal file
516
api/playback/allanime_client.go
Normal file
@@ -0,0 +1,516 @@
|
||||
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"
|
||||
}
|
||||
381
api/playback/handler.go
Normal file
381
api/playback/handler.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/middleware"
|
||||
"mal/web/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)
|
||||
}
|
||||
25
api/playback/http_utils.go
Normal file
25
api/playback/http_utils.go
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
}
|
||||
144
api/playback/progress.go
Normal file
144
api/playback/progress.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
194
api/playback/provider_extractor.go
Normal file
194
api/playback/provider_extractor.go
Normal file
@@ -0,0 +1,194 @@
|
||||
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
|
||||
}
|
||||
348
api/playback/proxy_security.go
Normal file
348
api/playback/proxy_security.go
Normal file
@@ -0,0 +1,348 @@
|
||||
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
|
||||
}
|
||||
45
api/playback/proxy_security_test.go
Normal file
45
api/playback/proxy_security_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
367
api/playback/service_base.go
Normal file
367
api/playback/service_base.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mal/internal/db"
|
||||
"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
|
||||
}
|
||||
71
api/playback/service_http.go
Normal file
71
api/playback/service_http.go
Normal file
@@ -0,0 +1,71 @@
|
||||
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
|
||||
}
|
||||
81
api/playback/service_proxy.go
Normal file
81
api/playback/service_proxy.go
Normal file
@@ -0,0 +1,81 @@
|
||||
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
|
||||
}
|
||||
184
api/playback/service_ranking.go
Normal file
184
api/playback/service_ranking.go
Normal file
@@ -0,0 +1,184 @@
|
||||
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")
|
||||
}
|
||||
169
api/playback/service_resolution.go
Normal file
169
api/playback/service_resolution.go
Normal file
@@ -0,0 +1,169 @@
|
||||
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"
|
||||
}
|
||||
227
api/playback/service_sources.go
Normal file
227
api/playback/service_sources.go
Normal file
@@ -0,0 +1,227 @@
|
||||
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
|
||||
}
|
||||
23
api/playback/service_utils.go
Normal file
23
api/playback/service_utils.go
Normal file
@@ -0,0 +1,23 @@
|
||||
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
|
||||
}
|
||||
47
api/playback/types.go
Normal file
47
api/playback/types.go
Normal file
@@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
327
api/watchlist/handler.go
Normal file
327
api/watchlist/handler.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/internal/middleware"
|
||||
"mal/web/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
236
api/watchlist/service.go
Normal file
236
api/watchlist/service.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
125
api/watchlist/service_test.go
Normal file
125
api/watchlist/service_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user