feat: add comments and cleanup unused imports across codebase

This commit is contained in:
2026-05-10 20:00:04 +02:00
parent b152e246ff
commit e48d95cb4e
68 changed files with 560 additions and 88 deletions

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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")

View File

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

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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 != "" {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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