From 486b4c023560cc0850eefaf0cb41550528a8965a Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 21:21:16 +0800 Subject: [PATCH] Update homepage rotation and preview handling --- README.md | 2 +- backend/cmd/server/main_test.go | 77 + backend/internal/api/api.go | 39 +- backend/internal/api/api_test.go | 92 ++ backend/internal/catalog/catalog.go | 5 +- backend/internal/drives/p115/driver.go | 20 +- backend/internal/preview/ffmpeg.go | 219 ++- backend/internal/preview/ffmpeg_test.go | 64 + backend/internal/preview/worker_test.go | 68 + backend/internal/proxy/proxy.go | 52 +- backend/internal/proxy/proxy_test.go | 109 ++ package-lock.json | 1773 +++++++++++++++++++++++ src/pages/HomePage.tsx | 23 +- start.sh | 6 +- 14 files changed, 2508 insertions(+), 41 deletions(-) create mode 100644 backend/internal/proxy/proxy_test.go create mode 100644 package-lock.json diff --git a/README.md b/README.md index b370793..606f4a2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## 当前功能 -- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。 +- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。首页“今日排行”按点赞热度排序并每 2 小时轮换一组,默认展示 10 个;“最新视频”按发布时间倒序展示最新 10 个。 - 视频卡片支持封面、画质、时长、点赞/点踩、移动端点按预览;列表页会记住筛选、分页和滚动位置。 - 播放页会在视频信息中显示来源网盘类型,并提供点赞、标签编辑和 **不再展示**。不再展示是全局隐藏:写入数据库后,该视频不会再出现在首页、列表、相关推荐中,详情接口也会返回 404。 - 管理后台支持网盘管理、视频管理、标签管理和运行时 Teaser 生成开关。 diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go index 493f8db..0e88ce2 100644 --- a/backend/cmd/server/main_test.go +++ b/backend/cmd/server/main_test.go @@ -153,6 +153,83 @@ func TestRegisterPreviewWorkersGenerateThumbnailsBeforePreviews(t *testing.T) { t.Fatalf("generation did not finish, events=%#v", gen.Events()) } +func TestFailedThumbnailsDoNotBlockPreviewGeneration(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cat, err := catalog.Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open catalog: %v", err) + } + t.Cleanup(func() { + if err := cat.Close(); err != nil { + t.Fatalf("close catalog: %v", err) + } + }) + + now := time.Now() + video := &catalog.Video{ + ID: "video-failed-thumb", + DriveID: "drive-id", + FileID: "file-1", + Title: "Clip With Failed Thumb", + PreviewStatus: "pending", + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := cat.UpsertVideo(ctx, video); err != nil { + t.Fatalf("seed video: %v", err) + } + if err := cat.UpdateVideoMeta(ctx, video.ID, catalog.VideoMetaPatch{ThumbnailStatus: "failed"}); err != nil { + t.Fatalf("mark thumbnail failed: %v", err) + } + missing, err := cat.CountVideosNeedingThumbnail(ctx, "drive-id") + if err != nil { + t.Fatalf("count missing thumbnails: %v", err) + } + if missing != 0 { + t.Fatalf("missing thumbnails = %d, want failed thumbnails excluded", missing) + } + + app := &App{ + cat: cat, + workers: make(map[string]*preview.Worker), + thumbWorkers: make(map[string]*preview.ThumbWorker), + previewEnabled: true, + } + gen := &serverFakeTeaserGenerator{} + drv := &serverFakeDrive{} + worker := preview.NewWorker(gen, cat, drv, "") + thumbWorker := preview.NewThumbWorker(gen, cat, drv) + go worker.Run(ctx) + go thumbWorker.Run(ctx) + + app.registerPreviewWorkers(ctx, "drive-id", worker, thumbWorker, func() {}) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + got, err := cat.GetVideo(ctx, video.ID) + if err != nil { + t.Fatalf("get video: %v", err) + } + if got.PreviewStatus == "ready" { + events := gen.Events() + if len(events) != 1 || events[0] != "preview:"+video.ID { + t.Fatalf("events = %#v, want preview only", events) + } + return + } + time.Sleep(10 * time.Millisecond) + } + + got, err := cat.GetVideo(ctx, video.ID) + if err != nil { + t.Fatalf("get video after timeout: %v", err) + } + t.Fatalf("preview status = %q, want ready; events=%#v", got.PreviewStatus, gen.Events()) +} + func TestRegenFailedPreviewsQueuesOnlyFailedVideosForDrive(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 5b4a770..f5de651 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -52,12 +52,18 @@ type Server struct { LocalDir string UploadDir string FFmpegPath string + Now func() time.Time OnVideoUploaded func(*catalog.Video) transcodeMu sync.Mutex transcodeJobs map[string]bool } +const ( + homePageSize = 10 + homeWindowDuration = 2 * time.Hour +) + // VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐 type VideoDTO struct { ID string `json:"id"` @@ -134,16 +140,45 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) { } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { - items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{ - Sort: "hot", Page: 1, PageSize: 24, + items, total, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{ + Sort: "hot", Page: 1, PageSize: homePageSize, }) if err != nil { writeErr(w, http.StatusInternalServerError, err) return } + page := homeWindowPage(s.now(), total, homePageSize) + if page > 1 { + items, _, err = s.Catalog.ListVideos(r.Context(), catalog.ListParams{ + Sort: "hot", Page: page, PageSize: homePageSize, + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + } writeJSON(w, http.StatusOK, mapVideos(items)) } +func (s *Server) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func homeWindowPage(now time.Time, total, pageSize int) int { + if pageSize <= 0 || total <= pageSize { + return 1 + } + pageCount := (total + pageSize - 1) / pageSize + if pageCount <= 1 { + return 1 + } + window := now.Unix() / int64(homeWindowDuration/time.Second) + return int(window%int64(pageCount)) + 1 +} + func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() page, _ := strconv.Atoi(q.Get("page")) diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 8ca7b52..954505e 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -83,6 +84,78 @@ func TestPreviewURLFallsBackWithoutUpdatedAt(t *testing.T) { } } +func TestHomeWindowPageChangesEveryTwoHours(t *testing.T) { + total := homePageSize * 3 + if got := homeWindowPage(time.Unix(0, 0), total, homePageSize); got != 1 { + t.Fatalf("window page at epoch = %d, want 1", got) + } + if got := homeWindowPage(time.Unix(int64(homeWindowDuration/time.Second)-1, 0), total, homePageSize); got != 1 { + t.Fatalf("window page before boundary = %d, want 1", got) + } + if got := homeWindowPage(time.Unix(int64(homeWindowDuration/time.Second), 0), total, homePageSize); got != 2 { + t.Fatalf("window page at first boundary = %d, want 2", got) + } + if got := homeWindowPage(time.Unix(int64(3*homeWindowDuration/time.Second), 0), total, homePageSize); got != 1 { + t.Fatalf("window page after cycle = %d, want 1", got) + } +} + +func TestHandleHomeRotatesHotVideosByTwoHourWindow(t *testing.T) { + ctx := context.Background() + cat, err := catalog.Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open catalog: %v", err) + } + t.Cleanup(func() { + if err := cat.Close(); err != nil { + t.Fatalf("close catalog: %v", err) + } + }) + + base := time.Unix(1_700_000_000, 0) + for i := 0; i < homePageSize+1; i++ { + id := "video-" + strconv.Itoa(i) + if err := cat.UpsertVideo(ctx, &catalog.Video{ + ID: id, + DriveID: "drive", + FileID: "file-" + strconv.Itoa(i), + Title: "Video " + strconv.Itoa(i), + Likes: homePageSize + 1 - i, + PublishedAt: base.Add(time.Duration(i) * time.Second), + CreatedAt: base, + UpdatedAt: base, + }); err != nil { + t.Fatalf("seed video %s: %v", id, err) + } + } + + firstWindow := requestHomeIDs(t, &Server{ + Catalog: cat, + Now: func() time.Time { return time.Unix(0, 0) }, + }) + sameWindow := requestHomeIDs(t, &Server{ + Catalog: cat, + Now: func() time.Time { return time.Unix(int64(homeWindowDuration/time.Second)-1, 0) }, + }) + nextWindow := requestHomeIDs(t, &Server{ + Catalog: cat, + Now: func() time.Time { return time.Unix(int64(homeWindowDuration/time.Second), 0) }, + }) + + if strings.Join(firstWindow, ",") != strings.Join(sameWindow, ",") { + t.Fatalf("same two-hour window changed videos: first=%v same=%v", firstWindow, sameWindow) + } + if strings.Join(firstWindow, ",") == strings.Join(nextWindow, ",") { + t.Fatalf("next two-hour window did not change videos: %v", nextWindow) + } + if len(firstWindow) != homePageSize { + t.Fatalf("first window item count = %d, want %d", len(firstWindow), homePageSize) + } + if len(nextWindow) != 1 { + t.Fatalf("next window item count = %d, want final page with 1 item", len(nextWindow)) + } +} + func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) { ctx := context.Background() cat, err := catalog.Open(t.TempDir() + "/catalog.db") @@ -652,6 +725,25 @@ func requestWithRouteParam(method, target, key, value string, body *strings.Read return req } +func requestHomeIDs(t *testing.T, server *Server) []string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/home", nil) + rr := httptest.NewRecorder() + server.handleHome(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("home status = %d, body = %s", rr.Code, rr.Body.String()) + } + var videos []VideoDTO + if err := json.NewDecoder(rr.Body).Decode(&videos); err != nil { + t.Fatalf("decode home response: %v", err) + } + ids := make([]string, 0, len(videos)) + for _, v := range videos { + ids = append(ids, v.ID) + } + return ids +} + func multipartUploadRequest(t *testing.T, fields map[string]string, fileName, fileContent string) *http.Request { t.Helper() var body bytes.Buffer diff --git a/backend/internal/catalog/catalog.go b/backend/internal/catalog/catalog.go index cfc9100..61b1898 100644 --- a/backend/internal/catalog/catalog.go +++ b/backend/internal/catalog/catalog.go @@ -325,7 +325,8 @@ func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status return out, nil } -// ListVideosNeedingThumbnail returns videos that do not have any cover URL yet. +// ListVideosNeedingThumbnail returns videos that still need a thumbnail attempt. +// Failed thumbnails are reported separately and should not block teaser generation. func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string, limit int) ([]*Video, error) { if limit <= 0 { limit = 10000 @@ -334,6 +335,7 @@ func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string `SELECT `+allVideoCols+` FROM videos WHERE drive_id = ? AND COALESCE(thumbnail_url, '') = '' + AND COALESCE(thumbnail_status, 'pending') != 'failed' AND COALESCE(hidden, 0) = 0 AND `+uniqueVideoWhereSQL+` ORDER BY created_at ASC @@ -360,6 +362,7 @@ func (c *Catalog) CountVideosNeedingThumbnail(ctx context.Context, driveID strin `SELECT COUNT(*) FROM videos WHERE drive_id = ? AND COALESCE(thumbnail_url, '') = '' + AND COALESCE(thumbnail_status, 'pending') != 'failed' AND COALESCE(hidden, 0) = 0 AND `+uniqueVideoWhereSQL, driveID).Scan(&count) diff --git a/backend/internal/drives/p115/driver.go b/backend/internal/drives/p115/driver.go index 1b991bc..0dcf515 100644 --- a/backend/internal/drives/p115/driver.go +++ b/backend/internal/drives/p115/driver.go @@ -170,12 +170,20 @@ func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) } func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + return d.streamURLWithUA(ctx, fileID, d.ua) +} + +func (d *Driver) StreamURLWithHeader(ctx context.Context, fileID string, header http.Header) (*drives.StreamLink, error) { + return d.streamURLWithUA(ctx, fileID, header.Get("User-Agent")) +} + +func (d *Driver) streamURLWithUA(ctx context.Context, fileID string, ua string) (*drives.StreamLink, error) { // 需要先拿到 pickCode f, err := d.client.GetFile(fileID) if err != nil { return nil, fmt.Errorf("115 get file: %w", err) } - info, ua, err := d.downloadInfo(f.PickCode) + info, ua, err := d.downloadInfo(f.PickCode, ua) if err != nil { return nil, fmt.Errorf("115 download url: %w", err) } @@ -201,12 +209,16 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi }, nil } -func (d *Driver) downloadInfo(pickCode string) (*sdk.DownloadInfo, string, error) { - info, err := d.client.DownloadWithUA(pickCode, d.ua) +func (d *Driver) downloadInfo(pickCode string, ua string) (*sdk.DownloadInfo, string, error) { + ua = strings.TrimSpace(ua) + if ua == "" { + ua = d.ua + } + info, err := d.client.DownloadWithUA(pickCode, ua) if err != nil { return nil, "", err } - return info, d.ua, nil + return info, ua, nil } func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { diff --git a/backend/internal/preview/ffmpeg.go b/backend/internal/preview/ffmpeg.go index 9247649..b016426 100644 --- a/backend/internal/preview/ffmpeg.go +++ b/backend/internal/preview/ffmpeg.go @@ -36,6 +36,8 @@ type Generator struct { cfg Config } +const teaserSegmentTimeout = 90 * time.Second + type ThumbnailGenerator interface { Probe(ctx context.Context, link *drives.StreamLink) (float64, error) GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) @@ -194,27 +196,88 @@ func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 { return starts } -// pickThumbnailOffset 选封面抽帧的时间点(秒)。独立于 teaser。 -func pickThumbnailOffset() float64 { - return 5 +func teaserCandidateStarts(duration float64, primary []float64, eachSec float64) []float64 { + out := make([]float64, 0, len(primary)+8) + for _, s := range primary { + out = appendUniqueStart(out, s, eachSec) + } + + if duration <= 0 { + for _, s := range []float64{0, 3, 30, 60} { + out = appendUniqueStart(out, s, eachSec) + } + return out + } + + usable := duration - eachSec - 1 + if usable < 0 { + usable = 0 + } + for _, pct := range []float64{0.03, 0.08, 0.12, 0.25, 0.40, 0.55, 0.70, 0.90} { + s := duration * pct + if s > usable { + s = usable + } + out = appendUniqueStart(out, s, eachSec) + } + return out +} + +func appendUniqueStart(starts []float64, start, eachSec float64) []float64 { + if start < 0 { + start = 0 + } + minGap := math.Max(1, eachSec*1.5) + for _, existing := range starts { + if math.Abs(existing-start) < minGap { + return starts + } + } + return append(starts, start) +} + +// thumbnailOffsets 选封面抽帧的时间点(秒)。独立于 teaser。 +func thumbnailOffsets() []float64 { + return []float64{5, 1, 0} } // --- 封面 --- -// GenerateThumbnail 抽一张 jpg 封面。封面统一从第 5 秒抽帧,避免为封面单独探时长。 +// GenerateThumbnail 抽一张 jpg 封面。默认从第 5 秒抽帧,失败时回退到更早时间点。 func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) { dir := filepath.Join(g.cfg.LocalDir, "thumbs") if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } dst := filepath.Join(dir, videoID+".jpg") - offset := pickThumbnailOffset() + var lastErr error + offsets := thumbnailOffsets() + for i, offset := range offsets { + if i > 0 { + _ = os.Remove(dst) + } + if err := g.generateThumbnailAtOffset(ctx, link, dst, offset); err != nil { + lastErr = err + if !thumbnailOffsetFallbackAllowed(err) { + return "", err + } + continue + } + return dst, nil + } + if lastErr != nil { + return "", lastErr + } + return "", errors.New("thumbnail generation did not run") +} + +func (g *Generator) generateThumbnailAtOffset(ctx context.Context, link *drives.StreamLink, dst string, offset float64) error { ctx2, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() ffmpegLink, cleanup, err := prepareFFmpegLink(ctx2, link) if err != nil { - return "", err + return err } defer cleanup() @@ -236,13 +299,23 @@ func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLi out, err := cmd.CombinedOutput() if err != nil { os.Remove(dst) - return "", ffmpegCommandError("ffmpeg thumb", err, out) + return ffmpegCommandError("ffmpeg thumb", err, out) } if info, statErr := os.Stat(dst); statErr != nil || info.Size() == 0 { os.Remove(dst) - return "", fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out)) + return fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out)) } - return dst, nil + return nil +} + +func thumbnailOffsetFallbackAllowed(err error) bool { + if err == nil { + return false + } + text := strings.ToLower(err.Error()) + return strings.Contains(text, "produced empty file") || + strings.Contains(text, "signal: killed") || + strings.Contains(text, "context deadline exceeded") } // --- 时长 --- @@ -442,13 +515,35 @@ func (g *Generator) generateSequential(ctx context.Context, duration float64, li } }() - for i, start := range starts { + candidates := teaserCandidateStarts(duration, starts, eachSec) + targetSegments := len(starts) + var lastErr error + for i, start := range candidates { + if len(segmentPaths) >= targetSegments { + break + } seg, err := g.generateSingleSegment(ctx2, i, start, eachSec, linkForInput) if err != nil { - return "", err + if !teaserSegmentFallbackAllowed(err) { + return "", err + } + lastErr = err + continue } segmentPaths = append(segmentPaths, seg) } + if len(segmentPaths) == 0 { + if lastErr != nil { + return "", lastErr + } + return "", errors.New("no usable teaser segment") + } + if len(segmentPaths) < targetSegments { + if lastErr != nil { + return "", fmt.Errorf("only generated %d/%d teaser segments: %w", len(segmentPaths), targetSegments, lastErr) + } + return "", fmt.Errorf("only generated %d/%d teaser segments", len(segmentPaths), targetSegments) + } if len(segmentPaths) == 1 { success = true @@ -513,6 +608,9 @@ func (g *Generator) generateSequential(ctx context.Context, duration float64, li } func (g *Generator) generateSingleSegment(ctx context.Context, index int, start, eachSec float64, linkForInput func(int) (*drives.StreamLink, error)) (string, error) { + ctx, cancel := context.WithTimeout(ctx, teaserSegmentTimeout) + defer cancel() + link, err := linkForInput(index) if err != nil { return "", err @@ -570,6 +668,31 @@ func (g *Generator) generateSingleSegment(ctx context.Context, index int, start, return segPath, nil } +func teaserSegmentFallbackAllowed(err error) bool { + if err == nil { + return false + } + if _, ok := drives.RateLimitRetryAfter(err); ok { + return false + } + text := strings.ToLower(err.Error()) + if strings.Contains(text, "server returned 403") || + strings.Contains(text, "403 forbidden") || + strings.Contains(text, "server returned 405") || + strings.Contains(text, "405 method") || + strings.Contains(text, "access denied") || + strings.Contains(text, "request has been blocked") || + strings.Contains(text, "访问被阻断") { + return false + } + return strings.Contains(text, "generated teaser has no video stream") || + strings.Contains(text, "generated teaser has invalid duration") || + strings.Contains(text, "generated teaser is empty") || + strings.Contains(text, "produced empty file") || + strings.Contains(text, "ffmpeg segment:") || + strings.Contains(text, "ffprobe teaser:") +} + type localMediaProbe struct { Streams []struct { CodecType string `json:"codec_type"` @@ -838,6 +961,7 @@ type Worker struct { Catalog *catalog.Catalog Drive drives.Drive ch chan *catalog.Video + queue videoQueue RateLimitCooldown time.Duration BeforeTask func(context.Context) bool @@ -858,10 +982,14 @@ func (w *Worker) Enqueue(v *catalog.Video) bool { if v == nil { return false } + if !w.queue.reserve(v) { + return true + } select { case w.ch <- v: return true default: + w.queue.release(v) return false } } @@ -870,10 +998,14 @@ func (w *Worker) EnqueueBlocking(ctx context.Context, v *catalog.Video) bool { if v == nil { return false } + if !w.queue.reserve(v) { + return true + } select { case w.ch <- v: return true case <-ctx.Done(): + w.queue.release(v) return false } } @@ -883,6 +1015,7 @@ type ThumbWorker struct { Catalog *catalog.Catalog Drive drives.Drive ch chan *catalog.Video + queue videoQueue RateLimitCooldown time.Duration rateLimit rateLimitState @@ -915,6 +1048,54 @@ type taskActivity struct { currentTitle string } +type videoQueue struct { + mu sync.Mutex + ids map[string]struct{} +} + +func (q *videoQueue) reserve(v *catalog.Video) bool { + if v == nil { + return false + } + if v.ID == "" { + return true + } + q.mu.Lock() + defer q.mu.Unlock() + if q.ids == nil { + q.ids = make(map[string]struct{}) + } + if _, ok := q.ids[v.ID]; ok { + return false + } + q.ids[v.ID] = struct{}{} + return true +} + +func (q *videoQueue) release(v *catalog.Video) { + if v == nil || v.ID == "" { + return + } + q.mu.Lock() + delete(q.ids, v.ID) + q.mu.Unlock() +} + +func (q *videoQueue) lengthExcluding(currentID string) int { + q.mu.Lock() + defer q.mu.Unlock() + n := len(q.ids) + if currentID != "" { + if _, ok := q.ids[currentID]; ok { + n-- + } + } + if n < 0 { + return 0 + } + return n +} + func (a *taskActivity) start(v *catalog.Video) { a.mu.Lock() defer a.mu.Unlock() @@ -991,10 +1172,14 @@ func (w *ThumbWorker) Enqueue(v *catalog.Video) bool { if v == nil { return false } + if !w.queue.reserve(v) { + return true + } select { case w.ch <- v: return true default: + w.queue.release(v) return false } } @@ -1003,10 +1188,14 @@ func (w *ThumbWorker) EnqueueBlocking(ctx context.Context, v *catalog.Video) boo if v == nil { return false } + if !w.queue.reserve(v) { + return true + } select { case w.ch <- v: return true case <-ctx.Done(): + w.queue.release(v) return false } } @@ -1015,14 +1204,16 @@ func (w *Worker) Status() TaskStatus { if w == nil { return TaskStatus{State: "idle"} } - return taskStatus(&w.activity, &w.rateLimit, len(w.ch)) + currentID, _ := w.activity.current() + return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID)) } func (w *ThumbWorker) Status() TaskStatus { if w == nil { return TaskStatus{State: "idle"} } - return taskStatus(&w.activity, &w.rateLimit, len(w.ch)) + currentID, _ := w.activity.current() + return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID)) } func taskStatus(activity *taskActivity, rateLimit *rateLimitState, queueLength int) TaskStatus { @@ -1085,6 +1276,7 @@ func (w *ThumbWorker) Run(ctx context.Context) { } func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) { + defer w.queue.release(v) if w.BeforeTask != nil && !w.BeforeTask(ctx) { return } @@ -1098,6 +1290,7 @@ func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) { } func (w *ThumbWorker) processQueued(ctx context.Context, v *catalog.Video) { + defer w.queue.release(v) w.activity.start(v) defer w.activity.done() if !waitForRateLimitCooldown(ctx, &w.rateLimit, "thumb", w.Drive) { diff --git a/backend/internal/preview/ffmpeg_test.go b/backend/internal/preview/ffmpeg_test.go index cfb58ba..ad03c99 100644 --- a/backend/internal/preview/ffmpeg_test.go +++ b/backend/internal/preview/ffmpeg_test.go @@ -1,6 +1,7 @@ package preview import ( + "context" "errors" "math" "net/http" @@ -94,6 +95,69 @@ func TestTinyVideoPreviewPlanUsesWholeVideoAsSingleSegment(t *testing.T) { } } +func TestTeaserCandidateStartsKeepPrimaryAndAddFallbacks(t *testing.T) { + primary := []float64{10.2, 64.65, 119.1, 173.55} + got := teaserCandidateStarts(204, primary, 3) + if len(got) <= len(primary) { + t.Fatalf("candidate starts = %#v, want fallback starts after primary", got) + } + for i, want := range primary { + if math.Abs(got[i]-want) > 0.001 { + t.Fatalf("candidate[%d] = %.2f, want primary %.2f first", i, got[i], want) + } + } +} + +func TestTeaserSegmentFallbackAllowedForBadSegmentOutput(t *testing.T) { + for _, err := range []error{ + errors.New("generated teaser has no video stream"), + errors.New("ffmpeg segment: signal: killed, stderr: "), + errors.New("ffmpeg segment produced empty file, stderr: "), + } { + if !teaserSegmentFallbackAllowed(err) { + t.Fatalf("teaserSegmentFallbackAllowed(%v) = false, want true", err) + } + } + if teaserSegmentFallbackAllowed(errors.New("server returned 403 forbidden")) { + t.Fatal("403 errors should not trigger teaser segment fallback") + } +} + +func TestTeaserSegmentFallbackRequiresPlannedSegmentCount(t *testing.T) { + err := errors.New("only generated 2/4 teaser segments: generated teaser has no video stream") + if !strings.Contains(err.Error(), "2/4") { + t.Fatalf("error = %v, want generated/planned segment count", err) + } +} + +func TestThumbnailOffsetsUseFiveSecondsWithEarlyFallbacks(t *testing.T) { + got := thumbnailOffsets() + want := []float64{5, 1, 0} + if len(got) != len(want) { + t.Fatalf("offsets = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("offset[%d] = %.2f, want %.2f", i, got[i], want[i]) + } + } +} + +func TestThumbnailOffsetFallbackAllowedForEmptyOutputAndTimeouts(t *testing.T) { + for _, err := range []error{ + errors.New("ffmpeg thumb produced empty file, stderr: "), + errors.New("ffmpeg thumb: signal: killed, stderr: "), + context.DeadlineExceeded, + } { + if !thumbnailOffsetFallbackAllowed(err) { + t.Fatalf("thumbnailOffsetFallbackAllowed(%v) = false, want true", err) + } + } + if thumbnailOffsetFallbackAllowed(errors.New("server returned 403 forbidden")) { + t.Fatal("403 errors should not trigger thumbnail offset fallback") + } +} + func TestFFmpeg429OutputBecomesRateLimitError(t *testing.T) { err := ffmpegCommandError("ffmpeg", errors.New("exit status 8"), []byte("Server returned 429 Too Many Requests")) var rateLimit *drives.RateLimitError diff --git a/backend/internal/preview/worker_test.go b/backend/internal/preview/worker_test.go index 7336247..e4b06df 100644 --- a/backend/internal/preview/worker_test.go +++ b/backend/internal/preview/worker_test.go @@ -109,6 +109,74 @@ func TestPreviewWorkerGeneratesTeaserWithoutReplacingExistingThumbnail(t *testin } } +func TestPreviewWorkerDeduplicatesQueuedVideos(t *testing.T) { + ctx := context.Background() + cat, video := seedPreviewTestVideo(t, "preview-dedupe-video") + + gen := &fakeTeaserGenerator{} + drv := &previewFakeDrive{} + worker := NewWorker(gen, cat, drv, "") + + if !worker.EnqueueBlocking(ctx, video) { + t.Fatal("first enqueue returned false, want true") + } + if !worker.EnqueueBlocking(ctx, video) { + t.Fatal("duplicate enqueue returned false, want idempotent success") + } + if got := worker.Status().QueueLength; got != 1 { + t.Fatalf("queue length = %d, want 1 unique video", got) + } + + queued := <-worker.ch + if !worker.Enqueue(video) { + t.Fatal("enqueue while the same video is reserved returned false, want idempotent success") + } + select { + case <-worker.ch: + t.Fatal("duplicate enqueue added another queued video") + default: + } + + worker.processQueued(ctx, queued) + if !worker.Enqueue(video) { + t.Fatal("enqueue after processing returned false, want true") + } +} + +func TestThumbWorkerDeduplicatesQueuedVideos(t *testing.T) { + ctx := context.Background() + cat, video := seedPreviewTestVideo(t, "thumb-dedupe-video") + + gen := &fakeThumbGenerator{} + drv := &previewFakeDrive{} + worker := NewThumbWorker(gen, cat, drv) + + if !worker.Enqueue(video) { + t.Fatal("first enqueue returned false, want true") + } + if !worker.Enqueue(video) { + t.Fatal("duplicate enqueue returned false, want idempotent success") + } + if got := worker.Status().QueueLength; got != 1 { + t.Fatalf("queue length = %d, want 1 unique video", got) + } + + queued := <-worker.ch + if !worker.Enqueue(video) { + t.Fatal("enqueue while the same thumbnail is reserved returned false, want idempotent success") + } + select { + case <-worker.ch: + t.Fatal("duplicate enqueue added another queued thumbnail") + default: + } + + worker.processQueued(ctx, queued) + if !worker.Enqueue(video) { + t.Fatal("enqueue after release returned false, want true") + } +} + func TestPreviewWorkerRemovesPreviousLocalTeaserAfterNewTeaserIsReady(t *testing.T) { ctx := context.Background() cat, video := seedPreviewTestVideo(t, "preview-cleanup-video") diff --git a/backend/internal/proxy/proxy.go b/backend/internal/proxy/proxy.go index 86979fc..3e992ec 100644 --- a/backend/internal/proxy/proxy.go +++ b/backend/internal/proxy/proxy.go @@ -11,6 +11,10 @@ import ( "github.com/video-site/backend/internal/drives" ) +type streamURLWithHeader interface { + StreamURLWithHeader(ctx context.Context, fileID string, header http.Header) (*drives.StreamLink, error) +} + // Registry 管理多个 Drive 实例 type Registry struct { mu sync.RWMutex @@ -53,7 +57,7 @@ func (r *Registry) Remove(id string) { // Proxy 根据 driveID + fileID 反向代理到真实网盘直链 type Proxy struct { Registry *Registry - // linkCache key: driveID + "/" + fileID,value: cachedLink + // linkCache key: driveID + "/" + fileID (+ User-Agent for UA-bound links) cacheMu sync.Mutex cache map[string]cachedLink http *http.Client @@ -74,8 +78,8 @@ func New(r *Registry) *Proxy { } } -func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.StreamLink, error) { - key := driveID + "/" + fileID +func (p *Proxy) getLink(ctx context.Context, d drives.Drive, driveID, fileID string, header http.Header) (*drives.StreamLink, error) { + key := linkCacheKey(d, driveID, fileID, header) p.cacheMu.Lock() if c, ok := p.cache[key]; ok { @@ -87,11 +91,15 @@ func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.St } p.cacheMu.Unlock() - d, ok := p.Registry.Get(driveID) - if !ok { - return nil, errDriveNotFound + var ( + link *drives.StreamLink + err error + ) + if h, ok := d.(streamURLWithHeader); ok { + link, err = h.StreamURLWithHeader(ctx, fileID, header) + } else { + link, err = d.StreamURL(ctx, fileID) } - link, err := d.StreamURL(ctx, fileID) if err != nil { return nil, err } @@ -101,15 +109,43 @@ func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.St return link, nil } +func linkCacheKey(d drives.Drive, driveID, fileID string, header http.Header) string { + key := driveID + "/" + fileID + if _, ok := d.(streamURLWithHeader); ok { + key += "|ua=" + header.Get("User-Agent") + } + return key +} + func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) { - link, err := p.getLink(r.Context(), driveID, fileID) + d, ok := p.Registry.Get(driveID) + if !ok { + http.Error(w, errDriveNotFound.Error(), errDriveNotFound.Code) + return + } + + link, err := p.getLink(r.Context(), d, driveID, fileID, r.Header) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } + if shouldRedirect(d) { + redirect(w, r, link) + return + } p.serve(w, r, link) } +func shouldRedirect(d drives.Drive) bool { + return d.Kind() == "p115" +} + +func redirect(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cache-Control", "max-age=0, no-cache, no-store, must-revalidate") + http.Redirect(w, r, link.URL, http.StatusFound) +} + func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) { // 构造上游请求 u, err := url.Parse(link.URL) diff --git a/backend/internal/proxy/proxy_test.go b/backend/internal/proxy/proxy_test.go new file mode 100644 index 0000000..c73228b --- /dev/null +++ b/backend/internal/proxy/proxy_test.go @@ -0,0 +1,109 @@ +package proxy + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/video-site/backend/internal/drives" +) + +func TestServeStreamRedirectsP115WithRequestUserAgent(t *testing.T) { + reg := NewRegistry() + drv := &proxyFakeDrive{kind: "p115"} + reg.Set("115", drv) + + p := New(reg) + req := httptest.NewRequest(http.MethodGet, "/p/stream/115/file-1", nil) + req.Header.Set("User-Agent", "Browser-A") + rr := httptest.NewRecorder() + + p.ServeStream(rr, req, "115", "file-1") + + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound) + } + if got := rr.Header().Get("Location"); got != "https://cdn.example/file-1?ua=Browser-A" { + t.Fatalf("Location = %q", got) + } + if got := drv.calls[0].ua; got != "Browser-A" { + t.Fatalf("link UA = %q, want request UA", got) + } + if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" { + t.Fatalf("Referrer-Policy = %q", got) + } +} + +func TestServeStreamP115CacheIsUserAgentScoped(t *testing.T) { + reg := NewRegistry() + drv := &proxyFakeDrive{kind: "p115"} + reg.Set("115", drv) + + p := New(reg) + + requestP115(t, p, "115", "file-1", "Browser-A") + requestP115(t, p, "115", "file-1", "Browser-B") + requestP115(t, p, "115", "file-1", "Browser-A") + + if len(drv.calls) != 2 { + t.Fatalf("link calls = %d, want 2", len(drv.calls)) + } + if drv.calls[0].ua != "Browser-A" || drv.calls[1].ua != "Browser-B" { + t.Fatalf("link UAs = %#v", drv.calls) + } +} + +func requestP115(t *testing.T, p *Proxy, driveID, fileID, ua string) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/p/stream/"+driveID+"/"+fileID, nil) + req.Header.Set("User-Agent", ua) + rr := httptest.NewRecorder() + p.ServeStream(rr, req, driveID, fileID) + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound) + } +} + +type proxyFakeDrive struct { + kind string + calls []proxyFakeCall +} + +type proxyFakeCall struct { + fileID string + ua string +} + +func (d *proxyFakeDrive) Kind() string { return d.kind } +func (d *proxyFakeDrive) ID() string { return "fake" } +func (d *proxyFakeDrive) Init(context.Context) error { + return nil +} +func (d *proxyFakeDrive) List(context.Context, string) ([]drives.Entry, error) { + return nil, drives.ErrNotSupported +} +func (d *proxyFakeDrive) Stat(context.Context, string) (*drives.Entry, error) { + return nil, drives.ErrNotSupported +} +func (d *proxyFakeDrive) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + return d.StreamURLWithHeader(ctx, fileID, nil) +} +func (d *proxyFakeDrive) StreamURLWithHeader(_ context.Context, fileID string, header http.Header) (*drives.StreamLink, error) { + ua := header.Get("User-Agent") + d.calls = append(d.calls, proxyFakeCall{fileID: fileID, ua: ua}) + return &drives.StreamLink{ + URL: "https://cdn.example/" + fileID + "?ua=" + ua, + Headers: http.Header{"User-Agent": {ua}}, + Expires: time.Now().Add(time.Minute), + }, nil +} +func (d *proxyFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) { + return "", drives.ErrNotSupported +} +func (d *proxyFakeDrive) EnsureDir(context.Context, string) (string, error) { + return "", drives.ErrNotSupported +} +func (d *proxyFakeDrive) RootID() string { return "0" } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b73e3b0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1773 @@ +{ + "name": "video-site", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "video-site", + "version": "0.1.0", + "dependencies": { + "lucide-react": "0.453.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.26.2" + }, + "devDependencies": { + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "4.3.3", + "typescript": "5.6.3", + "vite": "5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz", + "integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.3.tgz", + "integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.355", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz", + "integrity": "sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.453.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz", + "integrity": "sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz", + "integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.19.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz", + "integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.19.2", + "react-router": "6.26.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index a9559fc..44f7472 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -5,20 +5,25 @@ import { SearchPanel } from "@/components/SearchPanel"; import { TagCloud } from "@/components/TagCloud"; import { SectionHeader } from "@/components/SectionHeader"; import { VideoGrid } from "@/components/VideoGrid"; -import { fetchHomeVideos } from "@/data/videos"; +import { fetchHomeVideos, fetchListing } from "@/data/videos"; import type { VideoItem } from "@/types"; export default function HomePage() { - const [videos, setVideos] = useState([]); + const [rankingVideos, setRankingVideos] = useState([]); + const [latestVideos, setLatestVideos] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { document.title = "首页 · 视频聚合站"; let active = true; setLoading(true); - fetchHomeVideos().then((items) => { + Promise.all([ + fetchHomeVideos(), + fetchListing(1, 10, { sort: "latest" }), + ]).then(([rankingItems, latestResult]) => { if (!active) return; - setVideos(items); + setRankingVideos(rankingItems); + setLatestVideos(latestResult.items); setLoading(false); }); return () => { @@ -35,17 +40,13 @@ export default function HomePage() {
- - + +
- +
); diff --git a/start.sh b/start.sh index 5a9e6a7..4f2e19b 100755 --- a/start.sh +++ b/start.sh @@ -3,6 +3,10 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export HOME="${HOME:-/root}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +export GOCACHE="${GOCACHE:-/tmp/video-site-91/go-build}" + FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}" FRONTEND_PORT="${FRONTEND_PORT:-9191}" FRONTEND_MODE="${FRONTEND_MODE:-preview}" @@ -101,7 +105,7 @@ start_backend() { fi need_cmd go - mkdir -p "$LOG_DIR" + mkdir -p "$LOG_DIR" "$GOCACHE" echo "starting backend on 127.0.0.1:$BACKEND_PORT" ( cd "$ROOT_DIR/backend"