Merge pull request 'refactor/significant-changes' (#3) from refactor/significant-changes into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-05-14 11:25:25 +00:00
103 changed files with 3180 additions and 5619 deletions

View File

@@ -23,14 +23,14 @@ RUN bun install --frozen-lockfile
# Copy all source files # Copy all source files
COPY . . COPY . .
# Build frontend assets (tailwind + ts) from a clean state # Ensure dist is clean at build time (belt + suspenders)
RUN rm -rf dist/ && bun run build:assets RUN rm -rf dist/ && bun run build:assets
# Generate sqlc code # Generate sqlc code
RUN sqlc generate RUN sqlc generate
# Build the server and CLI tools # Build the server and CLI tools
RUN go build -o main_server ./cmd/server RUN go build -ldflags="-s -w" -o main_server ./cmd/server
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -42,13 +42,16 @@ RUN apt-get update && apt-get install -y ca-certificates sqlite3 && rm -rf /var/
# Create data directory for sqlite # Create data directory for sqlite
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Set DATABASE_FILE to use the persistent volume
ENV DATABASE_FILE=/app/data/mal.db
COPY --from=builder /app/main_server . COPY --from=builder /app/main_server .
COPY --from=builder /app/templates ./templates COPY --from=builder /app/templates ./templates
COPY --from=builder /app/static ./static COPY --from=builder /app/static ./static
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/migrations ./migrations COPY --from=builder /app/internal/database/migrations ./migrations
COPY docker/entrypoint.sh ./entrypoint.sh
# Expose the application port
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["./main_server"] ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -1,539 +0,0 @@
package anime
import (
"context"
"encoding/json"
"errors"
"html"
"log"
"net/http"
"strconv"
"strings"
"time"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/middleware"
"mal/templates"
"golang.org/x/sync/errgroup"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
type quickSearchResult struct {
ID int `json:"id"` // anime mal id
Title string `json:"title"` // display title
Type string `json:"type"` // anime type (tv, movie, etc)
Image string `json:"image"` // cover image url
}
func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
renderNotFoundPage(r, w)
return
}
user := middleware.GetUser(r.Context())
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "index.gohtml", map[string]any{
"User": user,
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleCatalogAiring(w http.ResponseWriter, r *http.Request) {
h.renderCatalogSection(w, r, "Airing")
}
func (h *Handler) HandleCatalogPopular(w http.ResponseWriter, r *http.Request) {
h.renderCatalogSection(w, r, "Popular")
}
func (h *Handler) HandleCatalogContinue(w http.ResponseWriter, r *http.Request) {
h.renderCatalogSection(w, r, "Continue")
}
// renderCatalogSection fetches catalog data (airing/popular/continue) and renders as htmx fragment
func (h *Handler) renderCatalogSection(w http.ResponseWriter, r *http.Request, section string) {
user := middleware.GetUser(r.Context())
userID := ""
if user != nil {
userID = user.ID
}
data, err := h.service.GetCatalogSection(r.Context(), userID, section)
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("catalog %s error: %v", section, err)
}
if section != "Continue" {
writeInlineLoadError(w, "Failed to load "+section)
}
return
}
data["User"] = user
data["Section"] = section
// render section as htmx partial, not full page
if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "index.gohtml", "catalog_section", data); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("fragment render error: %v", err)
}
}
}
func (h *Handler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "discover.gohtml", map[string]any{
"User": user,
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleDiscoverTrending(w http.ResponseWriter, r *http.Request) {
h.renderDiscoverSection(w, r, "Trending")
}
func (h *Handler) HandleDiscoverUpcoming(w http.ResponseWriter, r *http.Request) {
h.renderDiscoverSection(w, r, "Upcoming")
}
func (h *Handler) HandleDiscoverTop(w http.ResponseWriter, r *http.Request) {
h.renderDiscoverSection(w, r, "Top")
}
func (h *Handler) renderDiscoverSection(w http.ResponseWriter, r *http.Request, section string) {
user := middleware.GetUser(r.Context())
userID := ""
if user != nil {
userID = user.ID
}
data, err := h.service.GetDiscoverSection(r.Context(), userID, section)
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("discover %s error: %v", section, err)
}
writeInlineLoadError(w, "Failed to load "+section)
return
}
data["User"] = user
data["Section"] = section
if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "discover.gohtml", "discover_section", data); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("fragment render error: %v", err)
}
}
}
// HandleBrowse handles anime search/browse with filters. supports htmx partial loading.
func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
// parse query params for search/filter
q := r.URL.Query().Get("q")
animeType := r.URL.Query().Get("type")
status := r.URL.Query().Get("status")
orderBy := r.URL.Query().Get("order_by")
sort := r.URL.Query().Get("sort")
sfw := r.URL.Query().Get("sfw") != "false" // default to safe
var genres []int
for _, g := range r.URL.Query()["genres"] {
id, err := strconv.Atoi(g)
if err == nil {
genres = append(genres, id)
}
}
page := parsePageParam(r)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
res, err := h.service.jikanClient.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, 24)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
log.Printf("browse error: %v", err)
}
if r.Header.Get("HX-Request") == "true" {
// htmx: return just the card scroll fragment with watchlist state
watchlistMap := make(map[int]bool)
if user != nil {
watchlist, _ := h.service.db.GetUserWatchList(ctx, user.ID)
for _, entry := range watchlist {
watchlistMap[int(entry.AnimeID)] = true
}
}
w.Header().Set("Content-Type", "text/html")
err := templates.GetRenderer().ExecuteFragment(ctx, w, "browse.gohtml", "anime_card_scroll", map[string]any{
"Animes": res.Animes,
"NextPage": page + 1,
"HasNextPage": res.HasNextPage,
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"SFW": sfw,
"WatchlistMap": watchlistMap,
})
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("fragment render error: %v", err)
}
}
return
}
// full page load: fetch genres list and full watchlist
genresList, err := h.service.jikanClient.GetAnimeGenres(ctx)
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("genres error: %v", err)
}
}
watchlistMap := make(map[int]bool)
var watchlistIDs []int64
if user != nil {
watchlist, _ := h.service.db.GetUserWatchList(ctx, user.ID)
watchlistIDs = make([]int64, len(watchlist))
for i, entry := range watchlist {
watchlistMap[int(entry.AnimeID)] = true
watchlistIDs[i] = entry.AnimeID
}
}
if err := templates.GetRenderer().ExecuteTemplate(ctx, w, "browse.gohtml", map[string]any{
"User": user,
"CurrentPath": r.URL.Path,
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"SFW": sfw,
"GenresList": genresList,
"Animes": res.Animes,
"HasNextPage": res.HasNextPage,
"NextPage": page + 1,
"WatchlistMap": watchlistMap,
"WatchlistIDs": watchlistIDs,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleAnimeDetails renders anime detail page. handles htmx requests for characters/recommendations sections.
func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/anime/")
idStr = strings.TrimSuffix(idStr, "/")
id, err := strconv.Atoi(idStr)
if err != nil {
renderNotFoundPage(r, w)
return
}
user := middleware.GetUser(r.Context())
// htmx: return just the section (characters or recommendations)
section := r.URL.Query().Get("section")
if section != "" && r.Header.Get("HX-Request") == "true" {
h.renderAnimeDetailsSection(w, r, id, section)
return
}
var (
anime jikan.Anime
status string
episodesCount int
watchlistIDs []int64
)
g, gCtx := errgroup.WithContext(r.Context())
// fetch anime details + episode count if airing
g.Go(func() error {
var err error
anime, err = h.service.jikanClient.GetAnimeByID(gCtx, id)
if err == nil && anime.Airing {
// get episode count for airing anime (may span multiple pages)
eps, err := h.service.jikanClient.GetEpisodes(gCtx, id, 1)
if err == nil {
if eps.Pagination.LastVisiblePage > 1 {
lastEps, err := h.service.jikanClient.GetEpisodes(gCtx, id, eps.Pagination.LastVisiblePage)
if err == nil && len(lastEps.Data) > 0 {
lastEp := lastEps.Data[len(lastEps.Data)-1]
count, _ := strconv.Atoi(lastEp.Episode)
episodesCount = count
}
} else if len(eps.Data) > 0 {
lastEp := eps.Data[len(eps.Data)-1]
count, _ := strconv.Atoi(lastEp.Episode)
episodesCount = count
}
}
}
return err
})
if user != nil {
// fetch user's watchlist status for this anime
g.Go(func() error {
entry, err := h.service.db.GetWatchListEntry(gCtx, db.GetWatchListEntryParams{
UserID: user.ID,
AnimeID: int64(id),
})
if err == nil {
status = entry.Status
}
return nil
})
// fetch all watchlist ids for nav state
g.Go(func() error {
watchlist, err := h.service.db.GetUserWatchList(gCtx, user.ID)
if err == nil {
watchlistIDs = make([]int64, len(watchlist))
for i, e := range watchlist {
watchlistIDs[i] = e.AnimeID
}
}
return nil
})
}
if err := g.Wait(); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("anime details fetch error: %v", err)
}
renderNotFoundPage(r, w)
return
}
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "anime.gohtml", map[string]any{
"Anime": anime,
"User": user,
"Status": status,
"CurrentPath": r.URL.Path,
"WatchlistIDs": watchlistIDs,
"EpisodesCount": episodesCount,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// renderAnimeDetailsSection fetches and renders htmx partial for character/recommendation sections
func (h *Handler) renderAnimeDetailsSection(w http.ResponseWriter, r *http.Request, id int, section string) {
ctx := r.Context()
var data any
var err error
switch section {
case "characters":
data, err = h.service.jikanClient.GetAnimeCharacters(ctx, id)
case "recommendations":
data, err = h.service.jikanClient.GetAnimeRecommendations(ctx, id)
default:
http.Error(w, "Invalid section", http.StatusBadRequest)
return
}
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("anime details %s error: %v", section, err)
}
writeInlineLoadError(w, "Failed to load "+section)
return
}
tplName := "anime_characters"
if section == "recommendations" {
tplName = "anime_recommendations"
}
// render htmx partial for the section
if err := templates.GetRenderer().ExecuteFragment(ctx, w, "anime.gohtml", tplName, data); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("fragment render error: %v", err)
}
}
}
func (h *Handler) HandleHTMLWatchOrder(w http.ResponseWriter, r *http.Request) {
animeIdStr := r.URL.Query().Get("animeId")
id, err := strconv.Atoi(animeIdStr)
if err != nil {
http.Error(w, `<div class="mt-8 text-sm text-red-400">Invalid anime ID.</div>`, http.StatusBadRequest)
return
}
relations, err := h.service.jikanClient.GetFullRelations(r.Context(), id)
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("watch order error: %v", err)
}
http.Error(w, `<div class="mt-8 text-sm text-red-400">Failed to load watch order.</div>`, http.StatusInternalServerError)
return
}
user := middleware.GetUser(r.Context())
watchlistMap := make(map[int64]bool)
if user != nil {
watchlist, _ := h.service.db.GetUserWatchList(r.Context(), user.ID)
for _, entry := range watchlist {
watchlistMap[entry.AnimeID] = true
}
}
if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "anime.gohtml", "watch_order", map[string]any{
"Relations": relations,
"AnimeID": id,
"WatchlistMap": watchlistMap,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
}
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)
if err := writeJSON(w, []quickSearchResult{}); err != nil {
log.Printf("quick search encode error: %v", err)
}
return
}
res, err := h.service.jikanClient.SearchAdvanced(r.Context(), query, "", "", "", "", nil, true, 1, 5)
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("quick search error: %v", err)
}
w.WriteHeader(http.StatusOK)
if err := writeJSON(w, []quickSearchResult{}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("quick search encode error: %v", err)
}
}
return
}
output := make([]quickSearchResult, len(res.Animes))
for i, anime := range res.Animes {
output[i] = quickSearchResult{
ID: anime.MalID,
Title: anime.DisplayTitle(),
Type: anime.Type,
Image: anime.ImageURL(),
}
}
w.WriteHeader(http.StatusOK)
if err := writeJSON(w, output); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("quick search encode error: %v", err)
}
}
}
func (h *Handler) HandleRandomAnime(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
anime, err := h.service.jikanClient.GetRandomAnime(r.Context())
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("random anime error: %v", err)
}
w.WriteHeader(http.StatusInternalServerError)
if err := writeJSON(w, map[string]string{"error": "Failed to fetch random anime"}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("random anime encode error: %v", err)
}
}
return
}
if anime.MalID == 0 {
w.WriteHeader(http.StatusNotFound)
if err := writeJSON(w, map[string]string{"error": "No anime found"}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("random anime encode error: %v", err)
}
}
return
}
w.WriteHeader(http.StatusOK)
if err := writeJSON(w, map[string]any{"data": anime}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("random anime encode error: %v", err)
}
}
}
func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) {
renderNotFoundPage(r, w)
}
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
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);">` + html.EscapeString(message) + `</p>`))
}
func writeJSON(w http.ResponseWriter, v any) error {
return json.NewEncoder(w).Encode(v)
}
func parsePageParam(r *http.Request) int {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
return 1
}
return page
}

View File

@@ -1,130 +0,0 @@
package anime
import (
"context"
"mal/integrations/jikan"
"mal/internal/db"
"golang.org/x/sync/errgroup"
)
type Service struct {
jikanClient *jikan.Client
db db.Querier
}
func NewService(jikanClient *jikan.Client, db db.Querier) *Service {
return &Service{jikanClient: jikanClient, db: db}
}
// GetCatalogSection fetches homepage catalog sections (Airing, Popular, Continue) from jikan and db.
func (s *Service) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) {
var (
res jikan.TopAnimeResult
cw []db.GetContinueWatchingEntriesRow
watchlist []db.GetUserWatchListRow
err error
)
g, gCtx := errgroup.WithContext(ctx)
// fetch jikan data (season now or top anime)
g.Go(func() error {
switch section {
case "Airing":
res, err = s.jikanClient.GetSeasonsNow(gCtx, 1)
case "Popular":
res, err = s.jikanClient.GetTopAnime(gCtx, 1)
}
return err
})
// fetch user-specific data if logged in
if userID != "" {
g.Go(func() error {
if section == "Continue" {
var err error
cw, err = s.db.GetContinueWatchingEntries(gCtx, userID)
return err
}
return nil
})
g.Go(func() error {
var err error
watchlist, err = s.db.GetUserWatchList(gCtx, userID)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
// limit to 6 items for homepage grid
animes := res.Animes
if len(animes) > 6 {
animes = animes[:6]
}
watchlistMap := make(map[int64]bool)
for _, entry := range watchlist {
watchlistMap[entry.AnimeID] = true
}
return map[string]any{
"Animes": animes,
"ContinueWatching": cw,
"WatchlistMap": watchlistMap,
}, nil
}
// GetDiscoverSection fetches discover page sections (Trending, Upcoming, Top) from jikan.
func (s *Service) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) {
var (
res jikan.TopAnimeResult
watchlist []db.GetUserWatchListRow
err error
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
switch section {
case "Trending":
res, err = s.jikanClient.GetSeasonsNow(gCtx, 1)
case "Upcoming":
res, err = s.jikanClient.GetSeasonsUpcoming(gCtx, 1)
case "Top":
res, err = s.jikanClient.GetTopAnime(gCtx, 1)
}
return err
})
if userID != "" {
g.Go(func() error {
var err error
watchlist, err = s.db.GetUserWatchList(gCtx, userID)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
// limit to 8 items for discover grid
animes := res.Animes
if len(animes) > 8 {
animes = animes[:8]
}
watchlistMap := make(map[int64]bool)
for _, entry := range watchlist {
watchlistMap[entry.AnimeID] = true
}
return map[string]any{
"Animes": animes,
"WatchlistMap": watchlistMap,
}, nil
}

View File

@@ -1,127 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"net/http"
"os"
"time"
"golang.org/x/crypto/bcrypt"
"mal/internal/db"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrNotAuthenticated = errors.New("not authenticated")
)
type Service struct {
db db.Querier
}
func NewService(db db.Querier) *Service {
return &Service{db: db}
}
// generateToken creates a cryptographically random base64-encoded token
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
}
// generateSessionToken creates a 32-byte session token
func generateSessionToken() (string, error) {
return generateToken(32)
}
func (s *Service) Login(ctx context.Context, username, password string) (*db.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, db.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) (*db.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) // clean up expired session
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
}
// SetSessionCookie sets an http-only, secure session cookie
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: "/",
})
}
func (s *Service) Logout(ctx context.Context, sessionID string) error {
return s.db.DeleteSession(ctx, sessionID)
}
// ClearSessionCookie invalidates the session cookie
func ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "",
Expires: time.Unix(0, 0), // epoch to expire immediately
MaxAge: -1,
HttpOnly: true,
Path: "/",
})
}

View File

@@ -1,90 +0,0 @@
package auth
import (
"context"
"errors"
"log"
"net/http"
"mal/templates"
)
type Handler struct {
authService *Service
}
func NewHandler(authService *Service) *Handler {
return &Handler{authService: authService}
}
// HandleLoginPage renders the login form
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
}
// HandleLogin validates credentials and creates a session on success
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"Error": "Something went wrong. Please try again.",
"Username": "",
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"Error": "The email or password is wrong.",
"Username": username,
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
return
}
session, err := h.authService.Login(r.Context(), username, password)
if err != nil {
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
"Error": "The email or password is wrong.",
"Username": username,
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
return
}
SetSessionCookie(w, session.ID, session.ExpiresAt)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleLogout destroys the session and clears the cookie
func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err == nil {
_ = h.authService.Logout(r.Context(), cookie.Value)
}
ClearSessionCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -1,481 +0,0 @@
package playback
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"maps"
"net/http"
"sort"
"strconv"
"strings"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/middleware"
"mal/templates"
)
type Handler struct {
svc *Service
jikanClient *jikan.Client // client for Jikan API (MyAnimeList)
}
func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
return &Handler{svc: svc, jikanClient: jikanClient}
}
// renderNotFoundPage renders the 404 page.
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
}
// HandleWatchPage serves the anime watch page.
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
// path format: /anime/123/watch
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
renderNotFoundPage(r, w)
return
}
idStr := parts[2]
id, err := strconv.Atoi(idStr)
if err != nil {
renderNotFoundPage(r, w)
return
}
anime, err := h.jikanClient.GetAnimeByID(r.Context(), id)
if err != nil {
renderNotFoundPage(r, w)
return
}
allEpisodes, err := h.jikanClient.GetAllEpisodes(r.Context(), id)
if err != nil {
log.Printf("failed to fetch episodes: %v", err)
}
user := middleware.GetUser(r.Context())
// fetch user's watchlist to highlight episodes and show status
var watchlistIDs []int64
var watchlistStatus string
if user != nil {
watchlist, _ := h.svc.db.GetUserWatchList(r.Context(), user.ID)
watchlistIDs = make([]int64, len(watchlist))
for i, entry := range watchlist {
watchlistIDs[i] = entry.AnimeID
if entry.AnimeID == int64(id) {
watchlistStatus = entry.Status
}
}
}
// resolve current episode: query param > saved progress > first episode
currentEpID := r.URL.Query().Get("ep")
if currentEpID == "" {
if user != nil {
entry, err := h.svc.db.GetWatchListEntry(r.Context(), db.GetWatchListEntryParams{
UserID: user.ID,
AnimeID: int64(id),
})
if err == nil && entry.CurrentEpisode.Valid {
currentEpID = strconv.FormatInt(entry.CurrentEpisode.Int64, 10)
// redirect to include ep param for consistent URLs
http.Redirect(w, r, fmt.Sprintf("/anime/%d/watch?ep=%s", id, currentEpID), http.StatusFound)
return
}
}
currentEpID = "1"
}
mode := r.URL.Query().Get("mode")
userID := ""
if user != nil {
userID = user.ID
}
titleCandidates := []string{anime.Title}
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
titleCandidates = append(titleCandidates, anime.TitleEnglish)
}
if anime.TitleJapanese != "" {
titleCandidates = append(titleCandidates, anime.TitleJapanese)
}
watchData, err := h.svc.BuildWatchPageData(r.Context(), id, titleCandidates, currentEpID, mode, userID)
if err != nil {
log.Printf("watch data error: %v", err)
}
// Fill gaps with placeholder episodes if fallback has more
if watchData.FallbackEpisodes != nil {
maxCount := 0
for _, count := range watchData.FallbackEpisodes {
if count > maxCount {
maxCount = count
}
}
epMap := make(map[int]jikan.Episode)
for _, ep := range allEpisodes {
epMap[ep.MalID] = ep
}
if maxCount > 0 {
var filled []jikan.Episode
for i := 1; i <= maxCount; i++ {
if ep, ok := epMap[i]; ok {
filled = append(filled, ep)
} else {
filled = append(filled, jikan.Episode{
MalID: i,
Episode: fmt.Sprintf("Episode %d", i),
Title: fmt.Sprintf("Episode %d", i),
})
}
}
allEpisodes = filled
}
}
sort.Slice(allEpisodes, func(i, j int) bool {
return allEpisodes[i].MalID < allEpisodes[j].MalID
})
// fetch relations to build season/movie list
relations, err := h.jikanClient.GetFullRelations(r.Context(), id)
if err != nil {
log.Printf("failed to fetch relations: %v", err)
}
type SeasonEntry struct {
MalID int
Title string
Prefix string
IsCurrent bool
}
var tvSeasons []SeasonEntry
var movies []SeasonEntry
counter := 1
for _, rel := range relations {
if strings.ToLower(rel.Anime.Type) == "tv" {
tvSeasons = append(tvSeasons, SeasonEntry{
MalID: rel.Anime.MalID,
Title: rel.Anime.DisplayTitle(),
Prefix: fmt.Sprintf("%02d", counter),
IsCurrent: rel.IsCurrent,
})
counter++
}
}
for _, rel := range relations {
if strings.ToLower(rel.Anime.Type) == "movie" {
movies = append(movies, SeasonEntry{
MalID: rel.Anime.MalID,
Title: rel.Anime.DisplayTitle(),
Prefix: "Mov",
IsCurrent: rel.IsCurrent,
})
}
}
allSeasons := append(tvSeasons, movies...)
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "watch.gohtml", map[string]any{
"Anime": anime,
"Episodes": allEpisodes,
"WatchData": watchData,
"User": user,
"CurrentPath": r.URL.Path,
"CurrentEpID": currentEpID,
"WatchlistIDs": watchlistIDs,
"WatchlistStatus": watchlistStatus,
"Seasons": allSeasons,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
}
// HandleProxy proxies media requests through the backend to avoid CORS and hide source URLs.
func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", http.StatusBadRequest)
return
}
// determine proxy scope based on URL suffix
scope := proxyScopeStream
if strings.HasSuffix(r.URL.Path, "/segment") {
scope = proxyScopeSegment
} else if strings.HasSuffix(r.URL.Path, "/subtitle") {
scope = proxyScopeSubtitle
}
targetURL, referer, err := h.svc.resolveProxyToken(r.Context(), token, scope)
if err != nil {
http.Error(w, "invalid token", http.StatusForbidden)
return
}
rangeHeader := r.Header.Get("Range")
statusCode, headers, content, bodyReader, err := h.svc.ProxyStream(r.Context(), targetURL, referer, rangeHeader)
if err != nil {
log.Printf("proxy error for %s: %v", targetURL, err)
http.Error(w, "proxy failed", http.StatusBadGateway)
return
}
maps.Copy(w.Header(), headers)
w.WriteHeader(statusCode)
if bodyReader != nil {
defer func() { _ = bodyReader.Close() }()
_, _ = io.Copy(w, bodyReader)
} else {
_, _ = w.Write(content)
}
}
// HandleSaveProgress saves playback progress for a user.
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
}
var req struct {
MalID int64 `json:"mal_id"`
Episode int `json:"episode"`
TimeSeconds float64 `json:"time_seconds"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// We fetch the anime info to seed the DB if it's the first time saving progress for this show
anime, err := h.jikanClient.GetAnimeByID(r.Context(), int(req.MalID))
var seed *db.UpsertAnimeParams
if err == nil {
seed = &db.UpsertAnimeParams{
ID: int64(anime.MalID),
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},
DurationSeconds: sql.NullFloat64{Float64: anime.DurationSeconds(), Valid: anime.DurationSeconds() > 0},
}
}
if err := h.svc.SaveProgress(r.Context(), user.ID, req.MalID, req.Episode, req.TimeSeconds, seed); err != nil {
log.Printf("failed to save progress: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleCompleteAnime marks an anime as completed for a user.
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
}
var req struct {
MalID int64 `json:"mal_id"`
Episode int `json:"episode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Seed anime info if needed
anime, err := h.jikanClient.GetAnimeByID(r.Context(), int(req.MalID))
var seed *db.UpsertAnimeParams
if err == nil {
seed = &db.UpsertAnimeParams{
ID: int64(anime.MalID),
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},
DurationSeconds: sql.NullFloat64{Float64: anime.DurationSeconds(), Valid: anime.DurationSeconds() > 0},
}
}
if err := h.svc.CompleteAnime(r.Context(), user.ID, req.MalID, req.Episode, seed); err != nil {
log.Printf("failed to complete anime: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleEpisodeData returns episode streaming data for the player.
func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) {
// path: /api/watch/episode/{animeId}/{episodeId}
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 6 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
animeID, err := strconv.Atoi(parts[4])
if err != nil {
http.Error(w, "invalid animeId", http.StatusBadRequest)
return
}
episodeID := parts[5]
user := middleware.GetUser(r.Context())
userID := ""
if user != nil {
userID = user.ID
}
anime, err := h.jikanClient.GetAnimeByID(r.Context(), animeID)
if err != nil {
http.Error(w, "anime not found", http.StatusNotFound)
return
}
titleCandidates := []string{anime.Title}
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
titleCandidates = append(titleCandidates, anime.TitleEnglish)
}
if anime.TitleJapanese != "" {
titleCandidates = append(titleCandidates, anime.TitleJapanese)
}
watchData, err := h.svc.BuildWatchPageData(r.Context(), animeID, titleCandidates, episodeID, "", userID)
if err != nil {
http.Error(w, "failed to build watch data", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
if err := writeJSON(w, map[string]any{
"mal_id": watchData.MalID,
"title": watchData.Title,
"current_episode": watchData.CurrentEpisode,
"total_episodes": anime.Episodes,
"initial_mode": watchData.InitialMode,
"token": "", // The token might be per-source, wait, in Go it was per-mode?
"available_modes": watchData.AvailableModes,
"mode_sources": watchData.ModeSources,
"segments": watchData.Segments,
"episode_title": "", // Find episode title if possible
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("watch page encode error: %v", err)
}
}
}
// HandleEpisodeThumbnails returns episode list for the thumbnail strip.
func (h *Handler) HandleEpisodeThumbnails(w http.ResponseWriter, r *http.Request) {
// path: /api/watch/thumbnails/{animeId}
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 5 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(parts[4])
if err != nil {
http.Error(w, "invalid animeId", http.StatusBadRequest)
return
}
allEpisodes, err := h.jikanClient.GetAllEpisodes(r.Context(), id)
if err != nil {
log.Printf("failed to fetch thumbnails/episodes: %v", err)
}
// Fill gaps if anime has known total
anime, _ := h.jikanClient.GetAnimeByID(r.Context(), id)
if anime.Episodes > 0 && anime.Episodes > len(allEpisodes) {
epMap := make(map[int]jikan.Episode)
for _, ep := range allEpisodes {
epMap[ep.MalID] = ep
}
var filled []jikan.Episode
for i := 1; i <= anime.Episodes; i++ {
if ep, ok := epMap[i]; ok {
filled = append(filled, ep)
} else {
filled = append(filled, jikan.Episode{
MalID: i,
Episode: fmt.Sprintf("Episode %d", i),
Title: fmt.Sprintf("Episode %d", i),
})
}
}
allEpisodes = filled
}
type Result struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
}
results := make([]Result, len(allEpisodes))
for i, ep := range allEpisodes {
results[i] = Result{
MalID: ep.MalID,
Title: ep.Title,
}
}
w.Header().Set("Content-Type", "application/json")
if err := writeJSON(w, results); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("thumbnails encode error: %v", err)
}
}
}
func writeJSON(w http.ResponseWriter, v any) error {
return json.NewEncoder(w).Encode(v)
}

View File

@@ -1,144 +0,0 @@
package playback
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"mal/internal/db"
)
// SaveProgress updates watch progress and continue-watching state in a transaction.
func (s *Service) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64, animeSeed *db.UpsertAnimeParams) error {
if strings.TrimSpace(userID) == "" || animeID <= 0 || episode <= 0 {
return errors.New("invalid save progress input")
}
txQueries, tx, err := db.BeginTx(ctx, s.sqlDB)
if err != nil {
return err
}
defer func() { _ = 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, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if watchListErr != nil && !errors.Is(watchListErr, sql.ErrNoRows) {
return fmt.Errorf("failed to load watchlist entry: %w", watchListErr)
}
isCompleted := watchListErr == nil && watchListEntry.Status == "completed"
if !isCompleted {
if err := txQueries.SaveWatchProgress(ctx, db.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 isCompleted {
return tx.Commit()
}
var durationSeconds sql.NullFloat64
if animeSeed != nil {
durationSeconds = animeSeed.DurationSeconds
}
if _, err := txQueries.UpsertContinueWatchingEntry(ctx, db.UpsertContinueWatchingEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true},
CurrentTimeSeconds: timeSeconds,
DurationSeconds: durationSeconds,
}); 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
}
// CompleteAnime marks an anime as completed in the watchlist and clears continue-watching.
func (s *Service) CompleteAnime(ctx context.Context, userID string, animeID int64, episode int, animeSeed *db.UpsertAnimeParams) error {
if strings.TrimSpace(userID) == "" || animeID <= 0 || episode <= 0 {
return errors.New("invalid complete anime input")
}
txQueries, tx, err := db.BeginTx(ctx, s.sqlDB)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
watchListEntry, watchListErr := txQueries.GetWatchListEntry(ctx, db.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, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: "completed",
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
CurrentTimeSeconds: 0,
}); err != nil {
return fmt.Errorf("failed to mark watchlist as completed: %w", err)
}
if err := txQueries.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
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 := txQueries.DeleteContinueWatchingEntry(ctx, db.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 complete anime transaction: %w", err)
}
return nil
}

View File

@@ -1,356 +0,0 @@
package playback
import (
"bufio"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
)
const (
proxyStreamTokenTTL = 2 * time.Hour
proxySegmentTokenTTL = 6 * time.Hour
proxySubtitleTokenTTL = 6 * time.Hour
)
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)
// format: payload.signature (both base64url encoded)
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) { // constant-time comparison
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 {
// wrap stream url with proxy token
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,
Qualities: source.Qualities,
}
}
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(),
})
}
// proxyTokenTTLs defines ttl per scope type.
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
}
// resolve referer only if it passes public target check
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
}
// normalizeProxyURL validates and canonicalizes a proxy target URL.
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")
}
// block localhost and .local TLD
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
}
// isBlockedProxyIP checks for loopback, private, multicast, and unspecified addresses.
func isBlockedProxyIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsMulticast() ||
ip.IsLinkLocalMulticast() ||
ip.IsLinkLocalUnicast() ||
ip.IsUnspecified()
}
// ensurePublicProxyTarget validates that the target host resolves to a public IP.
// results are cached to avoid repeated DNS lookups.
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")
}
// direct IP already checked by normalizeProxyURL
if ip := net.ParseIP(host); ip != nil {
if isBlockedProxyIP(ip) {
return errors.New("private proxy targets are not allowed")
}
return nil
}
// check cache first
cached, ok := s.proxyHostCache.Get(host)
if ok {
if cached.Allowed {
return nil
}
return errors.New("private proxy targets are not allowed")
}
// DNS resolution for hostname
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.proxyHostCache.Add(host, proxyHostCacheItem{
Allowed: allowed,
})
if !allowed {
return errors.New("private proxy targets are not allowed")
}
return nil
}
// rewritePlaylistWithTokens replaces segment URLs with proxy tokens for HLS playlists.
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)
// preserve comments and empty lines
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

@@ -1,48 +0,0 @@
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, err := NewService(&fakeProxyQuerier{}, nil, Config{ProxyTokenSecret: "0123456789abcdef0123456789abcdef"})
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
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 {
db.Querier
}

View File

@@ -1,392 +0,0 @@
package playback
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"mal/internal/db"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/hashicorp/golang-lru/v2"
)
const (
providerProbeTimeout = 3 * time.Second
)
type Service struct {
allAnimeClient *allAnimeClient
httpClient *http.Client
sqlDB *sql.DB
db db.Querier
proxyTokens *proxyTokenSigner
proxyHostCache *lru.Cache[string, proxyHostCacheItem]
showResolution *lru.Cache[int, showResolutionCacheItem]
playbackDataCache *lru.Cache[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
}
type playbackDataCacheItem struct {
Data playbackBaseData
}
type playbackBaseData struct {
Title string
AvailableModes []string
ModeSources map[string]ModeSource
Segments []SkipSegment
FallbackEpisodes map[string]int
}
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
}
type userPlaybackState struct {
CurrentStatus string
StartTimeSeconds float64
}
// NewService initializes the playback service with db and sql connections.
func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) (*Service, error) {
proxyTokens, err := newProxyTokenSigner(cfg.ProxyTokenSecret)
if err != nil {
return nil, fmt.Errorf("failed to initialize proxy token signer: %w", err)
}
showResolution, err := lru.New[int, showResolutionCacheItem](5000)
if err != nil {
return nil, err
}
playbackDataCache, err := lru.New[string, playbackDataCacheItem](500)
if err != nil {
return nil, err
}
proxyHostCache, err := lru.New[string, proxyHostCacheItem](1000)
if err != nil {
return nil, err
}
return &Service{
allAnimeClient: newAllAnimeClient(),
httpClient: &http.Client{Timeout: 12 * time.Second},
sqlDB: sqlDB,
db: db,
proxyTokens: proxyTokens,
proxyHostCache: proxyHostCache,
showResolution: showResolution,
playbackDataCache: playbackDataCache,
}, nil
}
// BuildWatchPageData resolves show metadata and sources for a given MAL ID and episode.
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")
}
fallbackEpisodes := make(map[string]int)
if counts, err := s.allAnimeClient.GetAvailableEpisodes(ctx, showID); err == nil {
fallbackEpisodes["sub"] = len(counts.Sub)
fallbackEpisodes["dub"] = len(counts.Dub)
fallbackEpisodes["raw"] = len(counts.Raw)
}
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,
FallbackEpisodes: fallbackEpisodes,
}
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")
}
segments := baseData.Segments
if segments == nil {
segments = []SkipSegment{}
}
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(segments),
FallbackEpisodes: baseData.FallbackEpisodes,
}, 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, db.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, db.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) {
item, ok := s.playbackDataCache.Get(key)
if !ok {
return playbackBaseData{}, false
}
return clonePlaybackBaseData(item.Data), true
}
func (s *Service) setPlaybackBaseDataCache(key string, data playbackBaseData) {
s.playbackDataCache.Add(key, playbackDataCacheItem{
Data: clonePlaybackBaseData(data),
})
}
func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandidates []string) (string, string, error) {
if item, ok := s.showResolution.Get(malID); ok && strings.TrimSpace(item.ShowID) != "" {
return item.ShowID, item.Title, nil
}
showID, resolvedTitle, err := s.resolveShow(ctx, malID, titleCandidates)
if err != nil {
return "", "", err
}
s.showResolution.Add(malID, showResolutionCacheItem{
ShowID: showID,
Title: resolvedTitle,
})
return showID, resolvedTitle, nil
}
// fetchPlaybackSourcesAndSegments resolves sources for both dub and sub modes concurrently.
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{}
// parallel fetch for both modes
for _, mode := range []string{"dub", "sub"} {
modeValue := mode
go func() {
resolved, err := s.resolveModeSourceWithCache(ctx, showID, episode, modeValue, "best", probeCache, &probeCacheMu)
if err != nil {
log.Printf("playback source resolution failed for mode=%s showID=%s episode=%s: %v", modeValue, showID, episode, err)
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),
Qualities: toQualities(resolved.AvailableQualities),
},
OK: true,
}
}()
}
segmentsCh := make(chan []SkipSegment, 1)
go func() {
segmentsCh <- s.fetchSkipSegments(ctx, malID, episode)
}()
modeSources := make(map[string]ModeSource)
// collect results from both mode goroutines
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),
FallbackEpisodes: data.FallbackEpisodes,
}
}
func toQualities(sources []StreamSource) []string {
seen := make(map[string]struct{})
var qualities []string
for _, s := range sources {
q := strings.TrimSpace(s.Quality)
if q == "" || q == "auto" {
continue
}
if _, ok := seen[q]; !ok {
seen[q] = struct{}{}
qualities = append(qualities, q)
}
}
return qualities
}
func cloneSlice[T any](items []T) []T {
if items == nil {
return []T{}
}
if len(items) == 0 {
return []T{}
}
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),
Qualities: cloneSlice(source.Qualities),
}
}
return cloned
}

View File

@@ -1,74 +0,0 @@
package playback
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// fetchSkipSegments queries aniskip API for OP/ED skip times.
// returns nil if the API is unavailable or has no data.
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 func() { _ = 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
}
// filter to valid OP/ED segments
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

@@ -1,119 +0,0 @@
package playback
import (
"context"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
)
// ProxyStream fetches a stream URL and returns the response.
// retries on failure, rewrites m3u8 playlists to include auth tokens.
func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer string, rangeHeader string) (int, http.Header, []byte, io.ReadCloser, error) {
const maxRetries = 2
const retryDelay = 500 * time.Millisecond
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return 0, nil, nil, nil, ctx.Err()
case <-time.After(retryDelay):
}
log.Printf("retrying proxy request for %s (attempt %d/%d)", targetURL, attempt, maxRetries)
}
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 {
lastErr = err
continue
}
return s.handleProxyResponse(ctx, resp, targetURL, referer)
}
return 0, nil, nil, nil, fmt.Errorf("upstream request failed after %d retries: %w", maxRetries+1, lastErr)
}
// handleProxyResponse processes the upstream response.
// rewrites m3u8 playlists to proxy through our backend.
func (s *Service) handleProxyResponse(ctx context.Context, resp *http.Response, targetURL string, referer string) (int, http.Header, []byte, io.ReadCloser, error) {
// check if response is an m3u8 playlist that needs rewriting
if isM3U8(targetURL, resp.Header.Get("Content-Type")) {
defer func() { _ = 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.Del("Content-Length")
headers.Del("Transfer-Encoding")
headers.Set("Content-Type", "application/vnd.apple.mpegurl")
headers.Set("Content-Length", strconv.Itoa(len(rewritten)))
return resp.StatusCode, headers, []byte(rewritten), nil, nil
}
// for binary streams, remove chunked encoding and return body reader
headers := cloneHeaders(resp.Header)
headers.Del("Transfer-Encoding")
return resp.StatusCode, headers, nil, resp.Body, nil
}
// isM3U8 checks if the response is an m3u8 playlist by URL or content-type.
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": {},
"keep-alive": {},
"proxy-authenticate": {},
"proxy-authorization": {},
"te": {},
"trailers": {},
"upgrade": {},
}
// cloneHeaders copies headers, filtering out hop-by-hop headers.
// hop-by-hop headers are specific to a single transport connection.
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

@@ -1,188 +0,0 @@
package playback
import (
"bytes"
"errors"
"sort"
"strconv"
"strings"
)
func rankSources(sources []StreamSource, quality string) ([]sourceScore, error) {
filtered := make([]StreamSource, 0, len(sources))
seen := make(map[string]struct{})
for _, source := range sources {
if source.URL == "" {
continue
}
if _, exists := seen[source.URL]; exists {
continue
}
seen[source.URL] = struct{}{}
filtered = append(filtered, source)
}
if len(filtered) == 0 {
return nil, errors.New("no playable sources available")
}
targetQuality := normalizeQuality(quality)
scored := make([]sourceScore, 0, len(filtered))
for _, source := range filtered {
typeScore := lookupPriority(sourceTypePriority, source.Type, 200)
providerScore := lookupPriority(providerPriority, source.Provider, 60)
qualityScore := sourceQualityPriority(source.Quality, targetQuality)
refererScore := 0
if source.Referer != "" {
refererScore = 20
}
total := typeScore + providerScore + qualityScore + refererScore
scored = append(scored, sourceScore{
source: source,
total: total,
typeScore: typeScore,
providerScore: providerScore,
qualityScore: qualityScore,
refererScore: refererScore,
})
}
// stable sort to preserve insertion order for equal scores
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,
}
func lookupPriority(m map[string]int, key string, fallback int) int {
if p, ok := m[strings.ToLower(key)]; ok {
return p
}
return fallback
}
// sourceQualityPriority scores quality match: exact match gets boost, mismatch gets penalty.
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
}
}
// qualityMatches checks if source matches target by substring or extracted digits.
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)
}
// parseQualityValue extracts numeric value from quality string.
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
}
// extractDigits reads leading digits until a non-digit or break condition.
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)
}
// normalizeSourceTypeFromProbe overrides source type based on Content-Type header.
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
}
// isLikelyMP4 checks ftyp box header (bytes 4-8 of mp4 files).
func isLikelyMP4(payload []byte) bool {
if len(payload) < 12 {
return false
}
return bytes.Equal(payload[4:8], []byte("ftyp"))
}
// isLikelyM3U8 checks for m3u8 file header.
func isLikelyM3U8(payload []byte) bool {
trimmed := strings.TrimSpace(string(payload))
return strings.HasPrefix(trimmed, "#EXTM3U")
}

View File

