Add OS Lyrics extension (#2656)

* draft commit

* time to fight pipeline

* round 2 changes

* remove unnecessary line

* fight taglib. again

* make taglib work again???

* add id3 tags

* taglib 1.12 vs 1.13

* use int instead for windows

* store as json now

* add migration, more tests

* support repeated line, multiline

* fix ms and support .m, .mm, .mmm

* address some concerns, make cpp a bit safer

* separate responses from model

* remove [:]

* Add trace log

* Try to unblock pipeline

* Fix merge errors

* Fix SIGSEGV error (proper handling of empty frames)

* Add fallback artist/title to structured lyrics

* Rename conflicting named vars

* Fix tests

* Do we still need ffmpeg in the pipeline?

* Revert "Do we still need ffmpeg in the pipeline?"

Yes we do.

This reverts commit 87df7f6df7.

* Does this passes now, with a newer ffmpeg version?

* Revert "Does this passes now, with a newer ffmpeg version?"

No, it does not :(

This reverts commit 372eb4b0ae.

* My OCD made me do it :P

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
Kendall Garner 2023-12-28 01:20:29 +00:00 committed by GitHub
parent 130ab76c79
commit 814161d78d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1215 additions and 71 deletions

View File

@ -1,4 +1,4 @@
name: 'Pipeline: Test, Lint, Build'
name: "Pipeline: Test, Lint, Build"
on:
push:
branches:
@ -13,6 +13,9 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- name: Update ubuntu repo
run: sudo apt-get update
- name: Install taglib
run: sudo apt-get install libtag1-dev
@ -48,10 +51,13 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go_version: [1.21.x,1.20.x]
go_version: [1.21.x, 1.20.x]
steps:
- name: Update ubuntu repo
run: sudo apt-get update
- name: Install taglib
run: sudo apt-get install libtag1-dev
run: sudo apt-get install libtag1-dev ffmpeg
- name: Check out code into the Go module directory
uses: actions/checkout@v3
@ -75,14 +81,14 @@ jobs:
name: Build JS bundle
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max_old_space_size=4096'
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
cache: "npm"
cache-dependency-path: "**/package-lock.json"
- name: npm install dependencies
run: |

View File

@ -22,6 +22,7 @@ type FFmpeg interface {
ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
IsAvailable() bool
}
func New() FFmpeg {
@ -78,6 +79,11 @@ func (e *ffmpeg) CmdPath() (string, error) {
return ffmpegCmd()
}
func (e *ffmpeg) IsAvailable() bool {
_, err := ffmpegCmd()
return err == nil
}
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}

View File

@ -0,0 +1,82 @@
package migrations
import (
"context"
"database/sql"
"encoding/json"
"github.com/navidrome/navidrome/model"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAlterLyricColumn, downAlterLyricColumn)
}
func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `alter table media_file rename COLUMN lyrics TO lyrics_old`)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `alter table media_file add lyrics JSONB default '[]';`)
if err != nil {
return err
}
stmt, err := tx.Prepare(`update media_file SET lyrics = ? where id = ?`)
if err != nil {
return err
}
rows, err := tx.Query(`select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`)
if err != nil {
return err
}
var id string
var lyrics sql.NullString
for rows.Next() {
err = rows.Scan(&id, &lyrics)
if err != nil {
return err
}
if !lyrics.Valid {
continue
}
lyrics, err := model.ToLyrics("xxx", lyrics.String)
if err != nil {
return err
}
text, err := json.Marshal(model.LyricList{*lyrics})
if err != nil {
return err
}
_, err = stmt.Exec(string(text), id)
if err != nil {
return err
}
}
err = rows.Err()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN lyrics_old;`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to pick up additional lyrics (existing lyrics have been preserved)")
return forceFullRescan(tx)
}
func downAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

201
model/lyrics.go Normal file
View File

@ -0,0 +1,201 @@
package model
import (
"regexp"
"strconv"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
)
type Line struct {
Start *int64 `structs:"start,omitempty" json:"start,omitempty"`
Value string `structs:"value" json:"value"`
}
type Lyrics struct {
DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"`
DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"`
Lang string `structs:"lang" json:"lang"`
Line []Line `structs:"line" json:"line"`
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
Synced bool `structs:"synced" json:"synced"`
}
// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm]
const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?\]`
var (
// Should either be at the beginning of file, or beginning of line
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
timeRegex = regexp.MustCompile(timeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^]]+)]`)
)
func ToLyrics(language, text string) (*Lyrics, error) {
text = utils.SanitizeText(text)
lines := strings.Split(text, "\n")
artist := ""
title := ""
var offset *int64 = nil
structuredLines := []Line{}
synced := syncRegex.MatchString(text)
priorLine := ""
validLine := false
var timestamps []int64
for _, line := range lines {
line := strings.TrimSpace(line)
if line == "" {
if validLine {
priorLine += "\n"
}
continue
}
var text string
var time *int64 = nil
if synced {
idTag := lrcIdRegex.FindStringSubmatch(line)
if idTag != nil {
switch idTag[1] {
case "ar":
artist = utils.SanitizeText(strings.TrimSpace(idTag[2]))
case "offset":
{
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)
if err != nil {
log.Warn("Error parsing offset", "offset", idTag[2], "error", err)
} else {
offset = &off
}
}
case "ti":
title = utils.SanitizeText(strings.TrimSpace(idTag[2]))
}
continue
}
times := timeRegex.FindAllStringSubmatchIndex(line, -1)
// The second condition is for when there is a timestamp in the middle of
// a line (after any text)
if times == nil || times[0][0] != 0 {
if validLine {
priorLine += "\n" + line
}
continue
}
if validLine {
for idx := range timestamps {
structuredLines = append(structuredLines, Line{
Start: &timestamps[idx],
Value: strings.TrimSpace(priorLine),
})
}
timestamps = []int64{}
}
end := 0
// [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd]
for _, match := range times {
var hours, millis int64
var err error
// for multiple matches, we need to check that later matches are not
// in the middle of the string
if end != 0 {
middle := strings.TrimSpace(line[end:match[0]])
if middle != "" {
break
}
}
end = match[1]
hourStart := match[2]
if hourStart != -1 {
// subtract 1 because group has : at the end
hourEnd := match[3] - 1
hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64)
if err != nil {
return nil, err
}
}
minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64)
if err != nil {
return nil, err
}
sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64)
if err != nil {
return nil, err
}
msStart := match[8]
if msStart != -1 {
msEnd := match[9]
// +1 offset since this capture group contains .
millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64)
if err != nil {
return nil, err
}
length := msEnd - msStart
if length == 3 {
millis *= 10
} else if length == 2 {
millis *= 100
}
}
timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis
timestamps = append(timestamps, timeInMillis)
}
if end >= len(line) {
priorLine = ""
} else {
priorLine = strings.TrimSpace(line[end:])
}
validLine = true
} else {
text = line
structuredLines = append(structuredLines, Line{
Start: time,
Value: text,
})
}
}
if validLine {
for idx := range timestamps {
structuredLines = append(structuredLines, Line{
Start: &timestamps[idx],
Value: strings.TrimSpace(priorLine),
})
}
}
lyrics := Lyrics{
DisplayArtist: artist,
DisplayTitle: title,
Lang: language,
Line: structuredLines,
Offset: offset,
Synced: synced,
}
return &lyrics, nil
}
type LyricList []Lyrics

