feat(playback): add watch backend

This commit is contained in:
2026-04-18 05:55:42 +02:00
parent e21474ca55
commit 0dfd67be47
6 changed files with 1822 additions and 1 deletions

View File

@@ -0,0 +1,507 @@
package playback
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
allAnimeBaseURL = "https://api.allanime.day"
allAnimeReferer = "https://allmanga.to"
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"
)
type searchResult struct {
ID string
MalID string
Name string
}
type allAnimeClient struct {
httpClient *http.Client
extractor *providerExtractor
}
func newAllAnimeClient() *allAnimeClient {
return &allAnimeClient{
httpClient: &http.Client{Timeout: 12 * time.Second},
extractor: newProviderExtractor(),
}
}
func (c *allAnimeClient) graphqlRequest(ctx context.Context, query string, variables map[string]interface{}) (map[string]interface{}, error) {
payload := map[string]interface{}{
"query": query,
"variables": variables,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal graphql payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create graphql request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", allAnimeReferer)
req.Header.Set("User-Agent", defaultUserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("execute graphql request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
return nil, fmt.Errorf("read graphql response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graphql status %d", resp.StatusCode)
}
var parsed map[string]interface{}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("decode graphql response: %w", err)
}
if errs, ok := parsed["errors"].([]interface{}); ok && len(errs) > 0 {
return nil, fmt.Errorf("graphql error: %v", errs[0])
}
return parsed, nil
}
func (c *allAnimeClient) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) {
shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) {
edges {
_id
malId
name
}
}
}`
variables := map[string]interface{}{
"search": map[string]interface{}{
"allowAdult": false,
"allowUnknown": false,
"query": query,
},
"limit": 40,
"page": 1,
"translationType": mode,
"countryOrigin": "ALL",
}
result, err := c.graphqlRequest(ctx, graphqlQuery, variables)
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid search response")
}
shows, ok := data["shows"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid shows payload")
}
edges, ok := shows["edges"].([]interface{})
if !ok {
return nil, fmt.Errorf("invalid search edges")
}
out := make([]searchResult, 0, len(edges))
for _, edge := range edges {
item, ok := edge.(map[string]interface{})
if !ok {
continue
}
id, _ := item["_id"].(string)
malID, _ := item["malId"].(string)
name, _ := item["name"].(string)
name = strings.ReplaceAll(name, `\\"`, `"`)
name = strings.ReplaceAll(name, `\"`, `"`)
name = strings.TrimSpace(name)
if id == "" {
continue
}
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
}
return out, nil
}
func (c *allAnimeClient) GetEpisodes(ctx context.Context, showID string, mode string) ([]string, error) {
graphqlQuery := `query($showId: String!) {
show(_id: $showId) {
availableEpisodesDetail
}
}`
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]interface{}{"showId": showID})
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid episode response")
}
show, ok := data["show"].(map[string]interface{})
if !ok || show == nil {
return nil, fmt.Errorf("show not found")
}
detail, ok := show["availableEpisodesDetail"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid episodes detail")
}
rawList, ok := detail[mode].([]interface{})
if !ok {
return nil, fmt.Errorf("no episodes for mode %s", mode)
}
episodes := make([]string, 0, len(rawList))
for _, item := range rawList {
episode, ok := item.(string)
if !ok {
continue
}
episode = strings.TrimSpace(episode)
if episode == "" {
continue
}
episodes = append(episodes, episode)
}
return episodes, nil
}
func (c *allAnimeClient) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
graphqlQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
sourceUrls
}
}`
variables := map[string]interface{}{
"showId": showID,
"translationType": mode,
"episodeString": episode,
}
result, err := c.graphqlRequest(ctx, graphqlQuery, variables)
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid source response")
}
episodeData, err := extractEpisodeData(data)
if err != nil {
return nil, err
}
rawSourceURLs, ok := episodeData["sourceUrls"].([]interface{})
if !ok || len(rawSourceURLs) == 0 {
return nil, fmt.Errorf("no source urls")
}
references := buildSourceReferences(rawSourceURLs)
if len(references) == 0 {
return nil, fmt.Errorf("no source references")
}
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
target := strings.TrimSpace(ref.URL)
if target == "" {
continue
}
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
sourceType := detectStreamType(target)
if sourceType == "unknown" {
sourceType = detectEmbedType(target)
}
out = append(out, StreamSource{
URL: target,
Provider: ref.Name,
Type: sourceType,
Referer: allAnimeReferer,
})
continue
}
decoded := decodeSourceURL(target)
if decoded == "" {
continue
}
if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") {
sourceType := detectStreamType(decoded)
if sourceType == "unknown" {
sourceType = detectEmbedType(decoded)
}
out = append(out, StreamSource{
URL: decoded,
Provider: ref.Name,
Type: sourceType,
Referer: allAnimeReferer,
})
continue
}
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
if err != nil {
continue
}
out = append(out, extracted...)
}
if len(out) == 0 {
return nil, fmt.Errorf("no playable sources extracted")
}
return out, nil
}
type sourceReference struct {
URL string
Name string
}
func buildSourceReferences(rawSourceURLs []interface{}) []sourceReference {
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
prioritized := make(map[string]sourceReference)
fallback := make([]sourceReference, 0, len(rawSourceURLs))
seen := make(map[string]struct{})
for _, source := range rawSourceURLs {
item, ok := source.(map[string]interface{})
if !ok {
continue
}
sourceURL, _ := item["sourceUrl"].(string)
sourceName, _ := item["sourceName"].(string)
sourceURL = strings.TrimSpace(sourceURL)
sourceName = strings.TrimSpace(sourceName)
if sourceURL == "" {
continue
}
if _, exists := seen[sourceURL]; exists {
continue
}
seen[sourceURL] = struct{}{}
ref := sourceReference{URL: sourceURL, Name: sourceName}
normalized := strings.ToLower(sourceName)
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
if _, exists := prioritized[normalized]; !exists {
prioritized[normalized] = ref
}
continue
}
fallback = append(fallback, ref)
}
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
for _, provider := range priorityOrder {
if ref, ok := prioritized[provider]; ok {
ordered = append(ordered, ref)
}
}
ordered = append(ordered, fallback...)
return ordered
}
func extractEpisodeData(data map[string]interface{}) (map[string]interface{}, error) {
episodeData, ok := data["episode"].(map[string]interface{})
if ok && episodeData != nil {
return episodeData, nil
}
toBeParsed, ok := data["tobeparsed"].(string)
if !ok || strings.TrimSpace(toBeParsed) == "" {
return nil, fmt.Errorf("episode not found")
}
decoded, err := decryptTobeparsed(toBeParsed)
if err != nil {
return nil, fmt.Errorf("decode episode payload: %w", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(decoded, &parsed); err != nil {
return nil, fmt.Errorf("parse decoded payload: %w", err)
}
episodeData, ok = parsed["episode"].(map[string]interface{})
if !ok || episodeData == nil {
return nil, fmt.Errorf("decoded payload missing episode")
}
return episodeData, nil
}
func decryptTobeparsed(encoded string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("base64 decode failed: %w", err)
}
if len(raw) < 29 {
return nil, fmt.Errorf("encrypted payload too short")
}
iv := raw[:12]
cipherText := raw[12 : len(raw)-16]
tag := raw[len(raw)-16:]
key := sha256.Sum256([]byte("SimtVuagFbGR2K7P"))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, fmt.Errorf("cipher init failed: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err == nil {
combined := append(append([]byte{}, cipherText...), tag...)
plainText, openErr := gcm.Open(nil, iv, combined, nil)
if openErr == nil && json.Valid(plainText) {
return plainText, nil
}
}
ctrIV := append([]byte{}, iv...)
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
ctr := cipher.NewCTR(block, ctrIV)
plainText := make([]byte, len(cipherText))
ctr.XORKeyStream(plainText, cipherText)
if !json.Valid(plainText) {
return nil, fmt.Errorf("decryption failed")
}
return plainText, nil
}
func decodeSourceURL(encoded string) string {
if encoded == "" {
return ""
}
if strings.HasPrefix(encoded, "--") {
encoded = encoded[2:]
}
substitutions := map[string]string{
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
"62": "Z",
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
"42": "z",
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
"05": "=", "1d": "%",
}
var result strings.Builder
for idx := 0; idx < len(encoded); {
if idx+2 <= len(encoded) {
pair := encoded[idx : idx+2]
if sub, ok := substitutions[pair]; ok {
result.WriteString(sub)
idx += 2
continue
}
}
result.WriteByte(encoded[idx])
idx++
}
decoded := result.String()
if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
}
return decoded
}
func detectStreamType(sourceURL string) string {
lower := strings.ToLower(sourceURL)
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
return "m3u8"
}
if strings.Contains(lower, ".mp4") {
return "mp4"
}
return "unknown"
}
func detectEmbedType(rawURL string) string {
lower := strings.ToLower(rawURL)
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
for _, host := range embedHosts {
if strings.Contains(lower, host) {
return "embed"
}
}
return "unknown"
}

