mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
feat: improve admin storage and mobile UI
This commit is contained in:
@@ -101,8 +101,9 @@ func main() {
|
||||
}
|
||||
|
||||
adminServer := &api.AdminServer{
|
||||
Catalog: cat,
|
||||
Auth: authr,
|
||||
Catalog: cat,
|
||||
Auth: authr,
|
||||
LocalPreviewDir: cfg.Storage.LocalPreviewDir,
|
||||
OnDriveSaved: func(driveID string) error {
|
||||
d, err := cat.GetDrive(ctx, driveID)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
type AdminServer struct {
|
||||
Catalog *catalog.Catalog
|
||||
Auth *auth.Authenticator
|
||||
// LocalPreviewDir is the local directory that stores generated teasers and thumbs.
|
||||
LocalPreviewDir string
|
||||
// Hooks:外层注入实际执行者
|
||||
OnDriveSaved func(driveID string) error
|
||||
OnDriveRemoved func(driveID string)
|
||||
@@ -40,6 +42,7 @@ func (a *AdminServer) Register(r chi.Router) {
|
||||
|
||||
// 网盘
|
||||
r.Get("/drives", a.handleListDrives)
|
||||
r.Get("/drives/storage", a.handleDriveStorage)
|
||||
r.Post("/drives", a.handleUpsertDrive)
|
||||
r.Delete("/drives/{id}", a.handleDeleteDrive)
|
||||
r.Post("/drives/{id}/rescan", a.handleRescan)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -192,6 +194,117 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriveStorageReportsLocalMediaUsage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
cat, err := catalog.Open(filepath.Join(root, "catalog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
localDir := filepath.Join(root, "previews")
|
||||
thumbDir := filepath.Join(localDir, "thumbs")
|
||||
if err := os.MkdirAll(thumbDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir thumbs: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(localDir, "drive-one-video.mp4"), []byte("teaser-one"), 0o644); err != nil {
|
||||
t.Fatalf("write teaser one: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(localDir, "drive-two-video.mp4"), []byte("teaser-two!!"), 0o644); err != nil {
|
||||
t.Fatalf("write teaser two: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(thumbDir, "drive-one-video.jpg"), []byte("jpg-one"), 0o644); err != nil {
|
||||
t.Fatalf("write thumb one: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(thumbDir, "drive-two-video.jpg"), []byte("jpg-two!!"), 0o644); err != nil {
|
||||
t.Fatalf("write thumb two: %v", err)
|
||||
}
|
||||
|
||||
for _, d := range []*catalog.Drive{
|
||||
{ID: "drive-one", Kind: "onedrive", Name: "Drive One", RootID: "root", Status: "ok"},
|
||||
{ID: "drive-two", Kind: "pikpak", Name: "Drive Two", RootID: "", Status: "ok"},
|
||||
} {
|
||||
if err := cat.UpsertDrive(ctx, d); err != nil {
|
||||
t.Fatalf("seed drive %s: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
for _, v := range []*catalog.Video{
|
||||
{
|
||||
ID: "drive-one-video",
|
||||
DriveID: "drive-one",
|
||||
FileID: "file-one",
|
||||
Title: "Video One",
|
||||
PreviewLocal: filepath.Join(localDir, "drive-one-video.mp4"),
|
||||
PreviewStatus: "ready",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "drive-two-video",
|
||||
DriveID: "drive-two",
|
||||
FileID: "file-two",
|
||||
Title: "Video Two",
|
||||
PreviewLocal: filepath.Join(localDir, "drive-two-video.mp4"),
|
||||
PreviewStatus: "ready",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
} {
|
||||
if err := cat.UpsertVideo(ctx, v); err != nil {
|
||||
t.Fatalf("seed video %s: %v", v.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/drives/storage", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{Catalog: cat, LocalPreviewDir: localDir}).handleDriveStorage(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
ThumbnailBytes int64 `json:"thumbnailBytes"`
|
||||
TeaserBytes int64 `json:"teaserBytes"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
AvailableBytes int64 `json:"availableBytes"`
|
||||
Drives map[string]struct {
|
||||
ThumbnailBytes int64 `json:"thumbnailBytes"`
|
||||
TeaserBytes int64 `json:"teaserBytes"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
} `json:"drives"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.ThumbnailBytes != int64(len("jpg-one")+len("jpg-two!!")) {
|
||||
t.Fatalf("thumbnail bytes = %d, want %d", got.ThumbnailBytes, len("jpg-one")+len("jpg-two!!"))
|
||||
}
|
||||
if got.TeaserBytes != int64(len("teaser-one")+len("teaser-two!!")) {
|
||||
t.Fatalf("teaser bytes = %d, want %d", got.TeaserBytes, len("teaser-one")+len("teaser-two!!"))
|
||||
}
|
||||
if got.TotalBytes != got.ThumbnailBytes+got.TeaserBytes {
|
||||
t.Fatalf("total bytes = %d, want thumbnail + teaser", got.TotalBytes)
|
||||
}
|
||||
if got.AvailableBytes <= 0 {
|
||||
t.Fatalf("available bytes = %d, want positive", got.AvailableBytes)
|
||||
}
|
||||
if got.Drives["drive-one"].ThumbnailBytes != int64(len("jpg-one")) ||
|
||||
got.Drives["drive-one"].TeaserBytes != int64(len("teaser-one")) {
|
||||
t.Fatalf("drive-one usage = %#v", got.Drives["drive-one"])
|
||||
}
|
||||
if got.Drives["drive-two"].TotalBytes != int64(len("jpg-two!!")+len("teaser-two!!")) {
|
||||
t.Fatalf("drive-two total = %d, want %d", got.Drives["drive-two"].TotalBytes, len("jpg-two!!")+len("teaser-two!!"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateTagClassifiesExistingVideos(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
|
||||
@@ -313,7 +313,7 @@ func (s *Server) handleUploadVideo(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if title == "" {
|
||||
title = "upload-" + now.Format("20060102150405")
|
||||
title = uploadTitleFromFileName(originalName)
|
||||
}
|
||||
|
||||
uploadID, err := newUploadID(now)
|
||||
@@ -747,6 +747,20 @@ func uploadTagValues(r *http.Request) []string {
|
||||
return values
|
||||
}
|
||||
|
||||
func uploadTitleFromFileName(fileName string) string {
|
||||
name := strings.TrimSpace(filepath.Base(fileName))
|
||||
ext := filepath.Ext(name)
|
||||
if ext != "" {
|
||||
if trimmed := strings.TrimSuffix(name, ext); strings.TrimSpace(trimmed) != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return "upload-" + time.Now().Format("20060102150405")
|
||||
}
|
||||
|
||||
func parseUploadTags(values []string) ([]string, error) {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(values))
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadVideoDefaultsBlankTitleToTimestamp(t *testing.T) {
|
||||
func TestHandleUploadVideoDefaultsBlankTitleToOriginalFileName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
@@ -142,7 +142,7 @@ func TestHandleUploadVideoDefaultsBlankTitleToTimestamp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
server := &Server{Catalog: cat, LocalDir: t.TempDir()}
|
||||
req := multipartUploadRequest(t, map[string]string{"title": " "}, "clip.mp4", "video-bytes")
|
||||
req := multipartUploadRequest(t, map[string]string{"title": " "}, "holiday.clip.final.mp4", "video-bytes")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
server.handleUploadVideo(rr, req)
|
||||
@@ -158,8 +158,8 @@ func TestHandleUploadVideoDefaultsBlankTitleToTimestamp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get uploaded video: %v", err)
|
||||
}
|
||||
if got.Title == "" || !strings.HasPrefix(got.Title, "upload-") {
|
||||
t.Fatalf("title = %q, want upload timestamp fallback", got.Title)
|
||||
if got.Title != "holiday.clip.final" {
|
||||
t.Fatalf("title = %q, want original file name without extension", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/storageusage"
|
||||
)
|
||||
|
||||
func (a *AdminServer) handleDriveStorage(w http.ResponseWriter, r *http.Request) {
|
||||
usage, err := collectLocalMediaStorage(r.Context(), a.Catalog, a.LocalPreviewDir)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
func collectLocalMediaStorage(ctx context.Context, cat *catalog.Catalog, localDir string) (storageusage.Usage, error) {
|
||||
if cat == nil {
|
||||
return storageusage.Usage{}, errors.New("catalog is not configured")
|
||||
}
|
||||
localDir = strings.TrimSpace(localDir)
|
||||
if localDir == "" {
|
||||
return storageusage.Usage{}, errors.New("local preview dir is not configured")
|
||||
}
|
||||
if err := os.MkdirAll(localDir, 0o755); err != nil {
|
||||
return storageusage.Usage{}, err
|
||||
}
|
||||
drives, err := cat.ListDrives(ctx)
|
||||
if err != nil {
|
||||
return storageusage.Usage{}, err
|
||||
}
|
||||
refs, err := cat.ListLocalMediaRefs(ctx)
|
||||
if err != nil {
|
||||
return storageusage.Usage{}, err
|
||||
}
|
||||
driveIDs := make([]string, 0, len(drives))
|
||||
for _, drive := range drives {
|
||||
driveIDs = append(driveIDs, drive.ID)
|
||||
}
|
||||
assetRefs := make([]storageusage.VideoAssetRef, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
assetRefs = append(assetRefs, storageusage.VideoAssetRef{
|
||||
ID: ref.VideoID,
|
||||
DriveID: ref.DriveID,
|
||||
PreviewLocal: ref.PreviewLocal,
|
||||
})
|
||||
}
|
||||
return storageusage.Compute(localDir, assetRefs, driveIDs, localDiskStats)
|
||||
}
|
||||
|
||||
func localDiskStats(path string) (storageusage.DiskStats, error) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(path, &stat); err != nil {
|
||||
return storageusage.DiskStats{}, err
|
||||
}
|
||||
blockSize := uint64(stat.Bsize)
|
||||
return storageusage.DiskStats{
|
||||
AvailableBytes: int64(uint64(stat.Bavail) * blockSize),
|
||||
CapacityBytes: int64(uint64(stat.Blocks) * blockSize),
|
||||
}, nil
|
||||
}
|
||||
@@ -503,6 +503,35 @@ func (c *Catalog) CountTeasersByDrive(ctx context.Context) (map[string]DriveTeas
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type LocalMediaRef struct {
|
||||
DriveID string
|
||||
VideoID string
|
||||
PreviewLocal string
|
||||
}
|
||||
|
||||
func (c *Catalog) ListLocalMediaRefs(ctx context.Context) ([]LocalMediaRef, error) {
|
||||
rows, err := c.db.QueryContext(ctx,
|
||||
`SELECT drive_id, id, COALESCE(preview_local, '')
|
||||
FROM videos`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LocalMediaRef
|
||||
for rows.Next() {
|
||||
var ref LocalMediaRef
|
||||
if err := rows.Scan(&ref.DriveID, &ref.VideoID, &ref.PreviewLocal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- Drive ----------
|
||||
|
||||
type Drive struct {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package storageusage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type VideoAssetRef struct {
|
||||
ID string
|
||||
DriveID string
|
||||
PreviewLocal string
|
||||
}
|
||||
|
||||
type DiskStats struct {
|
||||
AvailableBytes int64 `json:"availableBytes"`
|
||||
CapacityBytes int64 `json:"capacityBytes"`
|
||||
}
|
||||
|
||||
type DriveUsage struct {
|
||||
ThumbnailBytes int64 `json:"thumbnailBytes"`
|
||||
TeaserBytes int64 `json:"teaserBytes"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
ThumbnailBytes int64 `json:"thumbnailBytes"`
|
||||
TeaserBytes int64 `json:"teaserBytes"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
AvailableBytes int64 `json:"availableBytes"`
|
||||
CapacityBytes int64 `json:"capacityBytes"`
|
||||
Drives map[string]DriveUsage `json:"drives"`
|
||||
}
|
||||
|
||||
func Compute(
|
||||
localDir string,
|
||||
refs []VideoAssetRef,
|
||||
driveIDs []string,
|
||||
diskStats func(string) (DiskStats, error),
|
||||
) (Usage, error) {
|
||||
localDir = strings.TrimSpace(localDir)
|
||||
if localDir == "" {
|
||||
return Usage{}, errors.New("local preview dir is not configured")
|
||||
}
|
||||
if diskStats == nil {
|
||||
diskStats = func(string) (DiskStats, error) { return DiskStats{}, nil }
|
||||
}
|
||||
stats, err := diskStats(localDir)
|
||||
if err != nil {
|
||||
return Usage{}, err
|
||||
}
|
||||
|
||||
out := Usage{
|
||||
AvailableBytes: stats.AvailableBytes,
|
||||
CapacityBytes: stats.CapacityBytes,
|
||||
Drives: make(map[string]DriveUsage, len(driveIDs)),
|
||||
}
|
||||
allowed := make(map[string]bool, len(driveIDs))
|
||||
for _, id := range driveIDs {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
allowed[id] = true
|
||||
out.Drives[id] = DriveUsage{}
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, ref := range refs {
|
||||
if ref.ID == "" || ref.DriveID == "" || !allowed[ref.DriveID] {
|
||||
continue
|
||||
}
|
||||
driveUsage := out.Drives[ref.DriveID]
|
||||
thumbPath := filepath.Join(localDir, "thumbs", ref.ID+".jpg")
|
||||
if size, exists, err := regularFileSize(thumbPath); err != nil {
|
||||
return Usage{}, err
|
||||
} else if exists {
|
||||
key := ref.DriveID + "\x00thumb\x00" + thumbPath
|
||||
if !seen[key] {
|
||||
driveUsage.ThumbnailBytes += size
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
if previewPath, ok := pathWithin(localDir, ref.PreviewLocal); ok {
|
||||
if size, exists, err := regularFileSize(previewPath); err != nil {
|
||||
return Usage{}, err
|
||||
} else if exists {
|
||||
key := ref.DriveID + "\x00teaser\x00" + previewPath
|
||||
if !seen[key] {
|
||||
driveUsage.TeaserBytes += size
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
driveUsage.TotalBytes = driveUsage.ThumbnailBytes + driveUsage.TeaserBytes
|
||||
out.Drives[ref.DriveID] = driveUsage
|
||||
}
|
||||
|
||||
for _, driveUsage := range out.Drives {
|
||||
out.ThumbnailBytes += driveUsage.ThumbnailBytes
|
||||
out.TeaserBytes += driveUsage.TeaserBytes
|
||||
}
|
||||
out.TotalBytes = out.ThumbnailBytes + out.TeaserBytes
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func regularFileSize(path string) (int64, bool, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, false, nil
|
||||
}
|
||||
return info.Size(), true, nil
|
||||
}
|
||||
|
||||
func pathWithin(root, path string) (string, bool) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", false
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
pathAbs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
rel, err := filepath.Rel(rootAbs, pathAbs)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
return pathAbs, true
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package storageusage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeCountsLocalThumbnailsAndTeasersByDrive(t *testing.T) {
|
||||
localDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(localDir, "thumbs"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir thumbs: %v", err)
|
||||
}
|
||||
writeSizedFile(t, filepath.Join(localDir, "thumbs", "video-a.jpg"), 3)
|
||||
writeSizedFile(t, filepath.Join(localDir, "thumbs", "video-b.jpg"), 5)
|
||||
teaserA := filepath.Join(localDir, "video-a.mp4")
|
||||
teaserB := filepath.Join(localDir, "video-b.mp4")
|
||||
writeSizedFile(t, teaserA, 7)
|
||||
writeSizedFile(t, teaserB, 11)
|
||||
outside := filepath.Join(t.TempDir(), "outside.mp4")
|
||||
writeSizedFile(t, outside, 99)
|
||||
|
||||
got, err := Compute(localDir, []VideoAssetRef{
|
||||
{ID: "video-a", DriveID: "drive-a", PreviewLocal: teaserA},
|
||||
{ID: "video-a-copy", DriveID: "drive-a", PreviewLocal: teaserA},
|
||||
{ID: "video-b", DriveID: "drive-b", PreviewLocal: teaserB},
|
||||
{ID: "outside", DriveID: "drive-b", PreviewLocal: outside},
|
||||
{ID: "unknown-drive-video", DriveID: "missing", PreviewLocal: teaserB},
|
||||
}, []string{"drive-a", "drive-b"}, func(string) (DiskStats, error) {
|
||||
return DiskStats{AvailableBytes: 123, CapacityBytes: 456}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("compute: %v", err)
|
||||
}
|
||||
|
||||
if got.AvailableBytes != 123 || got.CapacityBytes != 456 {
|
||||
t.Fatalf("disk stats = available:%d capacity:%d, want 123/456", got.AvailableBytes, got.CapacityBytes)
|
||||
}
|
||||
driveA := got.Drives["drive-a"]
|
||||
if driveA.ThumbnailBytes != 3 || driveA.TeaserBytes != 7 || driveA.TotalBytes != 10 {
|
||||
t.Fatalf("drive-a usage = %#v, want thumbnails=3 teaser=7 total=10", driveA)
|
||||
}
|
||||
driveB := got.Drives["drive-b"]
|
||||
if driveB.ThumbnailBytes != 5 || driveB.TeaserBytes != 11 || driveB.TotalBytes != 16 {
|
||||
t.Fatalf("drive-b usage = %#v, want thumbnails=5 teaser=11 total=16", driveB)
|
||||
}
|
||||
if got.ThumbnailBytes != 8 || got.TeaserBytes != 18 || got.TotalBytes != 26 {
|
||||
t.Fatalf("totals = %#v, want thumbnails=8 teaser=18 total=26", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSizedFile(t *testing.T, path string, size int) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Plus, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { Modal } from "./Modal";
|
||||
import { formatBytes } from "./storageFormat";
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
quark: "夸克网盘",
|
||||
@@ -34,6 +35,7 @@ const emptyForm: FormState = {
|
||||
|
||||
export function DrivesPage() {
|
||||
const [list, setList] = useState<api.AdminDrive[]>([]);
|
||||
const [storage, setStorage] = useState<api.AdminDriveStorage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
@@ -44,8 +46,12 @@ export function DrivesPage() {
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listDrives();
|
||||
const [data, storageData] = await Promise.all([
|
||||
api.listDrives(),
|
||||
api.getDriveStorage(),
|
||||
]);
|
||||
setList(data ?? []);
|
||||
setStorage(storageData);
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "加载失败", "error");
|
||||
} finally {
|
||||
@@ -146,6 +152,8 @@ export function DrivesPage() {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{storage && <StorageSummary storage={storage} />}
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
@@ -161,6 +169,7 @@ export function DrivesPage() {
|
||||
<th>ID</th>
|
||||
<th>状态</th>
|
||||
<th>扫描根</th>
|
||||
<th>本地占用</th>
|
||||
<th>Teaser</th>
|
||||
<th className="is-actions">操作</th>
|
||||
</tr>
|
||||
@@ -177,6 +186,9 @@ export function DrivesPage() {
|
||||
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
|
||||
{d.scanRootId || d.rootId}
|
||||
</td>
|
||||
<td>
|
||||
<StorageCell usage={storage?.drives[d.id]} />
|
||||
</td>
|
||||
<td>
|
||||
<TeaserCounts drive={d} />
|
||||
</td>
|
||||
@@ -230,6 +242,42 @@ export function DrivesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function StorageSummary({ storage }: { storage: api.AdminDriveStorage }) {
|
||||
return (
|
||||
<section className="admin-card admin-storage-summary" aria-label="本地媒体存储">
|
||||
<div className="admin-storage-summary__metric">
|
||||
<span>封面占用</span>
|
||||
<strong>{formatBytes(storage.thumbnailBytes)}</strong>
|
||||
</div>
|
||||
<div className="admin-storage-summary__metric">
|
||||
<span>Teaser 占用</span>
|
||||
<strong>{formatBytes(storage.teaserBytes)}</strong>
|
||||
</div>
|
||||
<div className="admin-storage-summary__metric">
|
||||
<span>本地媒体合计</span>
|
||||
<strong>{formatBytes(storage.totalBytes)}</strong>
|
||||
</div>
|
||||
<div className="admin-storage-summary__metric">
|
||||
<span>磁盘可用</span>
|
||||
<strong>{formatBytes(storage.availableBytes)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageCell({ usage }: { usage?: api.DriveStorageUsage }) {
|
||||
if (!usage || usage.totalBytes <= 0) {
|
||||
return <span className="admin-storage-cell__empty">0 B</span>;
|
||||
}
|
||||
return (
|
||||
<div className="admin-storage-cell">
|
||||
<strong>{formatBytes(usage.totalBytes)}</strong>
|
||||
<span>封面 {formatBytes(usage.thumbnailBytes)}</span>
|
||||
<span>Teaser {formatBytes(usage.teaserBytes)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeaserCounts({ drive }: { drive: api.AdminDrive }) {
|
||||
return (
|
||||
<div className="admin-teaser-counts">
|
||||
|
||||
@@ -70,6 +70,22 @@ export function listDrives() {
|
||||
return request<AdminDrive[]>("/drives");
|
||||
}
|
||||
|
||||
export type DriveStorageUsage = {
|
||||
thumbnailBytes: number;
|
||||
teaserBytes: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
export type AdminDriveStorage = DriveStorageUsage & {
|
||||
availableBytes: number;
|
||||
capacityBytes: number;
|
||||
drives: Record<string, DriveStorageUsage>;
|
||||
};
|
||||
|
||||
export function getDriveStorage() {
|
||||
return request<AdminDriveStorage>("/drives/storage");
|
||||
}
|
||||
|
||||
export type UpsertDriveInput = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
|
||||
const rounded = Number(value.toFixed(1));
|
||||
return `${rounded} ${units[unitIndex]}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Globe, LogIn, UserPlus } from "lucide-react";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
export function TopBar() {
|
||||
return (
|
||||
@@ -10,16 +10,6 @@ export function TopBar() {
|
||||
简体中文
|
||||
</a>
|
||||
</div>
|
||||
<div className="top-bar__side">
|
||||
<a href="#register">
|
||||
<UserPlus size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
|
||||
注册
|
||||
</a>
|
||||
<a href="#login">
|
||||
<LogIn size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
|
||||
登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function defaultUploadTitleFromFileName(fileName: string): string {
|
||||
const baseName = fileName.split(/[\\/]/).pop()?.trim() ?? "";
|
||||
const lastDot = baseName.lastIndexOf(".");
|
||||
if (lastDot > 0) {
|
||||
return baseName.slice(0, lastDot);
|
||||
}
|
||||
return baseName;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Check, Film, UploadCloud } from "lucide-react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SectionHeader } from "@/components/SectionHeader";
|
||||
import { uploadVideo } from "@/data/videos";
|
||||
import { defaultUploadTitleFromFileName } from "@/lib/uploadTitle";
|
||||
import type { VideoItem } from "@/types";
|
||||
|
||||
const UPLOAD_TAGS = ["奶子", "臀", "口角", "女大", "人妻", "AV"];
|
||||
@@ -27,7 +28,9 @@ export default function UploadPage() {
|
||||
}, [file]);
|
||||
|
||||
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setFile(event.target.files?.[0] ?? null);
|
||||
const nextFile = event.target.files?.[0] ?? null;
|
||||
setFile(nextFile);
|
||||
setTitle(nextFile ? defaultUploadTitleFromFileName(nextFile.name) : "");
|
||||
setUploaded(null);
|
||||
setError("");
|
||||
}
|
||||
@@ -83,7 +86,7 @@ export default function UploadPage() {
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="不填则使用当前时间戳"
|
||||
placeholder="选择文件后自动填入"
|
||||
maxLength={120}
|
||||
/>
|
||||
</label>
|
||||
|
||||
+135
-1
@@ -116,6 +116,28 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-storage-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-storage-summary__metric {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-storage-summary__metric span {
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-storage-summary__metric strong {
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
@@ -273,6 +295,23 @@
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.admin-storage-cell {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 118px;
|
||||
}
|
||||
|
||||
.admin-storage-cell strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-storage-cell span,
|
||||
.admin-storage-cell__empty {
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -339,10 +378,12 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #f5f5f4;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-login__card {
|
||||
width: 360px;
|
||||
width: min(360px, 100%);
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-5);
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-line);
|
||||
@@ -530,6 +571,99 @@
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: var(--space-3);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.admin-page__header {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.admin-page__header .admin-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.admin-storage-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-storage-summary__metric strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.admin-form__row input,
|
||||
.admin-form__row select,
|
||||
.admin-form__row textarea {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.admin-form__row--inline {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table td.is-actions {
|
||||
min-width: 168px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.admin-table td.is-actions .admin-btn {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
align-items: stretch;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 16px);
|
||||
}
|
||||
|
||||
.admin-modal__header,
|
||||
.admin-modal__body,
|
||||
.admin-modal__footer {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-modal__footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-modal__footer .admin-btn {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-empty {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
}
|
||||
|
||||
.main-nav.is-open .main-nav__link {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const adminCss = readFileSync(
|
||||
new URL("../src/styles/admin.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function ruleBody(css: string, selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||
assert.ok(match, `Expected CSS rule for ${selector}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function allRuleBodies(css: string, selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return Array.from(css.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, "g")))
|
||||
.map((match) => match[1])
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function mobileCss(): string {
|
||||
const marker = "@media (max-width: 768px)";
|
||||
const start = adminCss.indexOf(marker);
|
||||
assert.notEqual(start, -1, "Expected mobile admin media query");
|
||||
return adminCss.slice(start);
|
||||
}
|
||||
|
||||
test("admin login card fits narrow phone screens", () => {
|
||||
const body = ruleBody(adminCss, ".admin-login__card");
|
||||
|
||||
assert.match(body, /width\s*:\s*min\(360px,\s*100%\)/);
|
||||
assert.match(body, /box-sizing\s*:\s*border-box/);
|
||||
});
|
||||
|
||||
test("admin tables scroll inside the mobile viewport", () => {
|
||||
const css = mobileCss();
|
||||
const body = ruleBody(css, ".admin-table");
|
||||
|
||||
assert.match(body, /display\s*:\s*block/);
|
||||
assert.match(body, /max-width\s*:\s*100%/);
|
||||
assert.match(body, /overflow-x\s*:\s*auto/);
|
||||
});
|
||||
|
||||
test("admin modals and action footers adapt on mobile", () => {
|
||||
const css = mobileCss();
|
||||
|
||||
assert.match(ruleBody(css, ".admin-modal"), /width\s*:\s*100%/);
|
||||
assert.match(allRuleBodies(css, ".admin-modal__footer"), /flex-wrap\s*:\s*wrap/);
|
||||
assert.match(ruleBody(css, ".admin-form"), /max-width\s*:\s*100%/);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const navigationCss = readFileSync(
|
||||
new URL("../src/styles/navigation.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const topBarSource = readFileSync(
|
||||
new URL("../src/components/TopBar.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function ruleBody(css: string, selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||
assert.ok(match, `Expected CSS rule for ${selector}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("mobile menu links fill the full expanded menu row", () => {
|
||||
const body = ruleBody(navigationCss, ".main-nav.is-open .main-nav__link");
|
||||
|
||||
assert.match(body, /display\s*:\s*flex\b/);
|
||||
assert.match(body, /width\s*:\s*100%/);
|
||||
});
|
||||
|
||||
test("top bar does not render inactive public auth links", () => {
|
||||
assert.doesNotMatch(topBarSource, /href="#(?:register|login)"/);
|
||||
assert.doesNotMatch(topBarSource, />\s*(?:注册|登录)\s*</);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { formatBytes } from "../src/admin/storageFormat.ts";
|
||||
|
||||
test("formats byte counts for storage usage display", () => {
|
||||
assert.equal(formatBytes(0), "0 B");
|
||||
assert.equal(formatBytes(512), "512 B");
|
||||
assert.equal(formatBytes(1536), "1.5 KB");
|
||||
assert.equal(formatBytes(2 * 1024 * 1024), "2 MB");
|
||||
assert.equal(formatBytes(3.25 * 1024 * 1024 * 1024), "3.3 GB");
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { defaultUploadTitleFromFileName } from "../src/lib/uploadTitle.ts";
|
||||
|
||||
test("uses the selected file name without the extension as the default upload title", () => {
|
||||
assert.equal(defaultUploadTitleFromFileName("holiday.clip.final.mp4"), "holiday.clip.final");
|
||||
});
|
||||
|
||||
test("falls back to the full file name when there is no extension", () => {
|
||||
assert.equal(defaultUploadTitleFromFileName("clip"), "clip");
|
||||
});
|
||||
Reference in New Issue
Block a user