104
model/lyrics_test.go Normal file
View File

@ -0,0 +1,104 @@
package model_test
import (
. "github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ToLyrics", func() {
It("should parse tags with spaces", func() {
num := int64(1551)
lyrics, err := ToLyrics("xxx", "[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
Expect(lyrics.DisplayTitle).To(Equal("A title"))
Expect(lyrics.Offset).To(Equal(&num))
})
It("Should ignore bad offset", func() {
lyrics, err := ToLyrics("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Offset).To(BeNil())
})
It("should accept lines with no text and weird times", func() {
a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60)
lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "Hi there"},
{Start: &b, Value: ""},
{Start: &c, Value: "Test"},
{Start: &d, Value: "late"},
}))
})
It("Should support multiple timestamps per line", func() {
a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51)
lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "Repeated"},
{Start: &b, Value: "Repeated"},
{Start: &c, Value: ""},
{Start: &d, Value: ""},
}))
})
It("Should support parsing multiline string", func() {
a, b := int64(0), int64(10*60*1000+1)
lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "This is\na multiline\n\n[:0] string"},
{Start: &b, Value: "This is\nalso one"},
}))
})
It("Does not match timestamp in middle of line", func() {
lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeFalse())
Expect(lyrics.Line).To(Equal([]Line{
{Value: "This could [00:00:00] be a synced file"},
}))
})
It("Allows timestamp in middle of line if also at beginning", func() {
a, b := int64(0), int64(1000)
lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "This is [00:00:00] be a synced file"},
{Start: &b, Value: "Line 2"},
}))
})
It("Ignores lines in synchronized lyric prior to first timestamp", func() {
a := int64(0)
lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "Text"},
}))
})
It("Handles all possible ms cases", func() {
a, b, c := int64(1), int64(10), int64(100)
lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: &a, Value: "a"},
{Start: &b, Value: "b"},
{Start: &c, Value: "c"},
}))
})
})

View File

