summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-server/cmd/player/main.go82
-rw-r--r--player-server/internal/api/handlers_media.go24
-rw-r--r--player-server/internal/api/handlers_playback_test.go249
-rw-r--r--player-server/internal/api/server.go102
-rw-r--r--player-server/internal/service/mock.go14
-rw-r--r--player-server/internal/service/playback_hints.go155
-rw-r--r--player-server/internal/service/playback_hints_test.go152
7 files changed, 690 insertions, 88 deletions
diff --git a/player-server/cmd/player/main.go b/player-server/cmd/player/main.go
index 04fdc14..72bff60 100644
--- a/player-server/cmd/player/main.go
+++ b/player-server/cmd/player/main.go
@@ -25,21 +25,22 @@ import (
// appDeps bundles all wired service-layer dependencies.
type appDeps struct {
- store repository.Store
- hasher auth.Hasher
- sm auth.SessionManager
- cfg *internal.Config
- clk clock.Clock
- mediaSvc service.MediaService
- adminSvc service.AdminService
- progressSvc service.ProgressService
- authSvc service.AuthService
- podcastSvc service.PodcastEpisodeService
- scanner scanner.Scanner
- gcWorker *service.GCWorker
- logger *slog.Logger
- appCtx context.Context
- workersStarted chan<- struct{}
+ store repository.Store
+ hasher auth.Hasher
+ sm auth.SessionManager
+ cfg *internal.Config
+ clk clock.Clock
+ mediaSvc service.MediaService
+ adminSvc service.AdminService
+ progressSvc service.ProgressService
+ authSvc service.AuthService
+ podcastSvc service.PodcastEpisodeService
+ playbackHintSvc service.PlaybackHintsService
+ scanner scanner.Scanner
+ gcWorker *service.GCWorker
+ logger *slog.Logger
+ appCtx context.Context
+ workersStarted chan<- struct{}
}
// parseVersionFlag parses CLI flags and returns whether --version was requested.
@@ -100,6 +101,7 @@ func wireDeps(cfg *internal.Config, store repository.Store, logger *slog.Logger,
helper := service.NewAccessHelper(store)
browser := service.NewPodcastBrowseService(store, cfg.MediaRoot)
mediaSvc := service.NewMediaServiceWithPodcastBrowser(store, clk, cfg.MediaRoot, thumbGen, prober, browser)
+ playbackHintSvc := service.NewPlaybackHintsService(helper)
fsScanner := scanner.NewFSScannerWithLogger(store, prober, thumbGen, clk, cfg.MediaRoot, logger)
adminSvc := service.NewAdminServiceWithLogger(store, clk, hasher, fsScanner, cfg.MediaRoot, appCtx, logger)
@@ -112,20 +114,21 @@ func wireDeps(cfg *internal.Config, store repository.Store, logger *slog.Logger,
gcWorker := service.NewGCWorker(store, clk, cfg.MediaRoot, time.Duration(cfg.GCIntervalMinutes)*time.Minute, logger)
return &appDeps{
- store: store,
- hasher: hasher,
- sm: sm,
- cfg: cfg,
- clk: clk,
- mediaSvc: mediaSvc,
- adminSvc: adminSvc,
- progressSvc: progressSvc,
- authSvc: authSvc,
- podcastSvc: podcastSvc,
- scanner: fsScanner,
- gcWorker: gcWorker,
- logger: logger,
- appCtx: appCtx,
+ store: store,
+ hasher: hasher,
+ sm: sm,
+ cfg: cfg,
+ clk: clk,
+ mediaSvc: mediaSvc,
+ adminSvc: adminSvc,
+ progressSvc: progressSvc,
+ authSvc: authSvc,
+ podcastSvc: podcastSvc,
+ playbackHintSvc: playbackHintSvc,
+ scanner: fsScanner,
+ gcWorker: gcWorker,
+ logger: logger,
+ appCtx: appCtx,
}
}
@@ -251,16 +254,17 @@ func runWithSignal(args []string, sigCh <-chan os.Signal) error {
SessionManager: deps.sm,
Config: cfg,
Services: api.ServerServices{
- Browse: deps.mediaSvc,
- Write: deps.mediaSvc,
- Share: deps.mediaSvc,
- Tag: deps.mediaSvc,
- Favorite: deps.mediaSvc,
- Note: deps.mediaSvc,
- Admin: deps.adminSvc,
- Progress: deps.progressSvc,
- Auth: deps.authSvc,
- Podcast: deps.podcastSvc,
+ Browse: deps.mediaSvc,
+ Write: deps.mediaSvc,
+ Share: deps.mediaSvc,
+ Tag: deps.mediaSvc,
+ Favorite: deps.mediaSvc,
+ Note: deps.mediaSvc,
+ Admin: deps.adminSvc,
+ Progress: deps.progressSvc,
+ Auth: deps.authSvc,
+ Podcast: deps.podcastSvc,
+ PlaybackHints: deps.playbackHintSvc,
},
StaticFS: staticFS,
MediaStreamer: streamer,
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.
diff --git a/player-server/internal/service/mock.go b/player-server/internal/service/mock.go
index dbf1166..e363240 100644
--- a/player-server/internal/service/mock.go
+++ b/player-server/internal/service/mock.go
@@ -19,6 +19,7 @@ var (
_ MediaService = (*MockMediaService)(nil)
_ AuthService = (*MockAuthService)(nil)
_ ProgressService = (*MockProgressService)(nil)
+ _ PlaybackHintsService = (*MockPlaybackHintsService)(nil)
)
// MockMediaService is a fake MediaService for testing.
@@ -438,6 +439,19 @@ func (m *MockAuthService) GetUserByID(ctx context.Context, id int64) (*model.Use
return nil, nil
}
+// MockPlaybackHintsService is a fake PlaybackHintsService for testing.
+type MockPlaybackHintsService struct {
+ GetPlaybackHintFunc func(ctx context.Context, mediaID, userID int64) (*PlaybackHint, error)
+}
+
+// GetPlaybackHint calls GetPlaybackHintFunc or returns nil.
+func (m *MockPlaybackHintsService) GetPlaybackHint(ctx context.Context, mediaID, userID int64) (*PlaybackHint, error) {
+ if m.GetPlaybackHintFunc != nil {
+ return m.GetPlaybackHintFunc(ctx, mediaID, userID)
+ }
+ return nil, nil
+}
+
// MockProgressService is a fake ProgressService for testing.
type MockProgressService struct {
UpdateProgressFunc func(ctx context.Context, sessionID string, userID, mediaID int64, position float64) error
diff --git a/player-server/internal/service/playback_hints.go b/player-server/internal/service/playback_hints.go
new file mode 100644
index 0000000..5abbacc
--- /dev/null
+++ b/player-server/internal/service/playback_hints.go
@@ -0,0 +1,155 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "codeberg.org/snonux/player/internal/model"
+)
+
+// PlaybackHint contains codec/container metadata needed by a client to decide
+// whether to play natively or request a transcoded variant. No actual
+// transcoding takes place — the hint is derived purely from existing DB fields.
+type PlaybackHint struct {
+ StreamURL string `json:"stream_url"`
+ Container string `json:"container"`
+ VideoCodec string `json:"video_codec"`
+ AudioCodec string `json:"audio_codec"`
+ DurationSeconds float64 `json:"duration_seconds"`
+ FileSizeBytes int64 `json:"file_size_bytes"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Bitrate int `json:"bitrate"`
+ NeedsTranscode bool `json:"needs_transcode"`
+}
+
+// PlaybackHintsService returns playback hints for a media item.
+type PlaybackHintsService interface {
+ // GetPlaybackHint returns playback hints for a media item visible to the user.
+ GetPlaybackHint(ctx context.Context, mediaID, userID int64) (*PlaybackHint, error)
+}
+
+// Compile-time check: playbackHintsService implements PlaybackHintsService.
+var _ PlaybackHintsService = (*playbackHintsService)(nil)
+
+// playbackHintsService is the concrete implementation of PlaybackHintsService.
+type playbackHintsService struct {
+ helper *accessHelper
+}
+
+// NewPlaybackHintsService creates a PlaybackHintsService backed by an accessHelper.
+func NewPlaybackHintsService(helper *accessHelper) *playbackHintsService {
+ return &playbackHintsService{helper: helper}
+}
+
+// GetPlaybackHint fetches a media item, verifies access, and assembles hint fields.
+// It performs no I/O beyond the DB lookup inside verifyAccess.
+func (s *playbackHintsService) GetPlaybackHint(ctx context.Context, mediaID, userID int64) (*PlaybackHint, error) {
+ media, err := s.helper.verifyAccess(ctx, mediaID, userID)
+ if err != nil {
+ return nil, fmt.Errorf("verify access: %w", err)
+ }
+
+ return buildPlaybackHint(media), nil
+}
+
+// buildPlaybackHint assembles a PlaybackHint from Media fields without any I/O.
+// It splits the stored codec string into separate video/audio components and
+// determines whether the file is likely to require transcoding.
+func buildPlaybackHint(media *model.Media) *PlaybackHint {
+ streamURL := fmt.Sprintf("/api/v1/media/%d/stream", media.ID)
+ container := containerFromPath(media.FileName)
+ videoCodec, audioCodec := splitCodecs(media.Codec)
+
+ return &PlaybackHint{
+ StreamURL: streamURL,
+ Container: container,
+ VideoCodec: videoCodec,
+ AudioCodec: audioCodec,
+ DurationSeconds: media.Duration,
+ FileSizeBytes: media.FileSizeBytes,
+ Width: media.Width,
+ Height: media.Height,
+ Bitrate: media.Bitrate,
+ NeedsTranscode: needsTranscode(container, videoCodec, audioCodec),
+ }
+}
+
+// containerFromPath extracts the lowercase file extension (without dot) from a filename.
+func containerFromPath(filename string) string {
+ ext := strings.TrimPrefix(filepath.Ext(filename), ".")
+ return strings.ToLower(ext)
+}
+
+// splitCodecs splits a codec string of the form "video/audio" or "codec" into
+// separate video and audio components, normalised to lowercase. When only one
+// component is present, it is treated as the video codec and audio is left empty.
+func splitCodecs(codec string) (videoCodec, audioCodec string) {
+ parts := strings.SplitN(codec, "/", 2)
+ if len(parts) == 2 {
+ return strings.ToLower(strings.TrimSpace(parts[0])), strings.ToLower(strings.TrimSpace(parts[1]))
+ }
+ return strings.ToLower(strings.TrimSpace(codec)), ""
+}
+
+// nativeContainers lists containers that web browsers and common native players
+// can play without transcoding.
+var nativeContainers = map[string]bool{
+ "mp4": true,
+ "webm": true,
+ "ogg": true,
+ "mp3": true,
+ "m4a": true,
+ "wav": true,
+ "aac": true,
+ "opus": true,
+}
+
+// nativeVideoCodecs lists video codecs that can be played natively by most clients.
+// Codecs absent from this map (e.g. wmv, mpeg2, xvid) trigger needsTranscode=true.
+var nativeVideoCodecs = map[string]bool{
+ "h264": true,
+ "avc": true, // synonym used by some probers
+ "avc1": true,
+ "vp8": true,
+ "vp9": true,
+ "av1": true,
+ "hevc": true, // supported natively on Apple platforms
+ "h265": true, // synonym for hevc
+ "theora": true,
+}
+
+// nativeAudioCodecs lists audio codecs that can be played natively by most clients.
+// Codecs absent from this map (e.g. flac, ac3, dts) trigger needsTranscode=true.
+// Empty string (no audio track) is handled by the if-guard in needsTranscode.
+var nativeAudioCodecs = map[string]bool{
+ "aac": true,
+ "mp3": true,
+ "opus": true,
+ "vorbis": true,
+}
+
+// needsTranscode returns true when the container, video codec, or audio codec
+// is unlikely to play natively without transcoding.
+// Containers like .mkv and codecs like flac, wmv, mpeg2 are flagged as true.
+// Inputs are expected to already be lowercase (as returned by splitCodecs).
+// This is a best-effort heuristic — no actual transcoding occurs here.
+func needsTranscode(container, videoCodec, audioCodec string) bool {
+ if !nativeContainers[container] {
+ return true
+ }
+
+ // Non-empty video codec that is not in the native list requires transcode.
+ if videoCodec != "" && !nativeVideoCodecs[videoCodec] {
+ return true
+ }
+
+ // Audio codec: flac and other exotic codecs require transcode.
+ if audioCodec != "" && !nativeAudioCodecs[audioCodec] {
+ return true
+ }
+
+ return false
+}
diff --git a/player-server/internal/service/playback_hints_test.go b/player-server/internal/service/playback_hints_test.go
new file mode 100644
index 0000000..9d15402
--- /dev/null
+++ b/player-server/internal/service/playback_hints_test.go
@@ -0,0 +1,152 @@
+package service
+
+import (
+ "testing"
+
+ "codeberg.org/snonux/player/internal/model"
+)
+
+func TestContainerFromPath(t *testing.T) {
+ tests := []struct {
+ filename string
+ want string
+ }{
+ {"movie.mp4", "mp4"},
+ {"audio.FLAC", "flac"},
+ {"video.MKV", "mkv"},
+ {"no-ext", ""},
+ {"archive.tar.gz", "gz"},
+ {"doc.PDF", "pdf"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.filename, func(t *testing.T) {
+ got := containerFromPath(tt.filename)
+ if got != tt.want {
+ t.Errorf("containerFromPath(%q) = %q, want %q", tt.filename, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSplitCodecs(t *testing.T) {
+ tests := []struct {
+ codec string
+ wantVideo string
+ wantAudio string
+ }{
+ {"h264/aac", "h264", "aac"},
+ {"h264 / aac", "h264", "aac"},
+ {"h264", "h264", ""},
+ {"", "", ""},
+ {"vp9/opus", "vp9", "opus"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.codec, func(t *testing.T) {
+ v, a := splitCodecs(tt.codec)
+ if v != tt.wantVideo || a != tt.wantAudio {
+ t.Errorf("splitCodecs(%q) = (%q, %q), want (%q, %q)", tt.codec, v, a, tt.wantVideo, tt.wantAudio)
+ }
+ })
+ }
+}
+
+func TestNeedsTranscode(t *testing.T) {
+ tests := []struct {
+ name string
+ container string
+ videoCodec string
+ audioCodec string
+ want bool
+ }{
+ // mp4 + h264 + aac — fully native
+ {"mp4 h264 aac", "mp4", "h264", "aac", false},
+ // mkv container — always transcode
+ {"mkv", "mkv", "h264", "aac", true},
+ // webm + vp9 + opus — native
+ {"webm vp9 opus", "webm", "vp9", "opus", false},
+ // mp4 + exotic video codec
+ {"mp4 wmv", "mp4", "wmv", "aac", true},
+ // mp4 + flac audio
+ {"mp4 flac", "mp4", "h264", "flac", true},
+ // audio-only mp3
+ {"mp3 no video", "mp3", "", "mp3", false},
+ // unknown container
+ {"avi", "avi", "xvid", "mp3", true},
+ // m4a audio
+ {"m4a", "m4a", "", "aac", false},
+ // hevc is listed as native (Apple)
+ {"mp4 hevc", "mp4", "hevc", "aac", false},
+ // av1 is native
+ {"webm av1", "webm", "av1", "opus", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := needsTranscode(tt.container, tt.videoCodec, tt.audioCodec)
+ if got != tt.want {
+ t.Errorf("needsTranscode(%q, %q, %q) = %v, want %v",
+ tt.container, tt.videoCodec, tt.audioCodec, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBuildPlaybackHint(t *testing.T) {
+ media := &model.Media{
+ ID: 42,
+ FileName: "sample.mp4",
+ Codec: "h264/aac",
+ Duration: 123.5,
+ FileSizeBytes: 9876543,
+ Width: 1920,
+ Height: 1080,
+ Bitrate: 4000000,
+ }
+
+ hint := buildPlaybackHint(media)
+
+ if hint.StreamURL != "/api/v1/media/42/stream" {
+ t.Errorf("unexpected StreamURL: %s", hint.StreamURL)
+ }
+ if hint.Container != "mp4" {
+ t.Errorf("unexpected Container: %s", hint.Container)
+ }
+ if hint.VideoCodec != "h264" {
+ t.Errorf("unexpected VideoCodec: %s", hint.VideoCodec)
+ }
+ if hint.AudioCodec != "aac" {
+ t.Errorf("unexpected AudioCodec: %s", hint.AudioCodec)
+ }
+ if hint.DurationSeconds != 123.5 {
+ t.Errorf("unexpected DurationSeconds: %f", hint.DurationSeconds)
+ }
+ if hint.FileSizeBytes != 9876543 {
+ t.Errorf("unexpected FileSizeBytes: %d", hint.FileSizeBytes)
+ }
+ if hint.Width != 1920 {
+ t.Errorf("unexpected Width: %d", hint.Width)
+ }
+ if hint.Height != 1080 {
+ t.Errorf("unexpected Height: %d", hint.Height)
+ }
+ if hint.Bitrate != 4000000 {
+ t.Errorf("unexpected Bitrate: %d", hint.Bitrate)
+ }
+ if hint.NeedsTranscode {
+ t.Errorf("expected NeedsTranscode=false for mp4/h264/aac")
+ }
+}
+
+func TestBuildPlaybackHint_MKV(t *testing.T) {
+ media := &model.Media{
+ ID: 7,
+ FileName: "film.mkv",
+ Codec: "h264/ac3",
+ }
+ hint := buildPlaybackHint(media)
+ if !hint.NeedsTranscode {
+ t.Errorf("expected NeedsTranscode=true for mkv/ac3")
+ }
+ if hint.Container != "mkv" {
+ t.Errorf("expected container mkv, got %s", hint.Container)
+ }
+}