diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7bad9dc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Fase 1: Build +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Copia i file necessari per la compilazione +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Compila l'applicazione +RUN CGO_ENABLED=0 GOOS=linux go build -o u-pod + +# Fase 2: Container leggero +FROM alpine:latest + +WORKDIR /app + +# Copia solo l'eseguibile dalla fase di build +COPY --from=builder /app/u-pod . +COPY --from=builder /app/cover.jpg . +RUN mkdir -p audio covers + +# Variabili d'ambiente obbligatorie (da sovrascrivere al runtime) +ENV PODCAST_PORT=":8080" \ + PODCAST_BASE_URL="http://localhost:8080" \ + PODCAST_AUDIO_DIR="/app/audio" \ + PODCAST_COVERS_DIR="/app/covers" \ + PODCAST_TITLE="Yo mom Podcast" + +# Esponi la porta e avvia l'applicazione +EXPOSE 8080 +VOLUME ["/app/audio", "/app/covers"] +CMD ["./u-pod"] + diff --git a/README.md b/README.md index b6dcc43..ce3b3f3 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,26 @@ +# U-Pod + +## Overview + This is U-Pod, the torpedo-style podcast system with minimal footprint and maximum utility. No databases needed to function, no cloud required, no datacenter necessary - just the filesystem and your content. Even your mom on her knees could help set it up, though that's not a functional requirement. +## Setup + First, you need to build the image. I've included a handy Dockerfile in the folder. Use it. Once you've created your image, use Docker to run it after setting these environment variables: +
    PODCAST_PORT=:8080
    PODCAST_BASE_URL=http://your-domain.com
    PODCAST_AUDIO_DIR=/app/data/audio
    PODCAST_COVERS_DIR=/app/data/covers
    PODCAST_TITLE="My Podcast - and your mom"
