Files
MeBox/internal/service/image_proxy.go
T
Kiro 0f30c34463 feat: ffprobe + TMDb scrape + HLS transcode + history/favourites/playlists
Backend
  - service/ffprobe.go: thin ffprobe wrapper, parses duration / resolution /
    codecs into a typed ProbeResult. 30s per-file timeout.
  - service/tmdb.go: minimal TMDb provider (search/movie). Disabled when no
    api key; supports tmdb_api_proxy / tmdb_image_proxy overrides for users
    behind a firewall.
  - service/scraper.go: filename cleaner (handles bracketed tags, scene
    noise tokens, year extraction), per-row + per-library enrichment with a
    4 RPS throttle and WS hub progress events. Unit-tested.
  - service/scanner.go: now invokes ffprobe per file and kicks the TMDb
    scraper in the background once a library scan finishes.
  - service/transcoder.go: per-media ffmpeg HLS job manager; outputs
    index.m3u8 + seg_NNNNN.ts under cache/hls/<id>; cancels jobs on
    shutdown; publishes 'transcode' WS events.
  - service/stream.go: serves HLS playlist (with 30s wait-for-ready) and
    .ts segments with path-traversal protection. Adds Probe() helper used
    by the admin 'reprobe' button.
  - service/image_proxy.go: cached, host-allow-listed reverse proxy for
    TMDb / Bangumi / Douban / Fanart / TheTVDB images so the SPA never
    hits a CORS or GFW issue.
  - service/playback.go: history upsert, favourites toggle, playlist CRUD
    + ordered items. RecentHistory joins with model.Media in one extra
    query so the home page can render a 'Continue Watching' row.
  - handler/streaming.go + handler/playback.go: REST endpoints for HLS,
    image proxy, scrape (one + library), reprobe, history, favourites,
    playlists.
  - handler/handler.go: registers /api/hls/:id/{index.m3u8,:seg}, /api/img,
    /api/history, /api/favourites/:id, /api/playlists/* with proper
    auth/admin guards.

Frontend
  - api/client.ts: imageURL() helper; hlsURL() endpoint; reuses the JWT in
    a query parameter for <video src> and <img src>.
  - api/playback.ts: typed helpers for history, favourites, playlists.
  - components/MediaCard.tsx: optional 'progress' prop renders a thin
    bottom progress bar, used by the new Continue Watching row.
  - pages/HomePage.tsx: two rows (Continue Watching + Recently Added);
    falls back to the empty-state hint when both are empty.
  - pages/PlayerPage.tsx: hls.js (lazy-imported) with auto-fallback to
    direct play; ?mode=hls|direct query toggle; resume position written
    every 10s while playing.
  - pages/MediaDetailPage.tsx: heart toggle + admin 'rescrape' / 'reprobe'
    buttons + dedicated 'HLS 转码播放' CTA.
  - pages/FavouritesPage.tsx, PlaylistsPage.tsx, PlaylistDetailPage.tsx:
    new screens.
  - components/Layout.tsx + App.tsx: sidebar links for Favourites and
    Playlists; routes are now lazily code-split via React.lazy + Suspense
    so the initial bundle stays at ~243 KB / 82 KB gzipped (hls.js is
    fetched only on first HLS playback).

Verified: go build, go vet, go test (incl. CleanQuery cases) all pass;
frontend tsc -b && vite build emits 9 route chunks plus a deferred hls
chunk.
2026-05-14 15:44:31 +00:00

144 lines
3.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service — image proxy.
//
// Some deployments cannot reach image.tmdb.org directly (GFW, internal-only
// networks). ImageProxy fronts a remote image URL so the browser only ever
// talks to the MediaStationGo origin. The proxy:
//
// - validates the URL belongs to a small allow-list of trusted hosts,
// - streams bytes through with a small disk cache under cache/images,
// - falls back to a transparent 1×1 PNG on upstream failure so the UI
// never breaks layout.
package service
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// ImageProxy fetches and caches remote images on behalf of the browser.
type ImageProxy struct {
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
allowHost map[string]struct{}
mu sync.Mutex
}
// NewImageProxy is the constructor.
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
return &ImageProxy{
cfg: cfg,
log: log,
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
client: &http.Client{Timeout: 20 * time.Second},
allowHost: map[string]struct{}{
"image.tmdb.org": {},
"www.themoviedb.org": {},
"lain.bgm.tv": {},
"img.bgm.tv": {},
"webdav.bgm.tv": {},
"img1.doubanio.com": {},
"img2.doubanio.com": {},
"img3.doubanio.com": {},
"img9.doubanio.com": {},
"assets.fanart.tv": {},
"artworks.thetvdb.com": {},
},
}
}
// Serve writes the requested image to w. Caller is expected to validate
// the JWT before invoking it.
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw string) error {
if raw == "" {
return errors.New("missing url")
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return errors.New("invalid url")
}
if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
return errors.New("host not allowed")
}
// Cache key = sha1(url)
sum := sha1.Sum([]byte(raw))
key := hex.EncodeToString(sum[:])
cachePath := filepath.Join(p.cacheDir, key)
// Cache hit.
if f, err := os.Open(cachePath); err == nil {
defer f.Close()
stat, _ := f.Stat()
w.Header().Set("Cache-Control", "public, max-age=604800")
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f)
return nil
}
// Cache miss → fetch upstream.
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return errors.New("upstream returned " + resp.Status)
}
// Write to a temp file then rename for atomicity.
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
if err != nil {
return err
}
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return err
}
tmp.Close()
if err := os.Rename(tmp.Name(), cachePath); err != nil {
os.Remove(tmp.Name())
}
// Now serve the freshly cached file.
f, err := os.Open(cachePath)
if err != nil {
return err
}
defer f.Close()
stat, _ := f.Stat()
for _, h := range []string{"Content-Type", "Content-Length", "ETag", "Last-Modified"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
}
}
w.Header().Set("Cache-Control", "public, max-age=604800")
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f)
return nil
}