refactor: remove thumbnail preview feature
This commit is contained in:
@@ -9,7 +9,7 @@ ENV CGO_ENABLED=1
|
||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
|
||||
# Install build dependencies for bun + assets
|
||||
RUN apt-get update && apt-get install -y ca-certificates sqlite3 ffmpeg curl unzip && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl unzip && rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
@@ -34,8 +34,8 @@ FROM debian:bullseye-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Required at runtime (sqlite + preview generation)
|
||||
RUN apt-get update && apt-get install -y ca-certificates sqlite3 ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
# Required at runtime (sqlite)
|
||||
RUN apt-get update && apt-get install -y ca-certificates sqlite3 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create data directory for sqlite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -206,101 +205,6 @@ 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 {
|
||||
if previewErr.Error() != "preview temporarily disabled" {
|
||||
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) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
previewFrameWidth = 128
|
||||
previewFrameHeight = 72
|
||||
previewGridColumns = 8
|
||||
previewGridRows = 8
|
||||
previewFrameInterval = 15
|
||||
previewFrameLimit = previewGridColumns * previewGridRows
|
||||
previewGenerationLimit = 120 * time.Second
|
||||
previewFailureTTL = 2 * time.Minute
|
||||
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)
|
||||
if s.previewFailureActive(previewKey) {
|
||||
return PreviewMap{}, "", fmt.Errorf("preview temporarily disabled")
|
||||
}
|
||||
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 {
|
||||
if shouldBackoffPreview(err) {
|
||||
s.markPreviewFailure(previewKey)
|
||||
}
|
||||
return PreviewMap{}, "", err
|
||||
}
|
||||
s.clearPreviewFailure(previewKey)
|
||||
|
||||
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 (s *Service) previewFailureActive(key string) bool {
|
||||
s.previewFailMu.Lock()
|
||||
defer s.previewFailMu.Unlock()
|
||||
|
||||
expiresAt, ok := s.previewFailTTL[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
delete(s.previewFailTTL, key)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) markPreviewFailure(key string) {
|
||||
s.previewFailMu.Lock()
|
||||
defer s.previewFailMu.Unlock()
|
||||
|
||||
s.previewFailTTL[key] = time.Now().Add(previewFailureTTL)
|
||||
}
|
||||
|
||||
func (s *Service) clearPreviewFailure(key string) {
|
||||
s.previewFailMu.Lock()
|
||||
defer s.previewFailMu.Unlock()
|
||||
|
||||
delete(s.previewFailTTL, key)
|
||||
}
|
||||
|
||||
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,
|
||||
"-threads", "1",
|
||||
"-i", source,
|
||||
"-map", "0:v:0",
|
||||
"-an",
|
||||
"-sn",
|
||||
"-dn",
|
||||
"-vf", filter,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "8",
|
||||
"-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
|
||||
}
|
||||
|
||||
func shouldBackoffPreview(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
|
||||
errText := strings.ToLower(err.Error())
|
||||
if strings.Contains(errText, "signal: killed") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(errText, "context canceled") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(errText, "cannot allocate memory") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mal/internal/jikan"
|
||||
@@ -25,11 +24,6 @@ type Service struct {
|
||||
jikanClient *jikan.Client
|
||||
httpClient *http.Client
|
||||
db database.Querier
|
||||
previewRoot string
|
||||
previewMu sync.Mutex
|
||||
previewLocks map[string]*sync.Mutex
|
||||
previewFailMu sync.Mutex
|
||||
previewFailTTL map[string]time.Time
|
||||
}
|
||||
|
||||
type sourceScore struct {
|
||||
@@ -47,9 +41,6 @@ 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),
|
||||
previewFailTTL: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,32 +52,3 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -59,8 +59,6 @@ func NewRouter(cfg Config) http.Handler {
|
||||
mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxySegment)
|
||||
mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxySubtitle)
|
||||
mux.HandleFunc("/api/watch-progress", playbackHandler.HandleSaveProgress)
|
||||
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) {
|
||||
|
||||
@@ -198,7 +198,6 @@ templ VideoPlayer(data WatchPageData) {
|
||||
data-mal-id={ fmt.Sprintf("%d", data.MalID) }
|
||||
data-video-player
|
||||
data-stream-url="/watch/proxy/stream"
|
||||
data-preview-map-url="/watch/proxy/preview-map"
|
||||
data-current-episode={ data.CurrentEpisode }
|
||||
data-start-time-seconds={ fmt.Sprintf("%.3f", data.StartTimeSeconds) }
|
||||
data-initial-mode={ data.InitialMode }
|
||||
@@ -225,7 +224,6 @@ templ VideoPlayer(data WatchPageData) {
|
||||
<div data-progress-wrap class="group/progress relative mb-5 h-1 cursor-pointer bg-white/30">
|
||||
<div data-preview-popover class="pointer-events-none absolute bottom-[calc(100%+10px)] left-0 z-40 hidden -translate-x-1/2">
|
||||
<div class="overflow-hidden border border-white/20 bg-black shadow-xl">
|
||||
<div data-preview-frame class="h-[90px] w-[160px] bg-black"></div>
|
||||
<div data-preview-time class="bg-white px-2 py-1 text-center text-xs font-semibold text-black tabular-nums">00:00</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
193
static/player.ts
193
static/player.ts
@@ -18,73 +18,6 @@ interface SkipSegment {
|
||||
end: number
|
||||
}
|
||||
|
||||
interface PreviewCue {
|
||||
start: number
|
||||
end: number
|
||||
sprite: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface PreviewMap {
|
||||
width: number
|
||||
height: number
|
||||
columns: number
|
||||
rows: number
|
||||
interval: number
|
||||
duration: number
|
||||
cues: PreviewCue[]
|
||||
}
|
||||
|
||||
interface PreviewPayload {
|
||||
spriteURL: string
|
||||
map: PreviewMap
|
||||
}
|
||||
|
||||
interface PreviewMapResponse {
|
||||
sprite_url: string
|
||||
map: PreviewMap
|
||||
}
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
const isPreviewCue = (value: unknown): value is PreviewCue => {
|
||||
if (!isObjectRecord(value)) return false
|
||||
return Number.isFinite(value.start)
|
||||
&& Number.isFinite(value.end)
|
||||
&& typeof value.sprite === 'string'
|
||||
&& Number.isFinite(value.x)
|
||||
&& Number.isFinite(value.y)
|
||||
&& Number.isFinite(value.width)
|
||||
&& Number.isFinite(value.height)
|
||||
}
|
||||
|
||||
const isPreviewMap = (value: unknown): value is PreviewMap => {
|
||||
if (!isObjectRecord(value)) return false
|
||||
if (!Array.isArray(value.cues)) return false
|
||||
if (!value.cues.every((cue: unknown) => isPreviewCue(cue))) return false
|
||||
return Number.isFinite(value.width)
|
||||
&& Number.isFinite(value.height)
|
||||
&& Number.isFinite(value.columns)
|
||||
&& Number.isFinite(value.rows)
|
||||
&& Number.isFinite(value.interval)
|
||||
&& Number.isFinite(value.duration)
|
||||
}
|
||||
|
||||
const parsePreviewMapResponse = (value: unknown): PreviewMapResponse | null => {
|
||||
if (!isObjectRecord(value)) return null
|
||||
if (typeof value.sprite_url !== 'string') return null
|
||||
if (!isPreviewMap(value.map)) return null
|
||||
return {
|
||||
sprite_url: value.sprite_url,
|
||||
map: value.map,
|
||||
}
|
||||
}
|
||||
|
||||
const initPlayer = (): void => {
|
||||
const container = document.querySelector('[data-video-player]')
|
||||
if (!container) return
|
||||
@@ -113,7 +46,6 @@ const initPlayer = (): void => {
|
||||
const subtitleText = container.querySelector('[data-subtitle-text]') as HTMLElement
|
||||
|
||||
const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
||||
const previewMapURL = container.getAttribute('data-preview-map-url') || '/watch/proxy/preview-map'
|
||||
const currentEpisode = container.getAttribute('data-current-episode') || '1'
|
||||
const malID = Number.parseInt(container.getAttribute('data-mal-id') || '', 10)
|
||||
const startTimeSeconds = Number.parseFloat(container.getAttribute('data-start-time-seconds') || '0')
|
||||
@@ -121,13 +53,6 @@ const initPlayer = (): void => {
|
||||
const availableModes = JSON.parse(container.getAttribute('data-available-modes') || '[]')
|
||||
const initialMode = container.getAttribute('data-initial-mode') || 'dub'
|
||||
const segments = JSON.parse(container.getAttribute('data-segments') || '[]')
|
||||
const malIDFromPath = (() => {
|
||||
const pathParts = window.location.pathname.split('/').filter(Boolean)
|
||||
if (pathParts.length < 2) return ''
|
||||
if (pathParts[0] !== 'watch') return ''
|
||||
return pathParts[1] || ''
|
||||
})()
|
||||
|
||||
const maxIntroStartSeconds = 180
|
||||
const minOutroStartRatio = 0.5
|
||||
const minSegmentDurationSeconds = 20
|
||||
@@ -157,14 +82,11 @@ const initPlayer = (): void => {
|
||||
let pendingSeekTime: number | null = null
|
||||
let activeSkipSegment: { type: string, start: number, end: number } | null = null
|
||||
let activeSegments: Array<{ type: string, start: number, end: number }> = []
|
||||
let previewState: { [key: string]: PreviewPayload } = {}
|
||||
let previewRequestToken = 0
|
||||
let lastSavedProgress = { episode: currentEpisode, seconds: -1 }
|
||||
let progressSaveTimer: number | undefined
|
||||
let transitionEpisode: number | null = null
|
||||
|
||||
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
|
||||
const previewFrame = container.querySelector('[data-preview-frame]') as HTMLElement
|
||||
const previewTime = container.querySelector('[data-preview-time]') as HTMLElement
|
||||
|
||||
const streamUrlForMode = (mode: string): string => {
|
||||
@@ -291,22 +213,6 @@ const initPlayer = (): void => {
|
||||
showControls()
|
||||
}
|
||||
|
||||
const streamSourceForMode = (mode: string): { url: string, referer: string } | null => {
|
||||
const modeSource = modeSources[mode]
|
||||
if (!modeSource) return null
|
||||
const sourceURL = String(modeSource.url || '').trim()
|
||||
if (sourceURL === '') return null
|
||||
return {
|
||||
url: sourceURL,
|
||||
referer: String(modeSource.referer || ''),
|
||||
}
|
||||
}
|
||||
|
||||
const previewCacheKey = (mode: string, sourceURL: string, sourceReferer: string): string => {
|
||||
const normalizedReferer = sourceReferer.trim()
|
||||
return `${mode}|${sourceURL}|${normalizedReferer}`
|
||||
}
|
||||
|
||||
const hidePreviewPopover = (): void => {
|
||||
if (!previewPopover) return
|
||||
previewPopover.style.left = '0px'
|
||||
@@ -320,19 +226,8 @@ const initPlayer = (): void => {
|
||||
previewPopover.classList.add('block')
|
||||
}
|
||||
|
||||
const cueForTime = (map: PreviewMap, time: number): PreviewCue | null => {
|
||||
if (!map.cues.length) return null
|
||||
const match = map.cues.find((cue: PreviewCue) => time >= cue.start && time < cue.end)
|
||||
if (match) return match
|
||||
const first = map.cues[0]
|
||||
const last = map.cues[map.cues.length - 1]
|
||||
if (time <= first.start) return first
|
||||
if (time >= last.end) return last
|
||||
return null
|
||||
}
|
||||
|
||||
const updatePreviewUI = (ratio: number): void => {
|
||||
if (!progressWrap || !previewPopover || !previewFrame || !previewTime) return
|
||||
if (!progressWrap || !previewPopover || !previewTime) return
|
||||
if (!video.duration || !Number.isFinite(video.duration)) {
|
||||
hidePreviewPopover()
|
||||
return
|
||||
@@ -340,80 +235,22 @@ const initPlayer = (): void => {
|
||||
|
||||
const targetTime = Math.max(0, Math.min(video.duration, ratio * video.duration))
|
||||
previewTime.textContent = formatTime(targetTime)
|
||||
|
||||
const source = streamSourceForMode(currentMode)
|
||||
if (!source || malIDFromPath === '') {
|
||||
hidePreviewPopover()
|
||||
return
|
||||
}
|
||||
|
||||
const cacheKey = previewCacheKey(currentMode, source.url, source.referer)
|
||||
const cached = previewState[cacheKey]
|
||||
if (!cached || !cached.map || !cached.spriteURL) {
|
||||
hidePreviewPopover()
|
||||
return
|
||||
}
|
||||
|
||||
const cue = cueForTime(cached.map, targetTime)
|
||||
if (!cue) {
|
||||
hidePreviewPopover()
|
||||
return
|
||||
}
|
||||
|
||||
previewFrame.style.width = `${cue.width}px`
|
||||
previewFrame.style.height = `${cue.height}px`
|
||||
previewFrame.style.backgroundImage = `url('${cached.spriteURL}')`
|
||||
previewFrame.style.backgroundRepeat = 'no-repeat'
|
||||
previewFrame.style.backgroundPosition = `-${cue.x}px -${cue.y}px`
|
||||
previewFrame.style.backgroundSize = `${cached.map.columns * cue.width}px ${cached.map.rows * cue.height}px`
|
||||
|
||||
const barWidth = progressWrap.clientWidth
|
||||
const popoverOffset = ratio * barWidth
|
||||
const halfWidth = cue.width / 2
|
||||
const clampedOffset = Math.max(halfWidth, Math.min(barWidth - halfWidth, popoverOffset))
|
||||
previewPopover.style.left = `${clampedOffset}px`
|
||||
if (barWidth <= 0) {
|
||||
hidePreviewPopover()
|
||||
return
|
||||
}
|
||||
|
||||
showPreviewPopover()
|
||||
let popoverWidth = 72
|
||||
if (previewPopover.offsetWidth > 0) {
|
||||
popoverWidth = previewPopover.offsetWidth
|
||||
}
|
||||
|
||||
const loadPreviewMap = async (): Promise<void> => {
|
||||
if (!video.duration || !Number.isFinite(video.duration)) return
|
||||
const source = streamSourceForMode(currentMode)
|
||||
if (!source || malIDFromPath === '') return
|
||||
|
||||
const cacheKey = previewCacheKey(currentMode, source.url, source.referer)
|
||||
if (previewState[cacheKey]) return
|
||||
|
||||
const token = previewRequestToken + 1
|
||||
previewRequestToken = token
|
||||
|
||||
const query = new URLSearchParams({
|
||||
mal_id: malIDFromPath,
|
||||
ep: currentEpisode,
|
||||
mode: currentMode,
|
||||
u: source.url,
|
||||
r: source.referer,
|
||||
d: String(video.duration),
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(`${previewMapURL}?${query.toString()}`)
|
||||
if (!response.ok) return
|
||||
const payloadRaw: unknown = await response.json()
|
||||
if (token !== previewRequestToken) return
|
||||
const payload = parsePreviewMapResponse(payloadRaw)
|
||||
if (!payload) return
|
||||
|
||||
previewState = {
|
||||
...previewState,
|
||||
[cacheKey]: {
|
||||
spriteURL: payload.sprite_url,
|
||||
map: payload.map,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const popoverOffset = ratio * barWidth
|
||||
const halfWidth = popoverWidth / 2
|
||||
const clampedOffset = Math.max(halfWidth, Math.min(barWidth - halfWidth, popoverOffset))
|
||||
previewPopover.style.left = `${clampedOffset}px`
|
||||
}
|
||||
|
||||
const saveProgress = async (): Promise<void> => {
|
||||
@@ -624,7 +461,6 @@ const initPlayer = (): void => {
|
||||
const wasPlaying = !video.paused
|
||||
const previousTime = video.currentTime
|
||||
currentMode = mode
|
||||
previewRequestToken += 1
|
||||
hidePreviewPopover()
|
||||
video.src = streamUrlForMode(currentMode)
|
||||
video.load()
|
||||
@@ -720,7 +556,6 @@ const initPlayer = (): void => {
|
||||
}
|
||||
updateTimeline(video.currentTime)
|
||||
updateSkipButton(video.currentTime)
|
||||
loadPreviewMap()
|
||||
})
|
||||
|
||||
video.addEventListener('waiting', () => {
|
||||
@@ -908,10 +743,6 @@ const initPlayer = (): void => {
|
||||
showControls()
|
||||
})
|
||||
|
||||
progressWrap?.addEventListener('mouseenter', () => {
|
||||
loadPreviewMap()
|
||||
})
|
||||
|
||||
progressWrap?.addEventListener('mousemove', (event) => {
|
||||
const rect = progressWrap.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
|
||||
|
||||
Reference in New Issue
Block a user