Initial commit

This commit is contained in:
r4 2021-06-03 19:07:27 +02:00
parent 308f0429b3
commit 2be4bf1a5e
8 changed files with 662 additions and 2 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) <year> <copyright holders>
Copyright (c) 2021 r4 <r4@nobrain.org>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@ -2,4 +2,16 @@
A program that extracts the individual tracks from an Ogg/Vorbis http radio stream (without any loss in quality). Written in go without any non-standard dependencies.
MP3 support is planned but not yet implemented.
MP3 support is planned but not yet implemented.
## Building
- Install [go](https://golang.org/) (preferably a recent version)
- `go build`
## Usage
- General usage: `./rsr [-dir <OUTPUT_DIRECTORY>] <RADIO_STREAM_URL>`
- see `./rsr -h` for integrated usage documentation

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module rsr
go 1.16

167
main.go Normal file
View File

@ -0,0 +1,167 @@
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path"
"strconv"
"rsr/util"
"rsr/vorbis"
)
var (
colRed = "\033[31m"
colYellow = "\033[33m"
colReset = "\033[m"
)
func usage(arg0 string, exitStatus int) {
fmt.Fprintln(os.Stderr, `Usage:
` + arg0 + ` [options...] <STREAM_URL>
Options:
-dir <DIRECTORY> -- Output directory (default: ".").
Output types:
* <INFO>
` + colYellow + `! <WARNING>` + colReset + `
` + colRed + `! <ERROR>` + colReset)
os.Exit(exitStatus)
}
func printInfo(f string, v ...interface{}) {
fmt.Printf("* " + f + "\n", v...)
}
func printWarn(f string, v ...interface{}) {
fmt.Fprintf(os.Stderr, colYellow + "! " + f + colReset + "\n", v...)
}
func printNonFatalErr(f string, v ...interface{}) {
fmt.Fprintf(os.Stderr, colRed + "! " + f + colReset + "\n", v...)
}
func printErr(f string, v ...interface{}) {
printNonFatalErr(f, v...)
os.Exit(1)
}
func main() {
var url string
dir := "."
if len(os.Args) < 2 {
usage(os.Args[0], 1)
}
// Parse every arg except for the last one, since that will always be our
// stream URL.
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
if len(arg) >= 1 && arg[0] == '-' {
switch(arg) {
case "-dir":
i++
if i >= len(os.Args) {
printErr("Expected string after flag '%v'", arg)
}
dir = os.Args[i]
case "--help":
usage(os.Args[0], 0)
case "-h":
usage(os.Args[0], 0)
default:
printErr("Unknown flag: '%v'", arg)
}
} else {
if url == "" {
url = arg
} else {
printErr("Expected flag, but got '%v'", arg)
}
}
}
if url == "" {
printInfo("Please specify a stream URL")
os.Exit(1)
}
printInfo("URL: %v", url)
printInfo("Output directory: %v", dir)
resp, err := http.Get(url)
if err != nil {
printErr("HTTP error: %v", err)
}
defer resp.Body.Close()
contentType := resp.Header.Get("content-type")
if contentType != "application/ogg" {
printErr("Expected content type 'application/ogg', but got: '%v'", contentType)
}
waitReader := util.NewWaitReader(resp.Body)
// The first track is always discarded, as it is always going to be
// incomplete.
discard := true
printErrWhileRecording := func(f string, v ...interface{}) {
printNonFatalErr(f, v...)
printWarn("Unable to download track, skipping.")
discard = true
}
for {
var raw bytes.Buffer
r := io.TeeReader(waitReader, &raw)
d := vorbis.NewDecoder(r)
md, checksum, err := d.ReadMetadata()
if err != nil {
printErrWhileRecording("Error reading metadata: %v", err)
continue
}
var base string // File name without path or extension.
artist, artistOk := md.FieldByName("Artist")
title, titleOk := md.FieldByName("Title")
if artistOk || titleOk {
base = artist + " -- " + title
} else {
base = "Unknown_" + strconv.FormatInt(int64(checksum), 10)
}
if discard {
printInfo("Going to discard incomplete track: %v", base)
} else {
printInfo("Recording track: %v", base)
}
filename := path.Join(dir, base+".ogg")
err = d.ReadRest()
if err != nil {
printErrWhileRecording("Error reading stream: %v", err)
continue
}
if !discard {
err := os.WriteFile(filename, raw.Bytes(), 0666)
if err != nil {
printErrWhileRecording("Error reading stream: %v", err)
continue
}
printInfo("Saved track as: %v", filename)
}
discard = false
}
}

