diff --git a/cmd/server/main.go b/cmd/server/main.go index b84935e..e2102c0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -103,6 +103,11 @@ func main() { syncSvc := library.NewSyncService(db, audibleClient, cfg.Paths.Audiobooks) anClient := audnexus.NewClientWithRegion(region) log.Info().Str("region", region).Msg("audnexus client initialized with region") + // Wire optional dependencies for the Metadata Repair sync phase. + // Both are tolerated as nil at the SyncService boundary so tests + // and lightweight uses don't have to construct them. + syncSvc.SetFFmpeg(ffmpeg) + syncSvc.SetAudnexusClient(anClient) org := organizer.NewPlexOrganizer(db, ffmpeg, cfg.Paths.Audiobooks, cfg.Output.EmbedCover, cfg.Output.ChapterFile, cfg.Output.PlexMatchFile) // First-boot synthesis: if library_destinations is empty AND legacy @@ -170,7 +175,7 @@ func main() { log.Info().Bool("enabled", cfg.Sync.Enabled).Str("schedule", cfg.Sync.Schedule).Msg("scheduler started") // Start web server - webServer := web.NewServer(db, syncSvc, dlMgr, anClient, org, audibleClient, credPath, cfg.Server.Port, cfg.Paths.Audiobooks, cfg.Paths.Downloads, cfg.Paths.Config) + webServer := web.NewServer(db, syncSvc, dlMgr, sched, anClient, org, audibleClient, ffmpeg, credPath, cfg.Server.Port, cfg.Paths.Audiobooks, cfg.Paths.Downloads, cfg.Paths.Config) go func() { if err := webServer.Start(); err != nil { log.Fatal().Err(err).Msg("web server failed") diff --git a/internal/audio/probe_tags.go b/internal/audio/probe_tags.go new file mode 100644 index 0000000..720ad7f --- /dev/null +++ b/internal/audio/probe_tags.go @@ -0,0 +1,146 @@ +package audio + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" +) + +// ProbeTags returns the format-level metadata tags from an audio file. +// Reads via ffprobe; the freeform iTunes atoms emitted by the +// AudiobookRich profile (asin, series, series-part) come back as plain +// string keys. +func (f *FFmpeg) ProbeTags(ctx context.Context, inputPath string) (map[string]string, error) { + cmd := exec.CommandContext(ctx, f.probePath, + "-v", "quiet", + "-show_entries", "format_tags", + "-of", "json", + inputPath, + ) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ffprobe tags: %w", err) + } + var resp struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return nil, fmt.Errorf("decode ffprobe tags: %w", err) + } + if resp.Format.Tags == nil { + return map[string]string{}, nil + } + return resp.Format.Tags, nil +} + +// FileProbe is the rich-probe snapshot used by the Book Details "File +// info" block: format, codec, container/stream technicals, chapter +// count, embedded artwork flag, and the raw tag map. Numeric fields +// stay as their ffprobe-reported strings so the UI can render "—" +// for absent values without a separate "set" flag per field. +type FileProbe struct { + FormatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + DurationSec float64 `json:"duration_sec"` + BitRate string `json:"bit_rate"` + Size int64 `json:"size"` + NumStreams int `json:"num_streams"` + AudioCodec string `json:"audio_codec"` + AudioCodecLong string `json:"audio_codec_long"` + AudioBitRate string `json:"audio_bit_rate"` + SampleRate string `json:"sample_rate"` + Channels int `json:"channels"` + ChannelLayout string `json:"channel_layout"` + HasArtwork bool `json:"has_artwork"` + ChapterCount int `json:"chapter_count"` + Tags map[string]string `json:"tags"` +} + +// ProbeFile returns a FileProbe with everything the Book Details "File +// info" block surfaces: container/codec technicals, chapter count, +// embedded artwork flag, plus the raw format-level tags. One ffprobe +// invocation that asks for format+streams+chapters in JSON. +func (f *FFmpeg) ProbeFile(ctx context.Context, inputPath string) (*FileProbe, error) { + cmd := exec.CommandContext(ctx, f.probePath, + "-v", "quiet", + "-show_format", + "-show_streams", + "-show_chapters", + "-of", "json", + inputPath, + ) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ffprobe file: %w", err) + } + + var resp struct { + Format struct { + FormatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + Duration string `json:"duration"` + Size string `json:"size"` + BitRate string `json:"bit_rate"` + NumStreams int `json:"nb_streams"` + Tags map[string]string `json:"tags"` + } `json:"format"` + Streams []struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + CodecLongName string `json:"codec_long_name"` + BitRate string `json:"bit_rate"` + SampleRate string `json:"sample_rate"` + Channels int `json:"channels"` + ChannelLayout string `json:"channel_layout"` + Disposition map[string]int `json:"disposition"` + } `json:"streams"` + Chapters []struct { + ID int `json:"id"` + } `json:"chapters"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return nil, fmt.Errorf("decode ffprobe file: %w", err) + } + + probe := &FileProbe{ + FormatName: resp.Format.FormatName, + FormatLongName: resp.Format.FormatLongName, + BitRate: resp.Format.BitRate, + NumStreams: resp.Format.NumStreams, + ChapterCount: len(resp.Chapters), + Tags: resp.Format.Tags, + } + if probe.Tags == nil { + probe.Tags = map[string]string{} + } + if resp.Format.Duration != "" { + fmt.Sscanf(resp.Format.Duration, "%f", &probe.DurationSec) + } + if resp.Format.Size != "" { + fmt.Sscanf(resp.Format.Size, "%d", &probe.Size) + } + for _, st := range resp.Streams { + switch st.CodecType { + case "audio": + if probe.AudioCodec == "" { + probe.AudioCodec = st.CodecName + probe.AudioCodecLong = st.CodecLongName + probe.AudioBitRate = st.BitRate + probe.SampleRate = st.SampleRate + probe.Channels = st.Channels + probe.ChannelLayout = st.ChannelLayout + } + case "video": + // A video stream on an audiobook means embedded cover + // art. The attached_pic disposition makes that explicit. + if st.Disposition["attached_pic"] == 1 || !probe.HasArtwork { + probe.HasArtwork = true + } + } + } + return probe, nil +} + diff --git a/internal/audio/tag_profile.go b/internal/audio/tag_profile.go index 0fb6bc0..b8b5a29 100644 --- a/internal/audio/tag_profile.go +++ b/internal/audio/tag_profile.go @@ -6,8 +6,9 @@ import ( ) // TagProfile selects which set of metadata atoms get embedded into the m4b -// when EmbedMetadata runs. Existing libraries upgrade transparently to the -// Basic profile (today's behavior). Audiobook-rich is opt-in. +// when EmbedMetadata runs. New installs default to Audiobook-rich so series +// grouping works out of the box; Basic stays available for anyone who wants +// the historical tag set. type TagProfile string const ( @@ -32,6 +33,11 @@ const ( TagProfileAudiobookRich TagProfile = "audiobook-rich" ) +// DefaultTagProfile is the profile used when nothing is stored yet (new +// installs) or when a stored value can't be recognized. Audiobook-rich is +// additive and safe — it only adds series/asin atoms on top of Basic. +const DefaultTagProfile = TagProfileAudiobookRich + // SettingKeyTagProfile is the DB setting key that stores the active tag // profile. Stored alongside other Audplexus settings. const SettingKeyTagProfile = "tag_profile" @@ -45,19 +51,19 @@ type settingsReader interface { } // ResolveTagProfile reads the active tag profile from the DB setting, -// falling back to Basic for any unset or unknown value. Existing installs -// keep today's behavior automatically. +// falling back to DefaultTagProfile for any unset or unknown value. func ResolveTagProfile(ctx context.Context, db settingsReader) TagProfile { if db == nil { - return TagProfileBasic + return DefaultTagProfile } raw, _ := db.GetSetting(ctx, SettingKeyTagProfile) return ParseTagProfile(raw) } // ParseTagProfile maps a setting string to a TagProfile, defaulting to -// Basic for empty or unrecognized values. Lenient — used when reading -// existing settings where unknown legacy values should fall back gracefully. +// DefaultTagProfile for empty or unrecognized values. Lenient — used when +// reading existing settings where unknown legacy values should fall back +// gracefully. func ParseTagProfile(s string) TagProfile { p, _ := parseTagProfile(s) return p @@ -73,12 +79,14 @@ func ParseTagProfileStrict(s string) (TagProfile, bool) { func parseTagProfile(s string) (TagProfile, bool) { switch strings.ToLower(strings.TrimSpace(s)) { - case "", string(TagProfileBasic): + case "": + return DefaultTagProfile, true + case string(TagProfileBasic): return TagProfileBasic, true case string(TagProfileAudiobookRich), "audiobook_rich", "rich": return TagProfileAudiobookRich, true default: - return TagProfileBasic, false + return DefaultTagProfile, false } } diff --git a/internal/audio/tag_profile_test.go b/internal/audio/tag_profile_test.go index 6b59fd7..9e238ed 100644 --- a/internal/audio/tag_profile_test.go +++ b/internal/audio/tag_profile_test.go @@ -5,18 +5,18 @@ import ( "testing" ) -func TestParseTagProfileDefaultsToBasic(t *testing.T) { +func TestParseTagProfileDefaults(t *testing.T) { cases := map[string]TagProfile{ - "": TagProfileBasic, - "basic": TagProfileBasic, - "BASIC": TagProfileBasic, - "audiobook-rich": TagProfileAudiobookRich, - "AUDIOBOOK-RICH": TagProfileAudiobookRich, - " audiobook-rich ": TagProfileAudiobookRich, - "audiobook_rich": TagProfileAudiobookRich, - "rich": TagProfileAudiobookRich, - "unknown": TagProfileBasic, // anything unrecognized falls back - "plex": TagProfileBasic, // doesn't accidentally match a media-server type + "": DefaultTagProfile, + "basic": TagProfileBasic, + "BASIC": TagProfileBasic, + "audiobook-rich": TagProfileAudiobookRich, + "AUDIOBOOK-RICH": TagProfileAudiobookRich, + " audiobook-rich ": TagProfileAudiobookRich, + "audiobook_rich": TagProfileAudiobookRich, + "rich": TagProfileAudiobookRich, + "unknown": DefaultTagProfile, // anything unrecognized falls back to default + "plex": DefaultTagProfile, // doesn't accidentally match a media-server type } for input, want := range cases { t.Run(input, func(t *testing.T) { @@ -28,9 +28,9 @@ func TestParseTagProfileDefaultsToBasic(t *testing.T) { } func TestResolveTagProfileNilDB(t *testing.T) { - // A nil DB must not panic; falls back to Basic. - if got := ResolveTagProfile(context.Background(), nil); got != TagProfileBasic { - t.Errorf("ResolveTagProfile(nil db) = %q, want %q", got, TagProfileBasic) + // A nil DB must not panic; falls back to the default profile. + if got := ResolveTagProfile(context.Background(), nil); got != DefaultTagProfile { + t.Errorf("ResolveTagProfile(nil db) = %q, want %q", got, DefaultTagProfile) } } @@ -52,10 +52,10 @@ func TestResolveTagProfileFromDB(t *testing.T) { stored string want TagProfile }{ - {"", TagProfileBasic}, // unset = default Basic - {"basic", TagProfileBasic}, // explicit basic - {"audiobook-rich", TagProfileAudiobookRich}, // opt-in - {"junk", TagProfileBasic}, // unknown stored value falls back + {"", DefaultTagProfile}, // unset = default profile + {"basic", TagProfileBasic}, // explicit basic + {"audiobook-rich", TagProfileAudiobookRich}, // explicit rich + {"junk", DefaultTagProfile}, // unknown stored value falls back to default } { t.Run(tc.stored, func(t *testing.T) { db := &fakeSettingsReader{value: tc.stored} @@ -72,7 +72,7 @@ func TestAllTagProfilesIncludesBothInOrder(t *testing.T) { t.Fatalf("AllTagProfiles() = %d entries, want 2", len(all)) } if all[0] != TagProfileBasic { - t.Errorf("AllTagProfiles()[0] = %q, want Basic first (default)", all[0]) + t.Errorf("AllTagProfiles()[0] = %q, want Basic first", all[0]) } if all[1] != TagProfileAudiobookRich { t.Errorf("AllTagProfiles()[1] = %q, want AudiobookRich second", all[1]) diff --git a/internal/database/books_presence_filter_test.go b/internal/database/books_presence_filter_test.go new file mode 100644 index 0000000..5338ca2 --- /dev/null +++ b/internal/database/books_presence_filter_test.go @@ -0,0 +1,112 @@ +package database + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +// TestListBooksPresenceFilters covers the presence-filter slices on +// BookFilter (OnDisk + PresentIn/MissingFrom destinations). Verifies +// the WHERE-clause builder constructs subqueries that intersect with +// book_library_destinations correctly. +func TestListBooksPresenceFilters(t *testing.T) { + db := newTestSQLite(t) + ctx := context.Background() + + // Two destinations: Plex + ABS. + plex := &LibraryDestination{ + ID: uuid.NewString(), DisplayName: "Plex", + Type: LibraryDestinationTypePlex, Enabled: true, + URL: "http://plex.lan", PlexToken: "tok", PlexSectionID: "1", + AudiobookPath: "/m", + } + abs := &LibraryDestination{ + ID: uuid.NewString(), DisplayName: "ABS", + Type: LibraryDestinationTypeABS, Enabled: true, + URL: "http://abs.lan", APIKey: "k", LibraryID: "lib", + AudiobookPath: "/m", + } + if err := db.CreateLibraryDestination(ctx, plex); err != nil { + t.Fatalf("create plex: %v", err) + } + if err := db.CreateLibraryDestination(ctx, abs); err != nil { + t.Fatalf("create abs: %v", err) + } + + // Three books: + // A: on disk, synced to plex, synced to abs + // B: on disk, synced to plex only + // C: not on disk, no destination sync rows + books := []*Book{ + {ASIN: "B0001", Title: "A", Author: "x", Status: BookStatusComplete, FilePath: "/m/a.m4b"}, + {ASIN: "B0002", Title: "B", Author: "x", Status: BookStatusComplete, FilePath: "/m/b.m4b"}, + {ASIN: "B0003", Title: "C", Author: "x", Status: BookStatusNew, FilePath: ""}, + } + for _, b := range books { + if err := db.UpsertBook(ctx, b); err != nil { + t.Fatalf("upsert %s: %v", b.ASIN, err) + } + } + bookA, _ := db.GetBookByASIN(ctx, "B0001") + bookB, _ := db.GetBookByASIN(ctx, "B0002") + for _, bd := range []*BookDestination{ + {BookID: bookA.ID, DestinationID: plex.ID, SyncState: BookDestSyncSynced}, + {BookID: bookA.ID, DestinationID: abs.ID, SyncState: BookDestSyncSynced}, + {BookID: bookB.ID, DestinationID: plex.ID, SyncState: BookDestSyncSynced}, + } { + if err := db.UpsertBookDestination(ctx, bd); err != nil { + t.Fatalf("upsert book-dest: %v", err) + } + } + + tests := []struct { + name string + filter BookFilter + wantASIN []string + }{ + {"on disk only", BookFilter{OnDisk: ptrBool(true)}, []string{"B0001", "B0002"}}, + {"not on disk", BookFilter{OnDisk: ptrBool(false)}, []string{"B0003"}}, + {"present in plex", BookFilter{PresentInDestinations: []string{plex.ID}}, []string{"B0001", "B0002"}}, + {"present in abs", BookFilter{PresentInDestinations: []string{abs.ID}}, []string{"B0001"}}, + {"missing from abs", BookFilter{MissingFromDestinations: []string{abs.ID}}, []string{"B0002", "B0003"}}, + {"in plex AND missing from abs", BookFilter{PresentInDestinations: []string{plex.ID}, MissingFromDestinations: []string{abs.ID}}, []string{"B0002"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, _, err := db.ListBooks(ctx, tc.filter) + if err != nil { + t.Fatalf("ListBooks: %v", err) + } + gotASIN := make([]string, 0, len(got)) + for _, b := range got { + gotASIN = append(gotASIN, b.ASIN) + } + if !sameStringSet(gotASIN, tc.wantASIN) { + t.Fatalf("got %v, want %v", gotASIN, tc.wantASIN) + } + }) + } +} + +func ptrBool(b bool) *bool { return &b } + +func sameStringSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + m := map[string]int{} + for _, s := range a { + m[s]++ + } + for _, s := range b { + m[s]-- + } + for _, n := range m { + if n != 0 { + return false + } + } + return true +} diff --git a/internal/database/interface.go b/internal/database/interface.go index 661e7f2..cf6e2fc 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -74,5 +74,21 @@ type BookFilter struct { SortDir string // "asc", "desc" Limit int Offset int + + // Presence filters. Each is independent and stacks with the others; + // an empty/nil value means "no filter on this dimension". + // + // OnDisk filters on whether books.file_path is non-empty. nil means + // either; true keeps only on-disk rows; false keeps only rows whose + // file is missing locally. + // + // PresentInDestinations keeps books that ARE currently synced to + // every listed destination (sync_state in synced|syncing). + // MissingFromDestinations keeps books NOT currently synced to any + // of the listed destinations. The two slices stack — a book is + // included only when it satisfies every per-destination predicate. + OnDisk *bool + PresentInDestinations []string + MissingFromDestinations []string } diff --git a/internal/database/postgres.go b/internal/database/postgres.go index fa5df93..0b15c01 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -605,10 +605,27 @@ func buildBookWherePostgres(filter BookFilter) (string, []interface{}) { paramIdx++ } if filter.Search != "" { - clauses = append(clauses, fmt.Sprintf("(title ILIKE $%d OR author ILIKE $%d)", paramIdx, paramIdx+1)) + clauses = append(clauses, fmt.Sprintf("(title ILIKE $%d OR author ILIKE $%d OR series ILIKE $%d OR asin ILIKE $%d)", paramIdx, paramIdx+1, paramIdx+2, paramIdx+3)) search := "%" + filter.Search + "%" - args = append(args, search, search) - paramIdx += 2 + args = append(args, search, search, search, search) + paramIdx += 4 + } + if filter.OnDisk != nil { + if *filter.OnDisk { + clauses = append(clauses, "file_path != ''") + } else { + clauses = append(clauses, "(file_path IS NULL OR file_path = '')") + } + } + for _, destID := range filter.PresentInDestinations { + clauses = append(clauses, fmt.Sprintf("EXISTS (SELECT 1 FROM book_library_destinations bld WHERE bld.book_id = books.id AND bld.destination_id = $%d AND bld.sync_state IN ('synced','syncing'))", paramIdx)) + args = append(args, destID) + paramIdx++ + } + for _, destID := range filter.MissingFromDestinations { + clauses = append(clauses, fmt.Sprintf("NOT EXISTS (SELECT 1 FROM book_library_destinations bld WHERE bld.book_id = books.id AND bld.destination_id = $%d AND bld.sync_state IN ('synced','syncing'))", paramIdx)) + args = append(args, destID) + paramIdx++ } if len(clauses) == 0 { diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index 889262d..5c8e268 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -646,9 +646,27 @@ func buildBookWhere(filter BookFilter) (string, []interface{}) { args = append(args, *filter.Status) } if filter.Search != "" { - clauses = append(clauses, "(title LIKE ? OR author LIKE ?)") + clauses = append(clauses, "(title LIKE ? OR author LIKE ? OR series LIKE ? OR asin LIKE ?)") search := "%" + filter.Search + "%" - args = append(args, search, search) + args = append(args, search, search, search, search) + } + if filter.OnDisk != nil { + if *filter.OnDisk { + clauses = append(clauses, "file_path != ''") + } else { + clauses = append(clauses, "(file_path IS NULL OR file_path = '')") + } + } + // "Present in destination" — there is a synced/syncing row for + // this (book, destination). The index on (destination_id, sync_state) + // keeps the subquery cheap. + for _, destID := range filter.PresentInDestinations { + clauses = append(clauses, "EXISTS (SELECT 1 FROM book_library_destinations bld WHERE bld.book_id = books.id AND bld.destination_id = ? AND bld.sync_state IN ('synced','syncing'))") + args = append(args, destID) + } + for _, destID := range filter.MissingFromDestinations { + clauses = append(clauses, "NOT EXISTS (SELECT 1 FROM book_library_destinations bld WHERE bld.book_id = books.id AND bld.destination_id = ? AND bld.sync_state IN ('synced','syncing'))") + args = append(args, destID) } if len(clauses) == 0 { diff --git a/internal/library/metadata_repair.go b/internal/library/metadata_repair.go new file mode 100644 index 0000000..ef26622 --- /dev/null +++ b/internal/library/metadata_repair.go @@ -0,0 +1,179 @@ +package library + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mstrhakr/audplexus/internal/audio" + "github.com/mstrhakr/audplexus/internal/audnexus" + "github.com/mstrhakr/audplexus/internal/database" +) + +// RepairBookMetadata scans every complete book and re-embeds metadata +// when the file's ASIN tag is missing or doesn't match the DB record. +// Forces TagProfileAudiobookRich so the asin/series/series-part iTunes +// atoms are written regardless of the user's tag profile setting — +// this phase exists specifically to make books matchable by +// ASIN-aware servers like Audiobookshelf. +// +// When an audnexus client is supplied, each repaired book is enriched +// before re-tagging so genre/copyright/description match the original +// download. With a nil audnexus client the repair falls back to the +// DB record alone — sufficient for the ASIN-match use case. +// +// Returns the number of files re-tagged. +func RepairBookMetadata(ctx context.Context, db database.Database, ff *audio.FFmpeg, an *audnexus.Client, onProgress func(processed, total int)) (int, error) { + if ff == nil { + return 0, fmt.Errorf("ffmpeg not available") + } + completeStatus := database.BookStatusComplete + books, _, err := db.ListBooks(ctx, database.BookFilter{Status: &completeStatus}) + if err != nil { + return 0, fmt.Errorf("list complete books: %w", err) + } + total := len(books) + repaired := 0 + skipped := 0 + failed := 0 + for i := range books { + select { + case <-ctx.Done(): + return repaired, ctx.Err() + default: + } + if onProgress != nil { + onProgress(i, total) + } + book := &books[i] + if book.FilePath == "" { + skipped++ + continue + } + if _, statErr := os.Stat(book.FilePath); statErr != nil { + skipped++ + continue + } + tags, err := ff.ProbeTags(ctx, book.FilePath) + if err != nil { + syncLog.Warn().Err(err).Str("path", book.FilePath).Msg("metadata_repair: probe failed") + failed++ + continue + } + if !metadataNeedsRepair(book, tags) { + continue + } + if err := retagBookFile(ctx, ff, an, book); err != nil { + syncLog.Warn().Err(err).Str("path", book.FilePath).Str("asin", book.ASIN).Msg("metadata_repair: retag failed") + failed++ + continue + } + repaired++ + syncLog.Info().Str("asin", book.ASIN).Str("title", book.Title).Msg("metadata_repair: re-tagged file") + } + if onProgress != nil { + onProgress(total, total) + } + syncLog.Info(). + Int("books_complete", total). + Int("repaired", repaired). + Int("skipped_missing_file", skipped). + Int("failed", failed). + Msg("metadata_repair: pass complete") + return repaired, nil +} + +// metadataNeedsRepair returns true when the file's ASIN tag doesn't +// match the DB record. Triggers a rich re-tag rather than touching +// only the ASIN atom, so series/series-part land at the same time. +func metadataNeedsRepair(book *database.Book, tags map[string]string) bool { + return strings.TrimSpace(tags["asin"]) != book.ASIN +} + +// retagBookFile re-runs EmbedMetadata over the existing file with the +// AudiobookRich profile. Writes to a sibling temp file and atomically +// replaces the original on success so a crashed ffmpeg leaves the +// original file intact. +func retagBookFile(ctx context.Context, ff *audio.FFmpeg, an *audnexus.Client, book *database.Book) error { + meta, err := buildRepairMetadata(ctx, an, book) + if err != nil { + return err + } + origInfo, err := os.Stat(book.FilePath) + if err != nil { + return err + } + + dir := filepath.Dir(book.FilePath) + ext := filepath.Ext(book.FilePath) + tmp, err := os.CreateTemp(dir, ".audplexus-retag-*"+ext) + if err != nil { + return err + } + tmpPath := tmp.Name() + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + // Remove the placeholder so ffmpeg creates a fresh output file + // instead of truncating the 0600 CreateTemp file. + if err := os.Remove(tmpPath); err != nil { + return err + } + cleanup := true + defer func() { + if cleanup { + os.Remove(tmpPath) + } + }() + + if err := ff.EmbedMetadata(book.FilePath, tmpPath, meta); err != nil { + return err + } + if err := os.Chmod(tmpPath, origInfo.Mode().Perm()); err != nil { + return err + } + if err := os.Rename(tmpPath, book.FilePath); err != nil { + return err + } + cleanup = false + return nil +} + +// buildRepairMetadata constructs the AudiobookRich metadata set for a +// book. Uses the audnexus enrichment path when available so the +// repaired file ends up with the same rich tag set the initial +// download would have produced; falls back to the DB record alone +// when audnexus is unavailable or the network lookup fails. +func buildRepairMetadata(ctx context.Context, an *audnexus.Client, book *database.Book) (audio.Metadata, error) { + if an != nil { + enriched, err := an.EnrichMetadata(ctx, book) + if err == nil && enriched != nil { + meta := enriched.ToAudioMetadata() + meta.Profile = audio.TagProfileAudiobookRich + return meta, nil + } + } + year := "" + if !book.ReleaseDate.IsZero() { + year = book.ReleaseDate.Format("2006") + } + return audio.Metadata{ + Title: book.Title, + Author: book.Author, + Narrator: book.Narrator, + Publisher: book.Publisher, + Language: book.Language, + Album: book.Title, + AlbumArtist: book.Author, + Year: year, + Comment: book.Description, + Track: book.SeriesPosition, + Series: book.Series, + SeriesPart: book.SeriesPosition, + ASIN: book.ASIN, + Profile: audio.TagProfileAudiobookRich, + }, nil +} diff --git a/internal/library/sync.go b/internal/library/sync.go index 5152fdb..c349e8c 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "github.com/mstrhakr/audplexus/internal/audio" + "github.com/mstrhakr/audplexus/internal/audnexus" "github.com/mstrhakr/audplexus/internal/database" "github.com/mstrhakr/audplexus/internal/errs" "github.com/mstrhakr/audplexus/internal/logging" @@ -41,11 +43,12 @@ const ( type SyncPhase string const ( - PhaseAudibleSync SyncPhase = "audible_sync" - PhaseFileScan SyncPhase = "file_scan" - PhasePlexSync SyncPhase = "plex_sync" - PhaseCollectionSync SyncPhase = "collection_sync" - PhaseDownloadQueue SyncPhase = "download_queue" + PhaseAudibleSync SyncPhase = "audible_sync" + PhaseFileScan SyncPhase = "file_scan" + PhaseMetadataRepair SyncPhase = "metadata_repair" + PhasePlexSync SyncPhase = "plex_sync" + PhaseCollectionSync SyncPhase = "collection_sync" + PhaseDownloadQueue SyncPhase = "download_queue" ) // DefaultFullPhases returns the standard set of sync phases in idle state. @@ -57,6 +60,7 @@ func DefaultFullPhases() []PhaseStatus { return []PhaseStatus{ {Name: PhaseAudibleSync, Label: "Audible Library", Status: "idle"}, {Name: PhaseFileScan, Label: "File System Scan", Status: "idle"}, + {Name: PhaseMetadataRepair, Label: "Metadata Repair", Status: "idle"}, {Name: PhasePlexSync, Label: "Library Scan", Status: "idle"}, {Name: PhaseCollectionSync, Label: "Collection Sync", Status: "idle"}, } @@ -175,6 +179,13 @@ type SyncService struct { libraryDir string + // ffmpeg and audnexus are optional dependencies used by the + // Metadata Repair phase. Set via SetFFmpeg / SetAudnexusClient + // after construction so callers that don't run repair (tests, + // embedded use) don't have to build them. + ffmpeg *audio.FFmpeg + audnexus *audnexus.Client + // Plex callback (set by web layer after construction) plexSyncFunc PlexSyncFunc plexReconcileFunc PlexReconcileFunc @@ -263,6 +274,19 @@ func (s *SyncService) subPhaseFnFor(phase SyncPhase) SubPhaseFn { } } +// SetFFmpeg wires the ffmpeg wrapper used by the Metadata Repair phase. +// Optional — when nil, the repair phase fails with a clear message. +func (s *SyncService) SetFFmpeg(ff *audio.FFmpeg) { + s.ffmpeg = ff +} + +// SetAudnexusClient wires the audnexus client used by Metadata Repair +// to enrich book metadata before re-tagging. Optional — when nil, +// repair falls back to DB-only fields. +func (s *SyncService) SetAudnexusClient(c *audnexus.Client) { + s.audnexus = c +} + // SetPlexSyncCallback registers the combined Plex sync function. func (s *SyncService) SetPlexSyncCallback(fn PlexSyncFunc) { s.plexSyncFunc = fn @@ -468,6 +492,16 @@ func (s *SyncService) RunPhase(ctx context.Context, phase SyncPhase) error { s.setPhase(phase, "complete", fmt.Sprintf("%d files reconciled", reconciled)) } + case PhaseMetadataRepair: + repaired, err := RepairBookMetadata(ctx, s.db, s.ffmpeg, s.audnexus, func(processed, total int) { + s.updatePhaseProgress(PhaseMetadataRepair, processed, total, false) + }) + if err != nil { + phaseErr = err + } else { + s.setPhase(phase, "complete", fmt.Sprintf("%d files re-tagged", repaired)) + } + case PhasePlexSync: if s.plexSyncFunc == nil { phaseErr = fmt.Errorf("media server not configured") @@ -619,6 +653,29 @@ func (s *SyncService) runSync(ctx context.Context, mode SyncMode) (int, error) { } } + // --- Phase 2b: Metadata Repair (full sync only) --- + // Probes every complete book and re-embeds AudiobookRich tags when + // the file's ASIN atom is missing or stale, so audiobook servers + // (Audiobookshelf in particular) can match by ID. Non-fatal: + // failures degrade to a "partial" sync but don't block the rest. + if mode == SyncModeFull { + if s.ffmpeg == nil { + s.setPhase(PhaseMetadataRepair, "skipped", "ffmpeg unavailable") + } else { + s.setPhase(PhaseMetadataRepair, "running", "Checking metadata on existing files...") + repaired, repairErr := RepairBookMetadata(ctx, s.db, s.ffmpeg, s.audnexus, func(processed, total int) { + s.updatePhaseProgress(PhaseMetadataRepair, processed, total, false) + }) + if repairErr != nil { + s.setPhase(PhaseMetadataRepair, "failed", repairErr.Error()) + syncLog.Warn().Err(repairErr).Msg("metadata repair phase failed") + } else { + s.setPhase(PhaseMetadataRepair, "complete", fmt.Sprintf("%d files re-tagged", repaired)) + syncLog.Info().Int("repaired", repaired).Msg("metadata repair complete") + } + } + } + // --- Phase 3: Library Scan (full sync only). Phase identifier kept as // PhasePlexSync for URL/JS back-compat, but user-visible messages are // backend-agnostic — works for Plex, Emby, Jellyfin, ABS. --- @@ -710,6 +767,7 @@ func (s *SyncService) buildPhases(mode SyncMode, prev []PhaseStatus) []PhaseStat return []PhaseStatus{ defaultPhase(PhaseAudibleSync, "Audible Library"), defaultPhase(PhaseFileScan, "File System Scan"), + defaultPhase(PhaseMetadataRepair, "Metadata Repair"), defaultPhase(PhasePlexSync, "Library Scan"), defaultPhase(PhaseCollectionSync, "Collection Sync"), } @@ -721,6 +779,7 @@ func (s *SyncService) buildPhases(mode SyncMode, prev []PhaseStatus) []PhaseStat label string }{ {name: PhaseFileScan, label: "File System Scan"}, + {name: PhaseMetadataRepair, label: "Metadata Repair"}, {name: PhasePlexSync, label: "Library Scan"}, {name: PhaseCollectionSync, label: "Collection Sync"}, } { diff --git a/internal/scheduler/cron.go b/internal/scheduler/cron.go index a647fc8..18e8a78 100644 --- a/internal/scheduler/cron.go +++ b/internal/scheduler/cron.go @@ -3,6 +3,7 @@ package scheduler import ( "context" "errors" + "sync" "github.com/mstrhakr/audplexus/internal/library" "github.com/mstrhakr/audplexus/internal/logging" @@ -12,7 +13,13 @@ import ( var schedLog = logging.Component("scheduler") // Scheduler manages periodic tasks using cron expressions. +// +// mu guards syncEntry, syncMode, and autoQueue. These are configured at +// startup and can also be reconfigured live from the web settings handler, +// so reads in runSync (driven by the cron goroutine) and writes from the +// setters can race without it. type Scheduler struct { + mu sync.Mutex cron *cron.Cron syncSvc *library.SyncService dlMgr *library.DownloadManager @@ -34,24 +41,32 @@ func New(syncSvc *library.SyncService, dlMgr *library.DownloadManager) *Schedule // SetAutoQueueNew controls whether scheduled sync automatically queues new books. func (s *Scheduler) SetAutoQueueNew(enabled bool) { + s.mu.Lock() s.autoQueue = enabled + s.mu.Unlock() schedLog.Info().Bool("enabled", enabled).Msg("scheduled auto-queue-new set") } // SetSyncMode sets the mode used for scheduled syncs (quick or full). func (s *Scheduler) SetSyncMode(mode string) { + s.mu.Lock() switch mode { case "quick": s.syncMode = library.SyncModeQuick default: s.syncMode = library.SyncModeFull } - schedLog.Info().Str("mode", string(s.syncMode)).Msg("scheduled sync mode set") + resolved := s.syncMode + s.mu.Unlock() + schedLog.Info().Str("mode", string(resolved)).Msg("scheduled sync mode set") } // SetSyncSchedule configures the library sync cron schedule. // Pass an empty string to disable. func (s *Scheduler) SetSyncSchedule(schedule string) error { + s.mu.Lock() + defer s.mu.Unlock() + // Remove previous entry if set if s.syncEntry != 0 { s.cron.Remove(s.syncEntry) @@ -78,12 +93,17 @@ func (s *Scheduler) SetSyncSchedule(schedule string) error { } func (s *Scheduler) runSync() { - schedLog.Info().Str("mode", string(s.syncMode)).Msg("scheduled sync starting") + s.mu.Lock() + mode := s.syncMode + autoQueue := s.autoQueue + s.mu.Unlock() + + schedLog.Info().Str("mode", string(mode)).Msg("scheduled sync starting") ctx := context.Background() var added int var err error - switch s.syncMode { + switch mode { case library.SyncModeQuick: added, err = s.syncSvc.QuickSync(ctx) default: @@ -94,12 +114,12 @@ func (s *Scheduler) runSync() { schedLog.Info().Msg("sync already running, skipping scheduled run") return } - schedLog.Error().Err(err).Str("mode", string(s.syncMode)).Msg("scheduled sync failed") + schedLog.Error().Err(err).Str("mode", string(mode)).Msg("scheduled sync failed") return } - schedLog.Info().Int("added", added).Str("mode", string(s.syncMode)).Msg("scheduled sync complete") + schedLog.Info().Int("added", added).Str("mode", string(mode)).Msg("scheduled sync complete") - if added > 0 && s.autoQueue { + if added > 0 && autoQueue { queued, err := s.dlMgr.QueueNewBooks(ctx) if err != nil { schedLog.Error().Err(err).Msg("failed to queue new books after sync") diff --git a/internal/web/destinations_handlers.go b/internal/web/destinations_handlers.go index 825a3df..cca12d6 100644 --- a/internal/web/destinations_handlers.go +++ b/internal/web/destinations_handlers.go @@ -122,10 +122,9 @@ func destinationConfigured(d *database.LibraryDestination) bool { // (item count, coverage, last error) and a clear top-level CTA to add more. func (s *Server) handleDestinations(c *gin.Context) { ctx := c.Request.Context() - completeStatus := database.BookStatusComplete - _, completeBooks, _ := s.db.ListBooks(ctx, database.BookFilter{Status: &completeStatus, Limit: 1}) + _, libraryTotal, _ := s.db.ListBooks(ctx, database.BookFilter{Limit: 1}) - dests := s.destinationSummaries(ctx, completeBooks) + dests := s.destinationSummaries(ctx, libraryTotal) healthy := 0 for _, d := range dests { diff --git a/internal/web/server.go b/internal/web/server.go index ed6f7eb..f11615e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -35,6 +35,7 @@ import ( "github.com/mstrhakr/audplexus/internal/logging" "github.com/mstrhakr/audplexus/internal/mediaserver" "github.com/mstrhakr/audplexus/internal/organizer" + "github.com/mstrhakr/audplexus/internal/scheduler" audible "github.com/mstrhakr/go-audible" "github.com/robfig/cron/v3" ) @@ -53,9 +54,11 @@ type Server struct { db database.Database sync *library.SyncService downloads *library.DownloadManager + sched *scheduler.Scheduler audnexus *audnexus.Client organizer *organizer.PlexOrganizer audible *audible.Client + ffmpeg *audio.FFmpeg credPath string port int audiobooksPath string @@ -92,9 +95,11 @@ func NewServer( db database.Database, syncSvc *library.SyncService, dlMgr *library.DownloadManager, + sched *scheduler.Scheduler, anClient *audnexus.Client, org *organizer.PlexOrganizer, audibleClient *audible.Client, + ffmpeg *audio.FFmpeg, credPath string, port int, audiobooksPath string, @@ -110,9 +115,11 @@ func NewServer( db: db, sync: syncSvc, downloads: dlMgr, + sched: sched, audnexus: anClient, organizer: org, audible: audibleClient, + ffmpeg: ffmpeg, credPath: credPath, port: port, audiobooksPath: audiobooksPath, @@ -702,7 +709,11 @@ func (s *Server) getDashboardSummaryData(ctx context.Context) gin.H { // Per-destination summary cards. Replaces the legacy // single-active-backend stat cards. Empty slice means "no // destinations configured yet" — template shows a CTA. - "DestinationSummaries": s.destinationSummaries(ctx, completeBooks), + // Coverage % uses totalBooks (Audible library size) as the + // denominator so it represents "fraction of the library present + // in this destination" — stable across status transitions and + // matches the user's mental model. + "DestinationSummaries": s.destinationSummaries(ctx, totalBooks), } } @@ -750,7 +761,7 @@ type destinationSummaryView struct { // // Per a11y-lead: badges are role="status" so SR users hear destination // state changes (Healthy → Failed) without per-tick count chatter. -func (s *Server) destinationSummaries(ctx context.Context, completeBooks int) []destinationSummaryView { +func (s *Server) destinationSummaries(ctx context.Context, libraryTotal int) []destinationSummaryView { rows, err := s.db.ListLibraryDestinations(ctx) if err != nil { webLog.Warn().Err(err).Msg("dashboard: list destinations failed") @@ -763,7 +774,7 @@ func (s *Server) destinationSummaries(ctx context.Context, completeBooks int) [] out := make([]destinationSummaryView, 0, len(rows)) for _, r := range rows { row := r - out = append(out, s.buildDestinationSummary(ctx, &row, completeBooks)) + out = append(out, s.buildDestinationSummary(ctx, &row, libraryTotal)) } return out } @@ -778,12 +789,11 @@ func (s *Server) singleDestinationSummary(ctx context.Context, id string) *desti if err != nil || row == nil { return nil } - // Coverage % is relative to the count of complete books; we need - // that number for the meter to render the same as it does on the - // full grid. Cheap LIMIT-1 count. - completeStatus := database.BookStatusComplete - _, completeBooks, _ := s.db.ListBooks(ctx, database.BookFilter{Status: &completeStatus, Limit: 1}) - v := s.buildDestinationSummary(ctx, row, completeBooks) + // Coverage % uses the total library size as the denominator so the + // meter renders the same as it does on the full grid. Cheap + // LIMIT-1 count. + _, libraryTotal, _ := s.db.ListBooks(ctx, database.BookFilter{Limit: 1}) + v := s.buildDestinationSummary(ctx, row, libraryTotal) return &v } @@ -791,7 +801,7 @@ func (s *Server) singleDestinationSummary(ctx context.Context, id string) *desti // builder and the single-row swap path. Probes LibraryItemCount (with // the 30s cache) only for enabled+configured destinations so toggling // a disabled row stays cheap. -func (s *Server) buildDestinationSummary(ctx context.Context, row *database.LibraryDestination, completeBooks int) destinationSummaryView { +func (s *Server) buildDestinationSummary(ctx context.Context, row *database.LibraryDestination, libraryTotal int) destinationSummaryView { hasCred := row.APIKey != "" || row.PlexToken != "" v := destinationSummaryView{ ID: row.ID, @@ -832,8 +842,8 @@ func (s *Server) buildDestinationSummary(ctx context.Context, row *database.Libr } v.ItemCount = count v.ItemCountSet = true - if completeBooks > 0 { - cov := int(math.Round((float64(count) / float64(completeBooks)) * 100)) + if libraryTotal > 0 { + cov := int(math.Round((float64(count) / float64(libraryTotal)) * 100)) if cov < 0 { cov = 0 } @@ -1001,6 +1011,34 @@ func (s *Server) handleLibrary(c *gin.Context) { filter.Status = &status } + // Presence filters. on_disk is a tri-state: missing/empty = any, + // "yes" = on disk, "no" = missing locally. Per-destination filters + // arrive as presence_=in|out — iterating the raw query + // keeps the param list extensible as the user adds destinations. + switch c.Query("on_disk") { + case "yes": + v := true + filter.OnDisk = &v + case "no": + v := false + filter.OnDisk = &v + } + destFilterState := map[string]string{} + for key, vals := range c.Request.URL.Query() { + if !strings.HasPrefix(key, "presence_") || len(vals) == 0 { + continue + } + destID := strings.TrimPrefix(key, "presence_") + switch vals[0] { + case "in": + filter.PresentInDestinations = append(filter.PresentInDestinations, destID) + destFilterState[destID] = "in" + case "out": + filter.MissingFromDestinations = append(filter.MissingFromDestinations, destID) + destFilterState[destID] = "out" + } + } + books, total, err := s.db.ListBooks(ctx, filter) if err != nil { webLog.Error().Err(err).Msg("failed to list books") @@ -1022,20 +1060,22 @@ func (s *Server) handleLibrary(c *gin.Context) { } data := gin.H{ - "Books": books, - "Total": total, - "Filter": filter, - "Page": "library", - "BookActions": buildLibraryBookActions(books, s.settingBool(ctx, library.SettingKeyAutoQueueNewBooks, false)), - "BookPresence": s.computeBookPresence(ctx, books), - "StatusCounts": counts, - "ActiveStatus": statusStr, - "PageNum": pageNum, - "TotalPages": totalPages, - "PageSize": pageSize, - "PageFrom": from, - "PageTo": to, - "PageNums": paginationNumbers(pageNum, totalPages), + "Books": books, + "Total": total, + "Filter": filter, + "Page": "library", + "BookActions": buildLibraryBookActions(books, s.settingBool(ctx, library.SettingKeyAutoQueueNewBooks, false)), + "BookPresence": s.computeBookPresence(ctx, books), + "StatusCounts": counts, + "ActiveStatus": statusStr, + "PresenceFilters": s.libraryPresenceFilterOpts(ctx, destFilterState), + "OnDiskFilter": c.Query("on_disk"), + "PageNum": pageNum, + "TotalPages": totalPages, + "PageSize": pageSize, + "PageFrom": from, + "PageTo": to, + "PageNums": paginationNumbers(pageNum, totalPages), } // For HTMX partial requests, render only the table body @@ -1046,6 +1086,37 @@ func (s *Server) handleLibrary(c *gin.Context) { c.HTML(http.StatusOK, "library.html", s.withSidebar(ctx, data)) } +// libraryPresenceFilterOption is one row in the per-destination presence +// dropdown — rendered as a + + {{range .PresenceFilters}} + + {{end}} diff --git a/internal/web/templates/library_row.html b/internal/web/templates/library_row.html index 841a3a7..c419190 100644 --- a/internal/web/templates/library_row.html +++ b/internal/web/templates/library_row.html @@ -9,7 +9,7 @@ {{end}} - +
{{.Book.Title}} {{if eq (printf "%s" .Book.Status) "unavailable"}} @@ -21,14 +21,14 @@ {{if .Book.Narrator}}narr. {{.Book.Narrator}}{{end}}
- {{.Book.Author}} - + {{.Book.Author}} + {{if .Book.Series}}{{.Book.Series}} #{{.Book.SeriesPosition}} {{else}}{{end}} - {{formatDuration .Book.Duration}} - {{formatDate .Book.PurchaseDate}} - {{.Book.Status}} + {{formatDuration .Book.Duration}} + {{formatDate .Book.PurchaseDate}} + {{.Book.Status}} {{if .Presence}}
@@ -55,12 +55,34 @@
- {{/* Primary actions, always visible. View opens the modal - via the .book-detail-link delegated handler on library.html. */}} + {{$status := printf "%s" .Book.Status}} + {{/* Primary button. For books that aren't downloaded yet, + Download/Retry is the headline action and View moves into + the More menu. Downloaded and unavailable books keep View + as the primary button. View opens the modal via the + .book-detail-link delegated handler on library.html. */}} + {{if eq $status "new"}} + + {{else if eq $status "failed"}} + + {{else}} View + {{end}} {{if .BookAction.ShowDelete}} - {{else if eq (printf "%s" .Book.Status) "failed"}} - - {{else if eq (printf "%s" .Book.Status) "complete"}} + {{if or (eq $status "new") (eq $status "failed")}} + + + View details + + {{else if eq $status "complete"}}
@@ -207,8 +207,8 @@

Settings

Mode used by scheduled sync runs.
@@ -227,8 +227,8 @@

Settings

-

Sync schedule and auto-queue-new changes apply on next restart.

@@ -251,19 +250,19 @@

Settings

-
0 = auto. Higher values can improve throughput on fast networks.
+
0 lets Audplexus pick. Higher numbers can download faster on quick connections.
-
0 = auto. This stage is CPU-heavy.
+
0 lets Audplexus pick. This step works your CPU hard.
-
0 = auto. Controls metadata/organize stage parallelism.
+
0 lets Audplexus pick. Sets how many books get tagged and filed at once.
@@ -278,14 +277,14 @@

Settings

Minimum severity for log output. Changes take effect immediately.
-

Worker concurrency changes apply on next restart.

+

Worker count changes apply after a restart.

{{/* Single save row for the Library + Sync + Performance form. */}}
- +
@@ -306,10 +305,10 @@

Settings

{{end}}
- Atoms written into each m4b on download. - Basic matches Audplexus's historical behavior. - Audiobook-rich adds series, series-part, and asin — read by Audiobookshelf for series auto-grouping. - Applies to subsequent downloads only. + Tags saved inside each file when it downloads. + Audiobook-rich (default) adds series, series-part, and asin tags so apps like Audiobookshelf can group series for you. + Basic leaves those out and matches how older versions tagged files. + Only changes files you download from now on.
@@ -337,6 +336,7 @@

Settings

Config
{{.ConfigPath}}
+

These paths come from your container's volume mounts and can't be changed here. To move them, update the mounts in your compose file and restart.

@@ -615,7 +615,8 @@

⚠️ Factory Reset

var focusSection = '{{.FocusSection}}'; if (focusSection) { - var focusEl = document.getElementById(focusSection + '-settings'); + var focusMap = { auth: 'section-audible' }; + var focusEl = document.getElementById(focusMap[focusSection] || ('section-' + focusSection)); if (focusEl) { focusEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -633,13 +634,18 @@

⚠️ Factory Reset

.settings-container { display: flex; flex-direction: column; gap: 1.25rem; } .settings-section { background: transparent; padding: 0; border: 0; } + /* The Library/Sync/Performance cards live inside a single
, which + is one flex child of .settings-container — so the container gap can't + reach the sections nested inside it. Give the form the same flex + column + gap so every card is spaced consistently. */ + #settings-main-form { display: flex; flex-direction: column; gap: 1.25rem; } + /* Bottom save bar shared by the Library + Sync + Performance form. */ .settings-save-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; - margin-top: 0.25rem; } /* Configuration form — two-column grid of .form-rows so we fit a @@ -681,8 +687,11 @@

⚠️ Factory Reset

diff --git a/internal/web/templates/setup.html b/internal/web/templates/setup.html index cfa96ff..c737fce 100644 --- a/internal/web/templates/setup.html +++ b/internal/web/templates/setup.html @@ -99,24 +99,6 @@

Connect your Audible account

{{end}} {{if eq .CurrentStep 2}} -

Storage

-

Audplexus writes processed audiobooks to the path mounted into the container. Your media servers should be able to read this same path.

-
- -
{{.AudiobooksPath}}
-
Configured at container startup. If this isn't right, mount a different volume to /audiobooks in your compose file.
-
-
- -
{{.DownloadsPath}}
-
Temporary working directory for in-flight downloads. Doesn't need to be visible to media servers.
-
-
-
Naming templates and tagging options are configurable later from Settings.
-
- {{end}} - - {{if eq .CurrentStep 3}}

Add a destination

Connect at least one media server so Audplexus knows where to push processed files. You can add more later.

@@ -152,7 +134,7 @@

Add a destination

{{end}} - {{if eq .CurrentStep 4}} + {{if eq .CurrentStep 3}}

You're all set.

Audplexus is ready to run its first sync. You can change anything from Settings later.

@@ -166,6 +148,17 @@

You're all set.

+ + {{if .Authenticated}}
First sync will start automatically. Expect 1–3 minutes for a typical library.
{{else}} @@ -188,7 +181,7 @@

You're all set.

{{end}} Continue → {{else}} - +
{{end}}