mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
836 lines
24 KiB
Go
836 lines
24 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed schema.sql
|
|
var schemaSQL string
|
|
|
|
type Catalog struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
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"`
|
|
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"`
|
|
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)
|
|
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, parent_id, title, author, tags,
|
|
duration_seconds, size_bytes, ext, quality, thumbnail_url,
|
|
preview_file_id, preview_local, preview_status,
|
|
views, favorites, comments, likes, dislikes,
|
|
category, hidden, badges, description, published_at, created_at, updated_at
|
|
) VALUES (
|
|
?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?,
|
|
?, ?, ?,
|
|
?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?, ?, ?
|
|
)
|
|
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,
|
|
duration_seconds= excluded.duration_seconds,
|
|
size_bytes = excluded.size_bytes,
|
|
ext = excluded.ext,
|
|
quality = excluded.quality,
|
|
thumbnail_url = excluded.thumbnail_url,
|
|
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.ParentID, v.Title, v.Author, string(tagsJSON),
|
|
v.DurationSeconds, v.Size, v.Ext, v.Quality, 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, previewFileID, previewLocal, status string) error {
|
|
_, err := c.db.ExecContext(ctx,
|
|
`UPDATE videos SET preview_file_id = ?, preview_local = ?, preview_status = ?, updated_at = ? WHERE id = ?`,
|
|
previewFileID, previewLocal, status, time.Now().UnixMilli(), id)
|
|
return 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// VideoMetaPatch 轻量更新视频元数据(仅非零值字段会被写入)
|
|
type VideoMetaPatch struct {
|
|
ThumbnailURL string
|
|
ThumbnailStatus string
|
|
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)
|
|
}
|
|
if p.ThumbnailStatus != "" {
|
|
parts = append(parts, "thumbnail_status = ?")
|
|
args = append(args, nullableStatus(p.ThumbnailStatus))
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ListVideosNeedingThumbnail returns videos that still need a thumbnail attempt.
|
|
// Failed thumbnails are reported separately and should not block teaser generation.
|
|
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, '') = ''
|
|
AND COALESCE(thumbnail_status, 'pending') != 'failed'
|
|
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, '') = ''
|
|
AND COALESCE(thumbnail_status, 'pending') != 'failed'
|
|
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) DeleteVideo(ctx context.Context, id string) error {
|
|
tx, err := c.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
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
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
type ListParams struct {
|
|
Keyword string
|
|
DriveID string
|
|
Tag string
|
|
Category string
|
|
Sort string // latest | hot | week | long
|
|
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, `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
|
|
)`)
|
|
args = append(args, p.Tag)
|
|
}
|
|
if p.Category != "" && p.Category != "all" {
|
|
where = append(where, "category = ?")
|
|
args = append(args, p.Category)
|
|
}
|
|
where = append(where, "COALESCE(hidden, 0) = 0")
|
|
where = append(where, uniqueVideoWhereSQL)
|
|
|
|
whereSQL := ""
|
|
whereSQL = " WHERE " + strings.Join(where, " AND ")
|
|
|
|
orderBy := " ORDER BY published_at DESC"
|
|
switch p.Sort {
|
|
case "hot":
|
|
// 热度 = 点赞数,点赞相同按最新
|
|
orderBy = " ORDER BY likes DESC, published_at DESC"
|
|
case "week":
|
|
orderBy = " ORDER BY likes DESC"
|
|
case "long":
|
|
orderBy = " ORDER BY duration_seconds DESC"
|
|
}
|
|
|
|
// count
|
|
var total int
|
|
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
|
|
}
|
|
|
|
type DriveTeaserCounts struct {
|
|
Ready int
|
|
Pending int
|
|
Failed int
|
|
}
|
|
|
|
type DriveThumbnailCounts 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') != 'failed' THEN 1 END) AS pending_count,
|
|
COUNT(CASE WHEN COALESCE(thumbnail_url, '') = ''
|
|
AND COALESCE(thumbnail_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]DriveThumbnailCounts)
|
|
for rows.Next() {
|
|
var driveID string
|
|
var counts DriveThumbnailCounts
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ---------- Drive ----------
|
|
|
|
type Drive struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
RootID string `json:"rootId"`
|
|
ScanRootID string `json:"scanRootId"`
|
|
Credentials map[string]string `json:"credentials,omitempty"`
|
|
Status string `json:"status"`
|
|
LastError string `json:"lastError,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
func (c *Catalog) UpsertDrive(ctx context.Context, d *Drive) error {
|
|
cred, _ := json.Marshal(d.Credentials)
|
|
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, 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,
|
|
updated_at = excluded.updated_at
|
|
`, d.ID, d.Kind, d.Name, d.RootID, d.ScanRootID, string(cred), d.Status, d.LastError,
|
|
d.CreatedAt.UnixMilli(), d.UpdatedAt.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, ''), 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 string
|
|
var createdAt, updatedAt int64
|
|
if err := rows.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = json.Unmarshal([]byte(credsStr), &d.Credentials)
|
|
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, ''), created_at, updated_at FROM drives WHERE id = ?`, id)
|
|
d := &Drive{}
|
|
var credsStr string
|
|
var createdAt, updatedAt int64
|
|
if err := row.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = json.Unmarshal([]byte(credsStr), &d.Credentials)
|
|
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
|
|
}
|
|
|
|
// ---------- 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
|
|
}
|
|
|
|
// ---------- 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(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'),
|
|
views, favorites, comments, likes, dislikes,
|
|
COALESCE(category, ''), COALESCE(hidden, 0), COALESCE(badges, '[]'), COALESCE(description, ''),
|
|
published_at, created_at, updated_at
|
|
`
|
|
|
|
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.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.ParentID, &v.Title, &v.Author, &tagsJSON,
|
|
&v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL,
|
|
&v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus,
|
|
&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
|
|
}
|