Files
MeBox/internal/service/playback.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

199 lines
6.8 KiB
Go

// Package service — playback history / favourites / playlists.
//
// These three concerns are intentionally co-located: they all sit between
// "the user" and "a media item" and share the same join-table flavour. A
// dedicated PlaybackService keeps the wiring simple and lets handlers
// dispatch by feature instead of by repository.
package service
import (
"context"
"errors"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// PlaybackService bundles history / favourite / playlist business logic.
type PlaybackService struct {
log *zap.Logger
repo *repository.Container
}
// NewPlaybackService is the constructor.
func NewPlaybackService(log *zap.Logger, repo *repository.Container) *PlaybackService {
return &PlaybackService{log: log, repo: repo}
}
// ─── History ────────────────────────────────────────────────────────────────
// RecordProgress upserts the resume position for a (user, media) pair. A
// position within 30 seconds of the duration auto-flags the item as
// completed so the home page can hide it from "Continue Watching".
func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID string, position, duration int64) error {
if userID == "" || mediaID == "" {
return errors.New("missing user or media")
}
completed := duration > 0 && position >= duration-30_000
h := &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: position,
DurationMs: duration,
WatchedAt: time.Now(),
Completed: completed,
}
return p.repo.History.Upsert(ctx, h)
}
// HistoryItem joins the playback row with its media so the API consumer
// gets a fully-populated card without a second round-trip.
type HistoryItem struct {
model.PlaybackHistory
Media *model.Media `json:"media,omitempty"`
}
// RecentHistory returns the most recently-watched items for a user. We
// fetch the history rows first then attach each Media row in a single
// follow-up query.
func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limit int) ([]HistoryItem, error) {
rows, err := p.repo.History.ListByUser(ctx, userID, limit)
if err != nil {
return nil, err
}
items := make([]HistoryItem, 0, len(rows))
for i := range rows {
var m model.Media
if err := p.repo.DB.Where("id = ?", rows[i].MediaID).First(&m).Error; err == nil {
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &m})
} else {
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
}
}
return items, nil
}
// ─── Favourites ─────────────────────────────────────────────────────────────
// ToggleFavourite flips the favourite flag and reports the new state.
func (p *PlaybackService) ToggleFavourite(ctx context.Context, userID, mediaID string) (bool, error) {
return p.repo.Favorite.Toggle(ctx, userID, mediaID)
}
// ListFavourites returns every favourited media for a user.
func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([]model.Media, error) {
favs, err := p.repo.Favorite.ListByUser(ctx, userID)
if err != nil {
return nil, err
}
if len(favs) == 0 {
return nil, nil
}
ids := make([]string, len(favs))
for i, f := range favs {
ids[i] = f.MediaID
}
var out []model.Media
err = p.repo.DB.Where("id IN ?", ids).
Order("created_at desc").Find(&out).Error
return out, err
}
// ─── Playlists ──────────────────────────────────────────────────────────────
// CreatePlaylist persists a new playlist owned by userID.
func (p *PlaybackService) CreatePlaylist(ctx context.Context, userID, name string, isPublic bool) (*model.Playlist, error) {
if name == "" {
return nil, errors.New("name required")
}
pl := &model.Playlist{UserID: userID, Name: name, IsPublic: isPublic}
if err := p.repo.Playlist.Create(ctx, pl); err != nil {
return nil, err
}
return pl, nil
}
// ListPlaylists returns every playlist owned by userID.
func (p *PlaybackService) ListPlaylists(ctx context.Context, userID string) ([]model.Playlist, error) {
return p.repo.Playlist.ListByUser(ctx, userID)
}
// PlaylistDetail returns the playlist together with its ordered media items.
type PlaylistDetail struct {
Playlist model.Playlist `json:"playlist"`
Items []model.Media `json:"items"`
}
// GetPlaylist returns the playlist + its ordered media. Visibility is
// enforced at the handler level; the service trusts callers.
func (p *PlaybackService) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistDetail, error) {
var pl model.Playlist
if err := p.repo.DB.Where("id = ?", playlistID).First(&pl).Error; err != nil {
return nil, err
}
var rows []model.PlaylistItem
if err := p.repo.DB.
Where("playlist_id = ?", playlistID).
Order("position asc").
Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return &PlaylistDetail{Playlist: pl}, nil
}
ids := make([]string, len(rows))
for i, r := range rows {
ids[i] = r.MediaID
}
var media []model.Media
if err := p.repo.DB.Where("id IN ?", ids).Find(&media).Error; err != nil {
return nil, err
}
// Preserve playlist order.
byID := make(map[string]model.Media, len(media))
for _, m := range media {
byID[m.ID] = m
}
ordered := make([]model.Media, 0, len(rows))
for _, r := range rows {
if m, ok := byID[r.MediaID]; ok {
ordered = append(ordered, m)
}
}
return &PlaylistDetail{Playlist: pl, Items: ordered}, nil
}
// AddToPlaylist appends a media item to the end of a playlist.
func (p *PlaybackService) AddToPlaylist(ctx context.Context, playlistID, mediaID string) error {
var count int64
if err := p.repo.DB.Model(&model.PlaylistItem{}).
Where("playlist_id = ?", playlistID).Count(&count).Error; err != nil {
return err
}
item := &model.PlaylistItem{
PlaylistID: playlistID,
MediaID: mediaID,
Position: int(count) + 1,
}
return p.repo.DB.Create(item).Error
}
// RemoveFromPlaylist removes a media item from a playlist (idempotent).
func (p *PlaybackService) RemoveFromPlaylist(ctx context.Context, playlistID, mediaID string) error {
return p.repo.DB.
Where("playlist_id = ? AND media_id = ?", playlistID, mediaID).
Delete(&model.PlaylistItem{}).Error
}
// DeletePlaylist removes a playlist and all of its items.
func (p *PlaybackService) DeletePlaylist(ctx context.Context, playlistID string) error {
if err := p.repo.DB.Where("playlist_id = ?", playlistID).
Delete(&model.PlaylistItem{}).Error; err != nil {
return err
}
return p.repo.DB.Where("id = ?", playlistID).Delete(&model.Playlist{}).Error
}