Compare commits

...

6 Commits

Author SHA1 Message Date
github-actions[bot] c171b38155 chore: bump version to 0.0.58 [skip ci] 2026-08-30 14:32:56 +00:00
truewhile 89d7a6cbb2 优化 2026-08-30 22:32:39 +08:00
github-actions[bot] 85918d1196 chore: bump version to 0.0.57 [skip ci] 2026-08-30 13:41:17 +00:00
truewhile fa9307e2e1 优化 2026-08-30 21:40:58 +08:00
github-actions[bot] 5ab18c2725 chore: bump version to 0.0.56 [skip ci] 2026-08-30 12:05:41 +00:00
truewhile a4a2bde1a4 优化 2026-08-30 20:05:27 +08:00
20 changed files with 1095 additions and 53 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.55
0.0.58
+73 -11
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
@@ -27,6 +28,9 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc {
if svc.Sessions != nil {
svc.Sessions.ApplyToUsers(c.Request.Context(), users)
}
for i := range users {
users[i].PopulateComputedFields()
}
c.JSON(http.StatusOK, users)
}
}
@@ -188,19 +192,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if req.IsActive {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
} else {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
if req.IsActive {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
} else {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
}
type adminUpdateUserLibrariesReq struct {
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
}
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.Param("id")
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var rawJSON string
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
var cleanIDs []string
for _, id := range *req.AllowedLibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed != "" {
cleanIDs = append(cleanIDs, trimmed)
}
}
if len(cleanIDs) > 0 {
data, err := json.Marshal(cleanIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rawJSON = string(data)
}
}
updates := map[string]any{"allowed_library_ids": rawJSON}
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil || updated == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
}
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
+88 -2
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -43,7 +44,92 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
t.Fatal("recent realtime user should not be deleted")
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
t.Fatal("recent realtime user should not be deleted")
}
}
func TestUpdateUserLibraries(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
user := model.User{Base: model.Base{ID: "u1"}, Username: "alice", PasswordHash: "x", Role: "user", IsActive: true}
lib1 := model.Library{Base: model.Base{ID: "lib-1"}, Name: "电影", Type: "movie", Path: "/movie"}
lib2 := model.Library{Base: model.Base{ID: "lib-2"}, Name: "剧集", Type: "tv", Path: "/tv"}
lib3 := model.Library{Base: model.Base{ID: "lib-3"}, Name: "动漫", Type: "anime", Path: "/anime"}
if err := repos.DB.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&[]model.Library{lib1, lib2, lib3}).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos}
router := gin.New()
router.PATCH("/admin/users/:id/libraries", updateUserLibrariesHandler(svc))
// 1. 设置限制为 lib-1 和 lib-2
body := `{"allowed_library_ids":["lib-1","lib-2"]}`
req := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
found, err := repos.User.FindByID(t.Context(), "u1")
if err != nil || found == nil {
t.Fatal("user not found")
}
allowed := found.DecodeAllowedLibraryIDs()
if len(allowed) != 2 || allowed[0] != "lib-1" || allowed[1] != "lib-2" {
t.Fatalf("expected [lib-1, lib-2], got %v", allowed)
}
// 验证可见性
vis := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
if len(vis.AllowedLibraryIDs) != 2 {
t.Fatalf("expected 2 allowed libraries, got %v", vis.AllowedLibraryIDs)
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib1, vis) {
t.Fatal("lib1 should be visible")
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib2, vis) {
t.Fatal("lib2 should be visible")
}
if service.LibraryVisibleForUser(t.Context(), repos, lib3, vis) {
t.Fatal("lib3 should not be visible")
}
// 2. 清空限制,恢复全部可见
bodyEmpty := `{"allowed_library_ids":[]}`
reqEmpty := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(bodyEmpty))
reqEmpty.Header.Set("Content-Type", "application/json")
wEmpty := httptest.NewRecorder()
router.ServeHTTP(wEmpty, reqEmpty)
if wEmpty.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", wEmpty.Code, wEmpty.Body.String())
}
foundReset, _ := repos.User.FindByID(t.Context(), "u1")
if len(foundReset.DecodeAllowedLibraryIDs()) != 0 {
t.Fatalf("expected nil or empty, got %v", foundReset.DecodeAllowedLibraryIDs())
}
visReset := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
if len(visReset.AllowedLibraryIDs) != 0 {
t.Fatalf("expected no library restrictions, got %v", visReset.AllowedLibraryIDs)
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib3, visReset) {
t.Fatal("lib3 should now be visible")
}
}
+1
View File
@@ -89,6 +89,7 @@ func meHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
u.PopulateComputedFields()
c.JSON(http.StatusOK, u)
}
}
+1
View File
@@ -96,6 +96,7 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
admin.PATCH("/users/:id/status", updateUserStatusHandler(svc))
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
+8 -1
View File
@@ -32,7 +32,14 @@ func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.M
return visibility
}
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
profileAllowed := profileAllowedLibraryIDs(*profile)
if len(profileAllowed) > 0 {
if len(visibility.AllowedLibraryIDs) > 0 {
visibility.AllowedLibraryIDs = service.IntersectStrings(visibility.AllowedLibraryIDs, profileAllowed)
} else {
visibility.AllowedLibraryIDs = profileAllowed
}
}
if !visibility.IncludeNSFW {
visibility.HiddenLibraryIDs = service.AdultLibraryIDs(c.Request.Context(), svc.Repo)
} else {
+36 -1
View File
@@ -1,6 +1,10 @@
package model
import "time"
import (
"encoding/json"
"strings"
"time"
)
// User 是本地账户。第一个注册的管理员(或种子管理员)获得 "admin" 角色;
// 其他所有用户默认为 "user"。
@@ -17,6 +21,10 @@ type User struct {
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
IsActive bool `gorm:"default:true" json:"is_active"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。
// 为空时代表不限制(全库可访问)。
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
@@ -31,3 +39,30 @@ type User struct {
RealtimeOnline bool `gorm:"-" json:"realtime_online,omitempty"`
RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"`
}
// DecodeAllowedLibraryIDs 解析 AllowedLibraryIDs 字段。
func (u *User) DecodeAllowedLibraryIDs() []string {
if u == nil || strings.TrimSpace(u.AllowedLibraryIDs) == "" {
return nil
}
var ids []string
if err := json.Unmarshal([]byte(u.AllowedLibraryIDs), &ids); err != nil {
return nil
}
var out []string
for _, id := range ids {
trimmed := strings.TrimSpace(id)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。
func (u *User) PopulateComputedFields() {
if u == nil {
return
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
}
+46 -2
View File
@@ -50,7 +50,20 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container,
visibility.IncludeNSFW = false
}
visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW)
if userID == "" || repo.PlayProfile == nil {
if userID == "" {
return visibility
}
if repo.User != nil {
user, err := repo.User.FindByID(ctx, userID)
if err == nil && user != nil && user.Role != "admin" {
if userAllowed := user.DecodeAllowedLibraryIDs(); len(userAllowed) > 0 {
visibility.AllowedLibraryIDs = userAllowed
}
}
}
if repo.PlayProfile == nil {
return visibility
}
rows, err := repo.PlayProfile.ListByUser(ctx, userID)
@@ -62,13 +75,44 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container,
continue
}
visibility.IncludeNSFW = visibility.IncludeNSFW && row.AllowAdult
visibility.AllowedLibraryIDs = DecodeAllowedLibraryIDs(row.AllowedLibraryIDs)
profileAllowed := DecodeAllowedLibraryIDs(row.AllowedLibraryIDs)
if len(profileAllowed) > 0 {
if len(visibility.AllowedLibraryIDs) > 0 {
visibility.AllowedLibraryIDs = IntersectStrings(visibility.AllowedLibraryIDs, profileAllowed)
} else {
visibility.AllowedLibraryIDs = profileAllowed
}
}
visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW)
break
}
return visibility
}
// IntersectStrings 计算两个字符串切片的交集。
func IntersectStrings(a, b []string) []string {
if len(a) == 0 {
return b
}
if len(b) == 0 {
return a
}
set := make(map[string]struct{}, len(b))
for _, s := range b {
set[s] = struct{}{}
}
var out []string
for _, s := range a {
if _, ok := set[s]; ok {
out = append(out, s)
}
}
if len(out) == 0 {
return []string{"__no_access__"}
}
return out
}
// DecodeAllowedLibraryIDs normalises a PlayProfile allowed-library JSON string.
func DecodeAllowedLibraryIDs(raw string) []string {
if strings.TrimSpace(raw) == "" {
+3
View File
@@ -79,6 +79,9 @@ export const adminAPI = {
setUserStatus: (id: string, isActive: boolean) =>
api.patch<User>(`/admin/users/${id}/status`, { is_active: isActive }).then((r) => r.data),
updateUserLibraries: (id: string, allowedLibraryIDs: string[] | null) =>
api.patch<User>(`/admin/users/${id}/libraries`, { allowed_library_ids: allowedLibraryIDs }).then((r) => r.data),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
listSettings: () => api.get<Setting[]>('/admin/settings').then((r) => r.data),
@@ -0,0 +1,330 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
Check,
CheckSquare,
Film,
FolderLock,
Layers,
Loader2,
Square,
Tv,
X,
} from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, User } from '../types'
import { libraryDisplayPath } from '../pages/libraryDisplayModel'
type AdminUserLibrariesDialogProps = {
user: User | null
isOpen: boolean
onClose: () => void
onSaved: (updatedUser: User) => void
}
export function AdminUserLibrariesDialog({
user,
isOpen,
onClose,
onSaved,
}: AdminUserLibrariesDialogProps) {
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [libraries, setLibraries] = useState<Library[]>([])
const [mode, setMode] = useState<'all' | 'custom'>('all')
const [selectedIDs, setSelectedIDs] = useState<Set<string>>(new Set())
useEffect(() => {
if (!isOpen || !user) return
setLoading(true)
libraryAPI
.list({ includeHidden: true })
.then((libs) => {
setLibraries(libs ?? [])
})
.catch(() => {
toast.error('加载媒体库列表失败')
})
.finally(() => {
setLoading(false)
})
const initialIDs = user.allowed_library_ids ?? []
if (initialIDs.length > 0) {
setMode('custom')
setSelectedIDs(new Set(initialIDs))
} else {
setMode('all')
setSelectedIDs(new Set())
}
}, [isOpen, user])
if (!isOpen || !user) return null
const toggleLibrary = (libID: string) => {
setSelectedIDs((prev) => {
const next = new Set(prev)
if (next.has(libID)) {
next.delete(libID)
} else {
next.add(libID)
}
return next
})
}
const handleSelectAll = () => {
setSelectedIDs(new Set(libraries.map((l) => l.id)))
}
const handleSelectNone = () => {
setSelectedIDs(new Set())
}
const handleSave = async () => {
setSaving(true)
try {
let payloadIDs: string[] | null = null
if (mode === 'custom') {
const arr = Array.from(selectedIDs)
// 如果勾选了全部,或者勾选为空
if (arr.length === 0) {
toast.error('请至少选择一个媒体库,或切换为“允许访问全部媒体库”')
setSaving(false)
return
}
if (arr.length < libraries.length) {
payloadIDs = arr
}
}
const updated = await adminAPI.updateUserLibraries(user.id, payloadIDs)
toast.success(
payloadIDs && payloadIDs.length > 0
? `已成功配置用户【${user.username}】仅可访问 ${payloadIDs.length} 个媒体库`
: `已恢复用户【${user.username}】全库访问权限`,
)
onSaved(updated)
onClose()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'保存配置失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm animate-in fade-in-0"
onClick={onClose}
>
<div
className="w-full max-w-xl overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl transition-all"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-sand-100 p-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
<FolderLock size={20} />
</div>
<div>
<h3 className="font-display text-lg font-bold text-ink-600">
配置媒体库访问权限
</h3>
<p className="text-xs text-sand-500">
用户:<span className="font-semibold text-ink-600">{user.username}</span>
{user.role === 'admin' && (
<span className="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-600 border border-amber-200">
管理员拥有所有库访问权限
</span>
)}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-sand-500 hover:bg-sand-100 hover:text-ink-600 transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-5 space-y-4 max-h-[65vh] overflow-y-auto">
{/* 模式选择 */}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setMode('all')}
className={`flex items-center gap-3 rounded-2xl border p-3.5 text-left transition-all ${
mode === 'all'
? 'border-brand-400 bg-brand-50/70 text-brand-800 shadow-sm ring-1 ring-brand-400'
: 'border-sand-200 bg-white text-ink-600 hover:border-sand-300 hover:bg-sand-50/50'
}`}
>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${
mode === 'all' ? 'bg-brand-500 text-white' : 'bg-sand-100 text-sand-500'
}`}
>
<Layers size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-bold">全部媒体库 (默认)</div>
<div className="text-[11px] text-sand-500">用户可访问系统所有已建媒体库</div>
</div>
</button>
<button
type="button"
onClick={() => {
setMode('custom')
if (selectedIDs.size === 0) {
setSelectedIDs(new Set(libraries.map((l) => l.id)))
}
}}
className={`flex items-center gap-3 rounded-2xl border p-3.5 text-left transition-all ${
mode === 'custom'
? 'border-brand-400 bg-brand-50/70 text-brand-800 shadow-sm ring-1 ring-brand-400'
: 'border-sand-200 bg-white text-ink-600 hover:border-sand-300 hover:bg-sand-50/50'
}`}
>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${
mode === 'custom' ? 'bg-brand-500 text-white' : 'bg-sand-100 text-sand-500'
}`}
>
<FolderLock size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-bold">指定可访问媒体库</div>
<div className="text-[11px] text-sand-500">仅允许访问管理员勾选的媒体库</div>
</div>
</button>
</div>
{/* 媒体库列表 */}
{mode === 'custom' && (
<div className="space-y-2 pt-2 border-t border-sand-100 animate-in fade-in-0 duration-200">
<div className="flex items-center justify-between text-xs text-sand-500 px-1">
<span>
已选择{' '}
<strong className="text-brand-600">{selectedIDs.size}</strong> /{' '}
{libraries.length} 个媒体库
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSelectAll}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-brand-600 hover:text-brand-700"
>
<CheckSquare size={13} />
全选
</button>
<span>·</span>
<button
type="button"
onClick={handleSelectNone}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-sand-500 hover:text-sand-700"
>
<Square size={13} />
全不选
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12 text-sand-500 text-xs gap-2">
<Loader2 size={16} className="animate-spin text-brand-500" />
正在加载媒体库…
</div>
) : libraries.length === 0 ? (
<div className="py-10 text-center text-xs text-sand-500 bg-sand-50/50 rounded-2xl">
暂无可用媒体库
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{libraries.map((lib) => {
const isChecked = selectedIDs.has(lib.id)
const isTv =
lib.type === 'tv' || lib.type === 'anime' || lib.type === 'variety'
return (
<div
key={lib.id}
onClick={() => toggleLibrary(lib.id)}
className={`group flex cursor-pointer items-center justify-between rounded-xl border p-3 transition-all ${
isChecked
? 'border-brand-300 bg-brand-50/40 shadow-xs'
: 'border-sand-200 bg-white hover:border-sand-300 hover:bg-sand-50/40'
}`}
>
<div className="flex min-w-0 items-center gap-2.5">
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
isChecked
? 'bg-brand-500 text-white'
: 'bg-sand-100 text-sand-500 group-hover:text-sand-700'
}`}
>
{isTv ? <Tv size={15} /> : <Film size={15} />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-ink-600">
{lib.name}
</p>
<p
className="truncate text-[10px] text-sand-500"
title={lib.path}
>
{lib.type} · {libraryDisplayPath(lib.path)}
</p>
</div>
</div>
<div
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors ${
isChecked
? 'border-brand-500 bg-brand-500 text-white'
: 'border-sand-300 bg-white group-hover:border-sand-400'
}`}
>
{isChecked && <Check size={12} className="stroke-[3]" />}
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2.5 border-t border-sand-100 bg-sand-50/60 px-5 py-3.5">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-sand-200 bg-white px-4 py-2 text-xs font-semibold text-ink-600 hover:bg-sand-100 transition-colors"
>
取消
</button>
<button
type="button"
disabled={saving || loading}
onClick={handleSave}
className="neon-button inline-flex items-center gap-2 px-5 py-2 text-xs font-semibold disabled:opacity-50"
>
{saving && <Loader2 size={13} className="animate-spin" />}
<span>保存配置</span>
</button>
</div>
</div>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, ArrowUpDown, Check, Shuffle } from 'lucide-react'
import {
SORT_OPTIONS,
type SortField,
type SortOption,
type SortOrder,
} from '../utils/mediaSort'
type MediaSortDropdownProps = {
value: SortField
order: SortOrder
onChange: (field: SortField, order: SortOrder) => void
onReshuffle?: () => void
className?: string
}
export function MediaSortDropdown({
value,
order,
onChange,
onReshuffle,
className = '',
}: MediaSortDropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const currentOption = SORT_OPTIONS.find((opt) => opt.id === value) ?? 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: SortOption) => {
if (option.id === 'random') {
if (value === 'random') {
onReshuffle?.()
} else {
onChange('random', 'desc')
}
setIsOpen(false)
return
}
if (value === option.id) {
// 再次点击相同项:切换升降序
const nextOrder: SortOrder = order === 'asc' ? 'desc' : 'asc'
onChange(option.id, nextOrder)
} 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"
title="更改排序方式"
>
<ArrowUpDown size={14} className="text-sand-500" />
<span>排序: {currentOption.label}</span>
{value === 'random' ? (
<Shuffle size={12} className="text-brand-600" />
) : order === 'asc' ? (
<ArrowUp size={13} className="text-brand-600 font-bold" />
) : (
<ArrowDown size={13} className="text-brand-600 font-bold" />
)}
</button>
{isOpen && (
<div className="absolute right-0 z-50 mt-1.5 w-52 origin-top-right 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">
<div className="px-2.5 py-1.5 text-[11px] font-bold text-sand-500 border-b border-sand-100">
排序方式
</div>
<div className="max-h-80 overflow-y-auto py-1 space-y-0.5">
{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 text-brand-700 font-semibold'
: '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">
{option.id === 'random' ? (
<Shuffle size={13} />
) : order === 'asc' ? (
<ArrowUp size={14} className="stroke-[2.5]" />
) : (
<ArrowDown size={14} className="stroke-[2.5]" />
)}
</div>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
}
+14
View File
@@ -5,6 +5,7 @@ import { adminAPI } from '../api/admin'
import type { User } from '../types'
import { confirmAction } from '../components/confirmAction'
import { requestPassword } from '../components/requestPassword'
import { AdminUserLibrariesDialog } from '../components/AdminUserLibrariesDialog'
import { AdminUsersForm } from './AdminUsersForm'
import { AdminUsersTable } from './AdminUsersTable'
@@ -15,6 +16,8 @@ export function AdminUsersPanel() {
const [editingID, setEditingID] = useState<string | null>(null)
const [editingUsername, setEditingUsername] = useState('')
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState<User | null>(null)
const refresh = async () => {
setUsers(await adminAPI.listUsers())
}
@@ -148,9 +151,20 @@ export function AdminUsersPanel() {
onCancelEdit={() => setEditingID(null)}
onStartEdit={startEdit}
onResetPassword={resetPassword}
onConfigureLibraries={(u) => setConfiguringLibrariesUser(u)}
onToggleStatus={toggleStatus}
onDeleteUser={deleteUser}
/>
<AdminUserLibrariesDialog
user={configuringLibrariesUser}
isOpen={Boolean(configuringLibrariesUser)}
onClose={() => setConfiguringLibrariesUser(null)}
onSaved={async (updated) => {
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)))
await refresh()
}}
/>
</div>
)
}
+43 -1
View File
@@ -1,4 +1,14 @@
import { KeyRound, Loader2, Pencil, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
import {
FolderLock,
KeyRound,
Loader2,
Pencil,
ShieldCheck,
Trash2,
UserCheck,
UserX,
X,
} from 'lucide-react'
import type { User } from '../types'
@@ -12,6 +22,7 @@ type AdminUsersTableProps = {
onCancelEdit: () => void
onStartEdit: (user: User) => void
onResetPassword: (user: User) => void
onConfigureLibraries: (user: User) => void
onToggleStatus: (user: User) => void
onDeleteUser: (user: User) => void
}
@@ -26,6 +37,7 @@ export function AdminUsersTable({
onCancelEdit,
onStartEdit,
onResetPassword,
onConfigureLibraries,
onToggleStatus,
onDeleteUser,
}: AdminUsersTableProps) {
@@ -36,6 +48,7 @@ export function AdminUsersTable({
<tr>
<th className="py-2">用户名</th>
<th>角色</th>
<th>媒体库权限</th>
<th>状态</th>
<th>权限说明</th>
<th>最近登录</th>
@@ -60,6 +73,27 @@ export function AdminUsersTable({
)}
</td>
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
<td>
{u.role === 'admin' ? (
<span className="inline-flex items-center rounded-full bg-sand-100 px-2.5 py-0.5 text-xs text-sand-600 font-medium">
全库 (管理员)
</span>
) : !u.allowed_library_ids || u.allowed_library_ids.length === 0 ? (
<span className="inline-flex items-center rounded-full bg-green-50 border border-green-200/80 px-2.5 py-0.5 text-xs font-medium text-green-700">
全部媒体库 (默认)
</span>
) : (
<button
type="button"
onClick={() => onConfigureLibraries(u)}
className="inline-flex items-center gap-1 rounded-full bg-brand-50 border border-brand-300 px-2.5 py-0.5 text-xs font-semibold text-brand-700 hover:bg-brand-100/70 transition-colors"
title="点击修改媒体库访问权限"
>
<FolderLock size={12} />
<span>已指定 {u.allowed_library_ids.length} 个库</span>
</button>
)}
</td>
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
{u.is_active ? '正常' : '已禁用'}
</td>
@@ -92,11 +126,19 @@ export function AdminUsersTable({
) : (
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
title="重命名用户"
onClick={() => onStartEdit(u)}
>
<Pencil size={12} />
</button>
)}
<button
className="rounded-lg border border-brand-400/40 px-2 py-1 text-xs text-brand-600 hover:bg-brand-400/10"
title="配置媒体库访问权限"
onClick={() => onConfigureLibraries(u)}
>
<FolderLock size={12} />
</button>
<button
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
title="重置密码"
+71 -5
View File
@@ -1,10 +1,17 @@
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { historyAPI } from '../api/history'
import type { Media } from '../types'
import { useAuthStore } from '../stores/auth'
import type { SeriesCard } from '../utils/groupSeries'
import {
sortMediaList,
sortSeriesList,
type SortField,
type SortOrder,
} from '../utils/mediaSort'
import { LibraryPageDialogs } from './LibraryPageDialogs'
import { LibraryPageHeader } from './LibraryPageHeader'
import { LibraryMediaSections } from './LibraryMediaSections'
@@ -25,6 +32,53 @@ export function LibraryPage() {
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
const [manualMovie, setManualMovie] = useState<Media | null>(null)
// 排序状态(支持按库记忆)
const [sortField, setSortField] = useState<SortField>(() => {
const saved = localStorage.getItem(`mmtl_lib_sort_field_${id}`) || localStorage.getItem('mmtl_lib_sort_field')
return (saved as SortField) || 'title'
})
const [sortOrder, setSortOrder] = useState<SortOrder>(() => {
const saved = localStorage.getItem(`mmtl_lib_sort_order_${id}`) || localStorage.getItem('mmtl_lib_sort_order')
return (saved as SortOrder) || 'asc'
})
const [randomSeed, setRandomSeed] = useState(() => Date.now())
const [historyMap, setHistoryMap] = useState<Map<string, string>>(new Map())
useEffect(() => {
historyAPI
.list(1000)
.then((historyItems) => {
const map = new Map<string, string>()
for (const item of historyItems ?? []) {
if (item.media_id && item.watched_at) {
if (!map.has(item.media_id) || new Date(item.watched_at) > new Date(map.get(item.media_id)!)) {
map.set(item.media_id, item.watched_at)
}
}
}
setHistoryMap(map)
})
.catch(() => {})
}, [])
const handleSortChange = (field: SortField, order: SortOrder) => {
setSortField(field)
setSortOrder(order)
if (id) {
localStorage.setItem(`mmtl_lib_sort_field_${id}`, field)
localStorage.setItem(`mmtl_lib_sort_order_${id}`, order)
}
localStorage.setItem('mmtl_lib_sort_field', field)
localStorage.setItem('mmtl_lib_sort_order', order)
if (field === 'random') {
setRandomSeed(Date.now())
}
}
const handleReshuffle = () => {
setRandomSeed(Date.now())
}
// 剧集模式:选中某个剧集后展开详情
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
@@ -43,6 +97,14 @@ export function LibraryPage() {
reloadCurrentLibrary,
} = useLibraryData(id, selectedSeries)
const sortedItems = useMemo(() => {
return sortMediaList(items, sortField, sortOrder, randomSeed, historyMap)
}, [items, sortField, sortOrder, randomSeed, historyMap])
const sortedSeriesCards = useMemo(() => {
return sortSeriesList(seriesCards, sortField, sortOrder, randomSeed, historyMap)
}, [seriesCards, sortField, sortOrder, randomSeed, historyMap])
const {
scanning,
scanProgress,
@@ -66,7 +128,7 @@ export function LibraryPage() {
isSeriesLibrary,
isSeries,
loading,
seriesCards,
seriesCards: sortedSeriesCards,
searchParams,
setSearchParams,
selectedSeries,
@@ -116,7 +178,7 @@ export function LibraryPage() {
{!selectedSeries && (
<LibraryPageHeader
library={library}
itemCount={isSeries ? seriesCards.length : total}
itemCount={isSeries ? sortedSeriesCards.length : total}
loadingAllText={loadingAllText}
scanProgress={scanProgress}
isAdmin={role === 'admin'}
@@ -124,6 +186,10 @@ export function LibraryPage() {
scanning={scanning}
scraping={scraping}
repairing={repairing}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
onReshuffle={handleReshuffle}
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
onScan={handleScan}
onScrape={() => setScrapeDialogOpen(true)}
@@ -133,8 +199,8 @@ export function LibraryPage() {
<LibraryMediaSections
isSeries={isSeries}
items={items}
seriesCards={seriesCards}
items={sortedItems}
seriesCards={sortedSeriesCards}
selectedSeries={selectedSeries}
loading={loading}
movieActions={movieActions}
+42 -24
View File
@@ -1,5 +1,7 @@
import type { Library } from '../types'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { MediaSortDropdown } from '../components/MediaSortDropdown'
import type { SortField, SortOrder } from '../utils/mediaSort'
import { libraryDisplayPath } from './libraryDisplayModel'
type LibraryPageHeaderProps = {
@@ -12,6 +14,10 @@ type LibraryPageHeaderProps = {
scanning: boolean
scraping: boolean
repairing: boolean
sortField: SortField
sortOrder: SortOrder
onSortChange: (field: SortField, order: SortOrder) => void
onReshuffle: () => void
onScrapeEpisodeArtworkChange: (checked: boolean) => void
onScan: () => void
onScrape: () => void
@@ -28,6 +34,10 @@ export function LibraryPageHeader({
scanning,
scraping,
repairing,
sortField,
sortOrder,
onSortChange,
onReshuffle,
onScrapeEpisodeArtworkChange,
onScan,
onScrape,
@@ -46,30 +56,38 @@ export function LibraryPageHeader({
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-2">
<EpisodeArtworkToggle
checked={scrapeEpisodeArtwork}
onChange={onScrapeEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-10"
/>
<button onClick={onScan} disabled={scanning} className="btn-outline">
{scanning ? '扫描中…' : '扫描媒体库'}
</button>
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
{scraping ? '刮削中…' : '整库刮削元数据'}
</button>
<button
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline"
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
>
{repairing ? '修复中…' : '修复+重刮整库'}
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<MediaSortDropdown
value={sortField}
order={sortOrder}
onChange={onSortChange}
onReshuffle={onReshuffle}
/>
{isAdmin && (
<>
<EpisodeArtworkToggle
checked={scrapeEpisodeArtwork}
onChange={onScrapeEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9"
/>
<button onClick={onScan} disabled={scanning} className="btn-outline">
{scanning ? '扫描中…' : '扫描媒体库'}
</button>
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
{scraping ? '刮削中…' : '整库刮削元数据'}
</button>
<button
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline"
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
>
{repairing ? '修复中…' : '修复+重刮整库'}
</button>
</>
)}
</div>
</div>
)
}
+1
View File
@@ -50,6 +50,7 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
<Link
to={`/play/${media.id}`}
state={{ from: `/media/${media.id}` }}
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-brand-500 text-white shadow-xl transform scale-90 group-hover:scale-100 transition-transform">
+2 -1
View File
@@ -90,13 +90,14 @@ export function MediaDetailPlaybackActions({
}: MediaDetailPlaybackActionsProps) {
return (
<div className="flex flex-wrap gap-3">
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
<Link to={`/play/${media.id}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link
to={`/play/${media.id}?mode=hls`}
state={{ from: `/media/${media.id}` }}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
>
<RefreshCw size={14} className="animate-spin-slow" />
+1 -4
View File
@@ -10,7 +10,7 @@ import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
import type { Media } from '../types'
import { getSeriesKey, isEpisodeLike, seriesTitleFromPath } from '../utils/groupSeries'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
@@ -98,9 +98,6 @@ export function PlayerPage() {
const backTarget = useCallback(() => {
const state = location.state as { from?: string } | null
if (state?.from) return state.from
if (media && isEpisodeLike(media) && media.library_id) {
return `/library/${encodeURIComponent(media.display_library_id || media.library_id)}?series=${encodeURIComponent(getSeriesKey(media))}`
}
const target = media?.id || id
return target ? `/media/${target}` : '/'
}, [id, location.state, media])
+1
View File
@@ -9,6 +9,7 @@ export interface User {
hide_adult?: boolean
force_password_reset: boolean
is_active: boolean
allowed_library_ids?: string[]
is_default_admin?: boolean
is_protected?: boolean
realtime_online?: boolean
+205
View File
@@ -0,0 +1,205 @@
import type { Media } from '../types'
import { seriesTitle, type SeriesCard } from './groupSeries'
export type SortField =
| 'title'
| 'release_date'
| 'year'
| 'created_at'
| 'updated_at'
| 'rating'
| 'imdb_rating'
| 'last_played'
| 'duration'
| 'bitrate'
| 'random'
export type SortOrder = 'asc' | 'desc'
export type SortOption = {
id: SortField
label: string
defaultOrder: SortOrder
}
export const SORT_OPTIONS: SortOption[] = [
{ id: 'title', label: '标题', defaultOrder: 'asc' },
{ id: 'release_date', label: '发行日期', defaultOrder: 'desc' },
{ id: 'year', label: '年份', defaultOrder: 'desc' },
{ id: 'created_at', label: '加入日期', defaultOrder: 'desc' },
{ id: 'updated_at', label: '最后一集添加日期', defaultOrder: 'desc' },
{ id: 'rating', label: '影评人评分', defaultOrder: 'desc' },
{ id: 'imdb_rating', label: 'IMDB评分', defaultOrder: 'desc' },
{ id: 'last_played', label: '播放日期', defaultOrder: 'desc' },
{ id: 'duration', label: '播放时长', defaultOrder: 'desc' },
{ id: 'bitrate', label: '比特率', defaultOrder: 'desc' },
{ id: 'random', label: '随机', defaultOrder: 'desc' },
]
export function getSortOption(field: SortField): SortOption {
return SORT_OPTIONS.find((opt) => opt.id === field) ?? SORT_OPTIONS[0]
}
// 简单伪随机洗牌(基于 seed 保证同一渲染周期内稳定)
function pseudoRandomShuffle<T>(array: T[], seed = 1): T[] {
const result = [...array]
let currentSeed = seed
const random = () => {
currentSeed = (currentSeed * 9301 + 49297) % 233280
return currentSeed / 233280
}
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
const temp = result[i]
result[i] = result[j]
result[j] = temp
}
return result
}
function compareStrings(a?: string, b?: string, order: SortOrder = 'asc'): number {
const valA = (a ?? '').trim()
const valB = (b ?? '').trim()
if (!valA && !valB) return 0
if (!valA) return 1
if (!valB) return -1
const cmp = valA.localeCompare(valB, 'zh-CN', { numeric: true, sensitivity: 'base' })
return order === 'asc' ? cmp : -cmp
}
function compareNumbers(a: number, b: number, order: SortOrder = 'asc'): number {
if (isNaN(a) && isNaN(b)) return 0
if (isNaN(a)) return 1
if (isNaN(b)) return -1
return order === 'asc' ? a - b : b - a
}
function compareDates(a?: string, b?: string, order: SortOrder = 'asc'): number {
const timeA = a ? new Date(a).getTime() : 0
const timeB = b ? new Date(b).getTime() : 0
const safeTimeA = isNaN(timeA) ? 0 : timeA
const safeTimeB = isNaN(timeB) ? 0 : timeB
if (!safeTimeA && !safeTimeB) return 0
if (!safeTimeA) return 1
if (!safeTimeB) return -1
return order === 'asc' ? safeTimeA - safeTimeB : safeTimeB - safeTimeA
}
export function sortMediaList(
items: Media[],
field: SortField,
order: SortOrder,
randomSeed = 1,
historyMap?: Map<string, string>,
): Media[] {
if (!items || items.length === 0) return []
if (field === 'random') {
return pseudoRandomShuffle(items, randomSeed)
}
const list = [...items]
list.sort((a, b) => {
switch (field) {
case 'title':
return compareStrings(a.title || a.original_name, b.title || b.original_name, order)
case 'release_date': {
const dateA = a.release_date || (a.year ? `${a.year}-01-01` : '')
const dateB = b.release_date || (b.year ? `${b.year}-01-01` : '')
return compareDates(dateA, dateB, order) || compareStrings(a.title, b.title, 'asc')
}
case 'year': {
const cmp = compareNumbers(a.year || 0, b.year || 0, order)
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
}
case 'created_at':
return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc')
case 'updated_at':
return compareDates(a.updated_at || a.created_at, b.updated_at || b.created_at, order) || compareStrings(a.title, b.title, 'asc')
case 'rating':
case 'imdb_rating': {
const cmp = compareNumbers(a.rating || 0, b.rating || 0, order)
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
}
case 'last_played': {
const playedA = historyMap?.get(a.id)
const playedB = historyMap?.get(b.id)
return compareDates(playedA, playedB, order) || compareStrings(a.title, b.title, 'asc')
}
case 'duration': {
const cmp = compareNumbers(a.duration_sec || 0, b.duration_sec || 0, order)
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
}
case 'bitrate': {
// 比特率 / 大小
const cmp = compareNumbers(a.size_bytes || 0, b.size_bytes || 0, order)
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
}
default:
return 0
}
})
return list
}
export function sortSeriesList(
cards: SeriesCard[],
field: SortField,
order: SortOrder,
randomSeed = 1,
historyMap?: Map<string, string>,
): SeriesCard[] {
if (!cards || cards.length === 0) return []
if (field === 'random') {
return pseudoRandomShuffle(cards, randomSeed)
}
const list = [...cards]
list.sort((a, b) => {
const repA = a.rep
const repB = b.rep
const titleA = seriesTitle(repA)
const titleB = seriesTitle(repB)
switch (field) {
case 'title':
return compareStrings(titleA, titleB, order)
case 'release_date': {
const dateA = repA.release_date || (repA.year ? `${repA.year}-01-01` : '')
const dateB = repB.release_date || (repB.year ? `${repB.year}-01-01` : '')
return compareDates(dateA, dateB, order) || compareStrings(titleA, titleB, 'asc')
}
case 'year': {
const cmp = compareNumbers(repA.year || 0, repB.year || 0, order)
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
}
case 'created_at':
return compareDates(repA.created_at, repB.created_at, order) || compareStrings(titleA, titleB, 'asc')
case 'updated_at': {
const updateA = repA.updated_at || repA.created_at
const updateB = repB.updated_at || repB.created_at
return compareDates(updateA, updateB, order) || compareStrings(titleA, titleB, 'asc')
}
case 'rating':
case 'imdb_rating': {
const cmp = compareNumbers(repA.rating || 0, repB.rating || 0, order)
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
}
case 'last_played': {
const playedA = historyMap?.get(repA.id)
const playedB = historyMap?.get(repB.id)
return compareDates(playedA, playedB, order) || compareStrings(titleA, titleB, 'asc')
}
case 'duration': {
const cmp = compareNumbers(repA.duration_sec || 0, repB.duration_sec || 0, order)
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
}
case 'bitrate': {
const cmp = compareNumbers(repA.size_bytes || 0, repB.size_bytes || 0, order)
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
}
default:
return 0
}
})
return list
}