From 2be4bf1a5e998dc002d55265c5fc2776bb6a79fd Mon Sep 17 00:00:00 2001 From: r4 Date: Thu, 3 Jun 2021 19:07:27 +0200 Subject: [PATCH] Initial commit --- LICENSE | 2 +- README.md | 14 +++- go.mod | 3 + main.go | 167 +++++++++++++++++++++++++++++++++++++ util/io.go | 36 ++++++++ vorbis/metadata.go | 144 ++++++++++++++++++++++++++++++++ vorbis/ogg.go | 202 +++++++++++++++++++++++++++++++++++++++++++++ vorbis/vorbis.go | 96 +++++++++++++++++++++ 8 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 go.mod create mode 100644 main.go create mode 100644 util/io.go create mode 100644 vorbis/metadata.go create mode 100644 vorbis/ogg.go create mode 100644 vorbis/vorbis.go diff --git a/LICENSE b/LICENSE index 2071b23..bb2fe0a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) +Copyright (c) 2021 r4 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: diff --git a/README.md b/README.md index 6235d50..e07b7d4 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +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 ] ` + +- see `./rsr -h` for integrated usage documentation diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..435a61b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module rsr + +go 1.16 diff --git a/main.go b/main.go new file mode 100644 index 0000000..55d97db --- /dev/null +++ b/main.go @@ -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...] + +Options: + -dir -- Output directory (default: "."). + +Output types: + * + ` + colYellow + `! ` + colReset + ` + ` + colRed + `! ` + 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 + } +} diff --git a/util/io.go b/util/io.go new file mode 100644 index 0000000..031cfce --- /dev/null +++ b/util/io.go @@ -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 +} diff --git a/vorbis/metadata.go b/vorbis/metadata.go new file mode 100644 index 0000000..1cab171 --- /dev/null +++ b/vorbis/metadata.go @@ -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 +} diff --git a/vorbis/ogg.go b/vorbis/ogg.go new file mode 100644 index 0000000..38c5673 --- /dev/null +++ b/vorbis/ogg.go @@ -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 +} diff --git a/vorbis/vorbis.go b/vorbis/vorbis.go new file mode 100644 index 0000000..611c94f --- /dev/null +++ b/vorbis/vorbis.go @@ -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 +}