feat(audiobooks): Batch A — foundation, m4b ingest, playback sync, web player

Phase 1 (foundation): 'audiobooks' library type seeded (schema + DB);
audio_metadata JSONB on media_items (the kobo_metadata precedent —
only audio paths read it); reading_progress.audio_offset_seconds for
the honest {chapter, offset} locator; extensions/mimes registered for
ALL breadth formats (m4b/m4a/mp3/flac — deliberate per the DOC/LIT
doctrine, m4b scans richly first); classifyFormatGroup 'audiobook'
case (gotcha 39 recomputes on rescan); Go mime table extended at boot
so media elements accept the streams; ffmpeg/ffprobe in the image
(8.1.2 — also the xHE-AAC identification tripwire).

Phase 2 (ingest): sidecar-first (Libation/ABS metadata.json —
narrators/ASIN/curated chapters), ffprobe fallback/filler (duration,
bitrate, codec/profile, embedded chapters). Fixed three real
parser brittlenesses the Libation sidecar exposed: series accepts
string OR array-of-objects, publishedYear accepts number OR string,
ffprobe chapter timings accept number OR string (flexFloat). Folder-
structure fallback now runs on the UPDATE path too (rescans were
clearing sidecar-absent series/author); audio facts re-derive on
every rescan (gotcha 39).

Phase 3 (playback + sync): GET /api/media-items/:id/chapters (the
format-abstraction list); streaming rides the existing cookie-authed
Range /uploads path; PUT progress carries audio_offset_seconds +
elapsed_seconds (honest wall-clock playing time) — the reading-
history capture prefers player-reported elapsed over the inter-push
delta, same 2h ceiling; reading_mode='audio' marks the row.

Phase 4 (web player): dedicated audio reader page (foliate not
involved) — cover, chapter title, whole-book scrubber, skip ± (chips
1/5/10/15/30s, default 10s), speed (0.75×–2×), chapter list with
jump-to, Media Session lockscreen/notification transport, periodic
playing-only progress pushes, position restore on open.

Verified live: ingest of the Libation sample (Gardens of the Moon —
title/author/series/cover/date from sidecar+folders, 34 chapters,
26h duration, aac/LC); chapters endpoint; the reader route serving
the player; playback with position restore (0:57 round-trip);
progress rows in audio mode; wall-clock session rows in
reading_history.
This commit is contained in:
John O'Keefe committed 2026-10-15 16:14:34 -04:00
1 parent a093529b80
commit 902cbe5915
20 files changed
+1425 -212

No files matched your search

