feat(audiobooks): port ABS handler scaffolding (mount/handler/filter)

Stage 1 of the audiobookshelf-compatible REST + Socket.io port from
silo-plugin-audiobooks. Lands the package layout, Handler struct with
interface stubs for silo-side dependencies (MediaStore, TokenStore,
ProfileCredentialValidator, ConfigProvider, EventPublisher, Recommender),
and an empty Mount() method. Real route handlers arrive in subsequent stages
(auth, file serving, progress, browse).

Plugin imports of store/backend/streaming/mediatoken/bookref/runtimehost are
replaced with minimal interfaces; real silo implementations come when those
interfaces are needed by specific route handlers.

Also ports filter.go (ABS filter query parsing/matching), access_log.go
(request logging via log/slog), minified.go (minified=1 response shaping),
login_ratelimit.go (per-IP token bucket), jwt.go (JWT mint/parse), and
types.go (shared ABS wire-format types and constants) — all clean of
plugin-specific imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-24 15:14:12 +02:00
co-authored by Claude Opus 4.7
parent 7ea7609931
commit 226d721ea3
7 changed files with 992 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
package abs
import (
"log/slog"
"net/http"
"strings"
"time"
)
// accessLog is a minimal chi middleware that emits one structured line
// per request. The 2xx/3xx path logs at Debug so a default-Info runtime
// stays quiet during normal playback; non-2xx escalates to Warn so
// failures still surface without an explicit log-level flip. Path is
// captured query-less so ?token= and refresh tokens never land in logs.
func (h *Handler) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(sw, r)
auth := r.Header.Get("Authorization")
authKind := "none"
switch {
case strings.HasPrefix(auth, "Bearer "):
authKind = "bearer"
case auth != "":
authKind = "other"
case r.URL.Query().Get("token") != "":
authKind = "qtok"
}
// Short-circuit asset requests the mobile app never hits to keep
// the signal-to-noise high.
path := r.URL.Path
if strings.HasPrefix(path, "/assets/") {
return
}
args := []any{
"method", r.Method,
"path", path,
"auth", authKind,
"status", sw.status,
"dur_ms", time.Since(start).Milliseconds(),
}
if sw.status >= 400 {
slog.Warn("abs req failed", args...)
return
}
slog.Debug("abs req", append(args, "bytes", sw.bytes)...)
})
}
// statusRecorder lets the access log read the status code + bytes
// written without re-implementing http.ResponseWriter.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
+135
View File
@@ -0,0 +1,135 @@
package abs
import (
"encoding/base64"
"strings"
)
// FilterKind is the leading segment of an ABS `filter=` query value.
type FilterKind string
const (
FilterAuthors FilterKind = "authors"
FilterSeries FilterKind = "series"
FilterNarrators FilterKind = "narrators"
FilterGenres FilterKind = "genres"
FilterProgress FilterKind = "progress"
FilterTags FilterKind = "tags"
FilterLanguages FilterKind = "languages"
)
// SentinelNoSeries is the literal value real ABS clients send for "books
// without a series" — it is NOT base64-encoded, in contrast to ordinary
// series IDs which are.
const SentinelNoSeries = "no-series"
// Filter describes a parsed ABS `filter=<kind>.<value>` query parameter.
// Value is the post-decode value (base64-decoded for most kinds; sentinel
// values such as "no-series" are passed through). Raw preserves the
// original `<kind>.<value>` for echoing back in pagination envelopes.
type Filter struct {
Kind FilterKind
Value string
Raw string
}
// ParseFilter pulls apart an ABS `filter=` query value. Real ABS encodes the
// value as base64-then-URL-encoded — chi/http already URL-decodes the query,
// so the input we see is `<kind>.<base64-value>`. Two non-encoded special
// cases: the literal `no-series` sentinel and the `progress.*` family
// (in-progress / finished / not-finished). When the value isn't valid
// base64, we treat it as a sentinel and pass it through unchanged.
//
// Returns (Filter{}, false) when raw is empty or has no kind prefix.
func ParseFilter(raw string) (Filter, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Filter{}, false
}
dot := strings.IndexByte(raw, '.')
if dot <= 0 || dot >= len(raw)-1 {
return Filter{}, false
}
kind := FilterKind(raw[:dot])
rest := raw[dot+1:]
out := Filter{Kind: kind, Raw: raw}
// progress.* and the no-series sentinel are never base64-encoded by
// real ABS clients.
if kind == FilterProgress || rest == SentinelNoSeries {
out.Value = rest
return out, true
}
if b, err := base64.RawURLEncoding.DecodeString(rest); err == nil && len(b) > 0 {
out.Value = string(b)
return out, true
}
if b, err := base64.RawStdEncoding.DecodeString(rest); err == nil && len(b) > 0 {
out.Value = string(b)
return out, true
}
if b, err := base64.StdEncoding.DecodeString(rest); err == nil && len(b) > 0 {
out.Value = string(b)
return out, true
}
// Fall through — accept the raw string as a sentinel. Future ABS
// versions may add more sentinels (mirroring how no-series escaped the
// base64 encoding); this keeps us forward-compatible.
out.Value = rest
return out, true
}
// Matches reports whether the given LibraryItem satisfies this filter. genres
// is best-effort — backend summaries don't carry genres, so a genres filter
// matches nothing unless the caller pre-populated the field via detail
// lookups (we currently don't). tags / languages behave the same way and
// are accepted for forward-compat but always return false.
//
// Progress filters require the caller to supply an optional `inProgress`
// and `finished` flag for the book; without them the progress branch
// returns false. The caller is responsible for joining progress state from
// the store.
func (f Filter) Matches(item LibraryItem, inProgress, finished bool, hasProgress bool) bool {
switch f.Kind {
case FilterAuthors:
for _, a := range item.Media.Metadata.Authors {
if a.ID == f.Value || a.Name == f.Value {
return true
}
}
return false
case FilterSeries:
if f.Value == SentinelNoSeries {
return len(item.Media.Metadata.Series) == 0
}
for _, s := range item.Media.Metadata.Series {
if s.ID == f.Value || s.Name == f.Value {
return true
}
}
return false
case FilterNarrators:
for _, n := range item.Media.Metadata.Narrators {
if n == f.Value {
return true
}
}
return false
case FilterProgress:
switch f.Value {
case "in-progress":
return hasProgress && inProgress && !finished
case "finished":
return hasProgress && finished
case "not-finished":
return !finished
case "not-started":
return !hasProgress
}
return false
default:
// genres / tags / languages — not derivable from a summary today.
return false
}
}
+322
View File
@@ -0,0 +1,322 @@
// Package abs implements the Audiobookshelf-mobile-app compatibility surface.
// It mints self-contained JWTs signed with a per-deployment secret and serves
// the /abs/api/* and /abs/public/* routes, as well as the canonical
// root-level paths real ABS clients build against (e.g. /login, /api/items).
//
// Stage 1 lands the package skeleton: Handler struct, interface stubs for
// silo-side dependencies, and an empty Mount() method. Real route handlers
// are added in subsequent stages (auth, file serving, progress, browse).
package abs
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/models"
)
// ---------------------------------------------------------------------------
// Dependency interfaces
// ---------------------------------------------------------------------------
// MediaStore is the slice of silo's catalog the ABS handler reads.
// Real impl: catalog.ItemRepository + scanner.FileRepository wrapped in
// a small adapter struct added in a later stage.
type MediaStore interface {
GetAudiobookByID(ctx context.Context, contentID string) (*models.MediaItem, error)
ListAudiobooks(ctx context.Context, limit, offset int) ([]*models.MediaItem, int, error)
GetMediaFiles(ctx context.Context, contentID string) ([]*models.MediaFile, error)
}
// TokenStore persists and validates the ABS JWT JTIs that back the
// revocable-token surface (login, refresh, logout, bearerAuth).
// Real impl: a thin repo over the abs_tokens table added in Stage 2.
type TokenStore interface {
// InsertToken persists a newly minted JTI.
InsertToken(ctx context.Context, tok ABSToken) error
// GetTokenByJTI looks up a token by its JTI; returns ErrNotFound if absent.
GetTokenByJTI(ctx context.Context, jti string) (ABSToken, error)
// RevokeTokenByJTI marks a JTI as revoked (sets revoked_at).
RevokeTokenByJTI(ctx context.Context, jti string) error
// TouchToken extends last_seen_at for active-session bookkeeping.
TouchToken(ctx context.Context, jti string) error
}
// ABSToken is the in-memory representation of a persisted JTI row.
type ABSToken struct {
ID string
UserID string
ProfileID string
JTI string
ExpiresAt time.Time
RevokedAt *time.Time
}
// ProfileCredentialValidator validates a (username, password) pair against
// silo's auth backend. Implemented by an adapter over internal/auth in a
// later stage.
type ProfileCredentialValidator interface {
Validate(ctx context.Context, username, password string) (userID string, profileID string, displayName string, err error)
}
// EventPublisher delivers a realtime event to Socket.io clients. May be nil;
// handlers guard with publish/broadcast nil-safe wrappers.
type EventPublisher interface {
Publish(userID, event string, payload any)
Broadcast(event string, payload any)
}
// Recommender powers /items/{id}/similar. nil → route returns an empty list.
type Recommender interface {
Similar(ctx context.Context, contentID string, limit int) ([]string, error)
}
// ---------------------------------------------------------------------------
// Config provider
// ---------------------------------------------------------------------------
// ConfigProvider supplies runtime config values the ABS handler needs.
// Keeps the handler decoupled from any particular settings-store shape.
type ConfigProvider interface {
// JWTSecret returns the HMAC-SHA256 signing secret for ABS JWTs.
JWTSecret(ctx context.Context) ([]byte, error)
// AccessTTL / RefreshTTL are the default token lifetimes; zero means
// "use built-in default (24 h / 30 d)".
AccessTTL(ctx context.Context) (time.Duration, error)
RefreshTTL(ctx context.Context) (time.Duration, error)
// StandaloneLoginEnabled reports whether body-creds login is permitted
// (i.e., operator has not disabled it in settings).
StandaloneLoginEnabled(ctx context.Context) (bool, error)
}
// ---------------------------------------------------------------------------
// Dependencies + Handler
// ---------------------------------------------------------------------------
// Dependencies bundles everything the Handler needs at construction time.
type Dependencies struct {
MediaStore MediaStore
TokenStore TokenStore
CredValidator ProfileCredentialValidator
Config ConfigProvider
Publisher EventPublisher // may be nil
Recommender Recommender // may be nil
LoginLimiter *LoginLimiter // may be nil — one is created if absent
// InstallID returns the current plugin install ID for building
// host-proxy-routable URLs. Defaults to "silo.audiobooks" when nil.
InstallID func() string
}
// Handler wires the /abs/api/* and canonical ABS-client paths.
type Handler struct {
deps Dependencies
}
// New constructs an ABS Handler. Sensible defaults are applied for optional
// fields (LoginLimiter, InstallID).
func New(deps Dependencies) *Handler {
if deps.LoginLimiter == nil {
deps.LoginLimiter = NewLoginLimiter()
}
if deps.InstallID == nil {
deps.InstallID = func() string { return "silo.audiobooks" }
}
return &Handler{deps: deps}
}
// ---------------------------------------------------------------------------
// Mount
// ---------------------------------------------------------------------------
// Mount registers the ABS-compatible routes on r. Stage 1 registers an empty
// /abs group with the access-log middleware attached; subsequent stages add
// real route handlers.
//
// The dual-mount design (routes at both /abs/api/* and /* roots) is preserved
// here so stage-by-stage handlers land in the right places without needing to
// revisit Mount later.
func (h *Handler) Mount(parent chi.Router) {
parent.Group(func(r chi.Router) {
r.Use(h.accessLog)
h.mountRoutes(r)
})
}
func (h *Handler) mountRoutes(_ chi.Router) {
// TODO Stage 2: auth routes (login, logout, refresh, authorize, me, ping, status, init)
// TODO Stage 3: library browse routes (libraries, items, item detail, cover, authors, series, search, personalized)
// TODO Stage 4: playback session + file routes (play, file/download, public/track)
// TODO Stage 5: progress routes (me/progress/*, me/items-in-progress, me/listening-stats, me/stats/year/*)
// TODO Stage 6: social / collection routes (bookmarks, smart-collections, collections, playlists, RSS feeds, similar)
}
// ---------------------------------------------------------------------------
// Auth context helpers (used by bearerAuth middleware + handlers)
// ---------------------------------------------------------------------------
// ctxKey is the unexported ABS auth context key.
type ctxKey struct{}
// ctxAuth carries the decoded ABS JWT claims for the lifetime of a request.
type ctxAuth struct {
UserID string
ProfileID string
JTI string
Token string // raw bearer token
}
// absAuthFrom extracts ABS auth from the request context. Returns (zero, false)
// when bearerAuth middleware hasn't run (unauthenticated routes).
func absAuthFrom(r *http.Request) (ctxAuth, bool) {
a, ok := r.Context().Value(ctxKey{}).(ctxAuth)
return a, ok
}
// bearerAuth is the authentication middleware for protected ABS routes.
// It reads the bearer token from the Authorization header or ?token= query
// param, validates the JWT, checks the JTI isn't revoked, and injects
// ctxAuth into the request context.
//
// Placeholder implementation — full validation logic lands in Stage 2 when
// TokenStore and ConfigProvider are wired to real backing stores.
func (h *Handler) bearerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" {
raw = r.URL.Query().Get("token")
}
if raw == "" {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
if h.deps.Config == nil || h.deps.TokenStore == nil {
// Dependencies not yet wired — reject to avoid a security hole.
http.Error(w, "auth not configured", http.StatusServiceUnavailable)
return
}
secret, err := h.deps.Config.JWTSecret(r.Context())
if err != nil {
http.Error(w, "config unavailable", http.StatusInternalServerError)
return
}
claims, err := ParseToken(secret, raw)
if err != nil || claims.Type != "access" {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI)
if err != nil || row.RevokedAt != nil {
http.Error(w, "token revoked", http.StatusUnauthorized)
return
}
_ = h.deps.TokenStore.TouchToken(r.Context(), claims.JTI)
ctx := context.WithValue(r.Context(), ctxKey{}, ctxAuth{
UserID: claims.UserID,
ProfileID: claims.ProfileID,
JTI: claims.JTI,
Token: raw,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// ---------------------------------------------------------------------------
// Publisher nil-safe wrappers
// ---------------------------------------------------------------------------
func (h *Handler) publish(userID, event string, payload any) {
if h.deps.Publisher == nil {
return
}
h.deps.Publisher.Publish(userID, event, payload)
}
func (h *Handler) broadcast(event string, payload any) {
if h.deps.Publisher == nil {
return
}
h.deps.Publisher.Broadcast(event, payload)
}
// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------
// absBaseURL returns the server address prefix ABS clients should use to
// resolve response-embedded URLs.
//
// - Host-proxied (X-Silo-User-Id header present): returns the plugin-proxy
// path "<scheme>://<host>/api/v1/plugins/<installID>".
// - Standalone listener: returns "<scheme>://<host>" — origin only.
//
// Honors X-Forwarded-Proto / X-Forwarded-Host for TLS-terminating proxies.
func (h *Handler) absBaseURL(r *http.Request) string {
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
if r.Header.Get("X-Silo-User-Id") != "" {
return scheme + "://" + host + "/api/v1/plugins/" + h.deps.InstallID()
}
return scheme + "://" + host
}
// ---------------------------------------------------------------------------
// Shared response helpers (used by handlers across multiple stages)
// ---------------------------------------------------------------------------
// writeJSON serialises v as JSON and writes it with the given HTTP status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// readPagedQuery extracts `limit` and `page` from query params. Real ABS
// treats limit=0 as "return all" (not "return zero rows") — we surface that
// intent and let callers short-circuit pagination.
func readPagedQuery(r *http.Request, defaultLimit int) (limit, page int) {
limit = defaultLimit
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
limit = n
}
}
if v := r.URL.Query().Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
page = n
}
}
return limit, page
}
// pagedEnvelope builds the standard ABS pagination response shape. All eight
// fields are always emitted (no omitempty) because ABS clients branch on
// their presence (sortBy, filterBy, minified).
func pagedEnvelope(results any, total, limit, page int, sortBy string, sortDesc bool, filterBy string, minified bool, include string) map[string]any {
return map[string]any{
"results": results,
"total": total,
"limit": limit,
"page": page,
"sortBy": sortBy,
"sortDesc": sortDesc,
"filterBy": filterBy,
"minified": minified,
"include": include,
}
}
+91
View File
@@ -0,0 +1,91 @@
// Package abs — JWT minting and validation for the ABS-compat layer.
package abs
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims are the unified ABS JWT claim set. Different `Type` values denote
// access, refresh, or session tokens.
type Claims struct {
Type string `json:"type"` // access | refresh | session
UserID string `json:"sub"` // user id
ProfileID string `json:"pid,omitempty"` // empty = primary profile
JTI string `json:"jti"` // token id (revocable)
DeviceID string `json:"device_id,omitempty"`
SessionID string `json:"sid,omitempty"`
BookID string `json:"bid,omitempty"`
FileIdx int `json:"fidx,omitempty"`
jwt.RegisteredClaims
}
// IssueAccessToken mints a stateless access JWT.
func IssueAccessToken(secret []byte, userID, profileID, jti string, ttl time.Duration) (string, error) {
return issueJWT(secret, Claims{
Type: "access",
UserID: userID,
ProfileID: profileID,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
})
}
// IssueRefreshToken mints a refresh JWT.
func IssueRefreshToken(secret []byte, userID, profileID, jti string, ttl time.Duration) (string, error) {
return issueJWT(secret, Claims{
Type: "refresh",
UserID: userID,
ProfileID: profileID,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
})
}
// IssueSessionToken mints a streaming-capability JWT used in the public route.
func IssueSessionToken(secret []byte, userID, sessionID, bookID string, fileIdx int, ttl time.Duration) (string, error) {
return issueJWT(secret, Claims{
Type: "session",
UserID: userID,
SessionID: sessionID,
BookID: bookID,
FileIdx: fileIdx,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
})
}
// ParseToken validates and decodes a JWT. Returns an error on signature
// mismatch or expiry.
func ParseToken(secret []byte, raw string) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, err
}
if !tok.Valid {
return nil, errors.New("token invalid")
}
return claims, nil
}
func issueJWT(secret []byte, c Claims) (string, error) {
t := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return t.SignedString(secret)
}
+115
View File
@@ -0,0 +1,115 @@
package abs
import (
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// loginLimitBurst caps the number of body-creds /login attempts a single
// source IP can make in quick succession. The token bucket refills at
// loginLimitPerToken (10/min ≈ one every 6s), so a bursty client can spend
// the burst and then must wait. Tuned to be invisible to legitimate listeners
// (who attempt login once and succeed) while making credential-stuffing
// expensive.
const (
loginLimitBurst = 10
loginLimitPerToken = 6 * time.Second
loginLimitIdle = 10 * time.Minute
loginLimitGCEvery = 5 * time.Minute
)
type loginLimiterEntry struct {
limiter *rate.Limiter
last time.Time
}
// LoginLimiter is a process-local per-IP rate limiter for the standalone-port
// body-creds login path. The header-authenticated path is never gated here —
// that traffic comes from the trusted silo host proxy.
//
// Construct one per process (in server wiring) and inject via
// Dependencies.LoginLimiter. Constructing one per Handler would leak a
// janitor goroutine on every reconfigure.
type LoginLimiter struct {
mu sync.Mutex
buckets map[string]*loginLimiterEntry
stopCh chan struct{}
}
// NewLoginLimiter builds a limiter and starts its background janitor. The
// janitor exits when Stop() is called.
func NewLoginLimiter() *LoginLimiter {
l := &LoginLimiter{
buckets: make(map[string]*loginLimiterEntry),
stopCh: make(chan struct{}),
}
go l.janitor()
return l
}
// Stop terminates the janitor goroutine. Safe to call once.
func (l *LoginLimiter) Stop() { close(l.stopCh) }
func (l *LoginLimiter) allow(key string) bool {
if key == "" {
return true
}
l.mu.Lock()
e, ok := l.buckets[key]
if !ok {
e = &loginLimiterEntry{
limiter: rate.NewLimiter(rate.Every(loginLimitPerToken), loginLimitBurst),
}
l.buckets[key] = e
}
e.last = time.Now()
lim := e.limiter
l.mu.Unlock()
return lim.Allow()
}
func (l *LoginLimiter) janitor() {
ticker := time.NewTicker(loginLimitGCEvery)
defer ticker.Stop()
for {
select {
case <-l.stopCh:
return
case <-ticker.C:
cutoff := time.Now().Add(-loginLimitIdle)
l.mu.Lock()
for k, e := range l.buckets {
if e.last.Before(cutoff) {
delete(l.buckets, k)
}
}
l.mu.Unlock()
}
}
}
// clientIP returns the rate-limit key for a request. The standalone listener
// terminates TCP directly with the client, so r.RemoteAddr is the real client
// IP. Honors X-Forwarded-For's first hop as a defensive fallback for operators
// who put their own reverse proxy in front of the standalone listener; only
// the first hop is trusted (the rest is client-supplied).
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
xff = xff[:i]
}
if v := strings.TrimSpace(xff); v != "" {
return v
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
+100
View File
@@ -0,0 +1,100 @@
package abs
import "strings"
// MinifiedLibraryItem mirrors real-ABS's `minified=1` response shape. The
// big-ticket changes from the full LibraryItem: media.metadata.authors and
// media.metadata.series arrays are omitted; the metadata block grows flat
// authorName / authorNameLF / seriesName / seriesSequence fields built from
// the same source data; chapters and audioFiles are dropped.
//
// Real ABS clients sniff for these fields by name. Emitting the full shape
// for a minified request works (clients ignore extra fields) but is wasteful
// on the wire when a client is paging through hundreds of items. Emitting
// the minified shape for a full request silently breaks detail pages.
type MinifiedLibraryItem struct {
ID string `json:"id"`
LibraryID string `json:"libraryId"`
FolderID string `json:"folderId"`
MediaType string `json:"mediaType"`
Media minifiedMedia `json:"media"`
NumTracks int `json:"numTracks,omitempty"`
AddedAt int64 `json:"addedAt"`
UpdatedAt int64 `json:"updatedAt"`
}
type minifiedMedia struct {
Metadata minifiedMetadata `json:"metadata"`
Duration float64 `json:"duration"`
CoverPath string `json:"coverPath"`
}
type minifiedMetadata struct {
Title string `json:"title"`
AuthorName string `json:"authorName"`
AuthorNameLF string `json:"authorNameLF"`
SeriesName string `json:"seriesName,omitempty"`
SeriesSequence string `json:"seriesSequence,omitempty"`
Narrators []string `json:"narrators,omitempty"`
PublishedYear string `json:"publishedYear,omitempty"`
}
// Minify projects a LibraryItem onto the minified shape. The original item
// is not mutated. authorName joins authors with ", "; authorNameLF inverts
// the standard "First Last" → "Last, First" form when there's a space, and
// joins multiple authors with " & " (matching the convention real ABS uses
// for shelf sort labels).
func Minify(item LibraryItem) MinifiedLibraryItem {
m := item.Media.Metadata
names := make([]string, 0, len(m.Authors))
lfNames := make([]string, 0, len(m.Authors))
for _, a := range m.Authors {
if a.Name == "" {
continue
}
names = append(names, a.Name)
lfNames = append(lfNames, lastFirst(a.Name))
}
seriesName, seriesSeq := "", ""
if len(m.Series) > 0 {
s := m.Series[0]
seriesName = s.Name
seriesSeq = s.Sequence
}
return MinifiedLibraryItem{
ID: item.ID,
LibraryID: item.LibraryID,
FolderID: item.FolderID,
MediaType: item.MediaType,
Media: minifiedMedia{
Metadata: minifiedMetadata{
Title: m.Title,
AuthorName: strings.Join(names, ", "),
AuthorNameLF: strings.Join(lfNames, " & "),
SeriesName: seriesName,
SeriesSequence: seriesSeq,
Narrators: m.Narrators,
PublishedYear: m.PublishedYear,
},
Duration: item.Media.Duration,
CoverPath: item.Media.CoverPath,
},
NumTracks: item.NumTracks,
AddedAt: item.AddedAt,
UpdatedAt: item.UpdatedAt,
}
}
// lastFirst flips a "First Middle Last" name into "Last, First Middle". For
// single-token names it returns them unchanged. Empty inputs return empty.
func lastFirst(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
last := strings.LastIndexByte(name, ' ')
if last <= 0 || last == len(name)-1 {
return name
}
return name[last+1:] + ", " + name[:last]
}
+158
View File
@@ -0,0 +1,158 @@
package abs
// ABS wire-format constants. ServerVersion must be ≥ 2.26.0 for the official
// ABS mobile app to take its JWT path; below that it falls into "old token"
// mode and rejects modern refresh-token semantics.
// Ref: /opt/audiobookshelf-app/components/connect/ServerConnectForm.vue:731
const (
VirtualLibraryID = "silo-audiobooks"
VirtualLibraryName = "Audiobooks"
VirtualFolderID = "main"
LibraryMediaType = "book"
ServerVersion = "2.35.0"
ServerSourceTag = "silo"
)
// AuthorObj is the ABS-shaped author reference. ABS clients filter by id;
// some screens render only name.
type AuthorObj struct {
ID string `json:"id"`
Name string `json:"name"`
}
// SeriesObj is the ABS-shaped series reference; Sequence is the per-book
// position string (e.g. "1", "1.5").
type SeriesObj struct {
ID string `json:"id"`
Name string `json:"name"`
Sequence string `json:"sequence,omitempty"`
}
// ChapterABS is the ABS chapter shape (start/end in seconds, float).
type ChapterABS struct {
ID int `json:"id"`
Start float64 `json:"start"`
End float64 `json:"end"`
Title string `json:"title"`
}
// AudioTrackMetadata is the file-level metadata block nested inside each
// AudioTrack. The mobile downloader reads filename/ext to name the local
// copy, size to budget storage, and the mtime fields for cache invalidation.
type AudioTrackMetadata struct {
Filename string `json:"filename"`
Ext string `json:"ext"`
Path string `json:"path"`
RelPath string `json:"relPath"`
Size int64 `json:"size"`
MtimeMs int64 `json:"mtimeMs"`
CtimeMs int64 `json:"ctimeMs"`
BirthtimeMs int64 `json:"birthtimeMs"`
}
// AudioTrack is a single playable file as the ABS mobile client expects to
// see it. The shape is rich because the official audiobookshelf-app's Vue
// layer reads many fields off each track — ino + metadata for download URL
// construction and offline-cache decisions, bitRate / channels / codec /
// format for the "Now Playing" detail UI, embeddedCoverArt for whether to
// fall back to the item-level cover, metaTags for ID3-style display. A
// missing key on any of those code paths makes the player silently abort
// the audio load (the "spinner forever" we kept chasing before).
type AudioTrack struct {
Index int `json:"index"`
Ino string `json:"ino"`
Metadata *AudioTrackMetadata `json:"metadata,omitempty"`
AddedAt int64 `json:"addedAt,omitempty"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
TrackNumFromMeta *int `json:"trackNumFromMeta"`
DiscNumFromMeta *int `json:"discNumFromMeta"`
TrackNumFromFilename *int `json:"trackNumFromFilename"`
DiscNumFromFilename *int `json:"discNumFromFilename"`
ManuallyVerified bool `json:"manuallyVerified"`
Exclude bool `json:"exclude"`
Error *string `json:"error"`
Format string `json:"format,omitempty"`
Duration float64 `json:"duration"`
BitRate int `json:"bitRate,omitempty"`
Language *string `json:"language"`
Codec string `json:"codec,omitempty"`
TimeBase string `json:"timeBase,omitempty"`
Channels int `json:"channels,omitempty"`
ChannelLayout string `json:"channelLayout,omitempty"`
Chapters []ChapterABS `json:"chapters,omitempty"`
EmbeddedCoverArt any `json:"embeddedCoverArt"`
MetaTags map[string]string `json:"metaTags,omitempty"`
MimeType string `json:"mimeType"`
Title string `json:"title,omitempty"`
StartOffset float64 `json:"startOffset"`
ContentURL string `json:"contentUrl"`
}
// Metadata is the book-level metadata block. Authors / Narrators / Series
// match the ABS spec: arrays of references (or strings for Narrators).
type Metadata struct {
Title string `json:"title"`
Authors []AuthorObj `json:"authors"`
Narrators []string `json:"narrators"`
Series []SeriesObj `json:"series"`
Description string `json:"description,omitempty"`
PublishedYear string `json:"publishedYear,omitempty"`
ISBN string `json:"isbn,omitempty"`
Publisher string `json:"publisher,omitempty"`
Genres []string `json:"genres,omitempty"`
}
// LibraryItemMedia carries the bulk of the audiobook metadata.
//
// ABS distinguishes between audioFiles (file-level metadata) and tracks
// (the playback ordering the player iterates). For most audiobooks they're
// the same slice; we emit both because the item-detail page reads
// media.tracks.length to decide whether to render the play button, while
// card/list views read media.numTracks.
type LibraryItemMedia struct {
Metadata Metadata `json:"metadata"`
Duration float64 `json:"duration"`
CoverPath string `json:"coverPath"`
AudioFiles []AudioTrack `json:"audioFiles"`
Tracks []AudioTrack `json:"tracks"`
Chapters []ChapterABS `json:"chapters"`
NumTracks int `json:"numTracks"`
}
// CollapsedSeriesV1 is the per-item annotation real ABS attaches when
// collapseseries=1. The shape is "name + count + per-book books[]"; we emit
// a stable subset since clients differ on which fields they read.
type CollapsedSeriesV1 struct {
ID string `json:"id"`
Name string `json:"name"`
NameIgnorePrefix string `json:"nameIgnorePrefix,omitempty"`
NumBooks int `json:"numBooks"`
LibraryItemIDs []string `json:"libraryItemIds"`
}
// LibraryItem is the ABS-shaped audiobook summary. AddedAt / UpdatedAt are
// Unix milliseconds; some shelves on the home screen sort by these and
// clients also expect them as ints (not strings).
//
// CollapsedSeries is non-nil only on items returned with collapseseries=1.
// It folds every book in a series into a single representative entry. ABS
// clients pattern-match on the presence of this field to switch from "list
// of books" to "list of series" UI.
type LibraryItem struct {
ID string `json:"id"`
LibraryID string `json:"libraryId"`
FolderID string `json:"folderId"`
MediaType string `json:"mediaType"`
// IsMissing / IsInvalid are gating fields the ABS mobile client checks
// before rendering the play affordance. We always emit them (no omitempty)
// so the client never sees them as undefined; the catalog we serve is by
// definition present and valid.
// Ref: /opt/audiobookshelf-app/pages/item/_id/index.vue:445
IsMissing bool `json:"isMissing"`
IsInvalid bool `json:"isInvalid"`
Media LibraryItemMedia `json:"media"`
NumTracks int `json:"numTracks,omitempty"`
AddedAt int64 `json:"addedAt"`
UpdatedAt int64 `json:"updatedAt"`
CollapsedSeries *CollapsedSeriesV1 `json:"collapsedSeries,omitempty"`
}