diff --git a/db/migration/20210430212322_add_bpm_metadata.go b/db/migration/20210430212322_add_bpm_metadata.go new file mode 100644 index 000000000..3fc0c865f --- /dev/null +++ b/db/migration/20210430212322_add_bpm_metadata.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose" +) + +func init() { + goose.AddMigration(upAddBpmMetadata, downAddBpmMetadata) +} + +func upAddBpmMetadata(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add bpm integer; + +create index if not exists media_file_bpm + on media_file (bpm); +`) + if err != nil { + return err + } + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) +} + +func downAddBpmMetadata(tx *sql.Tx) error { + return nil +} diff --git a/model/mediafile.go b/model/mediafile.go index 859fe2264..f65f09165 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -39,6 +39,7 @@ type MediaFile struct { Compilation bool `json:"compilation"` Comment string `json:"comment"` Lyrics string `json:"lyrics"` + Bpm int `json:"bpm,omitempty"` CatalogNum string `json:"catalogNum"` MbzTrackID string `json:"mbzTrackId" orm:"column(mbz_track_id)"` MbzAlbumID string `json:"mbzAlbumId" orm:"column(mbz_album_id)"` diff --git a/scanner/mapping.go b/scanner/mapping.go index 6ee2bf23e..7f99e5377 100644 --- a/scanner/mapping.go +++ b/scanner/mapping.go @@ -64,7 +64,7 @@ func (s *mediaFileMapper) toMediaFile(md metadata.Metadata) model.MediaFile { mf.MbzAlbumComment = md.MbzAlbumComment() mf.Comment = s.policy.Sanitize(md.Comment()) mf.Lyrics = s.policy.Sanitize(md.Lyrics()) - + mf.Bpm = md.Bpm() mf.CreatedAt = time.Now() mf.UpdatedAt = md.ModificationTime() diff --git a/scanner/metadata/ffmpeg_test.go b/scanner/metadata/ffmpeg_test.go index 0d6f0ff42..33c0db9aa 100644 --- a/scanner/metadata/ffmpeg_test.go +++ b/scanner/metadata/ffmpeg_test.go @@ -286,4 +286,21 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"})) }) + It("parses an integer TBPM tag", func() { + const output = ` + Input #0, mp3, from 'tests/fixtures/test.mp3': + Metadata: + TBPM : 123` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md.Bpm()).To(Equal(123)) + }) + + It("parses and rounds a floating point fBPM tag", func() { + const output = ` + Input #0, ogg, from 'tests/fixtures/test.ogg': + Metadata: + FBPM : 141.7` + md, _ := e.extractMetadata("tests/fixtures/test.ogg", output) + Expect(md.Bpm()).To(Equal(142)) + }) }) diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index 137482adc..a4ca658c8 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -2,6 +2,7 @@ package metadata import ( "fmt" + "math" "os" "path" "regexp" @@ -66,6 +67,7 @@ type Metadata interface { FilePath() string Suffix() string Size() int64 + Bpm() int } type baseMetadata struct { @@ -127,6 +129,15 @@ func (m *baseMetadata) Suffix() string { func (m *baseMetadata) Duration() float32 { panic("not implemented") } func (m *baseMetadata) BitRate() int { panic("not implemented") } func (m *baseMetadata) HasPicture() bool { panic("not implemented") } +func (m *baseMetadata) Bpm() int { + var bpmStr = m.getTag("tbpm", "bpm", "fbpm") + var bpmFloat, err = strconv.ParseFloat(bpmStr, 64) + if err == nil { + return (int)(math.Round(bpmFloat)) + } else { + return 0 + } +} func (m *baseMetadata) parseInt(tagName string) int { if v, ok := m.tags[tagName]; ok { diff --git a/scanner/metadata/taglib_test.go b/scanner/metadata/taglib_test.go index 82c44ad57..8c638827f 100644 --- a/scanner/metadata/taglib_test.go +++ b/scanner/metadata/taglib_test.go @@ -33,19 +33,20 @@ var _ = Describe("taglibExtractor", func() { Expect(m.BitRate()).To(Equal(192)) Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3")) Expect(m.Suffix()).To(Equal("mp3")) - Expect(m.Size()).To(Equal(int64(60845))) + Expect(m.Size()).To(Equal(int64(51876))) Expect(m.Comment()).To(Equal("Comment1\nComment2")) + Expect(m.Bpm()).To(Equal(123)) - //TODO This file has some weird tags that makes the following tests fail sometimes. - //m = mds["tests/fixtures/test.ogg"] - //Expect(err).To(BeNil()) - //Expect(m.Title()).To(BeEmpty()) - //Expect(m.HasPicture()).To(BeFalse()) - //Expect(m.Duration()).To(Equal(float32(3))) - //Expect(m.BitRate()).To(Equal(10)) - //Expect(m.Suffix()).To(Equal("ogg")) - //Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg")) - //Expect(m.Size()).To(Equal(int64(4408))) + m = mds["tests/fixtures/test.ogg"] + Expect(err).To(BeNil()) + Expect(m.Title()).To(BeEmpty()) + Expect(m.HasPicture()).To(BeFalse()) + Expect(m.Duration()).To(Equal(float32(1))) + Expect(m.BitRate()).To(Equal(39)) + Expect(m.Suffix()).To(Equal("ogg")) + Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg")) + Expect(m.Size()).To(Equal(int64(5065))) + Expect(m.Bpm()).To(Equal(142)) // This file has a floating point BPM set to 141.7 under the fBPM tag. Ensure we parse and round correctly. }) }) }) diff --git a/tests/fixtures/test.mp3 b/tests/fixtures/test.mp3 index e6941d360..6f7c494c9 100644 Binary files a/tests/fixtures/test.mp3 and b/tests/fixtures/test.mp3 differ diff --git a/tests/fixtures/test.ogg b/tests/fixtures/test.ogg index 220f76f0c..be6728129 100644 Binary files a/tests/fixtures/test.ogg and b/tests/fixtures/test.ogg differ diff --git a/ui/src/album/AlbumSongs.js b/ui/src/album/AlbumSongs.js index b5bf6be32..9c5210478 100644 --- a/ui/src/album/AlbumSongs.js +++ b/ui/src/album/AlbumSongs.js @@ -3,6 +3,7 @@ import { BulkActionsToolbar, ListToolbar, TextField, + NumberField, useVersion, useListContext, } from 'react-admin' @@ -128,6 +129,7 @@ const AlbumSongs = (props) => { {isDesktop && <TextField source="artist" sortable={false} />} <DurationField source="duration" sortable={false} /> {isDesktop && <QualityInfo source="quality" sortable={false} />} + {isDesktop && <NumberField source="bpm" sortable={false} />} {isDesktop && config.enableStarRating && ( <RatingField source="rating" diff --git a/ui/src/common/SongDetails.js b/ui/src/common/SongDetails.js index 17676eb61..e95cf0264 100644 --- a/ui/src/common/SongDetails.js +++ b/ui/src/common/SongDetails.js @@ -5,7 +5,13 @@ import TableBody from '@material-ui/core/TableBody' import TableCell from '@material-ui/core/TableCell' import TableContainer from '@material-ui/core/TableContainer' import TableRow from '@material-ui/core/TableRow' -import { BooleanField, DateField, TextField, useTranslate } from 'react-admin' +import { + BooleanField, + DateField, + TextField, + NumberField, + useTranslate, +} from 'react-admin' import inflection from 'inflection' import { BitrateField, SizeField } from './index' import { MultiLineTextField } from './MultiLineTextField' @@ -32,6 +38,7 @@ export const SongDetails = (props) => { size: <SizeField record={record} source="size" />, updatedAt: <DateField record={record} source="updatedAt" showTime />, playCount: <TextField record={record} source="playCount" />, + bpm: <NumberField record={record} source="bpm" />, comment: <MultiLineTextField record={record} source="comment" />, } if (!record.discSubtitle) { @@ -40,6 +47,9 @@ export const SongDetails = (props) => { if (!record.comment) { delete data.comment } + if (!record.bpm) { + delete data.bpm + } if (record.playCount > 0) { data.playDate = <DateField record={record} source="playDate" showTime /> } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index a957c0473..c6904499b 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -22,7 +22,8 @@ "starred": "Favourite", "rating": "Rating", "comment": "Comment", - "quality": "Quality" + "quality": "Quality", + "bpm": "BPM" }, "actions": { "addToQueue": "Play Later", diff --git a/ui/src/playlist/PlaylistSongs.js b/ui/src/playlist/PlaylistSongs.js index 4d202d51e..fbf6c901a 100644 --- a/ui/src/playlist/PlaylistSongs.js +++ b/ui/src/playlist/PlaylistSongs.js @@ -3,6 +3,7 @@ import { BulkActionsToolbar, ListToolbar, TextField, + NumberField, useRefresh, useDataProvider, useNotify, @@ -166,6 +167,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { {isDesktop && <TextField source="artist" />} <DurationField source="duration" className={classes.draggable} /> {isDesktop && <QualityInfo source="quality" sortable={false} />} + {isDesktop && <NumberField source="bpm" />} <SongContextMenu onAddToPlaylist={onAddToPlaylist} showLove={false} diff --git a/ui/src/song/SongList.js b/ui/src/song/SongList.js index 03cada6d7..9aabf325d 100644 --- a/ui/src/song/SongList.js +++ b/ui/src/song/SongList.js @@ -121,6 +121,7 @@ const SongList = (props) => { )} {isDesktop && <QualityInfo source="quality" sortable={false} />} <DurationField source="duration" /> + {isDesktop && <NumberField source="bpm" />} {config.enableStarRating && ( <RatingField source="rating"