Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0c91856
fix: Correct column order in live-appended completed/failed download …
Wutname1 May 22, 2026
078f8d8
feat: Search library by series and ASIN as well as title/author
Wutname1 May 22, 2026
ca3fad3
fix: Use library total as destination coverage denominator
Wutname1 May 22, 2026
0ffa189
feat: Add Metadata Repair sync phase to re-tag books missing ASIN
Wutname1 May 22, 2026
4be08d1
feat: Add library presence filters (disk + per-destination)
Wutname1 May 22, 2026
5d4220e
feat: Add File info block and collapsible description to book detail
Wutname1 May 22, 2026
162b01e
fix: Dashboard auto-refresh, clickable New card, mobile nav + table
Wutname1 May 28, 2026
6aeda0e
fix: Settings page section links no longer jump back to the top
Wutname1 May 31, 2026
4101184
feat: New libraries tag audiobooks with series info by default
Wutname1 May 31, 2026
c019a26
feat: Setup wizard asks whether to auto-download new books
Wutname1 May 31, 2026
1e0d5be
improved: Sync settings now apply without a restart
Wutname1 May 31, 2026
9caf1e3
improved: Plainer wording on the settings page
Wutname1 May 31, 2026
0a2ad84
improved: Make all dashboard summary cards clickable
Wutname1 May 31, 2026
4249c6e
improved: Lead with Download on library rows that aren't downloaded yet
Wutname1 May 31, 2026
8b2958a
fix: Book links from Diagnostics open the book instead of an empty page
Wutname1 May 31, 2026
ccb1122
improved: Tidy settings spacing and drop the read-only Storage wizard…
Wutname1 May 31, 2026
b74b9f1
fix: update retagBookFile to handle temporary file cleanup and preser…
mstrhakr Jun 7, 2026
7981e1e
fix: update scroll position calculation in confirmRestart function
mstrhakr Jun 7, 2026
796acb5
fix: change checkbox to label for automatic download setting in setup
mstrhakr Jun 7, 2026
7e2066b
fix(setup): apply auto-download preference immediately after onboarding
mstrhakr Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
146 changes: 146 additions & 0 deletions internal/audio/probe_tags.go
Original file line number Diff line number Diff line change
@@ -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
}

26 changes: 17 additions & 9 deletions internal/audio/tag_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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
}
}

Expand Down
38 changes: 19 additions & 19 deletions internal/audio/tag_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
}

Expand All @@ -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}
Expand All @@ -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])
Expand Down
Loading