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"] | ||||||
|  | 
 | ||||||
							
								
								
									
										16
									
								
								README.md
								
								
								
								
							
							
						
						
									
										16
									
								
								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,7 +38,7 @@ 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. | ||||||
|  | @ -42,5 +52,5 @@ FAQ: | ||||||
| - 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