mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
7e5e67697e
Implement a new GuangYaPan cloud drive integration across the backend, admin UI, playback proxy, and Spider91 migration flow. Backend changes:\n- Add a GuangYaPan drive driver with token refresh, QR/device login support, directory listing, stream link resolution, directory creation, rename/delete operations, OSS multipart upload, and upload task polling.\n- Register GuangYaPan as a supported storage kind in configuration, catalog normalization, admin APIs, public drive labels, and 302 playback redirects.\n- Allow Spider91 crawler uploads to target GuangYaPan through a dedicated migration adapter.\n- Add scan, thumbnail, preview, and fingerprint cooldown handling for GuangYaPan based on explicit HTTP status codes, Retry-After values, and structured provider codes instead of natural-language message matching.\n- Tighten existing provider cooldown detectors so OneDrive, Google Drive, 115, PikPak, 123pan, Wopan, and media workers avoid treating arbitrary response text as a rate-limit signal.\n- Keep large videos eligible for preview generation unless the user disables preview generation. Admin and tooling changes:\n- Add GuangYaPan as a selectable drive type with QR login UI and token/root-path credential fields.\n- Add crawler upload target support for GuangYaPan in the admin UI.\n- Add drive branding, labels, metadata display, and docs/config examples for GuangYaPan.\n- Include a standalone GuangYaPan QR login helper script for manual credential acquisition. Tests:\n- Add GuangYaPan driver, QR login, proxy, admin API, crawler upload target, fingerprint, cooldown, and form coverage.\n- Update rate-limit tests to assert that message-only throttling text no longer starts cooldowns.\n- Cover explicit HTTP status parsing through shared drive helper tests.
2304 lines
73 KiB
Go
2304 lines
73 KiB
Go
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,保留视频自身的 id(spider91-<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 迁移到 PikPak,drive_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(¤t); 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
|
||
}
|
||
|
||
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", "guangyapan":
|
||
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
|
||
}
|