@ -1,6 +1,7 @@
package model
import (
"encoding/json"
"mime"
"path/filepath"
"sort"
@ -56,7 +57,7 @@ type MediaFile struct {
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
Compilation bool `structs:"compilation" json:"compilation"`
Comment string `structs:"comment" json:"comment,omitempty"`
Lyrics string `structs:"lyrics" json:"lyrics,omitempty"`
Lyrics string `structs:"lyrics" json:"lyrics"`
Bpm int `structs:"bpm" json:"bpm,omitempty"`
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"`
@ -92,6 +93,15 @@ func (mf MediaFile) AlbumCoverArtID() ArtworkID {
return artworkIDFromAlbum(Album{ID: mf.AlbumID})
}
func (mf MediaFile) StructuredLyrics() (LyricList, error) {
lyrics := LyricList{}
err := json.Unmarshal([]byte(mf.Lyrics), &lyrics)
if err != nil {
return nil, err
}
return lyrics, nil
}
type MediaFiles []MediaFile
// Dirs returns a deduped list of all directories from the MediaFiles' paths

View File

@ -73,7 +73,7 @@ func (s MediaFileMapper) ToMediaFile(md metadata.Tags) model.MediaFile {
mf.RGTrackGain = md.RGTrackGain()
mf.RGTrackPeak = md.RGTrackPeak()
mf.Comment = utils.SanitizeText(md.Comment())
mf.Lyrics = utils.SanitizeText(md.Lyrics())
mf.Lyrics = md.Lyrics()
mf.Bpm = md.Bpm()
mf.CreatedAt = md.BirthTime()
mf.UpdatedAt = md.ModificationTime()

View File

@ -316,4 +316,35 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu
Expect(md).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
})
It("parses lyrics with language code", func() {
const output = `
Input #0, mp3, from 'test.mp3':
Metadata:
lyrics-eng : [00:00.00]This is
: [00:02.50]English
lyrics-xxx : [00:00.00]This is
: [00:02.50]unspecified
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("lyrics-eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
Expect(md).To(HaveKeyWithValue("lyrics-xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}))
})
It("parses normal LYRICS tag", func() {
const output = `
Input #0, mp3, from 'test.mp3':
Metadata:
LYRICS : [00:00.00]This is
: [00:02.50]English
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("lyrics", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
})
})

View File

