Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.
internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
the package that authorizes the item; it imports home.RecordView for that one
thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
return different explore ids, which is the non-determinism the Explore rewrite
exists for.
internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
are not the scanner (modules register types, handlers serve the list, metadata
reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
not of the scan. The one genuinely scanner-owned helper became
library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
detection still short-circuits.
internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
the database writes. The split's whole point is that a provider is testable
WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
because real false matches were found.
dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
grep. That is what makes them reviewable.
- No field name, tag or error code changed.
internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.
Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.
Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.
Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
225 lines
7 KiB
Go
225 lines
7 KiB
Go
// Package home computes what the landing screen shows: the five shelves and the
|
|
// counts the sidebar needs.
|
|
//
|
|
// It is separated from internal/media because the two answer different
|
|
// questions. Media serves item RESOURCES — one item, its file, its cover, its
|
|
// progress. Home computes AGGREGATES over many items: what you were in the
|
|
// middle of, what you keep opening, what to try next. Neither can be built from
|
|
// the other, and a change to a shelf query has no business touching the endpoint
|
|
// that streams a file.
|
|
//
|
|
// It depends on internal/catalog only for the shape of an item; the SQL is its
|
|
// own because the shelves are the only queries in the server that aggregate
|
|
// across libraries, kinds and users at once.
|
|
package home
|
|
|
|
import (
|
|
"database/sql"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/internal/httpx"
|
|
)
|
|
|
|
// Handlers serves the home screen.
|
|
type Handlers struct {
|
|
db *sql.DB
|
|
log *slog.Logger
|
|
auth *auth.Authenticator
|
|
}
|
|
|
|
// NewHandlers builds the home-screen HTTP layer.
|
|
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
|
|
return &Handlers{db: db, log: log, auth: a}
|
|
}
|
|
|
|
// Register mounts the routes.
|
|
func (h *Handlers) Register(mux *http.ServeMux) {
|
|
mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome))
|
|
}
|
|
|
|
// 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"`
|
|
Views int `json:"views"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// suggestion is a recommendation with the reason it was made.
|
|
type suggestion struct {
|
|
ID int64 `json:"id"`
|
|
LibraryID int64 `json:"library_id"`
|
|
Kind string `json:"kind"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// homeResponse is everything the landing screen needs in one request.
|
|
type homeResponse struct {
|
|
Continue []shelfItem `json:"continue"`
|
|
MostViewed []shelfItem `json:"most_viewed"`
|
|
Suggestions []suggestion `json:"suggestions"`
|
|
Recent []shelfItem `json:"recent"`
|
|
Explore []shelfItem `json:"explore"`
|
|
CountsByKind map[string]int `json:"counts_by_kind"`
|
|
LibraryCount int `json:"library_count"`
|
|
TotalItems int `json:"total_items"`
|
|
}
|
|
|
|
// handleHome powers the landing screen: five shelves plus the counts the
|
|
// sidebar needs.
|
|
//
|
|
// One request rather than six 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
|
|
}
|
|
|
|
resp := homeResponse{
|
|
Continue: []shelfItem{},
|
|
MostViewed: []shelfItem{},
|
|
Suggestions: []suggestion{},
|
|
Recent: []shelfItem{},
|
|
Explore: []shelfItem{},
|
|
CountsByKind: map[string]int{},
|
|
}
|
|
if len(libs) == 0 {
|
|
httpx.WriteJSON(w, http.StatusOK, resp)
|
|
return
|
|
}
|
|
resp.LibraryCount = len(libs)
|
|
in, args := inClause(libs)
|
|
|
|
// ── Continue: started but unfinished, one row per item ───────────────
|
|
//
|
|
// GROUP BY item_id with MAX(updated_at): SQLite takes the bare column
|
|
// values from the row that produced the max, so a multi-track audiobook
|
|
// contributes its most recent track rather than one card per chapter.
|
|
rows, err := h.db.QueryContext(ctx, `
|
|
SELECT i.id, i.library_id, i.kind, i.title, p.percent, p.file_id, 0,
|
|
MAX(p.updated_at) AS 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+`)
|
|
GROUP BY p.item_id
|
|
ORDER BY updated_at DESC
|
|
LIMIT 20`, append([]any{u.ID}, args...)...)
|
|
if err != nil {
|
|
h.fail(w, "home: continue", err)
|
|
return
|
|
}
|
|
if resp.Continue, err = scanShelf(rows); err != nil {
|
|
h.fail(w, "home: scan continue", err)
|
|
return
|
|
}
|
|
|
|
// ── Most viewed: what this user actually opens ───────────────────────
|
|
if resp.MostViewed, err = MostViewed(ctx, h.db, u.ID, libs, 20); err != nil {
|
|
h.fail(w, "home: most viewed", err)
|
|
return
|
|
}
|
|
|
|
// ── Suggestions: related to what they have been consuming ────────────
|
|
if resp.Suggestions, err = Suggestions(ctx, h.db, u.ID, libs, 20); err != nil {
|
|
h.fail(w, "home: suggestions", err)
|
|
return
|
|
}
|
|
|
|
// ── Recently added ───────────────────────────────────────────────────
|
|
rows, err = h.db.QueryContext(ctx, `
|
|
SELECT i.id, i.library_id, i.kind, i.title, 0, 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`, args...)
|
|
if err != nil {
|
|
h.fail(w, "home: recent", err)
|
|
return
|
|
}
|
|
if resp.Recent, err = scanShelf(rows); err != nil {
|
|
h.fail(w, "home: scan recent", err)
|
|
return
|
|
}
|
|
|
|
// ── Explore: a mixed sample of everything else ───────────────────────
|
|
if resp.Explore, err = Explore(ctx, h.db, u.ID, libs, 30); err != nil {
|
|
h.fail(w, "home: explore", err)
|
|
return
|
|
}
|
|
|
|
// ── Counts per kind, for the sidebar and section headers ─────────────
|
|
rows, err = h.db.QueryContext(ctx, `
|
|
SELECT i.kind, count(*) FROM media_items i
|
|
WHERE i.missing = 0 AND i.library_id IN (`+in+`)
|
|
GROUP BY i.kind`, 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)
|
|
}
|
|
|
|
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
|
h.log.Error(what, "err", err)
|
|
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
|
}
|
|
|
|
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.Views, &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
|
|
}
|
|
|
|
// nowRFC3339 returns the canonical database timestamp for a write.
|
|
func nowRFC3339() string {
|
|
return core.Now()
|
|
}
|