diff --git a/backend/internal/catalog/catalog.go b/backend/internal/catalog/catalog.go index 644c562..679ef52 100644 --- a/backend/internal/catalog/catalog.go +++ b/backend/internal/catalog/catalog.go @@ -78,6 +78,7 @@ type Video struct { TranscodedFileID string `json:"transcodedFileId"` TranscodedSize int64 `json:"transcodedSize"` Views int `json:"views"` + LastViewedAt time.Time `json:"lastViewedAt"` Favorites int `json:"favorites"` Comments int `json:"comments"` Likes int `json:"likes"` @@ -112,13 +113,13 @@ INSERT INTO videos ( id, drive_id, file_id, file_name, content_hash, sampled_sha256, fingerprint_status, fingerprint_error, parent_id, title, author, tags, duration_seconds, size_bytes, ext, quality, thumbnail_url, thumbnail_status, preview_file_id, preview_local, preview_status, - views, favorites, comments, likes, dislikes, + views, last_viewed_at, favorites, comments, likes, dislikes, category, hidden, badges, description, published_at, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN COALESCE(?, '') != '' THEN 'ready' ELSE 'pending' END, ?, ?, ?, - ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(id) DO UPDATE SET @@ -169,7 +170,7 @@ ON CONFLICT(id) DO UPDATE SET v.ID, v.DriveID, v.FileID, v.FileName, v.ContentHash, v.SampledSHA256, fingerprintStatus, v.FingerprintError, v.ParentID, v.Title, v.Author, string(tagsJSON), v.DurationSeconds, v.Size, v.Ext, v.Quality, v.ThumbnailURL, v.ThumbnailURL, v.PreviewFileID, v.PreviewLocal, nullableStatus(v.PreviewStatus), - v.Views, v.Favorites, v.Comments, v.Likes, v.Dislikes, + v.Views, unixMilliOrZero(v.LastViewedAt), v.Favorites, v.Comments, v.Likes, v.Dislikes, v.Category, boolToInt(v.Hidden), string(badgesJSON), v.Description, v.PublishedAt.UnixMilli(), v.CreatedAt.UnixMilli(), v.UpdatedAt.UnixMilli(), ) @@ -423,9 +424,10 @@ func (c *Catalog) IncrementView(ctx context.Context, id string) (int, error) { return 0, err } defer tx.Rollback() + now := time.Now().UnixMilli() res, err := tx.ExecContext(ctx, - `UPDATE videos SET views = views + 1, updated_at = ? WHERE id = ?`, - time.Now().UnixMilli(), id) + `UPDATE videos SET views = views + 1, last_viewed_at = ?, updated_at = ? WHERE id = ?`, + now, now, id) if err != nil { return 0, err } @@ -1364,7 +1366,7 @@ type ListParams struct { DriveID string Tag string Category string - Sort string // latest | hot | week | long + Sort string // latest | hot | recent ThumbnailReadyOnly bool PreferReadyThumbnails bool SkipTotal bool @@ -1419,10 +1421,8 @@ func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, case "hot": // 热度 = 点赞数,点赞相同按最新 orderBy = " ORDER BY " + readyOrderPrefix + "likes DESC, published_at DESC" - case "week": - orderBy = " ORDER BY " + readyOrderPrefix + "likes DESC" - case "long": - orderBy = " ORDER BY " + readyOrderPrefix + "duration_seconds DESC" + case "recent": + orderBy = " ORDER BY " + readyOrderPrefix + "COALESCE(last_viewed_at, 0) DESC, published_at DESC" } var total int @@ -2215,7 +2215,7 @@ COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'), duration_seconds, size_bytes, COALESCE(ext, ''), COALESCE(quality, ''), COALESCE(thumbnail_url, ''), COALESCE(preview_file_id, ''), COALESCE(preview_local, ''), COALESCE(preview_status, 'pending'), COALESCE(transcode_status, ''), COALESCE(transcode_error, ''), COALESCE(transcoded_file_id, ''), COALESCE(transcoded_size, 0), -views, favorites, comments, likes, dislikes, +views, COALESCE(last_viewed_at, 0), favorites, comments, likes, dislikes, COALESCE(category, ''), COALESCE(hidden, 0), COALESCE(badges, '[]'), COALESCE(description, ''), published_at, created_at, updated_at ` @@ -2278,7 +2278,7 @@ type rowScanner interface { func scanVideo(row rowScanner) (*Video, error) { v := &Video{} var tagsJSON, badgesJSON string - var publishedAt, createdAt, updatedAt int64 + var publishedAt, createdAt, updatedAt, lastViewedAt int64 var hidden int err := row.Scan( &v.ID, &v.DriveID, &v.FileID, &v.FileName, &v.ContentHash, @@ -2287,7 +2287,7 @@ func scanVideo(row rowScanner) (*Video, error) { &v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL, &v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus, &v.TranscodeStatus, &v.TranscodeError, &v.TranscodedFileID, &v.TranscodedSize, - &v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes, + &v.Views, &lastViewedAt, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes, &v.Category, &hidden, &badgesJSON, &v.Description, &publishedAt, &createdAt, &updatedAt, ) @@ -2300,6 +2300,9 @@ func scanVideo(row rowScanner) (*Video, error) { v.PublishedAt = time.UnixMilli(publishedAt) v.CreatedAt = time.UnixMilli(createdAt) v.UpdatedAt = time.UnixMilli(updatedAt) + if lastViewedAt > 0 { + v.LastViewedAt = time.UnixMilli(lastViewedAt) + } return v, nil } @@ -2307,6 +2310,13 @@ func normalizeContentHash(hash string) string { return strings.ToLower(strings.TrimSpace(hash)) } +func unixMilliOrZero(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +} + func boolToInt(v bool) int { if v { return 1 diff --git a/backend/internal/catalog/list_sort_test.go b/backend/internal/catalog/list_sort_test.go new file mode 100644 index 0000000..b5f6555 --- /dev/null +++ b/backend/internal/catalog/list_sort_test.go @@ -0,0 +1,97 @@ +package catalog + +import ( + "context" + "testing" + "time" +) + +func TestIncrementViewStoresLastViewedAt(t *testing.T) { + ctx := context.Background() + cat, err := 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() + if err := cat.UpsertVideo(ctx, &Video{ + ID: "video-1", + DriveID: "drive", + FileID: "file-1", + Title: "Video 1", + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("seed video: %v", err) + } + + if _, err := cat.IncrementView(ctx, "video-1"); err != nil { + t.Fatalf("increment view: %v", err) + } + got, err := cat.GetVideo(ctx, "video-1") + if err != nil { + t.Fatalf("get video: %v", err) + } + if got.Views != 1 { + t.Fatalf("views = %d, want 1", got.Views) + } + if got.LastViewedAt.IsZero() { + t.Fatal("last viewed time was not stored") + } +} + +func TestListVideosRecentSortUsesLastViewedAt(t *testing.T) { + ctx := context.Background() + cat, err := 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() + for _, v := range []*Video{ + {ID: "old-view", DriveID: "drive", FileID: "old-view", Title: "Old View", PublishedAt: now.Add(3 * time.Hour), CreatedAt: now, UpdatedAt: now}, + {ID: "recent-view", DriveID: "drive", FileID: "recent-view", Title: "Recent View", PublishedAt: now, CreatedAt: now, UpdatedAt: now}, + {ID: "unviewed", DriveID: "drive", FileID: "unviewed", Title: "Unviewed", PublishedAt: now.Add(4 * time.Hour), CreatedAt: now, UpdatedAt: now}, + } { + if err := cat.UpsertVideo(ctx, v); err != nil { + t.Fatalf("seed %s: %v", v.ID, err) + } + } + if _, err := cat.db.ExecContext(ctx, + `UPDATE videos SET last_viewed_at = CASE id + WHEN 'old-view' THEN ? + WHEN 'recent-view' THEN ? + ELSE 0 + END`, + now.Add(-time.Hour).UnixMilli(), + now.Add(time.Hour).UnixMilli(), + ); err != nil { + t.Fatalf("seed last_viewed_at: %v", err) + } + + items, _, err := cat.ListVideos(ctx, ListParams{Sort: "recent", Page: 1, PageSize: 3}) + if err != nil { + t.Fatalf("list recent videos: %v", err) + } + if len(items) != 3 { + t.Fatalf("items = %d, want 3", len(items)) + } + got := []string{items[0].ID, items[1].ID, items[2].ID} + want := []string{"recent-view", "old-view", "unviewed"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("recent order = %#v, want %#v", got, want) + } + } +} diff --git a/backend/internal/catalog/schema.sql b/backend/internal/catalog/schema.sql index d59d8dc..9f04a32 100644 --- a/backend/internal/catalog/schema.sql +++ b/backend/internal/catalog/schema.sql @@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS videos ( transcoded_file_id TEXT DEFAULT '', -- 转码产物在同一 drive 上的 fileID,播放源优先用它 transcoded_size INTEGER DEFAULT 0, views INTEGER DEFAULT 0, + last_viewed_at INTEGER DEFAULT 0, favorites INTEGER DEFAULT 0, comments INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, diff --git a/backend/internal/catalog/tags.go b/backend/internal/catalog/tags.go index c0557e6..ce83924 100644 --- a/backend/internal/catalog/tags.go +++ b/backend/internal/catalog/tags.go @@ -66,6 +66,9 @@ func (c *Catalog) migrate(ctx context.Context) error { if err := c.addColumnIfMissing(ctx, "videos", "thumbnail_failures", "INTEGER DEFAULT 0"); err != nil { return err } + if err := c.addColumnIfMissing(ctx, "videos", "last_viewed_at", "INTEGER DEFAULT 0"); err != nil { + return err + } // videos.transcode_*:浏览器兼容性转码状态。 // status:''=未检测 / pending=已入队 / ready=已转码 / skipped=检测后无需转码 / failed=失败。 // transcoded_file_id 指向转码产物在同一 drive 上的 fileID,播放源优先使用它。 @@ -145,6 +148,9 @@ CREATE TABLE IF NOT EXISTS deleted_videos ( if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_visible_pub ON videos(COALESCE(hidden, 0), published_at DESC)`); err != nil { return err } + if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_last_viewed ON videos(last_viewed_at DESC)`); err != nil { + return err + } if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_file_name_size ON videos(file_name, size_bytes)`); err != nil { return err } diff --git a/package-lock.json b/package-lock.json index 1f7d05e..5cedbf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "video-site", - "version": "0.1.9", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "video-site", - "version": "0.1.9", + "version": "0.2.1", "license": "MIT", "dependencies": { "artplayer": "^5.4.0", diff --git a/package.json b/package.json index e163793..64be039 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "video-site", "private": true, "license": "MIT", - "version": "0.1.9", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/SortToolbar.tsx b/src/components/SortToolbar.tsx index eabdaa7..16ab63e 100644 --- a/src/components/SortToolbar.tsx +++ b/src/components/SortToolbar.tsx @@ -13,10 +13,7 @@ type Props = { const sortOptions: { key: SortKey; label: string }[] = [ { key: "latest", label: "最新" }, { key: "hot", label: "最热" }, - { key: "week", label: "本周" }, - { key: "long", label: "最长" }, - { key: "hd", label: "高清" }, - { key: "featured", label: "精选" }, + { key: "recent", label: "最近观看" }, ]; export function SortToolbar({ sort, view, onSortChange, onViewChange }: Props) { diff --git a/src/pages/ListingPage.tsx b/src/pages/ListingPage.tsx index 513ae6b..b521c1e 100644 --- a/src/pages/ListingPage.tsx +++ b/src/pages/ListingPage.tsx @@ -226,9 +226,6 @@ function isSortKey(value: unknown): value is SortKey { return ( value === "latest" || value === "hot" || - value === "week" || - value === "long" || - value === "hd" || - value === "featured" + value === "recent" ); } diff --git a/src/types.ts b/src/types.ts index d1a2f69..ce790c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,7 +57,7 @@ export type VideoDetail = VideoItem & { export type PreviewState = "idle" | "intent" | "loading" | "playing" | "error"; -export type SortKey = "latest" | "hot" | "week" | "long" | "hd" | "featured"; +export type SortKey = "latest" | "hot" | "recent"; export type TagItem = { id: string; diff --git a/tests/listSortOptions.test.ts b/tests/listSortOptions.test.ts new file mode 100644 index 0000000..5ddccb8 --- /dev/null +++ b/tests/listSortOptions.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const sortToolbarSource = readFileSync( + new URL("../src/components/SortToolbar.tsx", import.meta.url), + "utf8" +); +const typesSource = readFileSync(new URL("../src/types.ts", import.meta.url), "utf8"); + +test("list page sort toolbar only exposes active sort options", () => { + assert.match(sortToolbarSource, /\{ key: "latest", label: "最新" \}/); + assert.match(sortToolbarSource, /\{ key: "hot", label: "最热" \}/); + assert.match(sortToolbarSource, /\{ key: "recent", label: "最近观看" \}/); + + for (const removed of ["本周", "最长", "高清", "精选"]) { + assert.doesNotMatch(sortToolbarSource, new RegExp(removed)); + } + assert.match(typesSource, /export type SortKey = "latest" \| "hot" \| "recent";/); +});