Complete rewrite.

Everything is done in the library now, Abema's go-mp4 lib no longer needed.
This commit is contained in:
Sorrow446
2023-12-16 22:11:29 +00:00
committed by GitHub
parent 91290342e8
commit 0d364af5e4
6 changed files with 2032 additions and 0 deletions

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/Sorrow446/go-mp4tag
go 1.21.5

69
mp4tag.go Normal file
View File

@@ -0,0 +1,69 @@
package mp4tag
import (
"bytes"
"fmt"
"io"
"os"
)
func (mp4 *MP4) Close() error {
return mp4.f.Close()
}
func (mp4 *MP4) Read() (*MP4Tags, error) {
tags, _, err := mp4.actualRead()
return tags, err
}
func (mp4 *MP4) Write(tags *MP4Tags, delStrings []string) error {
if tags == nil && len(delStrings) == 0 {
return nil
}
err := mp4.actualWrite(tags, delStrings)
return err
}
func (mp4 *MP4) checkHeader() error {
buf := make([]byte, 8)
_, err := mp4.f.Seek(4, io.SeekStart)
if err != nil {
return err
}
_, err = io.ReadFull(mp4.f, buf)
if err != nil {
return err
}
if !bytes.Equal(buf[:4], []byte{0x66, 0x74, 0x79, 0x70}) {
return &ErrInvalidMagic{}
}
for _, ftyp := range ftyps {
if bytes.Equal(buf[4:], ftyp) {
return nil
}
}
return &ErrUnsupportedFtyp{
Msg: "unsupported ftyp: " + fmt.Sprintf("%x", buf[4:]),
}
}
func Open(trackPath string) (*MP4, error) {
f, err := os.Open(trackPath)
if err != nil {
return nil, err
}
stat, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
mp4 := &MP4{f: f, size : stat.Size(), path: trackPath}
err = mp4.checkHeader()
if err != nil {
f.Close()
return nil, err
}
return mp4, nil
}

307
objects.go Normal file
View File

