diff options
| -rw-r--r-- | player-server/cmd/player/main.go | 82 | ||||
| -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 | ||||
| -rw-r--r-- | player-server/internal/service/mock.go | 14 | ||||
| -rw-r--r-- | player-server/internal/service/playback_hints.go | 155 | ||||
| -rw-r--r-- | player-server/internal/service/playback_hints_test.go | 152 |
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) + } +} |