@@ -1,491 +0,0 @@
package playback
import (
"testing"
)
func TestRankSources(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sources []StreamSource
quality string
wantErr bool
}{
{
name: "empty sources returns error",
sources: nil,
quality: "best",
wantErr: true,
},
{
name: "filters empty URLs",
sources: []StreamSource{
{URL: "", Type: "mp4"},
{URL: "https://example.com/v.mp4", Type: "mp4"},
},
quality: "best",
wantErr: false,
},
{
name: "deduplicates URLs",
sources: []StreamSource{
{URL: "https://a.com/v.mp4", Type: "mp4"},
{URL: "https://b.com/v.mp4", Type: "m3u8"},
{URL: "https://a.com/v.mp4", Type: "mp4"},
},
quality: "best",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := rankSources(tt.sources, tt.quality)
if (err != nil) != tt.wantErr {
t.Errorf("rankSources() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestRankSourcesOrdering(t *testing.T) {
t.Parallel()
sources := []StreamSource{
{URL: "https://embed.com/v.mp4", Type: "embed", Provider: "streamwish"},
{URL: "https://mp4.com/v.mp4", Type: "mp4", Provider: "s-mp4"},
{URL: "https://m3u8.com/v.m3u8", Type: "m3u8", Provider: "default"},
{URL: "https://unknown.com/v.mp4", Type: "unknown", Provider: "other"},
}
ranked, err := rankSources(sources, "best")
if err != nil {
t.Fatalf("rankSources() error = %v", err)
}
if len(ranked) != 4 {
t.Fatalf("got %d sources, want 4", len(ranked))
}
if ranked[0].source.Type != "mp4" {
t.Errorf("ranked[0] = %q, want mp4 (type priority: mp4 > m3u8 > unknown > embed)", ranked[0].source.Type)
}
if ranked[1].source.Type != "m3u8" {
t.Errorf("ranked[1] = %q, want m3u8", ranked[1].source.Type)
}
}
func TestRankSourcesWithQuality(t *testing.T) {
t.Parallel()
sources := []StreamSource{
{URL: "https://a.com/v.mp4", Quality: "1080p", Type: "mp4"},
{URL: "https://b.com/v.mp4", Quality: "720p", Type: "mp4"},
{URL: "https://c.com/v.mp4", Quality: "480p", Type: "mp4"},
}
ranked, err := rankSources(sources, "1080p")
if err != nil {
t.Fatalf("rankSources() error = %v", err)
}
if ranked[0].source.Quality != "1080p" {
t.Errorf("ranked[0].Quality = %q, want 1080p", ranked[0].source.Quality)
}
}
func TestNormalizeQuality(t *testing.T) {
t.Parallel()
tests := []struct {
name string
quality string
wantNorm string
}{
{
name: "empty returns best",
quality: "",
wantNorm: "best",
},
{
name: "lowercase best",
quality: "BEST",
wantNorm: "best",
},
{
name: "with spaces",
quality: " 720p ",
wantNorm: "720p",
},
{
name: "worst",
quality: "worst",
wantNorm: "worst",
},
{
name: "specific quality",
quality: "1080p",
wantNorm: "1080p",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := normalizeQuality(tt.quality)
if got != tt.wantNorm {
t.Errorf("normalizeQuality(%q) = %q, want %q", tt.quality, got, tt.wantNorm)
}
})
}
}
func TestParseQualityValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
quality string
want int
}{
{
name: "auto returns 240",
quality: "auto",
want: 240,
},
{
name: "1080p extracts 1080",
quality: "1080p",
want: 1080,
},
{
name: "720 extracts 720",
quality: "720",
want: 720,
},
{
name: "fhd is treated as fhd",
quality: "fhd",
want: 0,
},
{
name: "empty returns 0",
quality: "",
want: 0,
},
{
name: "multiple digits stops at first non-digit",
quality: "1080p60fps",
want: 1080,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := parseQualityValue(tt.quality)
if got != tt.want {
t.Errorf("parseQualityValue(%q) = %d, want %d", tt.quality, got, tt.want)
}
})
}
}
func TestQualityMatches(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source string
target string
want bool
}{
{
name: "exact match",
source: "1080p",
target: "1080p",
want: true,
},
{
name: "target in source",
source: "1920x1080",
target: "1080",
want: true,
},
{
name: "digit match",
source: "1080p",
target: "1080",
want: true,
},
{
name: "no match",
source: "720p",
target: "1080",
want: false,
},
{
name: "empty source returns false",
source: "",
target: "1080",
want: false,
},
{
name: "empty target returns true (empty always contained)",
source: "1080p",
target: "",
want: true,
},
{
name: "auto doesn't match specific",
source: "auto",
target: "1080",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := qualityMatches(tt.source, tt.target)
if got != tt.want {
t.Errorf("qualityMatches(%q, %q) = %v, want %v", tt.source, tt.target, got, tt.want)
}
})
}
}
func TestSourceQualityPriority(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source string
target string
wantMin int
}{
{
name: "best mode favors higher quality",
source: "1080p",
target: "best",
wantMin: 1080,
},
{
name: "worst mode penalizes higher quality",
source: "1080p",
target: "worst",
wantMin: -2000,
},
{
name: "exact match gets bonus",
source: "1080p",
target: "1080p",
wantMin: 2000,
},
{
name: "close match gets penalty but positive score",
source: "1080p",
target: "720p",
wantMin: 500,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := sourceQualityPriority(tt.source, tt.target)
if tt.wantMin != 0 && got < tt.wantMin {
t.Errorf("sourceQualityPriority(%q, %q) = %d, want >= %d", tt.source, tt.target, got, tt.wantMin)
}
})
}
}
func TestSourceTypePriorityLookup(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sourceType string
want int
}{
{
name: "mp4 priority",
sourceType: "mp4",
want: 500,
},
{
name: "m3u8 priority",
sourceType: "m3u8",
want: 450,
},
{
name: "unknown uses fallback",
sourceType: "unknown",
want: 300,
},
{
name: "embed fallback",
sourceType: "embed",
want: 100,
},
{
name: "unrecognized uses fallback",
sourceType: "video",
want: 200,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := lookupPriority(sourceTypePriority, tt.sourceType, 200)
if got != tt.want {
t.Errorf("lookupPriority(sourceTypePriority, %q, 200) = %d, want %d", tt.sourceType, got, tt.want)
}
})
}
}
func TestProviderPriorityLookup(t *testing.T) {
t.Parallel()
tests := []struct {
name string
provider string
want int
}{
{
name: "s-mp4",
provider: "s-mp4",
want: 120,
},
{
name: "default",
provider: "default",
want: 115,
},
{
name: "yt-mp4",
provider: "yt-mp4",
want: 100,
},
{
name: "unknown uses fallback",
provider: "unknown",
want: 60,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := lookupPriority(providerPriority, tt.provider, 60)
if got != tt.want {
t.Errorf("lookupPriority(providerPriority, %q, 60) = %d, want %d", tt.provider, got, tt.want)
}
})
}
}
func TestNormalizeSourceTypeFromProbe(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source StreamSource
contentType string
wantType string
}{
{
name: "video/mp4 normalizes to mp4",
source: StreamSource{Type: "unknown"},
contentType: "video/mp4",
wantType: "mp4",
},
{
name: "application/octet-stream unchanged",
source: StreamSource{Type: "mp4"},
contentType: "application/octet-stream",
wantType: "mp4",
},
{
name: "mpegurl normalizes to m3u8",
source: StreamSource{Type: "unknown"},
contentType: "application/vnd.apple.mpegurl",
wantType: "m3u8",
},
{
name: "video/mpegurl",
source: StreamSource{Type: "unknown"},
contentType: "video/mpegurl",
wantType: "m3u8",
},
{
name: "case insensitive",
source: StreamSource{Type: "unknown"},
contentType: "VIDEO/MP4",
wantType: "mp4",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := normalizeSourceTypeFromProbe(tt.source, tt.contentType)
if got.Type != tt.wantType {
t.Errorf("normalizeSourceTypeFromProbe().Type = %q, want %q", got.Type, tt.wantType)
}
})
}
}
func TestExtractDigits(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
}{
{
name: "extracts digits",
value: "1080p",
want: "1080",
},
{
name: "empty if no digits",
value: "p",
want: "",
},
{
name: "stops at non-digit after digits",
value: "720p60",
want: "720",
},
{
name: "multiple non-digit does not break",
value: "abc123def",
want: "123",
},
{
name: "all digits",
value: "1080",
want: "1080",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := extractDigits(tt.value)
if got != tt.want {
t.Errorf("extractDigits(%q) = %q, want %q", tt.value, got, tt.want)
}
})
}
}

View File

@@ -1,171 +0,0 @@
package playback
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"sync"
)
func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []string) (string, string, error) {
malText := strconv.Itoa(malID)
modeCandidates := []string{"sub", "dub"}
queries := buildTitleSearchQueries(titleCandidates)
for _, query := range queries {
resultsByMode := s.searchShowResultsByMode(ctx, query, modeCandidates)
for _, mode := range modeCandidates {
for _, result := range resultsByMode[mode] {
// exact mal id match
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
}
// fallback to first result if no exact match
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 // capture loop variable
wg.Go(func() {
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, "+", " "))
// strip apostrophes to improve match rate
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...)
}
// selectInitialMode picks a mode prioritizing: requested mode > dub > sub > first available.
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

@@ -1,224 +0,0 @@
package playback
import (
"context"
"errors"
"io"
"net/http"
"strings"
"sync"
)
// resolveModeSourceWithCache is like resolveModeSource but caches probe results.
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
}
selected.AvailableQualities = sources
return selected, nil
}
// choosePlaybackSource selects the best playable source from ranked candidates.
// priority: direct media > probed media > embed sources > ranked fallback.
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 // known playable types
case "embed":
embedCandidates = append(embedCandidates, source) // need probing
default:
// probe unknown types
if playable, contentType := probeFn(ctx, source); playable {
return normalizeSourceTypeFromProbe(source, contentType), "probed-media", nil
}
}
}
// check embed sources for playability
for _, embed := range embedCandidates {
if s.probeEmbedSource(ctx, embed) {
return embed, "embed-probed", nil
}
}
// fallback to first embed or first ranked
if len(embedCandidates) > 0 {
return embedCandidates[0], "embed-fallback", nil
}
return ranked[0].source, "ranked-fallback", nil
}
// choosePlaybackSourceWithCache wraps choosePlaybackSource with cached probing.
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
}
// probeDirectMedia checks if a direct media URL is playable.
// checks content-type header, reads prefix for magic bytes, falls back to URL extension.
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") // small range to detect playable content
resp, err := s.httpClient.Do(req)
if err != nil {
return false, ""
}
defer func() { _ = resp.Body.Close() }()
// check content-type header first
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if strings.Contains(contentType, "video/") || strings.Contains(contentType, "mpegurl") {
return true, contentType
}
// check magic bytes in prefix
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"
}
}
// fallback to URL extension
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
}
// probeEmbedSource checks if an embed page is still available.
// returns false if the page contains deletion markers.
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 func() { _ = resp.Body.Close() }()
if resp.StatusCode >= http.StatusBadRequest {
return false
}
// check for common deletion messages
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

@@ -1,24 +0,0 @@
package playback
import (
"strings"
)
// toSubtitleItems converts raw subtitle entries into client-safe items.
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
}

View File

@@ -1,174 +0,0 @@
package watchlist
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"mal/internal/db"
"mal/internal/middleware"
"mal/templates"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// HandleUpdateWatchlist adds or updates anime in user's watchlist. accepts json {animeId, status}.
func (h *Handler) HandleUpdateWatchlist(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
}
var body struct {
AnimeID int64 `json:"animeId"`
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// default status if not provided
if body.Status == "" {
body.Status = "plan_to_watch"
}
if err := h.service.AddToWatchlist(r.Context(), user.ID, body.AnimeID, body.Status); err != nil {
log.Printf("failed to add to watchlist: %v", err)
http.Error(w, "failed to add to watchlist", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleDeleteWatchlist removes anime from user's watchlist. expects /api/watchlist/{animeId}.
func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
if user == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
animeIDStr := r.URL.Path[len("/api/watchlist/"):]
animeID, err := strconv.ParseInt(animeIDStr, 10, 64)
if err != nil {
http.Error(w, "invalid anime id", http.StatusBadRequest)
return
}
if _, err := h.service.RemoveEntry(r.Context(), user.ID, animeID); err != nil {
log.Printf("failed to remove from watchlist: %v", err)
http.Error(w, "failed to remove from watchlist", http.StatusInternalServerError)
return
}
// htmx: redirect to watchlist page after delete
w.Header().Set("HX-Redirect", "/watchlist")
w.WriteHeader(http.StatusOK)
}
// HandleDeleteContinueWatching removes entry from user's continue watching. expects /api/continue-watching/{animeId}.
func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
if user == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
animeIDStr := r.URL.Path[len("/api/continue-watching/"):]
animeID, err := strconv.ParseInt(animeIDStr, 10, 64)
if err != nil {
http.Error(w, "invalid anime id", http.StatusBadRequest)
return
}
if err := h.service.DeleteContinueWatching(r.Context(), user.ID, animeID); err != nil {
log.Printf("failed to remove from continue watching: %v", err)
http.Error(w, "failed to remove from continue watching", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(http.StatusOK)
}
// HandleGetWatchlist renders user's watchlist page, grouped by status.
func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
if user == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
entries, err := h.service.GetUserWatchlist(r.Context(), user.ID)
if err != nil {
log.Printf("failed to fetch watchlist: %v", err)
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
return
}
// group entries by status for display
watchlistByStatus := make(map[string][]db.GetUserWatchListRow)
allEntries := make([]db.GetUserWatchListRow, 0)
watchlistIDs := make([]int64, len(entries))
for i, entry := range entries {
status := entry.Status
if status == "" {
status = "plan_to_watch"
}
watchlistByStatus[status] = append(watchlistByStatus[status], entry)
allEntries = append(allEntries, entry)
watchlistIDs[i] = entry.AnimeID
}
data := map[string]any{
"User": user,
"CurrentPath": r.URL.Path,
"WatchlistByStatus": watchlistByStatus,
"AllEntries": allEntries,
"WatchlistIDs": watchlistIDs,
"StatusOrder": []string{"watching", "plan_to_watch", "on_hold", "completed", "dropped"},
"StatusLabels": map[string]string{
"watching": "Currently Watching",
"plan_to_watch": "Plan to Watch",
"on_hold": "On Hold",
"completed": "Completed",
"dropped": "Dropped",
},
}
// use partial template for htmx requests
templateName := "watchlist.gohtml"
if r.Header.Get("HX-Request") == "true" {
templateName = "watchlist_partial.gohtml"
}
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, templateName, data); err != nil {
if !errors.Is(err, context.Canceled) {
log.Printf("render error: %v", err)
}
}
}

View File

@@ -1,184 +0,0 @@
package watchlist
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"mal/integrations/jikan"
"mal/internal/db"
)
type Service struct {
db db.Querier
sqlDB *sql.DB
jikanClient *jikan.Client
}
var (
ErrInvalidAnimeID = errors.New("invalid anime ID")
ErrInvalidStatus = errors.New("invalid watchlist status")
)
var validStatuses = map[string]struct{}{
"watching": {},
"completed": {},
"dropped": {},
"plan_to_watch": {},
"on_hold": {},
}
func NewService(db db.Querier, sqlDB *sql.DB, jikanClient *jikan.Client) *Service {
return &Service{db: db, sqlDB: sqlDB, jikanClient: jikanClient}
}
// ensureAnimeExists checks if anime exists in db, fetches from jikan if not, then upserts.
func (s *Service) ensureAnimeExists(ctx context.Context, animeID int64) error {
_, err := s.db.GetAnime(ctx, animeID)
if err == nil {
return nil // already exists
}
// fetch from jikan and store locally
anime, err := s.jikanClient.GetAnimeByID(ctx, int(animeID))
if err != nil {
return fmt.Errorf("failed to fetch anime from jikan: %w", err)
}
_, err = s.db.UpsertAnime(ctx, db.UpsertAnimeParams{
ID: int64(anime.MalID),
TitleOriginal: anime.Title,
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
ImageUrl: anime.Images.Jpg.LargeImageURL,
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
})
if err != nil {
return fmt.Errorf("failed to save anime: %w", err)
}
return nil
}
type AddRequest struct {
AnimeID int64
TitleOriginal string
TitleEnglish string
TitleJapanese string
ImageURL string
Status string
Airing bool
}
// AddToWatchlist adds or updates an anime entry in user's watchlist.
func (s *Service) AddToWatchlist(ctx context.Context, userID string, animeID int64, status string) error {
if animeID <= 0 {
return ErrInvalidAnimeID
}
if _, ok := validStatuses[status]; !ok {
return ErrInvalidStatus
}
// ensure anime exists in local db before linking
if err := s.ensureAnimeExists(ctx, animeID); err != nil {
return err
}
entryID := uuid.New().String()
_, err := s.db.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: entryID,
UserID: userID,
AnimeID: animeID,
Status: status,
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
})
if err != nil {
return fmt.Errorf("failed to update watchlist: %w", err)
}
return nil
}
// RemoveEntry deletes a watchlist entry and returns the anime for potential use.
func (s *Service) RemoveEntry(ctx context.Context, userID string, animeID int64) (db.Anime, error) {
if animeID <= 0 {
return db.Anime{}, ErrInvalidAnimeID
}
anime, err := s.db.GetAnime(ctx, animeID)
if err != nil {
return db.Anime{}, fmt.Errorf("anime not found: %w", err)
}
err = s.db.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if err != nil {
return db.Anime{}, fmt.Errorf("failed to delete from watchlist: %w", err)
}
return anime, nil
}
// GetUserWatchlist retrieves all watchlist entries for a user.
func (s *Service) GetUserWatchlist(ctx context.Context, userID string) ([]db.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
}
// DeleteContinueWatching removes entry and clears associated watch progress.
// uses transaction when sqlDB is available.
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 := db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
}
clearProgress := db.SaveWatchProgressParams{
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
UserID: userID,
AnimeID: animeID,
}
// use transaction when sqlDB available for consistency
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 := db.BeginTx(ctx, s.sqlDB)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() { _ = 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()
}

View File

@@ -1,72 +0,0 @@
package watchlist
import (
"context"
"testing"
"mal/internal/db"
)
type fakeQuerier struct {
db.Querier
upsertAnimeCalled bool
upsertEntryCalled bool
upsertEntryParams db.UpsertWatchListEntryParams
getAnimeFunc func(ctx context.Context, id int64) (db.Anime, error)
}
func (f *fakeQuerier) GetAnime(ctx context.Context, id int64) (db.Anime, error) {
if f.getAnimeFunc != nil {
return f.getAnimeFunc(ctx, id)
}
return db.Anime{}, nil
}
func (f *fakeQuerier) UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) {
f.upsertAnimeCalled = true
return db.Anime{}, nil
}
func (f *fakeQuerier) UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) {
f.upsertEntryCalled = true
f.upsertEntryParams = arg
return db.WatchListEntry{}, nil
}
func (f *fakeQuerier) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
return nil, nil
}
func TestAddEntry_RejectsInvalidAnimeID(t *testing.T) {
t.Parallel()
q := &fakeQuerier{}
svc := NewService(q, nil, nil)
err := svc.AddToWatchlist(context.Background(), "user-1", 0, "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, nil)
err := svc.AddToWatchlist(context.Background(), "user-1", 1, "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")
}
}

View File

