From 0dfd67be4790b49d7f77b365c004fabfd5732708 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 18 Apr 2026 05:55:42 +0200 Subject: [PATCH] feat(playback): add watch backend --- internal/features/playback/allanime_client.go | 507 +++++++++++ internal/features/playback/handler.go | 227 +++++ .../features/playback/provider_extractor.go | 212 +++++ internal/features/playback/service.go | 816 ++++++++++++++++++ internal/features/playback/types.go | 52 ++ internal/server/routes.go | 9 +- 6 files changed, 1822 insertions(+), 1 deletion(-) create mode 100644 internal/features/playback/allanime_client.go create mode 100644 internal/features/playback/handler.go create mode 100644 internal/features/playback/provider_extractor.go create mode 100644 internal/features/playback/service.go create mode 100644 internal/features/playback/types.go diff --git a/internal/features/playback/allanime_client.go b/internal/features/playback/allanime_client.go new file mode 100644 index 0000000..7c93aa6 --- /dev/null +++ b/internal/features/playback/allanime_client.go @@ -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" +} diff --git a/internal/features/playback/handler.go b/internal/features/playback/handler.go new file mode 100644 index 0000000..99e91b1 --- /dev/null +++ b/internal/features/playback/handler.go @@ -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) +} diff --git a/internal/features/playback/provider_extractor.go b/internal/features/playback/provider_extractor.go new file mode 100644 index 0000000..b7ce18f --- /dev/null +++ b/internal/features/playback/provider_extractor.go @@ -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 +} diff --git a/internal/features/playback/service.go b/internal/features/playback/service.go new file mode 100644 index 0000000..d5347c6 --- /dev/null +++ b/internal/features/playback/service.go @@ -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 +} diff --git a/internal/features/playback/types.go b/internal/features/playback/types.go new file mode 100644 index 0000000..68dfa20 --- /dev/null +++ b/internal/features/playback/types.go @@ -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 +} diff --git a/internal/server/routes.go b/internal/server/routes.go index ceff675..e0ee88f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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 {