diff options
| -rw-r--r-- | internal/api/handlers_test.go | 51 | ||||
| -rw-r--r-- | internal/model/media.go | 138 | ||||
| -rw-r--r-- | internal/service/media_test.go | 13 | ||||
| -rw-r--r-- | internal/service/service.go | 18 | ||||
| -rw-r--r-- | web/js/app.js | 11 | ||||
| -rw-r--r-- | web/js/player.js | 10 | ||||
| -rw-r--r-- | web/js/tests/playback-resume.test.js | 51 |
7 files changed, 201 insertions, 91 deletions
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 99c009b..e65b854 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -705,17 +705,28 @@ func TestServer_MediaList(t *testing.T) { func TestServer_MediaDetail(t *testing.T) { tests := []struct { - name string - id string - result *service.MediaDetail - err error - wantCode int - wantMedia bool + name string + id string + result *service.MediaDetail + err error + wantCode int + wantMedia bool + wantResumeFrom float64 + wantProgressNil bool }{ - {"ok", "42", &service.MediaDetail{Media: &model.Media{ID: 42, FileName: "a.mp4"}}, nil, http.StatusOK, true}, - {"invalid id", "abc", nil, nil, http.StatusBadRequest, false}, - {"not found", "7", nil, nil, http.StatusNotFound, false}, - {"service error", "7", nil, errors.New("boom"), http.StatusInternalServerError, false}, + { + name: "ok with progress", + id: "42", + result: &service.MediaDetail{Media: &model.Media{ID: 42, FileName: "a.mp4"}, Progress: &model.PlaybackProgress{UserID: 1, MediaID: 42, PositionSeconds: 77}}, + err: nil, + wantCode: http.StatusOK, + wantMedia: true, + wantResumeFrom: 77, + }, + {"ok without progress", "42", &service.MediaDetail{Media: &model.Media{ID: 42, FileName: "a.mp4"}}, nil, http.StatusOK, true, 0, true}, + {"invalid id", "abc", nil, nil, http.StatusBadRequest, false, 0, false}, + {"not found", "7", nil, nil, http.StatusNotFound, false, 0, false}, + {"service error", "7", nil, errors.New("boom"), http.StatusInternalServerError, false, 0, false}, } for _, tt := range tests { @@ -736,6 +747,26 @@ func TestServer_MediaDetail(t *testing.T) { if rr.Code != tt.wantCode { t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) } + if tt.wantCode != http.StatusOK { + return + } + var resp struct { + Media *model.Media `json:"media"` + Progress *model.PlaybackProgress `json:"progress"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal detail: %v", err) + } + if tt.wantMedia && resp.Media == nil { + t.Fatal("expected media in response") + } + var gotResume float64 + if resp.Progress != nil { + gotResume = resp.Progress.PositionSeconds + } + if gotResume != tt.wantResumeFrom { + t.Fatalf("expected resume_from %v, got %v", tt.wantResumeFrom, gotResume) + } }) } } diff --git a/internal/model/media.go b/internal/model/media.go index 1cb2949..b1b4ef3 100644 --- a/internal/model/media.go +++ b/internal/model/media.go @@ -21,121 +21,121 @@ const ( // User represents an application account. type User struct { - ID int64 - Username string - PasswordHash string - IsAdmin bool - CreatedAt time.Time + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` } // Set represents a top-level media collection (a directory under MEDIA_ROOT). type Set struct { - ID int64 - Name string - RootPath string - CoverThumbnailPath string - Permissions []SetPermission - CreatedAt time.Time + ID int64 `json:"id"` + Name string `json:"name"` + RootPath string `json:"root_path"` + CoverThumbnailPath string `json:"cover_thumbnail_path"` + Permissions []SetPermission `json:"permissions"` + CreatedAt time.Time `json:"created_at"` } // SetPermission grants a user access to a set. type SetPermission struct { - SetID int64 - UserID int64 - Role Role - CreatedAt time.Time + SetID int64 `json:"set_id"` + UserID int64 `json:"user_id"` + Role Role `json:"role"` + CreatedAt time.Time `json:"created_at"` } // Media represents a single audio or video file within a set. type Media struct { - ID int64 - SetID int64 - RelPath string - FileName string - AbsPath string - Type MediaType - Duration float64 - Codec string - Resolution string - Bitrate int - FileSizeBytes int64 - ThumbnailPath string - PlayCount int - DeletedAt *time.Time - CreatedAt time.Time + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + RelPath string `json:"rel_path"` + FileName string `json:"file_name"` + AbsPath string `json:"abs_path"` + Type MediaType `json:"type"` + Duration float64 `json:"duration"` + Codec string `json:"codec"` + Resolution string `json:"resolution"` + Bitrate int `json:"bitrate"` + FileSizeBytes int64 `json:"file_size_bytes"` + ThumbnailPath string `json:"thumbnail_path"` + PlayCount int `json:"play_count"` + DeletedAt *time.Time `json:"deleted_at"` + CreatedAt time.Time `json:"created_at"` } // Tag is a label that can be attached to media items. type Tag struct { - ID int64 - Name string + ID int64 `json:"id"` + Name string `json:"name"` } // Session is an authenticated browser session. type Session struct { - ID string - UserID int64 - ExpiresAt time.Time - CreatedAt time.Time + ID string `json:"id"` + UserID int64 `json:"user_id"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` } // Share is a time-bounded public link to a media item. type Share struct { - Token string - MediaID int64 - CreatedBy int64 - CreatedAt time.Time - ExpiresAt time.Time - MaxUses *int - UsedCount int + Token string `json:"token"` + MediaID int64 `json:"media_id"` + CreatedBy int64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + MaxUses *int `json:"max_uses"` + UsedCount int `json:"used_count"` } // Note is a per-user, per-media text note. type Note struct { - ID int64 - MediaID int64 - UserID int64 - Content string - CreatedAt time.Time - UpdatedAt time.Time + ID int64 `json:"id"` + MediaID int64 `json:"media_id"` + UserID int64 `json:"user_id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // PlaybackProgress stores the last known playback position. type PlaybackProgress struct { - UserID int64 - MediaID int64 - PositionSeconds float64 - UpdatedAt time.Time + UserID int64 `json:"user_id"` + MediaID int64 `json:"media_id"` + PositionSeconds float64 `json:"position_seconds"` + UpdatedAt time.Time `json:"updated_at"` } // PlaybackAccumulator tracks deltas for the 60-second playback counter rule. type PlaybackAccumulator struct { - SessionID string - MediaID int64 - LastPosition float64 - AccumulatedSeconds float64 - Counted bool - UpdatedAt time.Time + SessionID string `json:"session_id"` + MediaID int64 `json:"media_id"` + LastPosition float64 `json:"last_position"` + AccumulatedSeconds float64 `json:"accumulated_seconds"` + Counted bool `json:"counted"` + UpdatedAt time.Time `json:"updated_at"` } // Favorite records that a user has favorited a media item. type Favorite struct { - UserID int64 - MediaID int64 - CreatedAt time.Time + UserID int64 `json:"user_id"` + MediaID int64 `json:"media_id"` + CreatedAt time.Time `json:"created_at"` } // MediaTag is the join table between media and tags. type MediaTag struct { - MediaID int64 - TagID int64 + MediaID int64 `json:"media_id"` + TagID int64 `json:"tag_id"` } // Metadata holds extracted file properties from ffprobe and os.Stat. type Metadata struct { - Duration float64 - Codec string - Resolution string - Bitrate int - FileSizeBytes int64 + Duration float64 `json:"duration"` + Codec string `json:"codec"` + Resolution string `json:"resolution"` + Bitrate int `json:"bitrate"` + FileSizeBytes int64 `json:"file_size_bytes"` } diff --git a/internal/service/media_test.go b/internal/service/media_test.go index 8dccef9..e3e7b42 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -242,6 +242,19 @@ func TestMediaService_GetMediaDetail(t *testing.T) { if detail.Media.ID != tt.mediaID { t.Fatalf("unexpected media id %d", detail.Media.ID) } + if tt.progress != nil { + if detail.Progress == nil { + t.Fatal("expected progress in detail") + } + if detail.Progress.PositionSeconds != tt.progress.PositionSeconds { + t.Fatalf("expected position %v, got %v", tt.progress.PositionSeconds, detail.Progress.PositionSeconds) + } + if detail.ResumeFrom() != tt.progress.PositionSeconds { + t.Fatalf("expected ResumeFrom %v, got %v", tt.progress.PositionSeconds, detail.ResumeFrom()) + } + } else if detail.Progress != nil { + t.Fatal("unexpected progress in detail") + } }) } } diff --git a/internal/service/service.go b/internal/service/service.go index 59f4e7d..467aaf6 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -62,9 +62,17 @@ type FileResult struct { // MediaDetail combines media with related data. type MediaDetail struct { - Media *model.Media - Tags []model.Tag - Favorite bool - Note *model.Note - Progress *model.PlaybackProgress + Media *model.Media `json:"media"` + Tags []model.Tag `json:"tags"` + Favorite bool `json:"favorite"` + Note *model.Note `json:"note,omitempty"` + Progress *model.PlaybackProgress `json:"progress,omitempty"` +} + +// ResumeFrom returns the saved playback position in seconds, or 0 if none. +func (d *MediaDetail) ResumeFrom() float64 { + if d.Progress != nil { + return d.Progress.PositionSeconds + } + return 0 } diff --git a/web/js/app.js b/web/js/app.js index 8e8393e..2238e5b 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -243,12 +243,19 @@ function renderItem(m, index) { `; } -function playSelected() { +async function playSelected() { const el = currentElement(); if (!el) return; const idx = parseInt(el.dataset.index, 10); const media = state.media[idx]; - if (media) selectAndPlay(media, idx); + if (!media) return; + try { + const detail = await API.mediaDetail(media.id); + const resumeFrom = detail?.progress?.position_seconds ?? 0; + selectAndPlay(media, idx, resumeFrom); + } catch { + selectAndPlay(media, idx, 0); + } } async function shareSelected() { diff --git a/web/js/player.js b/web/js/player.js index f87dd8d..ed3e8bf 100644 --- a/web/js/player.js +++ b/web/js/player.js @@ -89,16 +89,16 @@ export function togglePlay() { if (m.paused) { m.play().catch(() => {}); } else { m.pause(); } } -export function selectAndPlay(media, index) { +export function selectAndPlay(media, index, resumeFrom = 0) { currentMedia = media; currentMediaIndex = index ?? -1; - loadMedia(media); + loadMedia(media, resumeFrom); isPlaying = true; currentMediaElement()?.play().catch(() => {}); highlightPlayingCard(); } -function loadMedia(media) { +function loadMedia(media, resumeFrom = 0) { const e = els(); const isVideo = media.type === 'video'; const src = `/api/media/${media.id}/stream`; @@ -107,13 +107,13 @@ function loadMedia(media) { e.audio.style.display = 'none'; e.audio.pause(); e.audio.src = ''; e.video.src = src; - e.video.currentTime = media.resume_from ?? 0; + e.video.currentTime = resumeFrom; } else { e.video.style.display = 'none'; e.audio.style.display = ''; e.video.pause(); e.video.src = ''; e.audio.src = src; - e.audio.currentTime = media.resume_from ?? 0; + e.audio.currentTime = resumeFrom; } e.player?.classList.add('open'); e.btnPlay.textContent = '⏸'; diff --git a/web/js/tests/playback-resume.test.js b/web/js/tests/playback-resume.test.js new file mode 100644 index 0000000..2c9d199 --- /dev/null +++ b/web/js/tests/playback-resume.test.js @@ -0,0 +1,51 @@ +import { API } from '../api.js'; +import { state } from '../state.js'; + +// --- Minimal test harness for browser module validation --- +const failures = []; +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +// Mock fetch and DOM for headless validation +const mockDetail = { + media: { id: 7, file_name: 'song.mp3', type: 'audio', duration: 180 }, + progress: { user_id: 1, media_id: 7, position_seconds: 42.5, updated_at: new Date().toISOString() } +}; + +// We can't run the real module in Node without DOM, so we test the JSON shape contract instead. +function testDetailShape() { + assert(mockDetail.media.id === 7, 'media.id should exist'); + assert(mockDetail.progress.position_seconds === 42.5, 'progress.position_seconds should be 42.5'); +} + +function testResumeFromComputation() { + const detailWithProgress = { progress: { position_seconds: 99 } }; + const detailWithout = { progress: null }; + const resumeFrom = detailWithProgress.progress ? detailWithProgress.progress.position_seconds : 0; + assert(resumeFrom === 99, 'resumeFrom should be 99 when progress exists'); + const resumeFromNone = detailWithout.progress ? detailWithout.progress.position_seconds : 0; + assert(resumeFromNone === 0, 'resumeFrom should be 0 when no progress'); +} + +function testListItemShape() { + const item = { id: 1, file_name: 'a.mp4', type: 'video', duration: 120 }; + assert(item.id === 1, 'list item id'); + assert(item.file_name === 'a.mp4', 'list item file_name'); + assert(!('resume_from' in item), 'list item should not have resume_from'); +} + +// Run tests +console.log('Running playback resume frontend contract tests...'); +testDetailShape(); +testResumeFromComputation(); +testListItemShape(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All frontend contract tests passed.'); + process.exit(0); +} |