@@ -1,97 +1,14 @@
package main package main
import ( import (
"context" "mal/internal/app"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
"mal/api/auth"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/server"
"mal/internal/worker"
) )
func main() { func main() {
_ = godotenv.Load() _ = godotenv.Load()
dbConn, err := db.Open(db.GetDBFile()) application := app.NewApp()
if err != nil { application.Run()
log.Fatalf("failed to open db: %v", err)
}
defer func() { _ = dbConn.Close() }()
queries, err := db.Init(dbConn)
if err != nil {
log.Fatalf("failed to initialize database: %v", err)
}
jikanClient := jikan.NewClient(queries)
authLimiter := server.NewAuthLimiter()
go func() {
for {
time.Sleep(time.Minute)
authLimiter.Cleanup(time.Now())
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go worker.New(queries, jikanClient).Start(ctx)
app := server.Config{
DB: queries,
SQLDB: dbConn,
JikanClient: jikanClient,
AuthService: auth.NewService(queries),
AuthLimiter: authLimiter,
PlaybackProxySecret: playbackSecret(),
}
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
httpServer := &http.Server{
Addr: ":" + port,
Handler: server.NewRouter(app),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
go gracefulShutdown(httpServer, ctx)
log.Printf("Server starting on http://localhost:%s", port)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
}
func playbackSecret() string {
secret := os.Getenv("PLAYBACK_PROXY_SECRET")
if len(secret) < 32 {
log.Fatal("PLAYBACK_PROXY_SECRET must be set and at least 32 characters")
}
return secret
}
func gracefulShutdown(srv *http.Server, ctx context.Context) {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("server shutdown failed: %v", err)
}
} }

17
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -e
# Print diagnostic info
echo "[$(date -Iseconds)] Running as user: $(id)"
echo "[$(date -Iseconds)] Database file: $DATABASE_FILE"
if [ -f "/app/main_server" ]; then
echo "[$(date -Iseconds)] main_server found, size: $(stat -c%s /app/main_server) bytes"
chmod +x /app/main_server
else
echo "[$(date -Iseconds)] ERROR: /app/main_server not found!"
exit 1
fi
echo "[$(date -Iseconds)] Starting server..."
exec /app/main_server

42
go.mod
View File

@@ -1,6 +1,6 @@
module mal module mal
go 1.25.0 go 1.25.7
require ( require (
github.com/PuerkitoBio/goquery v1.11.0 github.com/PuerkitoBio/goquery v1.11.0
@@ -15,9 +15,45 @@ require (
require github.com/hashicorp/golang-lru/v2 v2.0.7 require github.com/hashicorp/golang-lru/v2 v2.0.7
require ( require (
github.com/andybalholm/brotli v1.1.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pressly/goose/v3 v3.27.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/fx v1.24.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/arch v0.22.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/compress v1.18.5 // indirect
golang.org/x/sync v0.20.0 // direct golang.org/x/sync v0.20.0 // direct
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect

87
go.sum
View File

@@ -2,22 +2,104 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94= github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94=
github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -91,3 +173,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -25,7 +25,7 @@ type Client struct {
lastReqTime time.Time // rate limiting: last request timestamp lastReqTime time.Time // rate limiting: last request timestamp
} }
func NewClient(db db.Querier) *Client { func NewClient(queries *db.Queries) *Client {
return &Client{ return &Client{
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
@@ -37,7 +37,7 @@ func NewClient(db db.Querier) *Client {
}, },
}, },
baseURL: "https://api.jikan.moe/v4", baseURL: "https://api.jikan.moe/v4",
db: db, db: queries,
retrySignal: make(chan struct{}, 1), retrySignal: make(chan struct{}, 1),
} }
} }

View File

@@ -0,0 +1,9 @@
package jikan
import (
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(NewClient),
)

View File

@@ -1,4 +1,4 @@
package playback package allanime
import ( import (
"bytes" "bytes"
@@ -10,6 +10,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"mal/internal/domain"
"mal/pkg/net/utls" "mal/pkg/net/utls"
"net/http" "net/http"
"net/url" "net/url"
@@ -46,13 +47,13 @@ type AvailableEpisodes struct {
Raw []string Raw []string
} }
type allAnimeClient struct { type AllAnimeProvider struct {
httpClient *http.Client httpClient *http.Client
extractor *providerExtractor extractor *providerExtractor
} }
func newAllAnimeClient() *allAnimeClient { func NewAllAnimeProvider() *AllAnimeProvider {
return &allAnimeClient{ return &AllAnimeProvider{
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
@@ -60,7 +61,141 @@ func newAllAnimeClient() *allAnimeClient {
} }
} }
func (c *allAnimeClient) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) { func (c *AllAnimeProvider) Name() string {
return "AllAnime"
}
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
// 1. Search for the show to get its AllAnime ID
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]any{
"search": map[string]any{
"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]any)
if !ok {
return nil, fmt.Errorf("invalid search response")
}
shows, ok := data["shows"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid shows payload")
}
edges, ok := shows["edges"].([]any)
if !ok {
return nil, fmt.Errorf("invalid search edges")
}
out := make([]searchResult, 0, len(edges))
for _, edge := range edges {
item, ok := edge.(map[string]any)
if !ok {
continue
}
id, _ := item["_id"].(string)
malID, _ := item["malId"].(string)
name, _ := item["name"].(string)
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
name = unquoted
}
name = strings.TrimSpace(name)
if id == "" {
continue
}
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
}
return out, nil
}
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
// 1. Search for the show to get its AllAnime ID
// Try each title candidate, preferring results with matching malId
targetMalIDStr := strconv.Itoa(animeID)
var showID string
var firstAvailableShowID string
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
if err != nil || len(searchResults) == 0 {
continue
}
for _, res := range searchResults {
if res.MalID == targetMalIDStr {
showID = res.ID
break
}
}
if showID != "" {
break
}
if firstAvailableShowID == "" {
firstAvailableShowID = searchResults[0].ID
}
}
if showID == "" {
showID = firstAvailableShowID
}
if showID == "" {
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
}
// 2. Get sources
sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
if err != nil || len(sources) == 0 {
return nil, fmt.Errorf("allanime: no sources for show %s", showID)
}
// 3. Return the first usable source
primary := sources[0]
result := &domain.StreamResult{
URL: primary.URL,
Referer: primary.Referer,
}
for _, sub := range primary.Subtitles {
result.Subtitles = append(result.Subtitles, domain.Subtitle{
Label: sub.Lang,
URL: sub.URL,
})
}
return result, nil
}
func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) {
if mode, ok := variables["translationType"].(string); ok { if mode, ok := variables["translationType"].(string); ok {
variables["translationType"] = strings.ToLower(mode) variables["translationType"] = strings.ToLower(mode)
} }
@@ -113,7 +248,7 @@ func (c *allAnimeClient) graphqlRequest(ctx context.Context, query string, varia
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec" const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
func (c *allAnimeClient) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) { func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
mode = strings.ToLower(mode) mode = strings.ToLower(mode)
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode) varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode)
@@ -216,7 +351,7 @@ func (c *allAnimeClient) graphqlRequestWithHash(ctx context.Context, showID, epi
} }
// GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub). // GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub).
func (c *allAnimeClient) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) { func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) { episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
sourceUrls sourceUrls
@@ -311,7 +446,7 @@ func (c *allAnimeClient) GetEpisodeSources(ctx context.Context, showID string, e
return out, nil return out, nil
} }
func (c *allAnimeClient) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource { func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource {
episodeData, ok := data["episode"].(map[string]any) episodeData, ok := data["episode"].(map[string]any)
if !ok { if !ok {
return nil return nil
@@ -493,77 +628,8 @@ func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
return plainText return plainText
} }
// Search queries AllAnime for shows matching the given search term.
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]any{
"search": map[string]any{
"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]any)
if !ok {
return nil, fmt.Errorf("invalid search response")
}
shows, ok := data["shows"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid shows payload")
}
edges, ok := shows["edges"].([]any)
if !ok {
return nil, fmt.Errorf("invalid search edges")
}
out := make([]searchResult, 0, len(edges))
for _, edge := range edges {
item, ok := edge.(map[string]any)
if !ok {
continue
}
id, _ := item["_id"].(string)
malID, _ := item["malId"].(string)
name, _ := item["name"].(string)
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
name = unquoted
}
name = strings.TrimSpace(name)
if id == "" {
continue
}
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
}
return out, nil
}
// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show. // GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show.
func (c *allAnimeClient) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) { func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
graphqlQuery := `query($showId: String!) { graphqlQuery := `query($showId: String!) {
show(_id: $showId) { show(_id: $showId) {
availableEpisodesDetail availableEpisodesDetail

View File

@@ -1,12 +1,25 @@
package playback package allanime
import ( import (
"bytes"
"context" "context"
"crypto/aes" "crypto/aes"
"encoding/json" "encoding/json"
"mal/internal/domain"
"testing" "testing"
) )
func isLikelyM3U8(data []byte) bool {
return bytes.HasPrefix(bytes.TrimSpace(data), []byte("#EXTM3U"))
}
func isLikelyMP4(data []byte) bool {
if len(data) < 8 {
return false
}
return string(data[4:8]) == "ftyp"
}
func TestDecodeSourceURL(t *testing.T) { func TestDecodeSourceURL(t *testing.T) {
t.Parallel() t.Parallel()
@@ -440,14 +453,8 @@ func TestAllAnimeClientImplementsInterfaces(t *testing.T) {
var ( var (
_ interface { _ interface {
Search(context.Context, string, string) ([]searchResult, error) GetStreams(context.Context, int, []string, string, string) (*domain.StreamResult, error)
} = &allAnimeClient{} } = &AllAnimeProvider{}
_ interface {
GetEpisodeSources(context.Context, string, string, string) ([]StreamSource, error)
} = &allAnimeClient{}
_ interface {
GetAvailableEpisodes(context.Context, string) (AvailableEpisodes, error)
} = &allAnimeClient{}
) )
t.Log("allAnimeClient implements required interfaces") t.Log("allAnimeClient implements required interfaces")

View File

@@ -1,4 +1,4 @@
package playback package allanime
import ( import (
"context" "context"

View File

@@ -1,4 +1,4 @@
package playback package allanime
import ( import (
"context" "context"

View File

@@ -0,0 +1,9 @@
package allanime
import (
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(NewAllAnimeProvider),
)

View File

@@ -1,4 +1,4 @@
package playback package allanime
// StreamSource represents a video stream from a provider. // StreamSource represents a video stream from a provider.
type StreamSource struct { type StreamSource struct {

View File

@@ -0,0 +1,386 @@
package handler
import (
"fmt"
"log"
"mal/internal/domain"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type AnimeHandler struct {
svc domain.AnimeService
watchlistSvc domain.WatchlistService
}
func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler {
return &AnimeHandler{
svc: svc,
watchlistSvc: watchlistSvc,
}
}
func (h *AnimeHandler) Register(r *gin.Engine) {
log.Println("Registering anime routes")
r.GET("/", h.HandleCatalog)
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
r.GET("/discover", h.HandleDiscover)
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
r.GET("/api/discover/top", h.HandleDiscoverTop)
r.GET("/browse", h.HandleBrowse)
r.GET("/anime/:id", h.HandleAnimeDetails)
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
r.GET("/api/search-quick", h.HandleQuickSearch)
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
}
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
user, _ := c.Get("User")
watchlistMap := make(map[int]bool)
if u, ok := user.(*domain.User); ok {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
}
c.HTML(http.StatusOK, "index.gohtml", gin.H{
"CurrentPath": "/",
"User": user,
"WatchlistMap": watchlistMap,
})
}
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
h.renderCatalogSection(c, "Airing")
}
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
h.renderCatalogSection(c, "Popular")
}
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
h.renderCatalogSection(c, "Continue")
}
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
if err != nil {
log.Printf("catalog %s error: %v", section, err)
return
}
watchlistMap := make(map[int]bool)
if userID != "" {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
}
data["Section"] = section
data["_fragment"] = "catalog_section"
data["WatchlistMap"] = watchlistMap
c.HTML(http.StatusOK, "index.gohtml", data)
}
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
user, _ := c.Get("User")
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
"CurrentPath": "/discover",
"User": user,
})
}
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
h.renderDiscoverSection(c, "Trending")
}
func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) {
h.renderDiscoverSection(c, "Upcoming")
}
func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
h.renderDiscoverSection(c, "Top")
}
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
if err != nil {
log.Printf("discover %s error: %v", section, err)
return
}
watchlistMap := make(map[int]bool)
if userID != "" {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
}
data["Section"] = section
data["_fragment"] = "discover_section"
data["WatchlistMap"] = watchlistMap
c.HTML(http.StatusOK, "discover.gohtml", data)
}
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
q := c.Query("q")
animeType := c.Query("type")
status := c.Query("status")
orderBy := c.Query("order_by")
sort := c.Query("sort")
sfw := c.Query("sfw") != "false"
var genres []int
for _, g := range c.QueryArray("genres") {
id, _ := strconv.Atoi(g)
if id > 0 {
genres = append(genres, id)
}
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24)
if err != nil {
log.Printf("browse error: %v", err)
}
user, _ := c.Get("User")
watchlistMap := make(map[int]bool)
if u, ok := user.(*domain.User); ok {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
}
if c.GetHeader("HX-Request") == "true" {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "anime_card_scroll",
"Animes": res.Animes,
"NextPage": page + 1,
"HasNextPage": res.HasNextPage,
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"SFW": sfw,
"WatchlistMap": watchlistMap,
})
return
}
genresList, _ := h.svc.GetGenres(c.Request.Context())
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"CurrentPath": "/browse",
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Genres": genres,
"SFW": sfw,
"GenresList": genresList,
"Animes": res.Animes,
"HasNextPage": res.HasNextPage,
"NextPage": page + 1,
"User": user,
"WatchlistMap": watchlistMap,
})
}
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
c.Status(http.StatusNotFound)
return
}
section := c.Query("section")
if section != "" && c.GetHeader("HX-Request") == "true" {
var data any
var tplName string
var err error
switch section {
case "characters":
data, err = h.svc.GetCharacters(c.Request.Context(), id)
tplName = "anime_characters"
case "recommendations":
data, err = h.svc.GetRecommendations(c.Request.Context(), id)
tplName = "anime_recommendations"
}
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": tplName,
"Items": data,
})
return
}
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
if err != nil {
c.Status(http.StatusNotFound)
return
}
user, _ := c.Get("User")
status := ""
var watchlistIDs []int64
ep := 1
var cwSeconds float64
if u, ok := user.(*domain.User); ok {
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
if err == nil {
status = entry.Status
watchlistIDs = []int64{entry.AnimeID}
}
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), u.ID, int64(id))
if err == nil && cwEntry.CurrentEpisode.Valid {
ep = int(cwEntry.CurrentEpisode.Int64)
cwSeconds = cwEntry.CurrentTimeSeconds
}
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime,
"CurrentPath": fmt.Sprintf("/anime/%d", id),
"User": user,
"Status": status,
"WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds,
})
}
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
id, _ := strconv.Atoi(c.Query("animeId"))
if id <= 0 {
c.Status(http.StatusBadRequest)
return
}
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
relations, err := h.svc.GetRelations(c.Request.Context(), id)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
watchlistMap := make(map[int]bool)
if userID != "" {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order",
"Relations": relations,
"AnimeID": id,
"WatchlistMap": watchlistMap,
})
}
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusOK, []any{})
return
}
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5)
if err != nil {
c.JSON(http.StatusOK, []any{})
return
}
user, _ := c.Get("User")
watchlistMap := make(map[int]bool)
if u, ok := user.(*domain.User); ok {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
}
type quickSearchResult struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Image string `json:"image"`
InWatchlist bool `json:"in_watchlist"`
}
output := make([]quickSearchResult, len(res.Animes))
for i, anime := range res.Animes {
output[i] = quickSearchResult{
ID: anime.MalID,
Title: anime.DisplayTitle(),
Type: anime.Type,
Image: anime.ImageURL(),
InWatchlist: watchlistMap[anime.MalID],
}
}
c.JSON(http.StatusOK, output)
}
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
anime, err := h.svc.GetRandomAnime(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"})
return
}
user, _ := c.Get("User")
inWatchlist := false
if u, ok := user.(*domain.User); ok {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID)
for _, e := range entries {
if int(e.AnimeID) == anime.MalID {
inWatchlist = true
break
}
}
}
c.JSON(http.StatusOK, gin.H{
"data": anime,
"in_watchlist": inWatchlist,
})
}

23
internal/anime/module.go Normal file
View File

@@ -0,0 +1,23 @@
package anime
import (
"mal/internal/anime/handler"
"mal/internal/anime/repository"
"mal/internal/anime/service"
"mal/internal/server"
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(
repository.NewAnimeRepository,
service.NewAnimeService,
handler.NewAnimeHandler,
),
fx.Provide(
server.AsRouteRegister(func(h *handler.AnimeHandler) server.RouteRegister {
return h
}),
),
)

View File

@@ -0,0 +1,27 @@
package repository
import (
"context"
"mal/internal/db"
"mal/internal/domain"
)
type animeRepository struct {
queries *db.Queries
}
func NewAnimeRepository(queries *db.Queries) domain.AnimeRepository {
return &animeRepository{queries: queries}
}
func (r *animeRepository) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
return r.queries.GetUserWatchList(ctx, userID)
}
func (r *animeRepository) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.GetWatchListEntry(ctx, params)
}
func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
return r.queries.GetContinueWatchingEntries(ctx, userID)
}

View File

@@ -0,0 +1,174 @@
package service
import (
"context"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"golang.org/x/sync/errgroup"
)
type animeService struct {
jikan *jikan.Client
repo domain.AnimeRepository
}
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService {
return &animeService{jikan: jikan, repo: repo}
}
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) {
var (
res jikan.TopAnimeResult
cw []db.GetContinueWatchingEntriesRow
watchlist []db.GetUserWatchListRow
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
switch section {
case "Airing":
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
case "Popular":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
})
if userID != "" {
g.Go(func() error {
if section == "Continue" {
var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
return err
}
return nil
})
g.Go(func() error {
var err error
watchlist, err = s.repo.GetUserWatchList(gCtx, userID)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
animes := res.Animes
if len(animes) > 6 {
animes = animes[:6]
}
watchlistMap := make(map[int64]bool)
for _, entry := range watchlist {
watchlistMap[entry.AnimeID] = true
}
return map[string]any{
"Animes": animes,
"ContinueWatching": cw,
"WatchlistMap": watchlistMap,
}, nil
}
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) {
var (
res jikan.TopAnimeResult
watchlist []db.GetUserWatchListRow
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
switch section {
case "Trending":
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
case "Upcoming":
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
case "Top":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
})
if userID != "" {
g.Go(func() error {
var err error
watchlist, err = s.repo.GetUserWatchList(gCtx, userID)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
animes := res.Animes
if len(animes) > 8 {
animes = animes[:8]
}
watchlistMap := make(map[int64]bool)
for _, entry := range watchlist {
watchlistMap[entry.AnimeID] = true
}
return map[string]any{
"Animes": animes,
"WatchlistMap": watchlistMap,
}, nil
}
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
return s.jikan.GetAnimeByID(ctx, id)
}
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) {
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit)
}
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
return s.jikan.GetAnimeGenres(ctx)
}
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Character, error) {
return s.jikan.GetAnimeCharacters(ctx, id)
}
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.Recommendation, error) {
return s.jikan.GetAnimeRecommendations(ctx, id)
}
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
return s.jikan.GetFullRelations(ctx, id)
}
func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) {
return s.jikan.GetEpisodes(ctx, id, page)
}
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
return s.jikan.GetRandomAnime(ctx)
}
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
if err != nil {
return nil, err
}
result := make([]domain.EpisodeData, len(episodes))
for i, ep := range episodes {
result[i] = domain.EpisodeData{
MalID: ep.MalID,
Title: ep.Title,
IsFiller: ep.Filler,
IsRecap: ep.Recap,
}
}
return result, nil
}

41
internal/app/app.go Normal file
View File

@@ -0,0 +1,41 @@
package app
import (
"mal/integrations/jikan"
"mal/integrations/playback/allanime"
"mal/internal/anime"
"mal/internal/auth"
"mal/internal/database"
"mal/internal/playback"
"mal/internal/server"
"mal/internal/templates"
"mal/internal/watchlist"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render"
"go.uber.org/fx"
)
func NewApp() *fx.App {
return fx.New(
database.Module,
jikan.Module,
allanime.Module,
auth.Module,
anime.Module,
watchlist.Module,
playback.Module,
templates.Module,
server.Module,
fx.Provide(func(r *templates.Renderer) render.HTMLRender {
return r
}),
fx.Invoke(fx.Annotate(
func(r *gin.Engine, authMiddleware gin.HandlerFunc, registers []server.RouteRegister) {
r.Use(authMiddleware)
server.RegisterRoutes(r, registers)
},
fx.ParamTags(``, ``, `group:"routes"`),
)),
)
}

View File

@@ -0,0 +1,61 @@
package handler
import (
"mal/internal/domain"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
svc domain.AuthService
}
func NewAuthHandler(svc domain.AuthService) *AuthHandler {
return &AuthHandler{svc: svc}
}
func (h *AuthHandler) Register(r *gin.Engine) {
r.GET("/login", h.HandleLoginPage)
r.POST("/login", h.HandleLogin)
r.GET("/logout", h.HandleLogout)
}
func (h *AuthHandler) HandleLoginPage(c *gin.Context) {
c.HTML(http.StatusOK, "login.gohtml", gin.H{
"CurrentPath": "/login",
})
}
func (h *AuthHandler) HandleLogin(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
session, err := h.svc.Login(c.Request.Context(), username, password)
if err != nil {
c.HTML(http.StatusUnauthorized, "login.gohtml", gin.H{
"Error": "Invalid username or password",
"CurrentPath": "/login",
})
return
}
c.SetCookie("session_id", session.ID, int(24*time.Hour.Seconds()), "/", "", false, true)
if c.GetHeader("HX-Request") == "true" {
c.Header("HX-Redirect", "/")
c.Status(http.StatusOK)
return
}
c.Redirect(http.StatusSeeOther, "/")
}
func (h *AuthHandler) HandleLogout(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err == nil {
_ = h.svc.Logout(c.Request.Context(), sessionID)
}
c.SetCookie("session_id", "", -1, "/", "", false, true)
c.Redirect(http.StatusSeeOther, "/login")
}

View File

@@ -0,0 +1,37 @@
package middleware
import (
"mal/internal/domain"
"net/http"
"github.com/gin-gonic/gin"
)
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
// Allow access to login, logout and static assets without authentication
if c.Request.URL.Path == "/login" || c.Request.URL.Path == "/logout" ||
len(c.Request.URL.Path) >= 7 && c.Request.URL.Path[:7] == "/static" ||
len(c.Request.URL.Path) >= 5 && c.Request.URL.Path[:5] == "/dist" {
c.Next()
return
}
sessionID, err := c.Cookie("session_id")
if err != nil {
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}
user, err := svc.ValidateSession(c.Request.Context(), sessionID)
if err != nil {
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}
c.Set("User", user)
c.Next()
}
}

29
internal/auth/module.go Normal file
View File

@@ -0,0 +1,29 @@
package auth
import (
"mal/internal/auth/handler"
"mal/internal/auth/middleware"
"mal/internal/auth/repository"
"mal/internal/auth/service"
"mal/internal/domain"
"mal/internal/server"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(
repository.NewAuthRepository,
service.NewAuthService,
handler.NewAuthHandler,
func(svc domain.AuthService) gin.HandlerFunc {
return middleware.AuthMiddleware(svc)
},
),
fx.Provide(
server.AsRouteRegister(func(h *handler.AuthHandler) server.RouteRegister {
return h
}),
),
)

View File