36
util/io.go Normal file
View File

@ -0,0 +1,36 @@
package util
import (
"io"
)
// A reader that waits for any unavailable bytes to become available for
// reading. This means that it will always wait until it can fill the entire
// `[]byte` when `Read()` is called, as long as no error occurs.
type WaitReader struct {
r io.Reader
}
func NewWaitReader(r io.Reader) WaitReader {
return WaitReader{
r: r,
}
}
func (r WaitReader) Read(p []byte) (int, error) {
var n int // Total number of bytes read.
for {
// Reattempt to read the unread bytes until we can fill `p` completely
// (or an error occurs).
nNew, err := r.r.Read(p[n:len(p)])
n += nNew
if err != nil {
return n, err
}
// Break when we have read enough bytes.
if n == len(p) {
break
}
}
return n, nil
}

144
vorbis/metadata.go Normal file
View File

@ -0,0 +1,144 @@
// Minimal 'Vorbis comment' metadata reader oriented around
// https://xiph.org/vorbis/doc/Vorbis_I_spec.html and its reference
// implementation written in C.
package vorbis
import (
"encoding/binary"
"errors"
"io"
"strings"
)
var (
ErrVorbisHeaderType = errors.New("vorbis: header not Vorbis")
ErrVorbisInvalidCommentFormat = errors.New("vorbis: invalid Vorbis comment")
)
type VorbisCommentField struct {
// According to the spec, the key capitalization doesn't matter, which is
// why we're using uppercase letters only in this implementation
// (see `VorbisCommentDecode()`).
Key string
// The value has some restrictions for what characters it can contain, but
// we're ignoring them for now.
Val string
}
// Track metadata represented by a Vorbis comment. The fields should be
// self-explanatory.
type VorbisComment struct {
Vendor string
Fields []VorbisCommentField
}
// Attempts to decode a Vorbis comment, leaving the reader `r` right past the
// data it decoded.
func VorbisCommentDecode(r io.Reader) (VorbisComment, error) {
var ret VorbisComment
// In Vorbis comment, strings are always preceded by a 32-bit length
// specifier.
getNextString := func() ([]byte, error) {
var sz uint32
err := binary.Read(r, binary.LittleEndian, &sz)
if err != nil {
return nil, err
}
content := make([]byte, sz)
_, err = r.Read(content)
if err != nil {
return nil, err
}
return content, nil
}
content, err := getNextString()
if err != nil {
return ret, err
}
ret.Vendor = string(content)
var numCommentFields uint32
err = binary.Read(r, binary.LittleEndian, &numCommentFields)
if err != nil {
return ret, err
}
for i := 0; i < int(numCommentFields); i++ {
content, err := getNextString()
if err != nil {
return ret, err
}
splits := strings.Split(string(content), "=")
if len(splits) != 2 {
return ret, ErrVorbisInvalidCommentFormat
}
var newField VorbisCommentField
newField.Key = strings.ToUpper(splits[0])
newField.Val = splits[1]
ret.Fields = append(ret.Fields, newField)
}
return ret, nil
}
// Field names are searched case insensitively, as specified in the spec.
// `found` is set to false if the field doesn't exist.
func (c *VorbisComment) FieldByName(name string) (val string, found bool) {
// All field names are stored as upper case strings in this implementation.
// That is why we only need to transform the search query string to
// uppercase.
upperName := strings.ToUpper(name)
// Linearly search through field names.
for _, v := range c.Fields {
if v.Key == upperName {
return v.Val, true
}
}
return "", false
}
var (
PackTypeInfo = uint8(0x1)
PackTypeComment = uint8(0x3) // Comment is the only one we really care about here.
PackTypeBooks = uint8(0x5)
)
type VorbisHeader struct {
PackType uint8
Comment *VorbisComment
}
func VorbisHeaderDecode(r io.Reader) (VorbisHeader, error) {
var ret VorbisHeader
err := binary.Read(r, binary.LittleEndian, &ret.PackType)
if err != nil {
return ret, err
}
switch ret.PackType {
case PackTypeComment:
buf := make([]byte, 6)
_, err = r.Read(buf)
if err != nil {
return ret, err
}
if string(buf) != "vorbis" {
return ret, ErrVorbisHeaderType
}
comment, err := VorbisCommentDecode(r)
if err != nil {
return ret, err
}
ret.Comment = &comment
}
return ret, nil
}