@@ -0,0 +1,307 @@
package mp4tag
import "os"
type ErrBoxNotPresent struct {
Msg string
}
type ErrUnsupportedFtyp struct {
Msg string
}
type ErrInvalidStcoSize struct {}
type ErrInvalidMagic struct {}
func (e *ErrBoxNotPresent) Error() string {
return e.Msg
}
func (e *ErrUnsupportedFtyp) Error() string {
return e.Msg
}
func (_ *ErrInvalidStcoSize) Error() string {
return "stco size is invalid"
}
func (_ *ErrInvalidMagic) Error() string {
return "file header is corrupted or not an mp4 file"
}
var ftyps = [8][]byte{
{0x4D, 0x34, 0x41, 0x20}, // M4A
{0x4D, 0x34, 0x42, 0x20}, // M4B
{0x64, 0x61, 0x73, 0x68}, // dash
{0x6D, 0x70, 0x34, 0x31}, // mp41
{0x6D, 0x70, 0x34, 0x32}, // mp42
{0x69, 0x73, 0x6F, 0x6D}, // isom
{0x69, 0x73, 0x6F, 0x32}, // iso2
{0x61, 0x76, 0x63, 0x31}, // avc1
}
var containers = []string{
"moov", "udta", "meta", "ilst", "----", "(c)alb",
"aART", "(c)art", "(c)nam", "(c)cmt", "(c)gen", "gnre",
"(c)wrt", "(c)con", "cprt", "desc", "(c)lyr", "(c)nrt",
"(c)pub", "trkn", "covr", "(c)day", "disk", "(c)too",
"trak", "mdia", "minf", "stbl", "rtng", "plID",
"atID", "tmpo", "sonm", "soal", "soar", "soco",
"soaa",
}
// 0-9
var numbers = []rune{
0x30, 0x31, 0x32, 0x33, 0x34,
0x35, 0x36, 0x37, 0x38, 0x39,
}
type MP4 struct {
f *os.File
path string
size int64
}
type MP4Box struct {
StartOffset int64
EndOffset int64
BoxSize int64
Path string
}
type MP4Boxes struct {
Boxes []*MP4Box
}
type ImageType int8
const (
ImageTypeJPEG ImageType = iota + 13
ImageTypePNG
ImageTypeAuto
)
var resolveImageType = map[uint8]ImageType{
13: ImageTypeJPEG,
14: ImageTypePNG,
}
type ItunesAdvisory int8
const (
ItunesAdvisoryNone ItunesAdvisory = iota
ItunesAdvisoryExplicit
ItunesAdvisoryClean
)
var resolveItunesAdvisory = map[uint8]ItunesAdvisory{
1: ItunesAdvisoryExplicit,
2: ItunesAdvisoryClean,
}
// GenreNone
type Genre int8
const (
GenreNone Genre = iota
GenreBlues
GenreClassicRock
GenreCountry
GenreDance
GenreDisco
GenreFunk
GenreGrunge
GenreHipHop
GenreJazz
GenreMetal
GenreNewAge
GenreOldies
GenreOther
GenrePop
GenreRhythmAndBlues
GenreRap
GenreReggae
GenreRock
GenreTechno
GenreIndustrial
GenreAlternative
GenreSka
GenreDeathMetal
GenrePranks
GenreSoundtrack
GenreEurotechno
GenreAmbient
GenreTripHop
GenreVocal
GenreJassAndFunk
GenreFusion
GenreTrance
GenreClassical
GenreInstrumental
GenreAcid
GenreHouse
GenreGame
GenreSoundClip
GenreGospel
GenreNoise
GenreAlternativeRock
GenreBass
GenreSoul
GenrePunk
GenreSpace
GenreMeditative
GenreInstrumentalPop
GenreInstrumentalRock
GenreEthnic
GenreGothic
GenreDarkwave
GenreTechnoindustrial
GenreElectronic
GenrePopFolk
GenreEurodance
GenreSouthernRock
GenreComedy
GenreCull
GenreGangsta
GenreTop40
GenreChristianRap
GenrePopSlashFunk
GenreJungleMusic
GenreNativeUS
GenreCabaret
GenreNewWave
GenrePsychedelic
GenreRave
GenreShowtunes
GenreTrailer
GenreLofi
GenreTribal
GenreAcidPunk
GenreAcidJazz
GenrePolka
GenreRetro
GenreMusical
GenreRockNRoll
GenreHardRock
)
var resolveGenre = map[uint8]Genre{
1: GenreBlues,
2: GenreClassicRock,
3: GenreCountry,
4: GenreDance,
5: GenreDisco,
6: GenreFunk,
7: GenreGrunge,
8: GenreHipHop,
9: GenreJazz,
10: GenreMetal,
11: GenreNewAge,
12: GenreOldies,
13: GenreOther,
14: GenrePop,
15: GenreRhythmAndBlues,
16: GenreRap,
17: GenreReggae,
18: GenreRock,
19: GenreTechno,
20: GenreIndustrial,
21: GenreAlternative,
22: GenreSka,
23: GenreDeathMetal,
24: GenrePranks,
25: GenreSoundtrack,
26: GenreEurotechno,
27: GenreAmbient,
28: GenreTripHop,
29: GenreVocal,
30: GenreJassAndFunk,
31: GenreFusion,
32: GenreTrance,
33: GenreClassical,
34: GenreInstrumental,
35: GenreAcid,
36: GenreHouse,
37: GenreGame,
38: GenreSoundClip,
39: GenreGospel,
40: GenreNoise,
41: GenreAlternativeRock,
42: GenreBass,
43: GenreSoul,
44: GenrePunk,
45: GenreSpace,
46: GenreMeditative,
47: GenreInstrumentalPop,
48: GenreInstrumentalRock,
49: GenreEthnic,
50: GenreGothic,
51: GenreDarkwave,
52: GenreTechnoindustrial,
53: GenreElectronic,
54: GenrePopFolk,
55: GenreEurodance,
56: GenreSouthernRock,
57: GenreComedy,
58: GenreCull,
59: GenreGangsta,
60: GenreTop40,
61: GenreChristianRap,
62: GenrePopSlashFunk,
63: GenreJungleMusic,
64: GenreNativeUS,
65: GenreCabaret,
66: GenreNewWave,
67: GenrePsychedelic,
68: GenreRave,
69: GenreShowtunes,
70: GenreTrailer,
71: GenreLofi,
72: GenreTribal,
73: GenreAcidPunk,
74: GenreAcidJazz,
75: GenrePolka,
76: GenreRetro,
77: GenreMusical,
78: GenreRockNRoll,
79: GenreHardRock,
}
type MP4Picture struct {
Format ImageType
Data []byte
}
type MP4Tags struct {
Album string
AlbumSort string
AlbumArtist string
AlbumArtistSort string
Artist string
ArtistSort string
BPM int16
Comment string
Composer string
ComposerSort string
Conductor string
Copyright string
Custom map[string]string
CustomGenre string
Date string
Description string
Director string
DiscNumber int16
DiscTotal int16
Genre Genre
ItunesAdvisory ItunesAdvisory
ItunesAlbumID int32
ItunesArtistID int32
Lyrics string
Narrator string
Pictures []*MP4Picture
Publisher string
Title string
TitleSort string
TrackNumber int16
TrackTotal int16
Year int32
}