View File

@@ -0,0 +1,227 @@
package playback
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"mal/internal/jikan"
"mal/internal/templates"
)
type Handler struct {
svc *Service
jikanClient *jikan.Client
}
func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
return &Handler{svc: svc, jikanClient: jikanClient}
}
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, "/watch/")
path = strings.Trim(path, "/")
if path == "" || strings.HasPrefix(path, "proxy/") {
http.NotFound(w, r)
return
}
parts := strings.Split(path, "/")
if len(parts) < 1 {
http.NotFound(w, r)
return
}
malID, err := strconv.Atoi(parts[0])
if err != nil || malID <= 0 {
http.NotFound(w, r)
return
}
// Get episode from path if provided, otherwise from query
episode := ""
if len(parts) >= 2 {
episode = strings.TrimSpace(parts[1])
}
if episode == "" {
episode = strings.TrimSpace(r.URL.Query().Get("ep"))
}
if episode == "" {
episode = "1"
}
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
// Fetch anime details
anime, err := h.jikanClient.GetAnimeByID(ctx, malID)
if err != nil {
log.Printf("failed to fetch anime %d: %v", malID, err)
http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError)
return
}
title := anime.DisplayTitle()
data, err := h.svc.BuildWatchPageData(ctx, malID, title, episode, mode)
if err != nil {
log.Printf("watch page error for mal_id=%d: %v", malID, err)
http.Error(w, "Failed to load playback", http.StatusBadGateway)
return
}
// Convert playback.WatchPageData to templates.WatchPageData
pageData := templates.WatchPageData{
MalID: data.MalID,
Title: data.Title,
CurrentEpisode: data.CurrentEpisode,
InitialMode: data.InitialMode,
AvailableModes: data.AvailableModes,
ModeSources: convertModeSources(data.ModeSources),
Segments: convertSegments(data.Segments),
}
templates.WatchPage(anime, pageData).Render(r.Context(), w)
}
func convertModeSources(sources map[string]ModeSource) map[string]templates.ModeSource {
result := make(map[string]templates.ModeSource, len(sources))
for k, v := range sources {
subtitles := make([]templates.SubtitleItem, len(v.Subtitles))
for i, s := range v.Subtitles {
subtitles[i] = templates.SubtitleItem{
Lang: s.Lang,
URL: s.URL,
Referer: s.Referer,
}
}
result[k] = templates.ModeSource{
URL: v.URL,
Referer: v.Referer,
Subtitles: subtitles,
}
}
return result
}
func convertSegments(segments []SkipSegment) []templates.SkipSegment {
result := make([]templates.SkipSegment, len(segments))
for i, s := range segments {
result[i] = templates.SkipSegment{
Type: s.Type,
Start: s.Start,
End: s.End,
}
}
return result
}
func (h *Handler) HandleProxyStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
mode := normalizeMode(r.URL.Query().Get("mode"))
if mode == "" {
mode = "dub"
}
state := r.URL.Query().Get("state")
if strings.TrimSpace(state) == "" {
http.Error(w, "missing playback state", http.StatusBadRequest)
return
}
modeSources := make(map[string]ModeSource)
if err := json.Unmarshal([]byte(state), &modeSources); err != nil {
http.Error(w, "invalid playback state", http.StatusBadRequest)
return
}
source, ok := modeSources[mode]
if !ok || strings.TrimSpace(source.URL) == "" {
http.Error(w, "stream mode unavailable", http.StatusBadRequest)
return
}
h.proxyUpstream(w, r, source.URL, source.Referer)
}
func (h *Handler) HandleProxySegment(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
targetURL := r.URL.Query().Get("u")
if strings.TrimSpace(targetURL) == "" {
http.Error(w, "missing target url", http.StatusBadRequest)
return
}
h.proxyUpstream(w, r, targetURL, r.URL.Query().Get("r"))
}
func (h *Handler) HandleProxySubtitle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
targetURL := r.URL.Query().Get("u")
if strings.TrimSpace(targetURL) == "" {
http.Error(w, "missing target url", http.StatusBadRequest)
return
}
h.proxyUpstream(w, r, targetURL, r.URL.Query().Get("r"))
}
func (h *Handler) proxyUpstream(w http.ResponseWriter, r *http.Request, targetURL string, referer string) {
parsed, err := url.Parse(targetURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
http.Error(w, "invalid upstream url", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
statusCode, headers, rewrittenBody, streamBody, err := h.svc.ProxyStream(ctx, targetURL, referer, r.Header.Get("Range"))
if err != nil {
log.Printf("proxy error for url=%s: %v", targetURL, err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
for key, values := range headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(statusCode)
if len(rewrittenBody) > 0 {
_, _ = w.Write(rewrittenBody)
return
}
if streamBody == nil {
return
}
defer streamBody.Close()
_, _ = io.Copy(w, streamBody)
}

View File

@@ -0,0 +1,212 @@
package playback
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
type providerExtractor struct {
httpClient *http.Client
baseURL string
referer string
}
func newProviderExtractor() *providerExtractor {
return &providerExtractor{
httpClient: &http.Client{Timeout: 12 * time.Second},
baseURL: "https://allanime.day",
referer: allAnimeReferer,
}
}
func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath string) ([]StreamSource, error) {
endpoint := e.baseURL + providerPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create provider request: %w", err)
}
req.Header.Set("Referer", e.referer)
req.Header.Set("User-Agent", defaultUserAgent)
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch provider response: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
return nil, fmt.Errorf("read provider response: %w", err)
}
return e.parseProviderResponse(ctx, string(body))
}
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) ([]StreamSource, error) {
sources := make([]StreamSource, 0)
providerReferer := e.referer
refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`)
if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 {
providerReferer = strings.ReplaceAll(match[1], `\/`, "/")
}
if providerReferer == "" {
providerReferer = e.referer
}
linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`)
for _, match := range linkPattern.FindAllStringSubmatch(response, -1) {
if len(match) < 3 {
continue
}
link := strings.ReplaceAll(match[1], `\/`, "/")
quality := strings.TrimSpace(match[2])
sourceType := detectStreamType(link)
if sourceType == "unknown" {
sourceType = detectEmbedType(link)
}
sources = append(sources, StreamSource{
URL: link,
Quality: quality,
Provider: "wixmp",
Type: sourceType,
Referer: providerReferer,
})
}
hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`)
for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) {
if len(match) < 2 {
continue
}
playlistURL := strings.ReplaceAll(match[1], `\/`, "/")
if strings.Contains(playlistURL, "master.m3u8") {
parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer)
if err == nil {
sources = append(sources, parsed...)
}
continue
}
sources = append(sources, StreamSource{
URL: playlistURL,
Quality: "auto",
Provider: "hls",
Type: "m3u8",
Referer: providerReferer,
})
}
subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`)
if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 {
subtitles := make([]Subtitle, 0)
subtitleEntryPattern := regexp.MustCompile(`"lang":"([^"]+)".*?"src":"([^"]+)"`)
for _, entry := range subtitleEntryPattern.FindAllStringSubmatch(subtitleMatch[1], -1) {
if len(entry) < 3 {
continue
}
subtitles = append(subtitles, Subtitle{
Lang: strings.TrimSpace(entry[1]),
URL: strings.ReplaceAll(entry[2], `\/`, "/"),
})
}
if len(subtitles) > 0 {
for idx := range sources {
sources[idx].Subtitles = subtitles
}
}
}
return sources, nil
}
func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, referer string) ([]StreamSource, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
if err != nil {
return nil, err
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", defaultUserAgent)
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil {
return nil, err
}
lines := strings.Split(string(body), "\n")
baseURL := masterURL
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
baseURL = masterURL[:idx+1]
}
currentBandwidth := 0
sources := make([]StreamSource, 0)
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") {
match := bwPattern.FindStringSubmatch(trimmed)
if len(match) >= 2 {
value, convErr := strconv.Atoi(match[1])
if convErr == nil {
currentBandwidth = value
}
}
continue
}
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
streamURL := trimmed
if !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") {
streamURL = baseURL + streamURL
}
quality := "auto"
kbps := currentBandwidth / 1000
switch {
case kbps >= 8000:
quality = "1080p"
case kbps >= 5000:
quality = "720p"
case kbps >= 2500:
quality = "480p"
case kbps > 0:
quality = "360p"
}
sources = append(sources, StreamSource{
URL: streamURL,
Quality: quality,
Provider: "hls",
Type: "m3u8",
Referer: referer,
})
}
return sources, nil
}