@@ -0,0 +1,67 @@
package repository
import (
"context"
"database/sql"
"errors"
"mal/internal/db"
"mal/internal/domain"
"time"
)
type authRepository struct {
queries *db.Queries
}
func NewAuthRepository(queries *db.Queries) domain.AuthRepository {
return &authRepository{queries: queries}
}
func (r *authRepository) GetUserByUsername(ctx context.Context, username string) (*domain.User, error) {
u, err := r.queries.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &u, nil
}
func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) {
u, err := r.queries.GetUser(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &u, nil
}
func (r *authRepository) CreateSession(ctx context.Context, userID string, sessionID string) (*domain.Session, error) {
s, err := r.queries.CreateSession(ctx, db.CreateSessionParams{
ID: sessionID,
UserID: userID,
ExpiresAt: time.Now().Add(24 * time.Hour),
})
if err != nil {
return nil, err
}
return &s, nil
}
func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) {
s, err := r.queries.GetSession(ctx, sessionID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &s, nil
}
func (r *authRepository) DeleteSession(ctx context.Context, sessionID string) error {
return r.queries.DeleteSession(ctx, sessionID)
}

View File

@@ -0,0 +1,57 @@
package service
import (
"context"
"errors"
"mal/internal/domain"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type authService struct {
repo domain.AuthRepository
}
func NewAuthService(repo domain.AuthRepository) domain.AuthService {
return &authService{repo: repo}
}
func (s *authService) Login(ctx context.Context, username, password string) (*domain.Session, error) {
user, err := s.repo.GetUserByUsername(ctx, username)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("invalid credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
sessionID := uuid.New().String()
return s.repo.CreateSession(ctx, user.ID, sessionID)
}
func (s *authService) ValidateSession(ctx context.Context, sessionID string) (*domain.User, error) {
session, err := s.repo.GetSession(ctx, sessionID)
if err != nil {
return nil, err
}
if session == nil {
return nil, errors.New("session not found")
}
if session.ExpiresAt.Before(time.Now()) {
_ = s.repo.DeleteSession(ctx, sessionID)
return nil, errors.New("session expired")
}
return s.repo.GetUserByID(ctx, session.UserID)
}
func (s *authService) Logout(ctx context.Context, sessionID string) error {
return s.repo.DeleteSession(ctx, sessionID)
}

View File

@@ -1,9 +0,0 @@
package context
// UserKey is the context key for storing the authenticated user.
// It is unexported to prevent collisions.
type key int
const (
UserKey key = iota
)

View File

@@ -0,0 +1,51 @@
package database
import (
"database/sql"
"embed"
"fmt"
"log"
"mal/internal/db"
"github.com/pressly/goose/v3"
"go.uber.org/fx"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
var Module = fx.Options(
fx.Provide(
ProvideSQLDB,
ProvideQueries,
),
fx.Invoke(RunMigrations),
)
func ProvideSQLDB() (*sql.DB, error) {
dbPath := db.GetDBFile()
dbConn, err := db.Open(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return dbConn, nil
}
func ProvideQueries(sqlDB *sql.DB) *db.Queries {
return db.New(sqlDB)
}
func RunMigrations(sqlDB *sql.DB) error {
goose.SetBaseFS(migrationsFS)
if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
log.Println("Running database migrations...")
if err := goose.Up(sqlDB, "migrations"); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
}

View File

@@ -1,3 +1,4 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
@@ -40,3 +41,10 @@ CREATE TABLE IF NOT EXISTS watch_list_entry (
current_time_seconds REAL NOT NULL DEFAULT 0, current_time_seconds REAL NOT NULL DEFAULT 0,
UNIQUE(user_id, anime_id) UNIQUE(user_id, anime_id)
); );
-- +goose Down
DROP TABLE IF EXISTS watch_list_entry;
DROP TABLE IF EXISTS anime;
DROP TABLE IF EXISTS account;
DROP TABLE IF EXISTS session;
DROP TABLE IF EXISTS user;

View File

@@ -1,6 +1,9 @@
-- +goose Up
-- Add English and Japanese title columns to anime table -- Add English and Japanese title columns to anime table
ALTER TABLE anime ADD COLUMN title_english TEXT; ALTER TABLE anime ADD COLUMN title_english TEXT;
ALTER TABLE anime ADD COLUMN title_japanese TEXT; ALTER TABLE anime ADD COLUMN title_japanese TEXT;
-- Rename existing title to title_original for clarity -- Rename existing title to title_original for clarity
ALTER TABLE anime RENAME COLUMN title TO title_original; ALTER TABLE anime RENAME COLUMN title TO title_original;
-- +goose Down

View File

@@ -1,2 +1,5 @@
-- +goose Up
-- Add airing status column to anime table -- Add airing status column to anime table
ALTER TABLE anime ADD COLUMN airing BOOLEAN DEFAULT 0; ALTER TABLE anime ADD COLUMN airing BOOLEAN DEFAULT 0;
-- +goose Down

View File

@@ -1,3 +1,4 @@
-- +goose Up
-- Note: watch_list_entry columns now in 001_init.sql -- Note: watch_list_entry columns now in 001_init.sql
-- Add notification preferences -- Add notification preferences
@@ -8,3 +9,5 @@ CREATE TABLE IF NOT EXISTS notification_preference (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id) UNIQUE(user_id)
); );
-- +goose Down

View File

@@ -1,3 +1,4 @@
-- +goose Up
ALTER TABLE anime ADD COLUMN status TEXT DEFAULT ''; ALTER TABLE anime ADD COLUMN status TEXT DEFAULT '';
ALTER TABLE anime ADD COLUMN relations_synced_at DATETIME; ALTER TABLE anime ADD COLUMN relations_synced_at DATETIME;
@@ -7,3 +8,5 @@ CREATE TABLE IF NOT EXISTS anime_relation (
relation_type TEXT NOT NULL, relation_type TEXT NOT NULL,
PRIMARY KEY (anime_id, related_anime_id) PRIMARY KEY (anime_id, related_anime_id)
); );
-- +goose Down

View File

@@ -1,6 +1,9 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS jikan_cache ( CREATE TABLE IF NOT EXISTS jikan_cache (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
data TEXT NOT NULL, data TEXT NOT NULL,
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
-- +goose Down

View File

@@ -1,3 +1,4 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_watch_list_entry_user_status_updated_at CREATE INDEX IF NOT EXISTS idx_watch_list_entry_user_status_updated_at
ON watch_list_entry(user_id, status, updated_at); ON watch_list_entry(user_id, status, updated_at);
@@ -9,3 +10,5 @@ ON anime(relations_synced_at, status);
CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at
ON jikan_cache(expires_at); ON jikan_cache(expires_at);
-- +goose Down

View File

@@ -1,3 +1,4 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS anime_fetch_retry ( CREATE TABLE IF NOT EXISTS anime_fetch_retry (
anime_id INTEGER PRIMARY KEY, anime_id INTEGER PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
@@ -9,3 +10,5 @@ CREATE TABLE IF NOT EXISTS anime_fetch_retry (
CREATE INDEX IF NOT EXISTS idx_anime_fetch_retry_next_retry_at CREATE INDEX IF NOT EXISTS idx_anime_fetch_retry_next_retry_at
ON anime_fetch_retry(next_retry_at); ON anime_fetch_retry(next_retry_at);
-- +goose Down

View File

@@ -0,0 +1,3 @@
-- +goose Up
-- Note: watch_list_entry columns now in 001_init.sql
-- +goose Down

View File

@@ -1,3 +1,4 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS continue_watching_entry ( CREATE TABLE IF NOT EXISTS continue_watching_entry (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
@@ -11,3 +12,5 @@ CREATE TABLE IF NOT EXISTS continue_watching_entry (
CREATE INDEX IF NOT EXISTS idx_continue_watching_user_updated CREATE INDEX IF NOT EXISTS idx_continue_watching_user_updated
ON continue_watching_entry(user_id, updated_at DESC); ON continue_watching_entry(user_id, updated_at DESC);
-- +goose Down

View File

@@ -1,7 +1,6 @@
-- +goose Up
PRAGMA foreign_keys = OFF; PRAGMA foreign_keys = OFF;
BEGIN TRANSACTION;
CREATE TABLE user_new ( CREATE TABLE user_new (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
@@ -17,6 +16,6 @@ DROP TABLE user;
ALTER TABLE user_new RENAME TO user; ALTER TABLE user_new RENAME TO user;
COMMIT;
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
-- +goose Down

View File

@@ -0,0 +1,4 @@
-- +goose Up
DROP TABLE IF EXISTS account;
DROP TABLE IF EXISTS notification_preference;
-- +goose Down

View File

@@ -1,3 +1,4 @@
-- +goose Up
-- Add "watching" and "on_hold" to the valid statuses for watch_list_entry -- Add "watching" and "on_hold" to the valid statuses for watch_list_entry
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
@@ -24,3 +25,5 @@ FROM watch_list_entry_old;
DROP TABLE watch_list_entry_old; DROP TABLE watch_list_entry_old;
PRAGMA foreign_keys=ON; PRAGMA foreign_keys=ON;
-- +goose Down

View File

@@ -1,5 +1,7 @@
-- +goose Up
-- Add duration column to anime table to store episode duration in seconds -- Add duration column to anime table to store episode duration in seconds
ALTER TABLE anime ADD COLUMN duration_seconds REAL; ALTER TABLE anime ADD COLUMN duration_seconds REAL;
-- Add duration_seconds column to continue_watching_entry to track episode duration -- Add duration_seconds column to continue_watching_entry to track episode duration
ALTER TABLE continue_watching_entry ADD COLUMN duration_seconds REAL; ALTER TABLE continue_watching_entry ADD COLUMN duration_seconds REAL;
-- +goose Down

View File

@@ -1,3 +1,5 @@
-- +goose Up
ALTER TABLE user ADD COLUMN avatar_url TEXT NOT NULL DEFAULT ''; ALTER TABLE user ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';
UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = ''; UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = '';
-- +goose Down

View File

@@ -1,11 +1,6 @@
package db package db
import ( import "database/sql"
"context"
"database/sql"
"errors"
"fmt"
)
// NullStringOr returns n.String if valid and non-empty, otherwise fallback // NullStringOr returns n.String if valid and non-empty, otherwise fallback
func NullStringOr(n sql.NullString, fallback string) string { func NullStringOr(n sql.NullString, fallback string) string {
@@ -23,17 +18,3 @@ func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal stri
func (r GetUserWatchListRow) DisplayTitle() string { func (r GetUserWatchListRow) DisplayTitle() string {
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal) return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
} }
// BeginTx starts a transaction and returns the Queries wrapper bound to it
func BeginTx(ctx context.Context, db *sql.DB) (*Queries, *sql.Tx, error) {
if db == nil {
return nil, nil, errors.New("database unavailable")
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to begin transaction: %w", err)
}
return New(tx), tx, nil
}

View File

@@ -1,118 +0,0 @@
package db
import (
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
)
// RunMigrations applies all *.sql files in migrationsDir in sorted order,
// skipping any already recorded in migration_version.
func RunMigrations(db *sql.DB, migrationsDir string) error {
if migrationsDir == "" {
return fmt.Errorf("migrations directory is required")
}
// Create migration tracking table
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS migration_version (
name TEXT PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
migrations, err := filepath.Glob(filepath.Join(migrationsDir, "*.sql"))
if err != nil {
return err
}
if len(migrations) == 0 {
return fmt.Errorf("no migration files found in %s", migrationsDir)
}
sort.Strings(migrations)
appliedNames, err := loadAppliedMigrationNames(db)
if err != nil {
return err
}
for _, migrationFile := range migrations {
migrationName := filepath.Base(migrationFile)
if migrationApplied(appliedNames, migrationName) {
continue // already applied
}
migrationSQL, err := os.ReadFile(migrationFile)
if err != nil {
return err
}
if _, err := db.Exec(string(migrationSQL)); err != nil {
return err // stop on first failure
}
// record applied migration
_, err = db.Exec("INSERT INTO migration_version (name) VALUES (?)", migrationName)
if err != nil {
return err
}
appliedNames[migrationName] = struct{}{}
log.Printf("migration %s applied successfully", migrationName)
}
return nil
}
func loadAppliedMigrationNames(db *sql.DB) (map[string]struct{}, error) {
rows, err := db.Query("SELECT name FROM migration_version")
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
applied := make(map[string]struct{})
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
applied[name] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, err
}
return applied, nil
}
// migrationApplied checks the applied names map for a match,
// including legacy paths and case-insensitive basename matches.
func migrationApplied(appliedNames map[string]struct{}, migrationName string) bool {
if _, exists := appliedNames[migrationName]; exists {
return true
}
legacyName := filepath.ToSlash(filepath.Join("migrations", migrationName))
if _, exists := appliedNames[legacyName]; exists {
return true
}
for appliedName := range appliedNames {
if strings.EqualFold(filepath.Base(appliedName), migrationName) {
return true
}
}
return false
}

View File

@@ -4,7 +4,6 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"os" "os"
"path/filepath"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -25,27 +24,3 @@ func GetDBFile() string {
} }
return "mal.db" return "mal.db"
} }
// GetMigrationsDir returns the migrations directory, checking MIGRATIONS_DIR env var first
func GetMigrationsDir() (string, error) {
if dir := os.Getenv("MIGRATIONS_DIR"); dir != "" {
return dir, nil
}
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
return filepath.Join(wd, "migrations"), nil
}
// Init opens the database, runs migrations, and returns a Queries instance
func Init(db *sql.DB) (*Queries, error) {
migrationsDir, err := GetMigrationsDir()
if err != nil {
return nil, err
}
if err := RunMigrations(db, migrationsDir); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
return New(db), nil
}

33
internal/domain/anime.go Normal file
View File

@@ -0,0 +1,33 @@
package domain
import (
"context"
"mal/integrations/jikan"
"mal/internal/db"
)
type Anime = jikan.Anime
type TopAnimeResult = jikan.TopAnimeResult
type Genre = jikan.Genre
type Character = jikan.CharacterEntry
type Recommendation = jikan.RecommendationEntry
type AnimeService interface {
GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error)
GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error)
GetAnimeByID(ctx context.Context, id int) (Anime, error)
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetGenres(ctx context.Context) ([]Genre, error)
GetCharacters(ctx context.Context, id int) ([]Character, error)
GetRecommendations(ctx context.Context, id int) ([]Recommendation, error)
GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error)
GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error)
GetAllEpisodes(ctx context.Context, id int) ([]EpisodeData, error)
GetRandomAnime(ctx context.Context) (Anime, error)
}
type AnimeRepository interface {
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error)
}

23
internal/domain/auth.go Normal file
View File

@@ -0,0 +1,23 @@
package domain
import (
"context"
"mal/internal/db"
)
type User = db.User
type Session = db.Session
type AuthService interface {
Login(ctx context.Context, username, password string) (*Session, error)
ValidateSession(ctx context.Context, sessionID string) (*User, error)
Logout(ctx context.Context, sessionID string) error
}
type AuthRepository interface {
GetUserByUsername(ctx context.Context, username string) (*User, error)
GetUserByID(ctx context.Context, id string) (*User, error)
CreateSession(ctx context.Context, userID string, sessionID string) (*Session, error)
GetSession(ctx context.Context, sessionID string) (*Session, error)
DeleteSession(ctx context.Context, sessionID string) error
}

View File

@@ -0,0 +1,41 @@
package domain
import (
"context"
"mal/internal/db"
)
type PlaybackService interface {
BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error)
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error
CompleteAnime(ctx context.Context, userID string, animeID int64) error
ResolveProxyToken(token string) (string, string, error)
}
type ProviderStream struct {
Name string `json:"name"`
URL string `json:"url"`
Quality string `json:"quality"`
MalID int `json:"mal_id"`
IsCurrent bool `json:"is_current"`
}
type ProviderData struct {
Streams []ProviderStream `json:"streams"`
}
type EpisodeData struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
IsFiller bool `json:"is_filler"`
IsRecap bool `json:"is_recap"`
}
type PlaybackRepository interface {
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error
UpsertWatchListEntry(ctx context.Context, params db.UpsertWatchListEntryParams) (db.WatchListEntry, error)
UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error
}

View File

@@ -0,0 +1,27 @@
package domain
import (
"context"
)
type StreamSource struct {
URL string
Quality string
}
type StreamResult struct {
URL string
Referer string
Subtitles []Subtitle
Qualities []StreamSource
}
type Subtitle struct {
URL string
Label string
}
type Provider interface {
Name() string
GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*StreamResult, error)
}

View File

@@ -0,0 +1,30 @@
package domain
import (
"context"
"mal/internal/db"
)
type WatchlistEntry = db.WatchListEntry
type UserWatchListRow = db.GetUserWatchListRow
type WatchlistService interface {
UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error
RemoveEntry(ctx context.Context, userID string, animeID int64) error
GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error)
GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error)
GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error)
DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error
}
type WatchlistRepository interface {
UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error)
GetAnime(ctx context.Context, id int64) (db.Anime, error)
UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error)
DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error
SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error
}

View File

@@ -1,64 +0,0 @@
package middleware
import (
"net/http"
"strings"
)
type AccessPolicy struct {
PublicPaths map[string]struct{} // exact match paths (e.g. /login)
PublicHeads []string // prefix match paths (e.g. /static/)
}
func NewAccessPolicy() AccessPolicy {
return AccessPolicy{
PublicPaths: map[string]struct{}{
"/login": {}, // login page is public
},
PublicHeads: []string{
"/static/", // static assets
"/dist/", // bundled assets
},
}
}
func (p AccessPolicy) IsPublicPath(path string) bool {
if _, ok := p.PublicPaths[path]; ok {
return true
}
for _, head := range p.PublicHeads {
if strings.HasPrefix(path, head) {
return true
}
}
return false
}
// RequireGlobalAuthWithPolicy redirects unauthenticated users to /login
// uses HX-Redirect for HTMX requests, regular redirect otherwise
func RequireGlobalAuthWithPolicy(policy AccessPolicy) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if policy.IsPublicPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
user := GetUser(r.Context())
ok := user != nil
if !ok || user == nil {
if strings.HasPrefix(r.URL.Path, "/api/") || r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -1,41 +0,0 @@
package middleware
import (
"context"
"net/http"
"mal/api/auth"
ctxpkg "mal/internal/context"
"mal/internal/db"
)
// Auth middleware validates the session cookie and injects the user into context
func Auth(authService *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err != nil {
next.ServeHTTP(w, r) // no cookie, proceed unauthenticated
return
}
user, err := authService.ValidateSession(r.Context(), cookie.Value)
if err != nil {
next.ServeHTTP(w, r) // invalid session, proceed unauthenticated
return
}
ctx := context.WithValue(r.Context(), ctxpkg.UserKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// GetUser retrieves the authenticated user from context, or nil if not authenticated
func GetUser(ctx context.Context) *db.User {
user, ok := ctx.Value(ctxpkg.UserKey).(*db.User)
if !ok {
return nil
}
return user
}

View File

@@ -0,0 +1,300 @@
package handler
import (
"fmt"
"io"
"log"
"mal/internal/domain"
"mal/pkg/net/proxytransport"
"net/http"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
type PlaybackHandler struct {
svc domain.PlaybackService
animeSvc domain.AnimeService
proxyClient *http.Client
streamingClient *http.Client
subtitleCache sync.Map
}
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler {
return &PlaybackHandler{
svc: svc,
animeSvc: animeSvc,
proxyClient: proxytransport.NewClient(),
streamingClient: proxytransport.NewStreamingClient(),
}
}
func (h *PlaybackHandler) Register(r *gin.Engine) {
log.Println("Registering playback routes")
r.GET("/anime/:id/watch", h.HandleWatchPage)
r.POST("/api/watch-progress", h.HandleSaveProgress)
r.POST("/api/watch-complete", h.HandleWatchComplete)
r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails)
r.GET("/watch/proxy/stream", h.HandleProxyStream)
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
}
func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
log.Printf("Route /anime/:id/watch triggered for ID: %s", c.Param("id"))
id, _ := strconv.Atoi(c.Param("id"))
ep := c.DefaultQuery("ep", "1")
mode := c.DefaultQuery("mode", "sub")
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID)
if err != nil {
log.Printf("BuildWatchData failed for ID %d: %v", id, err)
// Try to at least get anime info for the error page
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
c.HTML(http.StatusOK, "watch.gohtml", gin.H{
"Error": err.Error(),
"Anime": anime,
"Episodes": []domain.EpisodeData{},
"CurrentPath": c.Request.URL.Path,
"User": user,
"CurrentEpID": ep,
"WatchData": map[string]any{"Episodes": []domain.EpisodeData{}, "Providers": []any{}},
})
return
}
log.Printf("BuildWatchData succeeded for ID %d", id)
// Merge data from service with handler-specific context
responseData := gin.H{
"User": user,
"CurrentPath": c.Request.URL.Path,
}
for k, v := range data {
responseData[k] = v
}
c.HTML(http.StatusOK, "watch.gohtml", responseData)
log.Printf("c.HTML finished for ID %d", id)
}
func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
var req struct {
MalID int64 `json:"mal_id"`
Episode int `json:"episode"`
TimeSeconds float64 `json:"time_seconds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.Status(http.StatusBadRequest)
return
}
err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
}
func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
var req struct {
MalID int64 `json:"mal_id"`
Episode int `json:"episode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.Status(http.StatusBadRequest)
return
}
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
}
func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
id, err := strconv.Atoi(c.Param("animeId"))
if err != nil {
c.Status(http.StatusBadRequest)
return
}
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
if err != nil {
log.Printf("failed to fetch thumbnails/episodes: %v", err)
}
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
if anime.Episodes > 0 && anime.Episodes > len(allEpisodes) {
epMap := make(map[int]domain.EpisodeData)
for _, ep := range allEpisodes {
epMap[ep.MalID] = ep
}
var filled []domain.EpisodeData
for i := 1; i <= anime.Episodes; i++ {
if ep, ok := epMap[i]; ok {
filled = append(filled, ep)
} else {
filled = append(filled, domain.EpisodeData{
MalID: i,
Title: fmt.Sprintf("Episode %d", i),
})
}
}
allEpisodes = filled
}
type Result struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
}
results := make([]Result, len(allEpisodes))
for i, ep := range allEpisodes {
results[i] = Result{
MalID: ep.MalID,
Title: ep.Title,
}
}
c.JSON(http.StatusOK, results)
}
func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.Status(http.StatusBadRequest)
return
}
targetURL, referer, err := h.svc.ResolveProxyToken(token)
if err != nil {
log.Printf("proxy token error: %v", err)
c.Status(http.StatusForbidden)
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, targetURL, nil)
if err != nil {
c.Status(http.StatusBadGateway)
return
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0")
resp, err := h.streamingClient.Do(req)
if err != nil {
log.Printf("proxy stream fetch error: %v", err)
c.Status(http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
for k, v := range resp.Header {
c.Header(k, v[0])
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
type cachedSubtitle struct {
data []byte
contentType string
}
func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.Status(http.StatusBadRequest)
return
}
targetURL, referer, err := h.svc.ResolveProxyToken(token)
if err != nil {
log.Printf("proxy subtitle token error: %v", err)
c.Status(http.StatusForbidden)
return
}
if cached, ok := h.subtitleCache.Load(targetURL); ok {
entry := cached.(cachedSubtitle)
c.Data(http.StatusOK, entry.contentType, entry.data)
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, targetURL, nil)
if err != nil {
c.Status(http.StatusBadGateway)
return
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0")
resp, err := h.proxyClient.Do(req)
if err != nil {
log.Printf("proxy subtitle fetch error: %v", err)
c.Status(http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
log.Printf("proxy subtitle read error: %v", err)
c.Status(http.StatusBadGateway)
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = detectSubtitleType(targetURL)
}
h.subtitleCache.Store(targetURL, cachedSubtitle{data: body, contentType: contentType})
c.Data(http.StatusOK, contentType, body)
}
func detectSubtitleType(url string) string {
lower := strings.ToLower(url)
switch {
case strings.Contains(lower, ".vtt"):
return "text/vtt"
case strings.Contains(lower, ".srt"):
return "text/plain; charset=utf-8"
case strings.Contains(lower, ".ass") || strings.Contains(lower, ".ssa"):
return "text/plain; charset=utf-8"
default:
return "text/plain; charset=utf-8"
}
}

View File

@@ -0,0 +1,43 @@
package playback
import (
"os"
"mal/integrations/jikan"
"mal/integrations/playback/allanime"
"mal/internal/domain"
"mal/internal/playback/handler"
"mal/internal/playback/repository"
"mal/internal/playback/service"
"mal/internal/server"
"go.uber.org/fx"
)
func provideProxyTokenKey() string {
return os.Getenv("PLAYBACK_PROXY_SECRET")
}
var Module = fx.Options(
fx.Provide(
repository.NewPlaybackRepository,
fx.Annotate(
func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, proxyTokenKey string) domain.PlaybackService {
return service.NewPlaybackService(repo, providers, jikan, proxyTokenKey)
},
fx.ParamTags(``, ``, ``, ``),
),
func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler {
return handler.NewPlaybackHandler(svc, animeSvc)
},
),
fx.Provide(
server.AsRouteRegister(func(h *handler.PlaybackHandler) server.RouteRegister {
return h
}),
),
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.Provider {
return []domain.Provider{p}
}),
fx.Provide(provideProxyTokenKey),
)

View File

@@ -0,0 +1,39 @@
package repository
import (
"context"
"mal/internal/db"
"mal/internal/domain"
)
type playbackRepository struct {
queries *db.Queries
}
func NewPlaybackRepository(queries *db.Queries) domain.PlaybackRepository {
return &playbackRepository{queries: queries}
}
func (r *playbackRepository) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.GetWatchListEntry(ctx, params)
}
func (r *playbackRepository) GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) {
return r.queries.GetContinueWatchingEntry(ctx, params)
}
func (r *playbackRepository) SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error {
return r.queries.SaveWatchProgress(ctx, params)
}
func (r *playbackRepository) UpsertWatchListEntry(ctx context.Context, params db.UpsertWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.UpsertWatchListEntry(ctx, params)
}
func (r *playbackRepository) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) {
return r.queries.UpsertContinueWatchingEntry(ctx, params)
}
func (r *playbackRepository) DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error {
return r.queries.DeleteContinueWatchingEntry(ctx, params)
}

