diff options
Diffstat (limited to 'player-server/internal/api')
| -rw-r--r-- | player-server/internal/api/handlers_media.go | 24 | ||||
| -rw-r--r-- | player-server/internal/api/handlers_playback_test.go | 249 | ||||
| -rw-r--r-- | player-server/internal/api/server.go | 102 |
3 files changed, 326 insertions, 49 deletions
diff --git a/player-server/internal/api/handlers_media.go b/player-server/internal/api/handlers_media.go index fb49189..fa7a98d 100644 --- a/player-server/internal/api/handlers_media.go +++ b/player-server/internal/api/handlers_media.go @@ -388,6 +388,30 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { } // ------------------------------------------------------------------ +// Playback hints +// ------------------------------------------------------------------ + +// handlePlaybackHints returns codec/container metadata for a media item so that +// the client can decide whether to play natively or request a future transcoded +// variant. It performs no actual transcoding — only a DB lookup. +func (s *Server) handlePlaybackHints(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.playbackHintSvc) { + return + } + id := pathID(r, "id") + if id == 0 { + badRequest(w, "invalid media id") + return + } + hint, err := s.playbackHintSvc.GetPlaybackHint(r.Context(), id, userIDFromContext(r)) + if err != nil { + handleError(w, err) + return + } + writeJSON(w, http.StatusOK, hint) +} + +// ------------------------------------------------------------------ // Notes // ------------------------------------------------------------------ diff --git a/player-server/internal/api/handlers_playback_test.go b/player-server/internal/api/handlers_playback_test.go new file mode 100644 index 0000000..5b14b39 --- /dev/null +++ b/player-server/internal/api/handlers_playback_test.go @@ -0,0 +1,249 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "codeberg.org/snonux/player/internal" + "codeberg.org/snonux/player/internal/auth" + "codeberg.org/snonux/player/internal/clock" + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/repository" + "codeberg.org/snonux/player/internal/service" +) + +// newPlaybackTestServer creates a Server wired with a PlaybackHintsService for testing. +func newPlaybackTestServer(t *testing.T, store repository.Store, sm auth.SessionManager, hintSvc service.PlaybackHintsService) *Server { + t.Helper() + fs := newTestFS(map[string]string{ + "index.html": "index", "login.html": "login", + "bootstrap.html": "bootstrap", "share.html": "share", + }) + authSvc := &service.MockAuthService{ + CountUsersFunc: func(context.Context) (int, error) { return 1, nil }, + GetUserByIDFunc: func(context.Context, int64) (*model.User, error) { return &model.User{ID: 1, IsAdmin: true}, nil }, + } + return NewServer(ServerDeps{ + Store: store, + SessionManager: sm, + Config: &internal.Config{}, + Services: ServerServices{ + Auth: authSvc, + PlaybackHints: hintSvc, + }, + StaticFS: fs, + }) +} + +// sessionForPlaybackTest creates a session cookie that resolves to userID 1. +func sessionForPlaybackTest(t *testing.T, store repository.Store, sm auth.SessionManager) *http.Cookie { + t.Helper() + return addSessionCookie(t, store, sm, 1) +} + +// ------------------------------------------------------------------ +// GET /api/v1/media/{id}/playback +// ------------------------------------------------------------------ + +func TestHandlePlaybackHints_Success(t *testing.T) { + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + + hint := &service.PlaybackHint{ + StreamURL: "/api/v1/media/5/stream", + Container: "mp4", + VideoCodec: "h264", + AudioCodec: "aac", + DurationSeconds: 300.0, + FileSizeBytes: 1234567, + Width: 1920, + Height: 1080, + Bitrate: 4000000, + NeedsTranscode: false, + } + hintSvc := &service.MockPlaybackHintsService{ + GetPlaybackHintFunc: func(_ context.Context, mediaID, userID int64) (*service.PlaybackHint, error) { + if mediaID != 5 || userID != 1 { + return nil, errors.New("unexpected args") + } + return hint, nil + }, + } + + srv := newPlaybackTestServer(t, store, sm, hintSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/media/5/playback", nil) + req.AddCookie(sessionForPlaybackTest(t, store, sm)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var got service.PlaybackHint + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Container != "mp4" { + t.Errorf("Container: want mp4, got %s", got.Container) + } + if got.VideoCodec != "h264" { + t.Errorf("VideoCodec: want h264, got %s", got.VideoCodec) + } + if got.AudioCodec != "aac" { + t.Errorf("AudioCodec: want aac, got %s", got.AudioCodec) + } + if got.NeedsTranscode { + t.Error("NeedsTranscode: want false, got true") + } + if got.DurationSeconds != 300.0 { + t.Errorf("DurationSeconds: want 300, got %f", got.DurationSeconds) + } +} + +func TestHandlePlaybackHints_InvalidID(t *testing.T) { + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + hintSvc := &service.MockPlaybackHintsService{} + + srv := newPlaybackTestServer(t, store, sm, hintSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/media/abc/playback", nil) + req.AddCookie(sessionForPlaybackTest(t, store, sm)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid id, got %d", rr.Code) + } +} + +func TestHandlePlaybackHints_NotFound(t *testing.T) { + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + hintSvc := &service.MockPlaybackHintsService{ + GetPlaybackHintFunc: func(_ context.Context, _, _ int64) (*service.PlaybackHint, error) { + return nil, service.ErrNotFound + }, + } + + srv := newPlaybackTestServer(t, store, sm, hintSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/media/99/playback", nil) + req.AddCookie(sessionForPlaybackTest(t, store, sm)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestHandlePlaybackHints_Forbidden(t *testing.T) { + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + hintSvc := &service.MockPlaybackHintsService{ + GetPlaybackHintFunc: func(_ context.Context, _, _ int64) (*service.PlaybackHint, error) { + return nil, service.ErrForbidden + }, + } + + srv := newPlaybackTestServer(t, store, sm, hintSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/media/3/playback", nil) + req.AddCookie(sessionForPlaybackTest(t, store, sm)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rr.Code) + } +} + +func TestHandlePlaybackHints_NoService(t *testing.T) { + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + + // Passing nil PlaybackHintsService should yield 501. + srv := newPlaybackTestServer(t, store, sm, nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/media/1/playback", nil) + req.AddCookie(sessionForPlaybackTest(t, store, sm)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotImplemented { + t.Fatalf("expected 501, got %d", rr.Code) + } +} + +func TestHandlePlaybackHints_LegacyPath(t *testing.T) { + // The route is also available under /api/media/{id}/playback (handleBoth). + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + + hint := &service.PlaybackHint{Container: "mkv", NeedsTranscode: true} + hintSvc := &service.MockPlaybackHintsService{ + GetPlaybackHintFunc: func(_ context.Context, _, _ int64) (*service.PlaybackHint, error) { + return hint, nil + }, + } + + srv := newPlaybackTestServer(t, store, sm, hintSvc) + req := httptest.NewRequest(http.MethodGet, "/api/media/2/playback", nil) + req.AddCookie(sessionForPlaybackTest(t, store, sm)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 on legacy path, got %d: %s", rr.Code, rr.Body.String()) + } + + var got service.PlaybackHint + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if !got.NeedsTranscode { + t.Error("expected NeedsTranscode=true for mkv") + } +} + +func TestHandlePlaybackHints_MKVNeedsTranscode(t *testing.T) { + // End-to-end: hintSvc returns a real hint for an mkv file with exotic codec. + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + + hint := &service.PlaybackHint{ + StreamURL: "/api/v1/media/10/stream", + Container: "mkv", + VideoCodec: "h264", + AudioCodec: "ac3", + NeedsTranscode: true, + } + hintSvc := &service.MockPlaybackHintsService{ + GetPlaybackHintFunc: func(_ context.Context, _, _ int64) (*service.PlaybackHint, error) { + return hint, nil + }, + } + + srv := newPlaybackTestServer(t, store, sm, hintSvc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/media/10/playback", nil) + req.AddCookie(sessionForPlaybackTest(t, store, sm)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var got service.PlaybackHint + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if !got.NeedsTranscode { + t.Error("expected NeedsTranscode=true for mkv/ac3") + } + if got.Container != "mkv" { + t.Errorf("container: want mkv, got %s", got.Container) + } +} diff --git a/player-server/internal/api/server.go b/player-server/internal/api/server.go index 5a7998b..608e934 100644 --- a/player-server/internal/api/server.go +++ b/player-server/internal/api/server.go @@ -16,41 +16,43 @@ import ( // Server holds HTTP handlers and dependencies. type Server struct { - store repository.Store - hasher auth.Hasher - sm auth.SessionManager - cfg *internal.Config - mux *http.ServeMux - handler http.Handler - browseSvc service.MediaBrowseService - writeSvc service.MediaWriteService - shareSvc service.MediaShareService - tagSvc service.MediaTagService - favSvc service.MediaFavoriteService - noteSvc service.MediaNoteService - adminSvc service.AdminService - progressSvc service.ProgressService - authSvc service.AuthService - podcastSvc service.PodcastEpisodeService - streamer service.MediaStreamer - staticFS http.FileSystem - logger *slog.Logger - mw *Middleware + store repository.Store + hasher auth.Hasher + sm auth.SessionManager + cfg *internal.Config + mux *http.ServeMux + handler http.Handler + browseSvc service.MediaBrowseService + writeSvc service.MediaWriteService + shareSvc service.MediaShareService + tagSvc service.MediaTagService + favSvc service.MediaFavoriteService + noteSvc service.MediaNoteService + adminSvc service.AdminService + progressSvc service.ProgressService + authSvc service.AuthService + podcastSvc service.PodcastEpisodeService + playbackHintSvc service.PlaybackHintsService + streamer service.MediaStreamer + staticFS http.FileSystem + logger *slog.Logger + mw *Middleware } // ServerServices groups the optional service dependencies used by route handlers. // If any service is nil, its respective routes return 501. type ServerServices struct { - Browse service.MediaBrowseService - Write service.MediaWriteService - Share service.MediaShareService - Tag service.MediaTagService - Favorite service.MediaFavoriteService - Note service.MediaNoteService - Admin service.AdminService - Progress service.ProgressService - Auth service.AuthService - Podcast service.PodcastEpisodeService + Browse service.MediaBrowseService + Write service.MediaWriteService + Share service.MediaShareService + Tag service.MediaTagService + Favorite service.MediaFavoriteService + Note service.MediaNoteService + Admin service.AdminService + Progress service.ProgressService + Auth service.AuthService + Podcast service.PodcastEpisodeService + PlaybackHints service.PlaybackHintsService } // ServerDeps contains the dependencies needed to construct a Server. @@ -81,25 +83,26 @@ func NewServerWithLogger(deps ServerDeps, logger *slog.Logger) *Server { logger = slog.Default() } s := &Server{ - store: deps.Store, - hasher: deps.Hasher, - sm: deps.SessionManager, - cfg: deps.Config, - mux: http.NewServeMux(), - browseSvc: deps.Services.Browse, - writeSvc: deps.Services.Write, - shareSvc: deps.Services.Share, - tagSvc: deps.Services.Tag, - favSvc: deps.Services.Favorite, - noteSvc: deps.Services.Note, - adminSvc: deps.Services.Admin, - progressSvc: deps.Services.Progress, - authSvc: deps.Services.Auth, - podcastSvc: deps.Services.Podcast, - streamer: deps.MediaStreamer, - staticFS: deps.StaticFS, - logger: logger, - mw: NewMiddleware(deps.Services.Auth, deps.SessionManager), + store: deps.Store, + hasher: deps.Hasher, + sm: deps.SessionManager, + cfg: deps.Config, + mux: http.NewServeMux(), + browseSvc: deps.Services.Browse, + writeSvc: deps.Services.Write, + shareSvc: deps.Services.Share, + tagSvc: deps.Services.Tag, + favSvc: deps.Services.Favorite, + noteSvc: deps.Services.Note, + adminSvc: deps.Services.Admin, + progressSvc: deps.Services.Progress, + authSvc: deps.Services.Auth, + podcastSvc: deps.Services.Podcast, + playbackHintSvc: deps.Services.PlaybackHints, + streamer: deps.MediaStreamer, + staticFS: deps.StaticFS, + logger: logger, + mw: NewMiddleware(deps.Services.Auth, deps.SessionManager), } s.routes() s.handler = withCORS(s.cfg.CORSAllowedOrigins, s.mw.BootstrapRedirect(s.mux)) @@ -224,6 +227,7 @@ func (s *Server) routesMedia() { s.handleBoth(http.MethodPost, "/api/media/{id}/restore", s.requireSession(s.handleRestore)) s.handleBoth(http.MethodPost, "/api/media/{id}/shares", s.requireSession(s.handleCreateShare)) s.handleBoth(http.MethodGet, "/api/media/{id}/shares", s.requireSession(s.handleListShares)) + s.handleBoth(http.MethodGet, "/api/media/{id}/playback", s.requireSession(s.handlePlaybackHints)) } // routesNotes wires the notes API routes. |
