package media import ( "database/sql" "net/http" "strings" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" ) // shelfItem is one card on the home screen. type shelfItem struct { ID int64 `json:"id"` LibraryID int64 `json:"library_id"` Kind string `json:"kind"` Title string `json:"title"` Percent float64 `json:"percent"` FileID int64 `json:"file_id"` UpdatedAt string `json:"updated_at"` } // homeResponse is everything the landing screen needs in one request. type homeResponse struct { Continue []shelfItem `json:"continue"` Recent []shelfItem `json:"recent"` CountsByKind map[string]int `json:"counts_by_kind"` LibraryCount int `json:"library_count"` TotalItems int `json:"total_items"` } // handleHome powers the landing screen: what to continue, what is new, and how // much is in each section. // // One request rather than three round trips, because the landing screen is the // first thing a user sees and should not pop in piece by piece. func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.User) { ctx := r.Context() libs, err := auth.VisibleLibraryIDs(ctx, h.db, u) if err != nil { h.fail(w, "visible libraries", err) return } if len(libs) == 0 { httpx.WriteJSON(w, http.StatusOK, homeResponse{ Continue: []shelfItem{}, Recent: []shelfItem{}, CountsByKind: map[string]int{}, }) return } in, args := inClause(libs) resp := homeResponse{CountsByKind: map[string]int{}, LibraryCount: len(libs)} // Continue: started but unfinished, most recent first. continueQ := ` SELECT i.id, i.library_id, i.kind, i.title, p.percent, p.file_id, p.updated_at FROM progress p JOIN media_items i ON i.id = p.item_id WHERE p.user_id = ? AND p.finished = 0 AND i.missing = 0 AND i.library_id IN (` + in + `) ORDER BY p.updated_at DESC LIMIT 20` rows, err := h.db.QueryContext(ctx, continueQ, append([]any{u.ID}, args...)...) if err != nil { h.fail(w, "home: continue", err) return } resp.Continue, err = scanShelf(rows) if err != nil { h.fail(w, "home: scan continue", err) return } // Recent: newest additions across every visible library. recentQ := ` SELECT i.id, i.library_id, i.kind, i.title, 0, 0, i.added_at FROM media_items i WHERE i.missing = 0 AND i.library_id IN (` + in + `) ORDER BY i.added_at DESC, i.id DESC LIMIT 40` rows, err = h.db.QueryContext(ctx, recentQ, args...) if err != nil { h.fail(w, "home: recent", err) return } resp.Recent, err = scanShelf(rows) if err != nil { h.fail(w, "home: scan recent", err) return } // Counts per kind, for the sidebar and section headers. countQ := ` SELECT i.kind, count(*) FROM media_items i WHERE i.missing = 0 AND i.library_id IN (` + in + `) GROUP BY i.kind` rows, err = h.db.QueryContext(ctx, countQ, args...) if err != nil { h.fail(w, "home: counts", err) return } defer rows.Close() for rows.Next() { var kind string var n int if err := rows.Scan(&kind, &n); err != nil { h.fail(w, "home: scan counts", err) return } resp.CountsByKind[kind] = n resp.TotalItems += n } if err := rows.Err(); err != nil { h.fail(w, "home: iterate counts", err) return } httpx.WriteJSON(w, http.StatusOK, resp) } // handleAuthors returns the people credited on an item. func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) { it, ok := h.authorized(w, r, u) if !ok { return } rows, err := h.db.QueryContext(r.Context(), ` SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.item_id = ? ORDER BY ip.ord`, it.ID) if err != nil { h.fail(w, "authors", err) return } defer rows.Close() authors := []string{} for rows.Next() { var name string if err := rows.Scan(&name); err != nil { h.fail(w, "scan author", err) return } authors = append(authors, name) } httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors}) } func scanShelf(rows *sql.Rows) ([]shelfItem, error) { defer rows.Close() out := []shelfItem{} for rows.Next() { var s shelfItem if err := rows.Scan(&s.ID, &s.LibraryID, &s.Kind, &s.Title, &s.Percent, &s.FileID, &s.UpdatedAt); err != nil { return nil, err } out = append(out, s) } return out, rows.Err() } // inClause builds "?,?,?" plus the matching args for an IN list. func inClause(ids []int64) (string, []any) { placeholders := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } return strings.Join(placeholders, ","), args }