View File

@@ -0,0 +1,457 @@
package service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
type playbackService struct {
repo domain.PlaybackRepository
providers []domain.Provider
jikan *jikan.Client
httpClient *http.Client
proxyTokenKey string
}
type SkipSegment struct {
Type string `json:"type"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
type proxyTokenPayload struct {
TargetURL string `json:"u"`
Referer string `json:"r,omitempty"`
Scope string `json:"s"`
ExpiresAt int64 `json:"exp"`
}
func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client, proxyTokenKey string) domain.PlaybackService {
return &playbackService{repo: repo, providers: providers, jikan: jikan, httpClient: &http.Client{Timeout: 10 * time.Second}, proxyTokenKey: proxyTokenKey}
}
func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (string, error) {
if s.proxyTokenKey == "" {
return "", nil
}
payload := proxyTokenPayload{
TargetURL: targetURL,
Referer: referer,
Scope: scope,
ExpiresAt: time.Now().Add(2 * time.Hour).Unix(),
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
mac.Write(body)
signature := mac.Sum(nil)
encodedBody := base64.RawURLEncoding.EncodeToString(body)
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
return encodedBody + "." + encodedSignature, nil
}
func (s *playbackService) VerifyProxyToken(token string) (proxyTokenPayload, error) {
if s.proxyTokenKey == "" {
return proxyTokenPayload{}, fmt.Errorf("proxy token key not configured")
}
parts := strings.Split(token, ".")
if len(parts) != 2 {
return proxyTokenPayload{}, fmt.Errorf("invalid token format")
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return proxyTokenPayload{}, err
}
mac := hmac.New(sha256.New, []byte(s.proxyTokenKey))
mac.Write(body)
signature := mac.Sum(nil)
encodedSig := base64.RawURLEncoding.EncodeToString(signature)
if encodedSig != parts[1] {
return proxyTokenPayload{}, fmt.Errorf("invalid signature")
}
var payload proxyTokenPayload
if err := json.Unmarshal(body, &payload); err != nil {
return proxyTokenPayload{}, err
}
if payload.ExpiresAt < time.Now().Unix() {
return proxyTokenPayload{}, fmt.Errorf("token expired")
}
return payload, nil
}
func (s *playbackService) ResolveProxyToken(token string) (string, string, error) {
payload, err := s.VerifyProxyToken(token)
if err != nil {
return "", "", err
}
return payload.TargetURL, payload.Referer, nil
}
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) {
// 1. Get Anime details for total episodes and titles
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
if err != nil {
return nil, fmt.Errorf("failed to fetch anime: %w", err)
}
// 2. Resolve streams from providers
searchTitles := []string{anime.Title}
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
searchTitles = append(searchTitles, anime.TitleEnglish)
}
if anime.TitleJapanese != "" {
searchTitles = append(searchTitles, anime.TitleJapanese)
}
for _, syn := range anime.TitleSynonyms {
if syn != "" && syn != anime.Title && syn != anime.TitleEnglish && syn != anime.TitleJapanese {
searchTitles = append(searchTitles, syn)
}
}
type SubtitleItem struct {
Lang string `json:"lang"`
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
}
type ModeSource struct {
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
Subtitles []SubtitleItem `json:"subtitles"`
Qualities []string `json:"qualities,omitempty"`
}
modeSources := map[string]ModeSource{}
var result *domain.StreamResult
for _, m := range []string{"sub", "dub"} {
for _, p := range s.providers {
res, err := p.GetStreams(ctx, animeID, searchTitles, episode, m)
if err != nil || res == nil {
continue
}
var subItems []SubtitleItem
for _, sub := range res.Subtitles {
subToken, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
subItems = append(subItems, SubtitleItem{
Lang: sub.Label,
Token: subToken,
})
}
streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream")
modeSources[m] = ModeSource{
URL: res.URL,
Referer: res.Referer,
Token: streamToken,
Subtitles: subItems,
}
if m == mode {
result = res
}
break
}
}
if len(modeSources) == 0 {
return nil, fmt.Errorf("no streams found")
}
if result == nil {
return nil, fmt.Errorf("no streams found for mode %s", mode)
}
// 3. Get start time from progress
startTime := 0.0
var watchlistStatus string
var watchlistIDs []int64
if userID != "" {
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: int64(animeID),
})
if err == nil {
watchlistStatus = entry.Status
watchlistIDs = []int64{entry.AnimeID}
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode {
startTime = entry.CurrentTimeSeconds
}
}
// Fall back to continue_watching_entry for progress if not in watchlist
if startTime == 0 {
cwEntry, err := s.repo.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{
UserID: userID,
AnimeID: int64(animeID),
})
if err == nil && cwEntry.CurrentEpisode.Valid && strconv.FormatInt(cwEntry.CurrentEpisode.Int64, 10) == episode {
startTime = cwEntry.CurrentTimeSeconds
}
}
}
// 4. Get Episodes list
jikanEpisodes, err := s.jikan.GetAllEpisodes(ctx, animeID)
if err != nil {
log.Printf("failed to fetch episodes from jikan: %v", err)
}
// Fallback/Fill episodes if needed
totalCount := anime.Episodes
if len(jikanEpisodes) < totalCount {
epMap := make(map[int]jikan.Episode)
for _, ep := range jikanEpisodes {
epMap[ep.MalID] = ep
}
for i := 1; i <= totalCount; i++ {
if _, ok := epMap[i]; !ok {
jikanEpisodes = append(jikanEpisodes, jikan.Episode{
MalID: i,
Episode: fmt.Sprintf("Episode %d", i),
Title: fmt.Sprintf("Episode %d", i),
})
}
}
}
sort.Slice(jikanEpisodes, func(i, j int) bool {
return jikanEpisodes[i].MalID < jikanEpisodes[j].MalID
})
domainEpisodes := make([]domain.EpisodeData, len(jikanEpisodes))
for i, ep := range jikanEpisodes {
domainEpisodes[i] = domain.EpisodeData{
MalID: ep.MalID,
Title: ep.Title,
IsFiller: ep.Filler,
IsRecap: ep.Recap,
}
}
// 5. Build provider data
streams := []domain.ProviderStream{
{
Name: "Primary",
URL: result.URL,
Quality: "Auto",
MalID: animeID,
IsCurrent: true,
},
}
go s.warmStreamURL(result.URL, result.Referer)
// 6. Resolve relations/seasons
relations, _ := s.jikan.GetFullRelations(ctx, animeID)
type SeasonEntry struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
Prefix string `json:"prefix"`
IsCurrent bool `json:"is_current"`
}
var seasons []SeasonEntry
tvCounter := 1
for _, rel := range relations {
if strings.ToLower(rel.Anime.Type) == "tv" || strings.ToLower(rel.Anime.Type) == "movie" {
seasons = append(seasons, SeasonEntry{
MalID: rel.Anime.MalID,
Title: rel.Anime.DisplayTitle(),
Prefix: rel.Relation,
IsCurrent: rel.IsCurrent,
})
if rel.Relation == "TV" {
seasons[len(seasons)-1].Prefix = fmt.Sprintf("S%d", tvCounter)
tvCounter++
}
}
}
// Final assembly
segments := s.fetchSkipSegments(ctx, animeID, episode)
watchData := map[string]any{
"MalID": animeID,
"Title": anime.DisplayTitle(),
"CurrentEpisode": episode,
"StartTimeSeconds": startTime,
"Episodes": domainEpisodes,
"Providers": []domain.ProviderData{
{Streams: streams},
},
"ModeSources": modeSources,
"InitialMode": mode,
"AvailableModes": func() []string {
var modes []string
for m := range modeSources {
modes = append(modes, m)
}
sort.Strings(modes)
return modes
}(),
"Segments": segments,
}
return map[string]any{
"WatchData": watchData,
"Anime": anime,
"Episodes": domainEpisodes,
"CurrentEpID": episode,
"WatchlistStatus": watchlistStatus,
"WatchlistIDs": watchlistIDs,
"Seasons": seasons,
}, nil
}
func (s *playbackService) CompleteAnime(ctx context.Context, userID string, animeID int64) error {
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if err != nil || entry.Status != "completed" {
_, err = s.repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: "completed",
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
})
if err != nil {
return err
}
}
_ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
})
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
})
}
func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error {
_, err := s.repo.UpsertContinueWatchingEntry(ctx, db.UpsertContinueWatchingEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true},
CurrentTimeSeconds: timeSeconds,
DurationSeconds: sql.NullFloat64{Valid: false},
})
return err
}
func (s *playbackService) fetchSkipSegments(ctx context.Context, malID int, episode string) []SkipSegment {
if malID <= 0 || strings.TrimSpace(episode) == "" {
return []SkipSegment{}
}
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return []SkipSegment{}
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := s.httpClient.Do(req)
if err != nil {
return []SkipSegment{}
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return []SkipSegment{}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil {
return []SkipSegment{}
}
type resultItem struct {
SkipType string `json:"skip_type"`
Interval struct {
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
} `json:"interval"`
}
type apiResponse struct {
Found bool `json:"found"`
Result []resultItem `json:"results"`
}
var parsed apiResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return []SkipSegment{}
}
if !parsed.Found || len(parsed.Result) == 0 {
return []SkipSegment{}
}
segments := make([]SkipSegment, 0, len(parsed.Result))
for _, r := range parsed.Result {
skipType := strings.ToLower(r.SkipType)
switch skipType {
case "op":
skipType = "opening"
case "ed":
skipType = "ending"
}
segments = append(segments, SkipSegment{
Type: skipType,
Start: r.Interval.StartTime,
End: r.Interval.EndTime,
})
}
return segments
}
func (s *playbackService) warmStreamURL(targetURL, referer string) {
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
if err != nil {
return
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := s.httpClient.Do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}

View File

@@ -1,162 +0,0 @@
package server
import (
"database/sql"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"mal/api/anime"
"mal/api/auth"
"mal/api/playback"
"mal/api/watchlist"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/middleware"
pkgmiddleware "mal/pkg/middleware"
)
type Config struct {
DB *db.Queries
SQLDB *sql.DB
JikanClient *jikan.Client
AuthService *auth.Service
AuthLimiter *pkgmiddleware.Limiter
PlaybackProxySecret string
}
// withMimeTypes sets Content-Type for common static asset extensions
func withMimeTypes(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ext := strings.ToLower(filepath.Ext(r.URL.Path))
switch ext {
case ".js":
w.Header().Set("Content-Type", "application/javascript")
case ".css":
w.Header().Set("Content-Type", "text/css")
case ".svg":
w.Header().Set("Content-Type", "image/svg+xml")
case ".json":
w.Header().Set("Content-Type", "application/json")
}
next.ServeHTTP(w, r)
})
}
// noCache sends headers to prevent caching of dynamic/static assets
func noCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
next.ServeHTTP(w, r)
})
}
// NewAuthLimiter returns a rate limiter for auth endpoints: 5 attempts per minute
func NewAuthLimiter() *pkgmiddleware.Limiter {
return pkgmiddleware.NewLimiter(pkgmiddleware.Config{
MaxAttempts: 5,
Window: time.Minute,
})
}
// NewRouter wires up all HTTP handlers and middleware.
// Auth is enforced globally; public routes must opt-out via middleware policy.
func NewRouter(cfg Config) http.Handler {
mux := http.NewServeMux()
authHandler := auth.NewHandler(cfg.AuthService)
watchlistSvc := watchlist.NewService(cfg.DB, cfg.SQLDB, cfg.JikanClient)
watchlistHandler := watchlist.NewHandler(watchlistSvc)
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
animeHandler := anime.NewHandler(animeSvc)
playbackSvc, err := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{
ProxyTokenSecret: cfg.PlaybackProxySecret,
})
if err != nil {
panic(fmt.Sprintf("failed to initialize playback service: %v", err))
}
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
// Serve static files with no-cache headers
fs := noCache(http.FileServer(http.Dir("./static")))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
// Serve built frontend assets with no-cache headers
dist := noCache(http.FileServer(http.Dir("./dist")))
mux.Handle("/dist/", http.StripPrefix("/dist/", withMimeTypes(dist)))
// Serve Apple Touch Icons from static directory
mux.HandleFunc("/apple-touch-icon.png", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
http.ServeFile(w, r, "./static/apple-touch-icon.svg")
})
mux.HandleFunc("/apple-touch-icon-precomposed.png", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
http.ServeFile(w, r, "./static/apple-touch-icon-precomposed.svg")
})
mux.HandleFunc("/apple-touch-icon-120x120.png", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
http.ServeFile(w, r, "./static/apple-touch-icon-120x120.svg")
})
mux.HandleFunc("/apple-touch-icon-120x120-precomposed.png", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
http.ServeFile(w, r, "./static/apple-touch-icon-120x120-precomposed.svg")
})
mux.HandleFunc("/", animeHandler.HandleCatalog)
mux.HandleFunc("/api/catalog/airing", animeHandler.HandleCatalogAiring)
mux.HandleFunc("/api/catalog/popular", animeHandler.HandleCatalogPopular)
mux.HandleFunc("/api/catalog/continue", animeHandler.HandleCatalogContinue)
mux.HandleFunc("/search", animeHandler.HandleSearch)
mux.HandleFunc("/browse", animeHandler.HandleBrowse)
mux.HandleFunc("/discover", animeHandler.HandleDiscover)
mux.HandleFunc("/api/discover/trending", animeHandler.HandleDiscoverTrending)
mux.HandleFunc("/api/discover/upcoming", animeHandler.HandleDiscoverUpcoming)
mux.HandleFunc("/api/discover/top", animeHandler.HandleDiscoverTop)
mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch)
mux.HandleFunc("/api/jikan/random/anime", animeHandler.HandleRandomAnime)
mux.HandleFunc("/anime/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/watch") {
playbackHandler.HandleWatchPage(w, r)
return
}
animeHandler.HandleAnimeDetails(w, r)
})
mux.HandleFunc("/api/watch-order", animeHandler.HandleHTMLWatchOrder)
mux.HandleFunc("/watch/", playbackHandler.HandleWatchPage)
mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxy)
mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxy)
mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxy)
mux.HandleFunc("/api/watch-progress", playbackHandler.HandleSaveProgress)
mux.HandleFunc("/api/watch-complete", playbackHandler.HandleCompleteAnime)
mux.HandleFunc("/api/watch/episode/", playbackHandler.HandleEpisodeData)
mux.HandleFunc("/api/watch/thumbnails/", playbackHandler.HandleEpisodeThumbnails)
// Auth Endpoints
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
authHandler.HandleLoginPage(w, r)
} else {
cfg.AuthLimiter.AuthMiddleware(pkgmiddleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogin))).ServeHTTP(w, r)
}
})
mux.HandleFunc("/logout", authHandler.HandleLogout)
// Watchlist Endpoints
mux.HandleFunc("/api/watchlist", watchlistHandler.HandleUpdateWatchlist)
mux.HandleFunc("/api/watchlist/", watchlistHandler.HandleDeleteWatchlist)
mux.HandleFunc("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching)
mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist)
// Wrap mux with global CSRF origin verification and auth checking
protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux))
authenticatedHandler := middleware.Auth(cfg.AuthService)(protectedHandler)
return pkgmiddleware.RequestLogger(authenticatedHandler)
}

77
internal/server/server.go Normal file
View File

@@ -0,0 +1,77 @@
package server
import (
"context"
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render"
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(ProvideRouter),
fx.Invoke(RunServer),
)
func ProvideRouter(htmlRender render.HTMLRender) *gin.Engine {
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.Static("/static", "./static")
r.Static("/dist", "./dist")
r.HTMLRender = htmlRender
return r
}
func RunServer(lifecycle fx.Lifecycle, r *gin.Engine) {
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
srv := &http.Server{
Addr: ":" + port,
Handler: r,
}
lifecycle.Append(fx.Hook{
OnStart: func(context.Context) error {
log.Printf("Starting server on http://localhost:%s", port)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
log.Println("Shutting down server...")
return srv.Shutdown(ctx)
},
})
}
// RouteRegister is an interface that modules can implement to register their routes.
type RouteRegister interface {
Register(r *gin.Engine)
}
func RegisterRoutes(r *gin.Engine, registers []RouteRegister) {
for _, reg := range registers {
reg.Register(r)
}
}
// AsRouteRegister is a helper to provide a RouteRegister to the fx group.
func AsRouteRegister(f any) any {
return fx.Annotate(
f,
fx.As(new(RouteRegister)),
fx.ResultTags(`group:"routes"`),
)
}

View File

@@ -0,0 +1,224 @@
package templates
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render"
"go.uber.org/fx"
)
// FS is the interface for template filesystem, to be provided by the main app or a mock.
type FS interface {
ReadFile(name string) ([]byte, error)
ReadDir(name string) ([]os.DirEntry, error)
}
// We will use embed.FS but wrapped in an interface if needed, or just use it directly.
// For now let's assume we pass the root embed.FS to the constructor.
type Renderer struct {
templates map[string]*template.Template
}
var Module = fx.Options(
fx.Provide(ProvideRenderer),
)
func ProvideRenderer() (*Renderer, error) {
// In the final version, this will use an embedded FS.
// For now, let's keep it working with the local filesystem but as an fx service.
r := &Renderer{
templates: make(map[string]*template.Template),
}
funcs := template.FuncMap{
"dict": func(values ...any) map[string]any {
m := make(map[string]any)
for i := 0; i < len(values)-1; i += 2 {
key, ok := values[i].(string)
if !ok {
continue
}
m[key] = values[i+1]
}
return m
},
"json": func(v any) template.HTMLAttr {
b, _ := json.Marshal(v)
return template.HTMLAttr(b)
},
"genresParams": func(genres []int) string {
if len(genres) == 0 {
return ""
}
var s strings.Builder
for _, g := range genres {
s.WriteString("genres=" + fmt.Sprintf("%d", g) + "&")
}
return s.String()[:len(s.String())-1]
},
"hasGenre": func(id int, genres []int) bool {
return slices.Contains(genres, id)
},
"add": func(a, b int) int {
return a + b
},
"sub": func(a, b int) int {
return a - b
},
"mul": func(a, b float64) float64 {
return a * b
},
"imul": func(a, b int) int {
return a * b
},
"div": func(a, b float64) float64 {
if b == 0 {
return 0
}
return a / b
},
"ceilDiv": func(a, b int) int {
if b == 0 {
return 0
}
return (a + b - 1) / b
},
"toFloat": func(a int) float64 {
return float64(a)
},
"seq": func(v any) []int {
var count int
switch n := v.(type) {
case int:
count = n
case int64:
count = int(n)
default:
count = 0
}
res := make([]int, count)
for i := 0; i < count; i++ {
res[i] = i
}
return res
},
"min": func(a, b int) int {
if a < b {
return a
}
return b
},
"int": func(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case string:
i, _ := strconv.Atoi(n)
return i
default:
return 0
}
},
"percent": func(current, total float64) float64 {
if total == 0 {
return 0
}
return (current / total) * 100
},
}
pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml"))
if err != nil {
return nil, err
}
components, err := filepath.Glob(filepath.Join(".", "templates", "components", "*.gohtml"))
if err != nil {
return nil, err
}
basePath := filepath.Join(".", "templates", "base.gohtml")
for _, page := range pages {
name := filepath.Base(page)
if name == "base.gohtml" {
continue
}
tmpl := template.New("base.gohtml").Funcs(funcs)
tmpl = template.Must(tmpl.ParseFiles(basePath))
if len(components) > 0 {
tmpl = template.Must(tmpl.ParseFiles(components...))
}
tmpl = template.Must(tmpl.ParseFiles(page))
r.templates[name] = tmpl
}
return r, nil
}
func (r *Renderer) Instance(name string, data any) render.Render {
return HTMLRender{
Renderer: r,
Name: name,
Data: data,
}
}
type HTMLRender struct {
Renderer *Renderer
Name string
Data any
}
func (h HTMLRender) Render(w http.ResponseWriter) error {
tmpl, ok := h.Renderer.templates[h.Name]
if !ok {
return fmt.Errorf("template %s not found", h.Name)
}
var block any
// Handle both map[string]any and gin.H (which is map[string]any but might
// behave differently depending on the Go version/compiler in type assertions)
if dataMap, ok := h.Data.(map[string]any); ok {
block = dataMap["_fragment"]
} else if ginH, ok := h.Data.(gin.H); ok {
block = ginH["_fragment"]
}
if blockStr, ok := block.(string); ok && blockStr != "" {
return tmpl.ExecuteTemplate(w, blockStr, h.Data)
}
return tmpl.ExecuteTemplate(w, "base.gohtml", h.Data)
}
func (h HTMLRender) WriteContentType(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}
// ExecuteFragment is for HTMX partials
func (r *Renderer) ExecuteFragment(w io.Writer, name string, block string, data any) error {
tmpl, ok := r.templates[name]
if !ok {
return fmt.Errorf("template %s not found", name)
}
return tmpl.ExecuteTemplate(w, block, data)
}

View File

@@ -0,0 +1,115 @@
package handler
import (
"mal/internal/domain"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type WatchlistHandler struct {
svc domain.WatchlistService
}
func NewWatchlistHandler(svc domain.WatchlistService) *WatchlistHandler {
return &WatchlistHandler{svc: svc}
}
func (h *WatchlistHandler) Register(r *gin.Engine) {
r.POST("/api/watchlist", h.HandleUpdateWatchlist)
r.DELETE("/api/watchlist/:id", h.HandleDeleteWatchlist)
r.DELETE("/api/continue-watching/:id", h.HandleDeleteContinueWatching)
r.GET("/watchlist", h.HandleGetWatchlist)
}
func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
var body struct {
AnimeID int64 `json:"animeId"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.AnimeID <= 0 || body.Status == "" {
c.Status(http.StatusBadRequest)
return
}
err := h.svc.UpdateEntry(c.Request.Context(), userID, body.AnimeID, body.Status)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
}
func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if animeID <= 0 {
c.Status(http.StatusBadRequest)
return
}
err := h.svc.RemoveEntry(c.Request.Context(), userID, animeID)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
}
func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if animeID <= 0 {
c.Status(http.StatusBadRequest)
return
}
err := h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
}
func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.HTML(http.StatusOK, "watchlist.gohtml", gin.H{
"AllEntries": entries,
"CurrentPath": "/watchlist",
"User": user,
})
}

View File

@@ -0,0 +1,23 @@
package watchlist
import (
"mal/internal/server"
"mal/internal/watchlist/handler"
"mal/internal/watchlist/repository"
"mal/internal/watchlist/service"
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(
repository.NewWatchlistRepository,
service.NewWatchlistService,
handler.NewWatchlistHandler,
),
fx.Provide(
server.AsRouteRegister(func(h *handler.WatchlistHandler) server.RouteRegister {
return h
}),
),
)

View File

@@ -0,0 +1,51 @@
package repository
import (
"context"
"mal/internal/db"
"mal/internal/domain"
)
type watchlistRepository struct {
queries *db.Queries
}
func NewWatchlistRepository(queries *db.Queries) domain.WatchlistRepository {
return &watchlistRepository{queries: queries}
}
func (r *watchlistRepository) UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) {
return r.queries.UpsertAnime(ctx, arg)
}
func (r *watchlistRepository) GetAnime(ctx context.Context, id int64) (db.Anime, error) {
return r.queries.GetAnime(ctx, id)
}
func (r *watchlistRepository) UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.UpsertWatchListEntry(ctx, arg)
}
func (r *watchlistRepository) DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error {
return r.queries.DeleteWatchListEntry(ctx, arg)
}
func (r *watchlistRepository) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
return r.queries.GetUserWatchList(ctx, userID)
}
func (r *watchlistRepository) GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.GetWatchListEntry(ctx, arg)
}
func (r *watchlistRepository) GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) {
return r.queries.GetContinueWatchingEntry(ctx, arg)
}
func (r *watchlistRepository) DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error {
return r.queries.DeleteContinueWatchingEntry(ctx, arg)
}
func (r *watchlistRepository) SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error {
return r.queries.SaveWatchProgress(ctx, arg)
}

View File

@@ -0,0 +1,83 @@
package service
import (
"context"
"database/sql"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"github.com/google/uuid"
)
type watchlistService struct {
repo domain.WatchlistRepository
jikan *jikan.Client
}
func NewWatchlistService(repo domain.WatchlistRepository, jikan *jikan.Client) domain.WatchlistService {
return &watchlistService{repo: repo, jikan: jikan}
}
func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error {
_, err := s.repo.GetAnime(ctx, animeID)
if err != nil {
anime, err := s.jikan.GetAnimeByID(ctx, int(animeID))
if err == nil {
_, _ = s.repo.UpsertAnime(ctx, db.UpsertAnimeParams{
ID: int64(anime.MalID),
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},
})
}
}
_, err = s.repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: status,
})
return err
}
func (s *watchlistService) RemoveEntry(ctx context.Context, userID string, animeID int64) error {
return s.repo.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
}
func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]domain.UserWatchListRow, error) {
return s.repo.GetUserWatchList(ctx, userID)
}
func (s *watchlistService) GetWatchListEntry(ctx context.Context, userID string, animeID int64) (db.WatchListEntry, error) {
return s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
}
func (s *watchlistService) GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error) {
return s.repo.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
})
}
func (s *watchlistService) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error {
_ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
})
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
})
}

View File

@@ -1,235 +0,0 @@
package worker
import (
"context"
"database/sql"
"fmt"
"log"
"sync"
"time"
"mal/integrations/jikan"
"mal/internal/db"
)
type Worker struct {
db *db.Queries
client *jikan.Client
}
func New(db *db.Queries, client *jikan.Client) *Worker {
return &Worker{
db: db,
client: client,
}
}
func (w *Worker) Start(ctx context.Context) {
log.Println("Starting relations sync worker...")
// ticker: regular sync; retryTicker: check for failed API retries
ticker := time.NewTicker(1 * time.Minute)
retryTicker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
defer retryTicker.Stop()
// Run once immediately on startup
w.runAllTasks(ctx)
cleanupCounter := 0
for {
select {
case <-ctx.Done():
return
case <-w.client.RetrySignal():
w.processAnimeFetchRetries(ctx)
case <-retryTicker.C:
w.processAnimeFetchRetries(ctx)
case <-ticker.C:
w.syncRelations(ctx)
cleanupCounter++
if cleanupCounter >= 60 {
w.cleanupCache(ctx)
cleanupCounter = 0
}
}
}
}
func (w *Worker) runAllTasks(ctx context.Context) {
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
w.syncRelations(ctx)
}()
go func() {
defer wg.Done()
w.processAnimeFetchRetries(ctx)
}()
go func() {
defer wg.Done()
w.cleanupCache(ctx)
}()
wg.Wait()
}
// retryBackoff calculates the next retry delay, doubling up to 30 minutes max
func retryBackoff(attempts int64) string {
if attempts < 1 {
attempts = 1
}
delay := time.Minute
if attempts > 1 {
shift := min(attempts-1, 6)
delay = time.Minute * time.Duration(1<<shift)
}
if delay > 30*time.Minute {
delay = 30 * time.Minute
}
minutes := max(int(delay/time.Minute), 1)
return fmt.Sprintf("+%d minutes", minutes)
}
// processAnimeFetchRetries retries failed Jikan API fetches for anime with pending entries
func (w *Worker) processAnimeFetchRetries(ctx context.Context) {
retries, err := w.db.GetDueAnimeFetchRetries(ctx, 20)
if err != nil {
log.Printf("worker: failed to load due anime fetch retries: %v", err)
return
}
if len(retries) == 0 {
return
}
var wg sync.WaitGroup
for _, retry := range retries {
wg.Add(1)
go func(r db.AnimeFetchRetry) {
defer wg.Done()
_, err := w.client.GetAnimeByID(ctx, int(r.AnimeID))
if err != nil {
if !jikan.IsRetryableError(err) {
_ = w.db.DeleteAnimeFetchRetry(ctx, r.AnimeID)
return
}
_ = w.db.MarkAnimeFetchRetryFailed(ctx, db.MarkAnimeFetchRetryFailedParams{
Datetime: retryBackoff(r.Attempts + 1),
LastError: err.Error(),
AnimeID: r.AnimeID,
})
return
}
_ = w.db.DeleteAnimeFetchRetry(ctx, r.AnimeID)
}(retry)
}
wg.Wait()
}
func (w *Worker) cleanupCache(ctx context.Context) {
if err := w.db.DeleteExpiredJikanCache(ctx); err != nil {
log.Printf("worker: failed to clean up expired jikan cache: %v", err)
}
}
// syncRelations fetches relation data for anime that need syncing via a worker pool
func (w *Worker) syncRelations(ctx context.Context) {
animes, err := w.db.GetAnimeNeedingRelationSync(ctx)
if err != nil {
log.Printf("worker error: failed to get anime needing sync: %v", err)
return
}
if len(animes) == 0 {
return
}
// Use a small worker pool for Jikan API calls to respect rate limits while maintaining concurrency
const workerCount = 2
jobs := make(chan db.GetAnimeNeedingRelationSyncRow, len(animes))
var wg sync.WaitGroup
for range workerCount {
wg.Go(func() {
for a := range jobs {
w.syncSingleAnime(ctx, a.ID)
}
})
}
for _, a := range animes {
jobs <- a
}
close(jobs)
wg.Wait()
}
// syncSingleAnime fetches relations for one anime, inserts them, and marks it synced.
// For sequels, also ensures the related anime exists in the DB to enable linking.
func (w *Worker) syncSingleAnime(ctx context.Context, id int64) {
animeData, err := w.client.GetAnimeByID(ctx, int(id))
if err != nil {
log.Printf("worker: failed to fetch anime details for %d: %v", id, err)
return
}
for _, rel := range animeData.Relations {
for _, entry := range rel.Entry {
if entry.Type == "anime" {
err := w.db.UpsertAnimeRelation(ctx, db.UpsertAnimeRelationParams{
AnimeID: id,
RelatedAnimeID: int64(entry.MalID),
RelationType: rel.Relation,
})
if err != nil {
log.Printf("worker: failed to insert relation %d -> %d: %v", id, entry.MalID, err)
}
if rel.Relation == "Sequel" {
w.ensureAnimeExistsAndStatusUpdated(ctx, entry.MalID)
}
}
}
}
_ = w.db.UpdateAnimeStatus(ctx, db.UpdateAnimeStatusParams{
Status: sql.NullString{String: animeData.Status, Valid: true},
ID: id,
})
_ = w.db.MarkRelationsSynced(ctx, id)
}
func (w *Worker) ensureAnimeExistsAndStatusUpdated(ctx context.Context, malID int) {
animeDetails, err := w.client.GetAnimeByID(ctx, malID)
if err != nil {
log.Printf("worker: failed to fetch related anime %d: %v", malID, err)
return
}
_, err = w.db.UpsertAnime(ctx, db.UpsertAnimeParams{
ID: int64(animeDetails.MalID),
TitleOriginal: animeDetails.Title,
TitleEnglish: sql.NullString{String: animeDetails.TitleEnglish, Valid: animeDetails.TitleEnglish != ""},
TitleJapanese: sql.NullString{String: animeDetails.TitleJapanese, Valid: animeDetails.TitleJapanese != ""},
ImageUrl: animeDetails.ImageURL(),
Airing: sql.NullBool{Bool: animeDetails.Airing, Valid: true},
})
if err != nil {
log.Printf("worker: failed to insert related anime %d: %v", malID, err)
}
_ = w.db.UpdateAnimeStatus(ctx, db.UpdateAnimeStatusParams{
Status: sql.NullString{String: animeDetails.Status, Valid: true},
ID: int64(animeDetails.MalID),
})
}

View File

@@ -1 +0,0 @@
-- Note: watch_list_entry columns now in 001_init.sql

View File

@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS account;
DROP TABLE IF EXISTS notification_preference;

View File

@@ -1,54 +0,0 @@
package middleware
import (
"net/http"
"net/url"
)
// VerifyOrigin validates that the request Origin/Referer matches the host
// skips validation for safe methods (GET, HEAD, OPTIONS)
func VerifyOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
origin := r.Header.Get("Origin")
if origin == "" {
referer := r.Header.Get("Referer")
if referer == "" {
http.Error(w, "Missing Origin or Referer header", http.StatusForbidden)
return
}
refURL, err := url.Parse(referer)
if err != nil {
http.Error(w, "Invalid Referer header", http.StatusForbidden)
return
}
origin = refURL.Scheme + "://" + refURL.Host
}
originURL, err := url.Parse(origin)
if err != nil {
http.Error(w, "Invalid Origin header", http.StatusForbidden)
return
}
host := r.Host
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost // support reverse proxies
}
expectedHTTP := "http://" + host
expectedHTTPS := "https://" + host
if originURL.Scheme+"://"+originURL.Host != expectedHTTP && originURL.Scheme+"://"+originURL.Host != expectedHTTPS {
http.Error(w, "Cross-Site Request Forgery (CSRF) origin mismatch", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,100 +0,0 @@
package middleware
import (
"bufio"
"fmt"
"log"
"net"
"net/http"
"strings"
"time"
)
// statusRecorder wraps ResponseWriter to capture the status code
// defaults to 200 if WriteHeader is never called before Write
type statusRecorder struct {
http.ResponseWriter
statusCode int
wroteHeader bool
}
func newStatusRecorder(w http.ResponseWriter) *statusRecorder {
return &statusRecorder{
ResponseWriter: w,
statusCode: http.StatusOK,
}
}
// WriteHeader records the status code and proxies to underlying writer
func (rw *statusRecorder) WriteHeader(code int) {
if rw.wroteHeader {
return
}
rw.statusCode = code
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(code)
}
// Write ensures a status code is set before writing the body
func (rw *statusRecorder) Write(b []byte) (int, error) {
if !rw.wroteHeader {
rw.WriteHeader(http.StatusOK)
}
return rw.ResponseWriter.Write(b)
}
// Flush proxies the Flusher interface if supported
func (rw *statusRecorder) Flush() {
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
// Hijack proxies the Hijacker interface if supported
func (rw *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := rw.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("response writer does not support hijacking")
}
return hijacker.Hijack()
}
// Push proxies the Pusher interface if supported
func (rw *statusRecorder) Push(target string, opts *http.PushOptions) error {
pusher, ok := rw.ResponseWriter.(http.Pusher)
if !ok {
return http.ErrNotSupported
}
return pusher.Push(target, opts)
}
// Unwrap returns the underlying ResponseWriter for middleware chaining
func (rw *statusRecorder) Unwrap() http.ResponseWriter {
return rw.ResponseWriter
}
// RequestLogger logs requests that result in 4xx/5xx responses
// skips static assets, streaming, and common bot paths
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
if strings.HasPrefix(r.URL.Path, "/dist/") ||
strings.HasPrefix(r.URL.Path, "/static/") ||
strings.HasPrefix(r.URL.Path, "/watch/proxy/stream") ||
strings.HasPrefix(r.URL.Path, "/watch/proxy/segment") ||
r.URL.Path == "/favicon.ico" ||
r.URL.Path == "/robots.txt" {
next.ServeHTTP(w, r)
return
}
recorder := newStatusRecorder(w)
next.ServeHTTP(recorder, r)
if recorder.statusCode >= 400 {
log.Printf("%s %s %d %s", r.Method, r.URL.Path, recorder.statusCode, time.Since(start))
}
})
}

View File

@@ -1,101 +0,0 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// visitor tracks request attempts and last access time per IP
type visitor struct {
attempts int
lastSeen time.Time
}
// Config holds rate limiter settings
type Config struct {
MaxAttempts int // max requests per window
Window time.Duration // sliding window duration
}
// Limiter implements a simple in-memory IP-based rate limiter
type Limiter struct {
mu sync.Mutex
visitors map[string]*visitor
config Config
}
func NewLimiter(cfg Config) *Limiter {
return &Limiter{
visitors: make(map[string]*visitor),
config: cfg,
}
}
// Cleanup removes stale visitor entries older than 3x the window
func (l *Limiter) Cleanup(now time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
for ip, v := range l.visitors {
if now.Sub(v.lastSeen) > l.config.Window*3 {
delete(l.visitors, ip)
}
}
}
// getIP extracts the client IP, checking X-Forwarded-For and X-Real-IP headers
func getIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0]) // first proxy IP
}
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return realIP
}
ip := r.RemoteAddr
if colonIdx := strings.LastIndex(ip, ":"); colonIdx != -1 {
ip = ip[:colonIdx] // strip port for IPv4-mapped IPv6
}
return ip
}
// AuthMiddleware redirects rate-limited form submissions back to the page
// returns 429 for non-path requests (e.g. API calls)
func (l *Limiter) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !l.allow(getIP(r)) {
if strings.HasPrefix(r.URL.Path, "/") {
http.Redirect(w, r, fmt.Sprintf("%s?error=rate_limited", r.URL.Path), http.StatusFound)
return
}
http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// allow checks and updates the visitor's attempt count; returns true if allowed
// resets counter if window has expired, otherwise increments and checks limit
func (l *Limiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
v, exists := l.visitors[ip]
if !exists {
l.visitors[ip] = &visitor{1, time.Now()}
return true
}
if time.Since(v.lastSeen) > l.config.Window {
v.attempts = 1 // reset counter on window expiry
v.lastSeen = time.Now()
return true
}
v.attempts++
v.lastSeen = time.Now()
return v.attempts <= l.config.MaxAttempts
}

View File

@@ -0,0 +1,86 @@
package proxytransport
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"time"
)
var dnsCache sync.Map
func init() {
go func() {
for {
time.Sleep(5 * time.Minute)
dnsCache.Range(func(key, _ any) bool {
dnsCache.Delete(key)
return true
})
}
}()
}
func newTransport(dialTimeout, tlsTimeout, headerTimeout time.Duration) *http.Transport {
return &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: tlsTimeout,
ResponseHeaderTimeout: headerTimeout,
ExpectContinueTimeout: 1 * time.Second,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, ok := dnsCache.Load(host)
if !ok {
resolved, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("proxy dns lookup: %w", err)
}
dnsCache.Store(host, resolved)
ips = resolved
}
return dialIPs(ctx, network, host, port, ips.([]net.IPAddr), dialTimeout)
},
}
}
func dialIPs(ctx context.Context, network, host, port string, ips []net.IPAddr, timeout time.Duration) (net.Conn, error) {
var firstErr error
for _, ip := range ips {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
dialer := net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
if firstErr == nil {
firstErr = err
}
}
return nil, fmt.Errorf("proxy dial %s: %w", host, firstErr)
}
func NewClient() *http.Client {
return &http.Client{
Transport: newTransport(10*time.Second, 10*time.Second, 30*time.Second),
Timeout: 60 * time.Second,
}
}
func NewStreamingClient() *http.Client {
return &http.Client{
Transport: newTransport(10*time.Second, 10*time.Second, 15*time.Second),
}
}

View File

@@ -2,7 +2,7 @@ version: '2'
sql: sql:
- engine: 'sqlite' - engine: 'sqlite'
queries: 'internal/db/queries.sql' queries: 'internal/db/queries.sql'
schema: 'migrations/' schema: 'internal/database/migrations/'
gen: gen:
go: go:
package: 'db' package: 'db'

View File

