Add manual tag deletion

This commit is contained in:
nianzhibai
2026-05-31 10:38:33 +08:00
parent 674a92be16
commit 655da05b94
13 changed files with 388 additions and 25 deletions
+11 -5
View File
@@ -121,14 +121,20 @@ OneDrive 按 OpenList 默认应用方式调用 `https://api.oplist.org/onedrive/
## 文件名约定
扫描器按以下顺序解析文件名:
扫描器按以下顺序解析文件名,用于提取标题和作者
1. `[tag1,tag2] 标题 - 作者.mp4`
2. `[tag1,tag2] 标题.mp4`
1. `[前缀] 标题 - 作者.mp4`
2. `[前缀] 标题.mp4`
3. `标题 - 作者.mp4`
4. `标题.mp4`
标签分隔符支持 `, ` 和空格。解析结果会和系统标签池匹配,常见番号类噪声会归并到 `AV` 等系统标签,避免把每个番号都变成独立标签。解析结果可在管理后台覆盖。
开头的 `[前缀]` 只会从标题里剥离,不会按分隔符作为任意标签入库。视频标签来自三类规则:
1. 文件名、作者和目录名命中系统标签或已有标签的标签名 / 别名。
2. 符合条件的目录名会自动创建 `collection` 合集标签,并给同目录视频打上该标签。
3. 常见番号类噪声会统一归并到 `AV`,避免把每个番号都变成独立标签。
当前内置系统标签为:`后入`、`奶子`、`口交`、``、`人妻`、`女大`、`AV`。解析结果可在管理后台覆盖;手动保存后,该视频会标记为人工标签,后续扫描不会再自动覆盖。
## 视频去重
@@ -146,7 +152,7 @@ OneDrive 按 OpenList 默认应用方式调用 `https://api.oplist.org/onedrive/
- `/admin/drives`:新增、编辑、删除网盘,触发扫描。
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘 Teaser 统计,编辑标题/作者/分类/标签,单条或全量重生 teaser。
- `/admin/tags`:新增标签并用内置规则自动匹配已有视频。
- `/admin/tags`:新增标签并用内置规则自动匹配已有视频;删除非系统标签时会从所有视频上同步移除该标签
- 播放页视频信息会展示来源网盘类型;同时提供“不再展示”,点击后会把视频标记为全局隐藏。隐藏视频不会再出现在首页、列表、搜索、相关推荐和详情接口中。目前没有管理后台恢复入口,如需恢复可把数据库里对应视频的 `hidden` 字段改回 `0`。
## Teaser 生成
+22
View File
@@ -121,6 +121,7 @@ func (a *AdminServer) Register(r chi.Router) {
// 标签
r.Get("/tags", a.handleListTags)
r.Post("/tags", a.handleCreateTag)
r.Delete("/tags/{id}", a.handleDeleteTag)
// 运行时设置
r.Get("/settings", a.handleGetSettings)
@@ -745,6 +746,27 @@ func (a *AdminServer) handleCreateTag(w http.ResponseWriter, r *http.Request) {
})
}
func (a *AdminServer) handleDeleteTag(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil || id <= 0 {
writeErr(w, http.StatusBadRequest, errors.New("invalid tag id"))
return
}
removedVideos, err := a.Catalog.DeleteTag(r.Context(), id)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
writeErr(w, http.StatusNotFound, err)
case errors.Is(err, catalog.ErrSystemTag):
writeErr(w, http.StatusBadRequest, err)
default:
writeErr(w, http.StatusInternalServerError, err)
}
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removedVideos": removedVideos})
}
type updateVideoReq struct {
Title string `json:"title"`
Author string `json:"author"`
+59
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -663,6 +664,64 @@ func TestHandleCreateTagClassifiesExistingVideos(t *testing.T) {
}
}
func TestHandleDeleteTagRemovesTagFromVideos(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: "video-1",
DriveID: "drive",
FileID: "file-1",
Title: "清纯短发",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
if _, err := cat.CreateTagAndClassify(ctx, "清纯", nil, "user"); err != nil {
t.Fatalf("create tag: %v", err)
}
tags, err := cat.ListTags(ctx)
if err != nil {
t.Fatalf("list tags: %v", err)
}
var tagID int64
for _, tag := range tags {
if tag.Label == "清纯" {
tagID = tag.ID
break
}
}
if tagID == 0 {
t.Fatal("created tag not found")
}
req := requestWithRouteParam(http.MethodDelete, "/admin/api/tags/1", "id", strconv.FormatInt(tagID, 10), strings.NewReader(``))
rr := httptest.NewRecorder()
(&AdminServer{Catalog: cat}).handleDeleteTag(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
video, err := cat.GetVideo(ctx, "video-1")
if err != nil {
t.Fatalf("get video: %v", err)
}
if len(video.Tags) != 0 {
t.Fatalf("video tags = %#v, want none", video.Tags)
}
}
func TestHandleAdminListVideosFiltersByDriveID(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+1 -1
View File
@@ -40,7 +40,7 @@ var allowedUploadExtensions = map[string]struct{}{
var allowedUploadTags = map[string]struct{}{
"奶子": {},
"臀": {},
"口": {},
"口": {},
"女大": {},
"人妻": {},
"AV": {},
+2 -2
View File
@@ -261,7 +261,7 @@ func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
}
req := multipartUploadRequest(t, map[string]string{
"title": "用户上传标题",
"tags": "奶子,AV,女大",
"tags": "奶子,口交,AV,女大",
}, "clip.mp4", "video-bytes")
rr := httptest.NewRecorder()
@@ -287,7 +287,7 @@ func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
if got.Title != "用户上传标题" {
t.Fatalf("title = %q, want submitted title", got.Title)
}
if !sameStringSet(got.Tags, []string{"奶子", "AV", "女大"}) {
if !sameStringSet(got.Tags, []string{"奶子", "口交", "AV", "女大"}) {
t.Fatalf("tags = %#v, want selected tags", got.Tags)
}
if got.PreviewStatus != "pending" {
+107
View File
@@ -17,6 +17,7 @@ import (
)
var ErrUnknownTag = errors.New("unknown tag")
var ErrSystemTag = errors.New("system tag cannot be deleted")
const avTagLabel = "AV"
@@ -380,6 +381,62 @@ func (c *Catalog) CreateTagAndClassify(ctx context.Context, label string, aliase
return c.classifyTag(ctx, tag)
}
func (c *Catalog) DeleteTag(ctx context.Context, tagID int64) (int, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
tag, err := c.getTagByIDTx(ctx, tx, tagID)
if err != nil {
return 0, err
}
if tag.Source == "system" {
return 0, ErrSystemTag
}
rows, err := tx.QueryContext(ctx, `SELECT video_id FROM video_tags WHERE tag_id = ?`, tagID)
if err != nil {
return 0, err
}
var videoIDs []string
for rows.Next() {
var videoID string
if err := rows.Scan(&videoID); err != nil {
rows.Close()
return 0, err
}
videoIDs = append(videoIDs, videoID)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM video_tags WHERE tag_id = ?`, tagID); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, tagID); err != nil {
return 0, err
}
for _, videoID := range videoIDs {
manual := hasManualTagsTx(ctx, tx, videoID)
if err := syncVideoTagsJSONTx(ctx, tx, videoID, manual); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(videoIDs), nil
}
func (c *Catalog) ListTags(ctx context.Context) ([]Tag, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT t.id, t.label, t.aliases, t.source, COUNT(v.id) AS cnt
@@ -817,6 +874,56 @@ func (c *Catalog) getTagByLabelTx(ctx context.Context, tx *sql.Tx, label string)
return scanTag(row)
}
func (c *Catalog) getTagByIDTx(ctx context.Context, tx *sql.Tx, id int64) (Tag, error) {
row := tx.QueryRowContext(ctx,
`SELECT id, label, aliases, source, 0 FROM tags WHERE id = ?`,
id)
return scanTag(row)
}
func hasManualTagsTx(ctx context.Context, tx *sql.Tx, videoID string) bool {
var manual int
err := tx.QueryRowContext(ctx, `SELECT COALESCE(tags_manual, 0) FROM videos WHERE id = ?`, videoID).Scan(&manual)
return err == nil && manual == 1
}
func syncVideoTagsJSONTx(ctx context.Context, tx *sql.Tx, videoID string, manual bool) error {
rows, err := tx.QueryContext(ctx, `
SELECT t.label
FROM video_tags vt
JOIN tags t ON t.id = vt.tag_id
WHERE vt.video_id = ?
ORDER BY t.id ASC`, videoID)
if err != nil {
return err
}
var labels []string
for rows.Next() {
var label string
if err := rows.Scan(&label); err != nil {
rows.Close()
return err
}
labels = append(labels, label)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
labelsJSON, _ := json.Marshal(labels)
manualValue := 0
if manual {
manualValue = 1
}
_, err = tx.ExecContext(ctx,
`UPDATE videos SET tags = ?, tags_manual = ?, updated_at = ? WHERE id = ?`,
string(labelsJSON), manualValue, time.Now().UnixMilli(), videoID)
return err
}
type tagRowScanner interface {
Scan(dest ...any) error
}
+94
View File
@@ -3,6 +3,7 @@ package catalog
import (
"context"
"database/sql"
"errors"
"testing"
"time"
)
@@ -154,6 +155,79 @@ func TestCreateTagAndClassifyAddsTagToMatchingExistingVideos(t *testing.T) {
}
}
func TestDeleteTagRemovesTagFromVideos(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
if err := cat.UpsertVideo(ctx, &Video{
ID: "video-1",
DriveID: "drive",
FileID: "file-1",
Title: "清纯短发",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
if _, err := cat.CreateTagAndClassify(ctx, "清纯", nil, "user"); err != nil {
t.Fatalf("create tag: %v", err)
}
tag := mustTagByLabel(t, ctx, cat, "清纯")
removed, err := cat.DeleteTag(ctx, tag.ID)
if err != nil {
t.Fatalf("delete tag: %v", err)
}
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
got, err := cat.GetVideo(ctx, "video-1")
if err != nil {
t.Fatalf("get video: %v", err)
}
if len(got.Tags) != 0 {
t.Fatalf("video tags = %#v, want none", got.Tags)
}
for _, tag := range mustListTags(t, ctx, cat) {
if tag.Label == "清纯" {
t.Fatal("deleted tag still appears in ListTags")
}
}
}
func TestDeleteTagRejectsSystemTags(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
tag := mustTagByLabel(t, ctx, cat, "AV")
if _, err := cat.DeleteTag(ctx, tag.ID); !errors.Is(err, ErrSystemTag) {
t.Fatalf("delete system tag err = %v, want ErrSystemTag", err)
}
if tag := mustTagByLabel(t, ctx, cat, "AV"); tag.Source != "system" {
t.Fatalf("AV source = %q, want system", tag.Source)
}
}
func TestOpenClassifiesSystemTagsForExistingVideos(t *testing.T) {
path := t.TempDir() + "/catalog.db"
db, err := sql.Open("sqlite", path)
@@ -730,6 +804,26 @@ func sameStrings(a, b []string) bool {
return true
}
func mustListTags(t *testing.T, ctx context.Context, cat *Catalog) []Tag {
t.Helper()
tags, err := cat.ListTags(ctx)
if err != nil {
t.Fatalf("list tags: %v", err)
}
return tags
}
func mustTagByLabel(t *testing.T, ctx context.Context, cat *Catalog, label string) Tag {
t.Helper()
for _, tag := range mustListTags(t, ctx, cat) {
if tag.Label == label {
return tag
}
}
t.Fatalf("tag %q not found", label)
return Tag{}
}
// 删除 collection 标签的最后一个引用视频后,标签应当自动从 tags 表里消失。
// user/system 标签不受影响:用户/系统标签的语义由人维护,孤儿状态保留。
func TestDeleteVideoPrunesOrphanCollectionTag(t *testing.T) {
+2 -2
View File
@@ -16,11 +16,11 @@ type ParsedName struct {
}
var (
reTags = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*`) // [tag1,tag2]
reTags = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*`) // [前缀]
reAuthor = regexp.MustCompile(`\s*-\s*([^-]+?)\s*$`) // - author
)
// Parse 按约定解析:[tag1,tag2] 标题 - 作者.ext
// Parse 按约定解析:[前缀] 标题 - 作者.ext
// 任何字段缺失都能降级
func Parse(filename string) ParsedName {
name := strings.TrimSuffix(filename, path.Ext(filename))
+37 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Film, Plus, RefreshCw, Search, Tags } from "lucide-react";
import { Film, Plus, RefreshCw, Search, Tags, Trash2 } from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
@@ -9,6 +9,7 @@ export function TagsPage() {
const [aliases, setAliases] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filterSource, setFilterSource] = useState<string>("all");
const { show } = useToast();
@@ -45,6 +46,23 @@ export function TagsPage() {
}
}
async function handleDelete(tag: api.AdminTag) {
if (tag.source === "system") return;
if (!window.confirm(`确定删除标签「${tag.label}」吗?此操作会从所有视频上移除该标签。`)) {
return;
}
setDeletingId(tag.id);
try {
const r = await api.deleteTag(tag.id);
show(`已删除标签,并从 ${r.removedVideos} 个视频移除`, "success");
await refresh();
} catch (e) {
show(e instanceof Error ? e.message : "删除标签失败", "error");
} finally {
setDeletingId(null);
}
}
const stats = useMemo(() => {
let totalVideos = 0;
let systemCount = 0;
@@ -227,10 +245,24 @@ export function TagsPage() {
<div className="admin-tag-card__footer">
<span>ID: {tag.id}</span>
<span className="admin-tag-card__count">
<Film size={11} />
<strong>{tag.count} </strong>
</span>
<div className="admin-tag-card__footer-actions">
<span className="admin-tag-card__count">
<Film size={11} />
<strong>{tag.count} </strong>
</span>
{tag.source !== "system" && (
<button
type="button"
className="admin-tag-card__delete"
onClick={() => handleDelete(tag)}
disabled={deletingId === tag.id}
aria-label={`删除标签 ${tag.label}`}
>
<Trash2 size={11} />
<span>{deletingId === tag.id ? "删除中" : "删除"}</span>
</button>
)}
</div>
</div>
</div>
))}
+7
View File
@@ -333,6 +333,13 @@ export function createTag(label: string, aliases: string[]) {
});
}
export function deleteTag(id: number) {
return request<{ ok: boolean; removedVideos: number }>(
`/tags/${encodeURIComponent(String(id))}`,
{ method: "DELETE" }
);
}
// ---------- Settings ----------
export type Theme = "dark" | "pink";
+1 -1
View File
@@ -7,7 +7,7 @@ import { uploadVideo } from "@/data/videos";
import { defaultUploadTitleFromFileName } from "@/lib/uploadTitle";
import type { VideoItem } from "@/types";
const UPLOAD_TAGS = ["奶子", "臀", "口", "女大", "人妻", "AV"];
const UPLOAD_TAGS = ["奶子", "臀", "口", "女大", "人妻", "AV"];
export default function UploadPage() {
const [file, setFile] = useState<File | null>(null);
+31
View File
@@ -2242,3 +2242,34 @@
font-weight: var(--weight-semibold);
color: var(--text-default);
}
.admin-tag-card__footer-actions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.admin-tag-card__delete {
border: 1px solid var(--danger);
background: var(--danger-soft);
color: var(--danger);
border-radius: var(--radius-xs);
padding: 3px 6px;
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: var(--weight-semibold);
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-tag-card__delete:hover:not(:disabled) {
background: var(--danger);
color: white;
}
.admin-tag-card__delete:disabled {
opacity: 0.55;
cursor: not-allowed;
}
+14 -9
View File
@@ -1441,7 +1441,7 @@ VideoProject/
| 项 | 决定 |
|---|---|
| 登录方式 | **B**:管理后台做完整登录流程。115 扫码、夸克扫码或 Cookie 导入、沃盘手机号 + 短信验证。Token 持久化到 SQLite 并自动刷新。 |
| 元数据来源 | **默认文件名解析**`标题.mp4``[tag1,tag2] 标题 - 作者.mp4`;同时提供后台录入 API 覆盖字段 |
| 元数据来源 | **默认文件名解析**`标题.mp4``标题 - 作者.mp4`,或带前缀的 `[前缀] 标题 - 作者.mp4`;前缀只用于标题清理,不作为任意标签列表入库。标签来自系统 / 用户标签匹配和目录合集规则;同时提供后台录入 API 覆盖字段 |
| Hover teaser | **C 预生成**:scanner 发现新视频时异步生成 10s teaser 并存回网盘的 `previews/` 目录,详情页和列表页 hover 都秒开 |
| 部署目标 | Linux 服务器;本地 Windows 开发 |
| 扫描策略 | 启动时全量 + 每 6 小时增量 + 支持手动触发 |
@@ -1485,14 +1485,14 @@ type StreamLink struct {
### 15.5 文件名解析规则
默认解析顺序(取第一个匹配):
默认解析顺序(取第一个匹配),用于提取 `title` / `author`
1. 完整格式:`[tag1,tag2] 标题 - 作者.ext`
2. 去作者`[tag1,tag2] 标题.ext`
3. 去标签`标题 - 作者.ext`
1. 带前缀和作者:`[前缀] 标题 - 作者.ext`
2. 带前缀`[前缀] 标题.ext`
3. 带作者`标题 - 作者.ext`
4. 最简单:`标题.ext`
解析出的字段:`title` / `author` / `tags[]`。其余字段(`duration` / `views` / `favorites` 等)由 scanner 读取文件元数据或置默认值。
开头的 `[前缀]` 只会从标题里剥离,不会按 `,` / `` / `、` / 空格拆成任意标签入库。`tags[]` 由 scanner 另行生成:文件名、作者和目录名命中系统标签或已有标签的标签名 / 别名时自动打标;符合条件的目录名会创建 `collection` 合集标签;常见番号类文本会归并为 `AV`。当前内置系统标签是 `后入``奶子``口交``臀``人妻``女大``AV`。其余字段(`duration` / `views` / `favorites` 等)由 scanner 读取文件元数据或置默认值。
后台录入接口可用来覆盖解析结果:
@@ -1570,6 +1570,10 @@ POST /admin/api/videos # 手动新建
PUT /admin/api/videos/:id # 修改元数据
DELETE /admin/api/videos/:id
POST /admin/api/videos/:id/regen-preview
GET /admin/api/tags # 标签列表
POST /admin/api/tags # 新增标签并自动归类历史视频
DELETE /admin/api/tags/:id # 删除非系统标签,并从所有视频上移除
```
登录流程三家各不相同:
@@ -1684,9 +1688,10 @@ Teaser 不再是"固定从第 10 秒抽 10 秒",改为按视频时长分段挑
- `backend/internal/catalog/tags.go` `migrate` / `pruneOrphanCollectionTags` / `pruneOrphanCollectionTagsByID` / `collectVideoTagIDs`
- 测试:`backend/internal/catalog/tags_test.go` `TestDeleteVideoPrunesOrphanCollectionTag` / `TestMigratePrunesPreexistingOrphanCollectionTags`
**已知不在本次范围**
- `/admin/api/tags` 仍只有 `GET` / `POST`,没有 `DELETE`。如果将来要让管理员手动删 `user` 标签,再加 endpoint
- 数据迁移:上线时对运行中数据库一次性执行同样的 `DELETE` 即可(已对当前实例执行:清掉 10 条 `Season N` / `Better Call Saul SXX` / `东京爱情故事(1991``tags` 总数 153 → 143
**手动删除标签**
- `/admin/api/tags/{id}` 支持 `DELETE`。管理员手动删除非系统标签,删除时同步清理 `video_tags` 并刷新相关视频的 `videos.tags` JSON
- `system` 标签由固定标签池维护,不开放删除;`user` / `collection` / `legacy` 标签可由管理员按需删除
- 历史孤儿 `collection` 标签仍由迁移自愈逻辑自动清理。
### 14.7 取消浏览器内本机转码,全部走 302 直链 + VLC 外部播放器按钮(2026-05-21