202
vorbis/ogg.go Normal file
View File

@ -0,0 +1,202 @@
// Minimal Ogg decoder according to rfc3533
// (https://www.xiph.org/ogg/doc/rfc3533.txt). Whenever 'the spec' is mentioned,
// it is referring to rfc3533.
package vorbis
import (
"bytes"
"encoding/binary"
"errors"
"io"
)
var (
ErrOggInvalidMagicNumber = errors.New("ogg: invalid magic number")
ErrOggInvalidChecksum = errors.New("ogg: invalid checksum")
)
const (
headerSize = 27
// Uncombined segments are meant here (see Page.Segments or the spec for
// more details about segments).
maxSegments = 255
maxSegmentSize = 255
maxPageSize = headerSize + maxSegments + maxSegments*maxSegmentSize
)
// Using polynomial 0x04c11db7 (see the spec).
var crcLookup = [256]uint32{
0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9,
0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005,
0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61,
0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd,
0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9,
0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75,
0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011,
0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd,
0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039,
0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5,
0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81,
0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d,
0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49,
0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95,
0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1,
0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d,
0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae,
0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072,
0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16,
0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca,
0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde,
0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02,
0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066,
0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba,
0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e,
0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692,
0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6,
0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a,
0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e,
0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2,
0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686,
0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a,
0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637,
0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb,
0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f,
0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53,
0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47,
0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b,
0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff,
0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623,
0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7,
0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b,
0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f,
0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3,
0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7,
0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b,
0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f,
0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3,
0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640,
0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c,
0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8,
0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24,
0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30,
0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec,
0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088,
0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654,
0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0,
0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c,
0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18,
0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4,
0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0,
0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c,
0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668,
0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4,
}
type crc32Writer struct {
sum uint32
}
func (w *crc32Writer) Write(p []byte) (n int, err error) {
for _, v := range p {
w.sum = (w.sum << 8) ^ crcLookup[uint8(w.sum>>24)^v]
}
return len(p), nil
}
var (
FHeaderTypeContinuation = uint8(0x1)
FHeaderTypeBOS = uint8(0x2) // Beginning of stream
FHeaderTypeEOS = uint8(0x4) // End of stream
)
// See the spec for more info on the individual fields.
type OggPageHeader struct {
MagicNumber [4]uint8
Version uint8
HeaderType uint8
GranulePosition uint64
BitstreamSerialNum uint32
PageSequenceNum uint32
Checksum uint32
NumSegments uint8 // Number of uncombined segments (see Page.Segments or the spec for more details).
}
type OggPage struct {
Header OggPageHeader
// These are the combined segments. The spec says that when a segment is
// said to have a length of 255, it is to be combined with the next segment,
// which is already done here.
Segments [][]byte
}
// Decodes the given raw Ogg page according to rfc3533
// (https://www.xiph.org/ogg/doc/rfc3533.txt).
// The given reader `r` must be set to start at the beginning of the page, i.e.
// at the magic number "OggS".
// Leaves the reader position at the beginning of the next page (or EOF if
// the decoded page was the last page).
func OggDecode(r io.Reader) (OggPage, error) {
var ret OggPage
checksum := &crc32Writer{}
// Read header into struct and into byte buffer.
var rawHeaderBuf bytes.Buffer
hdrR := io.TeeReader(r, &rawHeaderBuf)
err := binary.Read(hdrR, binary.LittleEndian, &ret.Header)
if err != nil {
return ret, err
}
// Validate magic number.
if string(ret.Header.MagicNumber[:]) != "OggS" {
return ret, ErrOggInvalidMagicNumber
}
// Get raw header bytes, then set the header checksum field to 0 before
// calculating the checksum (see the spec).
rawHeader, err := io.ReadAll(&rawHeaderBuf)
if err != nil {
return ret, err
}
for i := 22; i <= 25; i++ {
rawHeader[i] = 0
}
// Add raw header to checksum.
checksum.Write(rawHeader) // Can't give an error (see implementation).
// From now on, write everything that is read into the checksum.
teeR := io.TeeReader(r, checksum)
// Read the sizes of all segments in the page.
segsizes := make([]byte, ret.Header.NumSegments)
teeR.Read(segsizes)
// Whether to append to the last segment. According to the spec, whenever
// a segment length is specified as being 255, that means the next segment
// should be appended to it.
var app bool
// Parse the segments, respecting combining segments (see the spec).
for _, v := range segsizes {
sz := int(v)
content := make([]byte, sz)
teeR.Read(content)
if app {
lastSeg := &ret.Segments[len(ret.Segments)-1]
*lastSeg = append(*lastSeg, content...)
} else {
ret.Segments = append(ret.Segments, content)
}
app = sz == 255
}
// Verify the checksum.
if checksum.sum != ret.Header.Checksum {
return ret, ErrOggInvalidChecksum
}
return ret, nil
}