+14 -3
View File
@@ -24,15 +24,20 @@ func NewLibraryService(db *database.Queries) *LibraryService {
// Library type definitions and file extensions
const (
LibraryTypeEbooks = "ebooks"
LibraryTypeComics = "comics"
LibraryTypeManga = "manga"
LibraryTypeEbooks = "ebooks"
LibraryTypeComics = "comics"
LibraryTypeManga = "manga"
LibraryTypeAudiobooks = "audiobooks"
)
var AllowedExtensions = map[string][]string{
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
// All four decided breadth formats registered from day one (the
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
// scans richly in Batch A — the chapter abstraction carries the rest.
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac"},
}
var MimeTypes = map[string]string{
@@ -62,6 +67,12 @@ var MimeTypes = map[string]string{
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".fb2": "application/x-fictionbook+xml",
".pdb": "application/vnd.palm",
// Audiobooks — audio/mp4 for the MP4-family containers is what
// every platform decoder expects from an <audio>/ExoPlayer source.
".m4b": "audio/mp4",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".flac": "audio/flac",
}
// GetLibraryTypes retrieves all available library types
+103 -26
View File
@@ -89,6 +89,10 @@ type MediaMetadata struct {
AlternateInfo string // JSONB string of alternate series info
ScanInformation string // Scan information
Summary string // Summary from ComicInfo.xml
// Audiobook fields (media_items.audio_metadata JSONB — only the
// audio paths read it; see media_scanner_audio.go)
AudioMeta *AudioMetadataPayload
}
type HashInfo struct {
@@ -774,6 +778,16 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
metadata = &MediaMetadata{}
}
// Audiobooks: sidecar narrators/chapters + ffprobe facts into the
// audio_metadata JSONB (sidecar-first, ffprobe-fills; the payload's
// shape is the chapter abstraction every format writes).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
}
// Extract hash information during metadata extraction
hashInfo, formatInfo, err := s.extractHashInfo(path)
if err != nil {
@@ -952,6 +966,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
})
if err != nil {
return false, fmt.Errorf("failed to create media item: %v", err)
@@ -1055,9 +1070,10 @@ func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMe
}
// extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style
// metadata.json sidecar next to the media file. Only fields with a matching
// media_items column are mapped; narrators, subtitle, explicit, abridged and
// chapters are deliberately skipped. Returns nil when no sidecar exists.
// metadata.json sidecar next to the media file. Common fields map to
// media_items columns; the audio-only fields (narrators, curated chapters)
// travel through the audio payload separately (readSidecarAudio). Returns
// nil when no sidecar exists.
func extractAudiobookshelfSidecar(path string) *MediaMetadata {
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
@@ -1070,29 +1086,49 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
return nil
}
// Series is tolerant: ABS/Calibre sidecars write an ARRAY of
// {series, sequence} objects; Libation writes a plain STRING (or
// null). An array-typed field made Libation's whole sidecar fail to
// parse and silently fall to folder-structure metadata (found by
// the audiobook ingest).
var sidecar struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Authors []string `json:"authors"`
Series []struct {
Series string `json:"series"`
Sequence string `json:"sequence"`
} `json:"series"`
Genres []string `json:"genres"`
Tags []string `json:"tags"`
PublishedYear *int `json:"publishedYear"`
PublishedDate *string `json:"publishedDate"`
Publisher *string `json:"publisher"`
Description *string `json:"description"`
ISBN *string `json:"isbn"`
ASIN *string `json:"asin"`
Language *string `json:"language"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Authors []string `json:"authors"`
Series json.RawMessage `json:"series"`
Genres []string `json:"genres"`
Tags []string `json:"tags"`
PublishedYear json.RawMessage `json:"publishedYear"`
PublishedDate *string `json:"publishedDate"`
Publisher *string `json:"publisher"`
Description *string `json:"description"`
ISBN *string `json:"isbn"`
ASIN *string `json:"asin"`
Language *string `json:"language"`
}
if err := json.Unmarshal(data, &sidecar); err != nil {
fmt.Printf("Warning: failed to parse metadata.json sidecar for %s: %v\n", path, err)
return nil
}
var seriesName, seriesSequence string
var seriesArr []struct {
Series string `json:"series"`
Sequence string `json:"sequence"`
}
if len(sidecar.Series) > 0 {
trimmed := strings.TrimSpace(string(sidecar.Series))
if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") {
if err := json.Unmarshal(sidecar.Series, &seriesArr); err == nil && len(seriesArr) > 0 {
seriesName = seriesArr[0].Series
seriesSequence = seriesArr[0].Sequence
}
} else if trimmed != "null" && trimmed != "" {
// Plain string form (Libation)
seriesName = strings.Trim(trimmed, "\"")
}
}
metadata := &MediaMetadata{
Title: strings.TrimSpace(sidecar.Title),
}
@@ -1104,9 +1140,9 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
if len(sidecar.Authors) > 0 {
metadata.Author = strings.TrimSpace(sidecar.Authors[0])
}
if len(sidecar.Series) > 0 {
metadata.Series = strings.TrimSpace(sidecar.Series[0].Series)
if index, err := strconv.ParseFloat(strings.TrimSpace(sidecar.Series[0].Sequence), 32); err == nil {
if seriesName != "" {
metadata.Series = strings.TrimSpace(seriesName)
if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 32); err == nil {
metadata.SeriesNumber = int32(index)
}
}
@@ -1118,8 +1154,12 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
metadata.PublishDate = date
}
}
if metadata.PublishDate.IsZero() && sidecar.PublishedYear != nil && *sidecar.PublishedYear > 0 {
metadata.PublishDate = time.Date(*sidecar.PublishedYear, 1, 1, 0, 0, 0, 0, time.UTC)
if metadata.PublishDate.IsZero() {
// publishedYear tolerates number AND string forms ("2009" from
// Libation lives as a string).
if pubYear := parseJSONInt(sidecar.PublishedYear); pubYear > 0 {
metadata.PublishDate = time.Date(pubYear, 1, 1, 0, 0, 0, 0, time.UTC)
}
}
if sidecar.Publisher != nil {
metadata.Publisher = strings.TrimSpace(*sidecar.Publisher)
@@ -1140,8 +1180,6 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
return metadata
}
// extractAudiobookshelfSidecar-TMP-END
// mergeMetadata intelligently merges metadata from multiple sources
// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename
// For comics: metadata.opf → ComicInfo.xml → folder structure → filename
@@ -3100,6 +3138,39 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
metadata = &MediaMetadata{}
}
// Rescans must RE-DERIVE audio facts, never preserve stale ones
// (gotcha 39's principle applied to audio_metadata).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
}
// Folder-structure fallback, mirroring the CREATE path
// (processMediaFile): the update path skipped it, so a force rescan
// could clear series/author that a sidecar didn't carry (Libation
// writes "series": null while the folder layout knows the series).
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
if metadata.Title == "" && folderMetadata.Title != "" {
metadata.Title = folderMetadata.Title
}
if metadata.Author == "" && folderMetadata.Author != "" {
metadata.Author = folderMetadata.Author
}
if metadata.Series == "" && folderMetadata.Series != "" {
metadata.Series = folderMetadata.Series
}
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
metadata.SeriesNumber = folderMetadata.SeriesNumber
}
}
break
}
}
// Normalize metadata fields
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
metadata.Tags = utils.NormalizeTags(metadata.Tags)
@@ -3144,6 +3215,7 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
}
// Keep user-customized fields, and keep the override set itself intact.
@@ -3905,6 +3977,11 @@ func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string
return "fixed_layout", false, true
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive", false, true
case ".m4b", ".m4a", ".mp3", ".flac":
// Audio is neither reflowable text nor fixed-layout pages; the
// "audiobook" group routes readers to the audio player instead
// of any document engine.
return "audiobook", false, false
default:
return "unknown", false, false
}
+278
View File
@@ -0,0 +1,278 @@
package services
// Audiobook ingestion (handoff item 3, Batch A). The m4b path is
// SIDECAR-FIRST: Libation (and Audiobookshelf-compatible tools) write a
// metadata.json next to the file carrying narrators/series/ASIN/chapters;
// ffprobe is the fallback (and the filler) for duration, codec profile,
// bitrate and embedded chapters. ffprobe is an OPTIONAL dependency — when
// absent the book still ingests (filename/folder metadata, no chapters
// until a rescan runs with ffprobe present).
//
// The chapters array written into media_items.audio_metadata IS the
// format abstraction: embedded m4b chapters and (later) folder-as-book
// files both produce {index, title, start, end} entries, so the locator
// {chapter, offset_seconds} and the /chapters endpoint never learn which
// file model produced them.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// AudioChapter is the uniform chapter entry for every audiobook file model.
type AudioChapter struct {
Index int `json:"index"`
Title string `json:"title"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
// AudioMetadataPayload is the JSONB stored in media_items.audio_metadata.
// Only the audio paths read it — ebook paths never do (the kobo_metadata
// precedent).
type AudioMetadataPayload struct {
Narrators []string `json:"narrators,omitempty"`
DurationSeconds float64 `json:"duration_seconds,omitempty"`
CodecProfile string `json:"codec_profile,omitempty"`
BitRate int64 `json:"bit_rate,omitempty"`
ASIN string `json:"asin,omitempty"`
Chapters []AudioChapter `json:"chapters,omitempty"`
}
func isAudioFile(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".m4b", ".m4a", ".mp3", ".flac":
return true
}
return false
}
// ffprobeAvailable is resolved once per scan process; a missing binary is
// a degradation, not an error.
var ffprobeAvailable = func() bool {
_, err := exec.LookPath("ffprobe")
return err == nil
}()
type ffprobeReport struct {
Format struct {
Duration flexFloat `json:"duration"`
BitRate flexFloat `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
SampleRate string `json:"sample_rate"`
} `json:"streams"`
Chapters []struct {
ID int `json:"id"`
StartTime flexFloat `json:"start_time"`
EndTime flexFloat `json:"end_time"`
Tags struct {
Title string `json:"title"`
} `json:"tags"`
} `json:"chapters"`
}
// flexFloat accepts JSON numbers OR number-strings: ffprobe's output
// switched to strings for chapter timings in the ffmpeg 8 build
// (Libation ingest exposed both shapes across formats).
type flexFloat float64
func (f *flexFloat) UnmarshalJSON(data []byte) error {
s := strings.TrimSpace(string(data))
s = strings.Trim(s, "\"")
if s == "" || s == "null" || s == "N/A" {
*f = 0
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
*f = flexFloat(v)
return nil
}
// runFFProbe shells out to ffprobe for the container facts the sidecar
// doesn't carry (duration/bitrate/codec). Returns nil when ffprobe is
// absent or the probe fails — callers degrade gracefully. Failures log:
// a silent nil made a real exec failure indistinguishable from a missing
// binary (found by the Libation ingest).
func runFFProbe(path string) *ffprobeReport {
if !ffprobeAvailable {
fmt.Printf("ffprobe unavailable (not on PATH); audio facts degrade to sidecar-only for %s\n", path)
return nil
}
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams", "-show_chapters",
path,
)
out, err := cmd.Output()
if err != nil {
fmt.Printf("ffprobe failed for %s: %v\n", path, err)
return nil
}
if len(out) == 0 {
fmt.Printf("ffprobe produced no output for %s\n", path)
return nil
}
var report ffprobeReport
if err := json.Unmarshal(out, &report); err != nil {
fmt.Printf("ffprobe output unparseable for %s: %v\n", path, err)
return nil
}
return &report
}
// enrichAudioMetadata merges ffprobe facts into the (possibly sidecar-
// seeded) audio payload, creating it when no sidecar existed. Sidecar
// narrators/chapters win (curated); ffprobe fills duration, codec,
// bitrate, and chapters only when the sidecar carried none.
func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata) {
if metadata == nil {
return
}
payload := metadata.AudioMeta
if payload == nil {
payload = &AudioMetadataPayload{}
metadata.AudioMeta = payload
}
report := runFFProbe(path)
if report == nil {
fmt.Printf("Audio metadata for %s: sidecar only (ffprobe absent or failed)\n", path)
} else {
if payload.DurationSeconds == 0 && report.Format.Duration > 0 {
payload.DurationSeconds = float64(report.Format.Duration)
}
if payload.BitRate == 0 && report.Format.BitRate > 0 {
payload.BitRate = int64(report.Format.BitRate)
}
if payload.CodecProfile == "" && len(report.Streams) > 0 {
st := report.Streams[0]
profile := st.CodecName
if st.Profile != "" && st.Profile != "unknown" {
profile += " / " + st.Profile
}
payload.CodecProfile = profile
}
if len(payload.Chapters) == 0 {
for _, ch := range report.Chapters {
payload.Chapters = append(payload.Chapters, AudioChapter{
Index: ch.ID,
Title: ch.Tags.Title,
Start: float64(ch.StartTime),
End: float64(ch.EndTime),
})
}
}
}
// The payload's ASIN mirrors the item column for the resolver; keep
// them in sync from whichever source set it.
if payload.ASIN == "" {
payload.ASIN = metadata.ASIN
}
}
// parseSidecarAudioFields extracts the audio-only fields from a
// Libation/Audiobookshelf metadata.json into the payload (narrators and
// the curated chapter list).
func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
var sidecar struct {
Narrators []string `json:"narrators"`
ASIN *string `json:"asin"`
Chapters []struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Title string `json:"title"`
ID int `json:"id"`
} `json:"chapters"`
}
if err := json.Unmarshal(data, &sidecar); err != nil {
return nil
}
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil {
return nil
}
payload := &AudioMetadataPayload{
Narrators: sidecar.Narrators,
}
if sidecar.ASIN != nil {
payload.ASIN = strings.TrimSpace(*sidecar.ASIN)
}
for i, ch := range sidecar.Chapters {
idx := ch.ID
if idx == 0 {
idx = i
}
payload.Chapters = append(payload.Chapters, AudioChapter{
Index: idx,
Title: strings.TrimSpace(ch.Title),
Start: ch.Start,
End: ch.End,
})
}
return payload
}
// readSidecarAudio is the sidecar hook used by the ABS metadata.json
// path: same file the common-field parser already read, audio fields only.
func readSidecarAudio(path string) *AudioMetadataPayload {
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
data, err := os.ReadFile(jsonPath)
if err != nil {
return nil
}
return parseSidecarAudioFields(path, data)
}
// marshalAudioMetadata serializes the payload for the DB column; nil
// payload → nil bytes (NULL column).
func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
if payload == nil {
return nil
}
out, err := json.Marshal(payload)
if err != nil {
return nil
}
return out
}
// audioDurationLabel renders the duration for logs/detail surfaces.
func audioDurationLabel(seconds float64) string {
if seconds <= 0 {
return "—"
}
d := time.Duration(seconds) * time.Second
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm", m)
}
// parseJSONInt reads a RawMessage that may hold a JSON number or a
// number-string; 0 for anything else. Used for sidecar fields whose
// type differs across producing tools (Libation vs Calibre/ABS).
func parseJSONInt(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
trimmed := strings.TrimSpace(string(raw))
if v, err := strconv.Atoi(strings.Trim(trimmed, "\"")); err == nil {
return v
}
return 0
}
+30 -30
View File
@@ -83,36 +83,36 @@ func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
libUUID := uuid.New()
row := database.GetContinueSeriesItemsRow{
ID: pgtype.UUID{Bytes: itemUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Full Book",
Author: pgtype.Text{String: "Author", Valid: true},
Isbn: pgtype.Text{String: "ISBN", Valid: true},
Description: pgtype.Text{String: "Desc", Valid: true},
FilePath: "/book.epub",
FileSize: pgtype.Int8{Int64: 2048, Valid: true},
MimeType: pgtype.Text{String: "epub", Valid: true},
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
Series: pgtype.Text{String: "Series", Valid: true},
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
Tags: []string{"tag1"},
Asin: pgtype.Text{String: "ASIN", Valid: true},
Publisher: pgtype.Text{String: "Pub", Valid: true},
Language: pgtype.Text{String: "en", Valid: true},
Edition: pgtype.Text{String: "1st", Valid: true},
Genre: pgtype.Text{String: "Fiction", Valid: true},
FormatGroup: "epub",
FormatMimetype: pgtype.Text{String: "application/epub+zip", Valid: true},
IsReflowable: pgtype.Bool{Bool: true, Valid: true},
HasFixedLayout: pgtype.Bool{Bool: false, Valid: true},
TotalCharacters: pgtype.Int8{Int64: 500000, Valid: true},
ChapterCount: pgtype.Int4{Int32: 20, Valid: true},
MangaType: pgtype.Text{String: "manga", Valid: true},
ReadingDirection: pgtype.Text{String: "rtl", Valid: true},
SeriesCount: pgtype.Int4{Int32: 10, Valid: true},
Volume: pgtype.Int4{Int32: 1, Valid: true},
LibraryTypeName: pgtype.Text{String: "ebooks", Valid: true},
FileSha256: pgtype.Text{String: "abc123", Valid: true},
ID: pgtype.UUID{Bytes: itemUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Full Book",
Author: pgtype.Text{String: "Author", Valid: true},
Isbn: pgtype.Text{String: "ISBN", Valid: true},
Description: pgtype.Text{String: "Desc", Valid: true},
FilePath: "/book.epub",
FileSize: pgtype.Int8{Int64: 2048, Valid: true},
MimeType: pgtype.Text{String: "epub", Valid: true},
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
Series: pgtype.Text{String: "Series", Valid: true},
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
Tags: []string{"tag1"},
Asin: pgtype.Text{String: "ASIN", Valid: true},
Publisher: pgtype.Text{String: "Pub", Valid: true},
Language: pgtype.Text{String: "en", Valid: true},
Edition: pgtype.Text{String: "1st", Valid: true},
Genre: pgtype.Text{String: "Fiction", Valid: true},
FormatGroup: "epub",
FormatMimetype: pgtype.Text{String: "application/epub+zip", Valid: true},
IsReflowable: pgtype.Bool{Bool: true, Valid: true},
HasFixedLayout: pgtype.Bool{Bool: false, Valid: true},
TotalCharacters: pgtype.Int8{Int64: 500000, Valid: true},
ChapterCount: pgtype.Int4{Int32: 20, Valid: true},
MangaType: pgtype.Text{String: "manga", Valid: true},
ReadingDirection: pgtype.Text{String: "rtl", Valid: true},
SeriesCount: pgtype.Int4{Int32: 10, Valid: true},
Volume: pgtype.Int4{Int32: 1, Valid: true},
LibraryTypeName: pgtype.Text{String: "ebooks", Valid: true},
FileSha256: pgtype.Text{String: "abc123", Valid: true},
}
result := continueSeriesRowToMediaItems(row)
+3 -3
View File
@@ -143,9 +143,9 @@ type UserSummaryStats struct {
MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing
MostActiveDay string `json:"most_active_day"` // date with the most minutes
MostActiveMinutes int `json:"most_active_day_minutes"`
TopBooks []TopBookStats `json:"top_books"`
LibraryBreakdown []LibraryStats `json:"library_breakdown"`
DeviceBreakdown []DeviceStats `json:"device_breakdown"`
TopBooks []TopBookStats `json:"top_books"`
LibraryBreakdown []LibraryStats `json:"library_breakdown"`
DeviceBreakdown []DeviceStats `json:"device_breakdown"`
}
type WeekSummaryStats struct {
+2 -3
View File
@@ -73,8 +73,8 @@ func (j *Job) UpdateProgress(progress float64, filesScanned, newItems, errors in
}
type JobResult struct {
JobID string `json:"job_id"`
Status JobStatus `json:"status"`
JobID string `json:"job_id"`
Status JobStatus `json:"status"`
// LibraryID carries the scan job's library (from Params) so clients
// can label progress rows after a page refresh, when the original
// start-request context is gone.
@@ -1026,4 +1026,3 @@ func (w *Worker) Shutdown() {
close(w.jobQueue)
w.wg.Wait()
}