Files
MeBox/internal/service/douban.go
T
Kiro 778bf77437 feat: backup, notifier, organizer, douban, watch-history, poster-wall
Complete the port of the remaining MediaStation features:

Backend services:
  - service/backup.go: SQLite hot backup via VACUUM INTO, list, delete,
    restore. Backup files stored under {data_dir}/backups/ with timestamps.
  - service/notifier.go: Multi-channel push notifications (Telegram /
    Bark / WeChat Server酱 / Webhook). Configuration via settings table
    keys notify.telegram.*, notify.bark.*, etc.
  - service/organizer.go: Auto-rename + move media files into library
    directory structure. Movie: {Title} ({Year})/{Title} ({Year}).ext,
    TV: {Title}/Season XX/{Title} - SxxExx.ext. Both per-media and
    per-library batch endpoints.
  - service/douban.go: Douban (豆瓣) metadata provider using the
    unofficial subject_suggest API. Returns Chinese titles + posters.
    Requires douban_cookie in secrets config.

Handlers:
  - handler/backup.go: GET/POST/DELETE /admin/backups, POST restore.
  - handler/organizer.go: POST /admin/media/:id/organize,
    POST /admin/libraries/:id/organize.
  - handler/notify.go: POST /admin/notify/test.

Frontend pages:
  - WatchHistoryPage.tsx: full paginated history with progress bars,
    resume buttons, poster thumbnails and timestamps.
  - PosterWallPage.tsx: dense 8-column poster grid of all media
    (matches the original MediaStation PosterWallView.vue).

Sidebar additions: 观看历史 + 海报墙 links in the main nav group.

Verified: go build, go vet, go test all pass; tsc -b && vite build
emits 30 lazy chunks; main bundle 254 KB / 85 KB gzipped.
2026-05-15 14:17:26 +00:00

121 lines
3.4 KiB
Go

// Package service — Douban (豆瓣) metadata provider.
//
// Douban is the dominant Chinese movie/TV rating and metadata site. Its
// unofficial API returns rich Chinese-language titles, overviews, ratings
// and poster URLs. A valid Douban cookie is required to avoid IP bans.
//
// We use the search endpoint at:
// https://movie.douban.com/j/subject_suggest?q=...
//
// And the detail endpoint at:
// https://movie.douban.com/j/subject_abstract?subject_id=...
//
// The provider is used as a supplemental source: after TMDb matches we
// attempt a Douban lookup to grab a localized Chinese title + overview.
package service
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// DoubanProvider talks to the unofficial Douban movie API.
type DoubanProvider struct {
cfg *config.Config
log *zap.Logger
client *http.Client
}
// NewDoubanProvider is the constructor.
func NewDoubanProvider(cfg *config.Config, log *zap.Logger) *DoubanProvider {
return &DoubanProvider{
cfg: cfg,
log: log,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// Enabled reports whether a Douban cookie is configured.
func (d *DoubanProvider) Enabled() bool {
return strings.TrimSpace(d.cfg.Secrets.DoubanCookie) != ""
}
// userAgents for anti-scraping randomization.
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
}
// DoubanMatch is the result of a Douban search hit.
type DoubanMatch struct {
DoubanID string `json:"douban_id"`
Title string `json:"title"`
Year string `json:"year"`
Img string `json:"img"`
Rating float32 `json:"rating"`
}
// Search runs a Douban subject_suggest query and returns the top match.
func (d *DoubanProvider) Search(ctx context.Context, query string) (*DoubanMatch, error) {
if !d.Enabled() || query == "" {
return nil, nil
}
u := "https://movie.douban.com/j/subject_suggest?q=" + url.QueryEscape(query)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
d.setHeaders(req)
resp, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("douban search: %d", resp.StatusCode)
}
type suggestion struct {
ID string `json:"id"`
Title string `json:"title"`
Year string `json:"year"`
Img string `json:"img"`
}
var results []suggestion
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return nil, err
}
if len(results) == 0 {
return nil, nil
}
r := results[0]
return &DoubanMatch{
DoubanID: r.ID,
Title: r.Title,
Year: r.Year,
Img: r.Img,
}, nil
}
func (d *DoubanProvider) setHeaders(req *http.Request) {
req.Header.Set("User-Agent", userAgents[rand.Intn(len(userAgents))])
req.Header.Set("Referer", "https://movie.douban.com/")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
if cookie := strings.TrimSpace(d.cfg.Secrets.DoubanCookie); cookie != "" {
req.Header.Set("Cookie", cookie)
}
}