@ -1,6 +1,7 @@
package metadata
import (
"encoding/json"
"fmt"
"math"
"os"
@ -15,6 +16,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Extractor interface {
@ -131,8 +133,47 @@ func (t Tags) OriginalDate() (int, string) { return t.getDate("originaldate") }
func (t Tags) ReleaseDate() (int, string) { return t.getDate("releasedate") }
func (t Tags) Comment() string { return t.getFirstTagValue("comment") }
func (t Tags) Lyrics() string {
return t.getFirstTagValue("lyrics", "lyrics-eng", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics")
lyricList := model.LyricList{}
basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics")
for _, value := range basicLyrics {
lyrics, err := model.ToLyrics("xxx", value)
if err != nil {
log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err)
continue
}
lyricList = append(lyricList, *lyrics)
}
for tag, value := range t.Tags {
if strings.HasPrefix(tag, "lyrics-") {
language := strings.TrimSpace(strings.TrimPrefix(tag, "lyrics-"))
if language == "" {
language = "xxx"
}
for _, text := range value {
lyrics, err := model.ToLyrics(language, text)
if err != nil {
log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err)
continue
}
lyricList = append(lyricList, *lyrics)
}
}
}
res, err := json.Marshal(lyricList)
if err != nil {
log.Warn("Unexpected error occurred when serializing lyrics", "file", t.filePath, "error", err)
return ""
}
return string(res)
}
func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation", "wm/iscompilation") }
func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") }
func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") }

View File

@ -1,15 +1,64 @@
package metadata_test
import (
"encoding/json"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner/metadata"
_ "github.com/navidrome/navidrome/scanner/metadata/ffmpeg"
_ "github.com/navidrome/navidrome/scanner/metadata/taglib"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/exp/slices"
)
var _ = Describe("Tags", func() {
var zero int64 = 0
var secondTs int64 = 2500
makeLyrics := func(synced bool, lang, secondLine string) model.Lyrics {
lines := []model.Line{
{Value: "This is"},
{Value: secondLine},
}
if synced {
lines[0].Start = &zero
lines[1].Start = &secondTs
}
lyrics := model.Lyrics{
Lang: lang,
Line: lines,
Synced: synced,
}
return lyrics
}
sortLyrics := func(lines model.LyricList) model.LyricList {
slices.SortFunc(lines, func(a, b model.Lyrics) bool {
langDiff := strings.Compare(a.Lang, b.Lang)
if langDiff == 0 {
return strings.Compare(a.Line[1].Value, b.Line[1].Value) < 0
} else {
return langDiff < 0
}
})
return lines
}
compareLyrics := func(m metadata.Tags, expected model.LyricList) {
lyrics := model.LyricList{}
Expect(json.Unmarshal([]byte(m.Lyrics()), &lyrics)).To(BeNil())
Expect(sortLyrics(lyrics)).To(Equal(sortLyrics(expected)))
}
Context("Extract", func() {
BeforeEach(func() {
conf.Server.Scanner.Extractor = "taglib"
@ -61,10 +110,10 @@ var _ = Describe("Tags", func() {
Expect(m.Duration()).To(BeNumerically("~", 1.04, 0.01))
Expect(m.Suffix()).To(Equal("ogg"))
Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg"))
Expect(m.Size()).To(Equal(int64(6333)))
Expect(m.Size()).To(Equal(int64(5534)))
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.BitRate()).To(BeElementOf(18, 39, 40, 49))
Expect(m.BitRate()).To(BeElementOf(18, 39, 40, 43, 49))
m = mds["tests/fixtures/test.wma"]
Expect(err).To(BeNil())
@ -74,8 +123,86 @@ var _ = Describe("Tags", func() {
Expect(m.Duration()).To(BeNumerically("~", 1.02, 0.01))
Expect(m.Suffix()).To(Equal("wma"))
Expect(m.FilePath()).To(Equal("tests/fixtures/test.wma"))
Expect(m.Size()).To(Equal(int64(21431)))
Expect(m.Size()).To(Equal(int64(21581)))
Expect(m.BitRate()).To(BeElementOf(128))
})
DescribeTable("Lyrics test",
func(file string, langEncoded bool) {
path := "tests/fixtures/" + file
mds, err := metadata.Extract(path)
Expect(err).ToNot(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[path]
lyrics := model.LyricList{
makeLyrics(true, "xxx", "English"),
makeLyrics(true, "xxx", "unspecified"),
}
if langEncoded {
lyrics[0].Lang = "eng"
}
compareLyrics(m, lyrics)
},
Entry("Parses AIFF file", "test.aiff", true),
Entry("Parses FLAC files", "test.flac", false),
Entry("Parses M4A files", "01 Invisible (RED) Edit Version.m4a", false),
Entry("Parses OGG Vorbis files", "test.ogg", false),
Entry("Parses WAV files", "test.wav", true),
Entry("Parses WMA files", "test.wma", false),
Entry("Parses WV files", "test.wv", false),
)
It("Should parse mp3 with USLT and SYLT", func() {
path := "tests/fixtures/test.mp3"
mds, err := metadata.Extract(path)
Expect(err).ToNot(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[path]
compareLyrics(m, model.LyricList{
makeLyrics(true, "eng", "English SYLT"),
makeLyrics(true, "eng", "English"),
makeLyrics(true, "xxx", "unspecified SYLT"),
makeLyrics(true, "xxx", "unspecified"),
})
})
})
// Only run these tests if FFmpeg is available
FFmpegContext := XContext
if ffmpeg.New().IsAvailable() {
FFmpegContext = Context
}
FFmpegContext("Extract with FFmpeg", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Scanner.Extractor = "ffmpeg"
})
DescribeTable("Lyrics test",
func(file string) {
path := "tests/fixtures/" + file
mds, err := metadata.Extract(path)
Expect(err).ToNot(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[path]
compareLyrics(m, model.LyricList{
makeLyrics(true, "eng", "English"),
makeLyrics(true, "xxx", "unspecified"),
})
},
Entry("Parses AIFF file", "test.aiff"),
Entry("Parses MP3 files", "test.mp3"),
// Disabled, because it fails in pipeline
// Entry("Parses WAV files", "test.wav"),
// FFMPEG behaves very weirdly for multivalued tags for non-ID3
// Specifically, they are separated by ";, which is indistinguishable
// from other fields
)
})
})

View File

@ -39,7 +39,10 @@ var _ = Describe("Extractor", func() {
Expect(m).To(HaveKeyWithValue("album", []string{"Album", "Album"}))
Expect(m).To(HaveKeyWithValue("artist", []string{"Artist", "Artist"}))
Expect(m).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m).To(HaveKeyWithValue("tcmp", []string{"1"})) // Compilation
Expect(m).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("tcmp", []string{"1"}))) // Compilation
Expect(m).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m).To(HaveKeyWithValue("date", []string{"2014-05-21", "2014"}))
Expect(m).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
@ -50,7 +53,21 @@ var _ = Describe("Extractor", func() {
Expect(m).To(HaveKeyWithValue("bitrate", []string{"192"}))
Expect(m).To(HaveKeyWithValue("channels", []string{"2"}))
Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m).To(HaveKeyWithValue("lyrics", []string{"Lyrics 1\rLyrics 2"}))
Expect(m).ToNot(HaveKey("lyrics"))
Expect(m).To(Or(HaveKeyWithValue("lyrics-eng", []string{
"[00:00.00]This is\n[00:02.50]English SYLT\n",
"[00:00.00]This is\n[00:02.50]English",
}), HaveKeyWithValue("lyrics-eng", []string{
"[00:00.00]This is\n[00:02.50]English",
"[00:00.00]This is\n[00:02.50]English SYLT\n",
})))
Expect(m).To(Or(HaveKeyWithValue("lyrics-xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
"[00:00.00]This is\n[00:02.50]unspecified",
}), HaveKeyWithValue("lyrics-xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
})))
Expect(m).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
Expect(m).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
@ -70,10 +87,10 @@ var _ = Describe("Extractor", func() {
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m).To(HaveKey("bitrate"))
Expect(m["bitrate"][0]).To(BeElementOf("18", "39", "40", "49"))
Expect(m["bitrate"][0]).To(BeElementOf("18", "39", "40", "43", "49"))
})
DescribeTable("Format-Specific tests",
func(file, duration, channels, albumGain, albumPeak, trackGain, trackPeak string) {
func(file, duration, channels, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
@ -113,7 +130,21 @@ var _ = Describe("Extractor", func() {
Expect(m).To(HaveKeyWithValue("channels", []string{channels}))
Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m).To(HaveKeyWithValue("lyrics", []string{"Lyrics1\nLyrics 2"}))
if id3Lyrics {
Expect(m).To(HaveKeyWithValue("lyrics-eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
Expect(m).To(HaveKeyWithValue("lyrics-xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}))
} else {
Expect(m).To(HaveKeyWithValue("lyrics", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]English",
}))
}
Expect(m).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m).To(HaveKey("tracknumber"))
@ -123,25 +154,26 @@ var _ = Describe("Extractor", func() {
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1.00", "1", "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948"),
Entry("correctly parses flac tags", "test.flac", "1.00", "1", "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false),
Entry("Correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48"),
Entry("Correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48"),
Entry("Correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48", false),
Entry("Correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48", false),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04", "2", "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506"),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04", "2", "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
Entry("correctly parses wma/asf tags", "test.wma", "1.02", "1", "3.27 dB", "0.132914", "3.27 dB", "0.132914"),
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02", "1", "3.27 dB", "0.132914", "3.27 dB", "0.132914", false),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1.00", "1", "3.43 dB", "0.125061", "3.43 dB", "0.125061"),
Entry("correctly parses wv (wavpak) tags", "test.wv", "1.00", "1", "3.43 dB", "0.125061", "3.43 dB", "0.125061", false),
// TODO - these breaks in the pipeline as it uses TabLib 1.11. Once Ubuntu 24.04 is released we can uncomment these tests
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
//Entry("correctly parses wav tags", "test.wav", "1.00", "1", "3.06 dB", "0.125056", "3.06 dB", "0.125056"),
// Entry("correctly parses wav tags", "test.wav", "1.00", "1", "3.06 dB", "0.125056", "3.06 dB", "0.125056", true),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
//Entry("correctly parses aiff tags", "test.aiff", "1.00", "1", "2.00 dB", "0.124972", "2.00 dB", "0.124972"),
// Entry("correctly parses aiff tags", "test.aiff", "1.00", "1", "2.00 dB", "0.124972", "2.00 dB", "0.124972", true),
)
})
@ -155,6 +187,12 @@ var _ = Describe("Extractor", func() {
_, err := e.extractMetadata(testFilePath)
Expect(err).To(MatchError(fs.ErrNotExist))
})
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
// File has an empty TDAT frame
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(md).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
})
})
})

