package manage import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" "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, notifier *mqttnotifier.Notifier) 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 } if slug, err := playlists.ScreenSlugByPlaylistID(r.Context(), playlistID); err == nil { notifier.NotifyChanged(slug) } 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, notifier *mqttnotifier.Notifier) 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 } if slug, err := playlists.ScreenSlugByItemID(r.Context(), id); err == nil { notifier.NotifyChanged(slug) } w.WriteHeader(http.StatusNoContent) } } // HandleDeleteItem removes a playlist item. func HandleDeleteItem(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("itemId") // Resolve slug before delete (item won't exist after). slug, _ := playlists.ScreenSlugByItemID(r.Context(), id) if err := playlists.DeleteItem(r.Context(), id); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if slug != "" { notifier.NotifyChanged(slug) } w.WriteHeader(http.StatusNoContent) } } // HandleReorder accepts an ordered list of item IDs and updates order_index. func HandleReorder(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) 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 } if slug, err := playlists.ScreenSlugByPlaylistID(r.Context(), playlistID); err == nil { notifier.NotifyChanged(slug) } 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) }