refactor: reorganize project structure following go standards

This commit is contained in:
2026-04-20 15:54:35 +02:00
parent 055ec1fca9
commit 6df8788749
70 changed files with 43 additions and 187 deletions

5
api/anime/errors.go Normal file
View File

@@ -0,0 +1,5 @@
package anime
import "errors"
var ErrAnimePendingFetch = errors.New("anime pending fetch")

429
api/anime/handler.go Normal file
View 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
View 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
View 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)
}

View 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
View 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)
}

View 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
View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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")
}

View 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"
}

View 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
}

View 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
View 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
View 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
View 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
}

View 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)
}
}