t添加标签功能

This commit is contained in:
truewhile
2026-09-16 12:30:34 +08:00
parent 57ee482146
commit cbfae0252e
19 changed files with 1696 additions and 29 deletions
+118
View File
@@ -0,0 +1,118 @@
package model
import (
"encoding/json"
"strings"
)
// MaxLibraryTags 是单个用户可创建的标签数量上限,避免恶意写入过大的 JSON。
const MaxLibraryTags = 50
// MaxLibraryTagNameLen 是单个标签名的最大字符长度(按 rune 计数)。
const MaxLibraryTagNameLen = 24
// LibraryTagSet 是用户自定义的媒体库标签分组。
// LibraryIDs 保存该标签下媒体库的 ID(含远程 Emby 挂载库的 embyremote~ 形式),
// 顺序即媒体库在该标签内的展示顺序。
type LibraryTagSet struct {
Name string `json:"name"`
LibraryIDs []string `json:"library_ids"`
}
// DecodeLibraryTags 解析 LibraryTags 字段,忽略损坏的数据。
func (u *User) DecodeLibraryTags() []LibraryTagSet {
if u == nil || strings.TrimSpace(u.LibraryTags) == "" {
return nil
}
var tags []LibraryTagSet
if err := json.Unmarshal([]byte(u.LibraryTags), &tags); err != nil {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
for _, tag := range NormalizeLibraryTags(tags) {
out = append(out, tag)
}
if len(out) == 0 {
return nil
}
return out
}
// EncodeLibraryTags 把标签集合序列化为可写入 LibraryTags 字段的 JSON 字符串。
// 空集合序列化为空字符串,便于用零值表达"没有标签"。
func EncodeLibraryTags(tags []LibraryTagSet) (string, error) {
if len(tags) == 0 {
return "", nil
}
raw, err := json.Marshal(tags)
if err != nil {
return "", err
}
return string(raw), nil
}
// NormalizeLibraryTags 清洗标签集合:去空白、丢弃空名标签、去掉标签名与
// 标签内媒体库 ID 的重复项,并把标签名重复的项合并。保持传入顺序。
func NormalizeLibraryTags(tags []LibraryTagSet) []LibraryTagSet {
if len(tags) == 0 {
return nil
}
out := make([]LibraryTagSet, 0, len(tags))
indexByName := make(map[string]int, len(tags))
for _, tag := range tags {
name := TruncateLibraryTagName(tag.Name)
if name == "" {
continue
}
if len(out) >= MaxLibraryTags {
break
}
key := strings.ToLower(name)
pos, exists := indexByName[key]
if !exists {
if len(out) >= MaxLibraryTags {
break
}
out = append(out, LibraryTagSet{Name: name, LibraryIDs: []string{}})
pos = len(out) - 1
indexByName[key] = pos
}
seen := make(map[string]struct{}, len(out[pos].LibraryIDs))
for _, id := range out[pos].LibraryIDs {
seen[id] = struct{}{}
}
for _, id := range tag.LibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out[pos].LibraryIDs = append(out[pos].LibraryIDs, trimmed)
}
}
if len(out) == 0 {
return nil
}
for i := range out {
if out[i].LibraryIDs == nil {
out[i].LibraryIDs = []string{}
}
}
return out
}
// TruncateLibraryTagName 去掉首尾空白并按 rune 截断到长度上限。
func TruncateLibraryTagName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
runes := []rune(name)
if len(runes) > MaxLibraryTagNameLen {
runes = runes[:MaxLibraryTagNameLen]
}
return strings.TrimSpace(string(runes))
}
+79
View File
@@ -0,0 +1,79 @@
package model
import "testing"
func TestNormalizeLibraryTagsMergesDuplicatesAndTrims(t *testing.T) {
tags := []LibraryTagSet{
{Name: " 动画 ", LibraryIDs: []string{" a ", "", "b", "a"}},
{Name: "动画", LibraryIDs: []string{"c", "b"}},
{Name: " ", LibraryIDs: []string{"x"}},
{Name: "电影", LibraryIDs: nil},
}
got := NormalizeLibraryTags(tags)
if len(got) != 2 {
t.Fatalf("NormalizeLibraryTags len = %d, want 2 (%#v)", len(got), got)
}
if got[0].Name != "动画" {
t.Fatalf("first tag name = %q, want 动画", got[0].Name)
}
want := []string{"a", "b", "c"}
if len(got[0].LibraryIDs) != len(want) {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
for i := range want {
if got[0].LibraryIDs[i] != want[i] {
t.Fatalf("first tag ids = %v, want %v", got[0].LibraryIDs, want)
}
}
if got[1].Name != "电影" || got[1].LibraryIDs == nil {
t.Fatalf("empty tag should survive with an empty non-nil id list: %#v", got[1])
}
}
func TestNormalizeLibraryTagsCapsCountAndNameLength(t *testing.T) {
long := make([]rune, MaxLibraryTagNameLen+10)
for i := range long {
long[i] = 'x'
}
got := NormalizeLibraryTags([]LibraryTagSet{{Name: string(long)}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if runes := []rune(got[0].Name); len(runes) != MaxLibraryTagNameLen {
t.Fatalf("name length = %d, want %d", len(runes), MaxLibraryTagNameLen)
}
many := make([]LibraryTagSet, 0, MaxLibraryTags+5)
for i := 0; i < MaxLibraryTags+5; i++ {
many = append(many, LibraryTagSet{Name: string(rune('a' + i%26)) + "-" + string(rune('a'+i/26))})
}
if capped := NormalizeLibraryTags(many); len(capped) > MaxLibraryTags {
t.Fatalf("capped len = %d, want <= %d", len(capped), MaxLibraryTags)
}
}
func TestEncodeDecodeLibraryTagsRoundTrip(t *testing.T) {
user := &User{}
if encoded, err := EncodeLibraryTags(nil); err != nil || encoded != "" {
t.Fatalf("EncodeLibraryTags(nil) = %q, %v; want \"\", nil", encoded, err)
}
raw, err := EncodeLibraryTags([]LibraryTagSet{{Name: "动画", LibraryIDs: []string{"lib-1"}}})
if err != nil {
t.Fatalf("EncodeLibraryTags: %v", err)
}
user.LibraryTags = raw
decoded := user.DecodeLibraryTags()
if len(decoded) != 1 || decoded[0].Name != "动画" || len(decoded[0].LibraryIDs) != 1 || decoded[0].LibraryIDs[0] != "lib-1" {
t.Fatalf("DecodeLibraryTags = %#v", decoded)
}
user.LibraryTags = "{not json"
if decoded := user.DecodeLibraryTags(); decoded != nil {
t.Fatalf("corrupt payload should decode to nil, got %#v", decoded)
}
user.PopulateComputedFields()
if user.LibraryTagList != nil {
t.Fatalf("PopulateComputedFields should mirror DecodeLibraryTags, got %#v", user.LibraryTagList)
}
}
+6
View File
@@ -28,6 +28,11 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// LibraryTags 存储用户自定义的媒体库标签分组(JSON 字符串),
// 形如 [{"name":"动画","library_ids":["lib-1","lib-2"]}]。标签属于用户本人,
// 用于在媒体库页把同一标签下的媒体库聚合到一起。
LibraryTags string `gorm:"type:text" json:"-"`
LibraryTagList []LibraryTagSet `gorm:"-" json:"library_tags,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
@@ -103,4 +108,5 @@ func (u *User) PopulateComputedFields() {
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
u.LibraryTagList = u.DecodeLibraryTags()
}