diff --git a/server/backend/go.mod b/server/backend/go.mod
index 014633d..6da0bfa 100644
--- a/server/backend/go.mod
+++ b/server/backend/go.mod
@@ -1,3 +1,12 @@
module git.az-it.net/az/morz-infoboard/server/backend
-go 1.24.0
+go 1.25.0
+
+require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.9.1 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/sync v0.17.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
+)
diff --git a/server/backend/go.sum b/server/backend/go.sum
new file mode 100644
index 0000000..ca531aa
--- /dev/null
+++ b/server/backend/go.sum
@@ -0,0 +1,19 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
+github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go
index efc45ce..82c25b7 100644
--- a/server/backend/internal/app/app.go
+++ b/server/backend/internal/app/app.go
@@ -1,11 +1,16 @@
package app
import (
+ "context"
"errors"
+ "log"
"net/http"
+ "os"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
+ "git.az-it.net/az/morz-infoboard/server/backend/internal/db"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi"
+ "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
type App struct {
@@ -15,17 +20,47 @@ type App struct {
func New() (*App, error) {
cfg := config.Load()
+ logger := log.New(os.Stdout, "backend ", log.LstdFlags|log.LUTC)
- store, err := httpapi.NewStoreFromConfig(cfg.StatusStorePath)
+ // Ensure upload directory exists.
+ if err := os.MkdirAll(cfg.UploadDir, 0755); err != nil {
+ return nil, err
+ }
+
+ // Connect to database and run migrations.
+ pool, err := db.Connect(context.Background(), cfg.DatabaseURL, logger)
if err != nil {
return nil, err
}
+ // Status store (existing in-memory/file store).
+ statusStore, err := httpapi.NewStoreFromConfig(cfg.StatusStorePath)
+ if err != nil {
+ pool.Close()
+ return nil, err
+ }
+
+ // Domain stores.
+ tenants := store.NewTenantStore(pool.Pool)
+ screens := store.NewScreenStore(pool.Pool)
+ media := store.NewMediaStore(pool.Pool)
+ playlists := store.NewPlaylistStore(pool.Pool)
+
+ handler := httpapi.NewRouter(httpapi.RouterDeps{
+ StatusStore: statusStore,
+ TenantStore: tenants,
+ ScreenStore: screens,
+ MediaStore: media,
+ PlaylistStore: playlists,
+ UploadDir: cfg.UploadDir,
+ Logger: logger,
+ })
+
return &App{
Config: cfg,
server: &http.Server{
Addr: cfg.HTTPAddress,
- Handler: httpapi.NewRouter(store),
+ Handler: handler,
},
}, nil
}
@@ -35,6 +70,5 @@ func (a *App) Run() error {
if errors.Is(err, http.ErrServerClosed) {
return nil
}
-
return err
}
diff --git a/server/backend/internal/config/config.go b/server/backend/internal/config/config.go
index 943d41f..e838778 100644
--- a/server/backend/internal/config/config.go
+++ b/server/backend/internal/config/config.go
@@ -5,12 +5,16 @@ import "os"
type Config struct {
HTTPAddress string
StatusStorePath string
+ DatabaseURL string
+ UploadDir string
}
func Load() Config {
return Config{
HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"),
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"),
+ DatabaseURL: getenv("MORZ_INFOBOARD_DATABASE_URL", "postgres://morz_infoboard:morz_infoboard@localhost:5432/morz_infoboard?sslmode=disable"),
+ UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"),
}
}
diff --git a/server/backend/internal/db/db.go b/server/backend/internal/db/db.go
new file mode 100644
index 0000000..7e68707
--- /dev/null
+++ b/server/backend/internal/db/db.go
@@ -0,0 +1,90 @@
+package db
+
+import (
+ "context"
+ "embed"
+ "fmt"
+ "log"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+//go:embed migrations/*.sql
+var migrationsFS embed.FS
+
+// Pool wraps a pgxpool.Pool with migration support.
+type Pool struct {
+ *pgxpool.Pool
+}
+
+// Connect opens a connection pool and runs pending migrations.
+func Connect(ctx context.Context, databaseURL string, logger *log.Logger) (*Pool, error) {
+ pool, err := pgxpool.New(ctx, databaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("db: open pool: %w", err)
+ }
+
+ if err := pool.Ping(ctx); err != nil {
+ pool.Close()
+ return nil, fmt.Errorf("db: ping: %w", err)
+ }
+
+ p := &Pool{pool}
+ if err := p.migrate(ctx, logger); err != nil {
+ pool.Close()
+ return nil, fmt.Errorf("db: migrate: %w", err)
+ }
+
+ return p, nil
+}
+
+// migrate runs all embedded SQL migration files in order (idempotent).
+func (p *Pool) migrate(ctx context.Context, logger *log.Logger) error {
+ // Ensure schema_migrations table exists first.
+ _, err := p.Exec(ctx, `
+ create table if not exists schema_migrations (
+ version integer primary key,
+ applied_at timestamptz not null default now()
+ )`)
+ if err != nil {
+ return fmt.Errorf("create schema_migrations: %w", err)
+ }
+
+ entries, err := migrationsFS.ReadDir("migrations")
+ if err != nil {
+ return err
+ }
+
+ for i, e := range entries {
+ version := i + 1
+ var applied bool
+ err := p.QueryRow(ctx,
+ "select exists(select 1 from schema_migrations where version=$1)", version,
+ ).Scan(&applied)
+ if err != nil {
+ return fmt.Errorf("check migration %d: %w", version, err)
+ }
+ if applied {
+ continue
+ }
+
+ sql, err := migrationsFS.ReadFile("migrations/" + e.Name())
+ if err != nil {
+ return err
+ }
+
+ if _, err := p.Exec(ctx, string(sql)); err != nil {
+ return fmt.Errorf("run migration %s: %w", e.Name(), err)
+ }
+
+ if _, err := p.Exec(ctx,
+ "insert into schema_migrations(version) values($1)", version,
+ ); err != nil {
+ return fmt.Errorf("record migration %d: %w", version, err)
+ }
+
+ logger.Printf("event=migration_applied version=%d file=%s", version, e.Name())
+ }
+
+ return nil
+}
diff --git a/server/backend/internal/db/migrations/001_initial.sql b/server/backend/internal/db/migrations/001_initial.sql
new file mode 100644
index 0000000..b53e703
--- /dev/null
+++ b/server/backend/internal/db/migrations/001_initial.sql
@@ -0,0 +1,76 @@
+-- 001_initial.sql
+-- Basis-Schema: Tenants, Screens, Medien, Playlists
+
+create extension if not exists pgcrypto;
+
+create table if not exists tenants (
+ id text primary key default gen_random_uuid()::text,
+ slug text not null unique,
+ name text not null,
+ created_at timestamptz not null default now()
+);
+
+create table if not exists screens (
+ id text primary key default gen_random_uuid()::text,
+ tenant_id text not null references tenants(id) on delete cascade,
+ slug text not null unique,
+ name text not null,
+ orientation text not null default 'landscape',
+ created_at timestamptz not null default now()
+);
+
+create table if not exists media_assets (
+ id text primary key default gen_random_uuid()::text,
+ tenant_id text not null references tenants(id) on delete cascade,
+ title text not null,
+ type text not null, -- image | video | pdf | web
+ storage_path text null, -- set for uploads
+ original_url text null, -- set for web/remote
+ mime_type text null,
+ size_bytes bigint null,
+ enabled boolean not null default true,
+ created_at timestamptz not null default now()
+);
+
+create table if not exists playlists (
+ id text primary key default gen_random_uuid()::text,
+ tenant_id text not null references tenants(id) on delete cascade,
+ screen_id text not null references screens(id) on delete cascade,
+ name text not null,
+ is_active boolean not null default true,
+ default_duration_seconds integer not null default 20,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ unique (screen_id) -- one active playlist per screen (simplified)
+);
+
+create table if not exists playlist_items (
+ id text primary key default gen_random_uuid()::text,
+ playlist_id text not null references playlists(id) on delete cascade,
+ media_asset_id text null references media_assets(id) on delete set null,
+ order_index integer not null default 0,
+ type text not null, -- image | video | pdf | web
+ src text not null, -- URL or served path
+ title text null,
+ duration_seconds integer not null default 20,
+ valid_from timestamptz null,
+ valid_until timestamptz null,
+ enabled boolean not null default true,
+ created_at timestamptz not null default now()
+);
+
+create index if not exists idx_screens_tenant_id on screens(tenant_id);
+create index if not exists idx_media_assets_tenant_id on media_assets(tenant_id);
+create index if not exists idx_playlists_screen_id on playlists(screen_id);
+create index if not exists idx_playlist_items_order on playlist_items(playlist_id, order_index);
+
+-- Schema-Versions-Tabelle
+create table if not exists schema_migrations (
+ version integer primary key,
+ applied_at timestamptz not null default now()
+);
+
+-- Seed: Standard-Tenant und erster Screen (idempotent)
+insert into tenants (id, slug, name)
+ values ('tenant-morz', 'morz', 'MORZ Schule')
+ on conflict (slug) do nothing;
diff --git a/server/backend/internal/httpapi/integration_test.go b/server/backend/internal/httpapi/integration_test.go
index 265c77c..23737b2 100644
--- a/server/backend/internal/httpapi/integration_test.go
+++ b/server/backend/internal/httpapi/integration_test.go
@@ -17,7 +17,7 @@ func TestPlayerStatusLifecycle(t *testing.T) {
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC)
}
- router := NewRouter(store)
+ router := NewRouter(RouterDeps{StatusStore: store})
// 1. POST /api/v1/player/status β ingest a status report
body := `{
diff --git a/server/backend/internal/httpapi/manage/media.go b/server/backend/internal/httpapi/manage/media.go
new file mode 100644
index 0000000..92de86b
--- /dev/null
+++ b/server/backend/internal/httpapi/manage/media.go
@@ -0,0 +1,154 @@
+package manage
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
+)
+
+const maxUploadSize = 512 << 20 // 512 MB
+
+// HandleListMedia returns all media assets for a tenant as JSON.
+func HandleListMedia(media *store.MediaStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.PathValue("tenantId")
+ assets, err := media.List(r.Context(), tenantID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(assets) //nolint:errcheck
+ }
+}
+
+// HandleUploadMedia handles multipart file upload and web-URL registration.
+func HandleUploadMedia(media *store.MediaStore, uploadDir string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.PathValue("tenantId")
+
+ if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+ http.Error(w, "request too large or not multipart", http.StatusBadRequest)
+ return
+ }
+
+ assetType := strings.TrimSpace(r.FormValue("type"))
+ title := strings.TrimSpace(r.FormValue("title"))
+
+ switch assetType {
+ case "web":
+ url := strings.TrimSpace(r.FormValue("url"))
+ if url == "" {
+ http.Error(w, "url required for type=web", http.StatusBadRequest)
+ return
+ }
+ if title == "" {
+ title = url
+ }
+ asset, err := media.Create(r.Context(), tenantID, title, "web", "", url, "", 0)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(asset) //nolint:errcheck
+ return
+
+ case "image", "video", "pdf":
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, "file required for type="+assetType, http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ mimeType := header.Header.Get("Content-Type")
+ if title == "" {
+ title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
+ }
+
+ // Generate unique storage path.
+ ext := filepath.Ext(header.Filename)
+ filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
+ destPath := filepath.Join(uploadDir, filename)
+
+ dest, err := os.Create(destPath)
+ if err != nil {
+ http.Error(w, "storage error", http.StatusInternalServerError)
+ return
+ }
+ defer dest.Close()
+
+ size, err := io.Copy(dest, file)
+ if err != nil {
+ os.Remove(destPath) //nolint:errcheck
+ http.Error(w, "write error", http.StatusInternalServerError)
+ return
+ }
+
+ // Storage path relative (served via /uploads/).
+ storagePath := "/uploads/" + filename
+
+ asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, size)
+ if err != nil {
+ os.Remove(destPath) //nolint:errcheck
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(asset) //nolint:errcheck
+
+ default:
+ http.Error(w, "type must be one of: image, video, pdf, web", http.StatusBadRequest)
+ }
+ }
+}
+
+// HandleDeleteMedia deletes a media asset from the database (and file if local).
+func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ asset, err := media.Get(r.Context(), id)
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+
+ // Delete physical file if it's a local upload.
+ if asset.StoragePath != "" {
+ filename := filepath.Base(asset.StoragePath)
+ os.Remove(filepath.Join(uploadDir, filename)) //nolint:errcheck
+ }
+
+ if err := media.Delete(r.Context(), id); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func sanitize(s string) string {
+ var b strings.Builder
+ for _, r := range s {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
+ b.WriteRune(r)
+ } else {
+ b.WriteRune('_')
+ }
+ }
+ out := b.String()
+ if len(out) > 40 {
+ out = out[:40]
+ }
+ return out
+}
diff --git a/server/backend/internal/httpapi/manage/playlist.go b/server/backend/internal/httpapi/manage/playlist.go
new file mode 100644
index 0000000..3cd6db0
--- /dev/null
+++ b/server/backend/internal/httpapi/manage/playlist.go
@@ -0,0 +1,295 @@
+package manage
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
+)
+
+// HandleGetPlaylist returns the playlist and its items for a screen.
+func HandleGetPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenID := r.PathValue("screenId")
+
+ screen, err := screens.GetBySlug(r.Context(), screenID)
+ if err != nil {
+ // Try by id if slug not found.
+ screen = &store.Screen{ID: screenID}
+ }
+
+ playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+
+ items, err := playlists.ListItems(r.Context(), playlist.ID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
+ "playlist": playlist,
+ "items": items,
+ })
+ }
+}
+
+// HandleAddItem adds a playlist item (from existing media asset or direct URL).
+func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ playlistID := r.PathValue("playlistId")
+
+ var body struct {
+ MediaAssetID string `json:"media_asset_id"`
+ Type string `json:"type"`
+ Src string `json:"src"`
+ Title string `json:"title"`
+ DurationSeconds int `json:"duration_seconds"`
+ ValidFrom string `json:"valid_from"`
+ ValidUntil string `json:"valid_until"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+
+ // If adding from media library, fill in src and type from asset.
+ if body.MediaAssetID != "" {
+ asset, err := media.Get(r.Context(), body.MediaAssetID)
+ if err != nil {
+ http.Error(w, "media asset not found", http.StatusBadRequest)
+ return
+ }
+ body.Type = asset.Type
+ if asset.StoragePath != "" {
+ body.Src = asset.StoragePath
+ } else {
+ body.Src = asset.OriginalURL
+ }
+ if body.Title == "" {
+ body.Title = asset.Title
+ }
+ }
+
+ if body.Type == "" || body.Src == "" {
+ http.Error(w, "type and src required", http.StatusBadRequest)
+ return
+ }
+
+ if body.DurationSeconds <= 0 {
+ body.DurationSeconds = 20
+ }
+
+ validFrom, _ := parseOptionalTime(body.ValidFrom)
+ validUntil, _ := parseOptionalTime(body.ValidUntil)
+
+ item, err := playlists.AddItem(r.Context(), playlistID, body.MediaAssetID,
+ body.Type, body.Src, body.Title, body.DurationSeconds, validFrom, validUntil)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(item) //nolint:errcheck
+ }
+}
+
+// HandleUpdateItem updates duration, title, enabled, valid_from, valid_until.
+func HandleUpdateItem(playlists *store.PlaylistStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("itemId")
+
+ var body struct {
+ Title string `json:"title"`
+ DurationSeconds int `json:"duration_seconds"`
+ Enabled *bool `json:"enabled"`
+ ValidFrom string `json:"valid_from"`
+ ValidUntil string `json:"valid_until"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+
+ enabled := true
+ if body.Enabled != nil {
+ enabled = *body.Enabled
+ }
+ if body.DurationSeconds <= 0 {
+ body.DurationSeconds = 20
+ }
+
+ validFrom, _ := parseOptionalTime(body.ValidFrom)
+ validUntil, _ := parseOptionalTime(body.ValidUntil)
+
+ if err := playlists.UpdateItem(r.Context(), id, body.Title, body.DurationSeconds, enabled, validFrom, validUntil); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// HandleDeleteItem removes a playlist item.
+func HandleDeleteItem(playlists *store.PlaylistStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("itemId")
+ if err := playlists.DeleteItem(r.Context(), id); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// HandleReorder accepts an ordered list of item IDs and updates order_index.
+func HandleReorder(playlists *store.PlaylistStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ playlistID := r.PathValue("playlistId")
+
+ var ids []string
+ if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
+ http.Error(w, "body must be JSON array of item IDs", http.StatusBadRequest)
+ return
+ }
+
+ if err := playlists.Reorder(r.Context(), playlistID, ids); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// HandleUpdatePlaylistDuration sets the default duration for a playlist.
+func HandleUpdatePlaylistDuration(playlists *store.PlaylistStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("playlistId")
+ secs, err := strconv.Atoi(strings.TrimSpace(r.FormValue("default_duration_seconds")))
+ if err != nil || secs <= 0 {
+ http.Error(w, "invalid duration", http.StatusBadRequest)
+ return
+ }
+ if err := playlists.UpdateDefaultDuration(r.Context(), id, secs); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// HandlePlayerPlaylist returns the active playlist for a screen (player sync).
+func HandlePlayerPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenSlug := r.PathValue("screenId")
+
+ screen, err := screens.GetBySlug(r.Context(), screenSlug)
+ if err != nil {
+ http.Error(w, "screen not found", http.StatusNotFound)
+ return
+ }
+
+ playlist, err := playlists.GetByScreen(r.Context(), screen.ID)
+ if err != nil {
+ // No playlist yet β return empty.
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) //nolint:errcheck
+ return
+ }
+
+ items, err := playlists.ListActiveItems(r.Context(), playlist.ID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
+ "playlist_id": playlist.ID,
+ "default_duration_seconds": playlist.DefaultDurationSeconds,
+ "items": items,
+ })
+ }
+}
+
+// HandleListScreens returns all screens for a tenant.
+func HandleListScreens(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ tenantSlug := r.PathValue("tenantSlug")
+ tenant, err := tenants.Get(r.Context(), tenantSlug)
+ if err != nil {
+ http.Error(w, "tenant not found", http.StatusNotFound)
+ return
+ }
+ list, err := screens.List(r.Context(), tenant.ID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(list) //nolint:errcheck
+ }
+}
+
+// HandleCreateScreen creates a new screen for a tenant.
+func HandleCreateScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ tenantSlug := r.PathValue("tenantSlug")
+ tenant, err := tenants.Get(r.Context(), tenantSlug)
+ if err != nil {
+ http.Error(w, "tenant not found", http.StatusNotFound)
+ return
+ }
+
+ var body struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Orientation string `json:"orientation"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ if body.Slug == "" || body.Name == "" {
+ http.Error(w, "slug and name required", http.StatusBadRequest)
+ return
+ }
+ if body.Orientation == "" {
+ body.Orientation = "landscape"
+ }
+
+ screen, err := screens.Create(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation)
+ if err != nil {
+ http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(screen) //nolint:errcheck
+ }
+}
+
+func parseOptionalTime(s string) (*time.Time, error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil, nil
+ }
+ // Accept RFC3339 (API) and datetime-local HTML input format.
+ for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02T15:04:05"} {
+ if t, err := time.Parse(layout, s); err == nil {
+ return &t, nil
+ }
+ }
+ return nil, fmt.Errorf("cannot parse time: %q", s)
+}
diff --git a/server/backend/internal/httpapi/manage/templates.go b/server/backend/internal/httpapi/manage/templates.go
new file mode 100644
index 0000000..24566c5
--- /dev/null
+++ b/server/backend/internal/httpapi/manage/templates.go
@@ -0,0 +1,422 @@
+package manage
+
+const adminTmpl = `
+
+
+
+
+ MORZ Infoboard β Admin
+
+
+
+
+
+
+
+
+
+
Bildschirme
+ {{if .Screens}}
+
+
+
+ | Name |
+ Slug |
+ Format |
+ Aktionen |
+
+
+
+ {{range .Screens}}
+
+ | {{.Name}} |
+ {{.Slug}} |
+ {{orientationLabel .Orientation}} |
+
+ Playlist verwalten
+
+
+ |
+
+ {{end}}
+
+
+ {{else}}
+
Noch keine Bildschirme angelegt.
+ {{end}}
+
+
+
+
+
+
+
+`
+
+const manageTmpl = `
+
+
+
+
+ Playlist β {{.Screen.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Aktuelle Playlist
+ {{if .Items}}
+
+
+
+ |
+ Typ |
+ Titel / Quelle |
+ Dauer |
+ Status |
+ Aktionen |
+
+
+
+ {{range .Items}}
+
+ | β Ώ |
+
+ {{typeIcon .Type}} {{.Type}}
+ |
+
+ {{if .Title}}{{.Title}}{{else}}{{shortSrc .Src}}{{end}}
+ {{if .Title}}{{shortSrc .Src}}{{end}}
+ |
+ {{.DurationSeconds}} s |
+
+ {{if .Enabled}}
+ Aktiv
+ {{else}}
+ Deaktiviert
+ {{end}}
+ |
+
+
+
+ |
+
+
+ |
+
+ |
+
+ {{end}}
+
+
+
EintrΓ€ge per Drag & Drop in der Reihenfolge verschieben.
+ {{else}}
+
+ Die Playlist ist noch leer. FΓΌge unten Medien aus der Bibliothek hinzu oder lade neue Dateien hoch.
+
+ {{end}}
+
+
+
+
+
Medienbibliothek
+ {{if .Assets}}
+
+
+
+ | Typ |
+ Titel |
+ Quelle |
+ Aktionen |
+
+
+
+ {{range .Assets}}
+
+ | {{typeIcon .Type}} {{.Type}} |
+ {{.Title}} |
+
+
+ {{if .StoragePath}}{{shortSrc .StoragePath}}{{else}}{{shortSrc .OriginalURL}}{{end}}
+
+ |
+
+ {{if index $.AddedAssets .ID}}
+ β In Playlist
+ {{else}}
+
+
+ {{end}}
+
+ |
+
+ {{end}}
+
+
+ {{else}}
+
Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder fΓΌge eine Webseite hinzu.
+ {{end}}
+
+
+
+
+
Neues Medium hinzufΓΌgen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
diff --git a/server/backend/internal/httpapi/manage/ui.go b/server/backend/internal/httpapi/manage/ui.go
new file mode 100644
index 0000000..6c52afe
--- /dev/null
+++ b/server/backend/internal/httpapi/manage/ui.go
@@ -0,0 +1,390 @@
+package manage
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
+)
+
+var tmplFuncs = template.FuncMap{
+ "typeIcon": func(t string) string {
+ switch t {
+ case "image":
+ return "πΌ"
+ case "video":
+ return "π¬"
+ case "pdf":
+ return "π"
+ case "web":
+ return "π"
+ default:
+ return "π"
+ }
+ },
+ "orientationLabel": func(o string) string {
+ if o == "portrait" {
+ return "Hochformat"
+ }
+ return "Querformat"
+ },
+ "shortSrc": func(s string) string {
+ if len(s) > 60 {
+ return s[:57] + "..."
+ }
+ return s
+ },
+ "formatDT": func(t *time.Time) string {
+ if t == nil {
+ return ""
+ }
+ return t.Format("2006-01-02T15:04")
+ },
+}
+
+// HandleAdminUI renders the admin overview page.
+func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
+ t := template.Must(template.New("admin").Funcs(tmplFuncs).Parse(adminTmpl))
+ return func(w http.ResponseWriter, r *http.Request) {
+ allScreens, err := screens.ListAll(r.Context())
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ allTenants, err := tenants.List(r.Context())
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ t.Execute(w, map[string]any{ //nolint:errcheck
+ "Screens": allScreens,
+ "Tenants": allTenants,
+ })
+ }
+}
+
+// HandleManageUI renders the playlist management UI for a specific screen.
+func HandleManageUI(
+ tenants *store.TenantStore,
+ screens *store.ScreenStore,
+ media *store.MediaStore,
+ playlists *store.PlaylistStore,
+) http.HandlerFunc {
+ t := template.Must(template.New("manage").Funcs(tmplFuncs).Parse(manageTmpl))
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenSlug := r.PathValue("screenSlug")
+
+ screen, err := screens.GetBySlug(r.Context(), screenSlug)
+ if err != nil {
+ http.Error(w, "Screen nicht gefunden: "+screenSlug, http.StatusNotFound)
+ return
+ }
+
+ tenant, _ := tenants.Get(r.Context(), "morz") // v1: single tenant
+ if tenant == nil {
+ tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"}
+ }
+
+ playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+
+ items, err := playlists.ListItems(r.Context(), playlist.ID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+
+ assets, err := media.List(r.Context(), screen.TenantID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+
+ // Build set of already-added asset IDs to mark them in library.
+ addedAssets := map[string]bool{}
+ for _, it := range items {
+ if it.MediaAssetID != "" {
+ addedAssets[it.MediaAssetID] = true
+ }
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ t.Execute(w, map[string]any{ //nolint:errcheck
+ "Screen": screen,
+ "Tenant": tenant,
+ "Playlist": playlist,
+ "Items": items,
+ "Assets": assets,
+ "AddedAssets": addedAssets,
+ })
+ }
+}
+
+// HandleCreateScreenUI handles form POST to create a screen, then redirects.
+func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ slug := strings.TrimSpace(r.FormValue("slug"))
+ name := strings.TrimSpace(r.FormValue("name"))
+ orientation := r.FormValue("orientation")
+ if slug == "" || name == "" {
+ http.Error(w, "slug und name erforderlich", http.StatusBadRequest)
+ return
+ }
+ if orientation == "" {
+ orientation = "landscape"
+ }
+
+ tenant, err := tenants.Get(r.Context(), "morz")
+ if err != nil {
+ http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError)
+ return
+ }
+
+ _, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation)
+ if err != nil {
+ http.Error(w, "Fehler: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/admin", http.StatusSeeOther)
+ }
+}
+
+// HandleDeleteScreenUI handles DELETE for a screen, then redirects.
+func HandleDeleteScreenUI(screens *store.ScreenStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("screenId")
+ if err := screens.Delete(r.Context(), id); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/admin", http.StatusSeeOther)
+ }
+}
+
+// HandleUploadMediaUI handles form upload from the manage UI and redirects back.
+func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenSlug := r.PathValue("screenSlug")
+
+ screen, err := screens.GetBySlug(r.Context(), screenSlug)
+ if err != nil {
+ http.Error(w, "screen nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+ http.Error(w, "Upload zu groΓ oder ungΓΌltig", http.StatusBadRequest)
+ return
+ }
+
+ assetType := strings.TrimSpace(r.FormValue("type"))
+ title := strings.TrimSpace(r.FormValue("title"))
+
+ switch assetType {
+ case "web":
+ url := strings.TrimSpace(r.FormValue("url"))
+ if url == "" {
+ http.Error(w, "URL erforderlich", http.StatusBadRequest)
+ return
+ }
+ if title == "" {
+ title = url
+ }
+ _, err = media.Create(r.Context(), screen.TenantID, title, "web", "", url, "", 0)
+ case "image", "video", "pdf":
+ file, header, ferr := r.FormFile("file")
+ if ferr != nil {
+ http.Error(w, "Datei erforderlich", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+ if title == "" {
+ title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
+ }
+ mimeType := header.Header.Get("Content-Type")
+ ext := filepath.Ext(header.Filename)
+ filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
+ destPath := filepath.Join(uploadDir, filename)
+ dest, ferr := os.Create(destPath)
+ if ferr != nil {
+ http.Error(w, "Speicherfehler", http.StatusInternalServerError)
+ return
+ }
+ defer dest.Close()
+ size, _ := io.Copy(dest, file)
+ storagePath := "/uploads/" + filename
+ _, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, size)
+ default:
+ http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
+ return
+ }
+
+ if err != nil {
+ http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
+ }
+}
+
+// HandleAddItemUI handles form POST to add a playlist item, then redirects.
+func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, screens *store.ScreenStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenSlug := r.PathValue("screenSlug")
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+
+ screen, err := screens.GetBySlug(r.Context(), screenSlug)
+ if err != nil {
+ http.Error(w, "screen nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+
+ mediaAssetID := r.FormValue("media_asset_id")
+ itemType := r.FormValue("type")
+ src := r.FormValue("src")
+ title := r.FormValue("title")
+ durationSeconds := 20
+ if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 {
+ durationSeconds = d
+ }
+ validFrom, _ := parseOptionalTime(r.FormValue("valid_from"))
+ validUntil, _ := parseOptionalTime(r.FormValue("valid_until"))
+
+ if mediaAssetID != "" {
+ asset, err := media.Get(r.Context(), mediaAssetID)
+ if err != nil {
+ http.Error(w, "Medium nicht gefunden", http.StatusBadRequest)
+ return
+ }
+ itemType = asset.Type
+ if asset.StoragePath != "" {
+ src = asset.StoragePath
+ } else {
+ src = asset.OriginalURL
+ }
+ if title == "" {
+ title = asset.Title
+ }
+ }
+
+ if itemType == "" || src == "" {
+ http.Error(w, "type und src erforderlich", http.StatusBadRequest)
+ return
+ }
+
+ _, err = playlists.AddItem(r.Context(), playlist.ID, mediaAssetID,
+ itemType, src, title, durationSeconds, validFrom, validUntil)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
+ }
+}
+
+// HandleDeleteItemUI removes a playlist item and redirects back.
+func HandleDeleteItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenSlug := r.PathValue("screenSlug")
+ itemID := r.PathValue("itemId")
+ if err := playlists.DeleteItem(r.Context(), itemID); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
+ }
+}
+
+// HandleReorderUI accepts JSON body with ordered IDs (HTMX/fetch).
+func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenSlug := r.PathValue("screenSlug")
+ screen, err := screens.GetBySlug(r.Context(), screenSlug)
+ if err != nil {
+ http.Error(w, "screen nicht gefunden", http.StatusNotFound)
+ return
+ }
+ playlist, err := playlists.GetByScreen(r.Context(), screen.ID)
+ if err != nil {
+ http.Error(w, "playlist nicht gefunden", http.StatusNotFound)
+ return
+ }
+ var ids []string
+ if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
+ http.Error(w, "JSON erwartet: array von item-IDs", http.StatusBadRequest)
+ return
+ }
+ if err := playlists.Reorder(r.Context(), playlist.ID, ids); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// HandleUpdateItemUI handles form PATCH/POST to update a single item.
+func HandleUpdateItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenSlug := r.PathValue("screenSlug")
+ itemID := r.PathValue("itemId")
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ title := r.FormValue("title")
+ durationSeconds := 20
+ if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 {
+ durationSeconds = d
+ }
+ enabled := r.FormValue("enabled") != "false"
+ validFrom, _ := parseOptionalTime(r.FormValue("valid_from"))
+ validUntil, _ := parseOptionalTime(r.FormValue("valid_until"))
+
+ if err := playlists.UpdateItem(r.Context(), itemID, title, durationSeconds, enabled, validFrom, validUntil); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
+ }
+}
+
+// HandleDeleteMediaUI deletes media and redirects back.
+func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ screenSlug := r.PathValue("screenSlug")
+ mediaID := r.PathValue("mediaId")
+
+ asset, err := media.Get(r.Context(), mediaID)
+ if err == nil && asset.StoragePath != "" {
+ os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
+ }
+ media.Delete(r.Context(), mediaID) //nolint:errcheck
+
+ http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
+ }
+}
diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go
index df9597b..7564f0f 100644
--- a/server/backend/internal/httpapi/router.go
+++ b/server/backend/internal/httpapi/router.go
@@ -1,12 +1,28 @@
package httpapi
import (
+ "log"
"net/http"
+
+ "git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
+ "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
-func NewRouter(store playerStatusStore) http.Handler {
+// RouterDeps holds all dependencies needed to build the HTTP router.
+type RouterDeps struct {
+ StatusStore playerStatusStore
+ TenantStore *store.TenantStore
+ ScreenStore *store.ScreenStore
+ MediaStore *store.MediaStore
+ PlaylistStore *store.PlaylistStore
+ UploadDir string
+ Logger *log.Logger
+}
+
+func NewRouter(deps RouterDeps) http.Handler {
mux := http.NewServeMux()
+ // ββ Health βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
@@ -14,9 +30,11 @@ func NewRouter(store playerStatusStore) http.Handler {
})
})
- mux.HandleFunc("GET /status", handleStatusPage(store))
- mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(store))
+ // ββ Status / diagnostic UI βββββββββββββββββββββββββββββββββββββββββββ
+ mux.HandleFunc("GET /status", handleStatusPage(deps.StatusStore))
+ mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(deps.StatusStore))
+ // ββ API meta βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"name": "morz-infoboard-backend",
@@ -30,15 +48,82 @@ func NewRouter(store playerStatusStore) http.Handler {
},
})
})
-
mux.HandleFunc("GET /api/v1/meta", handleMeta)
- mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(store))
- mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(store))
- mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(store))
- mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(store))
+ // ββ Player status (existing) ββββββββββββββββββββββββββββββββββββββββββ
+ mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore))
+ mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(deps.StatusStore))
+ mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(deps.StatusStore))
+ mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(deps.StatusStore))
+ // ββ Message wall ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall)
+ // ββ Playlist management β only register if stores are wired up ββββββββ
+ if deps.TenantStore != nil {
+ registerManageRoutes(mux, deps)
+ }
+
return mux
}
+
+func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
+ uploadDir := d.UploadDir
+ if uploadDir == "" {
+ uploadDir = "/tmp/morz-uploads"
+ }
+
+ // Serve uploaded files.
+ mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
+
+ // ββ Admin UI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
+ mux.HandleFunc("POST /admin/screens", manage.HandleCreateScreenUI(d.TenantStore, d.ScreenStore))
+ mux.HandleFunc("POST /admin/screens/{screenId}/delete", manage.HandleDeleteScreenUI(d.ScreenStore))
+
+ // ββ Playlist management UI ββββββββββββββββββββββββββββββββββββββββββββ
+ mux.HandleFunc("GET /manage/{screenSlug}",
+ manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))
+ mux.HandleFunc("POST /manage/{screenSlug}/upload",
+ manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
+ mux.HandleFunc("POST /manage/{screenSlug}/items",
+ manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore))
+ mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}",
+ manage.HandleUpdateItemUI(d.PlaylistStore))
+ mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}/delete",
+ manage.HandleDeleteItemUI(d.PlaylistStore))
+ mux.HandleFunc("POST /manage/{screenSlug}/reorder",
+ manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore))
+ mux.HandleFunc("POST /manage/{screenSlug}/media/{mediaId}/delete",
+ manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
+
+ // ββ JSON API β screens ββββββββββββββββββββββββββββββββββββββββββββββββ
+ mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/screens",
+ manage.HandleListScreens(d.TenantStore, d.ScreenStore))
+ mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/screens",
+ manage.HandleCreateScreen(d.TenantStore, d.ScreenStore))
+
+ // ββ JSON API β media ββββββββββββββββββββββββββββββββββββββββββββββββββ
+ mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/media",
+ manage.HandleListMedia(d.MediaStore))
+ mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/media",
+ manage.HandleUploadMedia(d.MediaStore, uploadDir))
+ mux.HandleFunc("DELETE /api/v1/media/{id}",
+ manage.HandleDeleteMedia(d.MediaStore, uploadDir))
+
+ // ββ JSON API β playlists ββββββββββββββββββββββββββββββββββββββββββββββ
+ mux.HandleFunc("GET /api/v1/screens/{screenId}/playlist",
+ manage.HandlePlayerPlaylist(d.ScreenStore, d.PlaylistStore))
+ mux.HandleFunc("GET /api/v1/playlists/{screenId}",
+ manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore))
+ mux.HandleFunc("POST /api/v1/playlists/{playlistId}/items",
+ manage.HandleAddItem(d.PlaylistStore, d.MediaStore))
+ mux.HandleFunc("PATCH /api/v1/items/{itemId}",
+ manage.HandleUpdateItem(d.PlaylistStore))
+ mux.HandleFunc("DELETE /api/v1/items/{itemId}",
+ manage.HandleDeleteItem(d.PlaylistStore))
+ mux.HandleFunc("PUT /api/v1/playlists/{playlistId}/order",
+ manage.HandleReorder(d.PlaylistStore))
+ mux.HandleFunc("PATCH /api/v1/playlists/{playlistId}/duration",
+ manage.HandleUpdatePlaylistDuration(d.PlaylistStore))
+}
diff --git a/server/backend/internal/httpapi/router_test.go b/server/backend/internal/httpapi/router_test.go
index e9012e3..1761789 100644
--- a/server/backend/internal/httpapi/router_test.go
+++ b/server/backend/internal/httpapi/router_test.go
@@ -14,7 +14,7 @@ func TestRouterHealthz(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
- NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -42,7 +42,7 @@ func TestRouterBaseAPI(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1", nil)
w := httptest.NewRecorder()
- NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -95,7 +95,7 @@ func TestRouterMeta(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
w := httptest.NewRecorder()
- NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -168,7 +168,7 @@ func TestRouterPlayerStatusRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running","heartbeat_every_seconds":30}`))
w := httptest.NewRecorder()
- NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -181,7 +181,7 @@ func TestRouterScreenStatusRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/demo/status", nil)
w := httptest.NewRecorder()
- NewRouter(store).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -193,7 +193,7 @@ func TestRouterScreenStatusListRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status", nil)
w := httptest.NewRecorder()
- NewRouter(store).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -219,7 +219,7 @@ func TestRouterScreenDetailPageRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status/info01-dev", nil)
w := httptest.NewRecorder()
- NewRouter(store).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -254,7 +254,7 @@ func TestRouterScreenDetailPageNotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status/missing-screen", nil)
w := httptest.NewRecorder()
- NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusNotFound; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -286,7 +286,7 @@ func TestRouterStatusPageRejectsInvalidQueryParams(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status"+tc.query, nil)
w := httptest.NewRecorder()
- NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@@ -309,7 +309,7 @@ func TestRouterStatusPageRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status?server_connectivity=offline&stale=true&updated_since=2026-03-22T15:55:00Z&limit=10", nil)
w := httptest.NewRecorder()
- NewRouter(store).ServeHTTP(w, req)
+ NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
diff --git a/server/backend/internal/store/store.go b/server/backend/internal/store/store.go
new file mode 100644
index 0000000..aafba7a
--- /dev/null
+++ b/server/backend/internal/store/store.go
@@ -0,0 +1,426 @@
+// Package store contains all database repositories.
+package store
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// ------------------------------------------------------------------
+// Domain types
+// ------------------------------------------------------------------
+
+type Tenant struct {
+ ID string
+ Slug string
+ Name string
+ CreatedAt time.Time
+}
+
+type Screen struct {
+ ID string
+ TenantID string
+ Slug string
+ Name string
+ Orientation string
+ CreatedAt time.Time
+}
+
+type MediaAsset struct {
+ ID string
+ TenantID string
+ Title string
+ Type string // image | video | pdf | web
+ StoragePath string
+ OriginalURL string
+ MimeType string
+ SizeBytes int64
+ Enabled bool
+ CreatedAt time.Time
+}
+
+type Playlist struct {
+ ID string
+ TenantID string
+ ScreenID string
+ Name string
+ IsActive bool
+ DefaultDurationSeconds int
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type PlaylistItem struct {
+ ID string
+ PlaylistID string
+ MediaAssetID string // may be empty for web items without asset
+ OrderIndex int
+ Type string // image | video | pdf | web
+ Src string
+ Title string
+ DurationSeconds int
+ ValidFrom *time.Time
+ ValidUntil *time.Time
+ Enabled bool
+ CreatedAt time.Time
+}
+
+// ------------------------------------------------------------------
+// Stores
+// ------------------------------------------------------------------
+
+type TenantStore struct{ pool *pgxpool.Pool }
+type ScreenStore struct{ pool *pgxpool.Pool }
+type MediaStore struct{ pool *pgxpool.Pool }
+type PlaylistStore struct{ pool *pgxpool.Pool }
+
+func NewTenantStore(pool *pgxpool.Pool) *TenantStore { return &TenantStore{pool} }
+func NewScreenStore(pool *pgxpool.Pool) *ScreenStore { return &ScreenStore{pool} }
+func NewMediaStore(pool *pgxpool.Pool) *MediaStore { return &MediaStore{pool} }
+func NewPlaylistStore(pool *pgxpool.Pool) *PlaylistStore { return &PlaylistStore{pool} }
+
+// ------------------------------------------------------------------
+// TenantStore
+// ------------------------------------------------------------------
+
+func (s *TenantStore) Get(ctx context.Context, slug string) (*Tenant, error) {
+ row := s.pool.QueryRow(ctx,
+ `select id, slug, name, created_at from tenants where slug=$1`, slug)
+ return scanTenant(row)
+}
+
+func (s *TenantStore) List(ctx context.Context) ([]*Tenant, error) {
+ rows, err := s.pool.Query(ctx, `select id, slug, name, created_at from tenants order by name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*Tenant
+ for rows.Next() {
+ t, err := scanTenant(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+func scanTenant(row interface {
+ Scan(dest ...any) error
+}) (*Tenant, error) {
+ var t Tenant
+ err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.CreatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("scan tenant: %w", err)
+ }
+ return &t, nil
+}
+
+// ------------------------------------------------------------------
+// ScreenStore
+// ------------------------------------------------------------------
+
+func (s *ScreenStore) List(ctx context.Context, tenantID string) ([]*Screen, error) {
+ rows, err := s.pool.Query(ctx,
+ `select id, tenant_id, slug, name, orientation, created_at
+ from screens where tenant_id=$1 order by name`, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*Screen
+ for rows.Next() {
+ sc, err := scanScreen(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, sc)
+ }
+ return out, rows.Err()
+}
+
+func (s *ScreenStore) ListAll(ctx context.Context) ([]*Screen, error) {
+ rows, err := s.pool.Query(ctx,
+ `select id, tenant_id, slug, name, orientation, created_at from screens order by name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*Screen
+ for rows.Next() {
+ sc, err := scanScreen(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, sc)
+ }
+ return out, rows.Err()
+}
+
+func (s *ScreenStore) GetBySlug(ctx context.Context, slug string) (*Screen, error) {
+ row := s.pool.QueryRow(ctx,
+ `select id, tenant_id, slug, name, orientation, created_at from screens where slug=$1`, slug)
+ return scanScreen(row)
+}
+
+func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
+ row := s.pool.QueryRow(ctx,
+ `insert into screens(tenant_id, slug, name, orientation)
+ values($1,$2,$3,$4)
+ returning id, tenant_id, slug, name, orientation, created_at`,
+ tenantID, slug, name, orientation)
+ return scanScreen(row)
+}
+
+func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) {
+ row := s.pool.QueryRow(ctx,
+ `update screens set name=$2, orientation=$3 where id=$1
+ returning id, tenant_id, slug, name, orientation, created_at`,
+ id, name, orientation)
+ return scanScreen(row)
+}
+
+func (s *ScreenStore) Delete(ctx context.Context, id string) error {
+ _, err := s.pool.Exec(ctx, `delete from screens where id=$1`, id)
+ return err
+}
+
+func scanScreen(row interface {
+ Scan(dest ...any) error
+}) (*Screen, error) {
+ var sc Screen
+ err := row.Scan(&sc.ID, &sc.TenantID, &sc.Slug, &sc.Name, &sc.Orientation, &sc.CreatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("scan screen: %w", err)
+ }
+ return &sc, nil
+}
+
+// ------------------------------------------------------------------
+// MediaStore
+// ------------------------------------------------------------------
+
+func (s *MediaStore) List(ctx context.Context, tenantID string) ([]*MediaAsset, error) {
+ rows, err := s.pool.Query(ctx,
+ `select id, tenant_id, title, type, coalesce(storage_path,''),
+ coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
+ enabled, created_at
+ from media_assets where tenant_id=$1 order by created_at desc`, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*MediaAsset
+ for rows.Next() {
+ m, err := scanMedia(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, m)
+ }
+ return out, rows.Err()
+}
+
+func (s *MediaStore) Get(ctx context.Context, id string) (*MediaAsset, error) {
+ row := s.pool.QueryRow(ctx,
+ `select id, tenant_id, title, type, coalesce(storage_path,''),
+ coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
+ enabled, created_at
+ from media_assets where id=$1`, id)
+ return scanMedia(row)
+}
+
+func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType string, sizeBytes int64) (*MediaAsset, error) {
+ row := s.pool.QueryRow(ctx,
+ `insert into media_assets(tenant_id, title, type, storage_path, original_url, mime_type, size_bytes)
+ values($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0))
+ returning id, tenant_id, title, type, coalesce(storage_path,''),
+ coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
+ enabled, created_at`,
+ tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes)
+ return scanMedia(row)
+}
+
+func (s *MediaStore) Delete(ctx context.Context, id string) error {
+ _, err := s.pool.Exec(ctx, `delete from media_assets where id=$1`, id)
+ return err
+}
+
+func scanMedia(row interface {
+ Scan(dest ...any) error
+}) (*MediaAsset, error) {
+ var m MediaAsset
+ err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
+ &m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
+ &m.Enabled, &m.CreatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("scan media: %w", err)
+ }
+ return &m, nil
+}
+
+// ------------------------------------------------------------------
+// PlaylistStore
+// ------------------------------------------------------------------
+
+func (s *PlaylistStore) GetOrCreateForScreen(ctx context.Context, tenantID, screenID, screenName string) (*Playlist, error) {
+ row := s.pool.QueryRow(ctx,
+ `insert into playlists(tenant_id, screen_id, name)
+ values($1,$2,$3)
+ on conflict(screen_id) do update set updated_at=now()
+ returning id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at`,
+ tenantID, screenID, screenName+" Playlist")
+ return scanPlaylist(row)
+}
+
+func (s *PlaylistStore) Get(ctx context.Context, id string) (*Playlist, error) {
+ row := s.pool.QueryRow(ctx,
+ `select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at
+ from playlists where id=$1`, id)
+ return scanPlaylist(row)
+}
+
+func (s *PlaylistStore) GetByScreen(ctx context.Context, screenID string) (*Playlist, error) {
+ row := s.pool.QueryRow(ctx,
+ `select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at
+ from playlists where screen_id=$1`, screenID)
+ return scanPlaylist(row)
+}
+
+func (s *PlaylistStore) UpdateDefaultDuration(ctx context.Context, id string, seconds int) error {
+ _, err := s.pool.Exec(ctx,
+ `update playlists set default_duration_seconds=$2, updated_at=now() where id=$1`, id, seconds)
+ return err
+}
+
+func scanPlaylist(row interface {
+ Scan(dest ...any) error
+}) (*Playlist, error) {
+ var p Playlist
+ err := row.Scan(&p.ID, &p.TenantID, &p.ScreenID, &p.Name,
+ &p.IsActive, &p.DefaultDurationSeconds, &p.CreatedAt, &p.UpdatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("scan playlist: %w", err)
+ }
+ return &p, nil
+}
+
+// ------------------------------------------------------------------
+// PlaylistItemStore (part of PlaylistStore for simplicity)
+// ------------------------------------------------------------------
+
+func (s *PlaylistStore) ListItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) {
+ rows, err := s.pool.Query(ctx,
+ `select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
+ coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at
+ from playlist_items where playlist_id=$1 order by order_index`, playlistID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*PlaylistItem
+ for rows.Next() {
+ item, err := scanPlaylistItem(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ return out, rows.Err()
+}
+
+func (s *PlaylistStore) ListActiveItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) {
+ rows, err := s.pool.Query(ctx,
+ `select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
+ coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at
+ from playlist_items
+ where playlist_id=$1
+ and enabled=true
+ and (valid_from is null or valid_from <= now())
+ and (valid_until is null or valid_until > now())
+ order by order_index`, playlistID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*PlaylistItem
+ for rows.Next() {
+ item, err := scanPlaylistItem(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ return out, rows.Err()
+}
+
+func (s *PlaylistStore) AddItem(ctx context.Context, playlistID, mediaAssetID, itemType, src, title string, durationSeconds int, validFrom, validUntil *time.Time) (*PlaylistItem, error) {
+ // Place at end of list.
+ var maxIdx int
+ s.pool.QueryRow(ctx,
+ `select coalesce(max(order_index)+1, 0) from playlist_items where playlist_id=$1`, playlistID,
+ ).Scan(&maxIdx) //nolint:errcheck
+
+ var mediaID *string
+ if mediaAssetID != "" {
+ mediaID = &mediaAssetID
+ }
+
+ row := s.pool.QueryRow(ctx,
+ `insert into playlist_items(playlist_id, media_asset_id, order_index, type, src, title, duration_seconds, valid_from, valid_until)
+ values($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ returning id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
+ coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at`,
+ playlistID, mediaID, maxIdx, itemType, src, title, durationSeconds, validFrom, validUntil)
+ return scanPlaylistItem(row)
+}
+
+func (s *PlaylistStore) UpdateItem(ctx context.Context, id, title string, durationSeconds int, enabled bool, validFrom, validUntil *time.Time) error {
+ _, err := s.pool.Exec(ctx,
+ `update playlist_items
+ set title=$2, duration_seconds=$3, enabled=$4, valid_from=$5, valid_until=$6
+ where id=$1`,
+ id, title, durationSeconds, enabled, validFrom, validUntil)
+ return err
+}
+
+func (s *PlaylistStore) DeleteItem(ctx context.Context, id string) error {
+ _, err := s.pool.Exec(ctx, `delete from playlist_items where id=$1`, id)
+ return err
+}
+
+// Reorder sets order_index for each item ID in the given slice order.
+func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx) //nolint:errcheck
+
+ for i, id := range itemIDs {
+ if _, err := tx.Exec(ctx,
+ `update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`,
+ i, id, playlistID,
+ ); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}
+
+func scanPlaylistItem(row interface {
+ Scan(dest ...any) error
+}) (*PlaylistItem, error) {
+ var it PlaylistItem
+ err := row.Scan(&it.ID, &it.PlaylistID, &it.MediaAssetID, &it.OrderIndex,
+ &it.Type, &it.Src, &it.Title, &it.DurationSeconds,
+ &it.ValidFrom, &it.ValidUntil, &it.Enabled, &it.CreatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("scan playlist_item: %w", err)
+ }
+ return &it, nil
+}