diff --git a/.github/jive-encoder.gif b/.github/jive-encoder.gif index 7258d1d..03bb1fc 100644 Binary files a/.github/jive-encoder.gif and b/.github/jive-encoder.gif differ diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5149936..5a6525e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -58,6 +58,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + CGO_ENABLED: 1 steps: - uses: actions/checkout@v7 with: @@ -97,6 +99,8 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read + env: + CGO_ENABLED: 1 steps: - uses: actions/checkout@v7 with: @@ -139,7 +143,7 @@ jobs: comment-summary-in-pr: on-failure - name: Run govulncheck - uses: golang/govulncheck-action@master + uses: golang/govulncheck-action@v1.0.4 with: repo-checkout: false go-version-file: go.mod @@ -193,6 +197,8 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read + env: + CGO_ENABLED: 1 steps: - uses: actions/checkout@v7 with: diff --git a/.tailor.yml b/.tailor.yml index 81aa5ac..8e223a9 100644 --- a/.tailor.yml +++ b/.tailor.yml @@ -2,7 +2,7 @@ license: GPL-3.0 repository: - description: Drop your podcast .wav into a shiny MP3, AAC or Opus with metadata, cover art, and all đŸĒŠ + description: Drop your podcast .wav into a shiny MP3, AAC or Opus with metadata, cover art, and all đŸ—œī¸ homepage: https://linuxmatters.sh has_wiki: false has_discussions: false diff --git a/AGENTS.md b/AGENTS.md index 10979c3..92fc0a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,11 @@ third_party/ffmpeg-statigo/ # Git submodule: FFmpeg 8.1 static bindings - **Shell:** Fish (interactive), bash (scripts) - **CGO required:** Set `CGO_ENABLED=1` for builds +## CI and Dependencies + +- **Release checksums:** GitHub attaches SHA256 sums to all release artifacts automatically. Do not hand-roll a checksum step. +- **Dependabot Nix:** Dependabot supports the `nix` ecosystem and updates `flake.lock`. The entry in `.github/dependabot.yml` is expected. + ## FFmpeg-Statigo Submodule This project uses [`ffmpeg-statigo`](https://github.com/linuxmatters/ffmpeg-statigo) for FFmpeg 8.1 static bindings: diff --git a/README.md b/README.md index 38f8821..1db0232 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Jive Encoder đŸĒŠ +# Jive Encoder đŸ—œī¸ *Formerly known as Jivedrop.* diff --git a/cmd/jive-encoder/hugo.go b/cmd/jive-encoder/hugo.go index 3dc8249..18b930a 100644 --- a/cmd/jive-encoder/hugo.go +++ b/cmd/jive-encoder/hugo.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" "strings" @@ -28,7 +29,7 @@ type HugoWorkflow struct { // Validate checks Hugo-specific arguments and file existence. func (h *HugoWorkflow) Validate() error { if h.opts.EpisodeMD == "" { - return fmt.Errorf("hugo mode requires episode markdown file as second argument") + return errors.New("hugo mode requires episode markdown file as second argument") } if !isMarkdownPath(h.opts.EpisodeMD) { @@ -62,6 +63,9 @@ func (h *HugoWorkflow) CollectMetadata() (encoder.Metadata, string, error) { episodeTitle := metadata.Title artist := HugoDefaultArtist comment := HugoDefaultComment + // Frontmatter supplies a parsed time.Time, so format it to the muxer's + // YYYY-MM tag string. Standalone mode instead passes --date through raw + // (see standalone.go), because there the user types the exact tag value. date := encoder.FormatDateForID3(metadata.Date) if h.opts.Artist != "" { @@ -112,6 +116,12 @@ func (h *HugoWorkflow) CollectMetadata() (encoder.Metadata, string, error) { // new podcast_duration/podcast_bytes and waits for confirmation before writing, so a non-mp3 // encode cannot silently overwrite values for a different enclosure. func (h *HugoWorkflow) PostEncode(stats *encoder.FileStats) error { + // hugoMetadata is populated by CollectMetadata; guard the implicit ordering + // so an out-of-order call returns an error instead of a nil-deref panic. + if h.hugoMetadata == nil { + return errors.New("PostEncode called before CollectMetadata") + } + printPodcastStats(stats) needsUpdate := false @@ -129,29 +139,33 @@ func (h *HugoWorkflow) PostEncode(stats *encoder.FileStats) error { // A mismatch and a missing value both need confirmation, but each gets its // own prompt wording so the user knows which case they are approving. if needsUpdate { - promptAndUpdateFrontmatter(h.opts.EpisodeMD, "\nUpdate frontmatter with new values? [y/N]: ", stats.DurationString, stats.FileSizeBytes) + return promptAndUpdateFrontmatter(h.opts.EpisodeMD, "\nUpdate frontmatter with new values? [y/N]: ", stats.DurationString, stats.FileSizeBytes) } else if h.hugoMetadata.PodcastDuration == "" || h.hugoMetadata.PodcastBytes == 0 { - promptAndUpdateFrontmatter(h.opts.EpisodeMD, "\nAdd podcast_duration and podcast_bytes to frontmatter? [y/N]: ", stats.DurationString, stats.FileSizeBytes) + return promptAndUpdateFrontmatter(h.opts.EpisodeMD, "\nAdd podcast_duration and podcast_bytes to frontmatter? [y/N]: ", stats.DurationString, stats.FileSizeBytes) } return nil } -// promptAndUpdateFrontmatter prompts the user and updates the frontmatter with podcast stats -func promptAndUpdateFrontmatter(markdownPath, promptMsg, duration string, bytes int64) { +// promptAndUpdateFrontmatter prompts the user and updates the frontmatter with +// podcast stats. A write failure is returned so the caller can exit non-zero; +// declining the prompt is not an error. +func promptAndUpdateFrontmatter(markdownPath, promptMsg, duration string, bytes int64) error { fmt.Print(promptMsg) var response string _, _ = fmt.Scanln(&response) - if strings.ToLower(strings.TrimSpace(response)) == "y" { - if err := encoder.UpdateFrontmatter(markdownPath, duration, bytes); err != nil { - cli.PrintError(fmt.Sprintf("Failed to update frontmatter: %v", err)) - } else { - cli.PrintSuccess("Frontmatter updated successfully") - } - } else { + if strings.ToLower(strings.TrimSpace(response)) != "y" { cli.PrintInfo("Frontmatter not updated") + return nil } + + if err := encoder.UpdateFrontmatter(markdownPath, duration, bytes); err != nil { + return fmt.Errorf("failed to update frontmatter: %w", err) + } + + cli.PrintSuccess("Frontmatter updated successfully") + return nil } // Ensure HugoWorkflow implements Workflow at compile time. diff --git a/cmd/jive-encoder/hugo_test.go b/cmd/jive-encoder/hugo_test.go index 1f7e90d..ec47516 100644 --- a/cmd/jive-encoder/hugo_test.go +++ b/cmd/jive-encoder/hugo_test.go @@ -1,10 +1,14 @@ package main import ( + "bytes" + "io" "os" "path/filepath" "strings" "testing" + + "github.com/linuxmatters/jive-encoder/internal/encoder" ) // TestHugoWorkflowValidate tests Hugo mode validation of episode markdown arguments. @@ -84,6 +88,284 @@ func TestHugoWorkflowValidate(t *testing.T) { }) } +// TestHugoWorkflowCollectMetadata covers flag-over-frontmatter precedence, +// episode validation, date formatting, and cover-path resolution. +func TestHugoWorkflowCollectMetadata(t *testing.T) { + t.Run("frontmatter values with Hugo defaults", func(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nDate: 2024-03-15\nepisode_image: ./cover.png\n") + if err := os.WriteFile(filepath.Join(filepath.Dir(mdPath), "cover.png"), []byte("img"), 0o644); err != nil { + t.Fatalf("write cover fixture: %v", err) + } + + wf := &HugoWorkflow{opts: CLIOptions{EpisodeMD: mdPath}} + meta, cover, err := wf.CollectMetadata() + if err != nil { + t.Fatalf("CollectMetadata() unexpected error: %v", err) + } + + if meta.EpisodeNumber != "67" { + t.Errorf("EpisodeNumber = %q; want 67", meta.EpisodeNumber) + } + if meta.Title != "The Show" { + t.Errorf("Title = %q; want The Show", meta.Title) + } + if meta.Artist != HugoDefaultArtist { + t.Errorf("Artist = %q; want %q", meta.Artist, HugoDefaultArtist) + } + if meta.Album != HugoDefaultArtist { + t.Errorf("Album = %q; want %q (falls back to artist)", meta.Album, HugoDefaultArtist) + } + if meta.Comment != HugoDefaultComment { + t.Errorf("Comment = %q; want %q", meta.Comment, HugoDefaultComment) + } + if meta.Date != "2024-03" { + t.Errorf("Date = %q; want 2024-03 (formatted via FormatDateForID3)", meta.Date) + } + if filepath.Base(cover) != "cover.png" { + t.Errorf("cover = %q; want a resolved cover.png path", cover) + } + }) + + t.Run("flags override frontmatter and defaults", func(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nDate: 2024-03-15\nepisode_image: ./cover.png\n") + flagCover := filepath.Join(t.TempDir(), "flag-cover.png") + + wf := &HugoWorkflow{opts: CLIOptions{ + EpisodeMD: mdPath, + Num: "99", + Title: "Override Title", + Artist: "Override Artist", + Album: "Override Album", + Comment: "https://override.example", + Date: "2020-01-02", + Cover: flagCover, + }} + meta, cover, err := wf.CollectMetadata() + if err != nil { + t.Fatalf("CollectMetadata() unexpected error: %v", err) + } + + if meta.EpisodeNumber != "99" { + t.Errorf("EpisodeNumber = %q; want 99", meta.EpisodeNumber) + } + if meta.Title != "Override Title" { + t.Errorf("Title = %q; want Override Title", meta.Title) + } + if meta.Artist != "Override Artist" { + t.Errorf("Artist = %q; want Override Artist", meta.Artist) + } + if meta.Album != "Override Album" { + t.Errorf("Album = %q; want Override Album", meta.Album) + } + if meta.Comment != "https://override.example" { + t.Errorf("Comment = %q; want https://override.example", meta.Comment) + } + if meta.Date != "2020-01-02" { + t.Errorf("Date = %q; want raw --date 2020-01-02", meta.Date) + } + if cover != flagCover { + t.Errorf("cover = %q; want flag cover %q", cover, flagCover) + } + }) + + t.Run("rejects invalid episode override", func(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nepisode_image: ./cover.png\n") + wf := &HugoWorkflow{opts: CLIOptions{EpisodeMD: mdPath, Num: "-5"}} + + _, _, err := wf.CollectMetadata() + if err == nil { + t.Fatal("CollectMetadata() expected error for invalid episode number, got nil") + } + if !strings.Contains(err.Error(), "invalid episode number") { + t.Errorf("error %q does not contain %q", err.Error(), "invalid episode number") + } + }) + + t.Run("reports cover resolution failure", func(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nepisode_image: ./missing.png\n") + wf := &HugoWorkflow{opts: CLIOptions{EpisodeMD: mdPath}} + + _, _, err := wf.CollectMetadata() + if err == nil { + t.Fatal("CollectMetadata() expected error for missing cover art, got nil") + } + if !strings.Contains(err.Error(), "resolve cover art") { + t.Errorf("error %q does not contain %q", err.Error(), "resolve cover art") + } + }) +} + +// TestHugoWorkflowPostEncode covers the mismatch, missing-value, and no-change +// branches that decide whether the user is prompted to update frontmatter. +func TestHugoWorkflowPostEncode(t *testing.T) { + stats := &encoder.FileStats{DurationString: "00:20:00", FileSizeBytes: 2048} + + tests := []struct { + name string + duration string + bytes int64 + input string + wantPrompt string // substring expected in the prompt; "" means no prompt + }{ + { + name: "no change when values match", + duration: "00:20:00", + bytes: 2048, + input: "", + wantPrompt: "", + }, + { + name: "duration mismatch prompts for update", + duration: "00:10:00", + bytes: 2048, + input: "n\n", + wantPrompt: "Update frontmatter with new values?", + }, + { + name: "byte size mismatch prompts for update", + duration: "00:20:00", + bytes: 1024, + input: "n\n", + wantPrompt: "Update frontmatter with new values?", + }, + { + name: "missing values prompt to add", + duration: "", + bytes: 0, + input: "n\n", + wantPrompt: "Add podcast_duration and podcast_bytes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wf := &HugoWorkflow{ + opts: CLIOptions{EpisodeMD: filepath.Join(t.TempDir(), "episode.md")}, + hugoMetadata: &encoder.EpisodeMetadata{PodcastDuration: tt.duration, PodcastBytes: tt.bytes}, + } + + var err error + out := captureStdio(t, tt.input, func() { err = wf.PostEncode(stats) }) + if err != nil { + t.Fatalf("PostEncode() unexpected error: %v", err) + } + + if tt.wantPrompt == "" { + // The "[y/N]:" marker is printed only by the prompt (via fmt.Print + // to the captured stdout); its absence proves no prompt fired. + // captureStdio does not observe cli.PrintWarning output, but a + // warning never fires without a following prompt, so the marker is + // a sound signal. + if strings.Contains(out, "[y/N]:") { + t.Errorf("PostEncode() unexpectedly prompted: %q", out) + } + return + } + if !strings.Contains(out, tt.wantPrompt) { + t.Errorf("PostEncode() prompt %q does not contain %q", out, tt.wantPrompt) + } + }) + } +} + +// TestHugoWorkflowPostEncodeWritesFrontmatter verifies the accept path writes +// the calculated stats back into the markdown file. +func TestHugoWorkflowPostEncodeWritesFrontmatter(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nepisode_image: ./cover.png\n") + wf := &HugoWorkflow{ + opts: CLIOptions{EpisodeMD: mdPath}, + hugoMetadata: &encoder.EpisodeMetadata{}, + } + stats := &encoder.FileStats{DurationString: "00:20:00", FileSizeBytes: 2048} + + var err error + captureStdio(t, "y\n", func() { err = wf.PostEncode(stats) }) + if err != nil { + t.Fatalf("PostEncode() unexpected error: %v", err) + } + + updated, readErr := os.ReadFile(mdPath) + if readErr != nil { + t.Fatalf("read updated markdown: %v", readErr) + } + if !strings.Contains(string(updated), "podcast_duration") { + t.Errorf("frontmatter not updated:\n%s", updated) + } +} + +// TestHugoWorkflowPostEncodePropagatesWriteFailure verifies a failed frontmatter +// write returns an error so the process exits non-zero. +func TestHugoWorkflowPostEncodePropagatesWriteFailure(t *testing.T) { + wf := &HugoWorkflow{ + opts: CLIOptions{EpisodeMD: filepath.Join(t.TempDir(), "missing.md")}, + hugoMetadata: &encoder.EpisodeMetadata{}, + } + stats := &encoder.FileStats{DurationString: "00:20:00", FileSizeBytes: 2048} + + var err error + captureStdio(t, "y\n", func() { err = wf.PostEncode(stats) }) + if err == nil { + t.Fatal("PostEncode() expected error when the frontmatter write fails, got nil") + } +} + +// writeHugoFixture writes a markdown file with the given frontmatter body +// (between --- delimiters) and returns its path. +func writeHugoFixture(t *testing.T, frontmatter string) string { + t.Helper() + + mdPath := filepath.Join(t.TempDir(), "episode.md") + body := "---\n" + frontmatter + "---\nBody\n" + if err := os.WriteFile(mdPath, []byte(body), 0o644); err != nil { + t.Fatalf("write markdown fixture: %v", err) + } + + return mdPath +} + +// captureStdio feeds input to the prompt's stdin and returns whatever fn writes +// to os.Stdout. The cli.Print* helpers hold the original stdout captured at +// package init, so only the raw prompt text is captured here. +func captureStdio(t *testing.T, input string, fn func()) string { + t.Helper() + + inR, inW, err := os.Pipe() + if err != nil { + t.Fatalf("create stdin pipe: %v", err) + } + origStdin := os.Stdin + os.Stdin = inR + go func() { + _, _ = inW.WriteString(input) + _ = inW.Close() + }() + + outR, outW, err := os.Pipe() + if err != nil { + t.Fatalf("create stdout pipe: %v", err) + } + origStdout := os.Stdout + os.Stdout = outW + + done := make(chan string) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, outR) + done <- buf.String() + }() + + fn() + + os.Stdout = origStdout + _ = outW.Close() + captured := <-done + + os.Stdin = origStdin + _ = inR.Close() + + return captured +} + func existingMarkdownArgument(t *testing.T, path string) string { t.Helper() diff --git a/cmd/jive-encoder/main.go b/cmd/jive-encoder/main.go index b5468de..782cccc 100644 --- a/cmd/jive-encoder/main.go +++ b/cmd/jive-encoder/main.go @@ -46,9 +46,12 @@ var CLI struct { OutputPath string `help:"Output file or directory path"` // Encoding options - Format string `help:"Output format: mp3, aac, or opus" enum:"mp3,opus,aac" default:"mp3"` - Stereo bool `help:"Encode as stereo at the format's stereo bitrate (default: mono)"` - Version bool `help:"Show version information"` + Format string `help:"Output format: mp3, aac, or opus" enum:"mp3,opus,aac" default:"mp3"` + Stereo bool `help:"Encode as stereo at the format's stereo bitrate (default: mono)"` + // Version is hand-checked in run() rather than using kong.VersionFlag so the + // styled cli.PrintVersion output (title banner + version) is preserved; + // kong.VersionFlag prints only the bare version string and exits. + Version bool `help:"Show version information"` } // detectMode reports whether the invocation is a Hugo or Standalone workflow. @@ -306,14 +309,24 @@ func run() int { kong.Help(cli.StyledHelpPrinter), ) + return dispatch(ctx) +} + +// dispatch runs the post-parse argument logic and returns the process exit code. +// It is split from run() so tests can drive it with a kong context built from an +// explicit argument slice, without kong.Parse reading the test binary's os.Args. +func dispatch(ctx *kong.Context) int { if CLI.Version { cli.PrintVersion(version) return 0 } + // --help and --version already exited above; a bare invocation with no audio + // file is a usage error, so print usage and exit non-zero. if CLI.AudioFile == "" { + cli.PrintError("audio file argument is required") _ = ctx.PrintUsage(false) - return 0 + return 1 } mode := detectMode(CLI.EpisodeMD) diff --git a/cmd/jive-encoder/main_test.go b/cmd/jive-encoder/main_test.go index d38f714..e06059d 100644 --- a/cmd/jive-encoder/main_test.go +++ b/cmd/jive-encoder/main_test.go @@ -1,12 +1,15 @@ package main import ( + "bytes" "os" "path/filepath" + "regexp" "strings" "testing" "github.com/alecthomas/kong" + "github.com/linuxmatters/jive-encoder/internal/cli" ) // TestSanitiseForFilename tests filename sanitisation for dangerous and special characters @@ -439,26 +442,29 @@ func BenchmarkGenerateFilename(b *testing.B) { } // TestFormatFlag verifies the --format Kong enum accepts the three supported -// formats, rejects unknown values at parse time, and defaults to mp3. +// formats, rejects unknown values at parse time, and defaults to mp3. It parses +// the real package-level CLI struct so the test guards the actual flag tag, not +// a copy of it. func TestFormatFlag(t *testing.T) { - type formatCLI struct { - Format string `enum:"mp3,opus,aac" default:"mp3"` - } + // CLI is package-level mutable state shared with other tests. Parsing applies + // defaults to it, so snapshot and restore it around the parse. + saved := CLI + defer func() { CLI = saved }() parse := func(args []string) (string, error) { - var c formatCLI - parser, err := kong.New(&c) + CLI = saved + parser, err := kong.New(&CLI) if err != nil { t.Fatalf("failed to build parser: %v", err) } if _, err := parser.Parse(args); err != nil { return "", err } - return c.Format, nil + return CLI.Format, nil } t.Run("opus accepted", func(t *testing.T) { - got, err := parse([]string{"--format", "opus"}) + got, err := parse([]string{"audio.flac", "--format", "opus"}) if err != nil { t.Fatalf("expected --format opus to parse, got error: %v", err) } @@ -467,14 +473,24 @@ func TestFormatFlag(t *testing.T) { } }) + t.Run("aac accepted", func(t *testing.T) { + got, err := parse([]string{"audio.flac", "--format", "aac"}) + if err != nil { + t.Fatalf("expected --format aac to parse, got error: %v", err) + } + if got != "aac" { + t.Fatalf("expected format aac, got %q", got) + } + }) + t.Run("flac rejected", func(t *testing.T) { - if _, err := parse([]string{"--format", "flac"}); err == nil { + if _, err := parse([]string{"audio.flac", "--format", "flac"}); err == nil { t.Fatal("expected --format flac to be rejected by the enum") } }) t.Run("defaults to mp3", func(t *testing.T) { - got, err := parse(nil) + got, err := parse([]string{"audio.flac"}) if err != nil { t.Fatalf("expected default invocation to parse, got error: %v", err) } @@ -483,3 +499,157 @@ func TestFormatFlag(t *testing.T) { } }) } + +// buildDispatchContext parses args against the real package-level CLI struct and +// returns a kong context for dispatch(). Writers go to buf and exit is stubbed so +// a parse error or usage print does not end the test. +func buildDispatchContext(t *testing.T, buf *bytes.Buffer, args []string) *kong.Context { + t.Helper() + parser, err := kong.New(&CLI, + kong.Name("jive-encoder"), + kong.Writers(buf, buf), + kong.Exit(func(int) {}), + ) + if err != nil { + t.Fatalf("failed to build parser: %v", err) + } + ctx, err := parser.Parse(args) + if err != nil { + t.Fatalf("failed to parse args %v: %v", args, err) + } + return ctx +} + +// TestDispatch covers the pure argument-dispatch branches of run() that return +// before any FFmpeg encoding starts. The encode path is exercised by the +// integration test, not here. +func TestDispatch(t *testing.T) { + // dispatch reads the package-level CLI, which parsing mutates, so snapshot + // and restore it around the whole test. + saved := CLI + defer func() { CLI = saved }() + + // A real, accessible audio file lets dispatch pass the stat check and reach + // mode-specific workflow validation without touching FFmpeg. + audioFile := filepath.Join(t.TempDir(), "audio.flac") + if err := os.WriteFile(audioFile, []byte("dummy"), 0o644); err != nil { + t.Fatalf("failed to create test audio file: %v", err) + } + + tests := []struct { + name string + args []string + wantCode int + }{ + { + name: "version short-circuit", + args: []string{"--version"}, + wantCode: 0, + }, + { + name: "missing audio file is a usage error", + args: nil, + wantCode: 1, + }, + { + name: "inaccessible audio file", + args: []string{filepath.Join(t.TempDir(), "does-not-exist.flac")}, + wantCode: 1, + }, + { + name: "standalone validation failure (no title)", + args: []string{audioFile}, + wantCode: 1, + }, + { + name: "hugo validation failure (missing markdown)", + args: []string{audioFile, filepath.Join(t.TempDir(), "missing.md")}, + wantCode: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + CLI = saved + var buf bytes.Buffer + ctx := buildDispatchContext(t, &buf, tt.args) + if got := dispatch(ctx); got != tt.wantCode { + t.Errorf("dispatch(%v) = %d; want %d", tt.args, got, tt.wantCode) + } + }) + } +} + +// TestUsageTextMatchesModel locks the hand-written usage examples in +// cli.StyledHelpPrinter to the real CLI Kong model. The usage lines hardcode +// flag and positional names; renaming a field on the CLI struct (or its Kong +// name tag) without updating the usage text would leave the help lying. This +// renders the help for the real model and fails if any flag or positional named +// in the usage examples no longer exists on the model. +func TestUsageTextMatchesModel(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + var out bytes.Buffer + parser, err := kong.New(&CLI, + kong.Name("jive-encoder"), + kong.Exit(func(int) {}), + kong.Writers(&out, &bytes.Buffer{}), + ) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + ctx, err := parser.Parse([]string{}) + if err != nil { + t.Fatalf("parser.Parse() error = %v", err) + } + + if err := cli.StyledHelpPrinter(kong.HelpOptions{}, ctx); err != nil { + t.Fatalf("StyledHelpPrinter() error = %v", err) + } + + // Build the sets of real flag and positional names from the Kong model. + flagNames := map[string]bool{} + for _, f := range ctx.Model.Flags { + flagNames[f.Name] = true + } + posNames := map[string]bool{} + for _, p := range ctx.Model.Positional { + posNames[p.Name] = true + } + + // Keep only the usage example lines (those invoking the program by name). + var usage []string + for line := range strings.SplitSeq(out.String(), "\n") { + if strings.Contains(line, "jive-encoder <") { + usage = append(usage, line) + } + } + if len(usage) != 2 { + t.Fatalf("expected 2 usage example lines, got %d: %q", len(usage), usage) + } + usageText := strings.Join(usage, "\n") + + // Every --flag named in the usage examples must exist on the model. + flagRe := regexp.MustCompile(`--([a-z-]+)`) + for _, m := range flagRe.FindAllStringSubmatch(usageText, -1) { + if !flagNames[m[1]] { + t.Errorf("usage text references --%s, which is not a flag on the CLI model; update the usage text or the flag", m[1]) + } + } + + // Every named in the usage examples must exist on the model. + posRe := regexp.MustCompile(`<([a-z-]+)>`) + for _, m := range posRe.FindAllStringSubmatch(usageText, -1) { + if !posNames[m[1]] { + t.Errorf("usage text references <%s>, which is not a positional on the CLI model; update the usage text or the argument", m[1]) + } + } + + // The finding names these three standalone flags; guard against dropping + // them from the usage line, not just renaming them. + for _, want := range []string{"title", "num", "cover"} { + if !strings.Contains(usageText, "--"+want) { + t.Errorf("standalone usage text no longer mentions --%s", want) + } + } +} diff --git a/cmd/jive-encoder/standalone.go b/cmd/jive-encoder/standalone.go index 60a073f..711faf4 100644 --- a/cmd/jive-encoder/standalone.go +++ b/cmd/jive-encoder/standalone.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" @@ -17,11 +18,11 @@ type StandaloneWorkflow struct { // Validate checks standalone-specific arguments and file existence. func (s *StandaloneWorkflow) Validate() error { if s.opts.Title == "" { - return fmt.Errorf("standalone mode requires --title flag") + return errors.New("standalone mode requires --title flag") } if s.opts.Num == "" { - return fmt.Errorf("standalone mode requires --num flag (episode number)") + return errors.New("standalone mode requires --num flag (episode number)") } if _, err := encoder.ParseEpisodeNumber(s.opts.Num); err != nil { @@ -29,7 +30,7 @@ func (s *StandaloneWorkflow) Validate() error { } if s.opts.Cover == "" { - return fmt.Errorf("standalone mode requires --cover flag (cover art path)") + return errors.New("standalone mode requires --cover flag (cover art path)") } if _, err := os.Stat(s.opts.Cover); err != nil { @@ -48,8 +49,11 @@ func (s *StandaloneWorkflow) CollectMetadata() (encoder.Metadata, string, error) Title: s.opts.Title, Artist: s.opts.Artist, Album: album, - Date: s.opts.Date, - Comment: s.opts.Comment, + // --date is passed through raw: the user types the exact tag string. + // Hugo mode instead formats a parsed time.Time via FormatDateForID3 + // (see hugo.go), because its date comes from frontmatter as a timestamp. + Date: s.opts.Date, + Comment: s.opts.Comment, } return tags, s.opts.Cover, nil diff --git a/cmd/jive-encoder/workflow.go b/cmd/jive-encoder/workflow.go index ad8b8ef..7b18a18 100644 --- a/cmd/jive-encoder/workflow.go +++ b/cmd/jive-encoder/workflow.go @@ -34,7 +34,7 @@ func resolveAlbum(album, artist string) string { // printPodcastStats displays the common podcast statistics shared by every workflow. func printPodcastStats(stats *encoder.FileStats) { - fmt.Println("\nPodcast statistics:") + cli.PrintInfo("Podcast statistics:") cli.PrintLabelValue("â€ĸ podcast_duration:", stats.DurationString) cli.PrintLabelValue("â€ĸ podcast_bytes:", fmt.Sprintf("%d", stats.FileSizeBytes)) } diff --git a/internal/artwork/artwork.go b/internal/artwork/artwork.go index 40271ee..66f78a4 100644 --- a/internal/artwork/artwork.go +++ b/internal/artwork/artwork.go @@ -46,6 +46,8 @@ func ScaleCoverArt(inputPath string) ([]byte, error) { var targetSize int var needsScaling bool + // No lower sanity bound is deliberate: any square smaller than 1400 upscales + // to 1400, so even a 1x1 input is accepted and blown up to spec. switch { case width < 1400: targetSize = 1400 @@ -64,13 +66,22 @@ func ScaleCoverArt(inputPath string) ([]byte, error) { if needsScaling { dst := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize)) - // Bilinear matches the scaler used by Jivefire thumbnail generation. - draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) + // Cover art uses CatmullRom, not the bilinear scaler of Jivefire + // thumbnails: cover art is not a thumbnail and downscales well beyond + // 2x (3000 from 5000 or 10000 px), where bilinear undersamples and + // softens. CatmullRom stays sharper at negligible cost for a + // once-per-encode resize. draw.Src writes the resized pixels straight + // into the fresh destination, the cheaper choice for a full-frame resize. + draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil) src = dst } // Normalise every re-encoded path to PNG for a consistent attached-picture payload. + // Known limitation: re-encoding does not preserve EXIF orientation (Go's + // image/jpeg ignores it) or ICC colour profiles (dropped on decode). Podcast + // cover art is expected to be square sRGB PNG with no orientation metadata, + // so this trade-off does not affect the intended inputs. var buf bytes.Buffer err = png.Encode(&buf, src) diff --git a/internal/artwork/artwork_test.go b/internal/artwork/artwork_test.go index 0bd7824..4da326c 100644 --- a/internal/artwork/artwork_test.go +++ b/internal/artwork/artwork_test.go @@ -4,6 +4,7 @@ import ( "bytes" "image" "image/color" + "image/gif" "image/jpeg" "image/png" "io" @@ -376,6 +377,36 @@ func TestScaleCoverArt_OutOfSpecJPEG(t *testing.T) { } } +// TestScaleCoverArt_OutOfSpecGIF tests that an undersized GIF decodes, scales up +// and re-encodes to PNG +func TestScaleCoverArt_OutOfSpecGIF(t *testing.T) { + tmpDir := t.TempDir() + testImagePath := filepath.Join(tmpDir, "test.gif") + + if err := createTestGIF(testImagePath, 800, 800); err != nil { + t.Fatalf("Failed to create test GIF: %v", err) + } + + scaledData, err := ScaleCoverArt(testImagePath) + if err != nil { + t.Fatalf("ScaleCoverArt failed: %v", err) + } + + if !bytes.HasPrefix(scaledData, pngMagic) { + t.Errorf("Expected output to start with PNG magic header, got % x", scaledData[:min(len(scaledData), 8)]) + } + + decodedImg, err := png.Decode(bytes.NewReader(scaledData)) + if err != nil { + t.Fatalf("Failed to decode scaled image: %v", err) + } + + bounds := decodedImg.Bounds() + if bounds.Dx() != 1400 || bounds.Dy() != 1400 { + t.Errorf("Expected 1400x1400, got %dx%d", bounds.Dx(), bounds.Dy()) + } +} + // TestScaleCoverArt_EdgeCases tests edge case sizes func TestScaleCoverArt_EdgeCases(t *testing.T) { tests := []struct { @@ -460,9 +491,11 @@ func TestScaleCoverArt_OutputDataSize(t *testing.T) { t.Errorf("Output data too small: %d bytes (expected > 1KB)", len(scaledData)) } - // Verify it's not excessively large (should be reasonable for a 1400x1400 PNG) - if len(scaledData) > 50*1024*1024 { - t.Errorf("Output data too large: %d bytes", len(scaledData)) + // A PNG of this content cannot exceed its uncompressed RGBA pixel data, so + // 1400x1400x4 bytes is a tight upper bound that still catches a gross regression. + maxSize := 1400 * 1400 * 4 + if len(scaledData) > maxSize { + t.Errorf("Output data too large: %d bytes (expected <= %d)", len(scaledData), maxSize) } } @@ -519,6 +552,13 @@ func createTestJPEG(path string, width, height int) error { }) } +// Helper function to create test GIF images +func createTestGIF(path string, width, height int) error { + return createTestImage(path, width, height, func(w io.Writer, img image.Image) error { + return gif.Encode(w, img, nil) + }) +} + func createTestImage(path string, width, height int, encode func(io.Writer, image.Image) error) error { img := image.NewRGBA(image.Rect(0, 0, width, height)) diff --git a/internal/cli/colours.go b/internal/cli/colours.go index c47c503..27441b8 100644 --- a/internal/cli/colours.go +++ b/internal/cli/colours.go @@ -12,9 +12,10 @@ var ( TextColor = lipgloss.Color("#FFFFFF") // White ErrorColor = lipgloss.Color("#DA70D6") // Orchid SecondaryColor = lipgloss.Color("#9370DB") // Medium purple - BorderColor = lipgloss.Color("#00BFFF") // Deep sky blue + WarningColor = lipgloss.Color("#FFA500") // Orange + BorderColor = PrimaryColor // Deep sky blue // Gradient colours. GradientIndigo = lipgloss.Color("#4B0082") // Deep indigo - GradientWhite = lipgloss.Color("#E0E0E0") // Light grey + GradientWhite = HighlightColor // Light grey ) diff --git a/internal/cli/help.go b/internal/cli/help.go index ca7089f..d3f581f 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -8,7 +8,7 @@ import ( "github.com/alecthomas/kong" ) -// Custom help styles using shared disco ball palette đŸĒŠ +// Custom help styles using shared palette đŸ—œī¸ var ( helpModeStyle = lipgloss.NewStyle(). Bold(true). @@ -40,6 +40,10 @@ var ( // StyledHelpPrinter is a kong.HelpPrinter that builds the help text with // Lipgloss styling, then writes it through a colour-profile writer so colour // degrades for non-TTY output. +// +// The options parameter is intentionally unused: the printer renders a fixed +// custom layout, so its Compact, Summary and Tree fields have no effect. The +// parameter stays only because Kong's HelpPrinter type requires this signature. func StyledHelpPrinter(options kong.HelpOptions, ctx *kong.Context) error { var sb strings.Builder @@ -102,8 +106,8 @@ func StyledHelpPrinter(options kong.HelpOptions, ctx *kong.Context) error { sb.WriteString("\n") // Degrade colour for non-TTY output, honouring NO_COLOR and TERM w := newColourWriter(ctx.Stdout) - fmt.Fprint(w, sb.String()) - return nil + _, err := fmt.Fprint(w, sb.String()) + return err } // argument represents a CLI argument @@ -146,6 +150,9 @@ func getFlags(ctx *kong.Context) []flag { if f.Name == "help" { continue // Already added above. } + if f.Hidden { + continue // Hidden flags are omitted from help output. + } var flagStr string if f.Short != 0 { diff --git a/internal/cli/help_golden_test.go b/internal/cli/help_golden_test.go new file mode 100644 index 0000000..32f0e77 --- /dev/null +++ b/internal/cli/help_golden_test.go @@ -0,0 +1,102 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/alecthomas/kong" +) + +// posFixture is a CLI with positional arguments, exercising the +// Model.Positional/Summary() path in getArguments and StyledHelpPrinter. +type posFixture struct { + Audio string `arg:"" optional:"" help:"Audio file to encode."` + Episode string `arg:"" optional:"" help:"Episode markdown file."` + Format string `help:"Output format." default:"mp3"` +} + +// newPositionalContext builds a kong context whose stdout is buf, so the +// styled help output can be captured. NO_COLOR keeps the output plain. +func newPositionalContext(t *testing.T, buf *bytes.Buffer) *kong.Context { + t.Helper() + t.Setenv("NO_COLOR", "1") + + var fixture posFixture + parser, err := kong.New(&fixture, + kong.Name("jive-encoder"), + kong.Description("Encode podcast audio."), + kong.Exit(func(int) {}), + kong.Writers(buf, &bytes.Buffer{}), + ) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + + ctx, err := parser.Parse([]string{}) + if err != nil { + t.Fatalf("parser.Parse() error = %v", err) + } + return ctx +} + +func TestGetArgumentsPositional(t *testing.T) { + args := getArguments(newPositionalContext(t, &bytes.Buffer{})) + + want := []argument{ + {name: "[