View File

@ -3,15 +3,19 @@
#include <typeinfo>
#define TAGLIB_STATIC
#include <aifffile.h>
#include <asffile.h>
#include <fileref.h>
#include <flacfile.h>
#include <id3v2tag.h>
#include <unsynchronizedlyricsframe.h>
#include <synchronizedlyricsframe.h>
#include <mp4file.h>
#include <mpegfile.h>
#include <opusfile.h>
#include <tpropertymap.h>
#include <vorbisfile.h>
#include <wavfile.h>
#include "taglib_wrapper.h"
@ -58,15 +62,86 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
}
}
TagLib::ID3v2::Tag *id3Tags = NULL;
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
if (mp3File != NULL) {
if (mp3File->ID3v2Tag()) {
const auto &frameListMap(mp3File->ID3v2Tag()->frameListMap());
id3Tags = mp3File->ID3v2Tag();
}
for (const auto &kv : frameListMap) {
if (!kv.second.isEmpty())
if (id3Tags == NULL) {
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
id3Tags = wavFile->ID3v2Tag();
}
}
if (id3Tags == NULL) {
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
if (aiffFile && aiffFile->hasID3v2Tag()) {
id3Tags = aiffFile->tag();
}
}
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
// with many players, so they will not be parsed
if (id3Tags != NULL) {
const auto &frames = id3Tags->frameListMap();
for (const auto &kv: frames) {
if (kv.first == "USLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
tags.erase("LYRICS");
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
char *val = (char *)frame->text().toCString(true);
go_map_put_lyrics(id, language, val);
}
} else if (kv.first == "SYLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
const auto format = frame->timestampFormat();
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
for (const auto &line: frame->synchedText()) {
char *text = (char *)line.text.toCString(true);
go_map_put_lyric_line(id, language, text, line.time);
}
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
const int sampleRate = props->sampleRate();
if (sampleRate != 0) {
for (const auto &line: frame->synchedText()) {
const int timeInMs = (line.time * 1000) / sampleRate;
char *text = (char *)line.text.toCString(true);
go_map_put_lyric_line(id, language, text, timeInMs);
}
}
}
}
} else {
if (!kv.second.isEmpty()) {
tags.insert(kv.first, kv.second.front()->toString());
}
}
}
}
@ -90,7 +165,7 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
const auto itemListMap = asfTags->attributeListMap();
for (const auto item : itemListMap) {
tags.insert(item.first, item.second.front().toString());
tags.insert(item.first, item.second.front().toString());
}
}

