Update
parent
d9a2ced2fb
commit
641590e179
|
@ -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"]
|
||||||
|
|
24
README.md
24
README.md
|
@ -1,16 +1,26 @@
|
||||||
|
# U-Pod
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
This is U-Pod, the torpedo-style podcast system with minimal footprint and maximum utility.
|
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.
|
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.
|
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:
|
Once you've created your image, use Docker to run it after setting these environment variables:
|
||||||
|
|
||||||
|
<pre>
|
||||||
PODCAST_PORT=:8080
|
PODCAST_PORT=:8080
|
||||||
PODCAST_BASE_URL=http://your-domain.com
|
PODCAST_BASE_URL=http://your-domain.com
|
||||||
PODCAST_AUDIO_DIR=/app/data/audio
|
PODCAST_AUDIO_DIR=/app/data/audio
|
||||||
PODCAST_COVERS_DIR=/app/data/covers
|
PODCAST_COVERS_DIR=/app/data/covers
|
||||||
PODCAST_TITLE="My Podcast - and your mom"
|
PODCAST_TITLE="My Podcast - and your mom"
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
## File Linking
|
||||||
|
|
||||||
What links files in /audio to files in /covers?
|
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.
|
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.
|
- 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.
|
- I don't know how to create Docker images.
|
||||||
- Learn.
|
- Learn.
|
||||||
|
|
||||||
- What do you mean by environment variable?
|
- What do you mean by environment variable?
|
||||||
- Die! Die! Die!
|
- Die! Die! Die!
|
||||||
|
|
||||||
- I need you to change this, this and that.
|
- 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!!!!
|
|
@ -0,0 +1,5 @@
|
||||||
|
module u-pod
|
||||||
|
|
||||||
|
go 1.21.0
|
||||||
|
|
||||||
|
require github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
|
@ -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=
|
|
@ -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))
|
||||||
|
}
|
|
@ -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(`<?xml version="1.0"?>
|
||||||
|
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
|
<channel>
|
||||||
|
<title>%s</title>
|
||||||
|
<link>%s</link>`, podTitle, baseURL)
|
||||||
|
|
||||||
|
for _, ep := range episodes {
|
||||||
|
rss += fmt.Sprintf(`
|
||||||
|
<item>
|
||||||
|
<title>%s</title>
|
||||||
|
<enclosure url="%s/%s" type="audio/mpeg" length="%d"/>
|
||||||
|
<pubDate>%s</pubDate>`,
|
||||||
|
ep.Title, baseURL, ep.File, ep.Size, ep.PubDate)
|
||||||
|
|
||||||
|
if ep.Cover != "" {
|
||||||
|
rss += fmt.Sprintf(`
|
||||||
|
<itunes:image href="%s/%s"/>`, baseURL, ep.Cover)
|
||||||
|
}
|
||||||
|
|
||||||
|
rss += "\n </item>"
|
||||||
|
}
|
||||||
|
|
||||||
|
rss += "\n</channel>\n</rss>"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
||||||
|
language: go
|
||||||
|
|
||||||
|
go:
|
||||||
|
- 1.7
|
||||||
|
- tip
|
|
@ -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.
|
|
@ -0,0 +1,71 @@
|
||||||
|
# MP3/MP4/OGG/FLAC metadata parsing library
|
||||||
|
[](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
|
||||||
|
```
|
|
@ -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()
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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) }
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
// <Header for 'Unsynchronised lyrics/text transcription', ID: "USLT">
|
||||||
|
// <Header for 'Comment', ID: "COMM">
|
||||||
|
// -- readTextWithDescrFrame(data, true, true)
|
||||||
|
// Text encoding $xx
|
||||||
|
// Language $xx xx xx
|
||||||
|
// Content descriptor <text string according to encoding> $00 (00)
|
||||||
|
// Lyrics/text <full text string according to encoding>
|
||||||
|
// -- Header
|
||||||
|
// <Header for 'User defined text information frame', ID: "TXXX">
|
||||||
|
// <Header for 'User defined URL link frame', ID: "WXXX">
|
||||||
|
// -- readTextWithDescrFrame(data, false, <isDataEncoded>)
|
||||||
|
// Text encoding $xx
|
||||||
|
// Description <text string according to encoding> $00 (00)
|
||||||
|
// Value <text string according to encoding>
|
||||||
|
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 <textstring> $00 (00)
|
||||||
|
// Picture data <binary 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
|
||||||
|
// <Header for 'Attached picture', ID: "APIC">
|
||||||
|
// -- readAPICFrame
|
||||||
|
// Text encoding $xx
|
||||||
|
// MIME type <text string> $00
|
||||||
|
// Picture type $xx
|
||||||
|
// Description <text string according to encoding> $00 (00)
|
||||||
|
// Picture data <binary 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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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{}))
|
||||||
|
}
|
|
@ -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{}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
# github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||||
|
## explicit; go 1.20
|
||||||
|
github.com/dhowden/tag
|
Loading…
Reference in New Issue