summaryrefslogtreecommitdiff
path: root/player-server/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
committerPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
commit914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch)
tree02b11537237a204048589e14ed24938b805e42f7 /player-server/internal
parent3b24f0e1be832584d6550e8cc3e5d24329f66f90 (diff)
Restructure repo: move Go server into player-server/
Diffstat (limited to 'player-server/internal')
-rw-r--r--player-server/internal/api/handlers.go193
-rw-r--r--player-server/internal/api/handlers_admin.go146
-rw-r--r--player-server/internal/api/handlers_auth.go126
-rw-r--r--player-server/internal/api/handlers_config.go17
-rw-r--r--player-server/internal/api/handlers_file.go109
-rw-r--r--player-server/internal/api/handlers_media.go538
-rw-r--r--player-server/internal/api/handlers_more_test.go2191
-rw-r--r--player-server/internal/api/handlers_podcast.go161
-rw-r--r--player-server/internal/api/handlers_podcast_test.go370
-rw-r--r--player-server/internal/api/handlers_share.go215
-rw-r--r--player-server/internal/api/handlers_test.go1673
-rw-r--r--player-server/internal/api/middleware.go116
-rw-r--r--player-server/internal/api/server.go296
-rw-r--r--player-server/internal/api/server_test.go19
-rw-r--r--player-server/internal/auth/auth.go41
-rw-r--r--player-server/internal/auth/auth_test.go184
-rw-r--r--player-server/internal/auth/interfaces.go15
-rw-r--r--player-server/internal/auth/mock.go50
-rw-r--r--player-server/internal/auth/mock_test.go61
-rw-r--r--player-server/internal/auth/session.go85
-rw-r--r--player-server/internal/clock/clock.go28
-rw-r--r--player-server/internal/clock/clock_test.go24
-rw-r--r--player-server/internal/config.go209
-rw-r--r--player-server/internal/config_test.go209
-rw-r--r--player-server/internal/mediatype/mediatype.go129
-rw-r--r--player-server/internal/mediatype/mediatype_test.go163
-rw-r--r--player-server/internal/model/media.go167
-rw-r--r--player-server/internal/model/media_test.go27
-rw-r--r--player-server/internal/model/podcast.go54
-rw-r--r--player-server/internal/model/scan.go88
-rw-r--r--player-server/internal/model/scan_test.go157
-rw-r--r--player-server/internal/podcast/cover.go39
-rw-r--r--player-server/internal/podcast/feed.go137
-rw-r--r--player-server/internal/podcast/podcast_test.go560
-rw-r--r--player-server/internal/probe/image_test.go232
-rw-r--r--player-server/internal/probe/probe.go242
-rw-r--r--player-server/internal/probe/probe_test.go126
-rw-r--r--player-server/internal/probe/remux.go119
-rw-r--r--player-server/internal/probe/remux_test.go85
-rw-r--r--player-server/internal/probe/testdata/golden.json14
-rw-r--r--player-server/internal/repository/favorite.go62
-rw-r--r--player-server/internal/repository/media.go319
-rw-r--r--player-server/internal/repository/mock.go1194
-rw-r--r--player-server/internal/repository/note.go48
-rw-r--r--player-server/internal/repository/playback_accumulator.go47
-rw-r--r--player-server/internal/repository/playback_progress.go122
-rw-r--r--player-server/internal/repository/podcast.go417
-rw-r--r--player-server/internal/repository/podcast_repo.go52
-rw-r--r--player-server/internal/repository/podcast_test.go311
-rw-r--r--player-server/internal/repository/repository.go321
-rw-r--r--player-server/internal/repository/schema.go262
-rw-r--r--player-server/internal/repository/session.go60
-rw-r--r--player-server/internal/repository/set.go84
-rw-r--r--player-server/internal/repository/set_permission.go87
-rw-r--r--player-server/internal/repository/share.go116
-rw-r--r--player-server/internal/repository/sqlite.go77
-rw-r--r--player-server/internal/repository/sqlite_no_rows_test.go132
-rw-r--r--player-server/internal/repository/sqlite_test.go1925
-rw-r--r--player-server/internal/repository/tag.go110
-rw-r--r--player-server/internal/repository/user.go94
-rw-r--r--player-server/internal/scanner/fs.go28
-rw-r--r--player-server/internal/scanner/scanner.go516
-rw-r--r--player-server/internal/scanner/scanner_test.go610
-rw-r--r--player-server/internal/service/access.go117
-rw-r--r--player-server/internal/service/admin.go81
-rw-r--r--player-server/internal/service/admin_test.go506
-rw-r--r--player-server/internal/service/auth.go96
-rw-r--r--player-server/internal/service/browse.go523
-rw-r--r--player-server/internal/service/browse_test.go179
-rw-r--r--player-server/internal/service/fav.go28
-rw-r--r--player-server/internal/service/fav_test.go67
-rw-r--r--player-server/internal/service/filename.go31
-rw-r--r--player-server/internal/service/gc.go169
-rw-r--r--player-server/internal/service/gc_test.go322
-rw-r--r--player-server/internal/service/import.go94
-rw-r--r--player-server/internal/service/media.go47
-rw-r--r--player-server/internal/service/media_test.go2304
-rw-r--r--player-server/internal/service/mock.go442
-rw-r--r--player-server/internal/service/mock_test.go176
-rw-r--r--player-server/internal/service/no_rows_test.go190
-rw-r--r--player-server/internal/service/note.go47
-rw-r--r--player-server/internal/service/note_test.go114
-rw-r--r--player-server/internal/service/perm.go62
-rw-r--r--player-server/internal/service/perm_test.go61
-rw-r--r--player-server/internal/service/podcast.go121
-rw-r--r--player-server/internal/service/podcast_checker.go179
-rw-r--r--player-server/internal/service/podcast_episode.go246
-rw-r--r--player-server/internal/service/podcast_helpers.go35
-rw-r--r--player-server/internal/service/podcast_sub.go296
-rw-r--r--player-server/internal/service/podcast_test.go995
-rw-r--r--player-server/internal/service/progress.go164
-rw-r--r--player-server/internal/service/progress_test.go387
-rw-r--r--player-server/internal/service/recover.go18
-rw-r--r--player-server/internal/service/scan.go101
-rw-r--r--player-server/internal/service/scan_test.go177
-rw-r--r--player-server/internal/service/service.go285
-rw-r--r--player-server/internal/service/share.go227
-rw-r--r--player-server/internal/service/share_test.go171
-rw-r--r--player-server/internal/service/streamer.go66
-rw-r--r--player-server/internal/service/streamer_test.go122
-rw-r--r--player-server/internal/service/tag.go64
-rw-r--r--player-server/internal/service/tag_test.go90
-rw-r--r--player-server/internal/service/trash.go22
-rw-r--r--player-server/internal/service/trash_test.go64
-rw-r--r--player-server/internal/service/user.go55
-rw-r--r--player-server/internal/service/user_test.go78
-rw-r--r--player-server/internal/service/write.go277
-rw-r--r--player-server/internal/service/write_test.go100
-rw-r--r--player-server/internal/setassign/setassign.go2
-rw-r--r--player-server/internal/setassign/setassign_test.go9
-rw-r--r--player-server/internal/thumb/thumb.go80
-rw-r--r--player-server/internal/thumb/thumb_test.go151
-rw-r--r--player-server/internal/version.go4
113 files changed, 26651 insertions, 0 deletions
diff --git a/player-server/internal/api/handlers.go b/player-server/internal/api/handlers.go
new file mode 100644
index 0000000..b276b10
--- /dev/null
+++ b/player-server/internal/api/handlers.go
@@ -0,0 +1,193 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "strconv"
+
+ "codeberg.org/snonux/player/internal/model"
+ "codeberg.org/snonux/player/internal/service"
+)
+
+// ------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------
+
+func writeJSON(w http.ResponseWriter, status int, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(data); err != nil {
+ slog.Error("encode json", "err", err)
+ }
+}
+
+func writeError(w http.ResponseWriter, status int, message string) {
+ writeJSON(w, status, map[string]string{"error": message})
+}
+
+func badRequest(w http.ResponseWriter, message string) {
+ writeError(w, http.StatusBadRequest, message)
+}
+
+func notFound(w http.ResponseWriter) {
+ writeError(w, http.StatusNotFound, "not found")
+}
+
+func forbidden(w http.ResponseWriter, message string) {
+ writeError(w, http.StatusForbidden, message)
+}
+
+// handleError maps service sentinel errors to the appropriate HTTP status
+// and writes a JSON error response. It falls back to 500 for unknown errors.
+func handleError(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, service.ErrNotFound),
+ errors.Is(err, service.ErrShareNotFound),
+ errors.Is(err, service.ErrMediaNotFound):
+ notFound(w)
+ case errors.Is(err, service.ErrForbidden):
+ forbidden(w, "forbidden")
+ case errors.Is(err, service.ErrAlreadyBootstrapped):
+ forbidden(w, "bootstrap already complete")
+ case errors.Is(err, service.ErrInvalidCredentials):
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
+ case errors.Is(err, service.ErrUnsupportedExtension),
+ errors.Is(err, service.ErrInvalidFeed),
+ errors.Is(err, service.ErrCannotDeleteSelf):
+ badRequest(w, err.Error())
+ default:
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ }
+}
+
+func readJSON(r *http.Request, dst interface{}) error {
+ if r.Body == nil {
+ return errors.New("missing body")
+ }
+ defer r.Body.Close()
+ return json.NewDecoder(r.Body).Decode(dst)
+}
+
+func pathID(r *http.Request, name string) int64 {
+ id, _ := strconv.ParseInt(r.PathValue(name), 10, 64)
+ return id
+}
+
+func userIDFromContext(r *http.Request) int64 {
+ sess, _ := r.Context().Value(sessionCtxKey).(*model.Session)
+ if sess == nil {
+ return 0
+ }
+ return sess.UserID
+}
+
+func sessionIDFromContext(r *http.Request) string {
+ sess, _ := r.Context().Value(sessionCtxKey).(*model.Session)
+ if sess == nil {
+ return ""
+ }
+ return sess.ID
+}
+
+func stringPtr(s string) *string { return &s }
+
+func floatPtr(f float64) *float64 { return &f }
+
+func intPtr(i int64) *int64 { return &i }
+
+func requireService(w http.ResponseWriter, svc any) bool {
+ if svc == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"})
+ return false
+ }
+ return true
+}
+
+// ------------------------------------------------------------------
+// Static pages
+// ------------------------------------------------------------------
+
+func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, filename string) {
+ f, err := s.staticFS.Open(filename)
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ defer f.Close()
+ stat, err := f.Stat()
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ rs, ok := f.(io.ReadSeeker)
+ if !ok {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ http.ServeContent(w, r, filename, stat.ModTime(), rs)
+}
+
+func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
+ s.serveFile(w, r, "index.html")
+}
+
+func (s *Server) serveLogin(w http.ResponseWriter, r *http.Request) {
+ s.serveFile(w, r, "login.html")
+}
+
+func (s *Server) serveBootstrap(w http.ResponseWriter, r *http.Request) {
+ s.serveFile(w, r, "bootstrap.html")
+}
+
+func (s *Server) serveDetach(w http.ResponseWriter, r *http.Request) {
+ s.serveFile(w, r, "detach.html")
+}
+
+// ------------------------------------------------------------------
+// File serving helpers
+// ------------------------------------------------------------------