View File

@ -103,6 +103,12 @@ func go_map_put_str(id C.ulong, key *C.char, val *C.char) {
do_put_map(id, k, val)
}
//export go_map_put_lyrics
func go_map_put_lyrics(id C.ulong, lang *C.char, val *C.char) {
k := "lyrics-" + strings.ToLower(C.GoString(lang))
do_put_map(id, k, val)
}
func do_put_map(id C.ulong, key string, val *C.char) {
if key == "" {
return
@ -126,3 +132,30 @@ func go_map_put_int(id C.ulong, key *C.char, val C.int) {
defer C.free(unsafe.Pointer(vp))
go_map_put_str(id, key, vp)
}
//export go_map_put_lyric_line
func go_map_put_lyric_line(id C.ulong, lang *C.char, text *C.char, time C.int) {
language := C.GoString(lang)
line := C.GoString(text)
timeGo := int64(time)
ms := timeGo % 1000
timeGo /= 1000
sec := timeGo % 60
timeGo /= 60
min := timeGo % 60
formatted_line := fmt.Sprintf("[%02d:%02d.%02d]%s\n", min, sec, ms/10, line)
lock.RLock()
defer lock.RUnlock()
key := "lyrics-" + language
m := maps[uint32(id)]
existing, ok := m[key]
if ok {
existing[0] += formatted_line
} else {
m[key] = []string{formatted_line}
}
}

View File

@ -14,6 +14,8 @@ extern "C" {
extern void go_map_put_m4a_str(unsigned long id, char *key, char *val);
extern void go_map_put_str(unsigned long id, char *key, char *val);
extern void go_map_put_int(unsigned long id, char *key, int val);
extern void go_map_put_lyrics(unsigned long id, char *lang, char *val);
extern void go_map_put_lyric_line(unsigned long id, char *lang, char *text, int time);
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
#ifdef __cplusplus

View File

@ -142,6 +142,7 @@ func (api *Router) routes() http.Handler {
r.Group(func(r chi.Router) {
hr(r, "getAvatar", api.GetAvatar)
h(r, "getLyrics", api.GetLyrics)
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
})
r.Group(func(r chi.Router) {
// configure request throttling

View File

@ -323,3 +323,45 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
dir.OriginalReleaseDate = toItemDate(album.OriginalDate)
return dir
}
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric {
lines := make([]responses.Line, len(lyrics.Line))
for i, line := range lyrics.Line {
lines[i] = responses.Line{
Start: line.Start,
Value: line.Value,
}
}
structured := responses.StructuredLyric{
DisplayArtist: lyrics.DisplayArtist,
DisplayTitle: lyrics.DisplayTitle,
Lang: lyrics.Lang,
Line: lines,
Offset: lyrics.Offset,
Synced: lyrics.Synced,
}
if structured.DisplayArtist == "" {
structured.DisplayArtist = mf.Artist
}
if structured.DisplayTitle == "" {
structured.DisplayTitle = mf.Title
}
return structured
}
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList {
lyricList := make(responses.StructuredLyrics, len(lyricsList))
for i, lyrics := range lyricsList {
lyricList[i] = buildStructuredLyric(mf, lyrics)
}
res := &responses.LyricsList{
StructuredLyrics: lyricList,
}
return res
}

View File

@ -5,7 +5,6 @@ import (
"errors"
"io"
"net/http"
"regexp"
"time"
"github.com/navidrome/navidrome/conf"
@ -90,16 +89,6 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons
return nil, err
}
const timeStampRegex string = `(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])`
func isSynced(rawLyrics string) bool {
r := regexp.MustCompile(timeStampRegex)
// Eg: [04:02:50.85]
// [02:50.85]
// [02:50]
return r.MatchString(rawLyrics)
}
func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
artist, _ := p.String("artist")
@ -117,15 +106,46 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return response, nil
}
structuredLyrics, err := mediaFiles[0].StructuredLyrics()
if err != nil {
return nil, err
}
if len(structuredLyrics) == 0 {
return response, nil
}
lyrics.Artist = artist
lyrics.Title = title
if isSynced(mediaFiles[0].Lyrics) {
r := regexp.MustCompile(timeStampRegex)
lyrics.Value = r.ReplaceAllString(mediaFiles[0].Lyrics, "")
} else {
lyrics.Value = mediaFiles[0].Lyrics
lyricsText := ""
for _, line := range structuredLyrics[0].Line {
lyricsText += line.Value + "\n"
}
lyrics.Value = lyricsText
return response, nil
}
func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, error) {
id, err := req.Params(r).String("id")
if err != nil {
return nil, err
}
mediaFile, err := api.ds.MediaFile(r.Context()).Get(id)
if err != nil {
return nil, err
}
lyrics, err := mediaFile.StructuredLyrics()
if err != nil {
return nil, err
}
response := newResponse()
response.LyricsList = buildLyricsList(mediaFile, lyrics)
return response, nil
}

View File

@ -3,6 +3,7 @@ package subsonic
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http/httptest"
@ -11,6 +12,7 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -72,12 +74,18 @@ var _ = Describe("MediaRetrievalController", func() {
Describe("GetLyrics", func() {
It("should return data for given artist & title", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")
lyricsJson, err := json.Marshal(model.LyricList{
*lyrics,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyrics(r)
@ -87,7 +95,7 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(err).To(BeNil())
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I"))
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
})
It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() {
r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa")
@ -100,7 +108,143 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(response.Lyrics.Artist).To(Equal(""))
Expect(response.Lyrics.Title).To(Equal(""))
Expect(response.Lyrics.Value).To(Equal(""))
})
})
Describe("getLyricsBySongId", func() {
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
var times = []int64{18800, 22801}
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
Expect(actual).ToNot(BeNil())
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
for i, realLyric := range actual.StructuredLyrics {
expectedLyric := expected.StructuredLyrics[i]
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
if expectedLyric.Offset == nil {
Expect(realLyric.Offset).To(BeNil())
} else {
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
}
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
for j, realLine := range realLyric.Line {
expectedLine := expectedLyric.Line[j]
Expect(realLine.Value).To(Equal(expectedLine.Value))
if expectedLine.Start == nil {
Expect(realLine.Start).To(BeNil())
} else {
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
}
}
}
}
It("should return mixed lyrics", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced, *unsynced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
Lang: "eng",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
},
{
Lang: "xxx",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: false,
Line: []responses.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
},
},
})
})
It("should parse lrc metadata", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
offset := int64(-100)
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
Offset: &offset,
},
},
})
})
})
})
@ -122,26 +266,6 @@ func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int) (
return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil
}
var _ = Describe("isSynced", func() {
It("returns false if lyrics contain no timestamps", func() {
Expect(isSynced("Just in case my car goes off the highway")).To(Equal(false))
Expect(isSynced("[02.50] Just in case my car goes off the highway")).To(Equal(false))
})
It("returns false if lyrics is an empty string", func() {
Expect(isSynced("")).To(Equal(false))
})
It("returns true if lyrics contain timestamps", func() {
Expect(isSynced(`NF Real Music
[00:00] First line
[00:00.85] JUST LIKE YOU
[00:00.85] Just in case my car goes off the highway`)).To(Equal(true))
Expect(isSynced("[04:02:50.85] Never gonna give you up")).To(Equal(true))
Expect(isSynced("[02:50.85] Never gonna give you up")).To(Equal(true))
Expect(isSynced("[02:50] Never gonna give you up")).To(Equal(true))
})
})
type mockedMediaFile struct {
model.MediaFileRepository
data model.MediaFiles
@ -154,3 +278,12 @@ func (m *mockedMediaFile) SetData(mfs model.MediaFiles) {
func (m *mockedMediaFile) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
return m.data, nil
}
func (m *mockedMediaFile) Get(id string) (*model.MediaFile, error) {
for _, mf := range m.data {
if mf.ID == id {
return &mf, nil
}
}
return nil, model.ErrNotFound
}

View File

@ -11,6 +11,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{
{Name: "transcodeOffset", Versions: []int32{1}},
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
}
return response, nil
}

