Compare commits

...

10 commits

Author SHA1 Message Date
ed7e1ddade Tag files 2023-11-12 22:10:31 -06:00
7a198a0273 🐛 Fail gracefully when none found 2023-11-11 18:51:38 -06:00
5881f3d538 Get tags from mb 2023-11-11 17:10:37 -06:00
739bed214c Add testify for simpler tests 2023-10-31 15:41:24 -05:00
0aa0ac284e 🛠 Added TernCall util 2023-10-29 11:58:00 -05:00
0a55d514fc 🚧 Saving tags for later writing 2023-10-29 11:14:44 -05:00
4990b09f67 🚚 Moving tracks to its own subpackage 2023-10-28 18:15:33 -05:00
04274dc788 📝 Add README 2023-10-01 13:41:41 -05:00
38202e58aa 📄 Add LICENSE 2023-10-01 13:34:44 -05:00
c87dc5e04f 🚧 More of fingerprinting 2023-09-24 22:34:30 -05:00
24 changed files with 598 additions and 49 deletions

View file

@ -0,0 +1,7 @@
© 2023 Dan Jones <danjones@goodevilgenius.org>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

23
README.md Normal file
View file

@ -0,0 +1,23 @@
# strip-beats
Imagine you have a folder full of music videos. You want to go through those videos, turn it into an audio file, tag them, and add it to your music collection.
This app aims to streamline that.
## Pre-requisites
- ffmpeg
- fpcalc (for fingerprinting audio so that we can identify it and automatically pull tags)
- mpv (for watching the video to ensure you're stipping out non-music elements, like an intro or outro)
- An account on acousticid.org (for querying the fingerprint to get data)
## Install
- Pull the repo
- `go install .`
Hopefully, I'll have binaries downloadable soon.
## Disclaimer
This does not work yet! Some features are implemented, but most are not yet. Have a look around at the code, but don't actually use this at all.

View file

@ -5,7 +5,7 @@ import (
"os" "os"
"codeberg.org/danjones000/strip-beats/files" "codeberg.org/danjones000/strip-beats/files"
"codeberg.org/danjones000/strip-beats/input/boolean" "codeberg.org/danjones000/strip-beats/io/boolean"
"codeberg.org/danjones000/strip-beats/media" "codeberg.org/danjones000/strip-beats/media"
"codeberg.org/danjones000/strip-beats/utils" "codeberg.org/danjones000/strip-beats/utils"
"github.com/rkoesters/xdg/trash" "github.com/rkoesters/xdg/trash"
@ -20,8 +20,10 @@ func PickNewFile() media.Probe {
} }
func SetFile(path string) media.Probe { func SetFile(path string) media.Probe {
resetTags()
f := media.ProbeFile(path) f := media.ProbeFile(path)
file = &f file = &f
copyTagsFromFile()
return f return f
} }
@ -70,6 +72,7 @@ func PickAgain() {
} }
file = nil file = nil
tmpfile = nil tmpfile = nil
resetTags()
} }
func Finish() { func Finish() {

16
app/convert.go Normal file
View file

@ -0,0 +1,16 @@
package app
import (
"fmt"
"codeberg.org/danjones000/strip-beats/media"
"codeberg.org/danjones000/strip-beats/utils"
)
func convert() {
in := utils.Tern(tmpfile == nil, file, tmpfile)
out, _ := media.ConvertAndTag(*in, tags)
fmt.Println(out)
quit()
}

View file

@ -2,6 +2,8 @@ package app
import ( import (
"os" "os"
p "path/filepath"
s "strings"
"codeberg.org/danjones000/strip-beats/media" "codeberg.org/danjones000/strip-beats/media"
"codeberg.org/danjones000/strip-beats/utils" "codeberg.org/danjones000/strip-beats/utils"
@ -31,7 +33,10 @@ func validateNumber(input string, lastChar rune) bool {
} }
func fadeFile() error { func fadeFile() error {
tmp, err := os.CreateTemp("", "audio.*.mka") base := p.Base(file.Format.Path)
ext := p.Ext(base)
base = s.TrimSuffix(base, ext)
tmp, err := os.CreateTemp("", base+".*.mka")
if err != nil { if err != nil {
return err return err
} }

View file

@ -3,14 +3,14 @@ package app
import ( import (
"fmt" "fmt"
"codeberg.org/danjones000/strip-beats/input/list" "codeberg.org/danjones000/strip-beats/io/list"
) )
func (st AppStep) Title() string { func (st AppStep) Title() string {
mustpick := "You need to pick a file" mustpick := "You need to pick a file"
switch st { switch st {
case Pick: case Pick:
return "Pick a new show" return "Pick a new file"
case Watch: case Watch:
if file == nil { if file == nil {
return mustpick return mustpick
@ -26,6 +26,11 @@ func (st AppStep) Title() string {
return mustpick return mustpick
} }
return fmt.Sprintf("Should we try to identify %s", file.ShortPath()) return fmt.Sprintf("Should we try to identify %s", file.ShortPath())
case Convert:
if file == nil {
return mustpick
}
return fmt.Sprintf("Convert %s?", file.ShortPath())
case Restart: case Restart:
return "Forget current selection" return "Forget current selection"
case Quit: case Quit:
@ -54,6 +59,8 @@ func (st AppStep) Rune() rune {
return 'r' return 'r'
case Print: case Print:
return 'a' return 'a'
case Convert:
return 'c'
case Quit: case Quit:
return 'q' return 'q'
default: default:
@ -70,7 +77,7 @@ func mainMenu() AppStep {
if file == nil { if file == nil {
steps = []list.Option{Pick, Quit} steps = []list.Option{Pick, Quit}
} else { } else {
steps = []list.Option{Pick, Watch, Fade, Print, Quit} steps = []list.Option{Pick, Watch, Fade, Print, Convert, Quit}
} }
step := list.List("What would you like to do next?", steps, nil) step := list.List("What would you like to do next?", steps, nil)

View file

@ -3,15 +3,48 @@ package app
import ( import (
"fmt" "fmt"
"codeberg.org/danjones000/strip-beats/io/boolean"
"codeberg.org/danjones000/strip-beats/io/list"
m "codeberg.org/danjones000/strip-beats/io/message"
"codeberg.org/danjones000/strip-beats/media" "codeberg.org/danjones000/strip-beats/media"
"codeberg.org/danjones000/strip-beats/media/brainz" "codeberg.org/danjones000/strip-beats/media/brainz"
"codeberg.org/danjones000/strip-beats/utils"
"github.com/akrennmair/slice"
) )
type recOpt struct {
rec brainz.Recording
r rune
score float64
}
func (o recOpt) Title() string {
return o.rec.Title
}
func (o recOpt) Text() string {
return fmt.Sprintf(
"(Score %.2f) By %s - First Released %s - %s %s",
100*o.score,
o.rec.FirstArtist().Name,
o.rec.FirstReleaseDate,
o.rec.FirstGenre().Name,
utils.Tern(o.rec.Video, "(Video)", ""))
}
func (o recOpt) Rune() rune {
return o.r
}
func (o recOpt) Selected() func() {
return nil
}
func print() { func print() {
if file == nil { if file == nil {
PickFileWithConf() PickFileWithConf()
} }
fp, err := media.Fingerprint("/home/drj/MyFiles/Videos/WebShows/YouTube/Dolly_Parton_-_Topic/Just_Because_I_m_a_Woman.Dolly_Parton_-_Topic.Fmv-XQerVkM.webm") fp, err := media.Fingerprint(file.Format.Path)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -19,17 +52,136 @@ func print() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
var recs []brainz.Recording rec, ok := chooseRec(ids)
if !ok {
m.Message("Couldn't find a marching recording")
return
}
fmt.Println(rec.Title)
tags.Title = rec.Title
tags.MusicbrainzRecordingId = rec.Id
tags.Date = rec.FirstReleaseDate
tags.Genre = rec.FirstGenre().Name
tags.Artist = rec.FirstArtist().Name
tags.ArtistSort = rec.FirstArtist().SortName
tags.AcoustidId = rec.AcousticId
rel := findFirstRelease(rec)
if !boolean.Choose(fmt.Sprintf("Is %s album (%s %s) correct?", rel.Title, rel.Country, rel.Date)) {
rel = chooseRel(rec)
}
media := rel.Media[0]
track := media.Tracks[0]
full, err := brainz.GetReleaseWithMedia(rel.Id)
if err != nil {
panic(err)
}
albumArtist := full.ArtistCredit[0]
tags.MusicbrainzReleaseGroupId = full.ReleaseGroup.Id
tags.MusicbrainzAlbumId = rel.Id
tags.MusicbrainzAlbumArtistId = albumArtist.Artist.Id
tags.AlbumArtist = albumArtist.Name
tags.Album = rel.Title
if rel.Date != "" {
tags.Date = rel.Date
}
tags.AlbumArtistSort = albumArtist.Artist.SortName
tags.ReleaseCountry = rel.Country
if len(full.LabelInfo) > 0 {
tags.Label = full.LabelInfo[0].Label.Name
tags.MusicBrainzLabelId = full.LabelInfo[0].Label.Id
}
if len(rel.Genres) > 0 && rel.Genres[0].Name != "" {
tags.Genre = rel.Genres[0].Name
}
tags.Disc = media.Position
tags.DiscCount = len(full.Media)
tags.Track = track.Position
tags.TrackCount = media.TrackCount
}
func chooseRec(ids media.IdResults) (brainz.Recording, bool) {
var recs []list.Option
var rec brainz.Recording
var err error
i := 'a'
for _, res := range ids.Results { for _, res := range ids.Results {
for _, rec := range res.Recordings { for _, rec = range res.Recordings {
err = brainz.FillRecording(&rec) err = brainz.FillRecording(&rec)
if err != nil { if err != nil {
panic(err) panic(err)
} }
recs = append(recs, rec) rec.AcousticId = res.Id
recs = append(recs, recOpt{rec, i, res.Score})
if rec.Title != "" {
// Empty titles will be filtered out, so we don't need to increment them
i = i + 1
} }
} }
}
recs = slice.Filter(recs, func(opt list.Option) bool {
return opt.Title() != ""
})
fmt.Printf("%+v\n", recs) if len(recs) < 1 {
return rec, false
}
return list.List("Which recording is the correct one?", recs, nil).(recOpt).rec, true
}
func findFirstRelease(rec brainz.Recording) brainz.Release {
var rel brainz.Release
for _, rel = range rec.Releases {
if rel.Date == rec.FirstReleaseDate {
return rel
}
}
if len(rec.Releases) > 0 {
return rec.Releases[0]
}
return brainz.Release{}
}
func chooseRel(rec brainz.Recording) brainz.Release {
var rels []list.Option
var rel brainz.Release
i := 'a'
for _, rel = range rec.Releases {
rels = append(rels, relOpt{rel, i})
if rel.Title != "" {
// Empty titles will be filtered out, so we don't need to increment them
i = i + 1
}
}
rels = slice.Filter(rels, func(opt list.Option) bool {
return opt.Title() != ""
})
return list.List("Which releases is the correct one?", rels, nil).(relOpt).rel
}
type relOpt struct {
rel brainz.Release
r rune
}
func (o relOpt) Title() string {
return o.rel.Title
}
func (o relOpt) Text() string {
return fmt.Sprintf("%s %s", o.rel.Country, o.rel.Date)
}
func (o relOpt) Rune() rune {
return o.r
}
func (o relOpt) Selected() func() {
return nil
} }

View file

@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"codeberg.org/danjones000/strip-beats/input/boolean" "codeberg.org/danjones000/strip-beats/io/boolean"
"codeberg.org/danjones000/strip-beats/media" "codeberg.org/danjones000/strip-beats/media"
"codeberg.org/danjones000/strip-beats/media/brainz" "codeberg.org/danjones000/strip-beats/media/brainz"
) )
@ -16,6 +16,7 @@ const (
Watch Watch
Fade Fade
Print Print
Convert
Restart Restart
Quit Quit
) )
@ -51,14 +52,17 @@ func testMb() {
} }
func testPrint() { func testPrint() {
SetFile("/home/drj/MyFiles/Videos/WebShows/YouTube/Dolly_Parton_-_Topic/Just_Because_I_m_a_Woman.Dolly_Parton_-_Topic.Fmv-XQerVkM.webm") // SetFile("/home/drj/MyFiles/Videos/WebShows/YouTube/Dolly_Parton_-_Topic/Just_Because_I_m_a_Woman.Dolly_Parton_-_Topic.Fmv-XQerVkM.webm")
// SetFile("/home/drj/MyFiles/Videos/WebShows/YouTube/Whitney_Houston/I_Will_Always_Love_You_Ultimate_Collection_Edit.Whitney_Houston.rB7z_l8mBxw.mp4")
SetFile("/home/drj/MyFiles/Music/Original_Broadway_Cast_of_Hamilton/Hamilton/d02t12-We_Know.m4a")
// SetFile("/home/drj/MyFiles/Videos/WebShows/YouTube/KaceyMusgravesVEVO/Kacey_Musgraves_-_Biscuits-nGIUtLO_x8g.mp4")
// SetFile("/home/drj/MyFiles/Videos/WebShows/YouTube/Willie_Nelson_-_Topic/Too_Sick_To_Pray.Willie_Nelson_-_Topic.8QgBXo41j2E.webm")
print() print()
quit() quit()
} }
func Run(step AppStep) { func Run(step AppStep) {
testPrint()
for step < Quit { for step < Quit {
switch step { switch step {
case Pick: case Pick:
@ -91,6 +95,9 @@ func Run(step AppStep) {
case Print: case Print:
print() print()
step = mainMenu() step = mainMenu()
case Convert:
convert()
step = mainMenu()
case Quit: case Quit:
quit() quit()
default: default:

21
app/tags.go Normal file
View file

@ -0,0 +1,21 @@
package app
import (
"errors"
t "codeberg.org/danjones000/strip-beats/media/tags"
)
var tags t.Tags
func resetTags() {
tags = t.Tags{}
}
func copyTagsFromFile() {
if file == nil {
panic(errors.New("Missing file"))
}
tags = file.FullTags()
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 NAME HERE <EMAIL ADDRESS> Copyright © 2023 Dan Jones <danjones@goodevilgenius.org>
*/ */
package cmd package cmd

View file

@ -12,7 +12,7 @@ import (
const ( const (
AppName string = "strip-beats" AppName string = "strip-beats"
Version string = "0.1.0" Version string = "0.1.1"
Url string = "https://codeberg.org/danjones000/strip-beats" Url string = "https://codeberg.org/danjones000/strip-beats"
Email string = "danjones@goodevilgenius.org" Email string = "danjones@goodevilgenius.org"
UserAgent string = AppName + "/" + Version + " (" + Url + "; " + Email + ")" UserAgent string = AppName + "/" + Version + " (" + Url + "; " + Email + ")"

4
go.mod
View file

@ -13,21 +13,25 @@ require (
github.com/rivo/tview v0.0.0-20230826224341-9754ab44dc1c github.com/rivo/tview v0.0.0-20230826224341-9754ab44dc1c
github.com/rkoesters/xdg v0.0.1 github.com/rkoesters/xdg v0.0.1
github.com/spf13/cobra v1.7.0 github.com/spf13/cobra v1.7.0
github.com/stretchr/testify v1.7.0
github.com/u2takey/ffmpeg-go v0.5.0 github.com/u2takey/ffmpeg-go v0.5.0
golang.org/x/term v0.11.0 golang.org/x/term v0.11.0
) )
require ( require (
github.com/aws/aws-sdk-go v1.38.20 // indirect github.com/aws/aws-sdk-go v1.38.20 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gdamore/encoding v1.0.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect
github.com/gdamore/tcell/v2 v2.6.0 // indirect github.com/gdamore/tcell/v2 v2.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.3 // indirect github.com/rivo/uniseg v0.4.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/u2takey/go-utils v0.3.1 // indirect github.com/u2takey/go-utils v0.3.1 // indirect
golang.org/x/sys v0.11.0 // indirect golang.org/x/sys v0.11.0 // indirect
golang.org/x/text v0.7.0 // indirect golang.org/x/text v0.7.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

1
go.sum
View file

@ -117,6 +117,7 @@ golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

20
io/message/message.go Normal file
View file

@ -0,0 +1,20 @@
package message
import "github.com/rivo/tview"
func Message(text string) {
app := tview.NewApplication()
modal := tview.NewModal()
if text != "" {
modal.SetText(text)
}
modal.AddButtons([]string{"Ok"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
app.Stop()
})
if err := app.SetRoot(modal, false).EnableMouse(true).Run(); err != nil {
panic(err)
}
}

View file

@ -1,6 +1,5 @@
/* /*
Copyright © 2023 NAME HERE <EMAIL ADDRESS> Copyright © 2023 Dan Jones <danjones@goodevilgenius.org>
*/ */
package main package main

View file

@ -12,6 +12,7 @@ import (
type Recording struct { type Recording struct {
Id uuid.UUID Id uuid.UUID
AcousticId uuid.UUID
Isrcs []string Isrcs []string
FirstReleaseDate string `json:"first-release-date"` FirstReleaseDate string `json:"first-release-date"`
Length int Length int
@ -19,6 +20,45 @@ type Recording struct {
Video bool Video bool
Releases []Release Releases []Release
Genres []Genre Genres []Genre
ArtistCredit []ArtistCredit `json:"artist-credit"`
}
func (r Recording) FirstArtist() Artist {
var a Artist
for _, ac := range r.ArtistCredit {
if ac.Artist.Name != "" {
return ac.Artist
}
}
for _, rel := range r.Releases {
for _, ac := range rel.ArtistCredit {
if ac.Artist.Name != "" {
return ac.Artist
}
}
}
if a.Name == "" {
a.Name = "Unknown Artist"
}
return a
}
func (r Recording) FirstGenre() Genre {
var g Genre
for _, g = range r.Genres {
if g.Name != "" {
return g
}
}
for _, rel := range r.Releases {
for _, g = range rel.Genres {
if g.Name != "" {
return g
}
}
}
return g
} }
type Genre struct { type Genre struct {
@ -28,20 +68,56 @@ type Genre struct {
type Release struct { type Release struct {
Id uuid.UUID Id uuid.UUID
Asin string
Barcode string
Country string Country string
Date string Date string
Disambiguation string
Media []Media Media []Media
Packaging string
PackagingId uuid.UUID `json:"packaging-id"`
Quality string
Status string Status string
StatusId uuid.UUID `json:"status-id"` StatusId uuid.UUID `json:"status-id"`
ArtistCredit []ArtistCredit `json:"artist-credit"` ArtistCredit []ArtistCredit `json:"artist-credit"`
Title string Title string
Genres []Genre Genres []Genre
ReleaseGroup ReleaseGroup `json:"release-group"`
LabelInfo []LabelInfo `json:"label-info"`
// ReleaseEvents []ReleaseEvent `json:"release-events"` // ReleaseEvents []ReleaseEvent `json:"release-events"`
} }
type LabelInfo struct {
CatalogNumber string `json:"catalog-number"`
Label Label
}
type Label struct {
Id uuid.UUID
Name string
SortName string `json:"sort-name"`
Disambiguation string
TypeId string `json:"type-id"`
Type string
LabelCode int `json:"label-code"`
}
type ReleaseGroup struct {
Id uuid.UUID
Title string
ArtistCredit []ArtistCredit `json:"artist-credit"`
Disambiguation string
FirstReleaseDate string `json:"first-release-date"`
PrimaryType string `json:"primary-type"`
PrimaryTypeId uuid.UUID `json:"primary-type-id"`
SecondaryTypes []string `json:"secondary-types"`
SecondaryTypeIds []uuid.UUID `json:"secondary-type-ids"`
}
type ArtistCredit struct { type ArtistCredit struct {
Name string Name string
Artist Artist Artist Artist
JoinPhrase string
} }
type Artist struct { type Artist struct {
@ -56,6 +132,7 @@ type Artist struct {
type Media struct { type Media struct {
FormatId uuid.UUID `json:"format-id"` FormatId uuid.UUID `json:"format-id"`
Position int Position int
Title string
TrackOffset int `json:"track-offset"` TrackOffset int `json:"track-offset"`
Format string Format string
TrackCount int `json:"track-count"` TrackCount int `json:"track-count"`
@ -70,6 +147,25 @@ type Track struct {
Length int Length int
} }
func GetReleaseWithMedia(id uuid.UUID) (Release, error) {
rel := Release{Id: id}
url := fmt.Sprintf("https://musicbrainz.org/ws/2/release/%s", id)
resp, err := h.GetWithQuery(url, u.Values{
"fmt": []string{"json"},
"inc": []string{"artist-credits+discids+labels+release-groups"}})
if err != nil {
return rel, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return rel, err
}
err = json.Unmarshal(body, &rel)
return rel, err
}
func GetRecording(id string) (Recording, error) { func GetRecording(id string) (Recording, error) {
u, err := uuid.Parse(id) u, err := uuid.Parse(id)
rec := Recording{Id: u} rec := Recording{Id: u}

View file

@ -3,12 +3,142 @@ package media
import ( import (
"errors" "errors"
"fmt" "fmt"
p "path/filepath"
"strconv" "strconv"
s "strings"
"codeberg.org/danjones000/strip-beats/config" "codeberg.org/danjones000/strip-beats/config"
t "codeberg.org/danjones000/strip-beats/media/tags"
"codeberg.org/danjones000/strip-beats/utils"
"github.com/google/uuid"
ffmpeg "github.com/u2takey/ffmpeg-go" ffmpeg "github.com/u2takey/ffmpeg-go"
) )
func ConvertAndTag(in Probe, tags t.Tags) (string, error) {
st := in.GetFirstAcceptableAudio()
if st == nil {
st = in.GetFirstAudio()
}
if st == nil {
return "", errors.New("Can't find an audio stream")
}
conf := config.GetConfig()
codec := utils.Tern(st.isAcceptableCodec(), "copy", conf.FfEncoder)
target := utils.Tern(st.isAcceptableCodec(), st.CodecName, conf.Codec)
base := p.Base(in.Format.Path)
ext := p.Ext(base)
base = s.TrimSuffix(base, ext)
ext = conf.CodecExt[target]
out := p.Join(conf.SavePath, base+"."+ext)
input := ffmpeg.Input(in.Format.Path).Get(strconv.Itoa(st.Index))
args := ffmpeg.KwArgs{"c:a": codec}
output := input.Output(out, args, getMetadataArgs(ext, tags)).GlobalArgs("-y")
return out, output.Run()
}
func getMetadataArgs(ext string, tags t.Tags) ffmpeg.KwArgs {
var meta []string
args := ffmpeg.KwArgs{}
switch ext {
case "opus":
fallthrough
case "flac":
fallthrough
case "ogg":
meta = append(meta, mapMetaKeys(getOggMeta(tags))...)
case "m4a":
fallthrough
case "mp4":
meta = append(meta, mapMetaKeys(getMp4Meta(tags))...)
case "mp3":
// @todo meta = append(meta, mapMetaKeys(getMp3Meta(tags))...)
}
meta = append(meta, "comment=Processed by "+config.UserAgent)
args["metadata"] = meta
return args
}
func mapMetaKeys(meta map[string]string) []string {
out := []string{}
for k, v := range meta {
if v != "" {
out = append(out, fmt.Sprintf("%s=%s", k, v))
}
}
return out
}
func getOggMeta(tags t.Tags) map[string]string {
meta := map[string]string{}
meta["TITLE"] = tags.Title
meta["ARTIST"] = tags.Artist
meta["ALBUM_ARTIST"] = tags.AlbumArtist
meta["ALBUMARTIST"] = tags.AlbumArtist
meta["ALBUM"] = tags.Album
meta["DATE"] = tags.Date
year, _, _ := s.Cut(tags.Date, "-")
meta["YEAR"] = year
meta["URL"] = tags.Url
meta["PURL"] = tags.Url
meta["URI"] = tags.Url
meta["ARTISTSORT"] = tags.ArtistSort
meta["ALBUMARTISTSORT"] = tags.AlbumArtistSort
meta["RELEASECOUNTRY"] = tags.ReleaseCountry
meta["LABEL"] = tags.Label
meta["GENRE"] = tags.Genre
meta["DISC"] = utils.Tern(tags.Disc > 0, strconv.Itoa(tags.Disc), "")
meta["DISCTOTAL"] = utils.Tern(tags.DiscCount > 0, strconv.Itoa(tags.DiscCount), "")
meta["TRACK"] = utils.Tern(tags.Track > 0, strconv.Itoa(tags.Track), "")
meta["TRACKTOTAL"] = utils.Tern(tags.TrackCount > 0, strconv.Itoa(tags.TrackCount), "")
if tags.AcoustidId != uuid.Nil {
meta["ACOUSTID_ID"] = tags.AcoustidId.String()
}
if tags.MusicbrainzReleaseGroupId != uuid.Nil {
meta["MUSICBRAINZ_RELEASEGROUPID"] = tags.MusicbrainzReleaseGroupId.String()
}
if tags.MusicbrainzAlbumId != uuid.Nil {
meta["MUSICBRAINZ_ALBUMID"] = tags.MusicbrainzAlbumId.String()
}
if tags.MusicbrainzAlbumArtistId != uuid.Nil {
meta["MUSICBRAINZ_ALBUMARTISTID"] = tags.MusicbrainzAlbumArtistId.String()
}
if tags.MusicbrainzRecordingId != uuid.Nil {
meta["MUSICBRAINZ_RECORDINGID"] = tags.MusicbrainzRecordingId.String()
}
if tags.MusicBrainzLabelId != uuid.Nil {
meta["MUSICBRAINZ_LABELID"] = tags.MusicBrainzLabelId.String()
}
return meta
}
func getMp4Meta(tags t.Tags) map[string]string {
meta := map[string]string{}
meta["title"] = tags.Title
meta["artist"] = tags.Artist
meta["album_artist"] = tags.AlbumArtist
meta["album"] = tags.Album
year, _, _ := s.Cut(tags.Date, "-")
meta["year"] = year
meta["description"] = tags.Url
meta["genre"] = tags.Genre
meta["network"] = tags.Label
if tags.Disc > 0 {
meta["disc"] = fmt.Sprintf("%d", tags.Disc) + utils.Tern(tags.DiscCount > 0, fmt.Sprintf("/%d", tags.DiscCount), "")
}
if tags.Track > 0 {
meta["track"] = fmt.Sprintf("%d", tags.Track) + utils.Tern(tags.TrackCount > 0, fmt.Sprintf("/%d", tags.TrackCount), "")
}
return meta
}
func TrimWithFade(in Probe, out string, start, stop, up, down float64) error { func TrimWithFade(in Probe, out string, start, stop, up, down float64) error {
// -ss (start) -t (end) -af afade=t=in:st=(start):d=(up),afade=t=out:st=(downstart):d=(down) // -ss (start) -t (end) -af afade=t=in:st=(start):d=(up),afade=t=out:st=(downstart):d=(down)
st := in.GetFirstAcceptableAudio() st := in.GetFirstAcceptableAudio()

View file

@ -12,6 +12,7 @@ import (
"codeberg.org/danjones000/strip-beats/config" "codeberg.org/danjones000/strip-beats/config"
"codeberg.org/danjones000/strip-beats/media/brainz" "codeberg.org/danjones000/strip-beats/media/brainz"
h "codeberg.org/danjones000/strip-beats/utils/http" h "codeberg.org/danjones000/strip-beats/utils/http"
"github.com/google/uuid"
) )
type FPrint struct { type FPrint struct {
@ -45,7 +46,7 @@ type IdResults struct {
} }
type IdResult struct { type IdResult struct {
Id string Id uuid.UUID
Score float64 Score float64
Recordings []brainz.Recording Recordings []brainz.Recording
} }

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"codeberg.org/danjones000/strip-beats/config" "codeberg.org/danjones000/strip-beats/config"
"codeberg.org/danjones000/strip-beats/media/tags"
"codeberg.org/danjones000/strip-beats/utils" "codeberg.org/danjones000/strip-beats/utils"
"dario.cat/mergo" "dario.cat/mergo"
"github.com/akrennmair/slice" "github.com/akrennmair/slice"
@ -99,7 +100,7 @@ type Stream struct {
StartTime float64 `json:"start_time,string"` StartTime float64 `json:"start_time,string"`
Duration float64 `json:",string"` Duration float64 `json:",string"`
DurationTs int `json:"duration_ts"` DurationTs int `json:"duration_ts"`
Tags Tags Tags tags.Tags
} }
func (st Stream) isWantedCodec() bool { func (st Stream) isWantedCodec() bool {
@ -124,26 +125,10 @@ type Format struct {
Size int `json:",string"` Size int `json:",string"`
BitRate int `json:"bit_rate,string"` BitRate int `json:"bit_rate,string"`
Score int `json:"probe_score"` Score int `json:"probe_score"`
Tags Tags Tags tags.Tags
} }
type Tags struct { func (pr Probe) FullTags() tags.Tags {
MajorBrand string `json:"major_brand"`
Title string
Artist string
AlbumArtist string `json:"album_artist"`
Album string
Date string
Encoder string
Comment string
Description string
Composer string
Genre string
Disc string
Track string
}
func (pr Probe) FullTags() Tags {
t := pr.Format.Tags t := pr.Format.Tags
s := pr.WantedAudioStream() s := pr.WantedAudioStream()
if s != nil { if s != nil {

35
media/tags/tags.go Normal file
View file

@ -0,0 +1,35 @@
package tags
import (
"github.com/google/uuid"
)
type Tags struct {
Title string
Artist string
AlbumArtist string `json:"album_artist"`
Album string
Date string
Comment string
Description string
Synopsis string
Url string `json:"purl"`
AcoustidId uuid.UUID `json:"ACOUSTID_ID"`
MusicbrainzReleaseGroupId uuid.UUID `json:"MUSICBRAINZ_RELEASEGROUPID"`
MusicbrainzAlbumId uuid.UUID `json:"MUSICBRAINZ_ALBUMID"`
MusicbrainzAlbumArtistId uuid.UUID `json:"MUSICBRAINZ_ALBUMARTISTID"`
MusicbrainzRecordingId uuid.UUID
MusicBrainzLabelId uuid.UUID
ArtistSort string
AlbumArtistSort string
ReleaseCountry string
Label string
Composer string
Genre string
Disc int `json:"-"`
DiscCount int
Track int `json:"-"`
TrackCount int
TrackStr string `json:"track"`
DiscStr string `json:"disc"`
}

View file

@ -29,3 +29,17 @@ func HourMinSecToSeconds(time string) (float64, error) {
dur, _ := t.ParseDuration(f) dur, _ := t.ParseDuration(f)
return dur.Seconds(), nil return dur.Seconds(), nil
} }
func Tern[V any](choice bool, one, two V) V {
if choice {
return one
}
return two
}
func TernCall[V any](choice bool, one, two func() V) V {
if choice {
return one()
}
return two()
}

23
utils/utils_tern_test.go Normal file
View file

@ -0,0 +1,23 @@
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTern(t *testing.T) {
assert.Equal(t, Tern(true, 5, 2), 5)
assert.Equal(t, Tern(false, 5, 2), 2)
}
func TestTernCall(t *testing.T) {
five := func() int {
return 5
}
two := func() int {
return 2
}
assert.Equal(t, TernCall(true, five, two), 5)
assert.Equal(t, TernCall(false, five, two), 2)
}