Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 0 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: "26"
- uses: AnimMouse/setup-ffmpeg@v1
with:
version: "9.0"
- uses: moonrepo/setup-toolchain@v0
with:
moon-version: "2.5.5"
Expand Down
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
Official command-line client for publishing and managing podcasts on
[Listenbox](https://listenbox.app).

The CLI uses Listenbox's public API. YouTube imports use [kkdai/youtube](https://github.com/kkdai/youtube) to download on your computer and bundled FFmpeg 9+ to prepare media before uploading it directly to Listenbox's media storage.
The CLI uses Listenbox's public API. YouTube imports use [kkdai/youtube](https://github.com/kkdai/youtube) to download on your computer and statically linked FFmpeg 9.0.2 through [go-astiav](https://github.com/asticode/go-astiav) to prepare media before uploading it directly to Listenbox's media storage.

## Install

Build a native distribution with Go 1.27+, Node.js 26, Moon, and FFmpeg/FFprobe 9+ installed:
Build a native distribution with Go 1.27+, Node.js 26, Moon, a C compiler, Make, pkg-config, and tar installed:

```sh
moon run package
moon run cli:package
tar -xzf dist/listenbox.tar.gz -C /your/bin/directory
```

Keep `listenbox`, `ffmpeg`, `ffprobe`, and the license notices together. The bundle uses the build machine's native media binaries; Linux builds require compatible system C libraries. Media processing resolves these bundled executables directly.
The package contains one executable and its license notices. FFmpeg is built from a checksum-verified source archive, then linked into the CLI through CGO. No FFmpeg/FFprobe installation or media subprocess is needed at runtime. Linux builds still use the system C library. `prepare-ffmpeg.ts` records the exact codecs and build options; the additional avdevice, avfilter, and swscale libraries satisfy go-astiav's binding requirements.

Make sure the installation directory is on `PATH`, then authorize the CLI:

Expand All @@ -38,14 +38,13 @@ Generated API client and configuration files are committed, so a fresh clone
builds without another repository or a code-generation step.

```sh
go build ./cmd/listenbox
go test -tags=dev ./...
moon run cli:build cli:test
```

[Moon](https://moonrepo.dev) runs the complete project check:

```sh
moon run check
moon run cli:check
```

CI follows [Moon's CI guide](https://moonrepo.dev/docs/guides/ci): full Git
Expand Down
32 changes: 0 additions & 32 deletions bundle-media.ts

This file was deleted.

4 changes: 4 additions & 0 deletions ffmpeg-env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Source from the project root before compiling packages that use go-astiav.
export CGO_ENABLED=1
export PKG_CONFIG_PATH="$PWD/.cache/ffmpeg/lib/pkgconfig"
export CGO_LDFLAGS="$(pkg-config --static --libs libavcodec libavdevice libavfilter libavformat libswresample libswscale libavutil)"
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.27.0

require (
github.com/Oudwins/zog v0.23.0
github.com/asticode/go-astiav v0.43.0
github.com/kkdai/youtube/v2 v2.10.6
github.com/knadh/koanf/parsers/yaml v1.1.1
github.com/knadh/koanf/providers/file v1.2.1
Expand All @@ -15,6 +16,7 @@ require (
)

require (
github.com/asticode/go-astikit v0.42.0 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Oudwins/zog v0.23.0 h1:aNjOcy7mE5NWOQVR9sFO1L0YCKfEjWMlaUTNquKGRQY=
github.com/Oudwins/zog v0.23.0/go.mod h1:yznncrIHC5tQeOui28FMz67LEGZV3nAoCdWL5t8QsNM=
github.com/asticode/go-astiav v0.43.0 h1:Pn+lQSdDs9ifSh5KcmesyUAjCC0/qJwAWFa1TYnGxBw=
github.com/asticode/go-astiav v0.43.0/go.mod h1:GI0pHw6K2/pl/o8upCtT49P/q4KCwhv/8nGLlCsZLdA=
github.com/asticode/go-astikit v0.42.0 h1:pnir/2KLUSr0527Tv908iAH6EGYYrYta132vvjXsH5w=
github.com/asticode/go-astikit v0.42.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand Down
279 changes: 279 additions & 0 deletions internal/cli/media.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
package cli

import (
"context"
"errors"
"fmt"
"path/filepath"
"time"

"github.com/asticode/go-astiav"
)

const (
mediaFastStart = "+faststart"
mediaMovFlags = "movflags"
)

// A mediaInput owns its demuxer and a single lookahead packet. Only downloaded
// local files enter FFmpeg; HTTP and cancellation remain owned by the importer.
type mediaInput struct {
format *astiav.FormatContext
stream *astiav.Stream
packet *astiav.Packet
ready bool
}

func openMediaInput(path string, kind astiav.MediaType) (*mediaInput, error) {
input := &mediaInput{format: astiav.AllocFormatContext(), packet: astiav.AllocPacket()}
if input.format == nil || input.packet == nil {
input.close()
return nil, errors.New("allocate native media input")
}
if err := input.format.OpenInput(path, nil, nil); err != nil {
input.close()
return nil, fmt.Errorf("open media %q: %w", path, err)
}
if err := input.format.FindStreamInfo(nil); err != nil {
input.close()
return nil, fmt.Errorf("inspect media %q: %w", path, err)
}
for _, stream := range input.format.Streams() {
if stream.CodecParameters().MediaType() == kind {
input.stream = stream
return input, nil
}
}
input.close()
return nil, fmt.Errorf("media %q has no %s stream", path, kind)
}

func (input *mediaInput) close() {
if input.packet != nil {
input.packet.Free()
}
if input.format != nil {
input.format.CloseInput()
input.format.Free()
}
}

func (input *mediaInput) next(ctx context.Context) error {
input.ready = false
input.packet.Unref()
for {
if err := ctx.Err(); err != nil {
return err
}
if err := input.format.ReadFrame(input.packet); err != nil {
if errors.Is(err, astiav.ErrEof) {
return nil
}
return fmt.Errorf("read media packet: %w", err)
}
if input.packet.StreamIndex() == input.stream.Index() {
input.ready = true
return nil
}
input.packet.Unref()
}
}

func (input *mediaInput) timestamp() int64 {
timestamp := input.packet.Dts()
if timestamp == astiav.NoPtsValue {
timestamp = input.packet.Pts()
}
if timestamp == astiav.NoPtsValue {
timestamp = 0
}
return astiav.RescaleQ(timestamp, input.stream.TimeBase(), astiav.NewRational(1, int(time.Second/time.Microsecond)))
}

type mediaOutput struct {
format *astiav.FormatContext
io *astiav.IOContext
}

func openMediaOutput(path string) (*mediaOutput, error) {
format, err := astiav.AllocOutputFormatContext(nil, "", path)
if err != nil {
return nil, fmt.Errorf("allocate output %q: %w", path, err)
}
output := &mediaOutput{format: format}
if !format.OutputFormat().Flags().Has(astiav.IOFormatFlagNofile) {
output.io, err = astiav.OpenIOContext(path, astiav.NewIOContextFlags(astiav.IOContextFlagWrite), nil, nil)
if err != nil {
format.Free()
return nil, fmt.Errorf("open output %q: %w", path, err)
}
format.SetPb(output.io)
}
return output, nil
}

func (output *mediaOutput) close() error {
var err error
if output.io != nil {
err = output.io.Close()
}
output.format.Free()
return err
}

func (output *mediaOutput) copyStream(input *mediaInput) (*astiav.Stream, error) {
stream := output.format.NewStream(nil)
if stream == nil {
return nil, errors.New("allocate output stream")
}
if err := input.stream.CodecParameters().Copy(stream.CodecParameters()); err != nil {
return nil, err
}
stream.CodecParameters().SetCodecTag(0)
stream.SetTimeBase(input.stream.TimeBase())
return stream, nil
}

func (output *mediaOutput) header(options map[string]string) error {
dictionary := astiav.NewDictionary()
defer dictionary.Free()
for key, value := range options {
if err := dictionary.Set(key, value, 0); err != nil {
return err
}
}
if err := output.format.WriteHeader(dictionary); err != nil {
return fmt.Errorf("write media header: %w", err)
}
return nil
}

func (output *mediaOutput) write(input *mediaInput, stream *astiav.Stream) error {
input.packet.RescaleTs(input.stream.TimeBase(), stream.TimeBase())
input.packet.SetStreamIndex(stream.Index())
input.packet.SetPos(-1)
if err := output.format.WriteInterleavedFrame(input.packet); err != nil {
return fmt.Errorf("write media packet: %w", err)
}
return nil
}

func muxMedia(ctx context.Context, videoPath, audioPath, destination string) (result error) {
video, err := openMediaInput(videoPath, astiav.MediaTypeVideo)
if err != nil {
return err
}
defer video.close()
audio, err := openMediaInput(audioPath, astiav.MediaTypeAudio)
if err != nil {
return err
}
defer audio.close()
output, err := openMediaOutput(destination)
if err != nil {
return err
}
defer func() { result = errors.Join(result, output.close()) }()
videoStream, err := output.copyStream(video)
if err != nil {
return err
}
audioStream, err := output.copyStream(audio)
if err != nil {
return err
}
if err := output.header(map[string]string{mediaMovFlags: mediaFastStart}); err != nil {
return err
}
if err := video.next(ctx); err != nil {
return err
}
if err := audio.next(ctx); err != nil {
return err
}
if !video.ready || !audio.ready {
return errors.New("downloaded media stream is empty")
}
return interleaveMedia(ctx, output, video, audio, videoStream, audioStream)
}

func interleaveMedia(
ctx context.Context, output *mediaOutput, video, audio *mediaInput,
videoStream, audioStream *astiav.Stream,
) error {
// Reading both inputs in timestamp order keeps muxer buffering bounded even
// for long recordings. Never enqueue the entire video before the audio.
for video.ready || audio.ready {
input, stream := audio, audioStream
if video.ready && (!audio.ready || video.timestamp() <= audio.timestamp()) {
input, stream = video, videoStream
}
if err := output.write(input, stream); err != nil {
return err
}
if err := input.next(ctx); err != nil {
return err
}
}
return output.format.WriteTrailer()
}

func remuxMedia(
ctx context.Context, source, destination string, kind astiav.MediaType, options map[string]string,
) (result error) {
input, err := openMediaInput(source, kind)
if err != nil {
return err
}
defer input.close()
output, err := openMediaOutput(destination)
if err != nil {
return err
}
defer func() { result = errors.Join(result, output.close()) }()
stream, err := output.copyStream(input)
if err != nil {
return err
}
if err := output.header(options); err != nil {
return err
}
count := 0
for {
if err := input.next(ctx); err != nil {
return err
}
if !input.ready {
break
}
if err := output.write(input, stream); err != nil {
return err
}
count++
}
if count == 0 {
return errors.New("downloaded media stream is empty")
}
return output.format.WriteTrailer()
}

func mediaDimensions(source string) (int, int, error) {
input, err := openMediaInput(source, astiav.MediaTypeVideo)
if err != nil {
return 0, 0, err
}
defer input.close()
parameters := input.stream.CodecParameters()
width, height := parameters.Width(), parameters.Height()
if width <= 0 || height <= 0 {
return 0, 0, fmt.Errorf("invalid video dimensions %dx%d", width, height)
}
return width, height, nil
}

func packageMediaTrack(ctx context.Context, source, root string, kind astiav.MediaType) error {
return remuxMedia(ctx, source, filepath.Join(root, "index.m3u8"), kind, map[string]string{
"hls_time": "6", "hls_playlist_type": "vod", "hls_segment_type": "fmp4",
"hls_fmp4_init_filename": "init.mp4", "hls_segment_filename": filepath.Join(root, "segment-%05d.m4s"),
})
}
Loading