View File

@ -0,0 +1,43 @@
{
"status": "ok",
"version": "1.8.0",
"type": "navidrome",
"serverVersion": "v0.0.0",
"openSubsonic": true,
"lyricsList": {
"structuredLyrics": [
{
"displayArtist": "Rick Astley",
"displayTitle": "Never Gonna Give You Up",
"lang": "eng",
"line": [
{
"start": 18800,
"value": "We're no strangers to love"
},
{
"start": 22801,
"value": "You know the rules and so do I"
}
],
"offset": 100,
"synced": true
},
{
"displayArtist": "Rick Astley",
"displayTitle": "Never Gonna Give You Up",
"lang": "xxx",
"line": [
{
"value": "We're no strangers to love"
},
{
"value": "You know the rules and so do I"
}
],
"offset": 100,
"synced": false
}
]
}
}

View File

@ -0,0 +1,20 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<lyricsList>
<structuredLyrics displayArtist="Rick Astley" displayTitle="Never Gonna Give You Up" lang="eng" offset="100" synced="true">
<line start="18800">
<value>We&#39;re no strangers to love</value>
</line>
<line start="22801">
<value>You know the rules and so do I</value>
</line>
</structuredLyrics>
<structuredLyrics displayArtist="Rick Astley" displayTitle="Never Gonna Give You Up" lang="xxx" offset="100" synced="false">
<line>
<value>We&#39;re no strangers to love</value>
</line>
<line>
<value>You know the rules and so do I</value>
</line>
</structuredLyrics>
</lyricsList>
</subsonic-response>

