mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-03 04:27:37 +03:00
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 commit87df7f6df7
. * 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 commit372eb4b0ae
. * My OCD made me do it :P --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
parent
130ab76c79
commit
814161d78d
37 changed files with 1215 additions and 71 deletions
18
.github/workflows/pipeline.yml
vendored
18
.github/workflows/pipeline.yml
vendored
|
@ -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: |
|
||||
|
|
|
@ -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}
|
||||
|
|
82
db/migration/20231209211223_alter_lyric_column.go
Normal file
82
db/migration/20231209211223_alter_lyric_column.go
Normal 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
201
model/lyrics.go
Normal 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: ×tamps[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: ×tamps[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
104
model/lyrics_test.go
Normal 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"},
|
||||
}))
|
||||
})
|
||||
})
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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") }
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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"}))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[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: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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'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're no strangers to love</value>
|
||||
</line>
|
||||
<line>
|
||||
<value>You know the rules and so do I</value>
|
||||
</line>
|
||||
</structuredLyrics>
|
||||
</lyricsList>
|
||||
</subsonic-response>
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"version": "1.8.0",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"lyricsList": {}
|
||||
}
|
|
@ -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>
|
|
@ -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"`
|
||||
|
|
|
@ -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: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
|
BIN
tests/fixtures/01 Invisible (RED) Edit Version.m4a
vendored
BIN
tests/fixtures/01 Invisible (RED) Edit Version.m4a
vendored
Binary file not shown.
BIN
tests/fixtures/invalid-files/test-invalid-frame.mp3
vendored
Normal file
BIN
tests/fixtures/invalid-files/test-invalid-frame.mp3
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/test.aiff
vendored
BIN
tests/fixtures/test.aiff
vendored
Binary file not shown.
BIN
tests/fixtures/test.flac
vendored
BIN
tests/fixtures/test.flac
vendored
Binary file not shown.
BIN
tests/fixtures/test.m4a
vendored
BIN
tests/fixtures/test.m4a
vendored
Binary file not shown.
BIN
tests/fixtures/test.mp3
vendored
BIN
tests/fixtures/test.mp3
vendored
Binary file not shown.
BIN
tests/fixtures/test.ogg
vendored
BIN
tests/fixtures/test.ogg
vendored
Binary file not shown.
BIN
tests/fixtures/test.wav
vendored
BIN
tests/fixtures/test.wav
vendored
Binary file not shown.
BIN
tests/fixtures/test.wma
vendored
BIN
tests/fixtures/test.wma
vendored
Binary file not shown.
BIN
tests/fixtures/test.wv
vendored
BIN
tests/fixtures/test.wv
vendored
Binary file not shown.
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue