feat: enhance media management with CRUD operations and runtime tracking in admin UI
CI / test (push) Successful in 1m39s
CI / docker (push) Successful in 3m9s
CI / release (push) Skipped

This commit is contained in:
zwitschi committed 2026-08-04 19:28:23 +02:00
1 parent 47740e3f4a
commit 7cfddfc7ef
6 files changed
+535 -103

No files matched your search

+21
View File
@@ -2,6 +2,27 @@
<!-- markdownlint-disable MD024 -->
## Unreleased
### Added
- Documentation for Admin UI Media Library (arc42 updates, refined TODO, media DB model decisions per ADR-006).
- Started implementation of full Media CRUD + video duration extraction using ffprobe (Media Library - Full CRUD section).
- Added `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
- Extended admin schedule and dashboard data endpoints with media duration totals, runtime strings, and media counts.
- Added admin UI runtime display for schedule/dashboard plus media library edit/delete actions.
### Changed
- Revised ADR-006 to reflect current implemented storage: PostgreSQL is the compose default and deployed primary backend, with SQLite retained as optional fallback.
- Updated chat runtime image to include ffmpeg/ffprobe for media duration probing on upload.
### Fixed
- Started Admin UI Media Library backend implementation by adding the `Media` GORM model and AutoMigrate integration in chat startup.
- Implemented media upload duration probing with ffprobe and persisted media metadata to the database alongside filesystem storage.
- Updated media listing to read from the media table and return duration metadata with HH:MM:SS runtime formatting.
## [1.0.3] - 2026-08-03
### Added
+1 -1
View File
@@ -9,7 +9,7 @@ RUN go mod tidy
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
FROM alpine:3.20
RUN apk --no-cache add ca-certificates curl curl
RUN apk --no-cache add ca-certificates curl ffmpeg
WORKDIR /app
COPY --from=builder /chat-server /usr/local/bin/chat-server
+339 -36
View File
@@ -10,6 +10,7 @@ import (
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
@@ -37,7 +38,7 @@ var (
authSessionsMu = sync.Mutex{}
)
// Message represents a chat message (persisted in SQLite per ADR-006)
// Message represents a chat message (persisted in SQL DB per ADR-006)
type Message struct {
ID uint `gorm:"primarykey" json:"id"`
Room string `json:"room"`
@@ -57,6 +58,21 @@ type ModerationLog struct {
Timestamp time.Time `json:"timestamp"`
}
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
type Media struct {
ID uint `gorm:"primarykey" json:"id"`
Filename string `gorm:"index" json:"filename"`
OriginalName string `json:"originalName"`
Path string `json:"path"`
SizeBytes int64 `json:"sizeBytes"`
DurationSeconds int `json:"durationSeconds"`
Status string `gorm:"index" json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
}
// Presence tracks online users per room (using Redis per ADR-007)
type Presence struct {
Username string `json:"username"`
@@ -68,6 +84,9 @@ type AdminOverview struct {
Viewers int `json:"viewers"`
StoredMessages int64 `json:"storedMessages"`
ModerationActions int64 `json:"moderationActions"`
MediaCount int64 `json:"mediaCount"`
MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"`
MediaTotalRuntime string `json:"mediaTotalRuntime"`
RedisEnabled bool `json:"redisEnabled"`
RedisClients int `json:"redisClients"`
RedisMemoryUsed string `json:"redisMemoryUsed"`
@@ -101,6 +120,9 @@ type AdminAnalytics struct {
MessagesLast24Hours int64 `json:"messagesLast24Hours"`
DistinctRooms int64 `json:"distinctRooms"`
ModerationActions int64 `json:"moderationActions"`
MediaCount int64 `json:"mediaCount"`
MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"`
MediaTotalRuntime string `json:"mediaTotalRuntime"`
TopRooms []RoomAnalytics `json:"topRooms"`
GeneratedAt string `json:"generatedAt"`
}
@@ -135,20 +157,37 @@ type AdminUsersResponse struct {
}
type ScheduleEntry struct {
Index int `json:"index"`
Path string `json:"path"`
Label string `json:"label"`
Index int `json:"index"`
Path string `json:"path"`
Label string `json:"label"`
DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Title string `json:"title"`
}
type AdminScheduleResponse struct {
Entries []ScheduleEntry `json:"entries"`
GeneratedAt string `json:"generatedAt"`
Entries []ScheduleEntry `json:"entries"`
TotalEntries int `json:"totalEntries"`
MatchedEntries int `json:"matchedEntries"`
TotalDurationSeconds int `json:"totalDurationSeconds"`
TotalRuntime string `json:"totalRuntime"`
GeneratedAt string `json:"generatedAt"`
}
type MediaFileEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
ID uint `json:"id"`
Name string `json:"name"`
OriginalName string `json:"originalName"`
Path string `json:"path"`
Size int64 `json:"size"`
DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
UploadedAt string `json:"uploadedAt"`
ModifiedAt string `json:"modifiedAt"`
}
type AdminMediaResponse struct {
@@ -199,7 +238,7 @@ func initRedis() {
}
}
// initDB initializes SQLite for message history and moderation audit log (ADR-006)
// initDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006)
func initDB() {
var err error
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
@@ -226,8 +265,8 @@ func initDB() {
}
databaseDriverName = driverName
db.AutoMigrate(&Message{}, &ModerationLog{})
log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName))
db.AutoMigrate(&Message{}, &ModerationLog{}, &Media{})
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driverName))
}
func parseRedisInfoValue(info, key string) string {
@@ -246,6 +285,7 @@ func getAdminOverview() AdminOverview {
RedisEnabled: redisClient != nil,
RedisMemoryUsed: "unavailable",
DatabaseDriver: databaseDriverName,
MediaTotalRuntime: "00:00:00",
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
@@ -256,6 +296,9 @@ func getAdminOverview() AdminOverview {
if db != nil {
db.Model(&Message{}).Count(&overview.StoredMessages)
db.Model(&ModerationLog{}).Count(&overview.ModerationActions)
db.Model(&Media{}).Count(&overview.MediaCount)
db.Model(&Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&overview.MediaTotalDurationSeconds)
overview.MediaTotalRuntime = formatDuration(int(overview.MediaTotalDurationSeconds))
}
if redisClient != nil {
@@ -541,10 +584,55 @@ func handleAdminSchedule(w http.ResponseWriter, r *http.Request) {
return
}
entries := parsePlaylistEntries(string(content))
var mediaRecords []Media
if err := db.Find(&mediaRecords).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to load media metadata: %v", err), http.StatusInternalServerError)
return
}
mediaByFilename := make(map[string]Media, len(mediaRecords))
mediaByPath := make(map[string]Media, len(mediaRecords))
for _, record := range mediaRecords {
mediaByFilename[strings.ToLower(record.Filename)] = record
mediaByPath[strings.ToLower(filepath.Clean(record.Path))] = record
}
totalDurationSeconds := 0
matchedEntries := 0
for i := range entries {
entry := &entries[i]
entry.Status = "missing"
entry.Runtime = "00:00:00"
cleanPath := strings.ToLower(filepath.Clean(entry.Path))
baseName := strings.ToLower(filepath.Base(entry.Path))
record, ok := mediaByPath[cleanPath]
if !ok {
record, ok = mediaByFilename[baseName]
}
if !ok {
continue
}
entry.DurationSeconds = record.DurationSeconds
entry.Runtime = formatDuration(record.DurationSeconds)
entry.Status = record.Status
entry.Title = record.Title
totalDurationSeconds += record.DurationSeconds
matchedEntries++
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminScheduleResponse{
Entries: parsePlaylistEntries(string(content)),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Entries: entries,
TotalEntries: len(entries),
MatchedEntries: matchedEntries,
TotalDurationSeconds: totalDurationSeconds,
TotalRuntime: formatDuration(totalDurationSeconds),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
})
}
@@ -624,6 +712,7 @@ func handleAdminModerationAction(w http.ResponseWriter, r *http.Request) {
func getAdminAnalytics() AdminAnalytics {
analytics := AdminAnalytics{
TopRooms: []RoomAnalytics{},
MediaTotalRuntime: "00:00:00",
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
@@ -637,6 +726,9 @@ func getAdminAnalytics() AdminAnalytics {
Count(&analytics.MessagesLast24Hours)
db.Model(&Message{}).Distinct("room").Count(&analytics.DistinctRooms)
db.Model(&ModerationLog{}).Count(&analytics.ModerationActions)
db.Model(&Media{}).Count(&analytics.MediaCount)
db.Model(&Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&analytics.MediaTotalDurationSeconds)
analytics.MediaTotalRuntime = formatDuration(int(analytics.MediaTotalDurationSeconds))
var topRooms []RoomAnalytics
db.Model(&Message{}).
@@ -784,37 +876,126 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
}
func listMediaFiles() ([]MediaFileEntry, error) {
mediaDir := getMediaDir()
entries, err := os.ReadDir(mediaDir)
if err != nil {
var records []Media
if err := db.Order("uploaded_at desc").Find(&records).Error; err != nil {
return nil, err
}
files := make([]MediaFileEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files := make([]MediaFileEntry, 0, len(records))
for _, record := range records {
files = append(files, MediaFileEntry{
Name: entry.Name(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
ID: record.ID,
Name: record.Filename,
OriginalName: record.OriginalName,
Path: record.Path,
Size: record.SizeBytes,
DurationSeconds: record.DurationSeconds,
Runtime: formatDuration(record.DurationSeconds),
Status: record.Status,
Title: record.Title,
Tags: record.Tags,
UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339),
ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
if files[i].UploadedAt == files[j].UploadedAt {
return files[i].Name < files[j].Name
}
return files[i].UploadedAt > files[j].UploadedAt
})
return files, nil
}
func formatDuration(seconds int) string {
if seconds < 0 {
seconds = 0
}
hours := seconds / 3600
minutes := (seconds % 3600) / 60
remainingSeconds := seconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
}
func getMediaIDFromPath(path string) (uint, error) {
prefix := "/api/admin/media/"
if !strings.HasPrefix(path, prefix) {
return 0, fmt.Errorf("invalid media path")
}
idPart := strings.TrimPrefix(path, prefix)
idPart = strings.TrimSpace(idPart)
if idPart == "" || strings.Contains(idPart, "/") {
return 0, fmt.Errorf("invalid media id")
}
id, err := strconv.ParseUint(idPart, 10, 64)
if err != nil || id == 0 {
return 0, fmt.Errorf("invalid media id")
}
return uint(id), nil
}
func mediaEntryFromRecord(record Media) MediaFileEntry {
return MediaFileEntry{
ID: record.ID,
Name: record.Filename,
OriginalName: record.OriginalName,
Path: record.Path,
Size: record.SizeBytes,
DurationSeconds: record.DurationSeconds,
Runtime: formatDuration(record.DurationSeconds),
Status: record.Status,
Title: record.Title,
Tags: record.Tags,
UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339),
ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func probeMediaDurationSeconds(mediaPath string) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out, err := exec.CommandContext(
ctx,
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
mediaPath,
).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("ffprobe timed out")
}
if err != nil {
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
rawDuration := strings.TrimSpace(string(out))
if rawDuration == "" {
return 0, fmt.Errorf("ffprobe returned empty duration")
}
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
}
if secondsFloat < 0 {
secondsFloat = 0
}
seconds := int(secondsFloat + 0.5)
if secondsFloat > 0 && seconds == 0 {
seconds = 1
}
return seconds, nil
}
func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -851,12 +1032,49 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
return
}
defer targetFile.Close()
if _, err := io.Copy(targetFile, file); err != nil {
targetFile.Close()
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError)
return
}
if err := targetFile.Close(); err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError)
return
}
fileInfo, err := os.Stat(targetPath)
if err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
return
}
durationSeconds, err := probeMediaDurationSeconds(targetPath)
if err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
return
}
filename := filepath.Base(header.Filename)
mediaRecord := Media{
Filename: filename,
OriginalName: header.Filename,
Path: targetPath,
SizeBytes: fileInfo.Size(),
DurationSeconds: durationSeconds,
Status: "ready",
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
Tags: "",
}
if err := db.Create(&mediaRecord).Error; err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError)
return
}
files, err := listMediaFiles()
if err != nil {
@@ -871,6 +1089,90 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
}
}
func handleAdminMediaByID(w http.ResponseWriter, r *http.Request) {
mediaID, err := getMediaIDFromPath(r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var record Media
if err := db.First(&record, mediaID).Error; err != nil {
http.Error(w, "media item not found", http.StatusNotFound)
return
}
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
case http.MethodPut:
var payload struct {
Status string `json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
updates := map[string]any{}
if payload.Status != "" {
updates["status"] = strings.TrimSpace(payload.Status)
}
if payload.Title != "" {
updates["title"] = strings.TrimSpace(payload.Title)
}
updates["tags"] = strings.TrimSpace(payload.Tags)
if err := db.Model(&record).Updates(updates).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to update media metadata: %v", err), http.StatusInternalServerError)
return
}
if err := db.First(&record, mediaID).Error; err != nil {
http.Error(w, "media item not found after update", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
case http.MethodDelete:
absMediaDir, err := filepath.Abs(getMediaDir())
if err != nil {
http.Error(w, fmt.Sprintf("failed to resolve media directory: %v", err), http.StatusInternalServerError)
return
}
absTargetPath, err := filepath.Abs(record.Path)
if err != nil {
http.Error(w, fmt.Sprintf("failed to resolve media path: %v", err), http.StatusInternalServerError)
return
}
prefix := absMediaDir + string(os.PathSeparator)
if absTargetPath != absMediaDir && !strings.HasPrefix(absTargetPath, prefix) {
http.Error(w, "media path consistency check failed", http.StatusConflict)
return
}
if err := os.Remove(absTargetPath); err != nil && !os.IsNotExist(err) {
http.Error(w, fmt.Sprintf("failed to delete media file: %v", err), http.StatusInternalServerError)
return
}
if err := db.Delete(&record).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to delete media record: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"deleted": true, "id": mediaID})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func countConnectedClients() int {
mutex.Lock()
defer mutex.Unlock()
@@ -976,7 +1278,7 @@ func broadcast(msg Message) {
mutex.Lock()
defer mutex.Unlock()
// Save to SQLite
// Persist to configured SQL backend.
db.Create(&msg)
for client := range clients {
@@ -1054,7 +1356,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
broadcast(msg)
// Save to SQLite (already done in broadcast, but ensure for private DMs)
// Save private messages explicitly as well.
if msg.Type == "private" {
db.Create(&msg)
}
@@ -1063,7 +1365,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
// handlePrivateDM routes private messages to the specific user pair
func handlePrivateDM(msg Message) {
// Save to SQLite for history
// Persist private messages for history.
db.Create(&msg)
// Broadcast only to participants (simplified - in production use a map of user connections)
@@ -1122,6 +1424,7 @@ func main() {
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
http.HandleFunc("/metrics", handleMetrics)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+45 -56
View File
@@ -1,7 +1,7 @@
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
**Status:** Proposed
**Date:** 2026-08-02
**Status:** Accepted
**Date:** 2026-08-04
**Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005)
**Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md)
@@ -12,89 +12,78 @@ The system needs persistent storage for:
- Chat messages and history (public rooms and private DMs).
- User accounts, roles, and ban lists.
- Moderation audit log (immutable record of admin and moderator actions).
- Playlist metadata and schedule overrides.
- System configuration (including Redis settings adjustable via admin UI per ADR-005/ADR-007).
- Playlist and media metadata used by admin flows.
- System configuration visibility in admin APIs.
From ADR-003 we prefer operational simplicity. ADR-005 requires a robust audit trail. We must decide between **SQLite** (embedded, zero-config) and **PostgreSQL** (full-featured, scalable) and document how the admin interface can view and adjust storage-related configuration.
The codebase now ships with a PostgreSQL service in Docker Compose (`db`) and chat is configured with a PostgreSQL `DATABASE_URL` by default. The backend still supports SQLite for optional local fallback, but the deployed and compose-default path is PostgreSQL.
## Decision Drivers
1. **Operational Simplicity** – Minimize infrastructure (no separate DB server for SQLite).
2. **Performance & Concurrency** – Chat and moderation may have high write volume.
3. **Backup and Recovery** – Easy point-in-time recovery and replication.
4. **Admin Capabilities** – Admin UI must be able to view storage stats, run vacuum/analyze, and switch between SQLite and PostgreSQL (with migration path).
5. **Auditability** – Moderation log must be immutable and queryable.
6. **Cost** – Avoid managed database costs during early stages.
1. **Implemented Reality** - Docker Compose already provisions PostgreSQL and chat depends on it.
2. **Concurrency & Throughput** - Chat, moderation, and media metadata writes require stronger concurrent write behavior.
3. **Operational Traceability** - Structured backups, service health checks, and persistent volume management are already in place for PostgreSQL.
4. **Admin Growth** - Upcoming admin features (media CRUD, analytics, moderation tooling) benefit from relational querying and indexing.
5. **Fallback Simplicity** - Keep SQLite support for lightweight local or recovery scenarios without blocking developer workflows.
## Decision
**Primary storage for v1: SQLite** (embedded in the chat and admin services).
**Primary storage for current implementation: PostgreSQL (Docker Compose `db` service).**
**Migration Path**: Provide a clear upgrade path to PostgreSQL when concurrency or replication needs exceed SQLite capabilities (target v2 or when concurrent users > 500).
**Secondary/Fallback mode:** SQLite remains supported via `DATABASE_URL=sqlite://...` for local-only scenarios.
**Admin Interface Capabilities** (extension of ADR-005):
**Current Backend Behavior**:
- "System Configuration" tab showing current storage engine, database size, connection stats, and vacuum status.
- Buttons to run `VACUUM`, `ANALYZE`, or trigger a backup.
- Configuration form to switch connection string (SQLite file path vs PostgreSQL URL) and restart services gracefully.
- Audit log of all configuration changes.
- Chat backend (`backend/chat/src/main.go`) resolves `DATABASE_URL` at startup.
- `postgres://` or `postgresql://` selects PostgreSQL driver.
- `sqlite://` selects SQLite driver.
- Empty `DATABASE_URL` falls back to local `chat.db` for developer convenience.
- GORM AutoMigrate is used for chat/admin data models.
**Technical Details**:
**Media Model Decision (Admin UI Feature Track)**:
- Use SQLite with WAL mode for better concurrency.
- Use GORM or sqlx (Go) or Prisma (if Node.js fallback) for ORM/migrations.
- Store large chat history in a separate table with retention policy (e.g., 30 days).
- Moderation log as an append-only table with triggers for immutability.
**Folder Placement** (per ADR-002):
- Database models and migrations in `backend/shared/db/` or `backend/chat/internal/db/`.
- Admin UI pages for storage management in `frontend/admin/system/`.
- Media metadata is persisted in the same database as messages and moderation logs.
- Physical media files remain on the shared filesystem (`/app/media`, mapped from `data/media`).
- The database is the source of truth for media library metadata and future runtime analytics.
## Rationale
- SQLite aligns with ADR-003's emphasis on operational simplicity and low resource usage for the initial deployment on a single Hetzner VPS.
- It eliminates the need for a separate database container in `docker-compose.yml` during early development.
- The admin interface can provide visibility and basic maintenance operations, fulfilling the requirement from the previous TODO plan.
- PostgreSQL can be introduced later with a migration script when scale demands it (replication, advanced querying, full-text search on chat).
- The repository already operationalizes PostgreSQL in both `docker-compose.yml` and `docker-compose.prod.yml` with persistent storage.
- Default environment documentation (`.env.example`, setup docs) already points to PostgreSQL.
- This aligns architecture documentation with implemented behavior and avoids drift.
- Keeping SQLite support preserves local flexibility while avoiding a hard breaking change.
## Consequences
**Positive**:
- Zero-config database for quick local development and small-scale production.
- Simple backups (just copy the `.db` file).
- Admin UI can directly manage and monitor storage without external tools.
- Better write concurrency and query flexibility for chat + admin workloads.
- Clear operational model for containerized deployments (health checks, named volume, service dependency).
- Easier extension for admin analytics and media metadata features.
**Negative / Risks**:
- SQLite has limited concurrency compared to PostgreSQL (WAL mode helps but is not a full replacement).
- Large chat history or analytics queries may become slow – plan retention policy and archiving.
- Migration to PostgreSQL will require downtime or dual-write strategy (documented in this ADR).
- Additional service to operate versus embedded-only SQLite.
- Misconfigured `DATABASE_URL` can still route to SQLite fallback unintentionally if left empty outside compose.
- Requires backup/restore procedures for PostgreSQL volume in production runbooks.
**Alternatives Considered**:
## Alternatives Considered
- PostgreSQL from day one – rejected for added operational complexity (separate service, connection pooling, managed service cost).
- Pure key-value store (e.g., BoltDB) – rejected because SQL is better for audit logs and complex moderation queries.
- Managed database (Neon, Supabase) – rejected to maintain full self-hosted independence per ADR-001.
- SQLite-first in production: rejected for current implementation because compose and deployment already run PostgreSQL.
- PostgreSQL-only with no fallback: rejected to preserve local dev and troubleshooting flexibility.
- Managed external PostgreSQL: deferred; self-hosted compose remains the preferred operational model.
## Implementation Plan
## Implementation Status
1. Create this ADR-006 (done).
2. Add SQLite support in `backend/shared/db/` with GORM migrations.
3. Implement admin UI pages for storage monitoring and configuration (linked to ADR-005).
4. Add configuration reload capability without full restart where possible.
5. Write migration script from SQLite to PostgreSQL.
6. Update `docker-compose.yml` to optionally include PostgreSQL.
7. Add tests for data access patterns and admin configuration changes.
**Next ADR**: ADR-007 (Redis usage and admin configuration capabilities).
1. ADR updated to match implemented state. Done.
2. Compose includes PostgreSQL service and persistent volume. Done.
3. Chat backend supports PostgreSQL and SQLite based on `DATABASE_URL`. Done.
4. Media metadata persistence model documented here for Admin UI track. In progress (implementation ongoing).
5. Admin storage observability and operations UI (vacuum/analyze/backup flows). Planned.
## References
- ADR-001: Chat persistence, moderation log, monitoring requirements.
- ADR-003: Preference for operational simplicity and Go backend.
- ADR-005: Admin interface for user management, moderation, and analytics – this ADR extends it with storage and Redis config capabilities.
- arc42 documentation: Cross-cutting Concepts (data management, monitoring) and Risks & Technical Debts.
This ADR ensures all data storage decisions are documented before code is written, maintaining full traceability.
- ADR-005: Admin interface for user management, moderation, and analytics.
- ADR-007: Redis usage for presence, rate limiting, pub/sub.
- `docker-compose.yml` and `docker-compose.prod.yml` database service definitions.
+128 -9
View File
@@ -38,7 +38,7 @@
<template x-if="currentTab === 'dashboard'">
<div>
<h1 class="text-3xl font-bold mb-8">Dashboard</h1>
<div class="grid grid-cols-3 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-emerald-400 text-sm">Concurrent Viewers</div>
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
@@ -51,6 +51,13 @@
<div class="text-amber-400 text-sm">Moderation Actions</div>
<div class="text-5xl font-bold mt-2" x-text="stats.moderationActions"></div>
</div>
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-violet-400 text-sm">Media Runtime</div>
<div class="text-3xl font-bold mt-2" x-text="stats.mediaTotalRuntime"></div>
<div class="text-xs text-slate-400 mt-2">
<span x-text="stats.mediaCount"></span> files
</div>
</div>
</div>
<div class="mt-6 text-sm text-slate-400" x-text="statusMessage"></div>
</div>
@@ -82,21 +89,34 @@
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">File</th>
<th class="text-left py-3 pr-4 font-medium">Title</th>
<th class="text-left py-3 pr-4 font-medium">Runtime</th>
<th class="text-left py-3 pr-4 font-medium">Status</th>
<th class="text-left py-3 pr-4 font-medium">Size</th>
<th class="text-left py-3 font-medium">Modified</th>
<th class="text-left py-3 pr-4 font-medium">Modified</th>
<th class="text-left py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="media.files.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No media files available.</td>
<td colspan="7" class="py-6 text-slate-500">No media files available.</td>
</tr>
</template>
<template x-for="file in media.files" :key="file.name">
<template x-for="file in media.files" :key="file.id || file.name">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="file.name"></td>
<td class="py-3 pr-4" x-text="file.title || '-' "></td>
<td class="py-3 pr-4 font-mono text-xs" x-text="file.runtime || '00:00:00'"></td>
<td class="py-3 pr-4" x-text="file.status || 'unknown'"></td>
<td class="py-3 pr-4" x-text="file.size"></td>
<td class="py-3 font-mono text-xs" x-text="file.modifiedAt"></td>
<td class="py-3 pr-4 font-mono text-xs" x-text="file.modifiedAt"></td>
<td class="py-3">
<div class="flex gap-2">
<button @click="editMedia(file)" class="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded-lg text-xs">Edit</button>
<button @click="deleteMedia(file)" class="bg-rose-700 hover:bg-rose-600 px-3 py-1 rounded-lg text-xs">Delete</button>
</div>
</td>
</tr>
</template>
</tbody>
@@ -116,25 +136,33 @@
<button @click="refreshSchedule()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload schedule</button>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<div class="mb-4 text-sm text-slate-400">
<span x-text="schedule.matchedEntries"></span>/<span x-text="schedule.totalEntries"></span> matched
• total runtime <span class="font-mono" x-text="schedule.totalRuntime"></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">#</th>
<th class="text-left py-3 pr-4 font-medium">Label</th>
<th class="text-left py-3 pr-4 font-medium">Runtime</th>
<th class="text-left py-3 pr-4 font-medium">Status</th>
<th class="text-left py-3 font-medium">Path</th>
</tr>
</thead>
<tbody>
<template x-if="schedule.entries.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No schedule entries found.</td>
<td colspan="5" class="py-6 text-slate-500">No schedule entries found.</td>
</tr>
</template>
<template x-for="entry in schedule.entries" :key="entry.index + ':' + entry.path">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="entry.index"></td>
<td class="py-3 pr-4" x-text="entry.label"></td>
<td class="py-3 pr-4 font-mono text-xs" x-text="entry.runtime || '00:00:00'"></td>
<td class="py-3 pr-4" x-text="entry.status || 'missing'"></td>
<td class="py-3 font-mono text-xs" x-text="entry.path"></td>
</tr>
</template>
@@ -448,7 +476,9 @@
stats: {
viewers: 0,
storedMessages: 0,
moderationActions: 0
moderationActions: 0,
mediaCount: 0,
mediaTotalRuntime: '00:00:00'
},
system: {
redisEnabled: false,
@@ -467,6 +497,8 @@
messagesLast24Hours: 0,
distinctRooms: 0,
moderationActions: 0,
mediaCount: 0,
mediaTotalRuntime: '00:00:00',
topRooms: [],
generatedAt: 'loading...',
status: 'Loading analytics...',
@@ -474,6 +506,9 @@
},
schedule: {
entries: [],
totalEntries: 0,
matchedEntries: 0,
totalRuntime: '00:00:00',
status: 'Loading schedule...',
error: false
},
@@ -520,6 +555,8 @@
this.stats.viewers = overview.viewers;
this.stats.storedMessages = overview.storedMessages;
this.stats.moderationActions = overview.moderationActions;
this.stats.mediaCount = overview.mediaCount || 0;
this.stats.mediaTotalRuntime = overview.mediaTotalRuntime || '00:00:00';
this.system.redisEnabled = overview.redisEnabled;
this.system.redisClients = overview.redisClients;
this.system.redisMemoryUsed = overview.redisMemoryUsed;
@@ -549,6 +586,8 @@
this.analytics.messagesLast24Hours = payload.messagesLast24Hours;
this.analytics.distinctRooms = payload.distinctRooms;
this.analytics.moderationActions = payload.moderationActions;
this.analytics.mediaCount = payload.mediaCount || 0;
this.analytics.mediaTotalRuntime = payload.mediaTotalRuntime || '00:00:00';
this.analytics.topRooms = payload.topRooms || [];
this.analytics.generatedAt = payload.generatedAt;
this.analytics.status = 'Analytics loaded.';
@@ -589,7 +628,10 @@
const payload = await response.json();
this.schedule.entries = payload.entries || [];
this.schedule.status = 'Schedule loaded.';
this.schedule.totalEntries = payload.totalEntries || 0;
this.schedule.matchedEntries = payload.matchedEntries || 0;
this.schedule.totalRuntime = payload.totalRuntime || '00:00:00';
this.schedule.status = `Schedule loaded. Total runtime ${this.schedule.totalRuntime}.`;
} catch (error) {
console.error('Failed to load schedule', error);
this.schedule.status = 'Failed to load schedule.';
@@ -641,6 +683,83 @@
this.media.error = true;
}
},
async editMedia(file) {
if (!file.id) {
this.media.status = 'Cannot edit media item without id.';
this.media.error = true;
return;
}
const title = window.prompt('Title', file.title || file.name || '');
if (title === null) {
return;
}
const status = window.prompt('Status', file.status || 'ready');
if (status === null) {
return;
}
const tags = window.prompt('Tags (comma-separated)', file.tags || '');
if (tags === null) {
return;
}
this.media.error = false;
this.media.status = `Saving metadata for ${file.name}...`;
try {
const response = await fetch(`/api/admin/media/${file.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ title, status, tags })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
this.media.status = 'Media metadata updated.';
await this.refreshMediaLibrary();
await this.refreshOverview();
await this.refreshSchedule();
} catch (error) {
console.error('Failed to update media metadata', error);
this.media.status = 'Failed to update media metadata.';
this.media.error = true;
}
},
async deleteMedia(file) {
if (!file.id) {
this.media.status = 'Cannot delete media item without id.';
this.media.error = true;
return;
}
if (!window.confirm(`Delete ${file.name}? This removes file and metadata.`)) {
return;
}
this.media.error = false;
this.media.status = `Deleting ${file.name}...`;
try {
const response = await fetch(`/api/admin/media/${file.id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
this.media.status = `${file.name} deleted.`;
await this.refreshMediaLibrary();
await this.refreshOverview();
await this.refreshSchedule();
} catch (error) {
console.error('Failed to delete media', error);
this.media.status = 'Failed to delete media file.';
this.media.error = true;
}
},
async savePlaylist() {
this.playlist.saving = true;
this.playlist.status = 'Saving playlist...';
@@ -799,7 +918,7 @@
}
function runVacuum() {
alert('VACUUM executed on SQLite database (per ADR-006). Audit log entry created.');
alert('Database maintenance action placeholder. Use storage backend-specific operations from server-side APIs.');
}
</script>
</body>
+1 -1
View File
@@ -39,7 +39,7 @@
</div>
<div class="p-4 bg-slate-900 border-t border-slate-800 text-xs text-slate-400 flex justify-between">
<div>Latency: ~25s • Synced for all viewers (per ADR-001)</div>
<div>Latency: ~25s • Synced</div>
<div class="flex gap-6">
<a href="#" onclick="switchRoom('main')" class="hover:text-white">Main Channel</a>
<a href="#" onclick="switchRoom('chill')" class="hover:text-white">Chill Room</a>