Files
91/backend/internal/catalog/catalog.go
T
nianzhibai 738406162a feat: add video blacklist management
Add backend blacklist tombstone APIs and hidden-video migration support.

Update the admin video management UI with blacklist tabs, restore actions, alignment fixes, responsive layout polish, and regression coverage.
2026-06-13 14:34:00 +08:00

2458 lines
77 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 catalog
import (
"context"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
)
//go:embed schema.sql
var schemaSQL string
type Catalog struct {
db *sql.DB
}
type CrawlerAssetCounts struct {
Total int
Local int
Migrated int
Thumbnail DriveThumbnailCounts
Teaser DriveTeaserCounts
Fingerprint DriveFingerprintCounts
}
func Open(path string) (*Catalog, error) {
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
if _, err := db.Exec(schemaSQL); err != nil {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
c := &Catalog{db: db}
if err := c.migrate(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("migrate catalog: %w", err)
}
return c, nil
}
func (c *Catalog) Close() error { return c.db.Close() }
// ---------- Video ----------
type Video struct {
ID string `json:"id"`
DriveID string `json:"driveId"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
ContentHash string `json:"contentHash"`
SampledSHA256 string `json:"sampledSha256"`
FingerprintStatus string `json:"fingerprintStatus"`
FingerprintError string `json:"fingerprintError"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Author string `json:"author"`
Tags []string `json:"tags"`
DurationSeconds int `json:"durationSeconds"`
Size int64 `json:"size"`
Ext string `json:"ext"`
Quality string `json:"quality"`
ThumbnailURL string `json:"thumbnailUrl"`
PreviewFileID string `json:"previewFileId"`
PreviewLocal string `json:"previewLocal"`
PreviewStatus string `json:"previewStatus"`
// TranscodeStatus:浏览器兼容性转码状态。
// ''=未检测 / pending=已入队 / ready=已转码 / skipped=无需转码 / failed=失败。
TranscodeStatus string `json:"transcodeStatus"`
TranscodeError string `json:"transcodeError"`
TranscodedFileID string `json:"transcodedFileId"`
TranscodedSize int64 `json:"transcodedSize"`
Views int `json:"views"`
Favorites int `json:"favorites"`
Comments int `json:"comments"`
Likes int `json:"likes"`
Dislikes int `json:"dislikes"`
Category string `json:"category"`
Hidden bool `json:"hidden"`
Badges []string `json:"badges"`
Description string `json:"description"`
PublishedAt time.Time `json:"publishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
existed := c.videoExists(ctx, v.ID)
v.ContentHash = normalizeContentHash(v.ContentHash)
v.SampledSHA256 = normalizeContentHash(v.SampledSHA256)
fingerprintStatus := nullableStatus(v.FingerprintStatus)
if v.SampledSHA256 != "" && (v.FingerprintStatus == "" || v.FingerprintStatus == "pending") {
fingerprintStatus = "ready"
}
tagsJSON, _ := json.Marshal(v.Tags)
badgesJSON, _ := json.Marshal(v.Badges)
now := time.Now().UnixMilli()
if v.CreatedAt.IsZero() {
v.CreatedAt = time.UnixMilli(now)
}
v.UpdatedAt = time.UnixMilli(now)
_, err := c.db.ExecContext(ctx, `
INSERT INTO videos (
id, drive_id, file_id, file_name, content_hash, sampled_sha256, fingerprint_status, fingerprint_error, parent_id, title, author, tags,
duration_seconds, size_bytes, ext, quality, thumbnail_url, thumbnail_status,
preview_file_id, preview_local, preview_status,
views, favorites, comments, likes, dislikes,
category, hidden, badges, description, published_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, CASE WHEN COALESCE(?, '') != '' THEN 'ready' ELSE 'pending' END,
?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
file_name = CASE
WHEN excluded.file_name != '' THEN excluded.file_name
ELSE videos.file_name
END,
title = excluded.title,
author = excluded.author,
tags = excluded.tags,
content_hash = CASE
WHEN excluded.content_hash != '' THEN excluded.content_hash
ELSE videos.content_hash
END,
sampled_sha256 = CASE
WHEN videos.size_bytes != excluded.size_bytes THEN excluded.sampled_sha256
WHEN excluded.sampled_sha256 != '' THEN excluded.sampled_sha256
ELSE videos.sampled_sha256
END,
fingerprint_status = CASE
WHEN videos.size_bytes != excluded.size_bytes THEN COALESCE(excluded.fingerprint_status, 'pending')
WHEN excluded.sampled_sha256 != '' THEN COALESCE(excluded.fingerprint_status, 'ready')
ELSE COALESCE(videos.fingerprint_status, 'pending')
END,
fingerprint_error = CASE
WHEN videos.size_bytes != excluded.size_bytes THEN COALESCE(excluded.fingerprint_error, '')
WHEN excluded.sampled_sha256 != '' THEN COALESCE(excluded.fingerprint_error, '')
ELSE COALESCE(videos.fingerprint_error, '')
END,
duration_seconds= excluded.duration_seconds,
size_bytes = excluded.size_bytes,
ext = excluded.ext,
quality = excluded.quality,
thumbnail_url = excluded.thumbnail_url,
-- thumbnail_url 写非空就意味着文件已就绪(要么 worker 抽帧填的本地 /p/thumb/<id>
-- 要么网盘 API 直接给的远程 URL,要么管理员手动指定)。同步把 status 标 'ready'
-- 避免出现 "url 非空 + status='pending'" 的脏状态。url 被改成空(本调用不发生,
-- 走 clearVolatileOneDriveThumbnails 直 SQL)保留原状态。
thumbnail_status= CASE
WHEN COALESCE(excluded.thumbnail_url, '') != '' THEN 'ready'
ELSE videos.thumbnail_status
END,
category = excluded.category,
badges = excluded.badges,
description = excluded.description,
updated_at = excluded.updated_at
`,
v.ID, v.DriveID, v.FileID, v.FileName, v.ContentHash, v.SampledSHA256, fingerprintStatus, v.FingerprintError, v.ParentID, v.Title, v.Author, string(tagsJSON),
v.DurationSeconds, v.Size, v.Ext, v.Quality, v.ThumbnailURL, v.ThumbnailURL,
v.PreviewFileID, v.PreviewLocal, nullableStatus(v.PreviewStatus),
v.Views, v.Favorites, v.Comments, v.Likes, v.Dislikes,
v.Category, boolToInt(v.Hidden), string(badgesJSON), v.Description,
v.PublishedAt.UnixMilli(), v.CreatedAt.UnixMilli(), v.UpdatedAt.UnixMilli(),
)
if err != nil {
return err
}
if len(v.Tags) > 0 && !existed {
return c.replaceVideoTags(ctx, v.ID, v.Tags, "auto", false, true)
}
return nil
}
func nullableStatus(s string) string {
if s == "" {
return "pending"
}
return s
}
func (c *Catalog) UpdatePreview(ctx context.Context, id, previewLocal, status string) error {
_, err := c.db.ExecContext(ctx,
`UPDATE videos SET preview_file_id = '', preview_local = ?, preview_status = ?, updated_at = ? WHERE id = ?`,
previewLocal, status, time.Now().UnixMilli(), id)
return err
}
// transcodeCandidateWhereSQL 圈定"可能需要浏览器兼容性转码"的视频:
// mp4/webm/m4v 默认浏览器可播不进候选;strm 是远程引用没有本体。
// 其余扩展名都先入候选,由转码 worker probe 实际编码后决定转码还是跳过
// skipped)。failed 也保留在候选里,重新点开始转码时会自动重试。
const transcodeCandidateWhereSQL = `COALESCE(ext, '') NOT IN ('mp4', 'webm', 'm4v', 'strm')
AND COALESCE(transcode_status, '') IN ('', 'pending', 'failed')`
// ListTranscodeCandidates 列出某盘所有转码候选视频。limit<=0 表示不限制。
func (c *Catalog) ListTranscodeCandidates(ctx context.Context, driveID string, limit int) ([]*Video, error) {
query := `SELECT ` + allVideoCols + ` FROM videos
WHERE drive_id = ? AND ` + transcodeCandidateWhereSQL + `
ORDER BY created_at ASC, id ASC`
args := []any{driveID}
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
}
rows, err := c.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// UpdateVideoTranscode 写回单条视频的转码结果。
// status=ready 时 transcodedFileID/transcodedSize 指向转码产物;
// 其它 status 调用方应传空值,本函数会按传入值原样覆盖。
func (c *Catalog) UpdateVideoTranscode(ctx context.Context, id, status, errMsg, transcodedFileID string, transcodedSize int64) error {
_, err := c.db.ExecContext(ctx,
`UPDATE videos SET transcode_status = ?, transcode_error = ?, transcoded_file_id = ?, transcoded_size = ?, updated_at = ? WHERE id = ?`,
status, errMsg, transcodedFileID, transcodedSize, time.Now().UnixMilli(), id)
return err
}
// DriveTranscodeCounts 是单盘的转码进度统计。
type DriveTranscodeCounts struct {
// Pending 是仍在候选集合里、还没有出结果的数量(含从未检测过的)。
Pending int
Ready int
Failed int
Skipped int
}
func (c *Catalog) CountTranscodesByDrive(ctx context.Context) (map[string]DriveTranscodeCounts, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT drive_id,
COUNT(CASE WHEN COALESCE(ext, '') NOT IN ('mp4', 'webm', 'm4v', 'strm')
AND COALESCE(transcode_status, '') IN ('', 'pending') THEN 1 END) AS pending_count,
COUNT(CASE WHEN COALESCE(transcode_status, '') = 'ready' THEN 1 END) AS ready_count,
COUNT(CASE WHEN COALESCE(transcode_status, '') = 'failed' THEN 1 END) AS failed_count,
COUNT(CASE WHEN COALESCE(transcode_status, '') = 'skipped' THEN 1 END) AS skipped_count
FROM videos
GROUP BY drive_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]DriveTranscodeCounts)
for rows.Next() {
var driveID string
var counts DriveTranscodeCounts
if err := rows.Scan(&driveID, &counts.Pending, &counts.Ready, &counts.Failed, &counts.Skipped); err != nil {
return nil, err
}
out[driveID] = counts
}
return out, rows.Err()
}
func (c *Catalog) HideVideo(ctx context.Context, id string) error {
res, err := c.db.ExecContext(ctx,
`UPDATE videos SET hidden = 1, updated_at = ? WHERE id = ?`,
time.Now().UnixMilli(), id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
// ListHiddenVideos 返回所有被标记隐藏(hidden=1)的视频。
// 仅用于一次性把历史「隐藏」视频迁移为黑名单墓碑——隐藏机制已废弃,
// 前台「不再展示」改走拉黑逻辑。
func (c *Catalog) ListHiddenVideos(ctx context.Context) ([]*Video, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos WHERE COALESCE(hidden, 0) = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// MigrateVideoToDrive 把 catalog 里 id=videoID 这条视频迁移到另一个 drive。
// 用于 spider91 → PikPak 的迁移:上传成功后改写 drive_id / file_id /
// content_hash,保留视频自身的 idspider91-<driveID>-<sourceID>),这样
// 关联表 (video_tags / 收藏 / 点赞) 都不需要动。
//
// scanner 后续看到 PikPak 目录下相同 hash / file_name 的文件时,会通过
// findDuplicate 命中本行,不会再插入重复行。
func (c *Catalog) MigrateVideoToDrive(ctx context.Context, videoID, newDriveID, newFileID, newContentHash string) error {
if videoID == "" || newDriveID == "" || newFileID == "" {
return fmt.Errorf("catalog: migrate video: empty id/drive/file")
}
res, err := c.db.ExecContext(ctx,
`UPDATE videos
SET drive_id = ?,
file_id = ?,
content_hash = CASE WHEN ? != '' THEN ? ELSE content_hash END,
updated_at = ?
WHERE id = ?`,
newDriveID, newFileID, newContentHash, newContentHash, time.Now().UnixMilli(), videoID)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
// ListVideosByDriveID 列出指定 drive 下所有未隐藏的视频,按 published_at 倒序。
// 给 spider91 → 115/PikPak 迁移 worker 用:扫描 spider91 drive 下所有视频,
// 检查哪些还有本地文件,依次上传到目标盘。
func (c *Catalog) ListVideosByDriveID(ctx context.Context, driveID string, limit int) ([]*Video, error) {
if driveID == "" {
return nil, fmt.Errorf("catalog: list videos by drive: empty drive id")
}
if limit <= 0 {
limit = 10000
}
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ? AND COALESCE(hidden, 0) = 0
ORDER BY published_at DESC
LIMIT ?`,
driveID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// IncrementLike 原子 +1,返回最新点赞数
func (c *Catalog) IncrementLike(ctx context.Context, id string) (int, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE videos SET likes = likes + 1, updated_at = ? WHERE id = ?`,
time.Now().UnixMilli(), id); err != nil {
return 0, err
}
var likes int
if err := tx.QueryRowContext(ctx, `SELECT likes FROM videos WHERE id = ?`, id).Scan(&likes); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return likes, nil
}
// DecrementLike 原子 -1(不会减到负数),返回最新点赞数。
// 视频不存在时返回 sql.ErrNoRows。
func (c *Catalog) DecrementLike(ctx context.Context, id string) (int, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE videos SET likes = MAX(likes - 1, 0), updated_at = ? WHERE id = ?`,
time.Now().UnixMilli(), id)
if err != nil {
return 0, err
}
if n, _ := res.RowsAffected(); n == 0 {
return 0, sql.ErrNoRows
}
var likes int
if err := tx.QueryRowContext(ctx, `SELECT likes FROM videos WHERE id = ?`, id).Scan(&likes); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return likes, nil
}
// IncrementView 原子 +1,返回最新观看数。视频不存在时返回 sql.ErrNoRows。
func (c *Catalog) IncrementView(ctx context.Context, id string) (int, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE videos SET views = views + 1, updated_at = ? WHERE id = ?`,
time.Now().UnixMilli(), id)
if err != nil {
return 0, err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return 0, sql.ErrNoRows
}
var views int
if err := tx.QueryRowContext(ctx, `SELECT views FROM videos WHERE id = ?`, id).Scan(&views); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return views, nil
}
// VideoMetaPatch 轻量更新视频元数据(仅非零值字段会被写入)
type VideoMetaPatch struct {
ThumbnailURL string
ThumbnailStatus string
ResetThumbnailFailures bool
DurationSeconds int
Category string
ContentHash string
FileName string
Tags []string
TagsSet bool
}
func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPatch) error {
parts := []string{}
args := []any{}
if p.ThumbnailURL != "" {
parts = append(parts, "thumbnail_url = ?")
args = append(args, p.ThumbnailURL)
}
switch {
case p.ThumbnailStatus != "":
// 调用方显式指定 status —— 信任之;典型是 worker 把状态置 'failed' 或
// 在重试时显式置 'pending'。
status := nullableStatus(p.ThumbnailStatus)
parts = append(parts, "thumbnail_status = ?")
args = append(args, status)
if status == "ready" {
p.ResetThumbnailFailures = true
}
case p.ThumbnailURL != "":
// 调用方写了 url 但没显式给 status —— 视为"封面就绪"。url 非空意味着
// 浏览器访问那个 URL 能拿到图(要么是本地 /p/thumb/<id>,要么是网盘 API
// 直接返回的远程 URL)。同步把 status 标 'ready',避免 url 非空但 status
// 仍是 'pending' 的脏状态(修过的历史 bug)。
parts = append(parts, "thumbnail_status = ?")
args = append(args, nullableStatus("ready"))
p.ResetThumbnailFailures = true
}
if p.ResetThumbnailFailures {
parts = append(parts, "thumbnail_failures = 0")
}
if p.DurationSeconds > 0 {
parts = append(parts, "duration_seconds = ?")
args = append(args, p.DurationSeconds)
}
if p.Category != "" {
parts = append(parts, "category = ?")
args = append(args, p.Category)
}
if p.ContentHash != "" {
parts = append(parts, "content_hash = ?")
args = append(args, normalizeContentHash(p.ContentHash))
}
if p.FileName != "" {
parts = append(parts, "file_name = ?")
args = append(args, p.FileName)
}
if p.TagsSet {
tagsJSON, _ := json.Marshal(p.Tags)
parts = append(parts, "tags = ?")
args = append(args, string(tagsJSON))
}
if len(parts) == 0 {
return nil
}
parts = append(parts, "updated_at = ?")
args = append(args, time.Now().UnixMilli())
args = append(args, id)
q := `UPDATE videos SET ` + strings.Join(parts, ", ") + ` WHERE id = ?`
if _, err := c.db.ExecContext(ctx, q, args...); err != nil {
return err
}
if p.TagsSet {
return c.SetAutoVideoTags(ctx, id, p.Tags)
}
return nil
}
func (c *Catalog) IncrementThumbnailFailures(ctx context.Context, id string) (int, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE videos
SET thumbnail_failures = COALESCE(thumbnail_failures, 0) + 1,
updated_at = ?
WHERE id = ?`,
time.Now().UnixMilli(), id)
if err != nil {
return 0, err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return 0, sql.ErrNoRows
}
var failures int
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(thumbnail_failures, 0) FROM videos WHERE id = ?`,
id).Scan(&failures); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return failures, nil
}
// ListCategories 聚合所有 category,按视频数降序
type CategoryStat struct {
Category string
Count int
}
func (c *Catalog) ListCategories(ctx context.Context) ([]CategoryStat, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT COALESCE(category, '') AS c, COUNT(*) AS cnt
FROM videos
WHERE category IS NOT NULL AND category != ''
AND COALESCE(hidden, 0) = 0
GROUP BY c
ORDER BY cnt DESC, c ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CategoryStat
for rows.Next() {
var s CategoryStat
if err := rows.Scan(&s.Category, &s.Count); err != nil {
return nil, err
}
out = append(out, s)
}
return out, nil
}
type TagStat struct {
Label string
Count int
}
func (c *Catalog) CountTags(ctx context.Context, labels []string) ([]TagStat, error) {
out := make([]TagStat, 0, len(labels))
for _, label := range labels {
var count int
if err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM video_tags vt
JOIN tags t ON t.id = vt.tag_id
JOIN videos v ON v.id = vt.video_id
WHERE t.label = ? COLLATE NOCASE
AND COALESCE(v.hidden, 0) = 0`,
label,
).Scan(&count); err != nil {
return nil, err
}
out = append(out, TagStat{Label: label, Count: count})
}
return out, nil
}
// ListVideosByPreviewStatus 按预览状态列出全部视频,通常用于启动补扫
func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status string, limit int) ([]*Video, error) {
if limit <= 0 {
limit = 10000
}
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ? AND preview_status = ?
AND COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL+`
ORDER BY created_at ASC LIMIT ?`,
driveID, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// ListVideosByThumbnailStatus 按封面(thumbnail)状态列出某 drive 下的视频。
//
// 与 ListVideosByPreviewStatus 的区别在 status 字段名:封面用 thumbnail_status
// 预览用 preview_status;两个 worker 是独立的。本接口主要用于 admin "重生失败
// 封面"操作 —— 把状态为 failed 的封面挑出来重新入队。
func (c *Catalog) ListVideosByThumbnailStatus(ctx context.Context, driveID, status string, limit int) ([]*Video, error) {
if limit <= 0 {
limit = 10000
}
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ? AND COALESCE(thumbnail_status, 'pending') = ?
AND COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL+`
ORDER BY created_at ASC LIMIT ?`,
driveID, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// ListVideosNeedingThumbnail returns videos that still need thumbnail-worker work.
// Besides missing thumbnails, this includes videos with an existing thumbnail but
// missing duration metadata, because the thumbnail worker probes duration while
// it already has a stream link.
// Failed thumbnails are reported separately and should not block preview-video generation.
// Videos whose local assets were cleared because they are fingerprint duplicates
// stay pending in the DB, but uniqueVideoWhereSQL keeps them out of this queue
// while their canonical sibling still exists.
func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string, limit int) ([]*Video, error) {
if limit <= 0 {
limit = 10000
}
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ?
AND (
COALESCE(thumbnail_url, '') = ''
OR COALESCE(duration_seconds, 0) <= 0
)
AND COALESCE(thumbnail_status, 'pending') NOT IN ('failed', 'skipped')
AND COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL+`
ORDER BY created_at ASC
LIMIT ?`,
driveID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
func (c *Catalog) CountVideosNeedingThumbnail(ctx context.Context, driveID string) (int, error) {
var count int
err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM videos
WHERE drive_id = ?
AND (
COALESCE(thumbnail_url, '') = ''
OR COALESCE(duration_seconds, 0) <= 0
)
AND COALESCE(thumbnail_status, 'pending') NOT IN ('failed', 'skipped')
AND COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL,
driveID).Scan(&count)
return count, err
}
func (c *Catalog) GetVideo(ctx context.Context, id string) (*Video, error) {
row := c.db.QueryRowContext(ctx, `SELECT `+allVideoCols+` FROM videos WHERE id = ?`, id)
return scanVideo(row)
}
func (c *Catalog) ListVideosByDrive(ctx context.Context, driveID string) ([]*Video, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos WHERE drive_id = ? ORDER BY created_at ASC, id ASC`,
driveID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (c *Catalog) ListVideosByIDPrefix(ctx context.Context, prefix string) ([]*Video, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return nil, fmt.Errorf("catalog: list videos by id prefix: empty prefix")
}
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE SUBSTR(id, 1, LENGTH(?)) = ?
ORDER BY created_at ASC, id ASC`,
prefix, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (c *Catalog) ListVideosWithMissingDrive(ctx context.Context) ([]*Video, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id != 'local-upload'
AND NOT EXISTS (
SELECT 1
FROM drives
WHERE drives.id = videos.drive_id
)
ORDER BY drive_id ASC, id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// ListVideoFileIDsByDrive 只返回某 drive 下所有视频的 file_id 集合,
// 比 ListVideosByDrive 轻量。
func (c *Catalog) ListVideoFileIDsByDrive(ctx context.Context, driveID string) ([]string, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT file_id FROM videos WHERE drive_id = ? AND file_id != ''`,
driveID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var fid string
if err := rows.Scan(&fid); err != nil {
return nil, err
}
out = append(out, fid)
}
return out, rows.Err()
}
// ListSpider91Viewkeys 列出某个 spider91 drive 历史上爬过的所有 ID 后缀。
// 函数名保留历史叫法;新 spider91 数据的后缀是 91 mp4 源 ID,不再是 viewkey。
//
// 不能再用 ListVideoFileIDsByDrive:那个只看 drive_id,但 spider91 视频
// 一旦被 spider91migrate 迁移到 PikPakdrive_id 就变成 PikPak 了。
//
// 这里按 video.id 前缀 "spider91-<driveID>-" 查,即使迁移后视频也仍能被
// 找到——id 本身会保留 "spider91-<driveID>-<sourceID>" 这个来源前缀。
//
// 用途:crawler 把这个集合写到 seen 文件,让 Python/Go 跳过已爬过的视频,
// 配合 --target-new 真正凑出 N 个未爬过的视频。
func (c *Catalog) ListSpider91Viewkeys(ctx context.Context, driveID string) ([]string, error) {
return c.ListCrawlerSourceIDs(ctx, "spider91", driveID)
}
// ListCrawlerSourceIDs lists source IDs that were already imported by a
// crawler-like drive. It reads both videos and deleted_videos so explicit admin
// deletions remain tombstoned for future crawler runs.
func (c *Catalog) ListCrawlerSourceIDs(ctx context.Context, kind, driveID string) ([]string, error) {
kind = strings.TrimSpace(kind)
driveID = strings.TrimSpace(driveID)
if kind == "" || driveID == "" {
return nil, nil
}
prefix := kind + "-" + driveID + "-"
rows, err := c.db.QueryContext(ctx,
`SELECT SUBSTR(id, ?) FROM videos WHERE id LIKE ? || '%'
UNION
SELECT SUBSTR(id, ?) FROM deleted_videos WHERE id LIKE ? || '%'
UNION
SELECT source_id FROM crawler_seen_sources
WHERE kind = ? AND drive_id = ? AND status IN ('imported', 'duplicate')`,
len(prefix)+1, prefix, len(prefix)+1, prefix, kind, driveID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var vk string
if err := rows.Scan(&vk); err != nil {
return nil, err
}
if vk = strings.TrimSpace(vk); vk != "" {
out = append(out, vk)
}
}
return out, rows.Err()
}
// MarkCrawlerSourceSeen records the outcome for a crawler source item. Duplicate
// source IDs are included in future seen files so scripts can skip them before
// the backend downloads the same duplicate content again.
func (c *Catalog) MarkCrawlerSourceSeen(ctx context.Context, kind, driveID, sourceID, status, canonicalVideoID, sampledSHA256 string, size int64) error {
kind = strings.TrimSpace(kind)
driveID = strings.TrimSpace(driveID)
sourceID = strings.TrimSpace(sourceID)
status = strings.TrimSpace(status)
if kind == "" || driveID == "" || sourceID == "" {
return nil
}
switch status {
case "imported", "duplicate":
default:
return fmt.Errorf("catalog: unsupported crawler source status %q", status)
}
sampledSHA256 = normalizeContentHash(sampledSHA256)
if size < 0 {
size = 0
}
now := time.Now().UnixMilli()
_, err := c.db.ExecContext(ctx, `
INSERT INTO crawler_seen_sources (
kind, drive_id, source_id, status, canonical_video_id, sampled_sha256, size_bytes, first_seen_at, last_seen_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(kind, drive_id, source_id) DO UPDATE SET
status = excluded.status,
canonical_video_id = excluded.canonical_video_id,
sampled_sha256 = CASE
WHEN excluded.sampled_sha256 != '' THEN excluded.sampled_sha256
ELSE crawler_seen_sources.sampled_sha256
END,
size_bytes = CASE
WHEN excluded.size_bytes > 0 THEN excluded.size_bytes
ELSE crawler_seen_sources.size_bytes
END,
last_seen_at = excluded.last_seen_at`,
kind, driveID, sourceID, status, strings.TrimSpace(canonicalVideoID), sampledSHA256, size, now, now)
return err
}
// DeleteVideoWithTombstone records that an administrator explicitly deleted a
// video, then removes the visible catalog row. The tombstone is used by
// scanners/crawlers to avoid importing the same source file again.
func (c *Catalog) DeleteVideoWithTombstone(ctx context.Context, id string) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var v struct {
ID string
DriveID string
FileID string
ContentHash string
FileName string
Size int64
}
row := tx.QueryRowContext(ctx, `
SELECT id, drive_id, file_id, COALESCE(content_hash, ''), COALESCE(file_name, ''), size_bytes
FROM videos
WHERE id = ?`, id)
if err := row.Scan(&v.ID, &v.DriveID, &v.FileID, &v.ContentHash, &v.FileName, &v.Size); err != nil {
return err
}
v.ContentHash = normalizeContentHash(v.ContentHash)
// 先记录这次视频关联的 tag_id,便于事务末尾清理孤儿 collection 标签。
tagIDs, err := collectVideoTagIDs(ctx, tx, id)
if err != nil {
return err
}
now := time.Now().UnixMilli()
if _, err := tx.ExecContext(ctx, `
INSERT INTO deleted_videos (id, drive_id, file_id, content_hash, file_name, size_bytes, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
drive_id = excluded.drive_id,
file_id = excluded.file_id,
content_hash = excluded.content_hash,
file_name = excluded.file_name,
size_bytes = excluded.size_bytes,
deleted_at = excluded.deleted_at`,
v.ID, v.DriveID, v.FileID, v.ContentHash, v.FileName, v.Size, now); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM video_tags WHERE video_id = ?`, id); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `DELETE FROM videos WHERE id = ?`, id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
if err := pruneOrphanCollectionTagsByID(ctx, tx, tagIDs); err != nil {
return err
}
return tx.Commit()
}
func (c *Catalog) DeleteVideo(ctx context.Context, id string) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 先记录这次视频关联的 tag_id,便于事务末尾清理孤儿 collection 标签
tagIDs, err := collectVideoTagIDs(ctx, tx, id)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM video_tags WHERE video_id = ?`, id); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `DELETE FROM videos WHERE id = ?`, id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
// collection 标签是 scanner 按目录名机器生成的;视频删完后若不再被引用就一起回收。
// system / user / auto / legacy 不在此处删除,避免破坏管理员手动维护的标签语义。
if err := pruneOrphanCollectionTagsByID(ctx, tx, tagIDs); err != nil {
return err
}
return tx.Commit()
}
// DeletedVideo 是黑名单(墓碑)表里的一条记录。原始视频行已删除,
// 这里只保留扫盘去重和后台展示需要的最小字段;没有 title/封面/作者。
type DeletedVideo struct {
ID string `json:"id"`
DriveID string `json:"driveId"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
Size int64 `json:"size"`
DeletedAt int64 `json:"deletedAt"` // unix 毫秒
}
// ListDeletedVideos 分页列出黑名单视频,按拉黑时间倒序。
// keyword 非空时按文件名模糊匹配。
func (c *Catalog) ListDeletedVideos(ctx context.Context, keyword string, page, size int) ([]*DeletedVideo, int, error) {
if size <= 0 {
size = 50
}
if page <= 0 {
page = 1
}
var where []string
var args []any
if kw := strings.TrimSpace(keyword); kw != "" {
where = append(where, "file_name LIKE ?")
args = append(args, "%"+kw+"%")
}
whereSQL := ""
if len(where) > 0 {
whereSQL = " WHERE " + strings.Join(where, " AND ")
}
var total int
if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deleted_videos`+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
offset := (page - 1) * size
rows, err := c.db.QueryContext(ctx,
`SELECT id, COALESCE(drive_id, ''), COALESCE(file_id, ''), COALESCE(file_name, ''), COALESCE(size_bytes, 0), deleted_at
FROM deleted_videos`+whereSQL+`
ORDER BY deleted_at DESC
LIMIT ? OFFSET ?`,
append(args, size, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []*DeletedVideo
for rows.Next() {
v := &DeletedVideo{}
if err := rows.Scan(&v.ID, &v.DriveID, &v.FileID, &v.FileName, &v.Size, &v.DeletedAt); err != nil {
return nil, 0, err
}
out = append(out, v)
}
return out, total, rows.Err()
}
// RemoveDeletedVideo 把视频移出黑名单(删除墓碑)。移除后该视频会在
// 下次扫盘/凌晨流水线时被重新发现并入库,本函数不主动触发扫描。
func (c *Catalog) RemoveDeletedVideo(ctx context.Context, id string) error {
res, err := c.db.ExecContext(ctx, `DELETE FROM deleted_videos WHERE id = ?`, id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
// VideoManagementCounts 返回后台视频管理两个标签的计数:
// current=当前可见(与「当前视频」页一致的去重+在线盘+hidden=0 口径),
// blacklisted=黑名单墓碑总数。
func (c *Catalog) VideoManagementCounts(ctx context.Context) (current, blacklisted int, err error) {
currentSQL := `SELECT COUNT(*) FROM videos WHERE COALESCE(hidden, 0) = 0 AND ` + activeDriveWhereSQL + ` AND ` + uniqueVideoWhereSQL
if err = c.db.QueryRowContext(ctx, currentSQL).Scan(&current); err != nil {
return 0, 0, err
}
if err = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deleted_videos`).Scan(&blacklisted); err != nil {
return 0, 0, err
}
return current, blacklisted, nil
}
func (c *Catalog) IsVideoDeleted(ctx context.Context, id string) (bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return false, nil
}
var found int
err := c.db.QueryRowContext(ctx, `SELECT 1 FROM deleted_videos WHERE id = ? LIMIT 1`, id).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (c *Catalog) IsDeletedVideoCandidate(ctx context.Context, id, driveID, fileID, contentHash, fileName string, size int64) (bool, error) {
id = strings.TrimSpace(id)
driveID = strings.TrimSpace(driveID)
fileID = strings.TrimSpace(fileID)
contentHash = normalizeContentHash(contentHash)
fileName = strings.TrimSpace(fileName)
if id == "" && driveID == "" {
return false, nil
}
var found int
err := c.db.QueryRowContext(ctx, `
SELECT 1
FROM deleted_videos
WHERE id = ?
OR (drive_id = ? AND ? != '' AND file_id = ?)
OR (drive_id = ? AND ? != '' AND content_hash = ?)
OR (drive_id = ? AND ? != '' AND ? > 0 AND file_name = ? AND size_bytes = ?)
LIMIT 1`,
id,
driveID, fileID, fileID,
driveID, contentHash, contentHash,
driveID, fileName, size, fileName, size,
).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (c *Catalog) FindVideoByContentHash(ctx context.Context, hash string) (*Video, error) {
hash = normalizeContentHash(hash)
if hash == "" {
return nil, sql.ErrNoRows
}
row := c.db.QueryRowContext(ctx,
`SELECT `+allVideoCols+`
FROM videos
WHERE content_hash = ?
ORDER BY created_at ASC, id ASC
LIMIT 1`, hash)
return scanVideo(row)
}
func (c *Catalog) FindVideoByFileSignature(ctx context.Context, fileName string, size int64) (*Video, error) {
if fileName == "" || size <= 0 {
return nil, sql.ErrNoRows
}
row := c.db.QueryRowContext(ctx,
`SELECT `+allVideoCols+`
FROM videos
WHERE file_name = ? AND size_bytes = ?
ORDER BY created_at ASC, id ASC
LIMIT 1`, fileName, size)
return scanVideo(row)
}
// FindEquivalentVideo returns the earliest visible video that represents the
// same content as source by strong hash or sampled fingerprint, regardless of
// which drive currently owns it.
func (c *Catalog) FindEquivalentVideo(ctx context.Context, source *Video) (*Video, error) {
if source == nil {
return nil, sql.ErrNoRows
}
where, args, ok := equivalentVideoLookupWhere(source)
if !ok {
return nil, sql.ErrNoRows
}
args = append([]any{source.ID}, args...)
row := c.db.QueryRowContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE id != ?
AND COALESCE(hidden, 0) = 0
AND COALESCE(file_id, '') != ''
AND (`+where+`)
ORDER BY created_at ASC, id ASC
LIMIT 1`, args...)
return scanVideo(row)
}
// FindEquivalentVideoOnDrive returns a visible video on driveID that represents
// the same content as source by strong hash or sampled fingerprint.
func (c *Catalog) FindEquivalentVideoOnDrive(ctx context.Context, source *Video, driveID string) (*Video, error) {
driveID = strings.TrimSpace(driveID)
if source == nil || driveID == "" {
return nil, sql.ErrNoRows
}
where, args, ok := equivalentVideoLookupWhere(source)
if !ok {
return nil, sql.ErrNoRows
}
args = append([]any{driveID, source.ID}, args...)
row := c.db.QueryRowContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ?
AND id != ?
AND COALESCE(hidden, 0) = 0
AND COALESCE(file_id, '') != ''
AND (`+where+`)
ORDER BY created_at ASC, id ASC
LIMIT 1`, args...)
return scanVideo(row)
}
// HasReadyEquivalentPreview reports whether another visible row for the same
// content already has a ready preview video.
func (c *Catalog) HasReadyEquivalentPreview(ctx context.Context, source *Video) (bool, error) {
if source == nil {
return false, nil
}
where, args, ok := equivalentVideoLookupWhere(source)
if !ok {
return false, nil
}
args = append([]any{source.ID}, args...)
var found int
err := c.db.QueryRowContext(ctx,
`SELECT 1 FROM videos
WHERE id != ?
AND COALESCE(hidden, 0) = 0
AND COALESCE(preview_status, 'pending') = 'ready'
AND (`+where+`)
LIMIT 1`, args...).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func equivalentVideoLookupWhere(source *Video) (string, []any, bool) {
if source == nil {
return "", nil, false
}
var parts []string
var args []any
if hash := normalizeContentHash(source.ContentHash); hash != "" {
parts = append(parts, "(COALESCE(content_hash, '') != '' AND content_hash = ?)")
args = append(args, hash)
}
if source.Size > 0 {
if sampled := normalizeContentHash(source.SampledSHA256); sampled != "" {
parts = append(parts, "(size_bytes = ? AND COALESCE(sampled_sha256, '') != '' AND sampled_sha256 = ?)")
args = append(args, source.Size, sampled)
}
}
if len(parts) == 0 {
return "", nil, false
}
return strings.Join(parts, " OR "), args, true
}
func (c *Catalog) ListVideosNeedingFingerprint(ctx context.Context, driveID string, limit int) ([]*Video, error) {
if limit <= 0 {
limit = 10000
}
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ?
AND size_bytes > 0
AND COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = 'pending'
AND COALESCE(hidden, 0) = 0
ORDER BY created_at ASC, id ASC
LIMIT ?`,
driveID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// ListVideosByFingerprintStatus lists visible videos on a drive by fingerprint status.
// It is used by the admin "retry failed fingerprints" action to reset failed rows
// back to pending and enqueue them again.
func (c *Catalog) ListVideosByFingerprintStatus(ctx context.Context, driveID, status string, limit int) ([]*Video, error) {
if limit <= 0 {
limit = 10000
}
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ?
AND COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = ?
AND COALESCE(hidden, 0) = 0
ORDER BY created_at ASC, id ASC
LIMIT ?`,
driveID, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (c *Catalog) UpdateVideoFingerprint(ctx context.Context, id, sampledSHA256, status, errText string) error {
sampledSHA256 = normalizeContentHash(sampledSHA256)
if status == "" {
status = "pending"
}
if len(errText) > 500 {
errText = errText[:500]
}
res, err := c.db.ExecContext(ctx,
`UPDATE videos
SET sampled_sha256 = ?,
fingerprint_status = ?,
fingerprint_error = ?,
updated_at = ?
WHERE id = ?`,
sampledSHA256, status, errText, time.Now().UnixMilli(), id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
type ListParams struct {
Keyword string
DriveID string
Tag string
Category string
Sort string // latest | hot | week | long
ThumbnailReadyOnly bool
PreferReadyThumbnails bool
SkipTotal bool
Page int
PageSize int
}
func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) {
if p.PageSize <= 0 {
p.PageSize = 24
}
if p.Page <= 0 {
p.Page = 1
}
var where []string
var args []any
if p.Keyword != "" {
where = append(where, "(title LIKE ? OR author LIKE ?)")
like := "%" + p.Keyword + "%"
args = append(args, like, like)
}
if p.DriveID != "" {
where = append(where, "drive_id = ?")
args = append(args, p.DriveID)
}
if p.Tag != "" {
where = append(where, videoMatchesTagLabelSQL("videos"))
args = append(args, p.Tag)
}
if p.Category != "" && p.Category != "all" {
where = append(where, "category = ?")
args = append(args, p.Category)
}
if p.ThumbnailReadyOnly {
where = append(where, "COALESCE(thumbnail_url, '') != ''")
}
where = append(where, "COALESCE(hidden, 0) = 0")
where = append(where, activeDriveWhereSQL)
where = append(where, uniqueVideoWhereSQL)
whereSQL := ""
whereSQL = " WHERE " + strings.Join(where, " AND ")
readyOrderPrefix := ""
if p.PreferReadyThumbnails {
readyOrderPrefix = "CASE WHEN COALESCE(thumbnail_url, '') != '' THEN 0 ELSE 1 END, "
}
orderBy := " ORDER BY " + readyOrderPrefix + "published_at DESC"
switch p.Sort {
case "hot":
// 热度 = 点赞数,点赞相同按最新
orderBy = " ORDER BY " + readyOrderPrefix + "likes DESC, published_at DESC"
case "week":
orderBy = " ORDER BY " + readyOrderPrefix + "likes DESC"
case "long":
orderBy = " ORDER BY " + readyOrderPrefix + "duration_seconds DESC"
}
var total int
if !p.SkipTotal {
if err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM videos"+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
}
// list
offset := (p.Page - 1) * p.PageSize
rows, err := c.db.QueryContext(ctx,
"SELECT "+allVideoCols+" FROM videos"+whereSQL+orderBy+" LIMIT ? OFFSET ?",
append(args, p.PageSize, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, 0, err
}
out = append(out, v)
}
return out, total, nil
}
// CountVisibleVideos 返回当前对前台可见的视频总数(未隐藏、且通过去重规则)。
// 用于短视频模式判断"已经轮过一遍"。
func (c *Catalog) CountVisibleVideos(ctx context.Context) (int, error) {
var total int
err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+activeDriveWhereSQL+`
AND `+uniqueVideoWhereSQL,
).Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
// RandomVideosExcluding 从对前台可见的视频里,随机返回 limit 个不在 excludeIDs 中的视频。
// 短视频模式用:客户端把当前轮已看的视频 id 传过来,避免本轮重复。
// 如果剩余可选数量 < limit,就返回所有可选项;调用方负责判断是否需要开新一轮。
// limit <= 0 时返回 nil, nil。
func (c *Catalog) RandomVideosExcluding(ctx context.Context, excludeIDs []string, limit int) ([]*Video, error) {
return c.randomVideosExcluding(ctx, excludeIDs, limit, false)
}
func (c *Catalog) RandomVideosWithReadyThumbnailsExcluding(ctx context.Context, excludeIDs []string, limit int) ([]*Video, error) {
return c.randomVideosExcluding(ctx, excludeIDs, limit, true)
}
func (c *Catalog) randomVideosExcluding(ctx context.Context, excludeIDs []string, limit int, thumbnailReadyOnly bool) ([]*Video, error) {
if limit <= 0 {
return nil, nil
}
cleaned := cleanVideoIDs(excludeIDs)
args := make([]any, 0, len(cleaned)+1)
whereSQL := `WHERE COALESCE(hidden, 0) = 0
AND ` + activeDriveWhereSQL + `
AND ` + uniqueVideoWhereSQL
if thumbnailReadyOnly {
whereSQL += " AND COALESCE(thumbnail_url, '') != ''"
}
if len(cleaned) > 0 {
placeholders := strings.Repeat("?,", len(cleaned))
placeholders = placeholders[:len(placeholders)-1]
whereSQL += " AND id NOT IN (" + placeholders + ")"
for _, id := range cleaned {
args = append(args, id)
}
}
args = append(args, limit)
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos `+whereSQL+`
ORDER BY RANDOM() LIMIT ?`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func cleanVideoIDs(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
cleaned := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
cleaned = append(cleaned, id)
}
return cleaned
}
func cleanTagLabels(labels []string) []string {
seen := make(map[string]struct{}, len(labels))
cleaned := make([]string, 0, len(labels))
for _, label := range labels {
label = strings.TrimSpace(label)
if label == "" {
continue
}
key := strings.ToLower(label)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
cleaned = append(cleaned, label)
}
return cleaned
}
func (c *Catalog) LeastPopulatedVisibleUniqueTag(ctx context.Context, labels []string) (string, error) {
cleaned := cleanTagLabels(labels)
bestLabel := ""
bestCount := 0
for _, label := range cleaned {
var count int
if err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+activeDriveWhereSQL+`
AND `+uniqueVideoWhereSQL+`
AND EXISTS (
SELECT 1
FROM video_tags vt
JOIN tags t ON t.id = vt.tag_id
WHERE vt.video_id = videos.id
AND t.label = ? COLLATE NOCASE
)`,
label,
).Scan(&count); err != nil {
return "", err
}
if count == 0 {
continue
}
if bestLabel == "" || count < bestCount {
bestLabel = label
bestCount = count
}
}
return bestLabel, nil
}
func (c *Catalog) RandomVideosByTagExcluding(ctx context.Context, tag string, excludeIDs []string, limit int) ([]*Video, error) {
if limit <= 0 {
return nil, nil
}
tag = strings.TrimSpace(tag)
if tag == "" {
return nil, nil
}
cleaned := cleanVideoIDs(excludeIDs)
args := make([]any, 0, len(cleaned)+2)
args = append(args, tag)
whereSQL := `WHERE COALESCE(hidden, 0) = 0
AND ` + activeDriveWhereSQL + `
AND ` + uniqueVideoWhereSQL + `
AND EXISTS (
SELECT 1
FROM video_tags vt
JOIN tags t ON t.id = vt.tag_id
WHERE vt.video_id = videos.id
AND t.label = ? COLLATE NOCASE
)`
if len(cleaned) > 0 {
placeholders := strings.Repeat("?,", len(cleaned))
placeholders = placeholders[:len(placeholders)-1]
whereSQL += " AND id NOT IN (" + placeholders + ")"
for _, id := range cleaned {
args = append(args, id)
}
}
args = append(args, limit)
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos `+whereSQL+`
ORDER BY RANDOM() LIMIT ?`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (c *Catalog) RandomVideosForPreferredVideoExcluding(ctx context.Context, preferredVideoID string, excludeIDs []string, limit int) ([]*Video, error) {
if limit <= 0 {
return nil, nil
}
preferredVideoID = strings.TrimSpace(preferredVideoID)
if preferredVideoID == "" {
return c.RandomVideosExcluding(ctx, excludeIDs, limit)
}
preferredExclude := append([]string{}, excludeIDs...)
preferredExclude = append(preferredExclude, preferredVideoID)
preferred, err := c.GetVideo(ctx, preferredVideoID)
if err != nil || preferred == nil || preferred.Hidden || len(preferred.Tags) == 0 {
return c.RandomVideosExcluding(ctx, preferredExclude, limit)
}
tag, err := c.LeastPopulatedVisibleUniqueTag(ctx, preferred.Tags)
if err != nil {
return nil, err
}
if tag == "" {
return c.RandomVideosExcluding(ctx, preferredExclude, limit)
}
items, err := c.RandomVideosByTagExcluding(ctx, tag, preferredExclude, limit)
if err != nil {
return nil, err
}
if len(items) >= limit {
return items, nil
}
mergedExclude := make([]string, 0, len(preferredExclude)+len(items))
mergedExclude = append(mergedExclude, preferredExclude...)
for _, item := range items {
if item != nil {
mergedExclude = append(mergedExclude, item.ID)
}
}
fallback, err := c.RandomVideosExcluding(ctx, mergedExclude, limit-len(items))
if err != nil {
return nil, err
}
return append(items, fallback...), nil
}
type DriveTeaserCounts struct {
Ready int
Pending int
Failed int
}
type DriveThumbnailCounts struct {
Ready int
Pending int
Failed int
DurationPending int
}
type DriveFingerprintCounts struct {
Ready int
Pending int
Failed int
}
func (c *Catalog) CountTeasersByDrive(ctx context.Context) (map[string]DriveTeaserCounts, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT drive_id,
COUNT(CASE WHEN COALESCE(preview_status, 'pending') = 'ready' THEN 1 END) AS ready_count,
COUNT(CASE WHEN COALESCE(preview_status, 'pending') = 'pending' THEN 1 END) AS pending_count,
COUNT(CASE WHEN COALESCE(preview_status, 'pending') = 'failed' THEN 1 END) AS failed_count
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL+`
GROUP BY drive_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]DriveTeaserCounts)
for rows.Next() {
var driveID string
var counts DriveTeaserCounts
if err := rows.Scan(&driveID, &counts.Ready, &counts.Pending, &counts.Failed); err != nil {
return nil, err
}
out[driveID] = counts
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (c *Catalog) CountThumbnailsByDrive(ctx context.Context) (map[string]DriveThumbnailCounts, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT drive_id,
COUNT(CASE WHEN COALESCE(thumbnail_url, '') != '' THEN 1 END) AS ready_count,
COUNT(CASE WHEN COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') NOT IN ('failed', 'skipped') THEN 1 END) AS pending_count,
COUNT(CASE WHEN COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') = 'failed' THEN 1 END) AS failed_count,
COUNT(CASE WHEN COALESCE(thumbnail_url, '') != ''
AND COALESCE(duration_seconds, 0) <= 0
AND COALESCE(thumbnail_status, 'pending') NOT IN ('failed', 'skipped') THEN 1 END) AS duration_pending_count
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+uniqueVideoWhereSQL+`
GROUP BY drive_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]DriveThumbnailCounts)
for rows.Next() {
var driveID string
var counts DriveThumbnailCounts
if err := rows.Scan(&driveID, &counts.Ready, &counts.Pending, &counts.Failed, &counts.DurationPending); err != nil {
return nil, err
}
out[driveID] = counts
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (c *Catalog) CountFingerprintsByDrive(ctx context.Context) (map[string]DriveFingerprintCounts, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT drive_id,
COUNT(CASE WHEN COALESCE(sampled_sha256, '') != ''
OR COALESCE(fingerprint_status, 'pending') = 'ready' THEN 1 END) AS ready_count,
COUNT(CASE WHEN size_bytes > 0
AND COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = 'pending' THEN 1 END) AS pending_count,
COUNT(CASE WHEN COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = 'failed' THEN 1 END) AS failed_count
FROM videos
WHERE COALESCE(hidden, 0) = 0
GROUP BY drive_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]DriveFingerprintCounts)
for rows.Next() {
var driveID string
var counts DriveFingerprintCounts
if err := rows.Scan(&driveID, &counts.Ready, &counts.Pending, &counts.Failed); err != nil {
return nil, err
}
out[driveID] = counts
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (c *Catalog) CountCrawlerAssets(ctx context.Context, crawlerID string, prefixes []string) (CrawlerAssetCounts, error) {
var out CrawlerAssetCounts
crawlerID = strings.TrimSpace(crawlerID)
prefixes = cleanCrawlerIDPrefixes(prefixes)
if crawlerID == "" || len(prefixes) == 0 {
return out, nil
}
where := make([]string, 0, len(prefixes))
args := make([]any, 0, 2+len(prefixes))
args = append(args, crawlerID, crawlerID)
for range prefixes {
where = append(where, "id LIKE ? ESCAPE '\\'")
}
for _, prefix := range prefixes {
args = append(args, escapeSQLLike(prefix)+"%")
}
query := `SELECT
COUNT(*) AS total_count,
COUNT(CASE WHEN drive_id = ? THEN 1 END) AS local_count,
COUNT(CASE WHEN drive_id != ? THEN 1 END) AS migrated_count,
COUNT(CASE WHEN EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.thumbnail_url, '') != ''
) THEN 1 END) AS thumbnail_ready_count,
COUNT(CASE WHEN NOT EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.thumbnail_url, '') != ''
)
AND COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') NOT IN ('failed', 'skipped') THEN 1 END) AS thumbnail_pending_count,
COUNT(CASE WHEN NOT EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.thumbnail_url, '') != ''
)
AND COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') = 'failed' THEN 1 END) AS thumbnail_failed_count,
COUNT(CASE WHEN EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.preview_status, 'pending') = 'ready'
) THEN 1 END) AS teaser_ready_count,
COUNT(CASE WHEN NOT EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.preview_status, 'pending') = 'ready'
)
AND COALESCE(preview_status, 'pending') = 'pending' THEN 1 END) AS teaser_pending_count,
COUNT(CASE WHEN NOT EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.preview_status, 'pending') = 'ready'
)
AND COALESCE(preview_status, 'pending') = 'failed' THEN 1 END) AS teaser_failed_count,
COUNT(CASE WHEN COALESCE(sampled_sha256, '') != ''
OR COALESCE(fingerprint_status, 'pending') = 'ready' THEN 1 END) AS fingerprint_ready_count,
COUNT(CASE WHEN size_bytes > 0
AND COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = 'pending' THEN 1 END) AS fingerprint_pending_count,
COUNT(CASE WHEN COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = 'failed' THEN 1 END) AS fingerprint_failed_count
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND (` + strings.Join(where, " OR ") + `)`
err := c.db.QueryRowContext(ctx, query, args...).Scan(
&out.Total,
&out.Local,
&out.Migrated,
&out.Thumbnail.Ready,
&out.Thumbnail.Pending,
&out.Thumbnail.Failed,
&out.Teaser.Ready,
&out.Teaser.Pending,
&out.Teaser.Failed,
&out.Fingerprint.Ready,
&out.Fingerprint.Pending,
&out.Fingerprint.Failed,
)
return out, err
}
func crawlerAssetEquivalentSQL(candidateAlias, sourceAlias string) string {
return fmt.Sprintf(`(%[1]s.id = %[2]s.id
OR (COALESCE(%[2]s.content_hash, '') != ''
AND %[1]s.content_hash = %[2]s.content_hash)
OR (%[2]s.size_bytes > 0
AND COALESCE(%[2]s.sampled_sha256, '') != ''
AND %[1]s.size_bytes = %[2]s.size_bytes
AND %[1]s.sampled_sha256 = %[2]s.sampled_sha256))`, candidateAlias, sourceAlias)
}
func cleanCrawlerIDPrefixes(prefixes []string) []string {
out := make([]string, 0, len(prefixes))
seen := map[string]bool{}
for _, prefix := range prefixes {
prefix = strings.TrimSpace(prefix)
if prefix == "" || seen[prefix] {
continue
}
seen[prefix] = true
out = append(out, prefix)
}
return out
}
func escapeSQLLike(raw string) string {
raw = strings.ReplaceAll(raw, `\`, `\\`)
raw = strings.ReplaceAll(raw, `%`, `\%`)
raw = strings.ReplaceAll(raw, `_`, `\_`)
return raw
}
func (c *Catalog) CountVideosNeedingFingerprint(ctx context.Context, driveID string) (int, error) {
var count int
err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM videos
WHERE drive_id = ?
AND size_bytes > 0
AND COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = 'pending'
AND COALESCE(hidden, 0) = 0`,
driveID).Scan(&count)
return count, err
}
type LocalMediaRef struct {
DriveID string
VideoID string
PreviewLocal string
}
func (c *Catalog) ListLocalMediaRefs(ctx context.Context) ([]LocalMediaRef, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT drive_id, id, COALESCE(preview_local, '')
FROM videos`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LocalMediaRef
for rows.Next() {
var ref LocalMediaRef
if err := rows.Scan(&ref.DriveID, &ref.VideoID, &ref.PreviewLocal); err != nil {
return nil, err
}
out = append(out, ref)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
// DuplicateAssetCleanupCandidate points at a non-canonical video in a
// size+sampled_sha256 duplicate group that still owns generated local assets.
// The cleanup job uses this to remove duplicate thumbnails/preview videos without
// touching the original cloud file or deleting the catalog row.
type DuplicateAssetCleanupCandidate struct {
VideoID string
DriveID string
Title string
PreviewLocal string
ThumbnailURL string
CanonicalID string
SampledSHA256 string
Size int64
}
// ListDuplicateAssetCleanupCandidates returns duplicate videos whose own local
// generated assets can be cleared. A group canonical is the same representative
// used by uniqueVideoWhereSQL: earliest created_at, then lexicographically
// smallest id.
func (c *Catalog) ListDuplicateAssetCleanupCandidates(ctx context.Context, limit int) ([]DuplicateAssetCleanupCandidate, error) {
if limit <= 0 {
limit = 10000
}
rows, err := c.db.QueryContext(ctx, `
WITH canonical AS (
SELECT v.id, v.size_bytes, v.sampled_sha256
FROM videos v
WHERE v.size_bytes > 0
AND COALESCE(v.sampled_sha256, '') != ''
AND NOT EXISTS (
SELECT 1
FROM videos earlier
WHERE earlier.size_bytes = v.size_bytes
AND earlier.sampled_sha256 = v.sampled_sha256
AND COALESCE(earlier.sampled_sha256, '') != ''
AND earlier.size_bytes > 0
AND (
earlier.created_at < v.created_at
OR (earlier.created_at = v.created_at AND earlier.id < v.id)
)
)
)
SELECT dup.id,
dup.drive_id,
dup.title,
COALESCE(dup.preview_local, ''),
COALESCE(dup.thumbnail_url, ''),
canonical.id,
dup.sampled_sha256,
dup.size_bytes
FROM videos dup
JOIN canonical
ON canonical.size_bytes = dup.size_bytes
AND canonical.sampled_sha256 = dup.sampled_sha256
WHERE dup.id != canonical.id
AND dup.size_bytes > 0
AND COALESCE(dup.sampled_sha256, '') != ''
AND (
COALESCE(dup.preview_local, '') != ''
OR COALESCE(dup.thumbnail_url, '') = '/p/thumb/' || dup.id
)
ORDER BY dup.created_at ASC, dup.id ASC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DuplicateAssetCleanupCandidate
for rows.Next() {
var item DuplicateAssetCleanupCandidate
if err := rows.Scan(
&item.VideoID,
&item.DriveID,
&item.Title,
&item.PreviewLocal,
&item.ThumbnailURL,
&item.CanonicalID,
&item.SampledSHA256,
&item.Size,
); err != nil {
return nil, err
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
// ClearGeneratedAssets clears DB references to generated local assets for a
// video. The statuses go back to pending so the video can regenerate assets if
// it later becomes the canonical item after its older duplicate is removed.
func (c *Catalog) ClearGeneratedAssets(ctx context.Context, videoID string, clearPreview, clearThumbnail bool) error {
parts := []string{}
args := []any{}
if clearPreview {
parts = append(parts, "preview_file_id = ''", "preview_local = ''", "preview_status = 'pending'")
}
if clearThumbnail {
parts = append(parts, "thumbnail_url = ''", "thumbnail_status = 'pending'")
}
if len(parts) == 0 {
return nil
}
parts = append(parts, "updated_at = ?")
args = append(args, time.Now().UnixMilli(), videoID)
res, err := c.db.ExecContext(ctx, `UPDATE videos SET `+strings.Join(parts, ", ")+` WHERE id = ?`, args...)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
// ---------- Drive ----------
type Drive struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
RootID string `json:"rootId"`
// Deprecated: 扫描入口固定等于 RootID;字段保留用于兼容旧数据/API。
ScanRootID string `json:"scanRootId"`
Credentials map[string]string `json:"credentials,omitempty"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"`
// TeaserEnabled 控制是否给本盘生成预览视频/封面。
// 替代早期的全局 preview.enabled 开关;新建 drive 时 UpsertDrive 默认置 true。
TeaserEnabled bool `json:"teaserEnabled"`
// SkipDirIDs 是用户在管理后台为该盘选定的"扫描跳过目录"集合(网盘侧的目录 fileID)。
// scanner 在 walk 时命中其中任意一个就直接 continue —— 不递归、不收集文件,也
// 不参与 stats 统计。替代旧版硬编码"影视"目录的特例分支。
// 含义按"目录 ID 自身"匹配,所以同名目录在不同父级下需要分别选定。
SkipDirIDs []string `json:"skipDirIds,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c *Catalog) UpsertDrive(ctx context.Context, d *Drive) error {
normalizeDriveRootFields(d)
cred, _ := json.Marshal(d.Credentials)
skipDirs := d.SkipDirIDs
if skipDirs == nil {
skipDirs = []string{}
}
skipDirsJSON, _ := json.Marshal(skipDirs)
now := time.Now().UnixMilli()
if d.CreatedAt.IsZero() {
d.CreatedAt = time.UnixMilli(now)
}
d.UpdatedAt = time.UnixMilli(now)
_, err := c.db.ExecContext(ctx, `
INSERT INTO drives (id, kind, name, root_id, scan_root_id, credentials, status, last_error, teaser_enabled, skip_dir_ids, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
kind = excluded.kind,
name = excluded.name,
root_id = excluded.root_id,
scan_root_id = excluded.scan_root_id,
credentials = excluded.credentials,
status = excluded.status,
last_error = excluded.last_error,
teaser_enabled = excluded.teaser_enabled,
skip_dir_ids = excluded.skip_dir_ids,
updated_at = excluded.updated_at
`, d.ID, d.Kind, d.Name, d.RootID, d.ScanRootID, string(cred), d.Status, d.LastError, boolToInt(d.TeaserEnabled), string(skipDirsJSON),
d.CreatedAt.UnixMilli(), d.UpdatedAt.UnixMilli())
return err
}
func normalizeDriveRootFields(d *Drive) {
if d == nil {
return
}
d.RootID = normalizeDriveRootID(d.Kind, d.RootID)
d.ScanRootID = d.RootID
}
func normalizeDriveRootID(kind, rootID string) string {
rootID = strings.TrimSpace(rootID)
switch kind {
case "pikpak":
if rootID == "0" {
return ""
}
return rootID
case "onedrive", "googledrive":
if rootID == "" {
return "root"
}
return rootID
case "localstorage", "spider91":
return "/"
default:
if rootID == "" {
return "0"
}
return rootID
}
}
func (c *Catalog) syncDriveScanRootIDToRootID(ctx context.Context) error {
_, err := c.db.ExecContext(ctx, `
UPDATE drives
SET scan_root_id = root_id,
updated_at = ?
WHERE COALESCE(scan_root_id, '') != COALESCE(root_id, '')`, time.Now().UnixMilli())
return err
}
func (c *Catalog) ListDrives(ctx context.Context) ([]*Drive, error) {
rows, err := c.db.QueryContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), COALESCE(teaser_enabled, 1), COALESCE(skip_dir_ids, '[]'), created_at, updated_at FROM drives ORDER BY created_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Drive
for rows.Next() {
d := &Drive{}
var credsStr, skipDirsStr string
var teaserEnabled int
var createdAt, updatedAt int64
if err := rows.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &teaserEnabled, &skipDirsStr, &createdAt, &updatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(credsStr), &d.Credentials)
_ = json.Unmarshal([]byte(skipDirsStr), &d.SkipDirIDs)
normalizeDriveRootFields(d)
d.TeaserEnabled = teaserEnabled != 0
d.CreatedAt = time.UnixMilli(createdAt)
d.UpdatedAt = time.UnixMilli(updatedAt)
out = append(out, d)
}
return out, nil
}
func (c *Catalog) GetDrive(ctx context.Context, id string) (*Drive, error) {
row := c.db.QueryRowContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), COALESCE(teaser_enabled, 1), COALESCE(skip_dir_ids, '[]'), created_at, updated_at FROM drives WHERE id = ?`, id)
d := &Drive{}
var credsStr, skipDirsStr string
var teaserEnabled int
var createdAt, updatedAt int64
if err := row.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &teaserEnabled, &skipDirsStr, &createdAt, &updatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(credsStr), &d.Credentials)
_ = json.Unmarshal([]byte(skipDirsStr), &d.SkipDirIDs)
normalizeDriveRootFields(d)
d.TeaserEnabled = teaserEnabled != 0
d.CreatedAt = time.UnixMilli(createdAt)
d.UpdatedAt = time.UnixMilli(updatedAt)
return d, nil
}
func (c *Catalog) DeleteDrive(ctx context.Context, id string) error {
_, err := c.db.ExecContext(ctx, `DELETE FROM drives WHERE id = ?`, id)
return err
}
// SetDriveTeaserEnabled 切换某盘的预览视频/封面生成开关。
//
// 与 UpsertDrive 的区别:只动 teaser_enabled + updated_at 一列,不要求调用方
// 重传 kind / name / credentials 等容易踩坑的字段。
//
// drive 不存在时返回 sql.ErrNoRows,调用方可以照此返回 404。
func (c *Catalog) SetDriveTeaserEnabled(ctx context.Context, id string, enabled bool) error {
if id == "" {
return fmt.Errorf("catalog: set drive teaser_enabled: empty id")
}
res, err := c.db.ExecContext(ctx,
`UPDATE drives SET teaser_enabled = ?, updated_at = ? WHERE id = ?`,
boolToInt(enabled), time.Now().UnixMilli(), id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
// SetDriveSkipDirIDs 重写某盘的"扫描跳过目录"集合(直接覆盖,不做增量合并)。
//
// 与 UpsertDrive 的区别:只动 skip_dir_ids + updated_at,不要求调用方重传
// kind / name / credentials 等字段(避免管理后台保存跳过目录时把凭证误覆盖)。
//
// 入参 ids 可以是 nil 或空切片,等价于"清空跳过列表"。元素会按字符串原样存储;
// 调用方负责在保存前 trim/去重;这里只保证编码成 JSON 数组。
//
// drive 不存在时返回 sql.ErrNoRows,调用方可以照此返回 404。
func (c *Catalog) SetDriveSkipDirIDs(ctx context.Context, id string, ids []string) error {
if id == "" {
return fmt.Errorf("catalog: set drive skip_dir_ids: empty id")
}
if ids == nil {
ids = []string{}
}
payload, err := json.Marshal(ids)
if err != nil {
return fmt.Errorf("catalog: marshal skip_dir_ids: %w", err)
}
res, err := c.db.ExecContext(ctx,
`UPDATE drives SET skip_dir_ids = ?, updated_at = ? WHERE id = ?`,
string(payload), time.Now().UnixMilli(), id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
// ---------- Admin session ----------
func (c *Catalog) CreateSession(ctx context.Context, token string, ttl time.Duration) error {
now := time.Now()
_, err := c.db.ExecContext(ctx,
`INSERT INTO admin_sessions (token, created_at, expires_at) VALUES (?, ?, ?)`,
token, now.UnixMilli(), now.Add(ttl).UnixMilli())
return err
}
func (c *Catalog) ValidateSession(ctx context.Context, token string) (bool, error) {
var expires int64
err := c.db.QueryRowContext(ctx, `SELECT expires_at FROM admin_sessions WHERE token = ?`, token).Scan(&expires)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return time.Now().UnixMilli() < expires, nil
}
func (c *Catalog) DeleteSession(ctx context.Context, token string) error {
_, err := c.db.ExecContext(ctx, `DELETE FROM admin_sessions WHERE token = ?`, token)
return err
}
func (c *Catalog) BanLoginIP(ctx context.Context, ip, reason string) error {
now := time.Now().UnixMilli()
_, err := c.db.ExecContext(ctx,
`INSERT INTO banned_login_ips (ip, reason, created_at) VALUES (?, ?, ?)
ON CONFLICT(ip) DO UPDATE SET reason = excluded.reason`,
ip, reason, now)
return err
}
func (c *Catalog) IsLoginIPBanned(ctx context.Context, ip string) (bool, error) {
var exists int
err := c.db.QueryRowContext(ctx, `SELECT 1 FROM banned_login_ips WHERE ip = ?`, ip).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// ---------- Settings ----------
func (c *Catalog) GetSetting(ctx context.Context, key, defaultValue string) (string, error) {
var v string
err := c.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if err == sql.ErrNoRows {
return defaultValue, nil
}
if err != nil {
return "", err
}
return v, nil
}
func (c *Catalog) SetSetting(ctx context.Context, key, value string) error {
_, err := c.db.ExecContext(ctx, `
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, key, value, time.Now().UnixMilli())
return err
}
// ---------- helpers ----------
const allVideoCols = `
id, drive_id, file_id, COALESCE(file_name, ''), COALESCE(content_hash, ''),
COALESCE(sampled_sha256, ''), COALESCE(fingerprint_status, 'pending'), COALESCE(fingerprint_error, ''),
COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'),
duration_seconds, size_bytes, COALESCE(ext, ''), COALESCE(quality, ''), COALESCE(thumbnail_url, ''),
COALESCE(preview_file_id, ''), COALESCE(preview_local, ''), COALESCE(preview_status, 'pending'),
COALESCE(transcode_status, ''), COALESCE(transcode_error, ''), COALESCE(transcoded_file_id, ''), COALESCE(transcoded_size, 0),
views, favorites, comments, likes, dislikes,
COALESCE(category, ''), COALESCE(hidden, 0), COALESCE(badges, '[]'), COALESCE(description, ''),
published_at, created_at, updated_at
`
const activeDriveWhereSQL = `(videos.drive_id = 'local-upload'
OR EXISTS (
SELECT 1
FROM drives
WHERE drives.id = videos.drive_id
)
OR NOT EXISTS (
SELECT 1
FROM drives
))`
const uniqueVideoWhereSQL = `((COALESCE(videos.content_hash, '') = ''
OR NOT EXISTS (
SELECT 1
FROM videos AS dup
WHERE dup.content_hash = videos.content_hash
AND COALESCE(dup.content_hash, '') != ''
AND (
dup.created_at < videos.created_at
OR (dup.created_at = videos.created_at AND dup.id < videos.id)
)
))
AND (COALESCE(videos.sampled_sha256, '') = ''
OR videos.size_bytes <= 0
OR NOT EXISTS (
SELECT 1
FROM videos AS dup
WHERE dup.sampled_sha256 = videos.sampled_sha256
AND dup.size_bytes = videos.size_bytes
AND COALESCE(dup.sampled_sha256, '') != ''
AND dup.size_bytes > 0
AND (
dup.created_at < videos.created_at
OR (dup.created_at = videos.created_at AND dup.id < videos.id)
)
))
AND (COALESCE(videos.file_name, '') = ''
OR videos.size_bytes <= 0
OR NOT EXISTS (
SELECT 1
FROM videos AS dup
WHERE dup.file_name = videos.file_name
AND dup.size_bytes = videos.size_bytes
AND COALESCE(dup.file_name, '') != ''
AND dup.size_bytes > 0
AND (
dup.created_at < videos.created_at
OR (dup.created_at = videos.created_at AND dup.id < videos.id)
)
)))`
type rowScanner interface {
Scan(dest ...any) error
}
func scanVideo(row rowScanner) (*Video, error) {
v := &Video{}
var tagsJSON, badgesJSON string
var publishedAt, createdAt, updatedAt int64
var hidden int
err := row.Scan(
&v.ID, &v.DriveID, &v.FileID, &v.FileName, &v.ContentHash,
&v.SampledSHA256, &v.FingerprintStatus, &v.FingerprintError,
&v.ParentID, &v.Title, &v.Author, &tagsJSON,
&v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL,
&v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus,
&v.TranscodeStatus, &v.TranscodeError, &v.TranscodedFileID, &v.TranscodedSize,
&v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes,
&v.Category, &hidden, &badgesJSON, &v.Description,
&publishedAt, &createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(tagsJSON), &v.Tags)
_ = json.Unmarshal([]byte(badgesJSON), &v.Badges)
v.Hidden = hidden == 1
v.PublishedAt = time.UnixMilli(publishedAt)
v.CreatedAt = time.UnixMilli(createdAt)
v.UpdatedAt = time.UnixMilli(updatedAt)
return v, nil
}
func normalizeContentHash(hash string) string {
return strings.ToLower(strings.TrimSpace(hash))
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}