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
|
@ -1,4 +1,4 @@
|
||||||
name: 'Pipeline: Test, Lint, Build'
|
name: "Pipeline: Test, Lint, Build"
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
@ -13,6 +13,9 @@ jobs:
|
||||||
name: Lint Go code
|
name: Lint Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Update ubuntu repo
|
||||||
|
run: sudo apt-get update
|
||||||
|
|
||||||
- name: Install taglib
|
- name: Install taglib
|
||||||
run: sudo apt-get install libtag1-dev
|
run: sudo apt-get install libtag1-dev
|
||||||
|
|
||||||
|
@ -48,10 +51,13 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go_version: [1.21.x,1.20.x]
|
go_version: [1.21.x, 1.20.x]
|
||||||
steps:
|
steps:
|
||||||
|
- name: Update ubuntu repo
|
||||||
|
run: sudo apt-get update
|
||||||
|
|
||||||
- name: Install taglib
|
- 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
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
@ -75,14 +81,14 @@ jobs:
|
||||||
name: Build JS bundle
|
name: Build JS bundle
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: "**/package-lock.json"
|
||||||
|
|
||||||
- name: npm install dependencies
|
- name: npm install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|
|
@ -22,6 +22,7 @@ type FFmpeg interface {
|
||||||
ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error)
|
ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error)
|
||||||
Probe(ctx context.Context, files []string) (string, error)
|
Probe(ctx context.Context, files []string) (string, error)
|
||||||
CmdPath() (string, error)
|
CmdPath() (string, error)
|
||||||
|
IsAvailable() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() FFmpeg {
|
func New() FFmpeg {
|
||||||
|
@ -78,6 +79,11 @@ func (e *ffmpeg) CmdPath() (string, error) {
|
||||||
return ffmpegCmd()
|
return ffmpegCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *ffmpeg) IsAvailable() bool {
|
||||||
|
_, err := ffmpegCmd()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
||||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||||
j := &ffCmd{args: args}
|
j := &ffCmd{args: args}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"mime"
|
"mime"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -56,7 +57,7 @@ type MediaFile struct {
|
||||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||||
Compilation bool `structs:"compilation" json:"compilation"`
|
Compilation bool `structs:"compilation" json:"compilation"`
|
||||||
Comment string `structs:"comment" json:"comment,omitempty"`
|
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"`
|
Bpm int `structs:"bpm" json:"bpm,omitempty"`
|
||||||
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
||||||
MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,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})
|
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
|
type MediaFiles []MediaFile
|
||||||
|
|
||||||
// Dirs returns a deduped list of all directories from the MediaFiles' paths
|
// 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.RGTrackGain = md.RGTrackGain()
|
||||||
mf.RGTrackPeak = md.RGTrackPeak()
|
mf.RGTrackPeak = md.RGTrackPeak()
|
||||||
mf.Comment = utils.SanitizeText(md.Comment())
|
mf.Comment = utils.SanitizeText(md.Comment())
|
||||||
mf.Lyrics = utils.SanitizeText(md.Lyrics())
|
mf.Lyrics = md.Lyrics()
|
||||||
mf.Bpm = md.Bpm()
|
mf.Bpm = md.Bpm()
|
||||||
mf.CreatedAt = md.BirthTime()
|
mf.CreatedAt = md.BirthTime()
|
||||||
mf.UpdatedAt = md.ModificationTime()
|
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"}))
|
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
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
@ -15,6 +16,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Extractor interface {
|
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) ReleaseDate() (int, string) { return t.getDate("releasedate") }
|
||||||
func (t Tags) Comment() string { return t.getFirstTagValue("comment") }
|
func (t Tags) Comment() string { return t.getFirstTagValue("comment") }
|
||||||
func (t Tags) Lyrics() string {
|
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) Compilation() bool { return t.getBool("tcmp", "compilation", "wm/iscompilation") }
|
||||||
func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") }
|
func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") }
|
||||||
func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") }
|
func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") }
|
||||||
|
|
|
@ -1,15 +1,64 @@
|
||||||
package metadata_test
|
package metadata_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"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"
|
||||||
_ "github.com/navidrome/navidrome/scanner/metadata/ffmpeg"
|
_ "github.com/navidrome/navidrome/scanner/metadata/ffmpeg"
|
||||||
_ "github.com/navidrome/navidrome/scanner/metadata/taglib"
|
_ "github.com/navidrome/navidrome/scanner/metadata/taglib"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Tags", func() {
|
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() {
|
Context("Extract", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.Scanner.Extractor = "taglib"
|
conf.Server.Scanner.Extractor = "taglib"
|
||||||
|
@ -61,10 +110,10 @@ var _ = Describe("Tags", func() {
|
||||||
Expect(m.Duration()).To(BeNumerically("~", 1.04, 0.01))
|
Expect(m.Duration()).To(BeNumerically("~", 1.04, 0.01))
|
||||||
Expect(m.Suffix()).To(Equal("ogg"))
|
Expect(m.Suffix()).To(Equal("ogg"))
|
||||||
Expect(m.FilePath()).To(Equal("tests/fixtures/test.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.
|
// TabLib 1.12 returns 18, previous versions return 39.
|
||||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
// 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"]
|
m = mds["tests/fixtures/test.wma"]
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
|
@ -74,8 +123,86 @@ var _ = Describe("Tags", func() {
|
||||||
Expect(m.Duration()).To(BeNumerically("~", 1.02, 0.01))
|
Expect(m.Duration()).To(BeNumerically("~", 1.02, 0.01))
|
||||||
Expect(m.Suffix()).To(Equal("wma"))
|
Expect(m.Suffix()).To(Equal("wma"))
|
||||||
Expect(m.FilePath()).To(Equal("tests/fixtures/test.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))
|
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("album", []string{"Album", "Album"}))
|
||||||
Expect(m).To(HaveKeyWithValue("artist", []string{"Artist", "Artist"}))
|
Expect(m).To(HaveKeyWithValue("artist", []string{"Artist", "Artist"}))
|
||||||
Expect(m).To(HaveKeyWithValue("albumartist", []string{"Album 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("genre", []string{"Rock"}))
|
||||||
Expect(m).To(HaveKeyWithValue("date", []string{"2014-05-21", "2014"}))
|
Expect(m).To(HaveKeyWithValue("date", []string{"2014-05-21", "2014"}))
|
||||||
Expect(m).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
|
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("bitrate", []string{"192"}))
|
||||||
Expect(m).To(HaveKeyWithValue("channels", []string{"2"}))
|
Expect(m).To(HaveKeyWithValue("channels", []string{"2"}))
|
||||||
Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
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("bpm", []string{"123"}))
|
||||||
Expect(m).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
|
Expect(m).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
|
||||||
Expect(m).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
|
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.
|
// TabLib 1.12 returns 18, previous versions return 39.
|
||||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||||
Expect(m).To(HaveKey("bitrate"))
|
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",
|
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
|
file = "tests/fixtures/" + file
|
||||||
mds, err := e.Parse(file)
|
mds, err := e.Parse(file)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
@ -113,7 +130,21 @@ var _ = Describe("Extractor", func() {
|
||||||
|
|
||||||
Expect(m).To(HaveKeyWithValue("channels", []string{channels}))
|
Expect(m).To(HaveKeyWithValue("channels", []string{channels}))
|
||||||
Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
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(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
|
|
||||||
Expect(m).To(HaveKey("tracknumber"))
|
Expect(m).To(HaveKey("tracknumber"))
|
||||||
|
@ -123,25 +154,26 @@ var _ = Describe("Extractor", func() {
|
||||||
},
|
},
|
||||||
|
|
||||||
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
|
// 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", "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"),
|
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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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)
|
_, err := e.extractMetadata(testFilePath)
|
||||||
Expect(err).To(MatchError(fs.ErrNotExist))
|
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>
|
#include <typeinfo>
|
||||||
|
|
||||||
#define TAGLIB_STATIC
|
#define TAGLIB_STATIC
|
||||||
|
#include <aifffile.h>
|
||||||
#include <asffile.h>
|
#include <asffile.h>
|
||||||
#include <fileref.h>
|
#include <fileref.h>
|
||||||
#include <flacfile.h>
|
#include <flacfile.h>
|
||||||
#include <id3v2tag.h>
|
#include <id3v2tag.h>
|
||||||
|
#include <unsynchronizedlyricsframe.h>
|
||||||
|
#include <synchronizedlyricsframe.h>
|
||||||
#include <mp4file.h>
|
#include <mp4file.h>
|
||||||
#include <mpegfile.h>
|
#include <mpegfile.h>
|
||||||
#include <opusfile.h>
|
#include <opusfile.h>
|
||||||
#include <tpropertymap.h>
|
#include <tpropertymap.h>
|
||||||
#include <vorbisfile.h>
|
#include <vorbisfile.h>
|
||||||
|
#include <wavfile.h>
|
||||||
|
|
||||||
#include "taglib_wrapper.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)
|
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
||||||
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
||||||
if (mp3File != NULL) {
|
if (mp3File != NULL) {
|
||||||
if (mp3File->ID3v2Tag()) {
|
id3Tags = mp3File->ID3v2Tag();
|
||||||
const auto &frameListMap(mp3File->ID3v2Tag()->frameListMap());
|
}
|
||||||
|
|
||||||
for (const auto &kv : frameListMap) {
|
if (id3Tags == NULL) {
|
||||||
if (!kv.second.isEmpty())
|
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());
|
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 TagLib::ASF::Tag *asfTags{asfFile->tag()};
|
||||||
const auto itemListMap = asfTags->attributeListMap();
|
const auto itemListMap = asfTags->attributeListMap();
|
||||||
for (const auto item : itemListMap) {
|
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)
|
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) {
|
func do_put_map(id C.ulong, key string, val *C.char) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return
|
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))
|
defer C.free(unsafe.Pointer(vp))
|
||||||
go_map_put_str(id, key, 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_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_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_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);
|
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
|
|
|
@ -142,6 +142,7 @@ func (api *Router) routes() http.Handler {
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
hr(r, "getAvatar", api.GetAvatar)
|
hr(r, "getAvatar", api.GetAvatar)
|
||||||
h(r, "getLyrics", api.GetLyrics)
|
h(r, "getLyrics", api.GetLyrics)
|
||||||
|
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
// configure request throttling
|
// configure request throttling
|
||||||
|
|
|
@ -323,3 +323,45 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
||||||
dir.OriginalReleaseDate = toItemDate(album.OriginalDate)
|
dir.OriginalReleaseDate = toItemDate(album.OriginalDate)
|
||||||
return dir
|
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"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
@ -90,16 +89,6 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons
|
||||||
return nil, err
|
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) {
|
func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||||
p := req.Params(r)
|
p := req.Params(r)
|
||||||
artist, _ := p.String("artist")
|
artist, _ := p.String("artist")
|
||||||
|
@ -117,15 +106,46 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||||
return response, nil
|
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.Artist = artist
|
||||||
lyrics.Title = title
|
lyrics.Title = title
|
||||||
|
|
||||||
if isSynced(mediaFiles[0].Lyrics) {
|
lyricsText := ""
|
||||||
r := regexp.MustCompile(timeStampRegex)
|
for _, line := range structuredLyrics[0].Line {
|
||||||
lyrics.Value = r.ReplaceAllString(mediaFiles[0].Lyrics, "")
|
lyricsText += line.Value + "\n"
|
||||||
} else {
|
|
||||||
lyrics.Value = mediaFiles[0].Lyrics
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package subsonic
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
@ -11,6 +12,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
@ -72,12 +74,18 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||||
Describe("GetLyrics", func() {
|
Describe("GetLyrics", func() {
|
||||||
It("should return data for given artist & title", func() {
|
It("should return data for given artist & title", func() {
|
||||||
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
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{
|
mockRepo.SetData(model.MediaFiles{
|
||||||
{
|
{
|
||||||
ID: "1",
|
ID: "1",
|
||||||
Artist: "Rick Astley",
|
Artist: "Rick Astley",
|
||||||
Title: "Never Gonna Give You Up",
|
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)
|
response, err := router.GetLyrics(r)
|
||||||
|
@ -87,7 +95,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
||||||
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
|
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() {
|
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")
|
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.Artist).To(Equal(""))
|
||||||
Expect(response.Lyrics.Title).To(Equal(""))
|
Expect(response.Lyrics.Title).To(Equal(""))
|
||||||
Expect(response.Lyrics.Value).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
|
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 {
|
type mockedMediaFile struct {
|
||||||
model.MediaFileRepository
|
model.MediaFileRepository
|
||||||
data model.MediaFiles
|
data model.MediaFiles
|
||||||
|
@ -154,3 +278,12 @@ func (m *mockedMediaFile) SetData(mfs model.MediaFiles) {
|
||||||
func (m *mockedMediaFile) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockedMediaFile) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
return m.data, nil
|
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{
|
response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{
|
||||||
{Name: "transcodeOffset", Versions: []int32{1}},
|
{Name: "transcodeOffset", Versions: []int32{1}},
|
||||||
{Name: "formPost", Versions: []int32{1}},
|
{Name: "formPost", Versions: []int32{1}},
|
||||||
|
{Name: "songLyrics", Versions: []int32{1}},
|
||||||
}
|
}
|
||||||
return response, nil
|
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"`
|
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
|
||||||
|
|
||||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||||
|
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JsonWrapper struct {
|
type JsonWrapper struct {
|
||||||
|
@ -446,6 +447,26 @@ type JukeboxPlaylist struct {
|
||||||
JukeboxStatus
|
JukeboxStatus
|
||||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
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 {
|
type OpenSubsonicExtension struct {
|
||||||
Name string `xml:"name,attr" json:"name"`
|
Name string `xml:"name,attr" json:"name"`
|
||||||
Versions []int32 `xml:"versions" json:"versions"`
|
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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
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.
Binary file not shown.
Binary file not shown.
|
@ -19,6 +19,10 @@ type MockFFmpeg struct {
|
||||||
Error error
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ff *MockFFmpeg) IsAvailable() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) {
|
func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) {
|
||||||
if ff.Error != nil {
|
if ff.Error != nil {
|
||||||
return nil, ff.Error
|
return nil, ff.Error
|
||||||
|
|
|
@ -20,8 +20,14 @@ const initialState = {
|
||||||
savedPlayIndex: 0,
|
savedPlayIndex: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestampRegex =
|
const pad = (value) => {
|
||||||
/(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g
|
const str = value.toString()
|
||||||
|
if (str.length === 1) {
|
||||||
|
return `0${str}`
|
||||||
|
} else {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mapToAudioLists = (item) => {
|
const mapToAudioLists = (item) => {
|
||||||
// If item comes from a playlist, trackId is mediaFileId
|
// If item comes from a playlist, trackId is mediaFileId
|
||||||
|
@ -40,12 +46,33 @@ const mapToAudioLists = (item) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lyrics } = 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 {
|
return {
|
||||||
trackId,
|
trackId,
|
||||||
uuid: uuidv4(),
|
uuid: uuidv4(),
|
||||||
song: item,
|
song: item,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
lyric: timestampRegex.test(lyrics) ? lyrics : '',
|
lyric: lyricText,
|
||||||
singer: item.artist,
|
singer: item.artist,
|
||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
musicSrc: subsonic.streamUrl(trackId),
|
musicSrc: subsonic.streamUrl(trackId),
|
||||||
|
|
Loading…
Reference in New Issue