View File

@@ -0,0 +1,816 @@
package playback
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"mal/internal/jikan"
)
type Service struct {
allAnimeClient *allAnimeClient
jikanClient *jikan.Client
httpClient *http.Client
}
type sourceScore struct {
source StreamSource
total int
typeScore int
providerScore int
qualityScore int
refererScore int
}
func NewService(jikanClient *jikan.Client) *Service {
return &Service{
allAnimeClient: newAllAnimeClient(),
jikanClient: jikanClient,
httpClient: &http.Client{Timeout: 12 * time.Second},
}
}
func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title string, episode string, mode 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"
}
showID, resolvedTitle, err := s.resolveShow(ctx, malID, title)
if err != nil {
return WatchPageData{}, err
}
modeSources := make(map[string]ModeSource)
for _, sourceMode := range []string{"dub", "sub"} {
resolved, resolveErr := s.resolveModeSource(ctx, showID, normalizedEpisode, sourceMode, "best")
if resolveErr != nil {
continue
}
if strings.ToLower(resolved.Type) == "embed" {
continue
}
modeSources[sourceMode] = ModeSource{
URL: resolved.URL,
Referer: resolved.Referer,
Subtitles: toSubtitleItems(resolved),
}
}
if len(modeSources) == 0 {
return WatchPageData{}, errors.New("no direct playable sources available")
}
availableModes := availableModes(modeSources)
initialMode := selectInitialMode(normalizedMode, modeSources)
episodes := s.fetchEpisodeList(ctx, malID)
if len(episodes) == 0 {
episodeNumbers := s.fetchModeEpisodes(ctx, showID, initialMode)
episodes = fallbackEpisodeList(episodeNumbers)
}
segments := s.fetchSkipSegments(ctx, malID, normalizedEpisode)
watchTitle := strings.TrimSpace(resolvedTitle)
if watchTitle == "" {
watchTitle = strings.TrimSpace(title)
}
if watchTitle == "" {
watchTitle = fmt.Sprintf("MAL #%d", malID)
}
return WatchPageData{
MalID: malID,
Title: watchTitle,
CurrentEpisode: normalizedEpisode,
InitialMode: initialMode,
AvailableModes: availableModes,
ModeSources: modeSources,
Episodes: episodes,
Segments: segments,
}, nil
}
func (s *Service) resolveShow(ctx context.Context, malID int, title string) (string, string, error) {
malText := strconv.Itoa(malID)
modeCandidates := []string{"sub", "dub"}
for _, mode := range modeCandidates {
results, err := s.allAnimeClient.Search(ctx, title, mode)
if err != nil {
continue
}
for _, result := range results {
if strings.TrimSpace(result.MalID) == malText && strings.TrimSpace(result.ID) != "" {
return result.ID, result.Name, nil
}
}
}
if strings.TrimSpace(title) != "" {
for _, mode := range modeCandidates {
results, err := s.allAnimeClient.Search(ctx, title, mode)
if err != nil || len(results) == 0 {
continue
}
best := results[0]
if strings.TrimSpace(best.ID) != "" {
return best.ID, best.Name, nil
}
}
}
return "", "", errors.New("unable to resolve allanime show")
}
func (s *Service) resolveModeSource(ctx context.Context, showID string, episode string, mode string, quality string) (StreamSource, error) {
sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode)
if err != nil {
return StreamSource{}, err
}
ranked, err := rankSources(sources, quality)
if err != nil {
return StreamSource{}, err
}
selected, _, err := s.choosePlaybackSource(ctx, ranked)
if err != nil {
return StreamSource{}, err
}
return selected, nil
}
func (s *Service) choosePlaybackSource(ctx context.Context, ranked []sourceScore) (StreamSource, string, error) {
if len(ranked) == 0 {
return StreamSource{}, "", errors.New("no ranked sources available")
}
embedCandidates := make([]StreamSource, 0)
for _, candidate := range ranked {
source := candidate.source
sourceType := strings.ToLower(source.Type)
switch sourceType {
case "mp4", "m3u8":
return source, "direct-media", nil
case "embed":
embedCandidates = append(embedCandidates, source)
default:
playable, contentType := s.probeDirectMedia(ctx, source)
if playable {
return normalizeSourceTypeFromProbe(source, contentType), "probed-media", nil
}
}
}
for _, embed := range embedCandidates {
if s.probeEmbedSource(ctx, embed) {
return embed, "embed-probed", nil
}
}
if len(embedCandidates) > 0 {
return embedCandidates[0], "embed-fallback", nil
}
return ranked[0].source, "ranked-fallback", nil
}
func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bool, string) {
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)
req.Header.Set("Range", "bytes=0-4095")
resp, err := s.httpClient.Do(req)
if err != nil {
return false, ""
}
defer resp.Body.Close()
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if strings.Contains(contentType, "video/") || strings.Contains(contentType, "mpegurl") {
return true, contentType
}
prefix, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err == nil {
if isLikelyM3U8(prefix) {
return true, "application/vnd.apple.mpegurl"
}
if isLikelyMP4(prefix) {
return true, "video/mp4"
}
}
finalURL := ""
if resp.Request != nil && resp.Request.URL != nil {
finalURL = strings.ToLower(resp.Request.URL.String())
}
if strings.Contains(finalURL, ".mp4") || strings.Contains(finalURL, ".m3u8") {
return true, contentType
}
return false, contentType
}
func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) bool {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.URL, nil)
if err != nil {
return false
}
if source.Referer != "" {
req.Header.Set("Referer", source.Referer)
}
req.Header.Set("User-Agent", defaultUserAgent)
resp, err := s.httpClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil {
return false
}
content := strings.ToLower(string(body))
markers := []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",
}
for _, marker := range markers {
if strings.Contains(content, marker) {
return false
}
}
return true
}
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))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil
}
req.Header.Set("User-Agent", defaultUserAgent)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil {
return nil
}
type resultItem struct {
SkipType string `json:"skip_type"`
Interval struct {
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
} `json:"interval"`
}
type apiResponse struct {
Found bool `json:"found"`
Result []resultItem `json:"results"`
}
var parsed apiResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil
}
segments := make([]SkipSegment, 0, len(parsed.Result))
for _, item := range parsed.Result {
if item.Interval.EndTime <= item.Interval.StartTime {
continue
}
t := strings.ToLower(item.SkipType)
if t != "op" && t != "ed" {
continue
}
segments = append(segments, SkipSegment{
Type: t,
Start: item.Interval.StartTime,
End: item.Interval.EndTime,
})
}
return segments
}
func (s *Service) fetchEpisodeList(ctx context.Context, malID int) []EpisodeListItem {
if malID <= 0 {
return nil
}
items := make([]EpisodeListItem, 0)
for page := 1; page <= 20; page++ {
result, err := s.jikanClient.GetEpisodes(ctx, malID, page)
if err != nil {
return items
}
for _, episode := range result.Data {
if episode.MalID <= 0 {
continue
}
items = append(items, EpisodeListItem{
Number: strconv.Itoa(episode.MalID),
Title: strings.TrimSpace(episode.Title),
Filler: episode.Filler,
Recap: episode.Recap,
Order: episode.MalID,
})
}
if !result.Pagination.HasNextPage {
break
}
}
return items
}
func (s *Service) fetchModeEpisodes(ctx context.Context, showID string, mode string) []string {
episodes, err := s.allAnimeClient.GetEpisodes(ctx, showID, mode)
if err == nil && len(episodes) > 0 {
return episodes
}
fallbackMode := "sub"
if mode == "sub" {
fallbackMode = "dub"
}
fallbackEpisodes, fallbackErr := s.allAnimeClient.GetEpisodes(ctx, showID, fallbackMode)
if fallbackErr != nil {
return nil
}
return fallbackEpisodes
}
func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer string, rangeHeader string) (int, http.Header, []byte, io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return 0, nil, nil, nil, fmt.Errorf("invalid upstream url: %w", err)
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", defaultUserAgent)
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return 0, nil, nil, nil, fmt.Errorf("upstream request failed: %w", err)
}
if isM3U8(targetURL, resp.Header.Get("Content-Type")) {
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if readErr != nil {
return 0, nil, nil, nil, fmt.Errorf("read playlist failed: %w", readErr)
}
rewritten, rewriteErr := rewritePlaylist(string(body), targetURL, referer)
if rewriteErr != nil {
return 0, nil, nil, nil, fmt.Errorf("rewrite playlist failed: %w", rewriteErr)
}
headers := cloneHeaders(resp.Header)
headers.Set("Content-Type", "application/vnd.apple.mpegurl")
return resp.StatusCode, headers, []byte(rewritten), nil, nil
}
headers := cloneHeaders(resp.Header)
return resp.StatusCode, headers, nil, resp.Body, nil
}
func fallbackEpisodeList(episodeNumbers []string) []EpisodeListItem {
items := make([]EpisodeListItem, 0, len(episodeNumbers))
for idx, number := range episodeNumbers {
trimmed := strings.TrimSpace(number)
if trimmed == "" {
continue
}
items = append(items, EpisodeListItem{
Number: trimmed,
Title: "",
Filler: false,
Recap: false,
Order: idx + 1,
})
}
return items
}
func normalizeMode(raw string) string {
lower := strings.ToLower(strings.TrimSpace(raw))
if lower == "sub" || lower == "dub" {
return lower
}
return lower
}
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
}
func availableModes(modeSources map[string]ModeSource) []string {
ordered := make([]string, 0, len(modeSources))
if _, ok := modeSources["dub"]; ok {
ordered = append(ordered, "dub")
}
if _, ok := modeSources["sub"]; ok {
ordered = append(ordered, "sub")
}
extra := make([]string, 0)
for mode := range modeSources {
if mode == "dub" || mode == "sub" {
continue
}
extra = append(extra, mode)
}
sort.Strings(extra)
return append(ordered, extra...)
}
func selectInitialMode(requestedMode string, modeSources map[string]ModeSource) string {
normalizedRequested := normalizeMode(requestedMode)
if normalizedRequested != "" {
if _, ok := modeSources[normalizedRequested]; ok {
return normalizedRequested
}
}
if _, ok := modeSources["dub"]; ok {
return "dub"
}
if _, ok := modeSources["sub"]; ok {
return "sub"
}
for mode := range modeSources {
return mode
}
return "dub"
}
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 := sourceTypePriority(source.Type)
providerScore := providerPriority(source.Provider)
qualityScore := sourceQualityPriority(source.Quality, targetQuality)
refererScore := 0
if source.Referer != "" {
refererScore = 20
}
total := typeScore + providerScore + qualityScore + refererScore
scored = append(scored, sourceScore{
source: source,
total: total,
typeScore: typeScore,
providerScore: providerScore,
qualityScore: qualityScore,
refererScore: refererScore,
})
}
sort.SliceStable(scored, func(i int, j int) bool {
return scored[i].total > scored[j].total
})
return scored, nil
}
func normalizeQuality(quality string) string {
lower := strings.ToLower(strings.TrimSpace(quality))
if lower == "" {
return "best"
}
return lower
}
func sourceTypePriority(sourceType string) int {
switch strings.ToLower(sourceType) {
case "mp4":
return 500
case "m3u8":
return 450
case "unknown":
return 300
case "embed":
return 100
default:
return 200
}
}
func providerPriority(provider string) int {
switch strings.ToLower(provider) {
case "s-mp4":
return 120
case "default":
return 115
case "luf-mp4":
return 110
case "vid-mp4":
return 105
case "yt-mp4":
return 100
case "mp4":
return 95
case "uv-mp4":
return 90
case "hls":
return 80
case "sw":
return 40
case "ok":
return 35
case "ss-hls":
return 30
default:
return 60
}
}
func sourceQualityPriority(sourceQuality string, targetQuality string) int {
qualityValue := parseQualityValue(sourceQuality)
switch targetQuality {
case "best":
return qualityValue
case "worst":
return -qualityValue
default:
if qualityMatches(sourceQuality, targetQuality) {
return 2000 + qualityValue
}
return -300 + qualityValue
}
}
func parseQualityValue(rawQuality string) int {
lower := strings.ToLower(rawQuality)
var digits strings.Builder
for _, char := range lower {
if char >= '0' && char <= '9' {
digits.WriteRune(char)
continue
}
if digits.Len() > 0 {
break
}
}
if digits.Len() > 0 {
value, err := strconv.Atoi(digits.String())
if err == nil {
return value
}
}
if lower == "auto" {
return 240
}
return 0
}
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
}
sourceDigits := extractDigits(sourceLower)
targetDigits := extractDigits(targetLower)
return sourceDigits != "" && sourceDigits == targetDigits
}
func extractDigits(value string) string {
var digits strings.Builder
for _, char := range value {
if char >= '0' && char <= '9' {
digits.WriteRune(char)
continue
}
if digits.Len() > 0 {
break
}
}
return digits.String()
}
func normalizeSourceTypeFromProbe(source StreamSource, contentType string) StreamSource {
lower := strings.ToLower(contentType)
if strings.Contains(lower, "video/mp4") {
source.Type = "mp4"
return source
}
if strings.Contains(lower, "mpegurl") {
source.Type = "m3u8"
return source
}
return source
}
func isLikelyMP4(payload []byte) bool {
if len(payload) < 12 {
return false
}
return bytes.Equal(payload[4:8], []byte("ftyp"))
}
func isLikelyM3U8(payload []byte) bool {
trimmed := strings.TrimSpace(string(payload))
return strings.HasPrefix(trimmed, "#EXTM3U")
}
func isM3U8(targetURL string, contentType string) bool {
lowerURL := strings.ToLower(targetURL)
lowerType := strings.ToLower(contentType)
if strings.Contains(lowerURL, ".m3u8") {
return true
}
return strings.Contains(lowerType, "application/vnd.apple.mpegurl") || strings.Contains(lowerType, "application/x-mpegurl")
}
func rewritePlaylist(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() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
out.WriteString(line)
out.WriteString("\n")
continue
}
relativeURL, parseErr := url.Parse(trimmed)
if parseErr != nil {
out.WriteString(line)
out.WriteString("\n")
continue
}
absolute := base.ResolveReference(relativeURL).String()
proxied := "/watch/proxy/segment?u=" + url.QueryEscape(absolute)
if referer != "" {
proxied += "&r=" + url.QueryEscape(referer)
}
out.WriteString(proxied)
out.WriteString("\n")
}
if err := scanner.Err(); err != nil {
return "", err
}
return out.String(), nil
}
func cloneHeaders(src http.Header) http.Header {
dst := make(http.Header)
for key, values := range src {
lower := strings.ToLower(key)
if lower == "connection" || lower == "transfer-encoding" || lower == "keep-alive" || lower == "proxy-authenticate" || lower == "proxy-authorization" || lower == "te" || lower == "trailers" || lower == "upgrade" {
continue
}
for _, value := range values {
dst.Add(key, value)
}
}
return dst
}