@@ -2,8 +2,8 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&display=swap');
@import '@toolwind/anchors'; @import '@toolwind/anchors';
@source "."; @source "../../templates/**/*.gohtml";
@source "../web/**/*.templ"; @source "../**/*.ts";
@theme { @theme {
--color-background: light-dark(#ffffff, #080808); --color-background: light-dark(#ffffff, #080808);

View File

@@ -209,7 +209,7 @@ export const setupControls = (): void => {
// mouse move in container shows controls // mouse move in container shows controls
state.container.addEventListener('mousemove', showControls); state.container.addEventListener('mousemove', showControls);
// initial sync // initial sync — check actual video state since inline script may have started playback
updatePlayPauseIcons(false); updatePlayPauseIcons(!state.video.paused);
syncVolumeUI(); syncVolumeUI();
}; };

View File

@@ -1,11 +1,5 @@
import DOMPurify from 'dompurify';
import { state } from '../state'; import { state } from '../state';
/**
* Marks anime as completed when final episode finishes.
* Calls completion API, updates dropdown UI, adds to watchlist.
* Retries up to 2 times on failure.
*/
export const completeAnime = async (episodeNumber: number): Promise<void> => { export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (state.completionSent || !state.malID || !episodeNumber) return; if (state.completionSent || !state.malID || !episodeNumber) return;
state.completionSent = true; state.completionSent = true;
@@ -20,7 +14,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (!res.ok) { if (!res.ok) {
state.completionSent = false; state.completionSent = false;
// retry
if (state.completionAttempts < 2) { if (state.completionAttempts < 2) {
state.completionAttempts++; state.completionAttempts++;
setTimeout(() => completeAnime(episodeNumber), 1000); setTimeout(() => completeAnime(episodeNumber), 1000);
@@ -28,7 +21,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
return; return;
} }
// update dropdown trigger text
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null; const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null;
if (trigger) { if (trigger) {
trigger.textContent = 'Completed '; trigger.textContent = 'Completed ';
@@ -37,37 +29,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
caret.textContent = '▾'; caret.textContent = '▾';
trigger.appendChild(caret); trigger.appendChild(caret);
} }
// add to watchlist with 'completed' status
const dropdown = document.getElementById('watch-status-dropdown');
if (dropdown) {
const payload = {
anime_id: String(state.malID),
anime_title: state.container.dataset.animeTitle ?? '',
anime_title_english: state.container.dataset.animeTitleEnglish ?? '',
anime_title_japanese: state.container.dataset.animeTitleJapanese ?? '',
anime_image: state.container.dataset.animeImage ?? '',
status: 'completed',
airing: state.container.dataset.animeAiring === 'true',
};
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' },
body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`,
credentials: 'same-origin',
})
.then(async res => {
if (!res.ok) return;
// replace dropdown with HTMX response
const html = await res.text();
const wrapper = document.createElement('span');
wrapper.id = 'watch-status-dropdown';
wrapper.innerHTML = DOMPurify.sanitize(html);
dropdown.replaceWith(wrapper);
})
.catch(() => {});
}
} catch { } catch {
state.completionSent = false; state.completionSent = false;
if (state.completionAttempts < 2) { if (state.completionAttempts < 2) {

View File

@@ -66,9 +66,10 @@ const initPlayer = (): void => {
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null; const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
// build video src from mode, token, and saved quality preference // build video src from mode, token, and saved quality preference
// Only set if not already provided by the inline script during HTML parsing
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'; const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
const streamToken = state.modeSources[state.currentMode]?.token; const streamToken = state.modeSources[state.currentMode]?.token;
if (streamToken) { if (!state.video.src && streamToken) {
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`; state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
} }
@@ -87,7 +88,7 @@ const initPlayer = (): void => {
updateAutoSkipButton(); updateAutoSkipButton();
showControls(); showControls();
state.video.addEventListener('loadedmetadata', () => { const onLoadedMetadata = (): void => {
loading && (loading.style.display = 'none'); loading && (loading.style.display = 'none');
invalidateBounds(); invalidateBounds();
@@ -104,11 +105,19 @@ const initPlayer = (): void => {
state.video.currentTime = state.pendingSeekTime; state.video.currentTime = state.pendingSeekTime;
state.pendingSeekTime = null; state.pendingSeekTime = null;
} }
if (state.shouldAutoPlay) state.video.play().catch(() => {}); // autoplay if not already playing (inline script may have already called play())
if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {});
updateTimeline(state.video.currentTime); updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime); updateSkipButton(state.video.currentTime);
}); };
state.video.addEventListener('loadedmetadata', onLoadedMetadata);
// inline script runs during HTML parsing before initPlayer; if metadata
// already loaded, fire the handler immediately
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
onLoadedMetadata();
}
state.video.addEventListener('waiting', () => { state.video.addEventListener('waiting', () => {
loading && (loading.style.display = 'flex'); loading && (loading.style.display = 'flex');

View File

@@ -29,7 +29,9 @@ const showToast = ({ message, duration = 3000 }: ToastOptions): void => {
return; return;
} }
const toast = template.content.cloneNode(true) as HTMLElement; const toast = (template.content.cloneNode(true) as DocumentFragment)
.firstElementChild as HTMLElement;
if (!toast) return;
const messageEl = toast.querySelector('.toast-message'); const messageEl = toast.querySelector('.toast-message');
const closeBtn = toast.querySelector('.toast-close'); const closeBtn = toast.querySelector('.toast-close');

View File

@@ -2,7 +2,7 @@
<div class="mt-12 w-full"> <div class="mt-12 w-full">
<h2 class="mb-6 text-lg font-normal text-foreground">Characters & Cast</h2> <h2 class="mb-6 text-lg font-normal text-foreground">Characters & Cast</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{{range (slice . 0 (min (len .) 10))}} {{range (slice .Items 0 (min (len .Items) 10))}}
<div class="flex gap-3 bg-background-surface p-3 ring-1 ring-border"> <div class="flex gap-3 bg-background-surface p-3 ring-1 ring-border">
<div class="h-16 w-12 shrink-0 overflow-hidden bg-background-surface"> <div class="h-16 w-12 shrink-0 overflow-hidden bg-background-surface">
<img src="{{.Character.Images.Jpg.ImageURL}}" alt="{{.Character.Name}}" class="h-full w-full object-cover" loading="lazy" /> <img src="{{.Character.Images.Jpg.ImageURL}}" alt="{{.Character.Name}}" class="h-full w-full object-cover" loading="lazy" />
@@ -21,11 +21,11 @@
{{end}} {{end}}
{{define "anime_recommendations"}} {{define "anime_recommendations"}}
{{if .}} {{if .Items}}
<div class="w-full"> <div class="w-full">
<h2 class="mb-6 text-lg font-normal text-foreground">Recommendations</h2> <h2 class="mb-6 text-lg font-normal text-foreground">Recommendations</h2>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8"> <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8">
{{range (slice . 0 (min (len .) 8))}} {{range (slice .Items 0 (min (len .Items) 8))}}
<a href="/anime/{{.Entry.MalID}}" class="group flex flex-col gap-2"> <a href="/anime/{{.Entry.MalID}}" class="group flex flex-col gap-2">
<div class="aspect-2/3 overflow-hidden bg-background-surface shadow-md"> <div class="aspect-2/3 overflow-hidden bg-background-surface shadow-md">
<img src="{{.Entry.Images.Webp.LargeImageURL}}" alt="{{.Entry.Title}}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" /> <img src="{{.Entry.Images.Webp.LargeImageURL}}" alt="{{.Entry.Title}}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
@@ -43,7 +43,7 @@
{{if .WatchlistIDs}}<script>initWatchlist({{.WatchlistIDs}})</script>{{end}} {{if .WatchlistIDs}}<script>initWatchlist({{.WatchlistIDs}})</script>{{end}}
{{$anime := .Anime}} {{$anime := .Anime}}
<div class="flex flex-col gap-10 pr-0 lg:pr-80"> <div class="flex flex-col gap-10 lg:pr-80">
<div class="flex flex-col gap-8 md:flex-row lg:gap-12"> <div class="flex flex-col gap-8 md:flex-row lg:gap-12">
<div class="flex w-64 shrink-0 flex-col items-center gap-6 md:w-80 md:items-start lg:w-96"> <div class="flex w-64 shrink-0 flex-col items-center gap-6 md:w-80 md:items-start lg:w-96">
<div class="aspect-2/3 w-full overflow-hidden bg-background-surface shadow-lg"> <div class="aspect-2/3 w-full overflow-hidden bg-background-surface shadow-lg">
@@ -85,7 +85,7 @@
{{if $anime.ShortRating}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.ShortRating}}</span>{{end}} {{if $anime.ShortRating}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.ShortRating}}</span>{{end}}
</div> </div>
{{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status}} {{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status "ContinueWatchingEp" .ContinueWatchingEp "ContinueWatchingTime" .ContinueWatchingTime}}
<div class="flex flex-col gap-12 lg:flex-row"> <div class="flex flex-col gap-12 lg:flex-row">
<div class="grow lg:max-w-4xl"> <div class="grow lg:max-w-4xl">

View File

@@ -109,7 +109,20 @@
const watchlistIds = new Set() const watchlistIds = new Set()
function initWatchlist(ids) { function initWatchlist(ids) {
ids.forEach(id => watchlistIds.add(id)) ids.forEach(id => watchlistIds.add(id));
const sync = () => ids.forEach(id => syncRemoveButtonVisibility(id));
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', sync);
} else {
sync();
}
}
function syncRemoveButtonVisibility(id) {
const container = document.getElementById('remove-watchlist-container-' + id);
if (container) {
container.classList.toggle('hidden', !watchlistIds.has(id));
}
} }
function toggleWatchlist(id, btn) { function toggleWatchlist(id, btn) {
@@ -169,10 +182,7 @@ if (window.showToast) showToast({ message: 'Something went wrong' })
const statusDisplay = document.getElementById('watchlist-status-display-' + id) const statusDisplay = document.getElementById('watchlist-status-display-' + id)
if (statusDisplay) { if (statusDisplay) {
statusDisplay.textContent = inWatchlist ? 'Plan to Watch' : 'Add to Watchlist' statusDisplay.textContent = inWatchlist ? 'Plan to Watch' : 'Add to Watchlist'
const removeContainer = document.getElementById('remove-watchlist-container-' + id) syncRemoveButtonVisibility(id)
if (removeContainer) {
removeContainer.classList.toggle('hidden', !inWatchlist)
}
} }
} }
@@ -186,6 +196,57 @@ if (window.showToast) showToast({ message: 'Something went wrong' })
} }
}) })
} }
function updateWatchlist(id, status, display, btn) {
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeId: id, status: status })
}).then(res => {
if (res.ok) {
watchlistIds.add(id);
document.getElementById('watchlist-status-display-' + id).textContent = display;
syncRemoveButtonVisibility(id);
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button');
if (button) {
const malId = button.dataset.malId;
if (malId && parseInt(malId) === id) {
button.classList.add('in-watchlist');
}
}
});
requestAnimationFrame(() => {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
});
}
});
}
function removeWatchlist(id, btn) {
fetch('/api/watchlist/' + id, { method: 'DELETE' }).then(res => {
if (res.ok) {
watchlistIds.delete(id);
document.getElementById('watchlist-status-display-' + id).textContent = 'Add to Watchlist';
syncRemoveButtonVisibility(id);
if (window.showToast) showToast({ message: 'Removed from watchlist' });
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button');
if (button) {
const malId = button.dataset.malId;
if (malId && parseInt(malId) === id) {
button.classList.remove('in-watchlist');
}
}
});
if (btn) {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
}
}
});
}
</script> </script>
</head> </head>
<body class="bg-background text-foreground"> <body class="bg-background text-foreground">
@@ -195,20 +256,19 @@ if (window.showToast) showToast({ message: 'Something went wrong' })
<button id="mobile-overlay" class="hidden fixed inset-0 z-40 w-full cursor-default border-none bg-black/60 backdrop-blur-sm outline-none lg:hidden" onclick="toggleMobileMenu()" aria-label="Close mobile menu"></button> <button id="mobile-overlay" class="hidden fixed inset-0 z-40 w-full cursor-default border-none bg-black/60 backdrop-blur-sm outline-none lg:hidden" onclick="toggleMobileMenu()" aria-label="Close mobile menu"></button>
<!-- Sidebar --> <!-- Sidebar -->
<div id="mobile-menu" class="fixed inset-y-0 left-0 z-50 shrink-0 overflow-hidden transform lg:relative lg:z-auto lg:translate-x-0 w-64 shadow-2xl transition-transform duration-300 -translate-x-full lg:block"> <div id="mobile-menu" class="fixed inset-y-0 left-0 z-50 shrink-0 overflow-hidden transform lg:relative lg:z-auto lg:translate-x-0 w-64 transition-transform duration-300 -translate-x-full lg:block">
{{block "sidebar" .}} {{block "sidebar" .}}
{{template "navigation" dict "CurrentPath" .CurrentPath}} {{template "navigation" dict "CurrentPath" .CurrentPath}}
{{end}} {{end}}
</div> </div>
<main class="w-full flex-1 overflow-x-hidden flex flex-col h-screen overflow-y-auto"> <main class="w-full flex-1 flex flex-col h-screen overflow-y-auto">
<div class="sticky top-0 z-40 w-full"> <div class="sticky top-0 z-40 w-full">
{{template "header" .}} {{template "header" .}}
</div> </div>
<div class="flex-1 p-4 md:p-8 lg:p-10"> <div class="flex-1 p-4 md:p-8 lg:p-10">
{{template "content" .}} {{template "content" .}}
</div> </div>
{{template "footer" .}}
</main> </main>
</div> </div>
{{else}} {{else}}
@@ -216,7 +276,6 @@ if (window.showToast) showToast({ message: 'Something went wrong' })
<div class="flex-1"> <div class="flex-1">
{{template "content" .}} {{template "content" .}}
</div> </div>
{{template "footer" .}}
</main> </main>
{{end}} {{end}}
</div> </div>

View File

@@ -20,19 +20,11 @@
</div> </div>
{{else}} {{else}}
<div id="anime-grid" class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6"> <div id="anime-grid" class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{{range $i, $anime := .Animes}} {{range .Animes}}
{{$isThreshold := eq (add $i 1) (sub (len $.Animes) 8)}}
{{if and $isThreshold $.HasNextPage}}
<div hx-get="/browse?q={{$.Query}}&type={{$.Type}}&status={{$.Status}}&order_by={{$.OrderBy}}&sort={{$.Sort}}&sfw={{$.SFW}}&{{genresParams $.Genres}}&page={{$.NextPage}}"
hx-trigger="revealed"
hx-swap="afterend"
hx-target="this"
class="contents">
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
</div>
{{else}}
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
{{end}} {{end}}
{{if .HasNextPage}}
{{template "browse_sentinel" .}}
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
@@ -41,19 +33,18 @@
{{end}} {{end}}
{{define "anime_card_scroll"}} {{define "anime_card_scroll"}}
{{$count := len .Animes}} {{range .Animes}}
{{range $i, $anime := .Animes}} {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
{{$isThreshold := eq (add $i 1) (sub $count 8)}}
{{if and $isThreshold $.HasNextPage}}
<div hx-get="/browse?q={{$.Query}}&type={{$.Type}}&status={{$.Status}}&order_by={{$.OrderBy}}&sort={{$.Sort}}&sfw={{$.SFW}}&{{genresParams $.Genres}}&page={{$.NextPage}}"
hx-trigger="revealed"
hx-swap="afterend"
hx-target="this"
class="contents">
{{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}}
</div>
{{else}}
{{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}}
{{end}} {{end}}
{{if .HasNextPage}}
{{template "browse_sentinel" .}}
{{end}} {{end}}
{{end}} {{end}}
{{define "browse_sentinel"}}
<div hx-get="/browse?q={{.Query}}&type={{.Type}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}&sfw={{.SFW}}&{{genresParams .Genres}}&page={{.NextPage}}"
hx-trigger="intersect once"
hx-swap="outerHTML"
hx-target="this"
class="col-span-full h-px"></div>
{{end}}

View File

@@ -9,7 +9,7 @@
<div id="continue-watching-{{.AnimeID}}" class="continue-watching-item group relative w-70 shrink-0 snap-start space-y-2 2xl:w-lg"> <div id="continue-watching-{{.AnimeID}}" class="continue-watching-item group relative w-70 shrink-0 snap-start space-y-2 2xl:w-lg">
<div class="bg-background/80 relative aspect-video w-full overflow-hidden"> <div class="bg-background/80 relative aspect-video w-full overflow-hidden">
<a href="/anime/{{.AnimeID}}/watch" class="block h-full w-full"> <a href="/anime/{{.AnimeID}}/watch{{if .CurrentEpisode.Valid}}?ep={{.CurrentEpisode.Int64}}{{end}}" class="block h-full w-full">
<img src="{{if .ImageUrl}}{{.ImageUrl}}{{else}}https://placehold.co/500x500{{end}}" alt="{{$title}}" class="h-full w-full object-cover" /> <img src="{{if .ImageUrl}}{{.ImageUrl}}{{else}}https://placehold.co/500x500{{end}}" alt="{{$title}}" class="h-full w-full object-cover" />
</a> </a>
@@ -29,7 +29,7 @@
</div> </div>
<div> <div>
<a href="/anime/{{.AnimeID}}/watch" class="block"> <a href="/anime/{{.AnimeID}}/watch{{if .CurrentEpisode.Valid}}?ep={{.CurrentEpisode.Int64}}{{end}}" class="block">
<h3 class="text-foreground truncate text-lg font-normal"> <h3 class="text-foreground truncate text-lg font-normal">
{{$title}} {{$title}}
</h3> </h3>

View File

@@ -14,7 +14,27 @@
class="group relative aspect-video w-full overflow-hidden bg-black"> class="group relative aspect-video w-full overflow-hidden bg-black">
<video class="h-full w-full cursor-pointer" preload="metadata" playsinline autoplay></video> <video class="h-full w-full cursor-pointer" preload="metadata" playsinline></video>
<script>
(function() {
var p = document.currentScript.closest('[data-video-player]');
var v = p.querySelector('video');
var sources = JSON.parse(p.getAttribute('data-mode-sources') || '{}');
var mode = p.getAttribute('data-initial-mode') || 'dub';
var stored = localStorage.getItem('player-audio-mode');
if (stored && sources[stored] && sources[stored].token) mode = stored;
var src = sources[mode];
if (!src || !src.token) {
for (var k in sources) {
if (sources[k] && sources[k].token) { src = sources[k]; mode = k; break; }
}
}
if (src && src.token) {
v.src = '/watch/proxy/stream?mode=' + encodeURIComponent(mode) + '&token=' + encodeURIComponent(src.token);
v.play().catch(function() {});
}
})();
</script>
<div data-loading class="absolute inset-0 flex items-center justify-center bg-black/60 hidden z-50"> <div data-loading class="absolute inset-0 flex items-center justify-center bg-black/60 hidden z-50">
<div class="border-accent size-10 animate-spin rounded-full border-4 border-t-transparent"></div> <div class="border-accent size-10 animate-spin rounded-full border-4 border-t-transparent"></div>

View File

@@ -36,79 +36,28 @@
<span class="font-medium text-sm text-foreground">Dropped</span> <span class="font-medium text-sm text-foreground">Dropped</span>
</button> </button>
<div id="remove-watchlist-container-{{$anime.MalID}}" class="{{if not $status}}hidden{{end}}"> {{template "watchlist_remove_button" dict
<div class="my-1 h-px bg-border"></div> "ID" $anime.MalID
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10 focus:bg-red-500/10" onclick="removeWatchlist({{$anime.MalID}})"> "ContainerClass" "hidden"
<span class="font-medium text-sm text-red-500 text-left whitespace-nowrap">Remove from Watchlist</span> "DividerClass" "my-1 h-px bg-border"
</button> "ButtonClass" "flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10 focus:bg-red-500/10"
</div> "SpanClass" "font-medium text-sm text-red-500 text-left whitespace-nowrap"
}}
</div> </div>
</div> </div>
</ui-dropdown> </ui-dropdown>
<a href="/anime/{{$anime.MalID}}/watch" class="bg-background-button hover:bg-background-button-hover px-5 py-2.5 text-sm font-medium text-foreground transition-colors"> <a href="/anime/{{$anime.MalID}}/watch{{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}?ep={{.ContinueWatchingEp}}{{end}}" class="bg-background-button hover:bg-background-button-hover px-5 py-2.5 text-sm font-medium text-foreground transition-colors">
Watch {{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}Continue Episode {{.ContinueWatchingEp}}{{else}}Watch Now{{end}}
</a> </a>
</div> </div>
{{end}}
<script>
function updateWatchlist(id, status, display, btn) { {{define "watchlist_remove_button"}}
fetch('/api/watchlist', { <div id="remove-watchlist-container-{{.ID}}" class="{{.ContainerClass}}">
method: 'POST', <div class="{{.DividerClass}}"></div>
headers: { 'Content-Type': 'application/json' }, <button class="{{.ButtonClass}}" onclick="removeWatchlist({{.ID}}, this)">
body: JSON.stringify({ animeId: id, status: status }) <span class="{{.SpanClass}}">Remove from Watchlist</span>
}).then(res => { </button>
if (res.ok) { </div>
watchlistIds.add(id);
document.getElementById('watchlist-status-display-' + id).textContent = display;
document.getElementById('remove-watchlist-container-' + id).classList.remove('hidden');
// Update all watchlist icons on the page
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button')
if (button) {
const malId = button.dataset.malId
if (malId && parseInt(malId) === id) {
button.classList.add('in-watchlist')
}
}
});
// Close dropdown after a small delay to let click event finish
requestAnimationFrame(() => {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
});
}
});
}
function removeWatchlist(id) {
fetch('/api/watchlist/' + id, { method: 'DELETE' }).then(res => {
if (res.ok) {
watchlistIds.delete(id);
document.getElementById('watchlist-status-display-' + id).textContent = 'Add to Watchlist';
document.getElementById('remove-watchlist-container-' + id).classList.add('hidden');
// Update all watchlist icons on the page
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button')
if (button) {
const malId = button.dataset.malId
if (malId && parseInt(malId) === id) {
button.classList.remove('in-watchlist')
}
}
});
// Close dropdown
const btn = document.getElementById('watchlist-status-display-' + id);
if (btn) {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
}
}
});
}
</script>
{{end}} {{end}}

View File

@@ -1,198 +0,0 @@
package templates
import (
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
)
var (
once sync.Once
renderer *Renderer
)
type Renderer struct {
templates map[string]*template.Template
}
// GetRenderer returns the singleton renderer, initializing it on first call.
// Templates are loaded from ./templates/*.gohtml and ./templates/components/*.gohtml.
func GetRenderer() *Renderer {
once.Do(func() {
renderer = &Renderer{
templates: make(map[string]*template.Template),
}
funcs := template.FuncMap{
"dict": func(values ...any) map[string]any {
m := make(map[string]any)
for i := 0; i < len(values)-1; i += 2 {
key, ok := values[i].(string)
if !ok {
continue
}
m[key] = values[i+1]
}
return m
},
"json": func(v any) template.HTMLAttr {
b, _ := json.Marshal(v)
return template.HTMLAttr(b)
},
"genresParams": func(genres []int) string {
if len(genres) == 0 {
return ""
}
var s strings.Builder
for _, g := range genres {
s.WriteString("genres=" + fmt.Sprintf("%d", g) + "&")
}
return s.String()[:len(s.String())-1]
},
"hasGenre": func(id int, genres []int) bool {
return slices.Contains(genres, id)
},
"add": func(a, b int) int {
return a + b
},
"sub": func(a, b int) int {
return a - b
},
"mul": func(a, b float64) float64 {
return a * b
},
"imul": func(a, b int) int {
return a * b
},
"div": func(a, b float64) float64 {
if b == 0 {
return 0
}
return a / b
},
"ceilDiv": func(a, b int) int {
if b == 0 {
return 0
}
return (a + b - 1) / b
},
"toFloat": func(a int) float64 {
return float64(a)
},
"seq": func(v any) []int {
var count int
switch n := v.(type) {
case int:
count = n
case int64:
count = int(n)
default:
count = 0
}
res := make([]int, count)
for i := 0; i < count; i++ {
res[i] = i
}
return res
},
"min": func(a, b int) int {
if a < b {
return a
}
return b
},
"int": func(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case string:
i, _ := strconv.Atoi(n)
return i
default:
return 0
}
},
"percent": func(current, total float64) float64 {
if total == 0 {
return 0
}
return (current / total) * 100
},
}
pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml"))
if err != nil {
log.Fatalf("failed to glob page templates: %v", err)
}
components, err := filepath.Glob(filepath.Join(".", "templates", "components", "*.gohtml"))
if err != nil {
log.Fatalf("failed to glob component templates: %v", err)
}
for _, page := range pages {
name := filepath.Base(page)
if name == "base.gohtml" {
continue
}
tmpl := template.New(name).Funcs(funcs)
// Parse base first so it establishes the core definitions
tmpl = template.Must(tmpl.ParseFiles(filepath.Join(".", "templates", "base.gohtml")))
// Parse all components next so they are available to the page
if len(components) > 0 {
tmpl = template.Must(tmpl.ParseFiles(components...))
}
// Parse the page itself last
tmpl = template.Must(tmpl.ParseFiles(page))
renderer.templates[name] = tmpl
log.Printf("Loaded page template: %s", name)
}
})
return renderer
}
// ExecuteTemplate renders a named template into wr, returning early if context is cancelled
func (r *Renderer) ExecuteTemplate(ctx context.Context, wr io.Writer, name string, data any) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
tmpl, ok := r.templates[name]
if !ok {
return fmt.Errorf("template %s not found", name)
}
return tmpl.ExecuteTemplate(wr, "base.gohtml", data)
}
// ExecuteFragment renders a specific named block within a template (e.g. a component)
func (r *Renderer) ExecuteFragment(ctx context.Context, wr io.Writer, name string, block string, data any) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
tmpl, ok := r.templates[name]
if !ok {
return fmt.Errorf("template %s not found", name)
}
return tmpl.ExecuteTemplate(wr, block, data)
}

Some files were not shown because too many files have changed in this diff Show More