96
vorbis/vorbis.go Normal file
View File

@ -0,0 +1,96 @@
package vorbis
import (
"bytes"
"errors"
"io"
)
var (
ErrNoHeaderSegment = errors.New("no header segment")
ErrNoMetadata = errors.New("no metadata found")
ErrCallReadRestAfterReadMetadata = errors.New("please call vorbis.Decoder.ReadRest() after having called vorbis.Decoder.ReadMetadata()")
ErrReadMetadataCalledTwice = errors.New("cannot call vorbis.Decoder.ReadMetadata() twice on the same file")
)
type Decoder struct {
r io.Reader
hasMetadata bool
}
func NewDecoder(r io.Reader) *Decoder {
return &Decoder{
r: r,
}
}
func (d *Decoder) readPage() (page OggPage, hdr VorbisHeader, err error) {
// Decode page.
page, err = OggDecode(d.r)
if err != nil {
return page, hdr, err
}
// We need to be able to access `page.Segments[0]`.
if len(page.Segments) == 0 {
return page, hdr, ErrNoHeaderSegment
}
// Decode Vorbis header, stored in `page.Segments[0]`.
hdr, err = VorbisHeaderDecode(bytes.NewBuffer(page.Segments[0]))
if err != nil {
return page, hdr, err
}
return page, hdr, nil
}
// Reads the Ogg/Vorbis file until it finds its metadata. Leaves the reader
// right after the end of the metadata. `crc32Sum` gives the crc32 checksum
// of the page containing the metadata. It is equivalent to the page checksum
// used in the Ogg container. Since the page contains more than just metadata,
// the checksum can usually be used as a unique identifier.
func (d *Decoder) ReadMetadata() (metadata *VorbisComment, crc32Sum uint32, err error) {
if d.hasMetadata {
return nil, 0, ErrReadMetadataCalledTwice
}
for {
page, hdr, err := d.readPage()
if err != nil {
return nil, 0, err
}
if (page.Header.HeaderType & FHeaderTypeEOS) > 0 {
// End of stream
return nil, 0, ErrNoMetadata
}
if hdr.PackType == PackTypeComment {
d.hasMetadata = true
return hdr.Comment, page.Header.Checksum, nil
}
}
}
// Must to be called after `ReadMetadata()`. Reads the rest of the Ogg/Vorbis
// file, leaving the reader right after the end of the Ogg/Vorbis file.
func (d *Decoder) ReadRest() error {
if !d.hasMetadata {
return ErrCallReadRestAfterReadMetadata
}
for {
page, _, err := d.readPage()
if err != nil {
return err
}
if (page.Header.HeaderType & FHeaderTypeEOS) > 0 {
// End of stream
break
}
}
return nil
}