feat(playback): add watch backend
This commit is contained in:
507
internal/features/playback/allanime_client.go
Normal file
507
internal/features/playback/allanime_client.go
Normal 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"
|
||||
}
|
||||
227
internal/features/playback/handler.go
Normal file
227
internal/features/playback/handler.go
Normal 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)
|
||||
}
|
||||
212
internal/features/playback/provider_extractor.go
Normal file
212
internal/features/playback/provider_extractor.go
Normal 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
|
||||
}
|
||||
816
internal/features/playback/service.go
Normal file
816
internal/features/playback/service.go
Normal 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
|
||||
}
|
||||
52
internal/features/playback/types.go
Normal file
52
internal/features/playback/types.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user