Skip to content

Commit

Permalink
Log In with Legacy API and use FFmpeg instead of gstreamer
Browse files Browse the repository at this point in the history
Pandora appears to have added some new required parameters to the
REST login payload that appear random/encrypted. Fortunately, the
tokens returned by the Legacy JSONv5 API are valid for the REST
API. As a workaround, we use the Legacy JSONv5 API to perform a
Partner Login as an android device and then a User Login to get
a token. Every request after this uses the REST API.

Additionally, swap out gstreamer for FFmpeg+beep. We pipe tracks
to FFmpeg and write them to a temporary file, then use beep to
play the file. github.com/faiface/beep/wav refuses to play  files
with garbage chunk lengths (which FFmpeg sets if you stream to
stdout) so we have to transcode the whole file first.

These two changes are combined because my gstreamer/pkg-config
install appears to be broken so I couldn't test them independently.

Fixes #24
Obsoletes #10, #16, and #23
Partially Addresses #8 and #9
  • Loading branch information
nlowe committed Apr 4, 2021
1 parent 7e6e721 commit 01ba342
Show file tree
Hide file tree
Showing 12 changed files with 618 additions and 305 deletions.
26 changes: 0 additions & 26 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,6 @@ jobs:
- name: Checkout
uses: actions/checkout@master

- name: Install Dev Dependencies
env:
GSTREAMER_1_0_ROOT_X86_64: C:\gstreamer\1.0\x86_64\
shell: bash
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
choco install -y --no-progress pkgconfiglite gstreamer
echo "Downloading gstreamer-1.0-devel"
curl.exe -fsSL "https://gstreamer.freedesktop.org/data/pkg/windows/1.16.2/gstreamer-1.0-devel-msvc-x86_64-1.16.2.msi" -o gstreamer-1.0-devel.msi
echo "Installing gstreamer-1.0-devel"
# Yes, this hack is required. Invoking msiexec from git bash on windows
# (which is how GH Actions runs bash on windows) seems to hang forever
pwsh.exe -command "start-process msiexec.exe -ArgumentList '/i gstreamer-1.0-devel.msi /qn /norestart /L*vx! c:/gst.log' -wait -NoNewWindow"
echo "Updating Environment"
echo "::add-path::C:\\gstreamer\\1.0\\x86_64\\bin"
echo "::set-env name=GSTREAMER_1_0_ROOT_X86_64::${GSTREAMER_1_0_ROOT_X86_64}"
echo "::set-env name=PKG_CONFIG_PATH::${GSTREAMER_1_0_ROOT_X86_64}lib\\pkgconfig"
elif [ "$RUNNER_OS" == "Linux" ]; then
sudo apt-get update && DEBIAN_FRONTEND=noninteractive sudo apt-get install -yq gstreamer-plugins-base1.0 libgstreamer1.0-dev
elif [ "$RUNNER_OS" == "macOS" ]; then
brew install pkg-config gstreamer gst-plugins-base
else
echo "Don't know how to build for $RUNNER_OS"
exit 1
fi
- name: Set up Go
uses: actions/setup-go@v1
with:
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ A command-line pandora client. Inspired by [PromyLOPh/pianobar](https://github.c

Right now you have to build from source. See [Building](#building).

Mousiki relies on gstreamer-1.0 for audio playback. Install it from your package manager or from
https://gstreamer.freedesktop.org/download/.
Mousiki relies on FFmpeg for audio transcoding. Install it from your package manager or from
https://ffmpeg.org/download.html.

## Usage

Expand Down Expand Up @@ -49,12 +49,12 @@ In no particular order:

Maybe some day:

* Pure-GO AAC Decoder to drop the CGO Dependency on gstreamer (or figure out how to get pandora to send us MP3 streams)
* FFmpeg streaming. Right now we have to transcode the entire track before `github.com/faiface/beep/wav` will even consider playing it
* OSC / HTTP API for controlling playback / running a playback server / writing custom frontends

## Building

You need Go 1.12+ or vgo for Go Modules support. You also need a C compiler that works with CGO and gstreamer-1.0-devel.
You need Go 1.12+ or vgo for Go Modules support.

See [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml) for a rough idea of what to install.

Expand Down
7 changes: 0 additions & 7 deletions audio/convert.go

This file was deleted.

257 changes: 257 additions & 0 deletions audio/ffmpeg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
package audio

import (
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/exec"
"time"

"github.com/faiface/beep"
"github.com/faiface/beep/effects"
"github.com/faiface/beep/speaker"
"github.com/faiface/beep/wav"
"github.com/sirupsen/logrus"
"go.uber.org/multierr"
)

const (
targetSampleRate beep.SampleRate = 41000
resampleQuality = 3
)

var ffmpegArgs = []string{
"-y", // Yes to All
"-hide_banner", "-loglevel", "panic", // Be Quiet
"-i", "pipe:0", // Input from stdin
"-c:a", "pcm_s16le", // PCM Signed 16-bit Little Endian output
"-f", "wav", // Output WAV
}

type beepFFmpegPlayer struct {
ffmpeg string

transcodedTrack *os.File
nowStreaming beep.StreamSeekCloser
streamingSampleRate beep.SampleRate
ctrl *beep.Ctrl

progressTicker *time.Ticker
progress chan PlaybackProgress
done chan error

log logrus.FieldLogger
}

// NewBeepFFmpegPipeline returns an audio.Player that transcodes tracks through FFmpeg
// via exec.Command to PCM and then plays audio via speaker.Play. Tracks must be fully
// transcoded first otherwise wav.Decode will refuse to play them. Because of this,
// UpdateStream will block until transcoding is complete.
func NewBeepFFmpegPipeline() (*beepFFmpegPlayer, error) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, fmt.Errorf("could not locate ffmpeg on $PATH: %w", err)
}

if err := speaker.Init(targetSampleRate, targetSampleRate.N(100*time.Millisecond)); err != nil {
return nil, fmt.Errorf("failed to init beep speaker: %w", err)
}

result := &beepFFmpegPlayer{
ffmpeg: ffmpeg,

ctrl: &beep.Ctrl{Paused: true},

progressTicker: time.NewTicker(1 * time.Second),
progress: make(chan PlaybackProgress, 1),
done: make(chan error, 1),

log: logrus.WithField("prefix", "ffmpeg"),
}

go func() {
for range result.progressTicker.C {
if result.nowStreaming != nil {
result.progress <- result.calculateProgress()
}
}
}()

return result, nil
}

func (b *beepFFmpegPlayer) cleanup() (err error) {
if b.transcodedTrack != nil {
err = multierr.Combine(
b.nowStreaming.Close(),
b.transcodedTrack.Close(),
os.Remove(b.transcodedTrack.Name()),
)

b.nowStreaming = nil
b.transcodedTrack = nil
}

return err
}

func (b *beepFFmpegPlayer) Close() error {
speaker.Lock()
defer speaker.Unlock()

speaker.Close()
b.progressTicker.Stop()
return b.cleanup()
}

func (b *beepFFmpegPlayer) UpdateStream(url string, volumeAdjustment float64) {
// Stop playing anything currently playing
speaker.Clear()
b.ctrl.Paused = true

// Clean up if we were previously playing something
_ = b.cleanup()

// Transcode to WAV
var err error
b.transcodedTrack, err = b.transcode(url)
if err != nil {
b.log.WithError(err).Errorf("Transcoding failed")
b.done <- err
return
}

// Decode
var format beep.Format
b.nowStreaming, format, err = wav.Decode(b.transcodedTrack)
if err != nil {
b.log.WithError(err).Errorf("Could not decode transcoded file")
b.done <- err
return
}

b.log.WithFields(logrus.Fields{
"sampleRate": format.SampleRate,
"channels": format.NumChannels,
"replayGain": volumeAdjustment,
}).Debug("Decoded track")

// Setup pipeline
b.streamingSampleRate = format.SampleRate
b.ctrl.Streamer = beep.Resample(resampleQuality, b.streamingSampleRate, targetSampleRate, &effects.Volume{
Base: 10,
Volume: volumeAdjustment / 10,
Streamer: b.nowStreaming,
})

// Reset progress
b.progressTicker.Reset(1 * time.Second)
b.progress <- b.calculateProgress()

// Play!
speaker.Play(beep.Seq(b.ctrl, beep.Callback(func() {
b.done <- nil
})))

b.ctrl.Paused = false
}

func (b *beepFFmpegPlayer) Play() {
b.log.WithFields(logrus.Fields{}).Trace("Asked to play")

speaker.Lock()
defer speaker.Unlock()

b.ctrl.Paused = false
}

func (b *beepFFmpegPlayer) Pause() {
b.log.WithFields(logrus.Fields{}).Trace("Asked to pause")

speaker.Lock()
defer speaker.Unlock()

b.ctrl.Paused = true
}

func (b *beepFFmpegPlayer) IsPlaying() bool {
speaker.Lock()
defer speaker.Unlock()

v := !b.ctrl.Paused
return v
}

func (b *beepFFmpegPlayer) ProgressChan() <-chan PlaybackProgress {
return b.progress
}

func (b *beepFFmpegPlayer) DoneChan() <-chan error {
return b.done
}

func (b *beepFFmpegPlayer) transcode(url string) (*os.File, error) {
b.log.WithField("track", url).Debug("Attempting to transcode track")

resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("transcode: failed to fetch track: %w", err)
}

defer func() {
_ = resp.Body.Close()
}()

tmp, err := ioutil.TempFile(os.TempDir(), "mousiki")
if err != nil {
return nil, fmt.Errorf("transcode: failed to create temp file: %w", err)
}
_ = tmp.Close()

b.log.WithField("file", tmp.Name()).Debug("Transcoding Track")

cmd := exec.Command(b.ffmpeg, append(ffmpegArgs, tmp.Name())...)

cmd.Stderr = os.Stderr
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("transcode: ffmpeg: failed to create stdin pipe: %w", err)
}

if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("transcode: ffmpeg: transcoding failed")
}

n, err := io.Copy(stdin, resp.Body)
if err != nil {
return nil, fmt.Errorf("transcode: ffmpeg: failed to transcode track: %w", err)
}

b.log.WithFields(logrus.Fields{
"file": tmp.Name(),
"len": n,
}).Debug("Transcoding complete")

if err := stdin.Close(); err != nil {
return nil, fmt.Errorf("transcode: ffmpeg: failed to close stdin: %w", err)
}

if err := cmd.Wait(); err != nil {
return nil, fmt.Errorf("transcode: ffmpeg: unknown transcoding error: %w", err)
}

return os.Open(tmp.Name())
}

func (b *beepFFmpegPlayer) calculateProgress() PlaybackProgress {
if b.nowStreaming == nil {
return PlaybackProgress{}
}

return PlaybackProgress{
Duration: b.streamingSampleRate.D(b.nowStreaming.Len()),
Progress: b.streamingSampleRate.D(b.nowStreaming.Position()),
}
}
Loading

0 comments on commit 01ba342

Please sign in to comment.