feat: add comments and cleanup unused imports across codebase
This commit is contained in:
@@ -28,10 +28,10 @@ func NewHandler(service *Service) *Handler {
|
||||
}
|
||||
|
||||
type quickSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Image string `json:"image"`
|
||||
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) {
|
||||
@@ -63,6 +63,7 @@ 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 := ""
|
||||
@@ -82,6 +83,7 @@ func (h *Handler) renderCatalogSection(w http.ResponseWriter, r *http.Request, s
|
||||
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 {
|
||||
log.Printf("fragment render error: %v", err)
|
||||
}
|
||||
@@ -133,15 +135,17 @@ func (h *Handler) renderDiscoverSection(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
sfw := r.URL.Query().Get("sfw") != "false" // default to safe
|
||||
|
||||
var genres []int
|
||||
for _, g := range r.URL.Query()["genres"] {
|
||||
@@ -165,6 +169,7 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -195,6 +200,7 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
@@ -237,6 +243,7 @@ func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "/")
|
||||
@@ -248,7 +255,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
|
||||
// If it's an HTMX request for a specific section, handle it
|
||||
// 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)
|
||||
@@ -264,10 +271,12 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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 {
|
||||
@@ -288,6 +297,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -298,6 +308,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 {
|
||||
@@ -329,6 +340,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -355,6 +367,7 @@ func (h *Handler) renderAnimeDetailsSection(w http.ResponseWriter, r *http.Reque
|
||||
tplName = "anime_recommendations"
|
||||
}
|
||||
|
||||
// render htmx partial for the section
|
||||
if err := templates.GetRenderer().ExecuteFragment(ctx, w, "anime.gohtml", tplName, data); err != nil {
|
||||
log.Printf("fragment render error: %v", err)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
@@ -27,6 +28,7 @@ func (s *Service) GetCatalogSection(ctx context.Context, userID string, section
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
// fetch jikan data (season now or top anime)
|
||||
g.Go(func() error {
|
||||
switch section {
|
||||
case "Airing":
|
||||
@@ -37,6 +39,7 @@ func (s *Service) GetCatalogSection(ctx context.Context, userID string, section
|
||||
return err
|
||||
})
|
||||
|
||||
// fetch user-specific data if logged in
|
||||
if userID != "" {
|
||||
g.Go(func() error {
|
||||
if section == "Continue" {
|
||||
@@ -57,6 +60,7 @@ func (s *Service) GetCatalogSection(ctx context.Context, userID string, section
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// limit to 6 items for homepage grid
|
||||
animes := res.Animes
|
||||
if len(animes) > 6 {
|
||||
animes = animes[:6]
|
||||
@@ -74,6 +78,7 @@ func (s *Service) GetCatalogSection(ctx context.Context, userID string, section
|
||||
}, 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
|
||||
@@ -107,6 +112,7 @@ func (s *Service) GetDiscoverSection(ctx context.Context, userID string, section
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// limit to 8 items for discover grid
|
||||
animes := res.Animes
|
||||
if len(animes) > 8 {
|
||||
animes = animes[:8]
|
||||
@@ -123,6 +129,7 @@ func (s *Service) GetDiscoverSection(ctx context.Context, userID string, section
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterUnique deduplicates anime list by mal id, respecting limit.
|
||||
func (s *Service) filterUnique(animes []jikan.Anime, seen map[int]bool, limit int) []jikan.Anime {
|
||||
unique := make([]jikan.Anime, 0)
|
||||
for _, a := range animes {
|
||||
|
||||
@@ -31,6 +31,7 @@ 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 {
|
||||
@@ -39,6 +40,7 @@ func generateToken(size int) (string, error) {
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// generateSessionToken creates a 32-byte session token
|
||||
func generateSessionToken() (string, error) {
|
||||
return generateToken(32)
|
||||
}
|
||||
@@ -84,7 +86,7 @@ func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*db.Us
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
_ = s.db.DeleteSession(ctx, sessionID)
|
||||
_ = s.db.DeleteSession(ctx, sessionID) // clean up expired session
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*db.Us
|
||||
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{
|
||||
@@ -113,11 +116,12 @@ 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),
|
||||
Expires: time.Unix(0, 0), // epoch to expire immediately
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
|
||||
@@ -17,6 +17,7 @@ func NewHandler(authService *Service) *Handler {
|
||||
return &Handler{authService: authService}
|
||||
}
|
||||
|
||||
// rateLimitErrorFromQuery checks for rate limit errors in the query string
|
||||
func rateLimitErrorFromQuery(r *http.Request) string {
|
||||
if r.URL.Query().Get("error") == "rate_limited" {
|
||||
return rateLimitFormError
|
||||
@@ -24,6 +25,7 @@ func rateLimitErrorFromQuery(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -32,6 +34,7 @@ func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{
|
||||
@@ -69,6 +72,7 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
|
||||
@@ -215,6 +215,7 @@ func (c *allAnimeClient) graphqlRequestWithHash(ctx context.Context, showID, epi
|
||||
return nil, fmt.Errorf("no usable data in response")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
|
||||
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
|
||||
@@ -387,6 +388,7 @@ type sourceReference struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// buildSourceReferences orders source URLs by provider priority, deduplicating entries.
|
||||
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
|
||||
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
|
||||
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
|
||||
@@ -416,6 +418,7 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
|
||||
|
||||
ref := sourceReference{URL: sourceURL, Name: sourceName}
|
||||
normalized := strings.ToLower(sourceName)
|
||||
// separate prioritized providers from fallback
|
||||
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
|
||||
if _, exists := prioritized[normalized]; !exists {
|
||||
prioritized[normalized] = ref
|
||||
@@ -426,6 +429,7 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
|
||||
fallback = append(fallback, ref)
|
||||
}
|
||||
|
||||
// output: prioritized in order, then fallback
|
||||
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
|
||||
for _, provider := range priorityOrder {
|
||||
if ref, ok := prioritized[provider]; ok {
|
||||
@@ -489,6 +493,7 @@ func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) ([]byte, er
|
||||
return plainText, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -557,6 +562,7 @@ func (c *allAnimeClient) Search(ctx context.Context, query string, mode string)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetEpisodes returns the list of available episode strings for a show and mode.
|
||||
func (c *allAnimeClient) GetEpisodes(ctx context.Context, showID string, mode string) ([]string, error) {
|
||||
graphqlQuery := `query($showId: String!) {
|
||||
show(_id: $showId) {
|
||||
@@ -607,6 +613,7 @@ func (c *allAnimeClient) GetEpisodes(ctx context.Context, showID string, mode st
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show.
|
||||
func (c *allAnimeClient) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
|
||||
graphqlQuery := `query($showId: String!) {
|
||||
show(_id: $showId) {
|
||||
|
||||
@@ -20,13 +20,14 @@ import (
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
jikanClient *jikan.Client
|
||||
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{
|
||||
@@ -36,8 +37,9 @@ func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWatchPage serves the anime watch page.
|
||||
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
// Path is like /anime/123/watch
|
||||
// path format: /anime/123/watch
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) < 4 {
|
||||
renderNotFoundPage(r, w)
|
||||
@@ -63,6 +65,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user := middleware.GetUser(r.Context())
|
||||
|
||||
// fetch user's watchlist to highlight episodes and show status
|
||||
var watchlistIDs []int64
|
||||
var watchlistStatus string
|
||||
if user != nil {
|
||||
@@ -76,7 +79,8 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
currentEpID := r.URL.Query().Get("ep")
|
||||
// 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{
|
||||
@@ -85,7 +89,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
if err == nil && entry.CurrentEpisode.Valid {
|
||||
currentEpID = strconv.FormatInt(entry.CurrentEpisode.Int64, 10)
|
||||
// Redirect to the correct episode URL to keep state consistent
|
||||
// redirect to include ep param for consistent URLs
|
||||
http.Redirect(w, r, fmt.Sprintf("/anime/%d/watch?ep=%s", id, currentEpID), http.StatusFound)
|
||||
return
|
||||
}
|
||||
@@ -147,7 +151,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
return allEpisodes[i].MalID < allEpisodes[j].MalID
|
||||
})
|
||||
|
||||
// Fetch seasons/relations
|
||||
// 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)
|
||||
@@ -204,6 +208,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
@@ -211,6 +216,7 @@ func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// determine proxy scope based on URL suffix
|
||||
scope := proxyScopeStream
|
||||
if strings.HasSuffix(r.URL.Path, "/segment") {
|
||||
scope = proxyScopeSegment
|
||||
@@ -244,6 +250,7 @@ func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -291,6 +298,7 @@ func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -337,9 +345,10 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
|
||||
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, "/")
|
||||
// /api/watch/episode/{animeId}/{episodeId}
|
||||
if len(parts) < 6 {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
@@ -394,9 +403,10 @@ func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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, "/")
|
||||
// /api/watch/thumbnails/{animeId}
|
||||
if len(parts) < 5 {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// doProxiedRequest performs an HTTP GET with standard playback headers.
|
||||
func doProxiedRequest(ctx context.Context, client *http.Client, url string, referer string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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")
|
||||
@@ -77,6 +78,7 @@ func (s *Service) SaveProgress(ctx context.Context, userID string, animeID int64
|
||||
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")
|
||||
|
||||
@@ -25,6 +25,7 @@ func newProviderExtractor() *providerExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractVideoLinks fetches provider page and returns stream sources.
|
||||
func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath string) ([]StreamSource, error) {
|
||||
endpoint := e.baseURL + providerPath
|
||||
|
||||
@@ -52,7 +53,7 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) // 2MB limit
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read provider response: %w", err)
|
||||
}
|
||||
@@ -60,10 +61,12 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
|
||||
return e.parseProviderResponse(ctx, string(body))
|
||||
}
|
||||
|
||||
// parseProviderResponse extracts stream sources from provider JSON response.
|
||||
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) ([]StreamSource, error) {
|
||||
sources := make([]StreamSource, 0)
|
||||
providerReferer := e.referer
|
||||
|
||||
// extract per-source referer if present
|
||||
refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`)
|
||||
if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 {
|
||||
providerReferer = strings.ReplaceAll(match[1], `\/`, "/")
|
||||
@@ -72,6 +75,7 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
providerReferer = e.referer
|
||||
}
|
||||
|
||||
// extract direct link sources (mp4/embed)
|
||||
linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`)
|
||||
for _, match := range linkPattern.FindAllStringSubmatch(response, -1) {
|
||||
if len(match) < 3 {
|
||||
@@ -94,6 +98,7 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
})
|
||||
}
|
||||
|
||||
// extract HLS playlist sources
|
||||
hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`)
|
||||
for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) {
|
||||
if len(match) < 2 {
|
||||
@@ -118,6 +123,7 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
})
|
||||
}
|
||||
|
||||
// extract subtitles and attach to all sources
|
||||
subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`)
|
||||
if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 {
|
||||
subtitles := make([]Subtitle, 0)
|
||||
@@ -143,6 +149,7 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality.
|
||||
func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, referer string) ([]StreamSource, error) {
|
||||
resp, err := doProxiedRequest(ctx, e.httpClient, masterURL, referer)
|
||||
if err != nil {
|
||||
@@ -150,7 +157,7 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) // 512KB limit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -178,6 +185,7 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
||||
continue
|
||||
}
|
||||
|
||||
// skip empty lines and non-stream lines
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ func (s *proxyTokenSigner) Sign(payload proxyTokenPayload) (string, error) {
|
||||
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
|
||||
@@ -87,7 +88,7 @@ func (s *proxyTokenSigner) Verify(token string) (proxyTokenPayload, error) {
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write(body)
|
||||
expected := mac.Sum(nil)
|
||||
if !hmac.Equal(signature, expected) {
|
||||
if !hmac.Equal(signature, expected) { // constant-time comparison
|
||||
return proxyTokenPayload{}, errors.New("invalid proxy token signature")
|
||||
}
|
||||
|
||||
@@ -107,6 +108,7 @@ func (s *Service) buildClientModeSources(modeSources map[string]ModeSource) (map
|
||||
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
|
||||
@@ -162,6 +164,7 @@ func (s *Service) issueProxyToken(targetURL string, referer string, scope proxyS
|
||||
})
|
||||
}
|
||||
|
||||
// proxyTokenTTLs defines ttl per scope type.
|
||||
var proxyTokenTTLs = map[proxyScope]time.Duration{
|
||||
proxyScopeStream: proxyStreamTokenTTL,
|
||||
proxyScopeSegment: proxySegmentTokenTTL,
|
||||
@@ -194,6 +197,7 @@ func (s *Service) resolveProxyToken(ctx context.Context, token string, scope pro
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// resolve referer only if it passes public target check
|
||||
normalizedReferer := ""
|
||||
if strings.TrimSpace(payload.Referer) != "" {
|
||||
refererURL, refererErr := normalizeProxyURL(payload.Referer)
|
||||
@@ -207,6 +211,7 @@ func (s *Service) resolveProxyToken(ctx context.Context, token string, scope pro
|
||||
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 {
|
||||
@@ -222,6 +227,7 @@ func normalizeProxyURL(rawURL string) (string, error) {
|
||||
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")
|
||||
}
|
||||
@@ -234,6 +240,7 @@ func normalizeProxyURL(rawURL string) (string, error) {
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// isBlockedProxyIP checks for loopback, private, multicast, and unspecified addresses.
|
||||
func isBlockedProxyIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
@@ -243,6 +250,8 @@ func isBlockedProxyIP(ip net.IP) bool {
|
||||
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 {
|
||||
@@ -254,6 +263,7 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
|
||||
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")
|
||||
@@ -261,6 +271,7 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// check cache first
|
||||
cached, ok := s.proxyHostCache.Get(host)
|
||||
if ok {
|
||||
if cached.Allowed {
|
||||
@@ -269,6 +280,7 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
|
||||
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")
|
||||
@@ -293,6 +305,7 @@ func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) er
|
||||
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 {
|
||||
@@ -310,6 +323,7 @@ func (s *Service) rewritePlaylistWithTokens(ctx context.Context, content string,
|
||||
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// preserve comments and empty lines
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
out.WriteString(line)
|
||||
out.WriteString("\n")
|
||||
|
||||
@@ -89,6 +89,7 @@ type userPlaybackState struct {
|
||||
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 {
|
||||
@@ -120,6 +121,7 @@ func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) (*Service, error) {
|
||||
}, 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")
|
||||
@@ -283,11 +285,13 @@ func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandida
|
||||
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() {
|
||||
@@ -321,8 +325,9 @@ func (s *Service) fetchPlaybackSourcesAndSegments(ctx context.Context, showID st
|
||||
segmentsCh <- s.fetchSkipSegments(ctx, malID, episode)
|
||||
}()
|
||||
|
||||
modeSources := make(map[string]ModeSource)
|
||||
for range 2 {
|
||||
modeSources := make(map[string]ModeSource)
|
||||
// collect results from both mode goroutines
|
||||
for range 2 {
|
||||
result := <-modeCh
|
||||
if !result.OK {
|
||||
continue
|
||||
@@ -344,6 +349,7 @@ func clonePlaybackBaseData(data playbackBaseData) playbackBaseData {
|
||||
}
|
||||
}
|
||||
|
||||
// GetEpisodeMetadata fetches episode notes and thumbnails from AllAnime.
|
||||
func (s *Service) GetEpisodeMetadata(ctx context.Context, malID int, episode string) (map[string]any, error) {
|
||||
showID, _, err := s.resolveShowCached(ctx, malID, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"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
|
||||
@@ -49,6 +51,7 @@ func (s *Service) fetchSkipSegments(ctx context.Context, malID int, episode stri
|
||||
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 {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"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
|
||||
@@ -51,8 +53,11 @@ func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer str
|
||||
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, rangeHeader 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 resp.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
@@ -73,12 +78,13 @@ func (s *Service) handleProxyResponse(ctx context.Context, resp *http.Response,
|
||||
return resp.StatusCode, headers, []byte(rewritten), nil, nil
|
||||
}
|
||||
|
||||
// for binary streams, remove chunked encoding and return body reader
|
||||
headers := cloneHeaders(resp.Header)
|
||||
// Some upstream servers send transfer-encoding chunked, we should let go's http server handle it
|
||||
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
|
||||
@@ -97,6 +103,8 @@ var hopHeaders = map[string]struct{}{
|
||||
"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 {
|
||||
|
||||
@@ -49,6 +49,7 @@ func rankSources(sources []StreamSource, quality string) ([]sourceScore, error)
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
@@ -97,6 +98,7 @@ func lookupPriority(m map[string]int, key string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// sourceQualityPriority scores quality match: exact match gets boost, mismatch gets penalty.
|
||||
func sourceQualityPriority(sourceQuality string, targetQuality string) int {
|
||||
qualityValue := parseQualityValue(sourceQuality)
|
||||
|
||||
@@ -114,6 +116,7 @@ func sourceQualityPriority(sourceQuality string, targetQuality string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -129,6 +132,7 @@ func qualityMatches(sourceQuality string, targetQuality string) bool {
|
||||
return extractDigits(sourceLower) == extractDigits(targetLower)
|
||||
}
|
||||
|
||||
// parseQualityValue extracts numeric value from quality string.
|
||||
func parseQualityValue(rawQuality string) int {
|
||||
lower := strings.ToLower(rawQuality)
|
||||
if lower == "auto" {
|
||||
@@ -147,6 +151,7 @@ func parseQualityValue(rawQuality string) int {
|
||||
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 {
|
||||
@@ -159,6 +164,7 @@ func extractDigits(value string) string {
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
// normalizeSourceTypeFromProbe overrides source type based on Content-Type header.
|
||||
func normalizeSourceTypeFromProbe(source StreamSource, contentType string) StreamSource {
|
||||
lower := strings.ToLower(contentType)
|
||||
switch {
|
||||
@@ -170,6 +176,7 @@ func normalizeSourceTypeFromProbe(source StreamSource, contentType string) Strea
|
||||
return source
|
||||
}
|
||||
|
||||
// isLikelyMP4 checks ftyp box header (bytes 4-8 of mp4 files).
|
||||
func isLikelyMP4(payload []byte) bool {
|
||||
if len(payload) < 12 {
|
||||
return false
|
||||
@@ -178,6 +185,7 @@ func isLikelyMP4(payload []byte) bool {
|
||||
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")
|
||||
|
||||
@@ -19,6 +19,7 @@ func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []
|
||||
|
||||
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
|
||||
}
|
||||
@@ -31,6 +32,7 @@ func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []
|
||||
continue
|
||||
}
|
||||
|
||||
// fallback to first result if no exact match
|
||||
best := results[0]
|
||||
if strings.TrimSpace(best.ID) != "" {
|
||||
return best.ID, best.Name, nil
|
||||
@@ -47,7 +49,7 @@ func (s *Service) searchShowResultsByMode(ctx context.Context, query string, mod
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, mode := range modeCandidates {
|
||||
modeValue := mode
|
||||
modeValue := mode // capture loop variable
|
||||
wg.Go(func() {
|
||||
results, err := s.allAnimeClient.Search(ctx, query, modeValue)
|
||||
searchCh <- searchModeResult{Mode: modeValue, Results: results, Err: err}
|
||||
@@ -96,6 +98,7 @@ func buildTitleSearchQueries(titleCandidates []string) []string {
|
||||
add(normalized)
|
||||
add(strings.ReplaceAll(normalized, "+", " "))
|
||||
|
||||
// strip apostrophes to improve match rate
|
||||
withoutApostrophes := strings.NewReplacer("'", "", "’", "", "`", "").Replace(normalized)
|
||||
add(withoutApostrophes)
|
||||
add(strings.ReplaceAll(withoutApostrophes, "+", " "))
|
||||
@@ -144,6 +147,7 @@ func availableModes(modeSources map[string]ModeSource) []string {
|
||||
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 != "" {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// resolveModeSource fetches sources for a given mode and selects the best one.
|
||||
func (s *Service) resolveModeSource(ctx context.Context, showID string, episode string, mode string, quality string) (StreamSource, error) {
|
||||
sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode)
|
||||
if err != nil {
|
||||
@@ -28,6 +29,7 @@ func (s *Service) resolveModeSource(ctx context.Context, showID string, episode
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
// resolveModeSourceWithCache is like resolveModeSource but caches probe results.
|
||||
func (s *Service) resolveModeSourceWithCache(
|
||||
ctx context.Context,
|
||||
showID string,
|
||||
@@ -56,6 +58,8 @@ func (s *Service) resolveModeSourceWithCache(
|
||||
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,
|
||||
@@ -70,22 +74,25 @@ func (s *Service) choosePlaybackSource(
|
||||
source := candidate.source
|
||||
switch strings.ToLower(source.Type) {
|
||||
case "mp4", "m3u8":
|
||||
return source, "direct-media", nil
|
||||
return source, "direct-media", nil // known playable types
|
||||
case "embed":
|
||||
embedCandidates = append(embedCandidates, source)
|
||||
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
|
||||
}
|
||||
@@ -93,6 +100,7 @@ func (s *Service) choosePlaybackSource(
|
||||
return ranked[0].source, "ranked-fallback", nil
|
||||
}
|
||||
|
||||
// choosePlaybackSourceWithCache wraps choosePlaybackSource with cached probing.
|
||||
func (s *Service) choosePlaybackSourceWithCache(
|
||||
ctx context.Context,
|
||||
ranked []sourceScore,
|
||||
@@ -131,6 +139,8 @@ func (s *Service) probeDirectMediaCached(
|
||||
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()
|
||||
@@ -144,7 +154,7 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
|
||||
req.Header.Set("Referer", source.Referer)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Range", "bytes=0-4095")
|
||||
req.Header.Set("Range", "bytes=0-4095") // small range to detect playable content
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -152,11 +162,13 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
|
||||
}
|
||||
defer 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) {
|
||||
@@ -167,6 +179,7 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to URL extension
|
||||
finalURL := ""
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = strings.ToLower(resp.Request.URL.String())
|
||||
@@ -179,6 +192,8 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
|
||||
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()
|
||||
@@ -203,6 +218,7 @@ func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) boo
|
||||
return false
|
||||
}
|
||||
|
||||
// check for common deletion messages
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package playback
|
||||
|
||||
// StreamSource represents a video stream from a provider.
|
||||
type StreamSource struct {
|
||||
URL string
|
||||
Quality string
|
||||
Provider string
|
||||
Type string
|
||||
Type string // m3u8, mp4, embed, unknown
|
||||
Referer string
|
||||
Subtitles []Subtitle
|
||||
AvailableQualities []StreamSource
|
||||
@@ -36,6 +37,7 @@ type SkipSegment struct {
|
||||
End float64 `json:"end"`
|
||||
}
|
||||
|
||||
// WatchPageData is the response payload for the watch page frontend.
|
||||
type WatchPageData struct {
|
||||
MalID int
|
||||
Title string
|
||||
|
||||
@@ -19,6 +19,7 @@ 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)
|
||||
@@ -40,6 +41,7 @@ func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// default status if not provided
|
||||
if body.Status == "" {
|
||||
body.Status = "plan_to_watch"
|
||||
}
|
||||
@@ -53,6 +55,7 @@ func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request)
|
||||
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 {
|
||||
@@ -73,10 +76,12 @@ func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request)
|
||||
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 {
|
||||
@@ -101,6 +106,7 @@ func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Re
|
||||
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 {
|
||||
@@ -119,6 +125,7 @@ func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// group entries by status for display
|
||||
watchlistByStatus := make(map[string][]db.GetUserWatchListRow)
|
||||
allEntries := make([]db.GetUserWatchListRow, 0)
|
||||
watchlistIDs := make([]int64, len(entries))
|
||||
@@ -149,6 +156,7 @@ func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
}
|
||||
|
||||
// use partial template for htmx requests
|
||||
templateName := "watchlist.gohtml"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
templateName = "watchlist_partial.gohtml"
|
||||
|
||||
@@ -36,12 +36,14 @@ func NewService(db db.Querier, sqlDB *sql.DB, jikanClient *jikan.Client) *Servic
|
||||
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
|
||||
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)
|
||||
@@ -72,6 +74,7 @@ type AddRequest struct {
|
||||
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
|
||||
@@ -81,6 +84,7 @@ func (s *Service) AddToWatchlist(ctx context.Context, userID string, animeID int
|
||||
return ErrInvalidStatus
|
||||
}
|
||||
|
||||
// ensure anime exists in local db before linking
|
||||
if err := s.ensureAnimeExists(ctx, animeID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -101,6 +105,7 @@ func (s *Service) AddToWatchlist(ctx context.Context, userID string, animeID int
|
||||
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
|
||||
@@ -122,6 +127,7 @@ func (s *Service) RemoveEntry(ctx context.Context, userID string, animeID int64)
|
||||
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 {
|
||||
@@ -130,6 +136,7 @@ func (s *Service) GetUserWatchlist(ctx context.Context, userID string) ([]db.Get
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// GetContinueWatching retrieves entries for continue watching section.
|
||||
func (s *Service) GetContinueWatching(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return nil, errors.New("invalid user id")
|
||||
@@ -143,6 +150,8 @@ func (s *Service) GetContinueWatching(ctx context.Context, userID string) ([]db.
|
||||
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")
|
||||
@@ -164,6 +173,7 @@ func (s *Service) DeleteContinueWatching(ctx context.Context, userID string, ani
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user