Compare commits

...

6 Commits

Author SHA1 Message Date
truewhile d283d39008 优化 2026-09-16 23:36:03 +08:00
truewhile fc89d23867 优化 2026-09-16 22:20:23 +08:00
truewhile 1f14a1e85b 优化 2026-09-16 21:44:42 +08:00
truewhile f0a6b630c6 优化 2026-09-16 21:19:46 +08:00
truewhile c723664e46 优化 2026-09-16 18:34:24 +08:00
truewhile 5faa6975d0 优化标签 2026-09-16 13:50:06 +08:00
19 changed files with 1397 additions and 285 deletions
+51
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/model"
@@ -193,6 +194,18 @@ func mediaVersionGroupKey(m model.Media) string {
return mediaVersionStemGroupKey(m, libKey)
}
// 成人条目按番号折叠。在线刮削(MetaTube)会把番号与 provider「借用」
// 存进 douban_id/thetvdb_id,本地 NFO 路径则拿不到这两个字段;一旦按
// 外部 ID 分组,同一部片的各个分片就会因为元数据来源不同(在线刮削 vs
// 本地 NFO)裂成两张标题相同的卡。番号取自原文件名/路径/标题,与元数据
// 来源无关,因此这里统一用番号做版本身份。
if code := mediaAdultGroupCode(m); code != "" {
if libKey == "" {
libKey = "_"
}
return fmt.Sprintf("adult:%s:%s", libKey, code)
}
switch {
case m.TMDbID > 0:
if libKey != "" {
@@ -267,6 +280,44 @@ func mediaVersionGroupKey(m model.Media) string {
return fmt.Sprintf("movie:%s:%d", title, year)
}
// mediaAdultGroupCode 返回成人条目的番号,作为与元数据来源无关的版本身份。
//
// 只在已经确认是成人条目(NSFW)时才返回番号:番号本身来自原文件名/路径/
// 标题,不会随「在线刮削(MetaTube 把番号与 provider 借用进 douban_id/
// thetvdb_id)」还是「本地 NFO(不带外部 ID)」而改变,因此两条来源能算出
// 同一个键。未标记 NSFW 的条目仍走标题分组,普通影视库里 "The Matrix 1999"
// 这类文件名不会被当成番号。
func mediaAdultGroupCode(m model.Media) string {
if !m.NSFW {
return ""
}
code := firstText(
normalizeAdultCode(m.OriginalName),
AdultCodeFromMediaPath(m.Path),
normalizeAdultCode(m.Title),
)
if code == "" {
return ""
}
return canonicalAdultGroupCode(code)
}
// canonicalAdultGroupCode 去掉番号数字部分的补零,让 IPVR-00192 与 IPVR-192
// 这类同一部片的不同写法落到同一个分组键上。无法解析数字的番号原样返回
// (如 FC2-PPV-4701981)。
func canonicalAdultGroupCode(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
prefix, digits, ok := strings.Cut(code, "-")
if !ok {
return code
}
value, err := strconv.Atoi(digits)
if err != nil {
return code
}
return prefix + "-" + strconv.Itoa(value)
}
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
+161
View File
@@ -442,3 +442,164 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
t.Fatalf("unexpected label %q", label)
}
}
// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进
// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。
// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
func TestGroupMediaVersionsMergesAdultPartsAcrossMetadataSources(t *testing.T) {
online := model.Media{
Base: model.Base{ID: "sivr-270-2"},
LibraryID: "cloud",
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
ScrapeStatus: "matched",
SizeBytes: 200,
}
fromNFO := model.Media{
Base: model.Base{ID: "sivr-270-3"},
LibraryID: "cloud",
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
Year: 2023,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
}
grouped := groupMediaVersions([]model.Media{online, fromNFO})
if len(grouped) != 1 {
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
}
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
if grouped[0].Path != online.Path {
t.Fatalf("larger part should stay primary, got %q", grouped[0].Path)
}
}
func TestGroupMediaVersionsKeepsDifferentAdultCodesSeparate(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "sivr-270"},
LibraryID: "cloud",
Title: "SIVR-270 作品",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
},
{
Base: model.Base{ID: "sivr-271"},
LibraryID: "cloud",
Title: "SIVR-271 作品",
Path: "/media/云下载/sivr-271/sivr-271-1.strm",
Year: 2023,
NSFW: true,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 2 {
t.Fatalf("different adult codes must stay separate: %#v", grouped)
}
}
// 同一部片的两种写法:路径里的 IPVR-00192 与刮削回来的 IPVR-192 必须归一。
func TestGroupMediaVersionsMergesAdultCodesIgnoringZeroPadding(t *testing.T) {
scraped := model.Media{
Base: model.Base{ID: "ipvr-192-part2"},
LibraryID: "cloud",
Title: "IPVR-192-相沢みなみ",
OriginalName: "IPVR-192",
Path: "/media/云下载/ipvr00192pl/scraped/part2.strm",
Year: 2022,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 200,
}
fromPath := model.Media{
Base: model.Base{ID: "ipvr-00192-part1"},
LibraryID: "cloud",
Title: "fbzip com@ipvr00192",
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
Year: 2022,
NSFW: true,
SizeBytes: 100,
}
grouped := groupMediaVersions([]model.Media{scraped, fromPath})
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
t.Fatalf("zero padded code must fold into one item: %#v", grouped)
}
}
func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) {
// IPVR-00192 现场:两个 part 标题、年份完全一样,但 part2 是走 MetaTube
// 刮削的(番号/provider 被借用进 douban_id/thetvdb_id),part1 只有本地
// NFO 身份、两个字段为空。旧逻辑按外部 ID 分组会裂成两张卡。
part1 := model.Media{
Base: model.Base{ID: "ipvr-00192-part1"},
LibraryID: "cloud",
Title: "IPVR-00192-IPVR-192-【VR】相沢みなみ",
OriginalName: "IPVR-00192",
Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm",
Year: 2022,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
}
part2 := part1
part2.ID = "ipvr-00192-part2"
part2.Path = "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm"
part2.DoubanID = "ipvr00192"
part2.TheTVDBID = "JAV321"
part2.SizeBytes = 200
grouped := groupMediaVersions([]model.Media{part1, part2})
if len(grouped) != 1 {
t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped)
}
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
}
func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
cases := []struct {
name string
m model.Media
want string
}{
{
name: "普通影片名里的字母+数字不当番号",
m: model.Media{Title: "Spider-Man 2008 1080p", Path: "/media/Movies/Spider-Man 2008 1080p.mkv"},
want: "",
},
{
name: "未识别成人的条目即使文件名像番号也不折叠",
m: model.Media{Title: "SIVR-270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
want: "",
},
{
name: "已标记 NSFW 时按番号分组",
m: model.Media{NSFW: true, Title: "sivr 270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"},
want: "SIVR-270",
},
{
name: "站点前缀里带补零的番号会归一",
m: model.Media{NSFW: true, Title: "fbzip com@ipvr00192", Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm"},
want: "IPVR-192",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mediaAdultGroupCode(tc.m); got != tc.want {
t.Fatalf("mediaAdultGroupCode = %q, want %q", got, tc.want)
}
})
}
}
+44 -14
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"go.uber.org/zap"
@@ -48,9 +49,13 @@ type movieNFO struct {
Poster string `xml:"thumb,omitempty"`
Fanart string `xml:"fanart,omitempty"`
TMDb int `xml:"tmdbid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
// UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
// 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
// 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
}
type episodeNFO struct {
@@ -167,17 +172,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
}
}
doc = movieNFO{
Title: title,
Original: original,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
Genre: splitNFOList(m.Genres),
Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
Title: title,
Original: original,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
UniqueIDs: nfoExternalUniqueIDs(m),
Genre: splitNFOList(m.Genres),
Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
}
}
out, err := xml.MarshalIndent(doc, "", " ")
@@ -202,3 +208,27 @@ func splitNFOList(value string) []string {
}
return out
}
// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回
// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。
// TMDb 走 <tmdbid>,单集 NFO 不写唯一 ID:单集 ID 属于单集,写进整剧身份会把
// 同一部剧拆成多张卡(见 mergeEpisodeMetadata 的约束说明)。
func nfoExternalUniqueIDs(m *model.Media) []nfoUniqueID {
if m == nil {
return nil
}
out := make([]nfoUniqueID, 0, 3)
if m.BangumiID > 0 {
out = append(out, nfoUniqueID{Type: "bangumi", Value: strconv.Itoa(m.BangumiID)})
}
if id := strings.TrimSpace(m.DoubanID); id != "" {
out = append(out, nfoUniqueID{Type: "douban", Value: id})
}
if id := strings.TrimSpace(m.TheTVDBID); id != "" {
out = append(out, nfoUniqueID{Type: "thetvdb", Value: id})
}
if len(out) == 0 {
return nil
}
return out
}
+48
View File
@@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) {
t.Fatalf("expected originaltitle with code, got:\n%s", text)
}
}
func TestWriteMediaNFORoundTripsExternalUniqueIDs(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "云下载", "sivr-270", "sivr-270-1.strm")
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(mediaPath, []byte("strm"), 0o644); err != nil {
t.Fatal(err)
}
dst, err := WriteMediaNFO(&model.Media{
Title: "SIVR-270-河北彩花",
OriginalName: "SIVR-270",
Path: mediaPath,
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
})
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
text := string(body)
// 成人条目把番号/provider 借用存在 douban/thetvdb 两列;sidecar 必须把它们
// 带回来,否则「在线刮削 → 写 NFO → 重扫读 NFO」之后同一部片会被按不同
// 分组键拆成多张卡。
for _, want := range []string{
`<uniqueid type="douban">SIVR-270</uniqueid>`,
`<uniqueid type="thetvdb">JavBus</uniqueid>`,
} {
if !strings.Contains(text, want) {
t.Fatalf("sidecar missing %s, got:\n%s", want, text)
}
}
meta, err := ReadLocalMetadata(mediaPath, "", false)
if err != nil {
t.Fatal(err)
}
if meta == nil || meta.DoubanID != "SIVR-270" || meta.TheTVDBID != "JavBus" {
t.Fatalf("external ids did not round trip: %#v", meta)
}
}
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from 'lucide-react'
import {
LIBRARY_LIST_SORT_OPTIONS,
type LibraryListSortField,
type LibraryListSortOption,
type LibraryListSortOrder,
} from '../utils/libraryListSort'
type LibraryListSortDropdownProps = {
value: LibraryListSortField
order: LibraryListSortOrder
onChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
className?: string
}
export function LibraryListSortDropdown({
value,
order,
onChange,
className = '',
}: LibraryListSortDropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const currentOption = LIBRARY_LIST_SORT_OPTIONS.find((opt) => opt.id === value) ?? LIBRARY_LIST_SORT_OPTIONS[0]
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
const handleSelect = (option: LibraryListSortOption) => {
if (value === option.id) {
onChange(option.id, order === 'asc' ? 'desc' : 'asc')
} else {
onChange(option.id, option.defaultOrder)
}
setIsOpen(false)
}
return (
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 sm:h-10 sm:text-sm"
title="更改媒体库列表排序"
>
<ArrowUpDown size={14} className="text-sand-500" />
<span>排序: {currentOption.label}</span>
{order === 'asc' ? (
<ArrowUp size={13} className="font-bold text-brand-600" />
) : (
<ArrowDown size={13} className="font-bold text-brand-600" />
)}
</button>
{isOpen && (
<div className="absolute left-0 z-50 mt-1.5 w-44 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
<div className="border-b border-sand-100 px-2.5 py-1.5 text-[11px] font-bold text-sand-500">
排序方式
</div>
<div className="space-y-0.5 py-1">
{LIBRARY_LIST_SORT_OPTIONS.map((option) => {
const isSelected = value === option.id
return (
<button
key={option.id}
type="button"
onClick={() => handleSelect(option)}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
isSelected
? 'bg-brand-50 font-semibold text-brand-700'
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
}`}
>
<div className="flex items-center gap-2">
{isSelected && <Check size={13} className="text-brand-600" />}
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
</div>
{isSelected && (
<div className="flex items-center text-brand-600">
{order === 'asc' ? (
<ArrowUp size={14} className="stroke-[2.5]" />
) : (
<ArrowDown size={14} className="stroke-[2.5]" />
)}
</div>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
}
+3 -2
View File
@@ -7,6 +7,7 @@ import type { LibraryTagTab } from '../utils/libraryTags'
/**
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
* 标签顺序由「管理标签」里的拖拽决定,这里只负责展示与选中。
*/
export function LibraryTagBar({
tabs,
@@ -151,7 +152,7 @@ export function LibraryTagBar({
type="button"
onClick={onManage}
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500"
title="重命名 / 删除标签,调整标签下的媒体库"
title="重命名 / 删除标签,调整标签下的媒体库,拖拽排序"
>
<Settings2 size={13} />
<span>{manageLabel}</span>
@@ -161,4 +162,4 @@ export function LibraryTagBar({
)}
</div>
)
}
}
+334 -118
View File
@@ -1,10 +1,17 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, GripVertical, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { confirmAction } from './confirmAction'
import type { Library } from '../types'
import { MAX_LIBRARY_TAGS, normalizeTagName, tagNameError, type LibraryTag } from '../utils/libraryTags'
import { useDragReorder } from '../hooks/useDragReorder'
import {
MAX_LIBRARY_TAGS,
filterLibrariesForTagging,
normalizeTagName,
tagNameError,
type LibraryTag,
} from '../utils/libraryTags'
export type ManageLibraryTagsDialogProps = {
tags: LibraryTag[]
@@ -13,13 +20,23 @@ export type ManageLibraryTagsDialogProps = {
onCreate: (name: string) => Promise<string>
onRename: (from: string, to: string) => Promise<void>
onRemove: (name: string) => Promise<void>
/** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
onReorder: (orderedNames: string[]) => Promise<void>
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
onAssign: (libraryId: string, tagName: string) => Promise<void>
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
onAssignBatch: (libraryIds: string[], tagName: string) => Promise<void>
onClose: () => void
}
/** 「未分类」在筛选/批量目标里的哨兵值,与标签名不会冲突。 */
const UNTAGGED = '__untagged__'
/**
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把每个媒体库归入
* 标签管理对话框:左侧维护标签(新建 / 重命名 / 删除),右侧把媒体库归入
* 某个标签。一个媒体库同时只属于一个标签,未选择即为「未分类」。
*
* 右侧支持搜索、按当前标签筛选、多选后一次性批量归类,避免逐条点击下拉框。
*/
export function ManageLibraryTagsDialogView({
tags,
@@ -28,13 +45,24 @@ export function ManageLibraryTagsDialogView({
onCreate,
onRename,
onRemove,
onReorder,
onAssign,
onAssignBatch,
onClose,
}: ManageLibraryTagsDialogProps) {
const [draftName, setDraftName] = useState('')
const [renaming, setRenaming] = useState('')
const [renameDraft, setRenameDraft] = useState('')
const [busyLibrary, setBusyLibrary] = useState('')
const [keyword, setKeyword] = useState('')
const [tagFilter, setTagFilter] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchTag, setBatchTag] = useState('')
const [applying, setApplying] = useState(false)
const { draggingId, dragOverId, dragProps } = useDragReorder(
tags.map((tag) => tag.name),
onReorder,
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -54,6 +82,58 @@ export function ManageLibraryTagsDialogView({
return map
}, [tags])
// 标签被删除/重命名后,批量目标与筛选值可能已经失效,落回默认项。
useEffect(() => {
if (batchTag && batchTag !== UNTAGGED && !tags.some((tag) => tag.name === batchTag)) {
setBatchTag('')
}
if (tagFilter && tagFilter !== UNTAGGED && !tags.some((tag) => tag.name === tagFilter)) {
setTagFilter('')
}
}, [tags, batchTag, tagFilter])
// 媒体库被删除后清掉已选项,避免批量应用时带着不存在的 ID。
useEffect(() => {
const available = new Set(libraries.map((lib) => lib.id))
setSelectedIds((prev) => {
const next = prev.filter((id) => available.has(id))
return next.length === prev.length ? prev : next
})
}, [libraries])
const visibleLibraries = useMemo(() => {
const searched = filterLibrariesForTagging(libraries, keyword)
if (!tagFilter) return searched
return searched.filter((lib) => {
const current = tagByLibrary.get(lib.id) ?? ''
return tagFilter === UNTAGGED ? current === '' : current === tagFilter
})
}, [libraries, keyword, tagFilter, tagByLibrary])
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const visibleSelectedCount = visibleLibraries.reduce(
(sum, lib) => sum + (selectedSet.has(lib.id) ? 1 : 0),
0,
)
const allVisibleSelected = visibleLibraries.length > 0 && visibleSelectedCount === visibleLibraries.length
const toggleLibrary = useCallback((libraryId: string) => {
setSelectedIds((prev) =>
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId],
)
}, [])
const toggleVisible = useCallback(() => {
const visibleIds = visibleLibraries.map((lib) => lib.id)
setSelectedIds((prev) => {
const current = new Set(prev)
const everySelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id))
if (everySelected) return prev.filter((id) => !visibleIds.includes(id))
for (const id of visibleIds) current.add(id)
return Array.from(current)
})
}, [visibleLibraries])
const handleCreate = async () => {
const error = tagNameError(draftName, tags)
if (error) {
@@ -103,6 +183,23 @@ export function ManageLibraryTagsDialogView({
}
}
const handleApplyBatch = async () => {
if (selectedIds.length === 0 || applying) return
setApplying(true)
try {
await onAssignBatch(selectedIds, batchTag)
const count = selectedIds.length
toast.success(
batchTag ? `已将 ${count} 个媒体库归入「${batchTag}」` : `已将 ${count} 个媒体库移出标签`,
)
setSelectedIds([])
} finally {
setApplying(false)
}
}
const busy = saving || applying
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 backdrop-blur-sm sm:p-4"
@@ -111,14 +208,14 @@ export function ManageLibraryTagsDialogView({
<div
role="dialog"
aria-modal="true"
className="flex h-[calc(100dvh-1rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[80vh] sm:rounded-3xl"
className="flex h-[calc(100dvh-1rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/70 bg-[var(--app-panel)] shadow-2xl sm:h-[82vh] sm:rounded-3xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center gap-2">
<Tag size={18} className="text-brand-500" />
<h3 className="font-display text-base font-bold text-[var(--app-text)] sm:text-lg">管理标签</h3>
{saving && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
{busy && <Loader2 size={14} className="animate-spin text-[var(--app-muted)]" />}
</div>
<button
type="button"
@@ -130,7 +227,7 @@ export function ManageLibraryTagsDialogView({
</button>
</div>
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
<div className="grid flex-1 grid-cols-1 gap-4 overflow-hidden p-3 sm:p-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
<section className="flex min-h-0 flex-col gap-3">
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">标签</h4>
<div className="flex items-center gap-1.5">
@@ -150,7 +247,7 @@ export function ManageLibraryTagsDialogView({
<button
type="button"
onClick={() => void handleCreate()}
disabled={saving || !draftName.trim()}
disabled={busy || !draftName.trim()}
className="btn-outline shrink-0 !px-3 !py-2 text-xs"
title="创建标签"
>
@@ -165,128 +262,247 @@ export function ManageLibraryTagsDialogView({
还没有标签。先创建一个,再把媒体库归入其中。
</p>
) : (
tags.map((tag) => (
<div
key={tag.name}
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
>
{renaming === tag.name ? (
<>
<input
autoFocus
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleRename(tag.name)
}
if (event.key === 'Escape') {
tags.map((tag) => {
const drag = dragProps(tag.name)
const isDragOver = dragOverId === tag.name && draggingId !== tag.name
return (
<div
key={tag.name}
onDragOver={drag.onDragOver}
onDragLeave={drag.onDragLeave}
onDrop={drag.onDrop}
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
isDragOver
? 'border-brand-500/60 bg-brand-500/5'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
}`}
>
{renaming === tag.name ? (
<>
<input
autoFocus
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleRename(tag.name)
}
if (event.key === 'Escape') {
setRenaming('')
setRenameDraft('')
}
}}
maxLength={24}
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
/>
<button
type="button"
onClick={() => void handleRename(tag.name)}
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
title="保存名称"
>
<Check size={14} />
</button>
<button
type="button"
onClick={() => {
setRenaming('')
setRenameDraft('')
}
}}
maxLength={24}
className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
/>
<button
type="button"
onClick={() => void handleRename(tag.name)}
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
title="保存名称"
>
<Check size={14} />
</button>
<button
type="button"
onClick={() => {
setRenaming('')
setRenameDraft('')
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={14} />
</button>
</>
) : (
<>
<span
className="min-w-0 flex-1 truncate text-sm font-bold text-[var(--app-text)]"
title={tag.name}
>
{tag.name}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
{tag.library_ids.length}
</span>
<button
type="button"
onClick={() => {
setRenaming(tag.name)
setRenameDraft(tag.name)
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
title="重命名"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => void handleRemove(tag)}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
title="删除标签"
>
<Trash2 size={13} />
</button>
</>
)}
</div>
))
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={14} />
</button>
</>
) : (
<>
<button
type="button"
draggable={drag.draggable}
onDragStart={drag.onDragStart}
onDragEnd={drag.onDragEnd}
className={`-ml-1 shrink-0 cursor-grab rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] ${
draggingId === tag.name ? 'opacity-40' : ''
}`}
title="拖拽调整标签顺序"
aria-label="拖拽调整标签顺序"
>
<GripVertical size={13} />
</button>
<button
type="button"
onClick={() => setTagFilter(tag.name)}
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
title={`只看「${tag.name}」标签下的媒体库`}
>
{tag.name}
</button>
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
{tag.library_ids.length}
</span>
<button
type="button"
onClick={() => {
setRenaming(tag.name)
setRenameDraft(tag.name)
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
title="重命名"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => void handleRemove(tag)}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
title="删除标签"
>
<Trash2 size={13} />
</button>
</>
)}
</div>
)
})
)}
</div>
</section>
<section className="flex min-h-0 flex-col gap-3 border-t border-[var(--app-border)] pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
媒体库归类({libraries.length})
</h4>
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-bold uppercase tracking-widest text-[var(--app-muted)]">
媒体库归类({libraries.length})
</h4>
<span className="text-xs text-[var(--app-muted)]">勾选多个后可一次性设置标签</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索名称 / 路径 / 类型"
className="input-base h-9 min-w-[10rem] flex-1 !py-0 text-xs"
/>
<select
value={tagFilter}
onChange={(event) => setTagFilter(event.target.value)}
className="input-base h-9 w-32 shrink-0 !py-0 text-xs"
title="筛选媒体库"
>
<option value="">全部标签</option>
<option value={UNTAGGED}>未分类</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2">
<button
type="button"
onClick={toggleVisible}
disabled={visibleLibraries.length === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500 disabled:opacity-40"
title="全选 / 取消全选当前列表中的媒体库"
>
<Check size={13} />
{allVisibleSelected ? '取消全选' : '全选'}
</button>
<span className="text-xs font-semibold text-[var(--app-subtle)]">
已选 {selectedIds.length} 个
</span>
<select
value={batchTag}
onChange={(event) => setBatchTag(event.target.value)}
className="input-base ml-auto h-8 w-36 shrink-0 !py-0 text-xs"
title="选择要批量设置的标签"
>
<option value="">未分类(移出标签)</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
<button
type="button"
onClick={() => void handleApplyBatch()}
disabled={busy || selectedIds.length === 0}
className="btn-outline shrink-0 !px-3 !py-1.5 text-xs"
title="把选中媒体库的标签一次性设为左侧所选值"
>
{applying && <Loader2 size={13} className="animate-spin" />}
应用到已选
</button>
{selectedIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedIds([])}
disabled={applying}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="清空选择"
>
<X size={13} />
</button>
)}
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
{libraries.length === 0 ? (
{visibleLibraries.length === 0 ? (
<p className="rounded-xl border border-dashed border-[var(--app-border)] px-3 py-6 text-center text-xs text-[var(--app-muted)]">
暂无可归类的媒体库。
{libraries.length === 0 ? '暂无可归类的媒体库。' : '没有符合当前筛选条件的媒体库。'}
</p>
) : (
libraries.map((library) => (
<div
key={library.id}
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2"
>
<span
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
title={library.name}
visibleLibraries.map((library) => {
const current = tagByLibrary.get(library.id) ?? ''
const checked = selectedSet.has(library.id)
return (
<div
key={library.id}
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
checked
? 'border-brand-500/50 bg-brand-500/5'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
}`}
>
{library.name}
</span>
{busyLibrary === library.id && (
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
)}
<select
value={tagByLibrary.get(library.id) ?? ''}
disabled={saving || busyLibrary === library.id}
onChange={(event) => void handleAssign(library, event.target.value)}
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
title="选择该媒体库所属标签"
>
<option value="">未分类</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
</div>
))
<input
type="checkbox"
checked={checked}
onChange={() => toggleLibrary(library.id)}
disabled={applying}
className="h-4 w-4 shrink-0 accent-brand-400"
title="勾选后可批量设置标签"
/>
<span
className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--app-text)]"
title={library.path || library.name}
>
{library.name}
</span>
{busyLibrary === library.id && (
<Loader2 size={13} className="shrink-0 animate-spin text-[var(--app-muted)]" />
)}
<select
value={current}
disabled={busy || busyLibrary === library.id}
onChange={(event) => void handleAssign(library, event.target.value)}
className="input-base h-8 w-32 shrink-0 !py-0 text-xs"
title="选择该媒体库所属标签"
>
<option value="">未分类</option>
{tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
</option>
))}
</select>
</div>
)
})
)}
</div>
</section>
@@ -1,27 +0,0 @@
import { createRoot } from 'react-dom/client'
import {
ManageLibraryTagsDialogView,
type ManageLibraryTagsDialogProps,
} from './ManageLibraryTagsDialogView'
export type OpenManageLibraryTagsDialogOptions = Omit<ManageLibraryTagsDialogProps, 'onClose'>
/** 以独立根节点打开标签管理对话框,关闭后 resolve。 */
export function openManageLibraryTagsDialog(
options: OpenManageLibraryTagsDialogOptions,
): Promise<void> {
return new Promise((resolve) => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const close = () => {
root.unmount()
host.remove()
resolve()
}
root.render(<ManageLibraryTagsDialogView {...options} onClose={close} />)
})
}
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useState, type DragEvent } from 'react'
/** 一组可直接展开到可拖拽元素上的事件处理器。 */
export type DragReorderHandlers = {
draggable: boolean
onDragStart: (event: DragEvent) => void
onDragOver: (event: DragEvent) => void
onDragLeave: (event: DragEvent) => void
onDrop: (event: DragEvent) => void
onDragEnd: () => void
}
/**
* 把 `fromId` 移动到 `toId` 当前所在的位置,返回新数组;不改动入参。
* 任一 id 不存在,或两者相同时返回 null,表示无需重排。
*/
export function moveIdToPosition(ids: string[], fromId: string, toId: string): string[] | null {
const fromIndex = ids.indexOf(fromId)
const toIndex = ids.indexOf(toId)
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null
const next = [...ids]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
return next
}
/**
* 列表拖拽排序的通用状态机:只负责「一串稳定 id」的拖拽与重排计算,
* 不关心渲染。调用方把 `dragProps(id)` 展开到拖拽柄上,并用返回的
* `draggingId` / `dragOverId` 决定高亮样式。
*
* 落点语义:把被拖项插入到目标项所在的位置。未传 `onReorder` 时整体禁用。
*/
export function useDragReorder(
ids: string[],
onReorder?: (orderedIds: string[]) => void | Promise<void>,
): {
draggingId: string | null
dragOverId: string | null
dragProps: (id: string) => DragReorderHandlers
} {
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
const enabled = Boolean(onReorder)
const dragProps = useCallback(
(id: string): DragReorderHandlers => ({
draggable: enabled,
onDragStart: (event) => {
if (!enabled) return
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', id)
setDraggingId(id)
setDragOverId(null)
},
onDragOver: (event) => {
if (!enabled || draggingId === id) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setDragOverId((prev) => (prev === id ? prev : id))
},
onDragLeave: () => {
setDragOverId((prev) => (prev === id ? null : prev))
},
onDrop: (event) => {
if (!enabled) return
event.preventDefault()
// 拖拽期间 state 可能尚未提交,优先信任 dataTransfer 里的来源 id。
const fromId = draggingId ?? event.dataTransfer.getData('text/plain')
setDraggingId(null)
setDragOverId(null)
if (!fromId || fromId === id) return
const next = moveIdToPosition(ids, fromId, id)
if (next) void onReorder?.(next)
},
onDragEnd: () => {
setDraggingId(null)
setDragOverId(null)
},
}),
[enabled, draggingId, ids, onReorder],
)
return { draggingId, dragOverId, dragProps }
}
+38
View File
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useState } from 'react'
import {
readLibraryListSort,
writeLibraryListSort,
type LibraryListSortField,
type LibraryListSortOrder,
} from '../utils/libraryListSort'
export type UseLibraryListSortResult = {
field: LibraryListSortField
order: LibraryListSortOrder
setSort: (field: LibraryListSortField, order: LibraryListSortOrder) => void
}
/**
* 媒体库列表的排序偏好(localStorage 持久化)。
*
* 首页和 /libraries 读的是同一份偏好,这样在 /libraries 改了排序后回到首页
* 顺序也一致;默认「库名倒序」。另外监听 storage 事件,多标签页同时打开时
* 一边改动另一边也能跟上。
*/
export function useLibraryListSort(): UseLibraryListSortResult {
const [sort, setSort] = useState(() => readLibraryListSort())
useEffect(() => {
const sync = () => setSort(readLibraryListSort())
window.addEventListener('storage', sync)
return () => window.removeEventListener('storage', sync)
}, [])
const update = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
writeLibraryListSort(field, order)
setSort({ field, order })
}, [])
return { field: sort.field, order: sort.order, setSort: update }
}
+26 -1
View File
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
import {
ALL_TAG_ID,
assignLibrariesToTag,
attachLibraryToTag,
dedupeLibraryTags,
loadLibraryTags,
@@ -10,6 +11,7 @@ import {
normalizeTagName,
readCachedLibraryTags,
readSelectedTagId,
reorderLibraryTags,
resolveSelectedTagId,
saveLibraryTags,
writeCachedLibraryTags,
@@ -31,11 +33,15 @@ export type UseLibraryTagsResult = {
setSelectedTagId: (tagId: string) => void
createTag: (name: string) => Promise<string>
renameTag: (from: string, to: string) => Promise<void>
/** 按给定标签名顺序重排标签栏(拖拽排序的落点)。 */
reorderTags: (orderedNames: string[]) => Promise<void>
removeTag: (name: string) => Promise<void>
/** 覆盖某个标签下的媒体库集合(保持传入顺序)。 */
setTagLibraries: (name: string, libraryIds: string[]) => Promise<void>
/** 把媒体库挂到标签下;tagName 为空表示移出所有标签。 */
assignLibrary: (libraryId: string, tagName: string) => Promise<void>
/** 批量把媒体库挂到标签下;tagName 为空表示批量移出所有标签。一次写入。 */
assignLibraries: (libraryIds: string[], tagName: string) => Promise<void>
}
/**
@@ -100,13 +106,16 @@ export function useLibraryTags(): UseLibraryTagsResult {
}, [])
// 选中的标签可能被删除,落回「全部」以免标签栏空白。
// 注意:标签首次加载完成前 tags 为空集合,此时不能据此判定标签已被删除,
// 否则从子页面返回时,持久化的选中标签会被误重置为「全部」。
useEffect(() => {
if (loading) return
const resolved = resolveSelectedTagId(tags, selectedTagId)
if (resolved !== selectedTagId) {
selectedRef.current = resolved
setSelectedTagIdState(resolved)
}
}, [tags, selectedTagId])
}, [loading, tags, selectedTagId])
const setSelectedTagId = useCallback((tagId: string) => {
const next = tagId && tagId !== ALL_TAG_ID ? tagId : ALL_TAG_ID
@@ -190,6 +199,13 @@ export function useLibraryTags(): UseLibraryTagsResult {
[mutate, setSelectedTagId],
)
const reorderTags = useCallback(
async (orderedNames: string[]) => {
await mutate((current) => reorderLibraryTags(current, orderedNames))
},
[mutate],
)
const removeTag = useCallback(
async (name: string) => {
await mutate((current) => current.filter((tag) => tag.name !== name))
@@ -220,6 +236,13 @@ export function useLibraryTags(): UseLibraryTagsResult {
[mutate],
)
const assignLibraries = useCallback(
async (libraryIds: string[], tagName: string) => {
await mutate((current) => assignLibrariesToTag(current, libraryIds, tagName))
},
[mutate],
)
return {
tags,
loading,
@@ -229,9 +252,11 @@ export function useLibraryTags(): UseLibraryTagsResult {
setSelectedTagId,
createTag,
renameTag,
reorderTags,
removeTag,
setTagLibraries,
assignLibrary,
assignLibraries,
}
}
+26 -15
View File
@@ -8,10 +8,11 @@ import type { SeriesCard } from '../utils/groupSeries'
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
import { LibraryTagBar } from '../components/LibraryTagBar'
import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog'
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
import { useLibraryTags } from '../hooks/useLibraryTags'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { sortByPinnedIds } from '../utils/pinnedLibraries'
import { useLibraryListSort } from '../hooks/useLibraryListSort'
import { sortLibrariesByField } from '../utils/libraryListSort'
import { partitionPreviewIDs } from '../utils/remoteEmby'
import {
ContinueWatchingSection,
@@ -34,6 +35,7 @@ export function HomePage() {
const [librariesLoading, setLibrariesLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(true)
const { pinnedIds } = usePinnedLibraries()
const { field: sortField, order: sortOrder } = useLibraryListSort()
const libraryTags = useLibraryTags()
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
@@ -90,7 +92,12 @@ export function HomePage() {
}
}, [])
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
// 与 /libraries 共用同一份排序偏好(默认库名倒序):置顶库始终在前,
// 组内按偏好排序字段排。这样在媒体库页改过排序后,首页顺序也一致。
const sortedLibraries = useMemo(
() => sortLibrariesByField(libraries, pinnedIds, sortField, sortOrder),
[libraries, pinnedIds, sortField, sortOrder],
)
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
const effectiveTagId = libraryTags.selectedTagId
@@ -103,17 +110,7 @@ export function HomePage() {
[sortedLibraries, libraryTags.tags, effectiveTagId],
)
const handleManageTags = useCallback(() => {
void openManageLibraryTagsDialog({
tags: libraryTags.tags,
libraries,
saving: libraryTags.saving,
onCreate: libraryTags.createTag,
onRename: libraryTags.renameTag,
onRemove: libraryTags.removeTag,
onAssign: libraryTags.assignLibrary,
})
}, [libraries, libraryTags])
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
@@ -350,9 +347,23 @@ export function HomePage() {
onCreate={(name) => {
void libraryTags.createTag(name)
}}
onManage={handleManageTags}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
{libraryTagsOpen && (
<ManageLibraryTagsDialogView
tags={libraryTags.tags}
libraries={libraries}
saving={libraryTags.saving}
onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag}
onReorder={libraryTags.reorderTags}
onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)}
/>
)}
{taggedLibraries.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-12 text-center text-sm text-[var(--app-muted)]">
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
+51 -31
View File
@@ -3,11 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { LibraryTagBar } from '../components/LibraryTagBar'
import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog'
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import { useLibraryTags } from '../hooks/useLibraryTags'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { useLibraryListSort } from '../hooks/useLibraryListSort'
import { useAuthStore } from '../stores/auth'
import {
LibrariesContent,
@@ -17,22 +18,29 @@ import {
import type { LibraryPreview } from './librariesPageModel'
import type { Library } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import {
sortLibraryPreviewsByField,
type LibraryListSortField,
type LibraryListSortOrder,
} from '../utils/libraryListSort'
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
import type { LibraryTag } from '../utils/libraryTags'
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
import { partitionPreviewIDs } from '../utils/remoteEmby'
export function LibrariesPage() {
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
const [libraries, setLibraries] = useState<Library[]>([])
// 会话缓存命中时首屏即用缓存渲染(避免先闪一帧占位再被撑高),
// 这样 useScrollMemory 的布局期恢复才能在返回列表时立刻落到原位置。
const [libraries, setLibraries] = useState<Library[]>(() => peekLibraries() ?? [])
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
const { pinnedIds, togglePin } = usePinnedLibraries()
const libraryTags = useLibraryTags()
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(() => peekLibraries() === null)
const [repairing, setRepairing] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
const [repairMsg, setRepairMsg] = useState('')
const { field: sortField, order: sortOrder, setSort: setLibrarySort } = useLibraryListSort()
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
@@ -146,24 +154,20 @@ export function LibrariesPage() {
})
}, [libraries, libraryData])
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
const sortedPreviews = useMemo(
() => sortLibraryPreviewsByField(previews, pinnedIds, sortField, sortOrder),
[previews, pinnedIds, sortField, sortOrder],
)
const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
setLibrarySort(field, order)
}, [setLibrarySort])
const handleTogglePin = useCallback((libraryId: string) => {
void togglePin(libraryId)
}, [togglePin])
const handleManageTags = useCallback(() => {
void openManageLibraryTagsDialog({
tags: libraryTags.tags,
libraries,
saving: libraryTags.saving,
onCreate: libraryTags.createTag,
onRename: libraryTags.renameTag,
onRemove: libraryTags.removeTag,
onAssign: libraryTags.assignLibrary,
})
}, [libraries, libraryTags])
const [libraryTagsOpen, setLibraryTagsOpen] = useState(false)
const effectiveTagId = libraryTags.selectedTagId
const tagTabs = useMemo(
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
@@ -178,7 +182,7 @@ export function LibrariesPage() {
[taggedPreviews],
)
if (loading || pinnedLoading) {
if (loading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
}
@@ -191,6 +195,9 @@ export function LibrariesPage() {
repairMsg={repairMsg}
repairEpisodeArtwork={repairEpisodeArtwork}
repairing={repairing}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
onRepairRescrape={handleRepairRescrape}
onManageLibraries={handleManageLibraries}
@@ -203,10 +210,25 @@ export function LibrariesPage() {
onCreate={(name) => {
void libraryTags.createTag(name)
}}
onManage={handleManageTags}
onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving}
/>
{libraryTagsOpen && (
<ManageLibraryTagsDialogView
tags={libraryTags.tags}
libraries={libraries}
saving={libraryTags.saving}
onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag}
onReorder={libraryTags.reorderTags}
onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)}
/>
)}
{previews.length === 0 ? (
<LibrariesEmptyState isAdmin={isAdmin} />
) : taggedPreviews.length === 0 ? (
@@ -225,19 +247,17 @@ export function LibrariesPage() {
)
}
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */
/**
* 按选中标签过滤媒体库预览:复用共享的成员筛选(保持输入顺序),
* 这样排序下拉(含倒序)在标签页与「全部」页行为一致。
*/
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
if (!tagId || tagId === ALL_TAG_ID) return previews
const ordered = filterLibrariesByTag(
previews.map((preview) => ({ id: preview.library.id })),
tags,
tagId,
)
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
const out: LibraryPreview[] = []
for (const item of ordered) {
const preview = byId.get(item.id)
if (preview) out.push(preview)
const kept: LibraryPreview[] = []
for (const library of filterLibrariesByTag(previews.map((preview) => preview.library), tags, tagId)) {
const preview = byId.get(library.id)
if (preview) kept.push(preview)
}
return out
return kept
}
+47 -36
View File
@@ -3,7 +3,9 @@ import { Link } from 'react-router-dom'
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
import type { LibraryPreview } from './librariesPageModel'
@@ -18,6 +20,9 @@ export function LibrariesHeader({
repairMsg,
repairEpisodeArtwork,
repairing,
sortField,
sortOrder,
onSortChange,
onRepairEpisodeArtworkChange,
onRepairRescrape,
onManageLibraries,
@@ -28,6 +33,9 @@ export function LibrariesHeader({
repairMsg: string
repairEpisodeArtwork: boolean
repairing: boolean
sortField: LibraryListSortField
sortOrder: LibraryListSortOrder
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
onRepairEpisodeArtworkChange: (value: boolean) => void
onRepairRescrape: () => void
onManageLibraries: () => void
@@ -40,42 +48,45 @@ export function LibrariesHeader({
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
</p>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={onRepairEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
>
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link
to="/scraper/queue"
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
title="查看正在进行的刮削任务与进度"
>
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button
type="button"
onClick={onManageLibraries}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
管理媒体库
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
{isAdmin && (
<>
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={onRepairEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
>
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link
to="/scraper/queue"
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
title="查看正在进行的刮削任务与进度"
>
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button
type="button"
onClick={onManageLibraries}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
管理媒体库
</button>
</>
)}
</div>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import {
DEFAULT_LIBRARY_LIST_SORT_FIELD,
DEFAULT_LIBRARY_LIST_SORT_ORDER,
readLibraryListSort,
sortLibrariesByField,
sortLibraryPreviewsByField,
} from './libraryListSort.ts'
import type { Library } from '../types/index.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`libraryListSort: ${name}`)
}
function lib(id: string, name: string, sortOrder = 0): Library {
return { id, name, sort_order: sortOrder } as Library
}
const ordered = (libs: Library[]) => libs.map((item) => item.id).join(',')
// 首页默认就是「库名倒序」,所以没写过偏好时要拿到 name/desc。
const fallback = readLibraryListSort()
check(
'default preference is name descending',
fallback.field === 'name' && fallback.order === 'desc',
)
check(
'default constants stay in sync with the fallback',
DEFAULT_LIBRARY_LIST_SORT_FIELD === 'name' && DEFAULT_LIBRARY_LIST_SORT_ORDER === 'desc',
)
const alpha = [lib('a', '动画'), lib('b', '电影'), lib('c', '纪录片')]
// zh-CN 拼音升序是 电影(b) → 动画(a) → 纪录片(c)。
check(
'name descending is the default order',
ordered(sortLibrariesByField(alpha, [], 'name', 'desc')) === 'c,a,b',
)
check(
'name ascending reverses it',
ordered(sortLibrariesByField(alpha, [], 'name', 'asc')) === 'b,a,c',
)
const manual = [lib('a', '动画', 2), lib('b', '电影', 0), lib('c', '纪录片', 1)]
check(
'manual order ascending follows sort_order',
ordered(sortLibrariesByField(manual, [], 'sort_order', 'asc')) === 'b,c,a',
)
check(
'manual order descending flips sort_order',
ordered(sortLibrariesByField(manual, [], 'sort_order', 'desc')) === 'a,c,b',
)
// 置顶只决定分组:置顶库整体在前,组内仍然按当前字段/方向排。
check(
'pinned libraries always come first',
ordered(sortLibrariesByField(alpha, ['c'], 'name', 'desc')) === 'c,a,b',
)
check(
'pinned group is sorted by the active field too',
ordered(sortLibrariesByField(alpha, ['a', 'c'], 'name', 'desc')) === 'c,a,b',
)
check(
'pinned ids that no longer exist are ignored',
ordered(sortLibrariesByField(alpha, ['missing'], 'name', 'desc')) === 'c,a,b',
)
// 同名时按 sort_order 兜底,再按 id 兜底,保证顺序稳定。
const sameName = [lib('b', '动画', 1), lib('a', '动画', 1), lib('c', '动画', 0)]
check(
'equal names fall back to sort_order then id',
ordered(sortLibrariesByField(sameName, [], 'name', 'desc')) === 'c,a,b',
)
check('single library is returned as-is', sortLibrariesByField([lib('a', '动画')], [], 'name', 'desc').length === 1)
const previews = [
{ library: lib('a', '动画'), total: 1 },
{ library: lib('b', '电影'), total: 2 },
]
check(
'previews follow the library order',
sortLibraryPreviewsByField(previews, [], 'name', 'desc').map((item) => item.library.id).join(',') === 'a,b',
)
check(
'previews keep their payload after sorting',
sortLibraryPreviewsByField(previews, [], 'name', 'desc')[0].total === 1,
)
check(
'previews respect pinning',
sortLibraryPreviewsByField(previews, ['b'], 'name', 'desc').map((item) => item.library.id).join(',') === 'b,a',
)
console.log('libraryListSort.test.ts ok')
+122
View File
@@ -0,0 +1,122 @@
import type { Library } from '../types'
export type LibraryListSortField = 'name' | 'sort_order'
export type LibraryListSortOrder = 'asc' | 'desc'
export type LibraryListSortOption = {
id: LibraryListSortField
label: string
defaultOrder: LibraryListSortOrder
}
export const LIBRARY_LIST_SORT_OPTIONS: LibraryListSortOption[] = [
{ id: 'name', label: '库名', defaultOrder: 'desc' },
{ id: 'sort_order', label: '手动顺序', defaultOrder: 'asc' },
]
export const DEFAULT_LIBRARY_LIST_SORT_FIELD: LibraryListSortField = 'name'
export const DEFAULT_LIBRARY_LIST_SORT_ORDER: LibraryListSortOrder = 'desc'
const FIELD_KEY = 'mebox_libraries_sort_field'
const ORDER_KEY = 'mebox_libraries_sort_order'
export function readLibraryListSort(): { field: LibraryListSortField; order: LibraryListSortOrder } {
if (typeof window === 'undefined') {
return { field: DEFAULT_LIBRARY_LIST_SORT_FIELD, order: DEFAULT_LIBRARY_LIST_SORT_ORDER }
}
const rawField = window.localStorage.getItem(FIELD_KEY)
const rawOrder = window.localStorage.getItem(ORDER_KEY)
const field: LibraryListSortField =
rawField === 'name' || rawField === 'sort_order' ? rawField : DEFAULT_LIBRARY_LIST_SORT_FIELD
const order: LibraryListSortOrder =
rawOrder === 'asc' || rawOrder === 'desc'
? rawOrder
: field === 'name'
? DEFAULT_LIBRARY_LIST_SORT_ORDER
: 'asc'
return { field, order }
}
export function writeLibraryListSort(field: LibraryListSortField, order: LibraryListSortOrder): void {
if (typeof window === 'undefined') return
window.localStorage.setItem(FIELD_KEY, field)
window.localStorage.setItem(ORDER_KEY, order)
}
function compareNames(a: string, b: string, order: LibraryListSortOrder): number {
const cmp = a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' })
return order === 'asc' ? cmp : -cmp
}
function compareSortOrder(a: number, b: number, order: LibraryListSortOrder): number {
const cmp = a - b
return order === 'asc' ? cmp : -cmp
}
function librarySortKey(library: Library): { name: string; sortOrder: number } {
return {
name: (library.name || '').trim(),
sortOrder: library.sort_order ?? 0,
}
}
function compareLibraries(a: Library, b: Library, field: LibraryListSortField, order: LibraryListSortOrder): number {
const ka = librarySortKey(a)
const kb = librarySortKey(b)
if (field === 'name') {
const byName = compareNames(ka.name, kb.name, order)
if (byName !== 0) return byName
return compareSortOrder(ka.sortOrder, kb.sortOrder, 'asc') || a.id.localeCompare(b.id)
}
const byOrder = compareSortOrder(ka.sortOrder, kb.sortOrder, order)
if (byOrder !== 0) return byOrder
return compareNames(ka.name, kb.name, 'asc') || a.id.localeCompare(b.id)
}
/**
* 置顶组始终在前;组内按 field/order 排序。
* pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。
*
* 首页与 /libraries 共用这份排序,保证两处的媒体库顺序一致。
*/
export function sortLibrariesByField(
libraries: Library[],
pinnedIds: string[],
field: LibraryListSortField,
order: LibraryListSortOrder,
): Library[] {
if (libraries.length < 2) return libraries
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index]))
const pinned: Library[] = []
const rest: Library[] = []
for (const library of libraries) {
if (pinnedRank.has(library.id)) pinned.push(library)
else rest.push(library)
}
const byField = (a: Library, b: Library) => compareLibraries(a, b, field, order)
pinned.sort(byField)
rest.sort(byField)
return [...pinned, ...rest]
}
/** 预览列表按库排序:先排库,再按排好的库顺序取回对应预览。 */
export function sortLibraryPreviewsByField<T extends { library: Library }>(
items: T[],
pinnedIds: string[],
field: LibraryListSortField,
order: LibraryListSortOrder,
): T[] {
if (items.length < 2) return items
const ordered = sortLibrariesByField(
items.map((item) => item.library),
pinnedIds,
field,
order,
)
const byId = new Map(items.map((item) => [item.library.id, item]))
return ordered
.map((library) => byId.get(library.id))
.filter((item): item is T => item !== undefined)
}
+74 -2
View File
@@ -1,12 +1,17 @@
import {
ALL_TAG_ID,
assignLibrariesToTag,
attachLibrariesToTag,
attachLibraryToTag,
buildLibraryTagTabs,
dedupeLibraryTags,
detachLibrariesFromTag,
detachLibraryFromTag,
filterLibrariesByTag,
filterLibrariesForTagging,
normalizeLibraryTags,
normalizeTagName,
reorderLibraryTags,
resolveSelectedTagId,
tagNameError,
} from './libraryTags.ts'
@@ -45,10 +50,10 @@ check('tag tab keeps creation order', tabs[1].name === '动画' && tabs[2].name
check('tag count ignores unavailable libraries', tabs[1].count === 1)
check(
'filtering keeps tag order and drops unknown ids',
'filtering drops unknown ids and keeps input order',
filterLibrariesByTag(libs, [{ name: '动画', library_ids: ['lib-c', 'missing', 'lib-a'] }], '动画')
.map((lib) => lib.id)
.join(',') === 'lib-c,lib-a',
.join(',') === 'lib-a,lib-c',
)
check('全部 returns every library in original order', filterLibrariesByTag(libs, [], ALL_TAG_ID).length === 3)
check('unknown tag yields no libraries', filterLibrariesByTag(libs, [], 'nope').length === 0)
@@ -84,6 +89,46 @@ const detached = detachLibraryFromTag(
)
check('detach removes only the requested library', detached[0].library_ids.join(',') === 'b')
const batchAttached = attachLibrariesToTag(
[
{ name: '动画', library_ids: ['a', 'x'] },
{ name: '电影', library_ids: ['b'] },
],
['b', 'x', 'b', ' missing '],
'动画',
)
check('batch attach moves every id out of its old tag', batchAttached[1].library_ids.length === 0)
check('batch attach keeps order and dedupes ids', batchAttached[0].library_ids.join(',') === 'a,b,x,missing')
check('batch attach ignores unknown target tag', attachLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '不存在')[0].library_ids.length === 0)
check('batch attach with no ids is a no-op', attachLibrariesToTag([{ name: '动画', library_ids: ['a'] }], [], '动画')[0].library_ids.join(',') === 'a')
const batchDetached = detachLibrariesFromTag(
[
{ name: '动画', library_ids: ['a', 'b'] },
{ name: '电影', library_ids: ['b', 'c'] },
],
['b', 'c'],
)
check('batch detach removes ids from every tag', batchDetached[0].library_ids.join(',') === 'a')
check('batch detach clears emptied tags', batchDetached[1].library_ids.length === 0)
check(
'assign with empty tag name clears everything',
assignLibrariesToTag([{ name: '动画', library_ids: ['a'] }], ['a'], '')[0].library_ids.length === 0,
)
check(
'assign with a tag routes to attach',
assignLibrariesToTag([{ name: '动画', library_ids: [] }], ['a'], '动画')[0].library_ids.join(',') === 'a',
)
const searchable = [
{ id: 'lib-a', name: '电影库', path: '/media/movies', type: 'movie' },
{ id: 'lib-b', name: 'TV Shows', path: '/media/tv', type: 'tv' },
]
check('tagging search matches name', filterLibrariesForTagging(searchable as never, '电影').length === 1)
check('tagging search matches path', filterLibrariesForTagging(searchable as never, 'media/tv').length === 1)
check('tagging search matches type case-insensitively', filterLibrariesForTagging(searchable as never, 'MOVIE').length === 1)
check('tagging search with blank keyword returns all', filterLibrariesForTagging(searchable as never, ' ').length === 2)
check('blank name is rejected', tagNameError(' ', []) !== '')
check('duplicate name is rejected', tagNameError('动画', [{ name: '动画', library_ids: [] }]) !== '')
check(
@@ -91,4 +136,31 @@ check(
tagNameError('动画', [{ name: '动画', library_ids: [] }], '动画') === '',
)
const reorderable = [
{ name: '动画', library_ids: [] },
{ name: '电影', library_ids: [] },
{ name: '音乐', library_ids: [] },
]
check(
'reorder moves a tag to the requested slot',
reorderLibraryTags(reorderable, ['电影', '动画', '音乐']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
)
check(
'reorder can move a tag to the front',
reorderLibraryTags(reorderable, ['音乐', '动画', '电影']).map((tag) => tag.name).join(',') === '音乐,动画,电影',
)
check(
'names missing from the order keep relative order at the end',
reorderLibraryTags(reorderable, ['电影']).map((tag) => tag.name).join(',') === '电影,动画,音乐',
)
check(
'empty order leaves tags untouched',
reorderLibraryTags(reorderable, []).map((tag) => tag.name).join(',') === '动画,电影,音乐',
)
check('reorder ignores extra unknown names', reorderLibraryTags(reorderable, ['不存在', '音乐']).map((tag) => tag.name).join(',') === '音乐,动画,电影')
check(
'reordering with fewer than two tags is a no-op',
reorderLibraryTags([{ name: '动画', library_ids: [] }], []).map((tag) => tag.name).join(',') === '动画',
)
console.log('libraryTags.test.ts ok')
+86 -11
View File
@@ -164,8 +164,9 @@ export function isLibraryInTag(tags: LibraryTag[], tagId: string, libraryId: str
}
/**
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回(保持原有置顶/手动排序),
* 否则只保留该标签下的库,并保持标签里记录的顺序。
* 按标签过滤媒体库:`tagId` 为「全部」时原样返回。
* 只做成员筛选、保持输入顺序,展示顺序(置顶、排序下拉等)由调用方决定;
* 标签里记录的 library_ids 顺序只表示归属,不作为展示排序。
*/
export function filterLibrariesByTag<T extends { id: string }>(
libraries: T[],
@@ -173,15 +174,9 @@ export function filterLibrariesByTag<T extends { id: string }>(
tagId: string,
): T[] {
if (!tagId || tagId === ALL_TAG_ID) return libraries
const ids = tagLibraryIds(tags, tagId)
if (ids.length === 0) return []
const byId = new Map(libraries.map((lib) => [lib.id, lib]))
const out: T[] = []
for (const id of ids) {
const lib = byId.get(id)
if (lib) out.push(lib)
}
return out
const ids = new Set(tagLibraryIds(tags, tagId))
if (ids.size === 0) return []
return libraries.filter((lib) => ids.has(lib.id))
}
/** 生成标签栏模型:首项固定为「全部」,其后按标签顺序追加。 */
@@ -202,6 +197,22 @@ export function buildLibraryTagTabs(libraries: Library[], tags: LibraryTag[]): L
return tabs
}
/**
* 按给定标签名顺序重排标签栏:`orderedNames` 是期望的标签名顺序(通常来自拖拽后的顺序)。
* 未出现在顺序里的标签(例如新建、旧数据并发写入)保持原相对顺序,追加到末尾,绝不丢失。
*/
export function reorderLibraryTags(tags: LibraryTag[], orderedNames: string[]): LibraryTag[] {
if (tags.length < 2) return tags
const rank = new Map(orderedNames.map((name, index) => [name, index]))
const ranked: LibraryTag[] = []
const rest: LibraryTag[] = []
for (const tag of tags) {
;(rank.has(tag.name) ? ranked : rest).push(tag)
}
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
return [...ranked, ...rest]
}
/** 选中的标签是否仍存在;被删除或被过滤掉时回落到「全部」。 */
export function resolveSelectedTagId(tags: LibraryTag[], selectedId: string): string {
if (!selectedId || selectedId === ALL_TAG_ID) return ALL_TAG_ID
@@ -232,3 +243,67 @@ export function detachLibraryFromTag(tags: LibraryTag[], libraryId: string, tagN
tag.name === tagName ? { ...tag, library_ids: tag.library_ids.filter((id) => id !== libraryId) } : tag,
)
}
/**
* 批量把一个或多个媒体库挂到标签下:先从各自原标签移除,再按传入顺序追加,
* 保证「一库一标签」。整批只产生一次写入。
*/
export function attachLibrariesToTag(
tags: LibraryTag[],
libraryIds: string[],
tagName: string,
): LibraryTag[] {
const target = findTagIdByName(tags, tagName)
const moving = normalizeLibraryIds(libraryIds)
if (!target || moving.length === 0) return tags
const movingSet = new Set(moving)
const next = tags.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => !movingSet.has(id)),
}))
return next.map((tag) =>
tag.name === target ? { ...tag, library_ids: [...tag.library_ids, ...moving] } : tag,
)
}
/** 批量把媒体库移出所有标签(即变为「未分类」)。 */
export function detachLibrariesFromTag(tags: LibraryTag[], libraryIds: string[]): LibraryTag[] {
const moving = new Set(normalizeLibraryIds(libraryIds))
if (moving.size === 0) return tags
return tags.map((tag) => ({
...tag,
library_ids: tag.library_ids.filter((id) => !moving.has(id)),
}))
}
/** 批量归类:tagName 为空表示移出所有标签。 */
export function assignLibrariesToTag(
tags: LibraryTag[],
libraryIds: string[],
tagName: string,
): LibraryTag[] {
return tagName ? attachLibrariesToTag(tags, libraryIds, tagName) : detachLibrariesFromTag(tags, libraryIds)
}
/** 去掉空白与重复,保留首次出现的顺序。 */
export function normalizeLibraryIds(libraryIds: unknown): string[] {
if (!Array.isArray(libraryIds)) return []
const seen = new Set<string>()
const out: string[] = []
for (const raw of libraryIds) {
const trimmed = String(raw ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
return out
}
/** 媒体库列表的模糊匹配(名称 / 路径 / 类型),供标签管理里的搜索框使用。 */
export function filterLibrariesForTagging(libraries: Library[], keyword: string): Library[] {
const needle = keyword.trim().toLowerCase()
if (!needle) return libraries
return libraries.filter((lib) =>
`${lib.name} ${lib.path} ${lib.type}`.toLowerCase().includes(needle),
)
}
-28
View File
@@ -67,31 +67,3 @@ export function togglePinnedLibraryId(current: string[], id: string): string[] {
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
return pinnedIds.includes(id)
}
export function sortByPinnedIds<T extends { id: string }>(items: T[], pinnedIds: string[]): T[] {
if (pinnedIds.length === 0) return items
const rank = new Map(pinnedIds.map((pinnedId, index) => [pinnedId, index]))
return [...items].sort((a, b) => {
const aRank = rank.get(a.id)
const bRank = rank.get(b.id)
const aPinned = aRank !== undefined
const bPinned = bRank !== undefined
if (aPinned !== bPinned) return aPinned ? -1 : 1
if (aPinned && bPinned) return aRank - bRank
return 0
})
}
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
if (pinnedIds.length === 0) return items
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
return [...items].sort((a, b) => {
const aRank = rank.get(a.library.id)
const bRank = rank.get(b.library.id)
const aPinned = aRank !== undefined
const bPinned = bRank !== undefined
if (aPinned !== bPinned) return aPinned ? -1 : 1
if (aPinned && bPinned) return aRank - bRank
return 0
})
}