fix: suppress deleted auto tags

This commit is contained in:
nianzhibai
2026-05-31 16:51:45 +08:00
parent 7f1c1a51a3
commit 91c03947d1
4 changed files with 272 additions and 1 deletions
+8
View File
@@ -62,6 +62,14 @@ CREATE TABLE IF NOT EXISTS video_tags (
CREATE INDEX IF NOT EXISTS idx_video_tags_tag ON video_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_video_tags_video ON video_tags(video_id);
-- 用户手动删除过的非系统标签。自动扫描/迁移不再重新创建同名标签;
-- 管理员手动新建同名标签时会移除这里的记录。
CREATE TABLE IF NOT EXISTS deleted_tags (
label TEXT PRIMARY KEY COLLATE NOCASE,
source TEXT NOT NULL DEFAULT '',
deleted_at INTEGER NOT NULL
);
-- 网盘账户
CREATE TABLE IF NOT EXISTS drives (
id TEXT PRIMARY KEY,
+83 -1
View File
@@ -18,6 +18,7 @@ import (
var ErrUnknownTag = errors.New("unknown tag")
var ErrSystemTag = errors.New("system tag cannot be deleted")
var ErrDeletedTag = errors.New("tag was previously deleted")
const avTagLabel = "AV"
@@ -366,6 +367,9 @@ GROUP BY category`)
if !LooksLikeCollectionTag(stat.category) {
continue
}
if c.tagDeleted(ctx, stat.category) {
continue
}
if _, err := c.ensureTag(ctx, stat.category, nil, "collection"); err != nil {
return err
}
@@ -426,6 +430,9 @@ func (c *Catalog) DeleteTag(ctx context.Context, tagID int64) (int, error) {
if _, err := tx.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, tagID); err != nil {
return 0, err
}
if err := markDeletedTagTx(ctx, tx, tag); err != nil {
return 0, err
}
for _, videoID := range videoIDs {
manual := hasManualTagsTx(ctx, tx, videoID)
@@ -513,6 +520,9 @@ func (c *Catalog) EnsureCollectionTag(ctx context.Context, label string) (string
if !LooksLikeCollectionTag(label) {
return "", false, nil
}
if c.tagDeleted(ctx, label) {
return "", false, nil
}
if !c.tagExists(ctx, label) {
count, err := c.categoryVideoCount(ctx, label)
if err != nil {
@@ -544,6 +554,14 @@ func (c *Catalog) ensureTag(ctx context.Context, label string, aliases []string,
if source == "" {
source = "user"
}
if source != "system" && source != "user" && c.tagDeleted(ctx, label) {
return Tag{}, ErrDeletedTag
}
if source == "system" || source == "user" {
if err := c.restoreDeletedTag(ctx, label); err != nil {
return Tag{}, err
}
}
aliases = cleanAliases(aliases, label)
aliasesJSON, _ := json.Marshal(aliases)
now := time.Now().UnixMilli()
@@ -617,9 +635,15 @@ FROM videos`)
func (c *Catalog) replaceVideoTags(ctx context.Context, videoID string, labels []string, source string, manual bool, createMissing bool) error {
labels = uniqueStrings(cleanLabels(labels))
if source != "manual" {
labels = c.filterDeletedTagLabels(ctx, labels)
}
if createMissing {
for _, label := range labels {
if _, err := c.ensureTag(ctx, label, nil, "legacy"); err != nil {
if errors.Is(err, ErrDeletedTag) {
continue
}
return err
}
}
@@ -662,7 +686,11 @@ func (c *Catalog) replaceVideoTags(ctx context.Context, videoID string, labels [
}
func (c *Catalog) addVideoTags(ctx context.Context, videoID string, labels []string, source string, createMissing bool) error {
for _, label := range uniqueStrings(cleanLabels(labels)) {
labels = uniqueStrings(cleanLabels(labels))
if source != "manual" {
labels = c.filterDeletedTagLabels(ctx, labels)
}
for _, label := range labels {
if _, err := c.addVideoTag(ctx, videoID, label, source, createMissing); err != nil {
return err
}
@@ -671,8 +699,14 @@ func (c *Catalog) addVideoTags(ctx context.Context, videoID string, labels []str
}
func (c *Catalog) addVideoTag(ctx context.Context, videoID, label, source string, createMissing bool) (bool, error) {
if source != "manual" && c.tagDeleted(ctx, label) {
return false, nil
}
if createMissing {
if _, err := c.ensureTag(ctx, label, nil, "legacy"); err != nil {
if errors.Is(err, ErrDeletedTag) {
return false, nil
}
return false, err
}
}
@@ -864,6 +898,39 @@ func (c *Catalog) tagExists(ctx context.Context, label string) bool {
return err == nil
}
func (c *Catalog) tagDeleted(ctx context.Context, label string) bool {
label = cleanTagLabel(label)
if label == "" {
return false
}
var exists int
err := c.db.QueryRowContext(ctx, `SELECT 1 FROM deleted_tags WHERE label = ? COLLATE NOCASE`, label).Scan(&exists)
return err == nil
}
func (c *Catalog) filterDeletedTagLabels(ctx context.Context, labels []string) []string {
if len(labels) == 0 {
return labels
}
out := labels[:0]
for _, label := range labels {
if c.tagDeleted(ctx, label) {
continue
}
out = append(out, label)
}
return out
}
func (c *Catalog) restoreDeletedTag(ctx context.Context, label string) error {
label = cleanTagLabel(label)
if label == "" {
return nil
}
_, err := c.db.ExecContext(ctx, `DELETE FROM deleted_tags WHERE label = ? COLLATE NOCASE`, label)
return err
}
func (c *Catalog) categoryVideoCount(ctx context.Context, category string) (int, error) {
var count int
err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM videos WHERE category = ?`, category).Scan(&count)
@@ -890,6 +957,21 @@ func hasManualTagsTx(ctx context.Context, tx *sql.Tx, videoID string) bool {
return err == nil && manual == 1
}
func markDeletedTagTx(ctx context.Context, tx *sql.Tx, tag Tag) error {
label := cleanTagLabel(tag.Label)
if label == "" {
return nil
}
now := time.Now().UnixMilli()
_, err := tx.ExecContext(ctx, `
INSERT INTO deleted_tags (label, source, deleted_at)
VALUES (?, ?, ?)
ON CONFLICT(label) DO UPDATE SET
source = excluded.source,
deleted_at = excluded.deleted_at`, label, tag.Source, now)
return err
}
func syncVideoTagsJSONTx(ctx context.Context, tx *sql.Tx, videoID string, manual bool) error {
rows, err := tx.QueryContext(ctx, `
SELECT t.label
+94
View File
@@ -206,6 +206,100 @@ func TestDeleteTagRemovesTagFromVideos(t *testing.T) {
}
}
func TestDeleteTagSuppressesAutomaticCollectionRecreation(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()
for _, id := range []string{"video-1", "video-2"} {
if err := cat.UpsertVideo(ctx, &Video{
ID: id,
DriveID: "drive",
FileID: id,
Title: "合集视频",
Category: "sunny",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video %s: %v", id, err)
}
}
if label, ok, err := cat.EnsureCollectionTag(ctx, "sunny"); err != nil || !ok || label != "sunny" {
t.Fatalf("ensure collection = %q, %v, %v; want sunny true nil", label, ok, err)
}
tag := mustTagByLabel(t, ctx, cat, "sunny")
if _, err := cat.DeleteTag(ctx, tag.ID); err != nil {
t.Fatalf("delete tag: %v", err)
}
if label, ok, err := cat.EnsureCollectionTag(ctx, "sunny"); err != nil || ok || label != "" {
t.Fatalf("ensure deleted collection = %q, %v, %v; want empty false nil", label, ok, err)
}
for _, tag := range mustListTags(t, ctx, cat) {
if tag.Label == "sunny" {
t.Fatal("deleted collection tag was recreated automatically")
}
}
}
func TestCreateTagAndClassifyRestoresDeletedTag(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, "清纯")
if _, err := cat.DeleteTag(ctx, tag.ID); err != nil {
t.Fatalf("delete tag: %v", err)
}
classified, err := cat.CreateTagAndClassify(ctx, "清纯", nil, "user")
if err != nil {
t.Fatalf("recreate tag: %v", err)
}
if classified != 1 {
t.Fatalf("classified = %d, want 1", classified)
}
got, err := cat.GetVideo(ctx, "video-1")
if err != nil {
t.Fatalf("get video: %v", err)
}
if !sameStrings(got.Tags, []string{"清纯"}) {
t.Fatalf("video tags = %#v, want 清纯", got.Tags)
}
}
func TestDeleteTagRejectsSystemTags(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
+87
View File
@@ -254,6 +254,93 @@ func TestRunAddsShortCollectionDirectoryAsTag(t *testing.T) {
}
}
func TestRunDoesNotRecreateDeletedCollectionDirectoryTag(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()
for _, id := range []string{"existing-1", "existing-2"} {
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: id,
DriveID: "drive",
FileID: id,
Title: "Existing",
Category: "sunny",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed existing sunny video: %v", err)
}
}
if label, ok, err := cat.EnsureCollectionTag(ctx, "sunny"); err != nil || !ok || label != "sunny" {
t.Fatalf("ensure collection = %q, %v, %v; want sunny true nil", label, ok, 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 == "sunny" {
tagID = tag.ID
break
}
}
if tagID == 0 {
t.Fatal("sunny tag not found before delete")
}
if _, err := cat.DeleteTag(ctx, tagID); err != nil {
t.Fatalf("delete tag: %v", err)
}
drv := &scannerTreeFakeDrive{
entries: map[string][]drives.Entry{
"root": {{
ID: "dir-1",
Name: "sunny",
IsDir: true,
}},
"dir-1": {{
ID: "file-1",
ParentID: "dir-1",
Name: "clip.mp4",
Size: 123,
ModTime: now,
}},
},
}
sc := New(cat, drv, []string{".mp4"}, nil, nil)
if _, err := sc.Run(ctx, ""); err != nil {
t.Fatalf("scan: %v", err)
}
got, err := cat.GetVideo(ctx, "fake-drive-file-1")
if err != nil {
t.Fatalf("get video: %v", err)
}
if len(got.Tags) != 0 {
t.Fatalf("tags = %#v, want none", got.Tags)
}
tags, err = cat.ListTags(ctx)
if err != nil {
t.Fatalf("list tags after scan: %v", err)
}
for _, tag := range tags {
if tag.Label == "sunny" {
t.Fatal("deleted collection tag was recreated during scan")
}
}
}
func TestRunMapsAVCodeDirectoryToAVTag(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")