+
+ +## File Linking What links files in /audio to files in /covers? @@ -28,19 +38,19 @@ U-Pod will then create the torpedo, with the image properly linked to the audio. Once you have the Docker image, simply create the /data folder and put your files in it. -FAQ: +## FAQ - My "SisterGermanaGangbang 3.0" software can't use it. -- It uses the standard podcast format http://www.itunes.com/dtds/podcast-1.0.dtd. If your shitty software can't read it, take it up with Sister Germana. + - It uses the standard podcast format http://www.itunes.com/dtds/podcast-1.0.dtd. If your shitty software can't read it, take it up with Sister Germana. - I don't know how to create Docker images. -- Learn. + - Learn. - What do you mean by environment variable? -- Die! Die! Die! + - Die! Die! Die! - I need you to change this, this and that. -- No, No, and No - - + - No, No, and No +- How can I get support for this software? +- You cannot. Code is simple, and you are alone in the night, like any operation engineer during the night. Man up!!!! \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..adf2485 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module u-pod + +go 1.21.0 + +require github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c435635 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg= +github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7547b9e --- /dev/null +++ b/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +var ( + port = os.Getenv("PODCAST_PORT") // Es: ":8080" + baseURL = os.Getenv("PODCAST_BASE_URL") // Es: "http://mioserver.com" + audioDir = os.Getenv("PODCAST_AUDIO_DIR") // Es: "/data/podcast/audio" + coversDir = os.Getenv("PODCAST_COVERS_DIR") // Es: "/data/podcast/covers" + podTitle = os.Getenv("PODCAST_TITLE") // Es: "Il Mio Podcast" +) + +func init() { + // Verifica variabili d'ambiente + for _, v := range []struct { + name string + value string + }{ + {"PODCAST_PORT", port}, + {"PODCAST_BASE_URL", baseURL}, + {"PODCAST_AUDIO_DIR", audioDir}, + {"PODCAST_COVERS_DIR", coversDir}, + {"PODCAST_TITLE", podTitle}, + } { + if v.value == "" { + log.Fatalf("Variabile d'ambiente mancante: %s", v.name) + } + } +} + +func main() { + // Handler principale + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Verifica se la richiesta รจ per un file audio o cover + requestedFile := filepath.Clean(r.URL.Path) + isAudio := strings.HasPrefix(requestedFile, "/audio/") + isCover := strings.HasPrefix(requestedFile, "/covers/") + + if isAudio || isCover { + // Servi il file richiesto + http.ServeFile(w, r, filepath.Join(".", requestedFile)) + return + } + + // Altrimenti servi sempre l'RSS + if err := generateRSS(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.ServeFile(w, r, "feed.xml") + }) + + // Aggiornamento periodico + go func() { + for { + time.Sleep(5 * time.Minute) + if err := generateRSS(); err != nil { + log.Printf("Aggiornamento RSS fallito: %v", err) + } + } + }() + + log.Printf("Server in ascolto su %s%s", baseURL, port) + log.Fatal(http.ListenAndServe(port, nil)) +} diff --git a/pod.go b/pod.go new file mode 100644 index 0000000..6de6b37 --- /dev/null +++ b/pod.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/dhowden/tag" +) + +type Episode struct { + Title string + File string + Cover string + PubDate string + Size int64 +} + +var ( + lastMod time.Time + rssLock sync.Mutex +) + +func generateRSS() error { + rssLock.Lock() + defer rssLock.Unlock() + + episodes, err := scanEpisodes() + if err != nil { + return err + } + + rss := fmt.Sprintf(` + + + %s + %s`, podTitle, baseURL) + + for _, ep := range episodes { + rss += fmt.Sprintf(` + + %s + + %s`, + ep.Title, baseURL, ep.File, ep.Size, ep.PubDate) + + if ep.Cover != "" { + rss += fmt.Sprintf(` + `, baseURL, ep.Cover) + } + + rss += "\n " + } + + rss += "\n\n" + + return os.WriteFile("feed.xml", []byte(rss), 0644) +} + +func scanEpisodes() ([]Episode, error) { + var episodes []Episode + + err := filepath.Walk(audioDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".mp3") { + return nil + } + + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + meta, err := tag.ReadFrom(file) + if err != nil { + log.Printf("File %s senza metadati ID3: %v", path, err) + return nil + } + + relPath, _ := filepath.Rel(".", path) + baseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + coverPath := filepath.Join(coversDir, baseName+".jpg") + + ep := Episode{ + Title: meta.Title(), + File: relPath, + PubDate: info.ModTime().Format(time.RFC1123), + Size: info.Size(), + } + + if _, err := os.Stat(coverPath); err == nil { + ep.Cover, _ = filepath.Rel(".", coverPath) + } + + episodes = append(episodes, ep) + return nil + }) + + return episodes, err +} diff --git a/vendor/github.com/dhowden/tag/.editorconfig b/vendor/github.com/dhowden/tag/.editorconfig new file mode 100644 index 0000000..57515d0 --- /dev/null +++ b/vendor/github.com/dhowden/tag/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*.go] +indent_style = tab +indent_size = 3 + +[*.md] +trim_trailing_whitespace = false + +[*] +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/vendor/github.com/dhowden/tag/.travis.yml b/vendor/github.com/dhowden/tag/.travis.yml new file mode 100644 index 0000000..3a81bf9 --- /dev/null +++ b/vendor/github.com/dhowden/tag/.travis.yml @@ -0,0 +1,5 @@ +language: go + +go: + - 1.7 + - tip \ No newline at end of file diff --git a/vendor/github.com/dhowden/tag/LICENSE b/vendor/github.com/dhowden/tag/LICENSE new file mode 100644 index 0000000..dfd760c --- /dev/null +++ b/vendor/github.com/dhowden/tag/LICENSE @@ -0,0 +1,23 @@ +Copyright 2015, David Howden +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/dhowden/tag/README.md b/vendor/github.com/dhowden/tag/README.md new file mode 100644 index 0000000..dbc05e0 --- /dev/null +++ b/vendor/github.com/dhowden/tag/README.md @@ -0,0 +1,71 @@ +# MP3/MP4/OGG/FLAC metadata parsing library +[![GoDoc](https://pkg.go.dev/badge/github.com/dhowden/tag)](https://pkg.go.dev/github.com/dhowden/tag) + +This package provides MP3 (ID3v1,2.{2,3,4}) and MP4 (ACC, M4A, ALAC), OGG and FLAC metadata detection, parsing and artwork extraction. + +Detect and parse tag metadata from an `io.ReadSeeker` (i.e. an `*os.File`): + +```go +m, err := tag.ReadFrom(f) +if err != nil { + log.Fatal(err) +} +log.Print(m.Format()) // The detected format. +log.Print(m.Title()) // The title of the track (see Metadata interface for more details). +``` + +Parsed metadata is exported via a single interface (giving a consistent API for all supported metadata formats). + +```go +// Metadata is an interface which is used to describe metadata retrieved by this package. +type Metadata interface { + Format() Format + FileType() FileType + + Title() string + Album() string + Artist() string + AlbumArtist() string + Composer() string + Genre() string + Year() int + + Track() (int, int) // Number, Total + Disc() (int, int) // Number, Total + + Picture() *Picture // Artwork + Lyrics() string + Comment() string + + Raw() map[string]interface{} // NB: raw tag names are not consistent across formats. +} +``` + +## Audio Data Checksum (SHA1) + +This package also provides a metadata-invariant checksum for audio files: only the audio data is used to +construct the checksum. + +[https://pkg.go.dev/github.com/dhowden/tag#Sum](https://pkg.go.dev/github.com/dhowden/tag#Sum) + +## Tools + +There are simple command-line tools which demonstrate basic tag extraction and summing: + +```console +$ go install github.com/dhowden/tag/cmd/tag@latest +$ cd $GOPATH/bin +$ ./tag 11\ High\ Hopes.m4a +Metadata Format: MP4 +Title: High Hopes +Album: The Division Bell +Artist: Pink Floyd +Composer: Abbey Road Recording Studios/David Gilmour/Polly Samson +Year: 1994 +Track: 11 of 11 +Disc: 1 of 1 +Picture: Picture{Ext: jpeg, MIMEType: image/jpeg, Type: , Description: , Data.Size: 606109} + +$ ./sum 11\ High\ Hopes.m4a +2ae208c5f00a1f21f5fac9b7f6e0b8e52c06da29 +``` diff --git a/vendor/github.com/dhowden/tag/dsf.go b/vendor/github.com/dhowden/tag/dsf.go new file mode 100644 index 0000000..8ca34f2 --- /dev/null +++ b/vendor/github.com/dhowden/tag/dsf.go @@ -0,0 +1,109 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "errors" + "io" +) + +// ReadDSFTags reads DSF metadata from the io.ReadSeeker, returning the resulting +// metadata in a Metadata implementation, or non-nil error if there was a problem. +// samples: http://www.2l.no/hires/index.html +func ReadDSFTags(r io.ReadSeeker) (Metadata, error) { + dsd, err := readString(r, 4) + if err != nil { + return nil, err + } + if dsd != "DSD " { + return nil, errors.New("expected 'DSD '") + } + + _, err = r.Seek(int64(16), io.SeekCurrent) + if err != nil { + return nil, err + } + + id3Pointer, err := readUint64LittleEndian(r) + if err != nil { + return nil, err + } + + _, err = r.Seek(int64(id3Pointer), io.SeekStart) + if err != nil { + return nil, err + } + + id3, err := ReadID3v2Tags(r) + if err != nil { + return nil, err + } + + return metadataDSF{id3}, nil +} + +type metadataDSF struct { + id3 Metadata +} + +func (m metadataDSF) Format() Format { + return m.id3.Format() +} + +func (m metadataDSF) FileType() FileType { + return DSF +} + +func (m metadataDSF) Title() string { + return m.id3.Title() +} + +func (m metadataDSF) Album() string { + return m.id3.Album() +} + +func (m metadataDSF) Artist() string { + return m.id3.Artist() +} + +func (m metadataDSF) AlbumArtist() string { + return m.id3.AlbumArtist() +} + +func (m metadataDSF) Composer() string { + return m.id3.Composer() +} + +func (m metadataDSF) Year() int { + return m.id3.Year() +} + +func (m metadataDSF) Genre() string { + return m.id3.Genre() +} + +func (m metadataDSF) Track() (int, int) { + return m.id3.Track() +} + +func (m metadataDSF) Disc() (int, int) { + return m.id3.Disc() +} + +func (m metadataDSF) Picture() *Picture { + return m.id3.Picture() +} + +func (m metadataDSF) Lyrics() string { + return m.id3.Lyrics() +} + +func (m metadataDSF) Comment() string { + return m.id3.Comment() +} + +func (m metadataDSF) Raw() map[string]interface{} { + return m.id3.Raw() +} diff --git a/vendor/github.com/dhowden/tag/flac.go b/vendor/github.com/dhowden/tag/flac.go new file mode 100644 index 0000000..c370467 --- /dev/null +++ b/vendor/github.com/dhowden/tag/flac.go @@ -0,0 +1,89 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "errors" + "io" +) + +// blockType is a type which represents an enumeration of valid FLAC blocks +type blockType byte + +// FLAC block types. +const ( + // Stream Info Block 0 + // Padding Block 1 + // Application Block 2 + // Seektable Block 3 + // Cue Sheet Block 5 + vorbisCommentBlock blockType = 4 + pictureBlock blockType = 6 +) + +// ReadFLACTags reads FLAC metadata from the io.ReadSeeker, returning the resulting +// metadata in a Metadata implementation, or non-nil error if there was a problem. +func ReadFLACTags(r io.ReadSeeker) (Metadata, error) { + flac, err := readString(r, 4) + if err != nil { + return nil, err + } + if flac != "fLaC" { + return nil, errors.New("expected 'fLaC'") + } + + m := &metadataFLAC{ + newMetadataVorbis(), + } + + for { + last, err := m.readFLACMetadataBlock(r) + if err != nil { + return nil, err + } + + if last { + break + } + } + return m, nil +} + +type metadataFLAC struct { + *metadataVorbis +} + +func (m *metadataFLAC) readFLACMetadataBlock(r io.ReadSeeker) (last bool, err error) { + blockHeader, err := readBytes(r, 1) + if err != nil { + return + } + + if getBit(blockHeader[0], 7) { + blockHeader[0] ^= (1 << 7) + last = true + } + + blockLen, err := readInt(r, 3) + if err != nil { + return + } + + switch blockType(blockHeader[0]) { + case vorbisCommentBlock: + err = m.readVorbisComment(r) + + case pictureBlock: + err = m.readPictureBlock(r) + + default: + _, err = r.Seek(int64(blockLen), io.SeekCurrent) + } + return +} + +func (m *metadataFLAC) FileType() FileType { + return FLAC +} diff --git a/vendor/github.com/dhowden/tag/id.go b/vendor/github.com/dhowden/tag/id.go new file mode 100644 index 0000000..2410356 --- /dev/null +++ b/vendor/github.com/dhowden/tag/id.go @@ -0,0 +1,81 @@ +package tag + +import ( + "fmt" + "io" +) + +// Identify identifies the format and file type of the data in the ReadSeeker. +func Identify(r io.ReadSeeker) (format Format, fileType FileType, err error) { + b, err := readBytes(r, 11) + if err != nil { + return + } + + _, err = r.Seek(-11, io.SeekCurrent) + if err != nil { + err = fmt.Errorf("could not seek back to original position: %v", err) + return + } + + switch { + case string(b[0:4]) == "fLaC": + return VORBIS, FLAC, nil + + case string(b[0:4]) == "OggS": + return VORBIS, OGG, nil + + case string(b[4:8]) == "ftyp": + b = b[8:11] + fileType = UnknownFileType + switch string(b) { + case "M4A": + fileType = M4A + + case "M4B": + fileType = M4B + + case "M4P": + fileType = M4P + } + return MP4, fileType, nil + + case string(b[0:3]) == "ID3": + b := b[3:] + switch uint(b[0]) { + case 2: + format = ID3v2_2 + case 3: + format = ID3v2_3 + case 4: + format = ID3v2_4 + case 0, 1: + fallthrough + default: + err = fmt.Errorf("ID3 version: %v, expected: 2, 3 or 4", uint(b[0])) + return + } + return format, MP3, nil + } + + n, err := r.Seek(-128, io.SeekEnd) + if err != nil { + return + } + + tag, err := readString(r, 3) + if err != nil { + return + } + + _, err = r.Seek(-n, io.SeekCurrent) + if err != nil { + return + } + + if tag != "TAG" { + err = ErrNoTagsFound + return + } + return ID3v1, MP3, nil +} diff --git a/vendor/github.com/dhowden/tag/id3v1.go b/vendor/github.com/dhowden/tag/id3v1.go new file mode 100644 index 0000000..0953f0b --- /dev/null +++ b/vendor/github.com/dhowden/tag/id3v1.go @@ -0,0 +1,144 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "errors" + "io" + "strconv" + "strings" +) + +// id3v1Genres is a list of genres as given in the ID3v1 specification. +var id3v1Genres = [...]string{ + "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", + "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", + "Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", + "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", + "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", + "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", + "Noise", "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", + "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", + "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", + "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", + "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", + "Cabaret", "New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", + "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", + "Musical", "Rock & Roll", "Hard Rock", "Folk", "Folk-Rock", + "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Revival", + "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", + "Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", + "Chorus", "Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", + "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus", + "Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", + "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", "Freestyle", + "Duet", "Punk Rock", "Drum Solo", "Acapella", "Euro-House", "Dance Hall", +} + +// ErrNotID3v1 is an error which is returned when no ID3v1 header is found. +var ErrNotID3v1 = errors.New("invalid ID3v1 header") + +// ReadID3v1Tags reads ID3v1 tags from the io.ReadSeeker. Returns ErrNotID3v1 +// if there are no ID3v1 tags, otherwise non-nil error if there was a problem. +func ReadID3v1Tags(r io.ReadSeeker) (Metadata, error) { + _, err := r.Seek(-128, io.SeekEnd) + if err != nil { + return nil, err + } + + if tag, err := readString(r, 3); err != nil { + return nil, err + } else if tag != "TAG" { + return nil, ErrNotID3v1 + } + + title, err := readString(r, 30) + if err != nil { + return nil, err + } + + artist, err := readString(r, 30) + if err != nil { + return nil, err + } + + album, err := readString(r, 30) + if err != nil { + return nil, err + } + + year, err := readString(r, 4) + if err != nil { + return nil, err + } + + commentBytes, err := readBytes(r, 30) + if err != nil { + return nil, err + } + + var comment string + var track int + if commentBytes[28] == 0 { + comment = trimString(string(commentBytes[:28])) + track = int(commentBytes[29]) + } else { + comment = trimString(string(commentBytes)) + } + + var genre string + genreID, err := readBytes(r, 1) + if err != nil { + return nil, err + } + if int(genreID[0]) < len(id3v1Genres) { + genre = id3v1Genres[int(genreID[0])] + } + + m := make(map[string]interface{}) + m["title"] = trimString(title) + m["artist"] = trimString(artist) + m["album"] = trimString(album) + m["year"] = trimString(year) + m["comment"] = trimString(comment) + m["track"] = track + m["genre"] = genre + + return metadataID3v1(m), nil +} + +func trimString(x string) string { + return strings.TrimSpace(strings.Trim(x, "\x00")) +} + +// metadataID3v1 is the implementation of Metadata used for ID3v1 tags. +type metadataID3v1 map[string]interface{} + +func (metadataID3v1) Format() Format { return ID3v1 } +func (metadataID3v1) FileType() FileType { return MP3 } +func (m metadataID3v1) Raw() map[string]interface{} { return m } + +func (m metadataID3v1) Title() string { return m["title"].(string) } +func (m metadataID3v1) Album() string { return m["album"].(string) } +func (m metadataID3v1) Artist() string { return m["artist"].(string) } +func (m metadataID3v1) Genre() string { return m["genre"].(string) } + +func (m metadataID3v1) Year() int { + y := m["year"].(string) + n, err := strconv.Atoi(y) + if err != nil { + return 0 + } + return n +} + +func (m metadataID3v1) Track() (int, int) { return m["track"].(int), 0 } + +func (m metadataID3v1) AlbumArtist() string { return "" } +func (m metadataID3v1) Composer() string { return "" } +func (metadataID3v1) Disc() (int, int) { return 0, 0 } +func (m metadataID3v1) Picture() *Picture { return nil } +func (m metadataID3v1) Lyrics() string { return "" } +func (m metadataID3v1) Comment() string { return m["comment"].(string) } diff --git a/vendor/github.com/dhowden/tag/id3v2.go b/vendor/github.com/dhowden/tag/id3v2.go new file mode 100644 index 0000000..fe33685 --- /dev/null +++ b/vendor/github.com/dhowden/tag/id3v2.go @@ -0,0 +1,469 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" +) + +// Genre definitions 0-79 follow the ID3 tag specification of 1999. +// More genres have been successively introduced in later Winamp versions. +// https://en.wikipedia.org/wiki/List_of_ID3v1_genres#Extension_by_Winamp +var id3v2Genres = [...]string{ + "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", + "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", + "Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", + "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", + "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", + "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", + "Noise", "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", + "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", + "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", + "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", + "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", + "Cabaret", "New Wave", "Psychedelic", "Rave", "Showtunes", "Trailer", + "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", + "Musical", "Rock & Roll", "Hard Rock", "Folk", "Folk-Rock", + "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Revival", + "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", + "Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", + "Chorus", "Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", + "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus", + "Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", + "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", "Freestyle", + "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", "Dance Hall", + "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", + "Britpop", "Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta Rap", + "Heavy Metal", "Black Metal", "Crossover", "Contemporary Christian", + "Christian Rock ", "Merengue", "Salsa", "Thrash Metal", "Anime", "JPop", + "Synthpop", + "Christmas", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", + "Chillout", "Downtempo", "Dub", "EBM", "Eclectic", "Electro", + "Electroclash", "Emo", "Experimental", "Garage", "Global", "IDM", + "Illbient", "Industro-Goth", "Jam Band", "Krautrock", "Leftfield", "Lounge", + "Math Rock", "New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance", + "Shoegaze", "Space Rock", "Trop Rock", "World Music", "Neoclassical", "Audiobook", + "Audio Theatre", "Neue Deutsche Welle", "Podcast", "Indie Rock", "G-Funk", "Dubstep", + "Garage Rock", "Psybient", +} + +// id3v2Header is a type which represents an ID3v2 tag header. +type id3v2Header struct { + Version Format + Unsynchronisation bool + ExtendedHeader bool + Experimental bool + Size uint +} + +// readID3v2Header reads the ID3v2 header from the given io.Reader. +// offset it number of bytes of header that was read +func readID3v2Header(r io.Reader) (h *id3v2Header, offset uint, err error) { + offset = 10 + b, err := readBytes(r, offset) + if err != nil { + return nil, 0, fmt.Errorf("expected to read 10 bytes (ID3v2Header): %v", err) + } + + if string(b[0:3]) != "ID3" { + return nil, 0, fmt.Errorf("expected to read \"ID3\"") + } + + b = b[3:] + var vers Format + switch uint(b[0]) { + case 2: + vers = ID3v2_2 + case 3: + vers = ID3v2_3 + case 4: + vers = ID3v2_4 + case 0, 1: + fallthrough + default: + return nil, 0, fmt.Errorf("ID3 version: %v, expected: 2, 3 or 4", uint(b[0])) + } + + // NB: We ignore b[1] (the revision) as we don't currently rely on it. + h = &id3v2Header{ + Version: vers, + Unsynchronisation: getBit(b[2], 7), + ExtendedHeader: getBit(b[2], 6), + Experimental: getBit(b[2], 5), + Size: uint(get7BitChunkedInt(b[3:7])), + } + + if h.ExtendedHeader { + switch vers { + case ID3v2_3: + b, err := readBytes(r, 4) + if err != nil { + return nil, 0, fmt.Errorf("expected to read 4 bytes (ID3v23 extended header len): %v", err) + } + // skip header, size is excluding len bytes + extendedHeaderSize := uint(getInt(b)) + _, err = readBytes(r, extendedHeaderSize) + if err != nil { + return nil, 0, fmt.Errorf("expected to read %d bytes (ID3v23 skip extended header): %v", extendedHeaderSize, err) + } + offset += extendedHeaderSize + case ID3v2_4: + b, err := readBytes(r, 4) + if err != nil { + return nil, 0, fmt.Errorf("expected to read 4 bytes (ID3v24 extended header len): %v", err) + } + // skip header, size is synchsafe int including len bytes + extendedHeaderSize := uint(get7BitChunkedInt(b)) - 4 + _, err = readBytes(r, extendedHeaderSize) + if err != nil { + return nil, 0, fmt.Errorf("expected to read %d bytes (ID3v24 skip extended header): %v", extendedHeaderSize, err) + } + offset += extendedHeaderSize + default: + // nop, only 2.3 and 2.4 should have extended header + } + } + + return h, offset, nil +} + +// id3v2FrameFlags is a type which represents the flags which can be set on an ID3v2 frame. +type id3v2FrameFlags struct { + // Message (ID3 2.3.0 and 2.4.0) + TagAlterPreservation bool + FileAlterPreservation bool + ReadOnly bool + + // Format (ID3 2.3.0 and 2.4.0) + Compression bool + Encryption bool + GroupIdentity bool + // ID3 2.4.0 only (see http://id3.org/id3v2.4.0-structure sec 4.1) + Unsynchronisation bool + DataLengthIndicator bool +} + +func readID3v23FrameFlags(r io.Reader) (*id3v2FrameFlags, error) { + b, err := readBytes(r, 2) + if err != nil { + return nil, err + } + + msg := b[0] + fmt := b[1] + + return &id3v2FrameFlags{ + TagAlterPreservation: getBit(msg, 7), + FileAlterPreservation: getBit(msg, 6), + ReadOnly: getBit(msg, 5), + Compression: getBit(fmt, 7), + Encryption: getBit(fmt, 6), + GroupIdentity: getBit(fmt, 5), + }, nil +} + +func readID3v24FrameFlags(r io.Reader) (*id3v2FrameFlags, error) { + b, err := readBytes(r, 2) + if err != nil { + return nil, err + } + + msg := b[0] + fmt := b[1] + + return &id3v2FrameFlags{ + TagAlterPreservation: getBit(msg, 6), + FileAlterPreservation: getBit(msg, 5), + ReadOnly: getBit(msg, 4), + GroupIdentity: getBit(fmt, 6), + Compression: getBit(fmt, 3), + Encryption: getBit(fmt, 2), + Unsynchronisation: getBit(fmt, 1), + DataLengthIndicator: getBit(fmt, 0), + }, nil + +} + +func readID3v2_2FrameHeader(r io.Reader) (name string, size uint, headerSize uint, err error) { + name, err = readString(r, 3) + if err != nil { + return + } + size, err = readUint(r, 3) + if err != nil { + return + } + headerSize = 6 + return +} + +func readID3v2_3FrameHeader(r io.Reader) (name string, size uint, headerSize uint, err error) { + name, err = readString(r, 4) + if err != nil { + return + } + size, err = readUint(r, 4) + if err != nil { + return + } + headerSize = 8 + return +} + +func readID3v2_4FrameHeader(r io.Reader) (name string, size uint, headerSize uint, err error) { + name, err = readString(r, 4) + if err != nil { + return + } + size, err = read7BitChunkedUint(r, 4) + if err != nil { + return + } + headerSize = 8 + return +} + +// readID3v2Frames reads ID3v2 frames from the given reader using the ID3v2Header. +func readID3v2Frames(r io.Reader, offset uint, h *id3v2Header) (map[string]interface{}, error) { + result := make(map[string]interface{}) + + for offset < h.Size { + var err error + var name string + var size, headerSize uint + var flags *id3v2FrameFlags + + switch h.Version { + case ID3v2_2: + name, size, headerSize, err = readID3v2_2FrameHeader(r) + + case ID3v2_3: + name, size, headerSize, err = readID3v2_3FrameHeader(r) + if err != nil { + return nil, err + } + flags, err = readID3v23FrameFlags(r) + headerSize += 2 + + case ID3v2_4: + name, size, headerSize, err = readID3v2_4FrameHeader(r) + if err != nil { + return nil, err + } + flags, err = readID3v24FrameFlags(r) + headerSize += 2 + } + + if err != nil { + return nil, err + } + + // FIXME: Do we still need this? + // if size=0, we certainly are in a padding zone. ignore the rest of + // the tags + if size == 0 { + break + } + + offset += headerSize + size + + // Avoid corrupted padding (see http://id3.org/Compliance%20Issues). + if !validID3Frame(h.Version, name) && offset > h.Size { + break + } + + if flags != nil { + if flags.Compression { + switch h.Version { + case ID3v2_3: + // No data length indicator defined. + if _, err := read7BitChunkedUint(r, 4); err != nil { // read 4 + return nil, err + } + size -= 4 + + case ID3v2_4: + // Must have a data length indicator (to give the size) if compression is enabled. + if !flags.DataLengthIndicator { + return nil, errors.New("compression without data length indicator") + } + + default: + return nil, fmt.Errorf("unsupported compression flag used in %v", h.Version) + } + } + + if flags.DataLengthIndicator { + if h.Version == ID3v2_3 { + return nil, fmt.Errorf("data length indicator set but not defined for %v", ID3v2_3) + } + + size, err = read7BitChunkedUint(r, 4) + if err != nil { // read 4 + return nil, err + } + } + + if flags.Encryption { + _, err = readBytes(r, 1) // read 1 byte of encryption method + if err != nil { + return nil, err + } + size-- + } + } + + b, err := readBytes(r, size) + if err != nil { + return nil, err + } + + // There can be multiple tag with the same name. Append a number to the + // name if there is more than one. + rawName := name + if _, ok := result[rawName]; ok { + for i := 0; ok; i++ { + rawName = name + "_" + strconv.Itoa(i) + _, ok = result[rawName] + } + } + + switch { + case name == "TXXX" || name == "TXX": + t, err := readTextWithDescrFrame(b, false, true) // no lang, but enc + if err != nil { + return nil, err + } + result[rawName] = t + + case name[0] == 'T': + txt, err := readTFrame(b) + if err != nil { + return nil, err + } + result[rawName] = txt + + case name == "UFID" || name == "UFI": + t, err := readUFID(b) + if err != nil { + return nil, err + } + result[rawName] = t + + case name == "WXXX" || name == "WXX": + t, err := readTextWithDescrFrame(b, false, false) // no lang, no enc + if err != nil { + return nil, err + } + result[rawName] = t + + case name[0] == 'W': + txt, err := readWFrame(b) + if err != nil { + return nil, err + } + result[rawName] = txt + + case name == "COMM" || name == "COM" || name == "USLT" || name == "ULT": + t, err := readTextWithDescrFrame(b, true, true) // both lang and enc + if err != nil { + return nil, fmt.Errorf("could not read %q (%q): %v", name, rawName, err) + } + result[rawName] = t + + case name == "APIC": + p, err := readAPICFrame(b) + if err != nil { + return nil, err + } + result[rawName] = p + + case name == "PIC": + p, err := readPICFrame(b) + if err != nil { + return nil, err + } + result[rawName] = p + + default: + result[rawName] = b + } + } + return result, nil +} + +type unsynchroniser struct { + io.Reader + ff bool +} + +// filter io.Reader which skip the Unsynchronisation bytes +func (r *unsynchroniser) Read(p []byte) (int, error) { + b := make([]byte, 1) + i := 0 + for i < len(p) { + if n, err := r.Reader.Read(b); err != nil || n == 0 { + return i, err + } + if r.ff && b[0] == 0x00 { + r.ff = false + continue + } + p[i] = b[0] + i++ + r.ff = (b[0] == 0xFF) + } + return i, nil +} + +// ReadID3v2Tags parses ID3v2.{2,3,4} tags from the io.ReadSeeker into a Metadata, returning +// non-nil error on failure. +func ReadID3v2Tags(r io.ReadSeeker) (Metadata, error) { + h, offset, err := readID3v2Header(r) + if err != nil { + return nil, err + } + + var ur io.Reader = r + if h.Unsynchronisation { + ur = &unsynchroniser{Reader: r} + } + + f, err := readID3v2Frames(ur, offset, h) + if err != nil { + return nil, err + } + return metadataID3v2{header: h, frames: f}, nil +} + +var id3v2genreRe = regexp.MustCompile(`(.*[^(]|.* |^)\(([0-9]+)\) *(.*)$`) + +// id3v2genre parse a id3v2 genre tag and expand the numeric genres +func id3v2genre(genre string) string { + c := true + for c { + orig := genre + if match := id3v2genreRe.FindStringSubmatch(genre); len(match) > 0 { + if genreID, err := strconv.Atoi(match[2]); err == nil { + if genreID < len(id3v2Genres) { + genre = id3v2Genres[genreID] + if match[1] != "" { + genre = strings.TrimSpace(match[1]) + " " + genre + } + if match[3] != "" { + genre = genre + " " + match[3] + } + } + } + } + c = (orig != genre) + } + return strings.Replace(genre, "((", "(", -1) +} diff --git a/vendor/github.com/dhowden/tag/id3v2frames.go b/vendor/github.com/dhowden/tag/id3v2frames.go new file mode 100644 index 0000000..8397eba --- /dev/null +++ b/vendor/github.com/dhowden/tag/id3v2frames.go @@ -0,0 +1,655 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "strings" + "unicode/utf16" +) + +// DefaultUTF16WithBOMByteOrder is the byte order used when the "UTF16 with BOM" encoding +// is specified without a corresponding BOM in the data. +var DefaultUTF16WithBOMByteOrder binary.ByteOrder = binary.LittleEndian + +// ID3v2.2.0 frames (see http://id3.org/id3v2-00, sec 4). +var id3v22Frames = map[string]string{ + "BUF": "Recommended buffer size", + + "CNT": "Play counter", + "COM": "Comments", + "CRA": "Audio encryption", + "CRM": "Encrypted meta frame", + + "ETC": "Event timing codes", + "EQU": "Equalization", + + "GEO": "General encapsulated object", + + "IPL": "Involved people list", + + "LNK": "Linked information", + + "MCI": "Music CD Identifier", + "MLL": "MPEG location lookup table", + + "PIC": "Attached picture", + "POP": "Popularimeter", + + "REV": "Reverb", + "RVA": "Relative volume adjustment", + + "SLT": "Synchronized lyric/text", + "STC": "Synced tempo codes", + + "TAL": "Album/Movie/Show title", + "TBP": "BPM (Beats Per Minute)", + "TCM": "Composer", + "TCO": "Content type", + "TCR": "Copyright message", + "TDA": "Date", + "TDY": "Playlist delay", + "TEN": "Encoded by", + "TFT": "File type", + "TIM": "Time", + "TKE": "Initial key", + "TLA": "Language(s)", + "TLE": "Length", + "TMT": "Media type", + "TOA": "Original artist(s)/performer(s)", + "TOF": "Original filename", + "TOL": "Original Lyricist(s)/text writer(s)", + "TOR": "Original release year", + "TOT": "Original album/Movie/Show title", + "TP1": "Lead artist(s)/Lead performer(s)/Soloist(s)/Performing group", + "TP2": "Band/Orchestra/Accompaniment", + "TP3": "Conductor/Performer refinement", + "TP4": "Interpreted, remixed, or otherwise modified by", + "TPA": "Part of a set", + "TPB": "Publisher", + "TRC": "ISRC (International Standard Recording Code)", + "TRD": "Recording dates", + "TRK": "Track number/Position in set", + "TSI": "Size", + "TSS": "Software/hardware and settings used for encoding", + "TT1": "Content group description", + "TT2": "Title/Songname/Content description", + "TT3": "Subtitle/Description refinement", + "TXT": "Lyricist/text writer", + "TXX": "User defined text information frame", + "TYE": "Year", + + "UFI": "Unique file identifier", + "ULT": "Unsychronized lyric/text transcription", + + "WAF": "Official audio file webpage", + "WAR": "Official artist/performer webpage", + "WAS": "Official audio source webpage", + "WCM": "Commercial information", + "WCP": "Copyright/Legal information", + "WPB": "Publishers official webpage", + "WXX": "User defined URL link frame", +} + +// ID3v2.3.0 frames (see http://id3.org/id3v2.3.0#Declared_ID3v2_frames). +var id3v23Frames = map[string]string{ + "AENC": "Audio encryption]", + "APIC": "Attached picture", + "COMM": "Comments", + "COMR": "Commercial frame", + "ENCR": "Encryption method registration", + "EQUA": "Equalization", + "ETCO": "Event timing codes", + "GEOB": "General encapsulated object", + "GRID": "Group identification registration", + "IPLS": "Involved people list", + "LINK": "Linked information", + "MCDI": "Music CD identifier", + "MLLT": "MPEG location lookup table", + "OWNE": "Ownership frame", + "PRIV": "Private frame", + "PCNT": "Play counter", + "POPM": "Popularimeter", + "POSS": "Position synchronisation frame", + "RBUF": "Recommended buffer size", + "RVAD": "Relative volume adjustment", + "RVRB": "Reverb", + "SYLT": "Synchronized lyric/text", + "SYTC": "Synchronized tempo codes", + "TALB": "Album/Movie/Show title", + "TBPM": "BPM (beats per minute)", + "TCMP": "iTunes Compilation Flag", + "TCOM": "Composer", + "TCON": "Content type", + "TCOP": "Copyright message", + "TDAT": "Date", + "TDLY": "Playlist delay", + "TENC": "Encoded by", + "TEXT": "Lyricist/Text writer", + "TFLT": "File type", + "TIME": "Time", + "TIT1": "Content group description", + "TIT2": "Title/songname/content description", + "TIT3": "Subtitle/Description refinement", + "TKEY": "Initial key", + "TLAN": "Language(s)", + "TLEN": "Length", + "TMED": "Media type", + "TOAL": "Original album/movie/show title", + "TOFN": "Original filename", + "TOLY": "Original lyricist(s)/text writer(s)", + "TOPE": "Original artist(s)/performer(s)", + "TORY": "Original release year", + "TOWN": "File owner/licensee", + "TPE1": "Lead performer(s)/Soloist(s)", + "TPE2": "Band/orchestra/accompaniment", + "TPE3": "Conductor/performer refinement", + "TPE4": "Interpreted, remixed, or otherwise modified by", + "TPOS": "Part of a set", + "TPUB": "Publisher", + "TRCK": "Track number/Position in set", + "TRDA": "Recording dates", + "TRSN": "Internet radio station name", + "TRSO": "Internet radio station owner", + "TSIZ": "Size", + "TSO2": "iTunes uses this for Album Artist sort order", + "TSOC": "iTunes uses this for Composer sort order", + "TSRC": "ISRC (international standard recording code)", + "TSSE": "Software/Hardware and settings used for encoding", + "TYER": "Year", + "TXXX": "User defined text information frame", + "UFID": "Unique file identifier", + "USER": "Terms of use", + "USLT": "Unsychronized lyric/text transcription", + "WCOM": "Commercial information", + "WCOP": "Copyright/Legal information", + "WOAF": "Official audio file webpage", + "WOAR": "Official artist/performer webpage", + "WOAS": "Official audio source webpage", + "WORS": "Official internet radio station homepage", + "WPAY": "Payment", + "WPUB": "Publishers official webpage", + "WXXX": "User defined URL link frame", +} + +// ID3v2.4.0 frames (see http://id3.org/id3v2.4.0-frames, sec 4). +var id3v24Frames = map[string]string{ + "AENC": "Audio encryption", + "APIC": "Attached picture", + "ASPI": "Audio seek point index", + + "COMM": "Comments", + "COMR": "Commercial frame", + + "ENCR": "Encryption method registration", + "EQU2": "Equalisation (2)", + "ETCO": "Event timing codes", + + "GEOB": "General encapsulated object", + "GRID": "Group identification registration", + + "LINK": "Linked information", + + "MCDI": "Music CD identifier", + "MLLT": "MPEG location lookup table", + + "OWNE": "Ownership frame", + + "PRIV": "Private frame", + "PCNT": "Play counter", + "POPM": "Popularimeter", + "POSS": "Position synchronisation frame", + + "RBUF": "Recommended buffer size", + "RVA2": "Relative volume adjustment (2)", + "RVRB": "Reverb", + + "SEEK": "Seek frame", + "SIGN": "Signature frame", + "SYLT": "Synchronised lyric/text", + "SYTC": "Synchronised tempo codes", + + "TALB": "Album/Movie/Show title", + "TBPM": "BPM (beats per minute)", + "TCMP": "iTunes Compilation Flag", + "TCOM": "Composer", + "TCON": "Content type", + "TCOP": "Copyright message", + "TDEN": "Encoding time", + "TDLY": "Playlist delay", + "TDOR": "Original release time", + "TDRC": "Recording time", + "TDRL": "Release time", + "TDTG": "Tagging time", + "TENC": "Encoded by", + "TEXT": "Lyricist/Text writer", + "TFLT": "File type", + "TIPL": "Involved people list", + "TIT1": "Content group description", + "TIT2": "Title/songname/content description", + "TIT3": "Subtitle/Description refinement", + "TKEY": "Initial key", + "TLAN": "Language(s)", + "TLEN": "Length", + "TMCL": "Musician credits list", + "TMED": "Media type", + "TMOO": "Mood", + "TOAL": "Original album/movie/show title", + "TOFN": "Original filename", + "TOLY": "Original lyricist(s)/text writer(s)", + "TOPE": "Original artist(s)/performer(s)", + "TOWN": "File owner/licensee", + "TPE1": "Lead performer(s)/Soloist(s)", + "TPE2": "Band/orchestra/accompaniment", + "TPE3": "Conductor/performer refinement", + "TPE4": "Interpreted, remixed, or otherwise modified by", + "TPOS": "Part of a set", + "TPRO": "Produced notice", + "TPUB": "Publisher", + "TRCK": "Track number/Position in set", + "TRSN": "Internet radio station name", + "TRSO": "Internet radio station owner", + "TSO2": "iTunes uses this for Album Artist sort order", + "TSOA": "Album sort order", + "TSOC": "iTunes uses this for Composer sort order", + "TSOP": "Performer sort order", + "TSOT": "Title sort order", + "TSRC": "ISRC (international standard recording code)", + "TSSE": "Software/Hardware and settings used for encoding", + "TSST": "Set subtitle", + "TXXX": "User defined text information frame", + + "UFID": "Unique file identifier", + "USER": "Terms of use", + "USLT": "Unsynchronised lyric/text transcription", + + "WCOM": "Commercial information", + "WCOP": "Copyright/Legal information", + "WOAF": "Official audio file webpage", + "WOAR": "Official artist/performer webpage", + "WOAS": "Official audio source webpage", + "WORS": "Official Internet radio station homepage", + "WPAY": "Payment", + "WPUB": "Publishers official webpage", + "WXXX": "User defined URL link frame", +} + +// ID3 frames that are defined in the specs. +var id3Frames = map[Format]map[string]string{ + ID3v2_2: id3v22Frames, + ID3v2_3: id3v23Frames, + ID3v2_4: id3v24Frames, +} + +func validID3Frame(version Format, name string) bool { + names, ok := id3Frames[version] + if !ok { + return false + } + _, ok = names[name] + return ok +} + +func readWFrame(b []byte) (string, error) { + // Frame text is always encoded in ISO-8859-1 + b = append([]byte{0}, b...) + return readTFrame(b) +} + +func readTFrame(b []byte) (string, error) { + if len(b) == 0 { + return "", nil + } + + txt, err := decodeText(b[0], b[1:]) + if err != nil { + return "", err + } + return strings.Join(strings.Split(txt, string(singleZero)), ""), nil +} + +const ( + encodingISO8859 byte = 0 + encodingUTF16WithBOM byte = 1 + encodingUTF16 byte = 2 + encodingUTF8 byte = 3 +) + +func decodeText(enc byte, b []byte) (string, error) { + if len(b) == 0 { + return "", nil + } + + switch enc { + case encodingISO8859: // ISO-8859-1 + return decodeISO8859(b), nil + + case encodingUTF16WithBOM: // UTF-16 with byte order marker + if len(b) == 1 { + return "", nil + } + return decodeUTF16WithBOM(b) + + case encodingUTF16: // UTF-16 without byte order (assuming BigEndian) + if len(b) == 1 { + return "", nil + } + return decodeUTF16(b, binary.BigEndian) + + case encodingUTF8: // UTF-8 + return string(b), nil + + default: // Fallback to ISO-8859-1 + return decodeISO8859(b), nil + } +} + +var ( + singleZero = []byte{0} + doubleZero = []byte{0, 0} +) + +func dataSplit(b []byte, enc byte) [][]byte { + delim := singleZero + if enc == encodingUTF16 || enc == encodingUTF16WithBOM { + delim = doubleZero + } + + result := bytes.SplitN(b, delim, 2) + if len(result) != 2 { + return result + } + + if len(result[1]) == 0 { + return result + } + + if result[1][0] == 0 { + // there was a double (or triple) 0 and we cut too early + result[0] = append(result[0], result[1][0]) + result[1] = result[1][1:] + } + return result +} + +func decodeISO8859(b []byte) string { + r := make([]rune, len(b)) + for i, x := range b { + r[i] = rune(x) + } + return string(r) +} + +func decodeUTF16WithBOM(b []byte) (string, error) { + if len(b) < 2 { + return "", errors.New("invalid encoding: expected at least 2 bytes for UTF-16 byte order mark") + } + + var bo binary.ByteOrder + switch { + case b[0] == 0xFE && b[1] == 0xFF: + bo = binary.BigEndian + b = b[2:] + + case b[0] == 0xFF && b[1] == 0xFE: + bo = binary.LittleEndian + b = b[2:] + + default: + bo = DefaultUTF16WithBOMByteOrder + } + return decodeUTF16(b, bo) +} + +func decodeUTF16(b []byte, bo binary.ByteOrder) (string, error) { + if len(b)%2 != 0 { + return "", errors.New("invalid encoding: expected even number of bytes for UTF-16 encoded text") + } + s := make([]uint16, 0, len(b)/2) + for i := 0; i < len(b); i += 2 { + s = append(s, bo.Uint16(b[i:i+2])) + } + return string(utf16.Decode(s)), nil +} + +// Comm is a type used in COMM, UFID, TXXX, WXXX and USLT tag. +// It's a text with a description and a specified language +// For WXXX, TXXX and UFID, we don't set a Language +type Comm struct { + Language string + Description string + Text string +} + +// String returns a string representation of the underlying Comm instance. +func (t Comm) String() string { + if t.Language != "" { + return fmt.Sprintf("Text{Lang: '%v', Description: '%v', %v lines}", + t.Language, t.Description, strings.Count(t.Text, "\n")) + } + return fmt.Sprintf("Text{Description: '%v', %v}", t.Description, t.Text) +} + +// IDv2.{3,4} +// -- Header +//
+//
+// -- readTextWithDescrFrame(data, true, true) +// Text encoding $xx +// Language $xx xx xx +// Content descriptor $00 (00) +// Lyrics/text +// -- Header +//
+//
+// -- readTextWithDescrFrame(data, false, ) +// Text encoding $xx +// Description $00 (00) +// Value +func readTextWithDescrFrame(b []byte, hasLang bool, encoded bool) (*Comm, error) { + if len(b) == 0 { + return nil, errors.New("error decoding tag description text: invalid encoding") + } + enc := b[0] + b = b[1:] + + c := &Comm{} + if hasLang { + if len(b) < 3 { + return nil, errors.New("hasLang set but not enough data for language information") + } + c.Language = string(b[:3]) + b = b[3:] + } + + descTextSplit := dataSplit(b, enc) + if len(descTextSplit) == 0 { + return nil, errors.New("error decoding tag description text: invalid encoding") + } + + desc, err := decodeText(enc, descTextSplit[0]) + if err != nil { + return nil, fmt.Errorf("error decoding tag description text: %v", err) + } + c.Description = desc + + if len(descTextSplit) == 1 { + return c, nil + } + + if !encoded { + enc = byte(0) + } + text, err := decodeText(enc, descTextSplit[1]) + if err != nil { + return nil, fmt.Errorf("error decoding tag text: %v", err) + } + c.Text = text + + return c, nil +} + +// UFID is composed of a provider (frequently a URL and a binary identifier) +// The identifier can be a text (Musicbrainz use texts, but not necessary) +type UFID struct { + Provider string + Identifier []byte +} + +func (u UFID) String() string { + return fmt.Sprintf("%v (%v)", u.Provider, string(u.Identifier)) +} + +func readUFID(b []byte) (*UFID, error) { + result := bytes.SplitN(b, singleZero, 2) + if len(result) != 2 { + return nil, errors.New("expected to split UFID data into 2 pieces") + } + + return &UFID{ + Provider: string(result[0]), + Identifier: result[1], + }, nil +} + +var pictureTypes = map[byte]string{ + 0x00: "Other", + 0x01: "32x32 pixels 'file icon' (PNG only)", + 0x02: "Other file icon", + 0x03: "Cover (front)", + 0x04: "Cover (back)", + 0x05: "Leaflet page", + 0x06: "Media (e.g. lable side of CD)", + 0x07: "Lead artist/lead performer/soloist", + 0x08: "Artist/performer", + 0x09: "Conductor", + 0x0A: "Band/Orchestra", + 0x0B: "Composer", + 0x0C: "Lyricist/text writer", + 0x0D: "Recording Location", + 0x0E: "During recording", + 0x0F: "During performance", + 0x10: "Movie/video screen capture", + 0x11: "A bright coloured fish", + 0x12: "Illustration", + 0x13: "Band/artist logotype", + 0x14: "Publisher/Studio logotype", +} + +// Picture is a type which represents an attached picture extracted from metadata. +type Picture struct { + Ext string // Extension of the picture file. + MIMEType string // MIMEType of the picture. + Type string // Type of the picture (see pictureTypes). + Description string // Description. + Data []byte // Raw picture data. +} + +// String returns a string representation of the underlying Picture instance. +func (p Picture) String() string { + return fmt.Sprintf("Picture{Ext: %v, MIMEType: %v, Type: %v, Description: %v, Data.Size: %v}", + p.Ext, p.MIMEType, p.Type, p.Description, len(p.Data)) +} + +// IDv2.2 +// -- Header +// Attached picture "PIC" +// Frame size $xx xx xx +// -- readPICFrame +// Text encoding $xx +// Image format $xx xx xx +// Picture type $xx +// Description $00 (00) +// Picture data +func readPICFrame(b []byte) (*Picture, error) { + if len(b) < 5 { + return nil, errors.New("invalid PIC frame") + } + + enc := b[0] + ext := string(b[1:4]) + picType := b[4] + + descDataSplit := dataSplit(b[5:], enc) + if len(descDataSplit) != 2 { + return nil, errors.New("error decoding PIC description text: invalid encoding") + } + desc, err := decodeText(enc, descDataSplit[0]) + if err != nil { + return nil, fmt.Errorf("error decoding PIC description text: %v", err) + } + + var mimeType string + switch ext { + case "jpeg", "jpg": + mimeType = "image/jpeg" + case "png": + mimeType = "image/png" + } + + return &Picture{ + Ext: ext, + MIMEType: mimeType, + Type: pictureTypes[picType], + Description: desc, + Data: descDataSplit[1], + }, nil +} + +// IDv2.{3,4} +// -- Header +//
+// -- readAPICFrame +// Text encoding $xx +// MIME type $00 +// Picture type $xx +// Description $00 (00) +// Picture data +func readAPICFrame(b []byte) (*Picture, error) { + if len(b) == 0 { + return nil, errors.New("error decoding APIC: invalid encoding") + } + enc := b[0] + mimeDataSplit := bytes.SplitN(b[1:], singleZero, 2) + if len(mimeDataSplit) != 2 { + return nil, errors.New("error decoding APIC: invalid encoding") + } + + mimeType := string(mimeDataSplit[0]) + + b = mimeDataSplit[1] + if len(b) < 1 { + return nil, fmt.Errorf("error decoding APIC mimetype") + } + picType := b[0] + + descDataSplit := dataSplit(b[1:], enc) + if len(descDataSplit) != 2 { + return nil, errors.New("error decoding APIC description text: invalid encoding") + } + desc, err := decodeText(enc, descDataSplit[0]) + if err != nil { + return nil, fmt.Errorf("error decoding APIC description text: %v", err) + } + + var ext string + switch mimeType { + case "image/jpeg": + ext = "jpg" + case "image/png": + ext = "png" + } + + return &Picture{ + Ext: ext, + MIMEType: mimeType, + Type: pictureTypes[picType], + Description: desc, + Data: descDataSplit[1], + }, nil +} diff --git a/vendor/github.com/dhowden/tag/id3v2metadata.go b/vendor/github.com/dhowden/tag/id3v2metadata.go new file mode 100644 index 0000000..4279f64 --- /dev/null +++ b/vendor/github.com/dhowden/tag/id3v2metadata.go @@ -0,0 +1,152 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "strconv" + "strings" + "time" +) + +type frameNames map[string][2]string + +func (f frameNames) Name(s string, fm Format) string { + l, ok := f[s] + if !ok { + return "" + } + + switch fm { + case ID3v2_2: + return l[0] + case ID3v2_3: + return l[1] + case ID3v2_4: + if s == "year" { + return "TDRC" + } + return l[1] + } + return "" +} + +var frames = frameNames(map[string][2]string{ + "title": [2]string{"TT2", "TIT2"}, + "artist": [2]string{"TP1", "TPE1"}, + "album": [2]string{"TAL", "TALB"}, + "album_artist": [2]string{"TP2", "TPE2"}, + "composer": [2]string{"TCM", "TCOM"}, + "year": [2]string{"TYE", "TYER"}, + "track": [2]string{"TRK", "TRCK"}, + "disc": [2]string{"TPA", "TPOS"}, + "genre": [2]string{"TCO", "TCON"}, + "picture": [2]string{"PIC", "APIC"}, + "lyrics": [2]string{"", "USLT"}, + "comment": [2]string{"COM", "COMM"}, +}) + +// metadataID3v2 is the implementation of Metadata used for ID3v2 tags. +type metadataID3v2 struct { + header *id3v2Header + frames map[string]interface{} +} + +func (m metadataID3v2) getString(k string) string { + v, ok := m.frames[k] + if !ok { + return "" + } + return v.(string) +} + +func (m metadataID3v2) Format() Format { return m.header.Version } +func (m metadataID3v2) FileType() FileType { return MP3 } +func (m metadataID3v2) Raw() map[string]interface{} { return m.frames } + +func (m metadataID3v2) Title() string { + return m.getString(frames.Name("title", m.Format())) +} + +func (m metadataID3v2) Artist() string { + return m.getString(frames.Name("artist", m.Format())) +} + +func (m metadataID3v2) Album() string { + return m.getString(frames.Name("album", m.Format())) +} + +func (m metadataID3v2) AlbumArtist() string { + return m.getString(frames.Name("album_artist", m.Format())) +} + +func (m metadataID3v2) Composer() string { + return m.getString(frames.Name("composer", m.Format())) +} + +func (m metadataID3v2) Genre() string { + return id3v2genre(m.getString(frames.Name("genre", m.Format()))) +} + +func (m metadataID3v2) Year() int { + stringYear := m.getString(frames.Name("year", m.Format())) + + if year, err := strconv.Atoi(stringYear); err == nil { + return year + } + + date, err := time.Parse(time.DateOnly, stringYear) + if err != nil { + return 0 + } + + return date.Year() +} + +func parseXofN(s string) (x, n int) { + xn := strings.Split(s, "/") + if len(xn) != 2 { + x, _ = strconv.Atoi(s) + return x, 0 + } + x, _ = strconv.Atoi(strings.TrimSpace(xn[0])) + n, _ = strconv.Atoi(strings.TrimSpace(xn[1])) + return x, n +} + +func (m metadataID3v2) Track() (int, int) { + return parseXofN(m.getString(frames.Name("track", m.Format()))) +} + +func (m metadataID3v2) Disc() (int, int) { + return parseXofN(m.getString(frames.Name("disc", m.Format()))) +} + +func (m metadataID3v2) Lyrics() string { + t, ok := m.frames[frames.Name("lyrics", m.Format())] + if !ok { + return "" + } + return t.(*Comm).Text +} + +func (m metadataID3v2) Comment() string { + t, ok := m.frames[frames.Name("comment", m.Format())] + if !ok { + return "" + } + // id3v23 has Text, id3v24 has Description + if t.(*Comm).Description == "" { + return trimString(t.(*Comm).Text) + } + return trimString(t.(*Comm).Description) +} + +func (m metadataID3v2) Picture() *Picture { + v, ok := m.frames[frames.Name("picture", m.Format())] + if !ok { + return nil + } + return v.(*Picture) +} diff --git a/vendor/github.com/dhowden/tag/mp4.go b/vendor/github.com/dhowden/tag/mp4.go new file mode 100644 index 0000000..19c1a81 --- /dev/null +++ b/vendor/github.com/dhowden/tag/mp4.go @@ -0,0 +1,379 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +var atomTypes = map[int]string{ + 0: "implicit", // automatic based on atom name + 1: "text", + 13: "jpeg", + 14: "png", + 21: "uint8", +} + +// NB: atoms does not include "----", this is handled separately +var atoms = atomNames(map[string]string{ + "\xa9alb": "album", + "\xa9art": "artist", + "\xa9ART": "artist", + "aART": "album_artist", + "\xa9day": "year", + "\xa9nam": "title", + "\xa9gen": "genre", + "trkn": "track", + "\xa9wrt": "composer", + "\xa9too": "encoder", + "cprt": "copyright", + "covr": "picture", + "\xa9grp": "grouping", + "keyw": "keyword", + "\xa9lyr": "lyrics", + "\xa9cmt": "comment", + "tmpo": "tempo", + "cpil": "compilation", + "disk": "disc", +}) + +var means = map[string]bool{ + "com.apple.iTunes": true, + "com.mixedinkey.mixedinkey": true, + "com.serato.dj": true, +} + +// Detect PNG image if "implicit" class is used +var pngHeader = []byte{137, 80, 78, 71, 13, 10, 26, 10} + +type atomNames map[string]string + +func (f atomNames) Name(n string) []string { + res := make([]string, 1) + for k, v := range f { + if v == n { + res = append(res, k) + } + } + return res +} + +// metadataMP4 is the implementation of Metadata for MP4 tag (atom) data. +type metadataMP4 struct { + fileType FileType + data map[string]interface{} +} + +// ReadAtoms reads MP4 metadata atoms from the io.ReadSeeker into a Metadata, returning +// non-nil error if there was a problem. +func ReadAtoms(r io.ReadSeeker) (Metadata, error) { + m := metadataMP4{ + data: make(map[string]interface{}), + fileType: UnknownFileType, + } + err := m.readAtoms(r) + return m, err +} + +func (m metadataMP4) readAtoms(r io.ReadSeeker) error { + for { + name, size, err := readAtomHeader(r) + if err != nil { + if err == io.EOF { + return nil + } + return err + } + + switch name { + case "meta": + // next_item_id (int32) + _, err := readBytes(r, 4) + if err != nil { + return err + } + fallthrough + + case "moov", "udta", "ilst": + return m.readAtoms(r) + } + + _, ok := atoms[name] + var data []string + if name == "----" { + name, data, err = readCustomAtom(r, size) + if err != nil { + return err + } + + if name != "----" { + ok = true + size = 0 // already read data + } + } + + if !ok { + _, err := r.Seek(int64(size-8), io.SeekCurrent) + if err != nil { + return err + } + continue + } + + err = m.readAtomData(r, name, size-8, data) + if err != nil { + return err + } + } +} + +func (m metadataMP4) readAtomData(r io.ReadSeeker, name string, size uint32, processedData []string) error { + var b []byte + var err error + var contentType string + if len(processedData) > 0 { + b = []byte(strings.Join(processedData, ";")) // add delimiter if multiple data fields + contentType = "text" + } else { + // read the data + b, err = readBytes(r, uint(size)) + if err != nil { + return err + } + if len(b) < 8 { + return fmt.Errorf("invalid encoding: expected at least %d bytes, got %d", 8, len(b)) + } + + // "data" + size (4 bytes each) + b = b[8:] + + if len(b) < 4 { + return fmt.Errorf("invalid encoding: expected at least %d bytes, for class, got %d", 4, len(b)) + } + class := getInt(b[1:4]) + var ok bool + contentType, ok = atomTypes[class] + if !ok { + return fmt.Errorf("invalid content type: %v (%x) (%x)", class, b[1:4], b) + } + + // 4: atom version (1 byte) + atom flags (3 bytes) + // 4: NULL (usually locale indicator) + if len(b) < 8 { + return fmt.Errorf("invalid encoding: expected at least %d bytes, for atom version and flags, got %d", 8, len(b)) + } + b = b[8:] + } + + if name == "trkn" || name == "disk" { + if len(b) < 6 { + return fmt.Errorf("invalid encoding: expected at least %d bytes, for track and disk numbers, got %d", 6, len(b)) + } + + m.data[name] = int(b[3]) + m.data[name+"_count"] = int(b[5]) + return nil + } + + if contentType == "implicit" { + if name == "covr" { + if bytes.HasPrefix(b, pngHeader) { + contentType = "png" + } + // TODO(dhowden): Detect JPEG formats too (harder). + } + } + + var data interface{} + switch contentType { + case "implicit": + if _, ok := atoms[name]; ok { + return fmt.Errorf("unhandled implicit content type for required atom: %q", name) + } + return nil + + case "text": + data = string(b) + + case "uint8": + if len(b) < 1 { + return fmt.Errorf("invalid encoding: expected at least %d bytes, for integer tag data, got %d", 1, len(b)) + } + data = getInt(b[:1]) + + case "jpeg", "png": + data = &Picture{ + Ext: contentType, + MIMEType: "image/" + contentType, + Data: b, + } + } + m.data[name] = data + + return nil +} + +func readAtomHeader(r io.ReadSeeker) (name string, size uint32, err error) { + err = binary.Read(r, binary.BigEndian, &size) + if err != nil { + return + } + name, err = readString(r, 4) + return +} + +// Generic atom. +// Should have 3 sub atoms : mean, name and data. +// We check that mean is "com.apple.iTunes" or others and we use the subname as +// the name, and move to the data atom. +// Data atom could have multiple data values, each with a header. +// If anything goes wrong, we jump at the end of the "----" atom. +func readCustomAtom(r io.ReadSeeker, size uint32) (_ string, data []string, _ error) { + subNames := make(map[string]string) + + for size > 8 { + subName, subSize, err := readAtomHeader(r) + if err != nil { + return "", nil, err + } + + // Remove the size of the atom from the size counter + if size >= subSize { + size -= subSize + } else { + return "", nil, errors.New("--- invalid size") + } + + b, err := readBytes(r, uint(subSize-8)) + if err != nil { + return "", nil, err + } + + if len(b) < 4 { + return "", nil, fmt.Errorf("invalid encoding: expected at least %d bytes, got %d", 4, len(b)) + } + switch subName { + case "mean", "name": + subNames[subName] = string(b[4:]) + case "data": + data = append(data, string(b[4:])) + } + } + + // there should remain only the header size + if size != 8 { + err := errors.New("---- atom out of bounds") + return "", nil, err + } + + if !means[subNames["mean"]] || subNames["name"] == "" || len(data) == 0 { + return "----", nil, nil + } + + return subNames["name"], data, nil +} + +func (metadataMP4) Format() Format { return MP4 } +func (m metadataMP4) FileType() FileType { return m.fileType } + +func (m metadataMP4) Raw() map[string]interface{} { return m.data } + +func (m metadataMP4) getString(n []string) string { + for _, k := range n { + if x, ok := m.data[k]; ok { + return x.(string) + } + } + return "" +} + +func (m metadataMP4) getInt(n []string) int { + for _, k := range n { + if x, ok := m.data[k]; ok { + return x.(int) + } + } + return 0 +} + +func (m metadataMP4) Title() string { + return m.getString(atoms.Name("title")) +} + +func (m metadataMP4) Artist() string { + return m.getString(atoms.Name("artist")) +} + +func (m metadataMP4) Album() string { + return m.getString(atoms.Name("album")) +} + +func (m metadataMP4) AlbumArtist() string { + return m.getString(atoms.Name("album_artist")) +} + +func (m metadataMP4) Composer() string { + return m.getString(atoms.Name("composer")) +} + +func (m metadataMP4) Genre() string { + return m.getString(atoms.Name("genre")) +} + +func (m metadataMP4) Year() int { + date := m.getString(atoms.Name("year")) + if len(date) >= 4 { + year, _ := strconv.Atoi(date[:4]) + return year + } + return 0 +} + +func (m metadataMP4) Track() (int, int) { + x := m.getInt([]string{"trkn"}) + if n, ok := m.data["trkn_count"]; ok { + return x, n.(int) + } + return x, 0 +} + +func (m metadataMP4) Disc() (int, int) { + x := m.getInt([]string{"disk"}) + if n, ok := m.data["disk_count"]; ok { + return x, n.(int) + } + return x, 0 +} + +func (m metadataMP4) Lyrics() string { + t, ok := m.data["\xa9lyr"] + if !ok { + return "" + } + return t.(string) +} + +func (m metadataMP4) Comment() string { + t, ok := m.data["\xa9cmt"] + if !ok { + return "" + } + return t.(string) +} + +func (m metadataMP4) Picture() *Picture { + v, ok := m.data["covr"] + if !ok { + return nil + } + p, _ := v.(*Picture) + return p +} diff --git a/vendor/github.com/dhowden/tag/ogg.go b/vendor/github.com/dhowden/tag/ogg.go new file mode 100644 index 0000000..f5c4770 --- /dev/null +++ b/vendor/github.com/dhowden/tag/ogg.go @@ -0,0 +1,174 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" +) + +var ( + vorbisCommentPrefix = []byte("\x03vorbis") + opusTagsPrefix = []byte("OpusTags") +) + +var oggCRC32Poly04c11db7 = oggCRCTable(0x04c11db7) + +type crc32Table [256]uint32 + +func oggCRCTable(poly uint32) *crc32Table { + var t crc32Table + + for i := 0; i < 256; i++ { + crc := uint32(i) << 24 + for j := 0; j < 8; j++ { + if crc&0x80000000 != 0 { + crc = (crc << 1) ^ poly + } else { + crc <<= 1 + } + } + t[i] = crc + } + + return &t +} + +func oggCRCUpdate(crc uint32, tab *crc32Table, p []byte) uint32 { + for _, v := range p { + crc = (crc << 8) ^ tab[byte(crc>>24)^v] + } + return crc +} + +type oggPageHeader struct { + Magic [4]byte // "OggS" + Version uint8 + Flags uint8 + GranulePosition uint64 + SerialNumber uint32 + SequenceNumber uint32 + CRC uint32 + Segments uint8 +} + +type oggDemuxer struct { + packetBufs map[uint32]*bytes.Buffer +} + +// Read ogg packets, can return empty slice of packets and nil err +// if more data is needed +func (o *oggDemuxer) Read(r io.Reader) ([][]byte, error) { + headerBuf := &bytes.Buffer{} + var oh oggPageHeader + if err := binary.Read(io.TeeReader(r, headerBuf), binary.LittleEndian, &oh); err != nil { + return nil, err + } + + if bytes.Compare(oh.Magic[:], []byte("OggS")) != 0 { + // TODO: seek for syncword? + return nil, errors.New("expected 'OggS'") + } + + segmentTable := make([]byte, oh.Segments) + if _, err := io.ReadFull(r, segmentTable); err != nil { + return nil, err + } + var segmentsSize int64 + for _, s := range segmentTable { + segmentsSize += int64(s) + } + segmentsData := make([]byte, segmentsSize) + if _, err := io.ReadFull(r, segmentsData); err != nil { + return nil, err + } + + headerBytes := headerBuf.Bytes() + // reset CRC to zero in header before checksum + headerBytes[22] = 0 + headerBytes[23] = 0 + headerBytes[24] = 0 + headerBytes[25] = 0 + crc := oggCRCUpdate(0, oggCRC32Poly04c11db7, headerBytes) + crc = oggCRCUpdate(crc, oggCRC32Poly04c11db7, segmentTable) + crc = oggCRCUpdate(crc, oggCRC32Poly04c11db7, segmentsData) + if crc != oh.CRC { + return nil, fmt.Errorf("expected crc %x != %x", oh.CRC, crc) + } + + if o.packetBufs == nil { + o.packetBufs = map[uint32]*bytes.Buffer{} + } + + var packetBuf *bytes.Buffer + continued := oh.Flags&0x1 != 0 + if continued { + if b, ok := o.packetBufs[oh.SerialNumber]; ok { + packetBuf = b + } else { + return nil, fmt.Errorf("could not find continued packet %d", oh.SerialNumber) + } + } else { + packetBuf = &bytes.Buffer{} + } + + var packets [][]byte + var p int + for _, s := range segmentTable { + packetBuf.Write(segmentsData[p : p+int(s)]) + if s < 255 { + packets = append(packets, packetBuf.Bytes()) + packetBuf = &bytes.Buffer{} + } + p += int(s) + } + + o.packetBufs[oh.SerialNumber] = packetBuf + + return packets, nil +} + +// ReadOGGTags reads OGG metadata from the io.ReadSeeker, returning the resulting +// metadata in a Metadata implementation, or non-nil error if there was a problem. +// See http://www.xiph.org/vorbis/doc/Vorbis_I_spec.html +// and http://www.xiph.org/ogg/doc/framing.html for details. +// For Opus see https://tools.ietf.org/html/rfc7845 +func ReadOGGTags(r io.Reader) (Metadata, error) { + od := &oggDemuxer{} + for { + bs, err := od.Read(r) + if err != nil { + return nil, err + } + + for _, b := range bs { + switch { + case bytes.HasPrefix(b, vorbisCommentPrefix): + m := &metadataOGG{ + newMetadataVorbis(), + } + err = m.readVorbisComment(bytes.NewReader(b[len(vorbisCommentPrefix):])) + return m, err + case bytes.HasPrefix(b, opusTagsPrefix): + m := &metadataOGG{ + newMetadataVorbis(), + } + err = m.readVorbisComment(bytes.NewReader(b[len(opusTagsPrefix):])) + return m, err + } + } + } +} + +type metadataOGG struct { + *metadataVorbis +} + +func (m *metadataOGG) FileType() FileType { + return OGG +} diff --git a/vendor/github.com/dhowden/tag/sum.go b/vendor/github.com/dhowden/tag/sum.go new file mode 100644 index 0000000..97b27a7 --- /dev/null +++ b/vendor/github.com/dhowden/tag/sum.go @@ -0,0 +1,219 @@ +package tag + +import ( + "crypto/sha1" + "encoding/binary" + "errors" + "fmt" + "hash" + "io" +) + +// Sum creates a checksum of the audio file data provided by the io.ReadSeeker which is metadata +// (ID3, MP4) invariant. +func Sum(r io.ReadSeeker) (string, error) { + b, err := readBytes(r, 11) + if err != nil { + return "", err + } + + _, err = r.Seek(-11, io.SeekCurrent) + if err != nil { + return "", fmt.Errorf("could not seek back to original position: %v", err) + } + + switch { + case string(b[0:4]) == "fLaC": + return SumFLAC(r) + + case string(b[4:11]) == "ftypM4A": + return SumAtoms(r) + + case string(b[0:3]) == "ID3": + return SumID3v2(r) + } + + h, err := SumID3v1(r) + if err != nil { + if err == ErrNotID3v1 { + return SumAll(r) + } + return "", err + } + return h, nil +} + +// SumAll returns a checksum of the content from the reader (until EOF). +func SumAll(r io.ReadSeeker) (string, error) { + h := sha1.New() + _, err := io.Copy(h, r) + if err != nil { + return "", nil + } + return hashSum(h), nil +} + +// SumAtoms constructs a checksum of MP4 audio file data provided by the io.ReadSeeker which is +// metadata invariant. +func SumAtoms(r io.ReadSeeker) (string, error) { + for { + var size uint32 + err := binary.Read(r, binary.BigEndian, &size) + if err != nil { + if err == io.EOF { + return "", fmt.Errorf("reached EOF before audio data") + } + return "", err + } + + name, err := readString(r, 4) + if err != nil { + return "", err + } + + switch name { + case "meta": + // next_item_id (int32) + _, err := r.Seek(4, io.SeekCurrent) + if err != nil { + return "", err + } + fallthrough + + case "moov", "udta", "ilst": + continue + + case "mdat": // stop when we get to the data + h := sha1.New() + _, err := io.CopyN(h, r, int64(size-8)) + if err != nil { + return "", fmt.Errorf("error reading audio data: %v", err) + } + return hashSum(h), nil + } + + _, err = r.Seek(int64(size-8), io.SeekCurrent) + if err != nil { + return "", fmt.Errorf("error reading '%v' tag: %v", name, err) + } + } +} + +func sizeToEndOffset(r io.ReadSeeker, offset int64) (int64, error) { + n, err := r.Seek(-128, io.SeekEnd) + if err != nil { + return 0, fmt.Errorf("error seeking end offset (%d bytes): %v", offset, err) + } + + _, err = r.Seek(-n, io.SeekCurrent) + if err != nil { + return 0, fmt.Errorf("error seeking back to original position: %v", err) + } + return n, nil +} + +// SumID3v1 constructs a checksum of MP3 audio file data (assumed to have ID3v1 tags) provided +// by the io.ReadSeeker which is metadata invariant. +func SumID3v1(r io.ReadSeeker) (string, error) { + n, err := sizeToEndOffset(r, 128) + if err != nil { + return "", fmt.Errorf("error determining read size to ID3v1 header: %v", err) + } + + // TODO: improve this check??? + if n <= 0 { + return "", fmt.Errorf("file size must be greater than 128 bytes (ID3v1 header size) for MP3") + } + + h := sha1.New() + _, err = io.CopyN(h, r, n) + if err != nil { + return "", fmt.Errorf("error reading %v bytes: %v", n, err) + } + return hashSum(h), nil +} + +// SumID3v2 constructs a checksum of MP3 audio file data (assumed to have ID3v2 tags) provided by the +// io.ReadSeeker which is metadata invariant. +func SumID3v2(r io.ReadSeeker) (string, error) { + header, _, err := readID3v2Header(r) + if err != nil { + return "", fmt.Errorf("error reading ID3v2 header: %v", err) + } + + _, err = r.Seek(int64(header.Size), io.SeekCurrent) + if err != nil { + return "", fmt.Errorf("error seeking to end of ID3V2 header: %v", err) + } + + n, err := sizeToEndOffset(r, 128) + if err != nil { + return "", fmt.Errorf("error determining read size to ID3v1 header: %v", err) + } + + // TODO: remove this check????? + if n < 0 { + return "", fmt.Errorf("file size must be greater than 128 bytes for MP3: %v bytes", n) + } + + h := sha1.New() + _, err = io.CopyN(h, r, n) + if err != nil { + return "", fmt.Errorf("error reading %v bytes: %v", n, err) + } + return hashSum(h), nil +} + +// SumFLAC costructs a checksum of the FLAC audio file data provided by the io.ReadSeeker (ignores +// metadata fields). +func SumFLAC(r io.ReadSeeker) (string, error) { + flac, err := readString(r, 4) + if err != nil { + return "", err + } + if flac != "fLaC" { + return "", errors.New("expected 'fLaC'") + } + + for { + last, err := skipFLACMetadataBlock(r) + if err != nil { + return "", err + } + + if last { + break + } + } + + h := sha1.New() + _, err = io.Copy(h, r) + if err != nil { + return "", fmt.Errorf("error reading data bytes from FLAC: %v", err) + } + return hashSum(h), nil +} + +func skipFLACMetadataBlock(r io.ReadSeeker) (last bool, err error) { + blockHeader, err := readBytes(r, 1) + if err != nil { + return + } + + if getBit(blockHeader[0], 7) { + blockHeader[0] ^= (1 << 7) + last = true + } + + blockLen, err := readInt(r, 3) + if err != nil { + return + } + + _, err = r.Seek(int64(blockLen), io.SeekCurrent) + return +} + +func hashSum(h hash.Hash) string { + return fmt.Sprintf("%x", h.Sum([]byte{})) +} diff --git a/vendor/github.com/dhowden/tag/tag.go b/vendor/github.com/dhowden/tag/tag.go new file mode 100644 index 0000000..306f1d7 --- /dev/null +++ b/vendor/github.com/dhowden/tag/tag.go @@ -0,0 +1,147 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package tag provides MP3 (ID3: v1, 2.2, 2.3 and 2.4), MP4, FLAC and OGG metadata detection, +// parsing and artwork extraction. +// +// Detect and parse tag metadata from an io.ReadSeeker (i.e. an *os.File): +// m, err := tag.ReadFrom(f) +// if err != nil { +// log.Fatal(err) +// } +// log.Print(m.Format()) // The detected format. +// log.Print(m.Title()) // The title of the track (see Metadata interface for more details). +package tag + +import ( + "errors" + "fmt" + "io" +) + +// ErrNoTagsFound is the error returned by ReadFrom when the metadata format +// cannot be identified. +var ErrNoTagsFound = errors.New("no tags found") + +// ReadFrom detects and parses audio file metadata tags (currently supports ID3v1,2.{2,3,4}, MP4, FLAC/OGG). +// Returns non-nil error if the format of the given data could not be determined, or if there was a problem +// parsing the data. +func ReadFrom(r io.ReadSeeker) (Metadata, error) { + b, err := readBytes(r, 11) + if err != nil { + return nil, err + } + + _, err = r.Seek(-11, io.SeekCurrent) + if err != nil { + return nil, fmt.Errorf("could not seek back to original position: %v", err) + } + + switch { + case string(b[0:4]) == "fLaC": + return ReadFLACTags(r) + + case string(b[0:4]) == "OggS": + return ReadOGGTags(r) + + case string(b[4:8]) == "ftyp": + return ReadAtoms(r) + + case string(b[0:3]) == "ID3": + return ReadID3v2Tags(r) + + case string(b[0:4]) == "DSD ": + return ReadDSFTags(r) + } + + m, err := ReadID3v1Tags(r) + if err != nil { + if err == ErrNotID3v1 { + err = ErrNoTagsFound + } + return nil, err + } + return m, nil +} + +// Format is an enumeration of metadata types supported by this package. +type Format string + +// Supported tag formats. +const ( + UnknownFormat Format = "" // Unknown Format. + ID3v1 Format = "ID3v1" // ID3v1 tag format. + ID3v2_2 Format = "ID3v2.2" // ID3v2.2 tag format. + ID3v2_3 Format = "ID3v2.3" // ID3v2.3 tag format (most common). + ID3v2_4 Format = "ID3v2.4" // ID3v2.4 tag format. + MP4 Format = "MP4" // MP4 tag (atom) format (see http://www.ftyps.com/ for a full file type list) + VORBIS Format = "VORBIS" // Vorbis Comment tag format. +) + +// FileType is an enumeration of the audio file types supported by this package, in particular +// there are audio file types which share metadata formats, and this type is used to distinguish +// between them. +type FileType string + +// Supported file types. +const ( + UnknownFileType FileType = "" // Unknown FileType. + MP3 FileType = "MP3" // MP3 file + M4A FileType = "M4A" // M4A file Apple iTunes (ACC) Audio + M4B FileType = "M4B" // M4A file Apple iTunes (ACC) Audio Book + M4P FileType = "M4P" // M4A file Apple iTunes (ACC) AES Protected Audio + ALAC FileType = "ALAC" // Apple Lossless file FIXME: actually detect this + FLAC FileType = "FLAC" // FLAC file + OGG FileType = "OGG" // OGG file + DSF FileType = "DSF" // DSF file DSD Sony format see https://dsd-guide.com/sites/default/files/white-papers/DSFFileFormatSpec_E.pdf +) + +// Metadata is an interface which is used to describe metadata retrieved by this package. +type Metadata interface { + // Format returns the metadata Format used to encode the data. + Format() Format + + // FileType returns the file type of the audio file. + FileType() FileType + + // Title returns the title of the track. + Title() string + + // Album returns the album name of the track. + Album() string + + // Artist returns the artist name of the track. + Artist() string + + // AlbumArtist returns the album artist name of the track. + AlbumArtist() string + + // Composer returns the composer of the track. + Composer() string + + // Year returns the year of the track. + Year() int + + // Genre returns the genre of the track. + Genre() string + + // Track returns the track number and total tracks, or zero values if unavailable. + Track() (int, int) + + // Disc returns the disc number and total discs, or zero values if unavailable. + Disc() (int, int) + + // Picture returns a picture, or nil if not available. + Picture() *Picture + + // Lyrics returns the lyrics, or an empty string if unavailable. + Lyrics() string + + // Comment returns the comment, or an empty string if unavailable. + Comment() string + + // Raw returns the raw mapping of retrieved tag names and associated values. + // NB: tag/atom names are not standardised between formats. + Raw() map[string]interface{} +} diff --git a/vendor/github.com/dhowden/tag/util.go b/vendor/github.com/dhowden/tag/util.go new file mode 100644 index 0000000..c738d2f --- /dev/null +++ b/vendor/github.com/dhowden/tag/util.go @@ -0,0 +1,102 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "bytes" + "encoding/binary" + "io" +) + +func getBit(b byte, n uint) bool { + x := byte(1 << n) + return (b & x) == x +} + +func get7BitChunkedInt(b []byte) int { + var n int + for _, x := range b { + n = n << 7 + n |= int(x) + } + return n +} + +func getInt(b []byte) int { + var n int + for _, x := range b { + n = n << 8 + n |= int(x) + } + return n +} + +func readUint64LittleEndian(r io.Reader) (uint64, error) { + b, err := readBytes(r, 8) + if err != nil { + return 0, err + } + return binary.LittleEndian.Uint64(b), nil +} + +// readBytesMaxUpfront is the max up-front allocation allowed +const readBytesMaxUpfront = 10 << 20 // 10MB + +func readBytes(r io.Reader, n uint) ([]byte, error) { + if n > readBytesMaxUpfront { + b := &bytes.Buffer{} + if _, err := io.CopyN(b, r, int64(n)); err != nil { + return nil, err + } + return b.Bytes(), nil + } + + b := make([]byte, n) + _, err := io.ReadFull(r, b) + if err != nil { + return nil, err + } + return b, nil +} + +func readString(r io.Reader, n uint) (string, error) { + b, err := readBytes(r, n) + if err != nil { + return "", err + } + return string(b), nil +} + +func readUint(r io.Reader, n uint) (uint, error) { + x, err := readInt(r, n) + if err != nil { + return 0, err + } + return uint(x), nil +} + +func readInt(r io.Reader, n uint) (int, error) { + b, err := readBytes(r, n) + if err != nil { + return 0, err + } + return getInt(b), nil +} + +func read7BitChunkedUint(r io.Reader, n uint) (uint, error) { + b, err := readBytes(r, n) + if err != nil { + return 0, err + } + return uint(get7BitChunkedInt(b)), nil +} + +func readUint32LittleEndian(r io.Reader) (uint32, error) { + b, err := readBytes(r, 4) + if err != nil { + return 0, err + } + return binary.LittleEndian.Uint32(b), nil +} diff --git a/vendor/github.com/dhowden/tag/vorbis.go b/vendor/github.com/dhowden/tag/vorbis.go new file mode 100644 index 0000000..f1c6a19 --- /dev/null +++ b/vendor/github.com/dhowden/tag/vorbis.go @@ -0,0 +1,266 @@ +// Copyright 2015, David Howden +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" +) + +func newMetadataVorbis() *metadataVorbis { + return &metadataVorbis{ + c: make(map[string]string), + } +} + +type metadataVorbis struct { + c map[string]string // the vorbis comments + p *Picture +} + +func (m *metadataVorbis) readVorbisComment(r io.Reader) error { + vendorLen, err := readUint32LittleEndian(r) + if err != nil { + return err + } + + vendor, err := readString(r, uint(vendorLen)) + if err != nil { + return err + } + m.c["vendor"] = vendor + + commentsLen, err := readUint32LittleEndian(r) + if err != nil { + return err + } + + for i := uint32(0); i < commentsLen; i++ { + l, err := readUint32LittleEndian(r) + if err != nil { + return err + } + s, err := readString(r, uint(l)) + if err != nil { + return err + } + k, v, err := parseComment(s) + if err != nil { + return err + } + m.c[strings.ToLower(k)] = v + } + + if b64data, ok := m.c["metadata_block_picture"]; ok { + data, err := base64.StdEncoding.DecodeString(b64data) + if err != nil { + return err + } + m.readPictureBlock(bytes.NewReader(data)) + } + + return nil +} + +func (m *metadataVorbis) readPictureBlock(r io.Reader) error { + b, err := readInt(r, 4) + if err != nil { + return err + } + pictureType, ok := pictureTypes[byte(b)] + if !ok { + return fmt.Errorf("invalid picture type: %v", b) + } + mimeLen, err := readUint(r, 4) + if err != nil { + return err + } + mime, err := readString(r, mimeLen) + if err != nil { + return err + } + + ext := "" + switch mime { + case "image/jpeg": + ext = "jpg" + case "image/png": + ext = "png" + case "image/gif": + ext = "gif" + } + + descLen, err := readUint(r, 4) + if err != nil { + return err + } + desc, err := readString(r, descLen) + if err != nil { + return err + } + + // We skip width <32>, height <32>, colorDepth <32>, coloresUsed <32> + _, err = readInt(r, 4) // width + if err != nil { + return err + } + _, err = readInt(r, 4) // height + if err != nil { + return err + } + _, err = readInt(r, 4) // color depth + if err != nil { + return err + } + _, err = readInt(r, 4) // colors used + if err != nil { + return err + } + + dataLen, err := readInt(r, 4) + if err != nil { + return err + } + data := make([]byte, dataLen) + _, err = io.ReadFull(r, data) + if err != nil { + return err + } + + m.p = &Picture{ + Ext: ext, + MIMEType: mime, + Type: pictureType, + Description: desc, + Data: data, + } + return nil +} + +func parseComment(c string) (k, v string, err error) { + kv := strings.SplitN(c, "=", 2) + if len(kv) != 2 { + err = errors.New("vorbis comment must contain '='") + return + } + k = kv[0] + v = kv[1] + return +} + +func (m *metadataVorbis) Format() Format { + return VORBIS +} + +func (m *metadataVorbis) Raw() map[string]interface{} { + raw := make(map[string]interface{}, len(m.c)) + for k, v := range m.c { + raw[k] = v + } + return raw +} + +func (m *metadataVorbis) Title() string { + return m.c["title"] +} + +func (m *metadataVorbis) Artist() string { + // ARTIST + // The artist generally considered responsible for the work. In popular music + // this is usually the performing band or singer. For classical music it would + // be the composer. For an audio book it would be the author of the original text. + return m.c["artist"] +} + +func (m *metadataVorbis) Album() string { + return m.c["album"] +} + +func (m *metadataVorbis) AlbumArtist() string { + // This field isn't actually included in the standard, though + // it is commonly assigned to albumartist. + return m.c["albumartist"] +} + +func (m *metadataVorbis) Composer() string { + if m.c["composer"] != "" { + return m.c["composer"] + } + // PERFORMER + // The artist(s) who performed the work. In classical music this would be the + // conductor, orchestra, soloists. In an audio book it would be the actor who + // did the reading. In popular music this is typically the same as the ARTIST + // and is omitted. + if m.c["performer"] != "" { + return m.c["performer"] + } + return m.c["artist"] +} + +func (m *metadataVorbis) Genre() string { + return m.c["genre"] +} + +func (m *metadataVorbis) Year() int { + var dateFormat string + + // The date need to follow the international standard https://en.wikipedia.org/wiki/ISO_8601 + // and obviously the VorbisComment standard https://wiki.xiph.org/VorbisComment#Date_and_time + switch len(m.c["date"]) { + case 0: + // Fallback on year tag as some files use that. + if len(m.c["year"]) != 0 { + year, err := strconv.Atoi(m.c["year"]) + if err == nil { + return year + } + } + return 0 + case 4: + dateFormat = "2006" + case 7: + dateFormat = "2006-01" + case 10: + dateFormat = "2006-01-02" + } + + t, _ := time.Parse(dateFormat, m.c["date"]) + return t.Year() +} + +func (m *metadataVorbis) Track() (int, int) { + x, _ := strconv.Atoi(m.c["tracknumber"]) + // https://wiki.xiph.org/Field_names + n, _ := strconv.Atoi(m.c["tracktotal"]) + return x, n +} + +func (m *metadataVorbis) Disc() (int, int) { + // https://wiki.xiph.org/Field_names + x, _ := strconv.Atoi(m.c["discnumber"]) + n, _ := strconv.Atoi(m.c["disctotal"]) + return x, n +} + +func (m *metadataVorbis) Lyrics() string { + return m.c["lyrics"] +} + +func (m *metadataVorbis) Comment() string { + if m.c["comment"] != "" { + return m.c["comment"] + } + return m.c["description"] +} + +func (m *metadataVorbis) Picture() *Picture { + return m.p +} diff --git a/vendor/modules.txt b/vendor/modules.txt new file mode 100644 index 0000000..67fa028 --- /dev/null +++ b/vendor/modules.txt @@ -0,0 +1,3 @@ +# github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 +## explicit; go 1.20 +github.com/dhowden/tag