mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
9cc8e02bec
Add the sky theme across the frontend and backend theme APIs, including starfield assets and icon-only branding. Refresh themed grid backgrounds, admin/login/sidebar styling, and theme-specific video/listing polish.
1210 lines
33 KiB
Go
1210 lines
33 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
crand "crypto/rand"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"math/rand/v2"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"github.com/video-site/backend/internal/auth"
|
||
"github.com/video-site/backend/internal/catalog"
|
||
"github.com/video-site/backend/internal/drives/localstorage"
|
||
"github.com/video-site/backend/internal/drives/localupload"
|
||
"github.com/video-site/backend/internal/drives/spider91"
|
||
"github.com/video-site/backend/internal/mediaasset"
|
||
"github.com/video-site/backend/internal/proxy"
|
||
)
|
||
|
||
const localUploadDriveID = localupload.DriveID
|
||
|
||
var allowedUploadExtensions = map[string]struct{}{
|
||
".avi": {},
|
||
".mkv": {},
|
||
".mov": {},
|
||
".mp4": {},
|
||
".webm": {},
|
||
}
|
||
|
||
var allowedUploadTags = map[string]struct{}{
|
||
"奶子": {},
|
||
"臀": {},
|
||
"口交": {},
|
||
"女大": {},
|
||
"人妻": {},
|
||
"AV": {},
|
||
}
|
||
|
||
type Server struct {
|
||
Catalog *catalog.Catalog
|
||
Proxy *proxy.Proxy
|
||
LocalDir string
|
||
UploadDir string
|
||
OnVideoUploaded func(*catalog.Video)
|
||
// OnHideVideo 处理前台「不再展示」。隐藏机制已废弃,改走拉黑逻辑:
|
||
// 删除库中记录 + 本地封面/预览,保留网盘源文件,并写黑名单墓碑
|
||
// (扫盘不再入库)。未注入时回退为旧的 hidden 标记。
|
||
OnHideVideo func(ctx context.Context, videoID string) error
|
||
|
||
tagCacheMu sync.Mutex
|
||
tagCacheUntil time.Time
|
||
tagCache []TagDTO
|
||
|
||
// GetTheme 返回当前生效的主题("dark" | "pink" | "sky")。前台 /api/settings/theme 用,
|
||
// 不需要登录。无注入时返回 "dark"。
|
||
GetTheme func() string
|
||
}
|
||
|
||
const (
|
||
homePageSize = 12
|
||
)
|
||
|
||
// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐
|
||
type VideoDTO struct {
|
||
ID string `json:"id"`
|
||
Href string `json:"href"`
|
||
Title string `json:"title"`
|
||
Thumbnail string `json:"thumbnail"`
|
||
PreviewSrc string `json:"previewSrc"`
|
||
PreviewDuration int `json:"previewDuration"`
|
||
PreviewStrategy string `json:"previewStrategy"`
|
||
Duration string `json:"duration"`
|
||
Badges []string `json:"badges"`
|
||
Quality string `json:"quality,omitempty"`
|
||
SourceLabel string `json:"sourceLabel,omitempty"`
|
||
Author string `json:"author"`
|
||
Views int `json:"views"`
|
||
Favorites int `json:"favorites"`
|
||
Comments int `json:"comments"`
|
||
Likes int `json:"likes"`
|
||
Dislikes int `json:"dislikes"`
|
||
PublishedAt string `json:"publishedAt"`
|
||
Tags []string `json:"tags,omitempty"`
|
||
Category string `json:"category,omitempty"`
|
||
}
|
||
|
||
type TagDTO struct {
|
||
ID string `json:"id"`
|
||
Label string `json:"label"`
|
||
Count int `json:"count"`
|
||
}
|
||
|
||
type VideoDetailDTO struct {
|
||
VideoDTO
|
||
VideoSrc string `json:"videoSrc"`
|
||
Poster string `json:"poster"`
|
||
Description string `json:"description"`
|
||
EmbedURL string `json:"embedUrl"`
|
||
Points int `json:"points,omitempty"`
|
||
AuthorProfile AuthorProfile `json:"authorProfile"`
|
||
RelatedVideos []VideoDTO `json:"relatedVideos"`
|
||
CommentsList []Comment `json:"commentsList"`
|
||
}
|
||
|
||
type AuthorProfile struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Href string `json:"href"`
|
||
Badges []string `json:"badges"`
|
||
}
|
||
|
||
type Comment struct {
|
||
ID string `json:"id"`
|
||
Author string `json:"author"`
|
||
Body string `json:"body"`
|
||
CreatedAt string `json:"createdAt"`
|
||
Likes int `json:"likes,omitempty"`
|
||
}
|
||
|
||
// RegisterRoutes 挂载前台 REST 路由。前台接口需要登录态。
|
||
func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
|
||
// 公开端点:拿当前生效的主题。登录页本身要在挂前就能读,所以单独挂在
|
||
// 鉴权组之外。只暴露 theme 一个字段,避免泄露其他设置。
|
||
r.Get("/api/settings/theme", s.handleGetTheme)
|
||
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(a.Required)
|
||
r.Get("/api/home", s.handleHome)
|
||
r.Get("/api/list", s.handleList)
|
||
r.Get("/api/video/{id}", s.handleVideoDetail)
|
||
r.Put("/api/video/{id}/tags", s.handleUpdateVideoTags)
|
||
r.Post("/api/video/{id}/like", s.handleLike)
|
||
r.Delete("/api/video/{id}/like", s.handleUnlike)
|
||
r.Post("/api/video/{id}/view", s.handleView)
|
||
r.Post("/api/video/{id}/hide", s.handleHideVideo)
|
||
r.Post("/api/upload", s.handleUploadVideo)
|
||
r.Get("/api/tags", s.handleTags)
|
||
r.Post("/api/shorts/next", s.handleShortsNext)
|
||
|
||
// 代理路由同样需要鉴权,防止绕过
|
||
r.Get("/p/stream/{driveID}/*", s.handleStream)
|
||
r.Get("/p/upload/{videoID}", s.handleUploadedVideo)
|
||
r.Get("/p/spider91/{videoID}", s.handleSpider91Video)
|
||
r.Get("/p/preview/{videoID}", s.handlePreview)
|
||
r.Get("/p/thumb/{videoID}", s.handleThumb)
|
||
})
|
||
}
|
||
|
||
// handleGetTheme 返回当前生效的主题。无需登录。响应永远是
|
||
// {"theme": "dark" | "pink" | "sky"},便于前端无脑解析。
|
||
func (s *Server) handleGetTheme(w http.ResponseWriter, r *http.Request) {
|
||
theme := "dark"
|
||
if s.GetTheme != nil {
|
||
if v := s.GetTheme(); v == "pink" || v == "dark" || v == "sky" {
|
||
theme = v
|
||
}
|
||
}
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
writeJSON(w, http.StatusOK, map[string]any{"theme": theme})
|
||
}
|
||
|
||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||
// 首页优先从全量已有封面的视频里随机抽取,避免只在最近一小段候选中反复出现。
|
||
excludeIDs := parseVideoIDQuery(r, "exclude", 120)
|
||
items, err := s.Catalog.RandomVideosWithReadyThumbnailsExcluding(r.Context(), excludeIDs, homePageSize)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
if len(items) < homePageSize {
|
||
fallbackExclude := append([]string{}, excludeIDs...)
|
||
for _, item := range items {
|
||
if item != nil {
|
||
fallbackExclude = append(fallbackExclude, item.ID)
|
||
}
|
||
}
|
||
fallback, err := s.Catalog.RandomVideosExcluding(r.Context(), fallbackExclude, homePageSize-len(items))
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
items = appendUniqueVideos(items, fallback, homePageSize)
|
||
}
|
||
if len(items) < homePageSize && len(excludeIDs) > 0 {
|
||
// The browser keeps a recent-video exclude list so normal refreshes do not
|
||
// repeat too quickly. On small libraries that list can cover every visible
|
||
// video; when that happens, start a new random round instead of returning
|
||
// an empty home section.
|
||
roundExclude := videoIDs(items)
|
||
fallback, err := s.Catalog.RandomVideosWithReadyThumbnailsExcluding(r.Context(), roundExclude, homePageSize-len(items))
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
items = appendUniqueVideos(items, fallback, homePageSize)
|
||
}
|
||
if len(items) < homePageSize && len(excludeIDs) > 0 {
|
||
fallback, err := s.Catalog.RandomVideosExcluding(r.Context(), videoIDs(items), homePageSize-len(items))
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
items = appendUniqueVideos(items, fallback, homePageSize)
|
||
}
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
writeJSON(w, http.StatusOK, mapVideos(items))
|
||
}
|
||
|
||
func parseVideoIDQuery(r *http.Request, key string, limit int) []string {
|
||
if r == nil {
|
||
return nil
|
||
}
|
||
values := r.URL.Query()[key]
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
seen := map[string]struct{}{}
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
for _, id := range strings.Split(value, ",") {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
out = append(out, id)
|
||
if limit > 0 && len(out) >= limit {
|
||
return out
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func appendUniqueVideos(dst []*catalog.Video, candidates []*catalog.Video, limit int) []*catalog.Video {
|
||
if len(dst) >= limit {
|
||
return dst[:limit]
|
||
}
|
||
seen := make(map[string]struct{}, len(dst))
|
||
for _, v := range dst {
|
||
if v != nil {
|
||
seen[v.ID] = struct{}{}
|
||
}
|
||
}
|
||
for _, v := range candidates {
|
||
if v == nil {
|
||
continue
|
||
}
|
||
if _, ok := seen[v.ID]; ok {
|
||
continue
|
||
}
|
||
dst = append(dst, v)
|
||
seen[v.ID] = struct{}{}
|
||
if len(dst) >= limit {
|
||
return dst
|
||
}
|
||
}
|
||
return dst
|
||
}
|
||
|
||
func videoIDs(items []*catalog.Video) []string {
|
||
out := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
if item != nil && item.ID != "" {
|
||
out = append(out, item.ID)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
page, _ := strconv.Atoi(q.Get("page"))
|
||
size, _ := strconv.Atoi(q.Get("size"))
|
||
if size <= 0 {
|
||
size = 24
|
||
}
|
||
sort := q.Get("sort")
|
||
params := catalog.ListParams{
|
||
Keyword: q.Get("q"),
|
||
Tag: q.Get("tag"),
|
||
Category: q.Get("cat"),
|
||
Sort: sort,
|
||
Page: page,
|
||
PageSize: size,
|
||
SkipTotal: strings.EqualFold(q.Get("count"), "false"),
|
||
}
|
||
if sort == "" || sort == "latest" {
|
||
params.PreferReadyThumbnails = true
|
||
}
|
||
items, total, err := s.Catalog.ListVideos(r.Context(), params)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"items": mapVideos(items),
|
||
"total": total,
|
||
"page": params.Page,
|
||
"size": params.PageSize,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||
id := routeParam(r, "id")
|
||
v, err := s.Catalog.GetVideo(r.Context(), id)
|
||
if err != nil {
|
||
writeErr(w, http.StatusNotFound, err)
|
||
return
|
||
}
|
||
if v.Hidden {
|
||
writeErr(w, http.StatusNotFound, sql.ErrNoRows)
|
||
return
|
||
}
|
||
if v.DriveID != localUploadDriveID {
|
||
if _, err := s.Catalog.GetDrive(r.Context(), v.DriveID); err != nil {
|
||
drives, listErr := s.Catalog.ListDrives(r.Context())
|
||
if listErr != nil || len(drives) > 0 {
|
||
writeErr(w, http.StatusNotFound, sql.ErrNoRows)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
related := s.pickRelatedVideos(r.Context(), v, 6)
|
||
dto := mapVideo(v)
|
||
if d, err := s.Catalog.GetDrive(r.Context(), v.DriveID); err == nil {
|
||
dto.SourceLabel = driveKindLabel(d.Kind)
|
||
}
|
||
|
||
detail := VideoDetailDTO{
|
||
VideoDTO: dto,
|
||
VideoSrc: s.videoSource(v),
|
||
Poster: thumbnailURL(v),
|
||
Description: v.Description,
|
||
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, pathSegment(v.ID)),
|
||
AuthorProfile: AuthorProfile{
|
||
ID: "author-" + v.Author,
|
||
Name: v.Author,
|
||
Href: "/author/" + v.Author,
|
||
Badges: []string{},
|
||
},
|
||
RelatedVideos: mapVideos(related),
|
||
CommentsList: []Comment{},
|
||
}
|
||
// 推荐每次随机生成,禁止浏览器和中间层缓存详情响应
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
writeJSON(w, http.StatusOK, detail)
|
||
}
|
||
|
||
// pickRelatedVideos 选 total 个推荐视频。
|
||
// 一半来自同标签命中,剩下用全库随机补齐;两段都优先取已有封面的视频,
|
||
// 不够时再回退到未生成封面的候选。结果不会重复,也不会包含当前视频。
|
||
func (s *Server) pickRelatedVideos(ctx context.Context, current *catalog.Video, total int) []*catalog.Video {
|
||
if total <= 0 || current == nil {
|
||
return nil
|
||
}
|
||
tagQuota := total / 2
|
||
if tagQuota <= 0 && len(current.Tags) > 0 {
|
||
tagQuota = 1
|
||
}
|
||
|
||
picked := make([]*catalog.Video, 0, total)
|
||
seen := map[string]struct{}{current.ID: {}}
|
||
|
||
// 1) 同标签候选:先取已有封面的候选,数量不够再从全部候选里补。
|
||
if tagQuota > 0 && len(current.Tags) > 0 {
|
||
picked = appendRandomRelated(
|
||
picked,
|
||
s.relatedTagPool(ctx, current.Tags, seen, true),
|
||
tagQuota,
|
||
seen,
|
||
)
|
||
if len(picked) < tagQuota {
|
||
picked = appendRandomRelated(
|
||
picked,
|
||
s.relatedTagPool(ctx, current.Tags, seen, false),
|
||
tagQuota,
|
||
seen,
|
||
)
|
||
}
|
||
}
|
||
|
||
// 2) 随机补齐:同样优先已有封面的全库候选,不够再回退。
|
||
if len(picked) < total {
|
||
picked = appendRandomRelated(
|
||
picked,
|
||
s.relatedListPool(ctx, seen, true, 200),
|
||
total,
|
||
seen,
|
||
)
|
||
}
|
||
if len(picked) < total {
|
||
picked = appendRandomRelated(
|
||
picked,
|
||
s.relatedListPool(ctx, seen, false, 200),
|
||
total,
|
||
seen,
|
||
)
|
||
}
|
||
|
||
return picked
|
||
}
|
||
|
||
func (s *Server) relatedTagPool(ctx context.Context, tags []string, seen map[string]struct{}, readyOnly bool) []*catalog.Video {
|
||
var pool []*catalog.Video
|
||
poolSeen := make(map[string]struct{})
|
||
for _, tag := range tags {
|
||
if tag == "" {
|
||
continue
|
||
}
|
||
items, _, err := s.Catalog.ListVideos(ctx, catalog.ListParams{
|
||
Tag: tag,
|
||
Sort: "latest",
|
||
Page: 1,
|
||
PageSize: 30,
|
||
ThumbnailReadyOnly: readyOnly,
|
||
PreferReadyThumbnails: !readyOnly,
|
||
})
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, v := range items {
|
||
if v == nil {
|
||
continue
|
||
}
|
||
if _, ok := seen[v.ID]; ok {
|
||
continue
|
||
}
|
||
if _, ok := poolSeen[v.ID]; ok {
|
||
continue
|
||
}
|
||
poolSeen[v.ID] = struct{}{}
|
||
pool = append(pool, v)
|
||
}
|
||
}
|
||
return pool
|
||
}
|
||
|
||
func (s *Server) relatedListPool(ctx context.Context, seen map[string]struct{}, readyOnly bool, pageSize int) []*catalog.Video {
|
||
items, _, err := s.Catalog.ListVideos(ctx, catalog.ListParams{
|
||
Sort: "latest",
|
||
Page: 1,
|
||
PageSize: pageSize,
|
||
ThumbnailReadyOnly: readyOnly,
|
||
PreferReadyThumbnails: !readyOnly,
|
||
})
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
pool := make([]*catalog.Video, 0, len(items))
|
||
for _, v := range items {
|
||
if v == nil {
|
||
continue
|
||
}
|
||
if _, ok := seen[v.ID]; ok {
|
||
continue
|
||
}
|
||
pool = append(pool, v)
|
||
}
|
||
return pool
|
||
}
|
||
|
||
func appendRandomRelated(picked []*catalog.Video, pool []*catalog.Video, targetLen int, seen map[string]struct{}) []*catalog.Video {
|
||
if len(picked) >= targetLen || len(pool) == 0 {
|
||
return picked
|
||
}
|
||
rand.Shuffle(len(pool), func(i, j int) {
|
||
pool[i], pool[j] = pool[j], pool[i]
|
||
})
|
||
for _, v := range pool {
|
||
if len(picked) >= targetLen {
|
||
break
|
||
}
|
||
if v == nil {
|
||
continue
|
||
}
|
||
if _, ok := seen[v.ID]; ok {
|
||
continue
|
||
}
|
||
seen[v.ID] = struct{}{}
|
||
picked = append(picked, v)
|
||
}
|
||
return picked
|
||
}
|
||
|
||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||
now := time.Now()
|
||
s.tagCacheMu.Lock()
|
||
if s.tagCache != nil && now.Before(s.tagCacheUntil) {
|
||
out := append([]TagDTO(nil), s.tagCache...)
|
||
s.tagCacheMu.Unlock()
|
||
w.Header().Set("Cache-Control", "private, max-age=15")
|
||
writeJSON(w, http.StatusOK, out)
|
||
return
|
||
}
|
||
s.tagCacheMu.Unlock()
|
||
|
||
stats, err := s.Catalog.ListTags(r.Context())
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
out := make([]TagDTO, 0, len(stats))
|
||
for _, stat := range stats {
|
||
out = append(out, TagDTO{ID: stat.Label, Label: stat.Label, Count: stat.Count})
|
||
}
|
||
s.tagCacheMu.Lock()
|
||
s.tagCache = append([]TagDTO(nil), out...)
|
||
s.tagCacheUntil = now.Add(30 * time.Second)
|
||
s.tagCacheMu.Unlock()
|
||
|
||
w.Header().Set("Cache-Control", "private, max-age=15")
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// shortsNextReq 客户端把当前轮已看过的 video id 列表传上来。
|
||
type shortsNextReq struct {
|
||
SeenIDs []string `json:"seenIds"`
|
||
Count int `json:"count"`
|
||
}
|
||
|
||
// ShortsItemDTO 是短视频流单条的精简结构。比 VideoDTO 多 videoSrc / poster,
|
||
// 方便前端直接喂给 <video>,不必再为每条请求 /api/video/:id。
|
||
type ShortsItemDTO struct {
|
||
VideoDTO
|
||
VideoSrc string `json:"videoSrc"`
|
||
Poster string `json:"poster"`
|
||
}
|
||
|
||
// handleShortsNext 为短视频模式提供"不重复随机视频"接口。
|
||
//
|
||
// 行为:
|
||
// - 入参 seenIds 为客户端当前轮已看过的视频 id(来自 localStorage)
|
||
// - 服务器从未在 seenIds 中的可见视频里随机抽至多 count 条返回
|
||
// - 当返回数量 < count 且小于全库可见总数时,说明本轮即将结束,
|
||
// 返回 roundComplete=true,前端应在用户看完返回的这些后清空本地已看记录开新一轮
|
||
// - 当 seenIds 真实覆盖当前全部可见视频时,本接口直接返回新一轮的随机一批
|
||
// (不能仅看 seenIds 长度,里面可能有隐藏、删除或历史脏 ID)
|
||
func (s *Server) handleShortsNext(w http.ResponseWriter, r *http.Request) {
|
||
var body shortsNextReq
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||
writeErr(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
count := body.Count
|
||
if count <= 0 {
|
||
count = 5
|
||
}
|
||
if count > 20 {
|
||
count = 20
|
||
}
|
||
|
||
total, err := s.Catalog.CountVisibleVideos(r.Context())
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
|
||
items, err := s.Catalog.RandomVideosExcluding(r.Context(), body.SeenIDs, count)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
if total > 0 && len(items) == 0 && len(body.SeenIDs) > 0 {
|
||
items, err = s.Catalog.RandomVideosExcluding(r.Context(), nil, count)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 注入 sourceLabel 以便前端展示来源网盘
|
||
driveLabels := make(map[string]string)
|
||
out := make([]ShortsItemDTO, 0, len(items))
|
||
for _, v := range items {
|
||
dto := mapVideo(v)
|
||
if label, ok := driveLabels[v.DriveID]; ok {
|
||
dto.SourceLabel = label
|
||
} else if d, err := s.Catalog.GetDrive(r.Context(), v.DriveID); err == nil {
|
||
label := driveKindLabel(d.Kind)
|
||
driveLabels[v.DriveID] = label
|
||
dto.SourceLabel = label
|
||
}
|
||
out = append(out, ShortsItemDTO{
|
||
VideoDTO: dto,
|
||
VideoSrc: s.videoSource(v),
|
||
Poster: thumbnailURL(v),
|
||
})
|
||
}
|
||
|
||
// roundComplete: 服务端能给出的视频数小于 count,说明剩余可选已耗尽,
|
||
// 前端把这批播完后应该清空本地 seenIds 开新一轮。
|
||
roundComplete := len(out) < count
|
||
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"items": out,
|
||
"total": total,
|
||
"roundComplete": roundComplete,
|
||
})
|
||
}
|
||
|
||
type updateVideoTagsReq struct {
|
||
Tags []string `json:"tags"`
|
||
}
|
||
|
||
func (s *Server) handleUpdateVideoTags(w http.ResponseWriter, r *http.Request) {
|
||
id := routeParam(r, "id")
|
||
var body updateVideoTagsReq
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeErr(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
if err := s.Catalog.SetManualVideoTags(r.Context(), id, body.Tags); err != nil {
|
||
if errors.Is(err, catalog.ErrUnknownTag) {
|
||
writeErr(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
v, err := s.Catalog.GetVideo(r.Context(), id)
|
||
if err != nil {
|
||
writeErr(w, http.StatusNotFound, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, mapVideo(v))
|
||
}
|
||
|
||
func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
|
||
id := routeParam(r, "id")
|
||
likes, err := s.Catalog.IncrementLike(r.Context(), id)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"likes": likes})
|
||
}
|
||
|
||
// handleUnlike 取消点赞:likes - 1(保底 0)。
|
||
// 短视频模式中爱心按钮点击切换状态时使用。
|
||
func (s *Server) handleUnlike(w http.ResponseWriter, r *http.Request) {
|
||
id := routeParam(r, "id")
|
||
likes, err := s.Catalog.DecrementLike(r.Context(), id)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, err)
|
||
return
|
||
}
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"likes": likes})
|
||
}
|
||
|
||
func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
|
||
id := routeParam(r, "id")
|
||
views, err := s.Catalog.IncrementView(r.Context(), id)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, err)
|
||
return
|
||
}
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"views": views})
|
||
}
|
||
|
||
func (s *Server) handleHideVideo(w http.ResponseWriter, r *http.Request) {
|
||
id := routeParam(r, "id")
|
||
var err error
|
||
if s.OnHideVideo != nil {
|
||
// 走拉黑逻辑:删记录 + 删本地封面/预览 + 写墓碑,保留网盘源文件。
|
||
err = s.OnHideVideo(r.Context(), id)
|
||
} else {
|
||
err = s.Catalog.HideVideo(r.Context(), id)
|
||
}
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, err)
|
||
return
|
||
}
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
func (s *Server) handleUploadVideo(w http.ResponseWriter, r *http.Request) {
|
||
if s.LocalDir == "" {
|
||
writeErr(w, http.StatusInternalServerError, errors.New("local storage is not configured"))
|
||
return
|
||
}
|
||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||
writeErr(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
if r.MultipartForm != nil {
|
||
defer r.MultipartForm.RemoveAll()
|
||
}
|
||
|
||
file, header, err := r.FormFile("file")
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, errors.New("video file is required"))
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
originalName := filepath.Base(strings.TrimSpace(header.Filename))
|
||
ext := strings.ToLower(filepath.Ext(originalName))
|
||
if _, ok := allowedUploadExtensions[ext]; !ok {
|
||
writeErr(w, http.StatusBadRequest, fmt.Errorf("unsupported video extension: %s", ext))
|
||
return
|
||
}
|
||
|
||
tags, err := parseUploadTags(uploadTagValues(r))
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
title := strings.TrimSpace(r.FormValue("title"))
|
||
if title == "" {
|
||
title = uploadTitleFromFileName(originalName)
|
||
}
|
||
|
||
uploadID, err := newUploadID(now)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
storedName := uploadID + ext
|
||
dst, err := s.localUploadFilePath(storedName)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
|
||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
size, copyErr := io.Copy(out, file)
|
||
closeErr := out.Close()
|
||
if copyErr != nil {
|
||
_ = os.Remove(dst)
|
||
writeErr(w, http.StatusInternalServerError, copyErr)
|
||
return
|
||
}
|
||
if closeErr != nil {
|
||
_ = os.Remove(dst)
|
||
writeErr(w, http.StatusInternalServerError, closeErr)
|
||
return
|
||
}
|
||
if size <= 0 {
|
||
_ = os.Remove(dst)
|
||
writeErr(w, http.StatusBadRequest, errors.New("uploaded video is empty"))
|
||
return
|
||
}
|
||
|
||
video := &catalog.Video{
|
||
ID: localUploadDriveID + "-" + uploadID,
|
||
DriveID: localUploadDriveID,
|
||
FileID: storedName,
|
||
FileName: originalName,
|
||
Title: title,
|
||
Author: "用户上传",
|
||
Tags: tags,
|
||
Size: size,
|
||
Ext: strings.TrimPrefix(ext, "."),
|
||
PreviewStatus: "pending",
|
||
PublishedAt: now,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
if err := s.Catalog.UpsertVideo(r.Context(), video); err != nil {
|
||
_ = os.Remove(dst)
|
||
writeErr(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
if s.OnVideoUploaded != nil {
|
||
s.OnVideoUploaded(video)
|
||
}
|
||
writeJSON(w, http.StatusCreated, mapVideo(video))
|
||
}
|
||
|
||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||
driveID := routeParam(r, "driveID")
|
||
fileID := routeWildcardParam(r, "*")
|
||
s.Proxy.ServeStream(w, r, driveID, fileID)
|
||
}
|
||
func (s *Server) handleUploadedVideo(w http.ResponseWriter, r *http.Request) {
|
||
videoID := routeParam(r, "videoID")
|
||
v, err := s.Catalog.GetVideo(r.Context(), videoID)
|
||
if err != nil || v.Hidden || v.DriveID != localUploadDriveID {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
path, err := s.localUploadFilePath(v.FileID)
|
||
if err != nil {
|
||
http.Error(w, "invalid upload file", http.StatusForbidden)
|
||
return
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil || info.IsDir() || info.Size() == 0 {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||
http.ServeFile(w, r, path)
|
||
}
|
||
|
||
// handleSpider91Video 服务 spider91 drive 下载到本地的视频文件。
|
||
// 路径形如 /p/spider91/<videoID>,videoID = "spider91-<driveID>-<sourceID>"。
|
||
// 通过 catalog 拿到 file_id("<sourceID>.mp4"),再让 driver 解析到绝对路径并 ServeFile。
|
||
func (s *Server) handleSpider91Video(w http.ResponseWriter, r *http.Request) {
|
||
videoID := routeParam(r, "videoID")
|
||
v, err := s.Catalog.GetVideo(r.Context(), videoID)
|
||
if err != nil || v.Hidden {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if s.Proxy == nil || s.Proxy.Registry == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
d, ok := s.Proxy.Registry.Get(v.DriveID)
|
||
if !ok || d.Kind() != spider91.Kind {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
sd, ok := d.(*spider91.Driver)
|
||
if !ok {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
path, err := sd.VideoPath(v.FileID)
|
||
if err != nil {
|
||
http.Error(w, "invalid video id", http.StatusForbidden)
|
||
return
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil || info.IsDir() || info.Size() == 0 {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||
http.ServeFile(w, r, path)
|
||
}
|
||
|
||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||
videoID := routeParam(r, "videoID")
|
||
v, err := s.Catalog.GetVideo(r.Context(), videoID)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if v.PreviewStatus != "ready" {
|
||
http.Error(w, "preview not ready", http.StatusNotFound)
|
||
return
|
||
}
|
||
if v.PreviewLocal != "" {
|
||
if !strings.HasPrefix(filepath.Clean(v.PreviewLocal), filepath.Clean(s.LocalDir)) {
|
||
http.Error(w, "invalid local path", http.StatusForbidden)
|
||
return
|
||
}
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.Header().Set("Pragma", "no-cache")
|
||
w.Header().Set("Expires", "0")
|
||
s.Proxy.ServeLocal(w, r, v.PreviewLocal)
|
||
return
|
||
}
|
||
http.NotFound(w, r)
|
||
}
|
||
|
||
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||
videoID := routeParam(r, "videoID")
|
||
var clean string
|
||
for _, path := range mediaasset.ThumbnailPathCandidates(s.LocalDir, videoID) {
|
||
candidate := filepath.Clean(path)
|
||
if !strings.HasPrefix(candidate, filepath.Clean(s.LocalDir)) {
|
||
http.Error(w, "invalid path", http.StatusForbidden)
|
||
return
|
||
}
|
||
if _, err := os.Stat(candidate); err == nil {
|
||
clean = candidate
|
||
break
|
||
}
|
||
}
|
||
if clean == "" {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||
s.Proxy.ServeLocal(w, r, clean)
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
|
||
func mapVideo(v *catalog.Video) VideoDTO {
|
||
badges := v.Badges
|
||
if badges == nil {
|
||
badges = []string{}
|
||
}
|
||
tags := v.Tags
|
||
if tags == nil {
|
||
tags = []string{}
|
||
}
|
||
return VideoDTO{
|
||
ID: v.ID,
|
||
Href: "/video/" + pathSegment(v.ID),
|
||
Title: v.Title,
|
||
Thumbnail: thumbnailURL(v),
|
||
PreviewSrc: previewURL(v),
|
||
PreviewDuration: 12,
|
||
PreviewStrategy: "teaser-file",
|
||
Duration: formatDuration(v.DurationSeconds),
|
||
Badges: badges,
|
||
Quality: v.Quality,
|
||
Author: v.Author,
|
||
Views: v.Views,
|
||
Favorites: v.Favorites,
|
||
Comments: v.Comments,
|
||
Likes: v.Likes,
|
||
Dislikes: v.Dislikes,
|
||
PublishedAt: v.PublishedAt.Format("2006-01-02"),
|
||
Tags: tags,
|
||
Category: v.Category,
|
||
}
|
||
}
|
||
|
||
func previewURL(v *catalog.Video) string {
|
||
base := "/p/preview/" + pathSegment(v.ID)
|
||
if v.UpdatedAt.IsZero() {
|
||
return base
|
||
}
|
||
return base + "?v=" + strconv.FormatInt(v.UpdatedAt.UnixMilli(), 10)
|
||
}
|
||
|
||
func thumbnailURL(v *catalog.Video) string {
|
||
base := "/p/thumb/" + pathSegment(v.ID)
|
||
if v.ThumbnailURL != "" {
|
||
base = v.ThumbnailURL
|
||
if thumbnailURLMatchesVideoID(base, v.ID) {
|
||
base = "/p/thumb/" + pathSegment(v.ID)
|
||
}
|
||
}
|
||
if !strings.HasPrefix(base, "/p/thumb/") || v.UpdatedAt.IsZero() {
|
||
return base
|
||
}
|
||
return base + "?v=" + strconv.FormatInt(v.UpdatedAt.UnixMilli(), 10)
|
||
}
|
||
|
||
// transcodedSource 在视频有就绪的浏览器兼容性转码产物时返回产物的播放地址。
|
||
// 产物和原始文件在同一个 drive 上,走同一条 /p/stream 代理/302 链路。
|
||
func transcodedSource(v *catalog.Video) (string, bool) {
|
||
if v.TranscodeStatus == "ready" && v.TranscodedFileID != "" && v.DriveID != localUploadDriveID {
|
||
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.TranscodedFileID)), true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func (s *Server) videoSource(v *catalog.Video) string {
|
||
if v.DriveID == localUploadDriveID {
|
||
return "/p/upload/" + pathSegment(v.ID)
|
||
}
|
||
if s.Proxy != nil && s.Proxy.Registry != nil {
|
||
if d, ok := s.Proxy.Registry.Get(v.DriveID); ok {
|
||
switch d.Kind() {
|
||
case spider91.Kind:
|
||
return "/p/spider91/" + pathSegment(v.ID)
|
||
}
|
||
}
|
||
}
|
||
if src, ok := transcodedSource(v); ok {
|
||
return src
|
||
}
|
||
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID))
|
||
}
|
||
|
||
// videoSource 兼容旧调用点,没有 server context 时按之前逻辑回退到 /p/stream。
|
||
// 内部新增的代码请使用 (*Server).videoSource。
|
||
func videoSource(v *catalog.Video) string {
|
||
if v.DriveID == localUploadDriveID {
|
||
return "/p/upload/" + pathSegment(v.ID)
|
||
}
|
||
if src, ok := transcodedSource(v); ok {
|
||
return src
|
||
}
|
||
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID))
|
||
}
|
||
|
||
func pathSegment(value string) string {
|
||
return url.PathEscape(value)
|
||
}
|
||
|
||
func routeParam(r *http.Request, key string) string {
|
||
value := chi.URLParam(r, key)
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if decoded, err := url.PathUnescape(value); err == nil {
|
||
return decoded
|
||
}
|
||
return value
|
||
}
|
||
|
||
func routeWildcardParam(r *http.Request, key string) string {
|
||
value := chi.URLParam(r, key)
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
value = strings.TrimPrefix(value, "/")
|
||
if decoded, err := url.PathUnescape(value); err == nil {
|
||
return decoded
|
||
}
|
||
return value
|
||
}
|
||
|
||
func thumbnailURLMatchesVideoID(value, videoID string) bool {
|
||
if !strings.HasPrefix(value, "/p/thumb/") {
|
||
return false
|
||
}
|
||
tail := strings.TrimPrefix(value, "/p/thumb/")
|
||
if idx := strings.IndexByte(tail, '?'); idx >= 0 {
|
||
tail = tail[:idx]
|
||
}
|
||
if tail == videoID {
|
||
return true
|
||
}
|
||
decoded, err := url.PathUnescape(tail)
|
||
return err == nil && decoded == videoID
|
||
}
|
||
|
||
func driveKindLabel(kind string) string {
|
||
switch kind {
|
||
case "quark":
|
||
return "夸克网盘"
|
||
case "p115":
|
||
return "115 网盘"
|
||
case "p123":
|
||
return "123网盘"
|
||
case "pikpak":
|
||
return "PikPak"
|
||
case "wopan":
|
||
return "联通网盘"
|
||
case "onedrive":
|
||
return "OneDrive"
|
||
case "googledrive":
|
||
return "Google Drive"
|
||
case localstorage.Kind:
|
||
return "本地存储"
|
||
case spider91.Kind:
|
||
return "91 爬虫"
|
||
default:
|
||
return kind
|
||
}
|
||
}
|
||
|
||
func (s *Server) localUploadFilePath(fileID string) (string, error) {
|
||
if strings.TrimSpace(fileID) == "" || filepath.Base(fileID) != fileID {
|
||
return "", errors.New("invalid upload file id")
|
||
}
|
||
root := s.localUploadDir()
|
||
if root == "" {
|
||
return "", errors.New("local upload storage is not configured")
|
||
}
|
||
path := filepath.Join(root, fileID)
|
||
cleanRoot, err := filepath.Abs(root)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
cleanPath, err := filepath.Abs(path)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if cleanPath != cleanRoot && !strings.HasPrefix(cleanPath, cleanRoot+string(os.PathSeparator)) {
|
||
return "", errors.New("invalid upload file id")
|
||
}
|
||
return cleanPath, nil
|
||
}
|
||
|
||
func (s *Server) localUploadDir() string {
|
||
if s.UploadDir != "" {
|
||
return s.UploadDir
|
||
}
|
||
if s.LocalDir == "" {
|
||
return ""
|
||
}
|
||
return filepath.Join(filepath.Dir(s.LocalDir), "uploads")
|
||
}
|
||
|
||
func uploadTagValues(r *http.Request) []string {
|
||
if r.MultipartForm == nil {
|
||
return nil
|
||
}
|
||
values := append([]string{}, r.MultipartForm.Value["tags"]...)
|
||
values = append(values, r.MultipartForm.Value["tag"]...)
|
||
return values
|
||
}
|
||
|
||
func uploadTitleFromFileName(fileName string) string {
|
||
name := strings.TrimSpace(filepath.Base(fileName))
|
||
ext := filepath.Ext(name)
|
||
if ext != "" {
|
||
if trimmed := strings.TrimSuffix(name, ext); strings.TrimSpace(trimmed) != "" {
|
||
return trimmed
|
||
}
|
||
}
|
||
if name != "" {
|
||
return name
|
||
}
|
||
return "upload-" + time.Now().Format("20060102150405")
|
||
}
|
||
|
||
func parseUploadTags(values []string) ([]string, error) {
|
||
seen := make(map[string]struct{})
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
for _, label := range splitUploadTags(value) {
|
||
if _, ok := allowedUploadTags[label]; !ok {
|
||
return nil, fmt.Errorf("unsupported upload tag: %s", label)
|
||
}
|
||
if _, ok := seen[label]; ok {
|
||
continue
|
||
}
|
||
seen[label] = struct{}{}
|
||
out = append(out, label)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func splitUploadTags(value string) []string {
|
||
fields := strings.FieldsFunc(value, func(r rune) bool {
|
||
switch r {
|
||
case ',', ',', ';', ';', '\n', '\r', '\t', ' ':
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
})
|
||
out := make([]string, 0, len(fields))
|
||
for _, field := range fields {
|
||
if label := strings.TrimSpace(field); label != "" {
|
||
out = append(out, label)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func newUploadID(now time.Time) (string, error) {
|
||
var suffix [6]byte
|
||
if _, err := crand.Read(suffix[:]); err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("upload-%d-%s", now.UnixNano(), hex.EncodeToString(suffix[:])), nil
|
||
}
|
||
|
||
func mapVideos(vs []*catalog.Video) []VideoDTO {
|
||
out := make([]VideoDTO, 0, len(vs))
|
||
for _, v := range vs {
|
||
out = append(out, mapVideo(v))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func formatDuration(sec int) string {
|
||
if sec <= 0 {
|
||
return "00:00"
|
||
}
|
||
m := sec / 60
|
||
s := sec % 60
|
||
return fmt.Sprintf("%02d:%02d", m, s)
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(code)
|
||
_ = json.NewEncoder(w).Encode(body)
|
||
}
|
||
|
||
func writeErr(w http.ResponseWriter, code int, err error) {
|
||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||
}
|