diff --git a/internal/features/playback/handler.go b/internal/features/playback/handler.go index b95a7c8..fe90072 100644 --- a/internal/features/playback/handler.go +++ b/internal/features/playback/handler.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -203,6 +204,99 @@ func (h *Handler) HandleProxySubtitle(w http.ResponseWriter, r *http.Request) { h.proxyUpstream(w, r, targetURL, r.URL.Query().Get("r")) } +func (h *Handler) HandleProxyPreviewMap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + malIDText := strings.TrimSpace(r.URL.Query().Get("mal_id")) + malID, err := strconv.Atoi(malIDText) + if err != nil || malID <= 0 { + http.Error(w, "invalid mal id", http.StatusBadRequest) + return + } + + episode := strings.TrimSpace(r.URL.Query().Get("ep")) + if episode == "" { + episode = "1" + } + + mode := normalizeMode(r.URL.Query().Get("mode")) + if mode == "" { + mode = "dub" + } + + source := strings.TrimSpace(r.URL.Query().Get("u")) + if source == "" { + http.Error(w, "missing target url", http.StatusBadRequest) + return + } + + referer := strings.TrimSpace(r.URL.Query().Get("r")) + duration := 0.0 + durationText := strings.TrimSpace(r.URL.Query().Get("d")) + if durationText != "" { + parsedDuration, parseErr := strconv.ParseFloat(durationText, 64) + if parseErr != nil || parsedDuration <= 0 { + http.Error(w, "invalid duration", http.StatusBadRequest) + return + } + duration = parsedDuration + } + + mapData, previewKey, previewErr := h.svc.EnsurePreviewMap(r.Context(), PreviewRequest{ + MalID: malID, + Episode: episode, + Mode: mode, + Source: source, + Referer: referer, + Duration: duration, + }) + if previewErr != nil { + log.Printf("preview map error mal_id=%d ep=%s mode=%s: %v", malID, episode, mode, previewErr) + http.Error(w, "failed to generate preview map", http.StatusBadGateway) + return + } + + spriteURL := "/watch/proxy/preview-sprite?k=" + url.QueryEscape(previewKey) + response := struct { + SpriteURL string `json:"sprite_url"` + Map PreviewMap `json:"map"` + }{ + SpriteURL: spriteURL, + Map: mapData, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("preview map encode error mal_id=%d ep=%s mode=%s: %v", malID, episode, mode, err) + } +} + +func (h *Handler) HandleProxyPreviewSprite(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + previewKey := strings.TrimSpace(r.URL.Query().Get("k")) + spritePath := h.svc.PreviewSpritePath(previewKey) + if spritePath == "" { + http.Error(w, "invalid preview key", http.StatusBadRequest) + return + } + + if _, err := os.Stat(spritePath); err != nil { + http.Error(w, "preview sprite not found", http.StatusNotFound) + return + } + + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Content-Type", "image/jpeg") + http.ServeFile(w, r, spritePath) +} + 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") { diff --git a/internal/features/playback/preview_service.go b/internal/features/playback/preview_service.go new file mode 100644 index 0000000..aaa7502 --- /dev/null +++ b/internal/features/playback/preview_service.go @@ -0,0 +1,400 @@ +package playback + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +const ( + previewFrameWidth = 160 + previewFrameHeight = 90 + previewGridColumns = 10 + previewGridRows = 10 + previewFrameInterval = 10 + previewFrameLimit = previewGridColumns * previewGridRows + previewGenerationLimit = 120 * time.Second + previewManifestName = "map.json" + spriteFileName = "sprite.jpg" +) + +type ffprobeFormat struct { + Duration string `json:"duration"` +} + +type ffprobeResult struct { + Format ffprobeFormat `json:"format"` +} + +func newPreviewRootDir() string { + return filepath.Join(os.TempDir(), "mal-preview-cache") +} + +func (s *Service) EnsurePreviewMap(ctx context.Context, req PreviewRequest) (PreviewMap, string, error) { + if strings.TrimSpace(req.Source) == "" { + return PreviewMap{}, "", fmt.Errorf("missing preview source") + } + + parsedSource, err := url.Parse(req.Source) + if err != nil { + return PreviewMap{}, "", fmt.Errorf("invalid preview source") + } + if parsedSource.Scheme != "http" && parsedSource.Scheme != "https" { + return PreviewMap{}, "", fmt.Errorf("invalid preview source scheme") + } + + normalizedEpisode := strings.TrimSpace(req.Episode) + if normalizedEpisode == "" { + normalizedEpisode = "1" + } + + normalizedMode := normalizeMode(req.Mode) + if normalizedMode == "" { + normalizedMode = "dub" + } + + previewHash := hashPreviewIdentity(req.MalID, normalizedEpisode, normalizedMode, req.Source, req.Referer) + previewKey := fmt.Sprintf("%d-%s", req.MalID, previewHash) + previewDir := filepath.Join(s.previewRoot, previewKey) + manifestPath := filepath.Join(previewDir, previewManifestName) + spritePath := filepath.Join(previewDir, spriteFileName) + + if mapData, err := readPreviewManifest(manifestPath); err == nil { + if mapData.Duration > 0 && fileExists(spritePath) { + return mapData, previewKey, nil + } + } + + lock := s.previewLock(previewKey) + lock.Lock() + defer lock.Unlock() + + if mapData, err := readPreviewManifest(manifestPath); err == nil { + if mapData.Duration > 0 && fileExists(spritePath) { + return mapData, previewKey, nil + } + } + + if err := os.MkdirAll(previewDir, 0o755); err != nil { + return PreviewMap{}, "", fmt.Errorf("create preview cache dir: %w", err) + } + + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + return PreviewMap{}, "", fmt.Errorf("ffmpeg not found in PATH") + } + + ffprobePath, err := exec.LookPath("ffprobe") + if err != nil { + return PreviewMap{}, "", fmt.Errorf("ffprobe not found in PATH") + } + + ctxWithTimeout, cancel := context.WithTimeout(ctx, previewGenerationLimit) + defer cancel() + + duration, err := probePreviewDuration(ctxWithTimeout, ffprobePath, req.Source, req.Referer) + if err != nil { + if req.Duration > 0 { + duration = req.Duration + } else { + return PreviewMap{}, "", fmt.Errorf("probe preview duration: %w", err) + } + } + + if duration <= 0 { + return PreviewMap{}, "", fmt.Errorf("invalid duration for preview generation") + } + + interval := selectPreviewInterval(duration) + + if err := generatePreviewSprite(ctxWithTimeout, ffmpegPath, req.Source, req.Referer, spritePath, interval); err != nil { + return PreviewMap{}, "", err + } + + mapData := buildPreviewMap(duration, interval) + if err := writePreviewManifest(manifestPath, mapData); err != nil { + return PreviewMap{}, "", fmt.Errorf("write preview manifest: %w", err) + } + + return mapData, previewKey, nil +} + +func (s *Service) PreviewSpritePath(previewKey string) string { + trimmed := strings.TrimSpace(previewKey) + if trimmed == "" { + return "" + } + + safeKey := sanitizePreviewKey(trimmed) + if safeKey == "" { + return "" + } + + return filepath.Join(s.previewRoot, safeKey, spriteFileName) +} + +func (s *Service) previewLock(key string) *sync.Mutex { + s.previewMu.Lock() + defer s.previewMu.Unlock() + + lock, exists := s.previewLocks[key] + if exists { + return lock + } + + newLock := &sync.Mutex{} + s.previewLocks[key] = newLock + return newLock +} + +func hashPreviewIdentity(malID int, episode string, mode string, source string, referer string) string { + payload := fmt.Sprintf("%d|%s|%s|%s|%s", malID, episode, mode, source, referer) + sum := sha1.Sum([]byte(payload)) + return hex.EncodeToString(sum[:8]) +} + +func sanitizePreviewKey(raw string) string { + var builder strings.Builder + for _, char := range raw { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' { + builder.WriteRune(char) + } + } + + return builder.String() +} + +func selectPreviewInterval(duration float64) float64 { + if duration <= 0 { + return previewFrameInterval + } + + candidate := duration / float64(previewFrameLimit) + if candidate < previewFrameInterval { + return previewFrameInterval + } + + return math.Ceil(candidate) +} + +func computePreviewFrames(duration float64, interval float64) int { + if duration <= 0 || interval <= 0 { + return 1 + } + + frames := int(math.Ceil(duration / interval)) + if frames > previewFrameLimit { + return previewFrameLimit + } + + return frames +} + +func buildPreviewMap(duration float64, interval float64) PreviewMap { + frames := computePreviewFrames(duration, interval) + cues := make([]PreviewCue, 0, frames) + + for idx := 0; idx < frames; idx++ { + start := float64(idx) * interval + end := start + interval + if idx == frames-1 { + end = duration + } + if end > duration { + end = duration + } + + column := idx % previewGridColumns + row := idx / previewGridColumns + x := column * previewFrameWidth + y := row * previewFrameHeight + + cues = append(cues, PreviewCue{ + Start: start, + End: end, + Sprite: spriteFileName, + X: x, + Y: y, + Width: previewFrameWidth, + Height: previewFrameHeight, + }) + } + + return PreviewMap{ + Width: previewFrameWidth, + Height: previewFrameHeight, + Columns: previewGridColumns, + Rows: previewGridRows, + Interval: interval, + Duration: duration, + Cues: cues, + } +} + +func readPreviewManifest(path string) (PreviewMap, error) { + payload, err := os.ReadFile(path) + if err != nil { + return PreviewMap{}, err + } + + var mapData PreviewMap + if err := json.Unmarshal(payload, &mapData); err != nil { + return PreviewMap{}, err + } + + if mapData.Duration <= 0 || len(mapData.Cues) == 0 { + return PreviewMap{}, fmt.Errorf("invalid preview manifest") + } + + return mapData, nil +} + +func writePreviewManifest(path string, mapData PreviewMap) error { + payload, err := json.Marshal(mapData) + if err != nil { + return err + } + + return os.WriteFile(path, payload, 0o644) +} + +func probePreviewDuration(ctx context.Context, ffprobePath string, source string, referer string) (float64, error) { + args := []string{ + "-v", "error", + "-show_entries", "format=duration", + "-of", "json", + } + + headers := ffmpegHeaders(referer) + if headers != "" { + args = append(args, "-headers", headers) + } + + args = append(args, source) + + cmd := exec.CommandContext(ctx, ffprobePath, args...) + output, err := cmd.Output() + if err != nil { + return 0, err + } + + var parsed ffprobeResult + if err := json.Unmarshal(output, &parsed); err != nil { + return 0, err + } + + durationText := strings.TrimSpace(parsed.Format.Duration) + if durationText == "" { + return 0, fmt.Errorf("missing duration") + } + + duration, err := strconv.ParseFloat(durationText, 64) + if err != nil { + return 0, err + } + + if !isFinitePositive(duration) { + return 0, fmt.Errorf("non-finite duration") + } + + return duration, nil +} + +func generatePreviewSprite(ctx context.Context, ffmpegPath string, source string, referer string, outputPath string, interval float64) error { + filter := fmt.Sprintf("fps=1/%0.3f,scale=%d:%d,tile=%dx%d", interval, previewFrameWidth, previewFrameHeight, previewGridColumns, previewGridRows) + args := []string{ + "-hide_banner", + "-loglevel", "error", + "-y", + } + + headers := ffmpegHeaders(referer) + if headers != "" { + args = append(args, "-headers", headers) + } + + args = append(args, + "-i", source, + "-vf", filter, + "-frames:v", "1", + "-q:v", "5", + "-update", "1", + outputPath, + ) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("create ffmpeg stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start ffmpeg: %w", err) + } + + errPayload, _ := io.ReadAll(io.LimitReader(stderr, 64*1024)) + waitErr := cmd.Wait() + if waitErr != nil { + message := strings.TrimSpace(string(errPayload)) + if message == "" { + return fmt.Errorf("ffmpeg failed: %w", waitErr) + } + return fmt.Errorf("ffmpeg failed: %s", message) + } + + if _, statErr := os.Stat(outputPath); statErr != nil { + return fmt.Errorf("preview sprite not generated") + } + + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func ffmpegHeaders(referer string) string { + var builder strings.Builder + builder.WriteString("User-Agent: ") + builder.WriteString(defaultUserAgent) + builder.WriteString("\r\n") + + trimmedReferer := strings.TrimSpace(referer) + if trimmedReferer != "" { + builder.WriteString("Referer: ") + builder.WriteString(trimmedReferer) + builder.WriteString("\r\n") + } + + builder.WriteString("Connection: keep-alive\r\n") + return builder.String() +} + +func isFinitePositive(value float64) bool { + if value <= 0 { + return false + } + + if math.IsInf(value, 0) { + return false + } + + if math.IsNaN(value) { + return false + } + + return true +} diff --git a/internal/features/playback/service.go b/internal/features/playback/service.go index 5f44a3a..a846dbc 100644 --- a/internal/features/playback/service.go +++ b/internal/features/playback/service.go @@ -14,6 +14,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "mal/internal/jikan" @@ -24,6 +25,9 @@ type Service struct { jikanClient *jikan.Client httpClient *http.Client db database.Querier + previewRoot string + previewMu sync.Mutex + previewLocks map[string]*sync.Mutex } type sourceScore struct { @@ -41,6 +45,8 @@ func NewService(jikanClient *jikan.Client, db database.Querier) *Service { jikanClient: jikanClient, httpClient: &http.Client{Timeout: 12 * time.Second}, db: db, + previewRoot: newPreviewRootDir(), + previewLocks: make(map[string]*sync.Mutex), } } diff --git a/internal/features/playback/types.go b/internal/features/playback/types.go index 2952c61..febbd55 100644 --- a/internal/features/playback/types.go +++ b/internal/features/playback/types.go @@ -51,3 +51,32 @@ type WatchPageData struct { Episodes []EpisodeListItem Segments []SkipSegment } + +type PreviewRequest struct { + MalID int + Episode string + Mode string + Source string + Referer string + Duration float64 +} + +type PreviewCue struct { + Start float64 `json:"start"` + End float64 `json:"end"` + Sprite string `json:"sprite"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + +type PreviewMap struct { + Width int `json:"width"` + Height int `json:"height"` + Columns int `json:"columns"` + Rows int `json:"rows"` + Interval float64 `json:"interval"` + Duration float64 `json:"duration"` + Cues []PreviewCue `json:"cues"` +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 88a49d7..7b24670 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -58,6 +58,8 @@ func NewRouter(cfg Config) http.Handler { mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxyStream) mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxySegment) mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxySubtitle) + mux.HandleFunc("/watch/proxy/preview-map", playbackHandler.HandleProxyPreviewMap) + mux.HandleFunc("/watch/proxy/preview-sprite", playbackHandler.HandleProxyPreviewSprite) // Auth Endpoints mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {