package gobackend // WAV (RIFF) and AIFF/AIFC support: quality probing, tag reading/writing, and // cover-art extraction. These containers are not handled by go-flac, so chunks // are parsed/written by hand here. // // Tags are stored as an embedded ID3v2.4 tag (UTF-8): WAV uses a lowercase // "id3 " chunk, AIFF uses an uppercase "ID3 " chunk. ID3v2.4 is chosen because // the existing ID3 reader (parseID3v23Frames with version=4) reads synchsafe // frame sizes and UTF-8 text, so anything we write is read back losslessly. // // Reading also recognises a WAV "LIST"/"INFO" block as a fallback for files // that carry only RIFF INFO tags (common from other taggers). import ( "bytes" "encoding/binary" "fmt" "io" "math" "os" "path/filepath" "strconv" "strings" ) // WAVQuality / AIFFQuality mirror the other GetXQuality result shapes. type WAVQuality struct { SampleRate int BitDepth int Channels int Duration int } const ( wavMaxMetaChunk = 16 * 1024 * 1024 // safety cap for buffering a metadata chunk id3ChunkWAV = "id3 " id3ChunkAIFF = "ID3 " wavFormatPCM = 0x0001 wavFormatFloat = 0x0003 wavFormatExtensn = 0xFFFE ) // ---------- low-level chunk size helpers ---------- func putUint32(dst []byte, le bool, v uint32) { if le { binary.LittleEndian.PutUint32(dst, v) } else { binary.BigEndian.PutUint32(dst, v) } } func readUint32(b []byte, le bool) uint32 { if le { return binary.LittleEndian.Uint32(b) } return binary.BigEndian.Uint32(b) } func synchsafeEncode(n int) []byte { return []byte{ byte((n >> 21) & 0x7f), byte((n >> 14) & 0x7f), byte((n >> 7) & 0x7f), byte(n & 0x7f), } } func synchsafeDecode(b []byte) int { if len(b) < 4 { return 0 } return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3]) } // parseExtendedFloat80 decodes an 80-bit IEEE 754 extended float (used by the // AIFF COMM chunk for the sample rate). func parseExtendedFloat80(b []byte) float64 { if len(b) < 10 { return 0 } sign := 1.0 if b[0]&0x80 != 0 { sign = -1.0 } exponent := int(b[0]&0x7f)<<8 | int(b[1]) var mantissa uint64 for i := 2; i < 10; i++ { mantissa = mantissa<<8 | uint64(b[i]) } if exponent == 0 && mantissa == 0 { return 0 } return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63)) } // ---------- WAV (RIFF) ---------- type wavProbe struct { sampleRate int bitDepth int channels int byteRate int dataSize int64 id3 []byte info map[string]string } // streamProbeWAV walks the top-level RIFF chunks, buffering only the small // metadata chunks (fmt/id3/LIST) and skipping the large data chunk. func streamProbeWAV(f *os.File) (*wavProbe, error) { header := make([]byte, 12) if _, err := io.ReadFull(f, header); err != nil { return nil, err } if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { return nil, fmt.Errorf("not a WAVE file") } p := &wavProbe{info: map[string]string{}} hdr := make([]byte, 8) for { if _, err := io.ReadFull(f, hdr); err != nil { break } id := string(hdr[0:4]) size := readUint32(hdr[4:8], true) pad := int64(size) & 1 switch id { case "fmt ": buf := make([]byte, size) if _, err := io.ReadFull(f, buf); err != nil { return p, nil } if len(buf) >= 16 { format := binary.LittleEndian.Uint16(buf[0:2]) p.channels = int(binary.LittleEndian.Uint16(buf[2:4])) p.sampleRate = int(binary.LittleEndian.Uint32(buf[4:8])) p.byteRate = int(binary.LittleEndian.Uint32(buf[8:12])) p.bitDepth = int(binary.LittleEndian.Uint16(buf[14:16])) if format == wavFormatExtensn && len(buf) >= 26 { // Valid bits per sample lives in the extension; the real // PCM format tag is in the GUID, but bitDepth from the // container field is sufficient for display. if vb := int(binary.LittleEndian.Uint16(buf[18:20])); vb > 0 { p.bitDepth = vb } } } if pad == 1 { f.Seek(pad, io.SeekCurrent) } case "data": p.dataSize = int64(size) f.Seek(int64(size)+pad, io.SeekCurrent) case id3ChunkWAV, "ID3 ": if size > 0 && size <= wavMaxMetaChunk { buf := make([]byte, size) if _, err := io.ReadFull(f, buf); err == nil { p.id3 = buf } if pad == 1 { f.Seek(pad, io.SeekCurrent) } } else { f.Seek(int64(size)+pad, io.SeekCurrent) } case "LIST": if size > 0 && size <= wavMaxMetaChunk { buf := make([]byte, size) if _, err := io.ReadFull(f, buf); err == nil { parseRIFFInfo(buf, p.info) } if pad == 1 { f.Seek(pad, io.SeekCurrent) } } else { f.Seek(int64(size)+pad, io.SeekCurrent) } default: f.Seek(int64(size)+pad, io.SeekCurrent) } } return p, nil } // parseRIFFInfo reads a LIST/INFO block ("INFO" + sub-chunks like INAM, IART). func parseRIFFInfo(buf []byte, out map[string]string) { if len(buf) < 4 || string(buf[0:4]) != "INFO" { return } pos := 4 for pos+8 <= len(buf) { id := string(buf[pos : pos+4]) size := int(binary.LittleEndian.Uint32(buf[pos+4 : pos+8])) pos += 8 if size <= 0 || pos+size > len(buf) { break } val := strings.TrimRight(string(buf[pos:pos+size]), "\x00") out[id] = strings.TrimSpace(val) pos += size if size&1 == 1 { pos++ } } } func wavMetadataFromProbe(p *wavProbe) *AudioMetadata { if p == nil { return nil } if len(p.id3) > 0 { if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil && (meta.Title != "" || meta.Artist != "" || meta.Album != "") { return meta } } if len(p.info) > 0 { meta := &AudioMetadata{ Title: p.info["INAM"], Artist: p.info["IART"], Album: p.info["IPRD"], Genre: cleanGenre(p.info["IGNR"]), Date: p.info["ICRD"], Comment: p.info["ICMT"], Copyright: p.info["ICOP"], Composer: p.info["IMUS"], } if n, err := strconv.Atoi(strings.TrimSpace(p.info["ITRK"])); err == nil { meta.TrackNumber = n } if meta.Date != "" && len(meta.Date) >= 4 { meta.Year = meta.Date[:4] } if meta.Title != "" || meta.Artist != "" || meta.Album != "" { return meta } } return nil } // GetWAVQuality probes PCM parameters and computes duration from the data size. func GetWAVQuality(filePath string) (*WAVQuality, error) { f, err := os.Open(filePath) if err != nil { return nil, err } defer f.Close() p, err := streamProbeWAV(f) if err != nil { return nil, err } q := &WAVQuality{ SampleRate: p.sampleRate, BitDepth: p.bitDepth, Channels: p.channels, } if p.byteRate > 0 && p.dataSize > 0 { q.Duration = int(p.dataSize / int64(p.byteRate)) } else if p.sampleRate > 0 && p.channels > 0 && p.bitDepth > 0 && p.dataSize > 0 { bytesPerSec := int64(p.sampleRate * p.channels * p.bitDepth / 8) if bytesPerSec > 0 { q.Duration = int(p.dataSize / bytesPerSec) } } return q, nil } // ReadWAVTags reads tags from a WAV file (ID3 chunk preferred, RIFF INFO fallback). func ReadWAVTags(filePath string) (*AudioMetadata, error) { f, err := os.Open(filePath) if err != nil { return nil, err } defer f.Close() p, err := streamProbeWAV(f) if err != nil { return nil, err } meta := wavMetadataFromProbe(p) if meta == nil { return nil, fmt.Errorf("no WAV tags found") } return meta, nil } // ---------- AIFF / AIFC ---------- type aiffProbe struct { sampleRate int bitDepth int channels int numFrames int64 id3 []byte nameChunk string authChunk string annoChunk string copyrightChunk string } func streamProbeAIFF(f *os.File) (*aiffProbe, error) { header := make([]byte, 12) if _, err := io.ReadFull(f, header); err != nil { return nil, err } form := string(header[8:12]) if string(header[0:4]) != "FORM" || (form != "AIFF" && form != "AIFC") { return nil, fmt.Errorf("not an AIFF file") } p := &aiffProbe{} hdr := make([]byte, 8) for { if _, err := io.ReadFull(f, hdr); err != nil { break } id := string(hdr[0:4]) size := readUint32(hdr[4:8], false) pad := int64(size) & 1 switch id { case "COMM": buf := make([]byte, size) if _, err := io.ReadFull(f, buf); err != nil { return p, nil } if len(buf) >= 18 { p.channels = int(binary.BigEndian.Uint16(buf[0:2])) p.numFrames = int64(binary.BigEndian.Uint32(buf[2:6])) p.bitDepth = int(binary.BigEndian.Uint16(buf[6:8])) p.sampleRate = int(parseExtendedFloat80(buf[8:18]) + 0.5) } if pad == 1 { f.Seek(pad, io.SeekCurrent) } case id3ChunkAIFF, "id3 ": if size > 0 && size <= wavMaxMetaChunk { buf := make([]byte, size) if _, err := io.ReadFull(f, buf); err == nil { p.id3 = buf } if pad == 1 { f.Seek(pad, io.SeekCurrent) } } else { f.Seek(int64(size)+pad, io.SeekCurrent) } case "NAME", "AUTH", "ANNO", "(c) ": if size > 0 && size <= wavMaxMetaChunk { buf := make([]byte, size) if _, err := io.ReadFull(f, buf); err == nil { val := strings.TrimRight(strings.TrimSpace(string(buf)), "\x00") switch id { case "NAME": p.nameChunk = val case "AUTH": p.authChunk = val case "ANNO": p.annoChunk = val case "(c) ": p.copyrightChunk = val } } if pad == 1 { f.Seek(pad, io.SeekCurrent) } } else { f.Seek(int64(size)+pad, io.SeekCurrent) } default: f.Seek(int64(size)+pad, io.SeekCurrent) } } return p, nil } func aiffMetadataFromProbe(p *aiffProbe) *AudioMetadata { if p == nil { return nil } if len(p.id3) > 0 { if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil && (meta.Title != "" || meta.Artist != "" || meta.Album != "") { return meta } } if p.nameChunk != "" || p.authChunk != "" { meta := &AudioMetadata{ Title: p.nameChunk, Artist: p.authChunk, Comment: p.annoChunk, Copyright: p.copyrightChunk, } return meta } return nil } // GetAIFFQuality probes PCM parameters and computes duration from frame count. func GetAIFFQuality(filePath string) (*WAVQuality, error) { f, err := os.Open(filePath) if err != nil { return nil, err } defer f.Close() p, err := streamProbeAIFF(f) if err != nil { return nil, err } q := &WAVQuality{ SampleRate: p.sampleRate, BitDepth: p.bitDepth, Channels: p.channels, } if p.sampleRate > 0 && p.numFrames > 0 { q.Duration = int(p.numFrames / int64(p.sampleRate)) } return q, nil } // ReadAIFFTags reads tags from an AIFF file (ID3 chunk preferred, AIFF text chunks fallback). func ReadAIFFTags(filePath string) (*AudioMetadata, error) { f, err := os.Open(filePath) if err != nil { return nil, err } defer f.Close() p, err := streamProbeAIFF(f) if err != nil { return nil, err } meta := aiffMetadataFromProbe(p) if meta == nil { return nil, fmt.Errorf("no AIFF tags found") } return meta, nil } // ---------- ID3v2 reading from a buffered chunk ---------- // readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 " // or AIFF "ID3 " chunk) by reusing the existing frame parsers. func readID3v2FromBytes(data []byte) (*AudioMetadata, error) { if len(data) < 10 || string(data[0:3]) != "ID3" { return nil, fmt.Errorf("no ID3v2 header") } majorVersion := data[3] flags := data[5] unsync := (flags & 0x80) != 0 extendedHeader := (flags & 0x40) != 0 footerPresent := (flags & 0x10) != 0 size := synchsafeDecode(data[6:10]) if size <= 0 || 10+size > len(data) { size = len(data) - 10 } tagData := data[10 : 10+size] if footerPresent && len(tagData) >= 10 { footerStart := len(tagData) - 10 if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" { tagData = tagData[:footerStart] } } if extendedHeader { if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) { tagData = tagData[skip:] } } metadata := &AudioMetadata{} if majorVersion == 2 { parseID3v22Frames(tagData, metadata, unsync) } else { parseID3v23Frames(tagData, metadata, majorVersion, unsync) } return metadata, nil } // extractAPICFromID3 returns the first embedded picture (APIC/PIC) and its MIME. func extractAPICFromID3(tag []byte) ([]byte, string) { if len(tag) < 10 || string(tag[0:3]) != "ID3" { return nil, "" } ver := tag[3] size := synchsafeDecode(tag[6:10]) if size <= 0 || 10+size > len(tag) { size = len(tag) - 10 } data := tag[10 : 10+size] pos := 0 for { if ver == 2 { if pos+6 > len(data) || data[pos] == 0 { break } id := string(data[pos : pos+3]) fsz := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5]) if fsz <= 0 || pos+6+fsz > len(data) { break } if id == "PIC" { return parseAPICFrame(data[pos+6:pos+6+fsz], ver) } pos += 6 + fsz continue } if pos+10 > len(data) || data[pos] == 0 { break } id := string(data[pos : pos+4]) var fsz int if ver == 4 { fsz = synchsafeDecode(data[pos+4 : pos+8]) } else { fsz = int(binary.BigEndian.Uint32(data[pos+4 : pos+8])) } if fsz <= 0 || pos+10+fsz > len(data) { break } if id == "APIC" { return parseAPICFrame(data[pos+10:pos+10+fsz], ver) } pos += 10 + fsz } return nil, "" } // ---------- ID3v2.4 building ---------- // buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover. func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte { var frames bytes.Buffer writeFrame := func(id string, payload []byte) { frames.WriteString(id) frames.Write(synchsafeEncode(len(payload))) frames.Write([]byte{0, 0}) frames.Write(payload) } writeText := func(id, val string) { if strings.TrimSpace(val) == "" { return } payload := append([]byte{0x03}, []byte(val)...) writeFrame(id, payload) } writeText("TIT2", meta.Title) writeText("TPE1", meta.Artist) writeText("TALB", meta.Album) writeText("TPE2", meta.AlbumArtist) writeText("TCON", meta.Genre) writeText("TCOM", meta.Composer) writeText("TPUB", meta.Label) writeText("TCOP", meta.Copyright) writeText("TSRC", meta.ISRC) date := meta.Date if date == "" { date = meta.Year } writeText("TDRC", date) if meta.TrackNumber > 0 { if meta.TotalTracks > 0 { writeText("TRCK", fmt.Sprintf("%d/%d", meta.TrackNumber, meta.TotalTracks)) } else { writeText("TRCK", strconv.Itoa(meta.TrackNumber)) } } if meta.DiscNumber > 0 { if meta.TotalDiscs > 0 { writeText("TPOS", fmt.Sprintf("%d/%d", meta.DiscNumber, meta.TotalDiscs)) } else { writeText("TPOS", strconv.Itoa(meta.DiscNumber)) } } if strings.TrimSpace(meta.Comment) != "" { // COMM: encoding + language(3) + short desc(null) + text payload := []byte{0x03} payload = append(payload, []byte("eng")...) payload = append(payload, 0x00) // empty description payload = append(payload, []byte(meta.Comment)...) writeFrame("COMM", payload) } if strings.TrimSpace(meta.Lyrics) != "" { payload := []byte{0x03} payload = append(payload, []byte("eng")...) payload = append(payload, 0x00) payload = append(payload, []byte(meta.Lyrics)...) writeFrame("USLT", payload) } // ReplayGain as TXXX (description\0value), UTF-8. writeTXXX := func(desc, val string) { if strings.TrimSpace(val) == "" { return } payload := []byte{0x03} payload = append(payload, []byte(desc)...) payload = append(payload, 0x00) payload = append(payload, []byte(val)...) writeFrame("TXXX", payload) } writeTXXX("REPLAYGAIN_TRACK_GAIN", meta.ReplayGainTrackGain) writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak) writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain) writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak) if len(coverData) > 0 { if strings.TrimSpace(coverMIME) == "" { coverMIME = "image/jpeg" } // APIC: encoding + mime(null) + picture-type(0x03 front) + desc(null) + data payload := []byte{0x03} payload = append(payload, []byte(coverMIME)...) payload = append(payload, 0x00) payload = append(payload, 0x03) payload = append(payload, 0x00) payload = append(payload, coverData...) writeFrame("APIC", payload) } body := frames.Bytes() var out bytes.Buffer out.WriteString("ID3") out.Write([]byte{0x04, 0x00}) // v2.4.0 out.WriteByte(0x00) // flags out.Write(synchsafeEncode(len(body))) out.Write(body) return out.Bytes() } // ---------- tag writing (streaming chunk rewrite) ---------- // writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID, // matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end. // The audio data and all other chunks are preserved; container size is patched. func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) error { in, err := os.Open(filePath) if err != nil { return err } defer in.Close() header := make([]byte, 12) if _, err := io.ReadFull(in, header); err != nil { return err } if string(header[0:4]) != expectMagic { return fmt.Errorf("unexpected container magic %q", string(header[0:4])) } tmpPath := filePath + ".tagtmp" out, err := os.Create(tmpPath) if err != nil { return err } cleanup := func() { out.Close() os.Remove(tmpPath) } if _, err := out.Write(header); err != nil { cleanup() return err } var bodyLen int64 = 4 // the 4-byte form type after the size field hdr := make([]byte, 8) for { n, rerr := io.ReadFull(in, hdr) if n < 8 { break } if rerr != nil { break } id := string(hdr[0:4]) size := readUint32(hdr[4:8], le) pad := int64(size) & 1 if strings.EqualFold(id, chunkID) { // Drop the existing tag chunk. if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil { cleanup() return err } continue } if _, err := out.Write(hdr); err != nil { cleanup() return err } if _, err := io.CopyN(out, in, int64(size)+pad); err != nil { cleanup() return err } bodyLen += 8 + int64(size) + pad } // Append the new tag chunk. newSize := len(id3) chunkHdr := make([]byte, 8) copy(chunkHdr[0:4], chunkID) putUint32(chunkHdr[4:8], le, uint32(newSize)) if _, err := out.Write(chunkHdr); err != nil { cleanup() return err } if _, err := out.Write(id3); err != nil { cleanup() return err } if newSize&1 == 1 { if _, err := out.Write([]byte{0}); err != nil { cleanup() return err } } bodyLen += 8 + int64(newSize) + int64(newSize&1) // Patch the container size field (bytes 4..8). sizeBuf := make([]byte, 4) putUint32(sizeBuf, le, uint32(bodyLen)) if _, err := out.WriteAt(sizeBuf, 4); err != nil { cleanup() return err } if err := out.Close(); err != nil { os.Remove(tmpPath) return err } in.Close() return os.Rename(tmpPath, filePath) } func loadCoverForTag(fields map[string]string) ([]byte, string) { coverPath := strings.TrimSpace(fields["cover_path"]) if coverPath == "" { return nil, "" } data, err := os.ReadFile(coverPath) if err != nil || len(data) == 0 { return nil, "" } mime := "image/jpeg" if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { mime = "image/png" } return data, mime } func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata { atoi := func(k string) int { n := 0 if v, ok := fields[k]; ok && strings.TrimSpace(v) != "" { fmt.Sscanf(strings.TrimSpace(v), "%d", &n) } return n } return &AudioMetadata{ Title: fields["title"], Artist: fields["artist"], Album: fields["album"], AlbumArtist: fields["album_artist"], Date: fields["date"], TrackNumber: atoi("track_number"), TotalTracks: atoi("track_total"), DiscNumber: atoi("disc_number"), TotalDiscs: atoi("disc_total"), ISRC: fields["isrc"], Lyrics: fields["lyrics"], Genre: fields["genre"], Label: fields["label"], Copyright: fields["copyright"], Composer: fields["composer"], Comment: fields["comment"], ReplayGainTrackGain: fields["replaygain_track_gain"], ReplayGainTrackPeak: fields["replaygain_track_peak"], ReplayGainAlbumGain: fields["replaygain_album_gain"], ReplayGainAlbumPeak: fields["replaygain_album_peak"], } } // mergeWAVEditFields merges edit fields onto existing tags so untouched fields // (and cover art, when no new cover is provided) are preserved. func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]string) *AudioMetadata { meta := audioMetadataFromEditFields(fields) if existing == nil { return meta } // Only overwrite fields that are present as keys in the edit set; otherwise // keep the existing value. An empty value with the key present clears it. keep := func(key, newVal, oldVal string) string { if _, ok := fields[key]; ok { return newVal } return oldVal } meta.Title = keep("title", meta.Title, existing.Title) meta.Artist = keep("artist", meta.Artist, existing.Artist) meta.Album = keep("album", meta.Album, existing.Album) meta.AlbumArtist = keep("album_artist", meta.AlbumArtist, existing.AlbumArtist) meta.Genre = keep("genre", meta.Genre, existing.Genre) meta.Composer = keep("composer", meta.Composer, existing.Composer) meta.Label = keep("label", meta.Label, existing.Label) meta.Copyright = keep("copyright", meta.Copyright, existing.Copyright) meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC) meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics) meta.Comment = keep("comment", meta.Comment, existing.Comment) meta.Date = keep("date", meta.Date, existing.Date) if _, ok := fields["track_number"]; !ok { meta.TrackNumber = existing.TrackNumber } if _, ok := fields["track_total"]; !ok { meta.TotalTracks = existing.TotalTracks } if _, ok := fields["disc_number"]; !ok { meta.DiscNumber = existing.DiscNumber } if _, ok := fields["disc_total"]; !ok { meta.TotalDiscs = existing.TotalDiscs } if _, ok := fields["replaygain_track_gain"]; !ok { meta.ReplayGainTrackGain = existing.ReplayGainTrackGain } if _, ok := fields["replaygain_track_peak"]; !ok { meta.ReplayGainTrackPeak = existing.ReplayGainTrackPeak } if _, ok := fields["replaygain_album_gain"]; !ok { meta.ReplayGainAlbumGain = existing.ReplayGainAlbumGain } if _, ok := fields["replaygain_album_peak"]; !ok { meta.ReplayGainAlbumPeak = existing.ReplayGainAlbumPeak } return meta } // WriteWAVTags writes/merges tags into a WAV file's "id3 " chunk. func WriteWAVTags(filePath string, fields map[string]string) error { existing, _ := ReadWAVTags(filePath) meta := mergeEditFieldsOntoExisting(existing, fields) coverData, coverMIME := loadCoverForTag(fields) if coverData == nil { // Preserve an existing embedded cover when no new one is supplied. if f, err := os.Open(filePath); err == nil { if p, perr := streamProbeWAV(f); perr == nil && len(p.id3) > 0 { coverData, coverMIME = extractAPICFromID3(p.id3) } f.Close() } } tag := buildID3v24Tag(meta, coverData, coverMIME) return writeID3Chunk(filePath, "RIFF", id3ChunkWAV, true, tag) } // WriteAIFFTags writes/merges tags into an AIFF file's "ID3 " chunk. func WriteAIFFTags(filePath string, fields map[string]string) error { existing, _ := ReadAIFFTags(filePath) meta := mergeEditFieldsOntoExisting(existing, fields) coverData, coverMIME := loadCoverForTag(fields) if coverData == nil { if f, err := os.Open(filePath); err == nil { if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 { coverData, coverMIME = extractAPICFromID3(p.id3) } f.Close() } } tag := buildID3v24Tag(meta, coverData, coverMIME) return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag) } // ---------- library scan integration ---------- func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) { if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil { applyAudioMetadataToScan(metadata, result) } if quality, err := GetWAVQuality(filePath); err == nil && quality != nil { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate result.Duration = quality.Duration } result.Bitrate = 0 // lossless PCM result.Format = "wav" applyDefaultLibraryMetadata(filePath, displayNameHint, result) return result, nil } func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) { if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil { applyAudioMetadataToScan(metadata, result) } if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate result.Duration = quality.Duration } result.Bitrate = 0 // lossless PCM result.Format = "aiff" applyDefaultLibraryMetadata(filePath, displayNameHint, result) return result, nil } func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) { result.TrackName = metadata.Title result.ArtistName = metadata.Artist result.AlbumName = metadata.Album result.AlbumArtist = metadata.AlbumArtist result.ISRC = metadata.ISRC result.TrackNumber = metadata.TrackNumber result.TotalTracks = metadata.TotalTracks result.DiscNumber = metadata.DiscNumber result.TotalDiscs = metadata.TotalDiscs if metadata.Date != "" { result.ReleaseDate = metadata.Date } else { result.ReleaseDate = metadata.Year } result.Genre = metadata.Genre result.Composer = metadata.Composer result.Label = metadata.Label result.Copyright = metadata.Copyright } // extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a // WAV or AIFF file, or an error when none is present. func extractWAVAIFFCover(filePath string) ([]byte, string, error) { ext := strings.ToLower(filepath.Ext(filePath)) f, err := os.Open(filePath) if err != nil { return nil, "", err } defer f.Close() var id3 []byte switch ext { case ".aiff", ".aif", ".aifc": if p, perr := streamProbeAIFF(f); perr == nil { id3 = p.id3 } default: if p, perr := streamProbeWAV(f); perr == nil { id3 = p.id3 } } if len(id3) == 0 { return nil, "", fmt.Errorf("no embedded cover") } data, mime := extractAPICFromID3(id3) if len(data) == 0 { return nil, "", fmt.Errorf("no embedded cover") } return data, mime, nil }