summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/api/handlers_test.go51
-rw-r--r--internal/model/media.go138
-rw-r--r--internal/service/media_test.go13
-rw-r--r--internal/service/service.go18
-rw-r--r--web/js/app.js11
-rw-r--r--web/js/player.js10
-rw-r--r--web/js/tests/playback-resume.test.js51
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);
+}