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/database"
|
||||||
"mal/internal/features/anime"
|
"mal/internal/features/anime"
|
||||||
"mal/internal/features/auth"
|
"mal/internal/features/auth"
|
||||||
|
"mal/internal/features/playback"
|
||||||
"mal/internal/features/watchlist"
|
"mal/internal/features/watchlist"
|
||||||
"mal/internal/jikan"
|
"mal/internal/jikan"
|
||||||
"mal/internal/shared/middleware"
|
"mal/internal/shared/middleware"
|
||||||
@@ -27,6 +28,8 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
|
|
||||||
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
|
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
|
||||||
animeHandler := anime.NewHandler(animeSvc)
|
animeHandler := anime.NewHandler(animeSvc)
|
||||||
|
playbackSvc := playback.NewService(cfg.JikanClient)
|
||||||
|
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
|
||||||
|
|
||||||
// Serve static files
|
// Serve static files
|
||||||
fs := http.FileServer(http.Dir("./static"))
|
fs := http.FileServer(http.Dir("./static"))
|
||||||
@@ -48,10 +51,14 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog)
|
mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog)
|
||||||
mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails)
|
mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails)
|
||||||
mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnime)
|
mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnime)
|
||||||
|
mux.HandleFunc("/api/episodes/", animeHandler.HandleAPIEpisodes)
|
||||||
mux.HandleFunc("/studios/", animeHandler.HandleStudioDetails)
|
mux.HandleFunc("/studios/", animeHandler.HandleStudioDetails)
|
||||||
mux.HandleFunc("/api/studios/", animeHandler.HandleAPIStudioAnime)
|
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
|
// Auth Endpoints
|
||||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
|
|||||||
Reference in New Issue
Block a user