504
read.go Normal file
View File

@@ -0,0 +1,504 @@
package mp4tag
import (
"encoding/binary"
"fmt"
"io"
"strconv"
"strings"
)
func (boxes MP4Boxes) getBoxByPath(boxPath string) *MP4Box {
for _, box := range boxes.Boxes {
if box.Path == boxPath {
return box
}
}
return nil
}
func (boxes MP4Boxes) getBoxesByPath(boxPath string) []*MP4Box {
var outBoxes []*MP4Box
for _, box := range boxes.Boxes {
if box.Path == boxPath {
outBoxes = append(outBoxes, box)
}
}
return outBoxes
}
func (mp4 MP4) readString(size int64) (string, error) {
buf := make([]byte, size)
_, err := io.ReadFull(mp4.f, buf)
if err != nil {
return "", err
}
return string(buf), nil
}
func (mp4 MP4) readBoxName() (string, error) {
buf := make([]byte, 4)
_, err := io.ReadFull(mp4.f, buf)
if err != nil {
return "", err
}
boxName := string(buf)
if buf[0] == 0xA9 {
boxName = "(c)" + strings.ToLower(boxName[1:])
}
return boxName, nil
}
func (mp4 MP4) readI16BE() (int16, error) {
buf := make([]byte, 2)
_, err := io.ReadFull(mp4.f, buf)
if err != nil {
return -1, err
}
num := binary.BigEndian.Uint16(buf)
return int16(num), nil
}
func (mp4 MP4) readI32BE() (int32, error) {
buf := make([]byte, 4)
_, err := io.ReadFull(mp4.f, buf)
if err != nil {
return -1, err
}
num := binary.BigEndian.Uint32(buf)
return int32(num), nil
}
func (mp4 MP4) readBoxes(boxes MP4Boxes, parentEndsAt, level int64, p string) (MP4Boxes, error) {
empty := MP4Boxes{}
pos, err := getPos(mp4.f)
if err != nil {
return empty, err
}
if pos >= parentEndsAt {
return boxes, err
}
boxSizeI32, err := mp4.readI32BE()
if err != nil {
return empty, err
}
boxName, err := mp4.readBoxName()
if err != nil {
return empty, err
}
boxSize := int64(boxSizeI32)
endsAt := pos + boxSize
if boxName == "meta" {
_, err = mp4.f.Seek(4, io.SeekCurrent)
if err != nil {
return empty, err
}
}
p += "." + boxName
box := &MP4Box{
StartOffset: pos,
EndOffset: endsAt,
BoxSize: boxSize,
Path: p[1:],
}
boxes.Boxes = append(boxes.Boxes, box)
if containsStr(containers, boxName) {
boxes, err = mp4.readBoxes(boxes, endsAt, level+1, p)
if err != nil {
return empty, err
}
}
p = p[:len(p)-len(boxName)-1]
_, err = mp4.f.Seek(pos + boxSize, io.SeekStart)
if err != nil {
return empty, err
}
boxes, err = mp4.readBoxes(boxes, parentEndsAt, level, p)
return boxes, err
}
func checkBoxes(boxes MP4Boxes) error {
paths := [5]string{
"moov", "mdat", "moov.udta", "moov.udta.meta",
"moov.trak.mdia.minf.stbl.stco",
}
// "moov.udta.meta.ilst"
for _, path := range paths {
if boxes.getBoxByPath(path) == nil {
return &ErrBoxNotPresent{Msg: path + " box not present"}
}
}
return nil
}
func (mp4 MP4) readTag(boxes MP4Boxes, boxName string) (string, error) {
path := fmt.Sprintf("moov.udta.meta.ilst.%s.data", boxName)
box := boxes.getBoxByPath(path)
if box == nil {
return "", nil
}
_, err := mp4.f.Seek(box.StartOffset+16, io.SeekStart)
if err != nil {
return "", err
}
tag, err := mp4.readString(box.BoxSize-16)
return tag, err
}
func (mp4 MP4) readByte() (byte, error) {
buf := make([]byte, 1)
_, err := mp4.f.Read(buf)
if err != nil {
return 0x0, err
}
return buf[0], nil
}
func (mp4 MP4) readBPM(boxes MP4Boxes) (int16, error) {
box := boxes.getBoxByPath("moov.udta.meta.ilst.tmpo.data")
if box == nil {
return -1, nil
}
_, err := mp4.f.Seek(box.StartOffset+16, io.SeekStart)
if err != nil {
return -1, err
}
bpm, err := mp4.readI16BE()
return bpm, err
}
func (mp4 MP4) readPics(_boxes MP4Boxes) ([]*MP4Picture, error) {
var outPics []*MP4Picture
boxes := _boxes.getBoxesByPath("moov.udta.meta.ilst.covr.data")
if boxes == nil {
return nil, nil
}
for _, box := range boxes {
var pic MP4Picture
_, err := mp4.f.Seek(box.StartOffset+11, io.SeekStart)
if err != nil {
return nil, err
}
b, err := mp4.readByte()
if err != nil {
return nil, err
}
imageType, ok := resolveImageType[uint8(b)]
if ok {
if imageType == ImageTypeJPEG {
pic.Format = ImageTypeJPEG
} else {
pic.Format = ImageTypePNG
}
}
_, err = mp4.f.Seek(4, io.SeekCurrent)
if err != nil {
return nil, err
}
buf := make([]byte, box.BoxSize-16)
_, err = io.ReadFull(mp4.f, buf)
if err != nil {
return nil, err
}
pic.Data = buf
outPics = append(outPics, &pic)
}
return outPics, nil
}
func (mp4 MP4) readTrknDisk(boxes MP4Boxes, boxName string) (int16, int16, error) {
path := fmt.Sprintf("moov.udta.meta.ilst.%s.data", boxName)
box := boxes.getBoxByPath(path)
if box == nil {
return -1, -1, nil
}
_, err := mp4.f.Seek(box.StartOffset+18, io.SeekStart)
if err != nil {
return -1, -1, nil
}
num, err := mp4.readI16BE()
if err != nil {
return -1, -1, nil
}
total, err := mp4.readI16BE()
if err != nil {
return -1, -1, nil
}
return num, total, nil
}
func (mp4 MP4) readCustom(boxes MP4Boxes) (map[string]string, error) {
var (
names []string
values []string
)
path := "moov.udta.meta.ilst.----"
nameBoxes := boxes.getBoxesByPath(path+".name")
if nameBoxes == nil {
return nil, nil
}
for _, box := range nameBoxes {
_, err := mp4.f.Seek(box.StartOffset+12, io.SeekStart)
if err != nil {
return nil, err
}
name, err := mp4.readString(box.BoxSize-12)
if err != nil {
return nil, err
}
names = append(names, name)
}
dataBoxes := boxes.getBoxesByPath(path+".data")
for _, box := range dataBoxes {
_, err := mp4.f.Seek(box.StartOffset+16, io.SeekStart)
if err != nil {
return nil, err
}
value, err := mp4.readString(box.BoxSize-16)
if err != nil {
return nil, err
}
values = append(values, value)
}
custom := map[string]string{}
for idx, name := range names {
custom[name] = values[idx]
}
return custom, nil
}
func (mp4 MP4) readITAlbumID(boxes MP4Boxes) (int32, error) {
box := boxes.getBoxByPath("moov.udta.meta.ilst.plID.data")
if box == nil {
return -1, nil
}
_, err := mp4.f.Seek(box.StartOffset+20, io.SeekStart)
if err != nil {
return -1, err
}
id, err := mp4.readI32BE()
return id, err
}
func (mp4 MP4) readITArtistID(boxes MP4Boxes) (int32, error) {
box := boxes.getBoxByPath("moov.udta.meta.ilst.atID.data")
if box == nil {
return -1, nil
}
_, err := mp4.f.Seek(box.StartOffset+16, io.SeekStart)
if err != nil {
return -1, err
}
id, err := mp4.readI32BE()
return id, err
}
func (mp4 MP4) readAdvisory(boxes MP4Boxes) (ItunesAdvisory, error) {
none := ItunesAdvisoryNone
box := boxes.getBoxByPath("moov.udta.meta.ilst.rtng.data")
if box == nil {
return none, nil
}
_, err := mp4.f.Seek(box.StartOffset+16, io.SeekStart)
if err != nil {
return none, err
}
b, err := mp4.readByte()
if err != nil {
return none, err
}
advisory, ok := resolveItunesAdvisory[uint8(b)]
if !ok {
return none, nil
}
return advisory, nil
}
func (mp4 MP4) readGenre(boxes MP4Boxes) (Genre, error) {
none := GenreNone
box := boxes.getBoxByPath("moov.udta.meta.ilst.gnre.data")
if box == nil {
return none, nil
}
_, err := mp4.f.Seek(box.StartOffset+17, io.SeekStart)
if err != nil {
return none, err
}
b, err := mp4.readByte()
if err != nil {
return none, err
}
genre, ok := resolveGenre[uint8(b)]
if !ok {
return none, nil
}
return genre, nil
}
func (mp4 MP4) readTags(boxes MP4Boxes) (*MP4Tags, error) {
album, err := mp4.readTag(boxes, "(c)alb")
if err != nil {
return nil, err
}
albumArtist, err := mp4.readTag(boxes, "aART")
if err != nil {
return nil, err
}
artist, err := mp4.readTag(boxes, "(c)art")
if err != nil {
return nil, err
}
bpm, err := mp4.readBPM(boxes)
if err != nil {
return nil, err
}
comment, err := mp4.readTag(boxes, "(c)cmt")
if err != nil {
return nil, err
}
composer, err := mp4.readTag(boxes, "(c)wrt")
if err != nil {
return nil, err
}
conductor, err := mp4.readTag(boxes, "(c)con")
if err != nil {
return nil, err
}
copyright, err := mp4.readTag(boxes, "cprt")
if err != nil {
return nil, err
}
custom, err := mp4.readCustom(boxes)
if err != nil {
return nil, err
}
customGenre, err := mp4.readTag(boxes, "(c)gen")
if err != nil {
return nil, err
}
description, err := mp4.readTag(boxes, "desc")
if err != nil {
return nil, err
}
lyrics, err := mp4.readTag(boxes, "(c)lyr")
if err != nil {
return nil, err
}
narrator, err := mp4.readTag(boxes, "(c)nrt")
if err != nil {
return nil, err
}
publisher, err := mp4.readTag(boxes, "(c)pub")
if err != nil {
return nil, err
}
title, err := mp4.readTag(boxes, "(c)nam")
if err != nil {
return nil, err
}
pics, err := mp4.readPics(boxes)
if err != nil {
return nil, err
}
trackNum, trackTotal, err := mp4.readTrknDisk(boxes, "trkn")
if err != nil {
return nil, err
}
discNum, discTotal, err := mp4.readTrknDisk(boxes, "disk")
if err != nil {
return nil, err
}
genre, err := mp4.readGenre(boxes)
if err != nil {
return nil, err
}
advisory, err := mp4.readAdvisory(boxes)
if err != nil {
return nil, err
}
albumID, err := mp4.readITAlbumID(boxes)
if err != nil {
return nil, err
}
artistID, err := mp4.readITArtistID(boxes)
if err != nil {
return nil, err
}
tags := &MP4Tags{
Album: album,
AlbumArtist: albumArtist,
Artist: artist,
BPM: bpm,
Comment: comment,
Composer: composer,
Conductor: conductor,
Copyright: copyright,
Custom: custom,
CustomGenre: customGenre,
Description: description,
DiscNumber: discNum,
DiscTotal: discTotal,
Genre: genre,
ItunesAdvisory: advisory,
ItunesAlbumID: albumID,
ItunesArtistID: artistID,
Lyrics: lyrics,
Narrator: narrator,
Pictures: pics,
Publisher: publisher,
Title: title,
TrackNumber: trackNum,
TrackTotal: trackTotal,
}
year, err := mp4.readTag(boxes, "(c)day")
if err != nil {
return nil, err
}
if year != "" {
if containsOnlyNums(year) {
yearInt, err := strconv.ParseInt(year, 10, 32)
if err != nil {
return nil, err
}
tags.Year = int32(yearInt)
} else {
tags.Date = year
}
}
return tags, nil
}
func (mp4 MP4) actualRead() (*MP4Tags, MP4Boxes, error) {
var boxes MP4Boxes
_, err := mp4.f.Seek(0, io.SeekStart)
if err != nil {
return nil, boxes, err
}
boxes, err = mp4.readBoxes(boxes, mp4.size, 0, "")
if err != nil {
return nil, boxes, err
}
err = checkBoxes(boxes)
if err != nil {
return nil, boxes, err
}
if boxes.getBoxByPath("moov.udta.meta.ilst") == nil {
return &MP4Tags{}, boxes, nil
}
tags, err := mp4.readTags(boxes)
return tags, boxes, err
}

