diff options
Diffstat (limited to 'internal')
| -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 |
4 files changed, 136 insertions, 84 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 } |