View File

@ -0,0 +1,8 @@
{
"status": "ok",
"version": "1.8.0",
"type": "navidrome",
"serverVersion": "v0.0.0",
"openSubsonic": true,
"lyricsList": {}
}

View File

@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<lyricsList></lyricsList>
</subsonic-response>

View File

@ -58,6 +58,7 @@ type Subsonic struct {
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
}
type JsonWrapper struct {
@ -446,6 +447,26 @@ type JukeboxPlaylist struct {
JukeboxStatus
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
type Line struct {
Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"`
Value string `xml:"value" json:"value"`
}
type StructuredLyric struct {
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
Lang string `xml:"lang,attr" json:"lang"`
Line []Line `xml:"line" json:"line"`
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
Synced bool `xml:"synced,attr" json:"synced"`
}
type StructuredLyrics []StructuredLyric
type LyricsList struct {
StructuredLyrics []StructuredLyric `xml:"structuredLyrics,omitempty" json:"structuredLyrics,omitempty"`
}
type OpenSubsonicExtension struct {
Name string `xml:"name,attr" json:"name"`
Versions []int32 `xml:"versions" json:"versions"`

View File

@ -796,4 +796,69 @@ var _ = Describe("Responses", func() {
})
})
})
Describe("LyricsList", func() {
BeforeEach(func() {
response.LyricsList = &LyricsList{}
})
Describe("without data", func() {
It("should match .XML", func() {
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
})
Describe("with data", func() {
BeforeEach(func() {
times := []int64{18800, 22801}
offset := int64(100)
response.LyricsList.StructuredLyrics = StructuredLyrics{
{
Lang: "eng",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Offset: &offset,
Synced: true,
Line: []Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
},
{
Lang: "xxx",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Offset: &offset,
Synced: false,
Line: []Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
},
}
})
It("should match .XML", func() {
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
})
})
})
})

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/fixtures/test.wv vendored

Binary file not shown.

View File

@ -19,6 +19,10 @@ type MockFFmpeg struct {
Error error
}
func (ff *MockFFmpeg) IsAvailable() bool {
return true
}
func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) {
if ff.Error != nil {
return nil, ff.Error

View File

@ -20,8 +20,14 @@ const initialState = {
savedPlayIndex: 0,
}
const timestampRegex =
/(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g
const pad = (value) => {
const str = value.toString()
if (str.length === 1) {
return `0${str}`
} else {
return str
}
}
const mapToAudioLists = (item) => {
// If item comes from a playlist, trackId is mediaFileId
@ -40,12 +46,33 @@ const mapToAudioLists = (item) => {
}
const { lyrics } = item
let lyricText = ''
if (lyrics) {
const structured = JSON.parse(lyrics)
for (const structuredLyric of structured) {
if (structuredLyric.synced) {
for (const line of structuredLyric.line) {
let time = Math.floor(line.start / 10)
const ms = time % 100
time = Math.floor(time / 100)
const sec = time % 60
time = Math.floor(time / 60)
const min = time % 60
ms.toString()
lyricText += `[${pad(min)}:${pad(sec)}.${pad(ms)}] ${line.value}\n`
}
}
}
}
return {
trackId,
uuid: uuidv4(),
song: item,
name: item.title,
lyric: timestampRegex.test(lyrics) ? lyrics : '',
lyric: lyricText,
singer: item.artist,
duration: item.duration,
musicSrc: subsonic.streamUrl(trackId),