77
utils.go Normal file
View File

@@ -0,0 +1,77 @@
package mp4tag
import (
"path/filepath"
"time"
"os"
"io"
"strings"
"fmt"
)
func containsRune(items []rune, value rune) bool {
for _, item := range items {
if item == value {
return true
}
}
return false
}
func containsOnlyNums(str string) bool {
for _, r := range str {
if !containsRune(numbers, r) {
return false
}
}
return true
}
func strArrToLower(arr []string) []string {
var lowerArr []string
for _, str := range arr {
lowerArr = append(lowerArr, strings.ToLower(str))
}
return lowerArr
}
func containsStr(arr []string, val string) bool {
for _, str := range arr {
if str == val {
return true
}
}
return false
}
func getTempPath(path string) string {
fname := filepath.Base(path)
unix := time.Now().UnixMilli()
tempPath := filepath.Join(
os.TempDir(), fmt.Sprintf("%s_tmp_%d", fname, unix))
return tempPath
}
func getPos(f *os.File) (int64, error) {
return f.Seek(0, io.SeekCurrent)
}
func moveMP4(srcPath, destPath string) error {
inFile, err := os.Open(srcPath)
if err != nil {
return err
}
outFile, err := os.Create(destPath)
if err != nil {
inFile.Close()
return err
}
defer outFile.Close()
_, err = io.Copy(outFile, inFile)
if err != nil {
return err
}
inFile.Close()
err = os.Remove(srcPath)
return err
}

1072
write.go Normal file

File diff suppressed because it is too large Load Diff