View File

@@ -0,0 +1,52 @@
package playback
type StreamSource struct {
URL string
Quality string
Provider string
Type string
Referer string
Subtitles []Subtitle
}
type Subtitle struct {
Lang string
URL string
}
type ModeSource struct {
URL string `json:"url"`
Referer string `json:"referer"`
Subtitles []SubtitleItem `json:"subtitles"`
}
type SubtitleItem struct {
Lang string `json:"lang"`
URL string `json:"url"`
Referer string `json:"referer"`
}
type EpisodeListItem struct {
Number string `json:"number"`
Title string `json:"title"`
Filler bool `json:"filler"`
Recap bool `json:"recap"`
Order int `json:"order"`
}
type SkipSegment struct {
Type string `json:"type"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
type WatchPageData struct {
MalID int
Title string
CurrentEpisode string
InitialMode string
AvailableModes []string
ModeSources map[string]ModeSource
Episodes []EpisodeListItem
Segments []SkipSegment
}

View File

@@ -6,6 +6,7 @@ import (
"mal/internal/database"
"mal/internal/features/anime"
"mal/internal/features/auth"
"mal/internal/features/playback"
"mal/internal/features/watchlist"
"mal/internal/jikan"
"mal/internal/shared/middleware"
@@ -27,6 +28,8 @@ func NewRouter(cfg Config) http.Handler {
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
animeHandler := anime.NewHandler(animeSvc)
playbackSvc := playback.NewService(cfg.JikanClient)
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
// Serve static files
fs := http.FileServer(http.Dir("./static"))
@@ -48,10 +51,14 @@ func NewRouter(cfg Config) http.Handler {
mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog)
mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails)
mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnime)
mux.HandleFunc("/api/episodes/", animeHandler.HandleAPIEpisodes)
mux.HandleFunc("/studios/", animeHandler.HandleStudioDetails)
mux.HandleFunc("/api/studios/", animeHandler.HandleAPIStudioAnime)
mux.HandleFunc("/watch/", playbackHandler.HandleWatchPage)
mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxyStream)
mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxySegment)
mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxySubtitle)
mux.HandleFunc("/api/episodes/", animeHandler.HandleAPIEpisodes)
// Auth Endpoints
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {