Added embedded/external lyrics support: parsing + indexing, ref #379

This commit is contained in:
emeric
2024-10-26 16:58:13 +02:00
parent 74a1a3063f
commit 72b1367ea6
51 changed files with 2034 additions and 253 deletions
+5
View File
@@ -4,8 +4,13 @@ if(BUILD_TESTING)
add_subdirectory(test)
endif()
if (BUILD_BENCHMARKS)
add_subdirectory(bench)
endif()
add_library(lmsmetadata SHARED
impl/AvFormatTagReader.cpp
impl/Lyrics.cpp
impl/Parser.cpp
impl/TagLibTagReader.cpp
impl/Utils.cpp
+9
View File
@@ -0,0 +1,9 @@
add_executable(bench-metadata
LyricsBench.cpp
)
target_link_libraries(bench-metadata PRIVATE
lmsmetadata
benchmark
)
+94
View File
@@ -0,0 +1,94 @@
/*
* Copyright (C) 2024 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <iostream>
#include <sstream>
#include <benchmark/benchmark.h>
#include "metadata/Lyrics.hpp"
namespace lms::metadata::benchmarks
{
static void BM_Lyrics(benchmark::State& state)
{
std::istringstream lyricsStream{ R"(
[id: rkrzmqos]
[ar: Billie Eilish]
[al: HIT ME HARD AND SOFT]
[ti: WILDFLOWER]
[length: 04:21]
[00:15.16]Things fall apart and time breaks your heart
[00:21.57]I wasn't there, but I know
[00:28.01]She was your girl, you showed her the world
[00:34.18]You fell out of love and you both let go
[00:40.33]She was cryin' on my shoulder, all I could do was hold her
[00:46.71]Only made us closer until July
[00:53.47]Now I know that you love me, you don't need to remind me
[00:59.86]I should put it all behind me, shouldn't I?
[01:04.72]But I see her in the back of my mind
[01:11.50]All the time
[01:17.75]Like a fever, like I'm burning alive
[01:24.31]Like a sign
[01:32.67]Did I cross the line?
[01:37.72]Mm, hm
[01:48.97]Well, good things don't last (good things don't last)
[01:52.28]And life moves so fast (life moves so fast)
[01:55.50]I'd never ask who was better (I'd never ask who was better)
[02:01.68]'Cause she couldn't be (she couldn't be)
[02:05.20]More different from me (more different)
[02:08.53]Happy and free (happy and free) in leather
[02:14.51]And I know that you love me (you love me)
[02:18.01]You don't need to remind me (remind me)
[02:20.88]Wanna put it all behind me, but baby
[02:26.41]I see her in the back of my mind (back of my mind)
[02:32.66]All the time (all the time)
[02:38.95]Feels like a fever (like a fever)
[02:42.06]Like I'm burning alive (burning alive)
[02:45.66]Like a sign
[02:53.68]Did I cross the line?
[02:58.03]You say no one knows you so well (oh)
[03:02.63]But every time you touch me, I just wonder how she felt
[03:08.54]Valentine's Day, cryin' in the hotel
[03:14.48]I know you didn't mean to hurt me, so I kept it to myself
[03:21.13]And I wonder
[03:24.41]Do you see her in the back of your mind?
[03:31.13]In my eyes?
[03:51.94]You say no one knows you so well
[03:56.88]But every time you touch me, I just wonder how she felt
[04:03.87]Valentine's Day, cryin' in the hotel
[04:09.16]I know you didn't mean to hurt me, so I kept it to myself
[04:15.59]
)" };
for (auto _ : state)
{
lyricsStream.clear();
lyricsStream.seekg(0, std::ios::beg);
const Lyrics lyrics{ parseLyrics(lyricsStream) };
assert(lyrics.synchronizedLines.size() == 41);
}
}
BENCHMARK(BM_Lyrics);
} // namespace lms::metadata::benchmarks
BENCHMARK_MAIN();
+212
View File
@@ -0,0 +1,212 @@
/*
* Copyright (C) 2024 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "metadata/Lyrics.hpp"
#include <cassert>
#include <regex>
#include "core/String.hpp"
namespace lms::metadata
{
std::span<const std::filesystem::path> getSupportedLyricsFileExtensions()
{
static const std::array<std::filesystem::path, 1> fileExtensions{ ".lrc" }; // TODO handle ".txt" and ".elrc"
return fileExtensions;
}
namespace
{
std::string_view getSubmatchString(const std::csub_match& submatch)
{
assert(submatch.matched);
return std::string_view{ submatch.first, static_cast<std::string_view::size_type>(submatch.length()) };
}
// Parse a single line with ID tags like [ar: Artist] and set the appropriate fields in the Lyrics object
bool parseIDTag(std::string_view line, Lyrics& lyrics)
{
static const std::regex idTagRegex{ R"(^\[([a-zA-Z_]+):(.+?)\])" };
std::cmatch match;
if (std::regex_search(line.data(), line.data() + line.size(), match, idTagRegex))
{
std::string_view tagType{ getSubmatchString(match[1]) };
std::string_view tagValue{ core::stringUtils::stringTrim(getSubmatchString(match[2])) };
if (tagType == "ar")
{
lyrics.displayArtist = tagValue;
}
else if (tagType == "al")
{
lyrics.displayAlbum = tagValue;
}
else if (tagType == "ti")
{
lyrics.displayTitle = tagValue;
}
else if (tagType == "la")
{
lyrics.language = tagValue;
}
else if (tagType == "offset")
{
if (const auto value{ core::stringUtils::readAs<int>(tagValue) })
lyrics.offset = std::chrono::milliseconds{ *value };
}
// not interrested by other tags like 'duration', 'id', etc.
return true;
}
return false;
}
// Parse timestamps from a line and return the associated times in milliseconds
void extractTimestamps(std::string_view line, std::vector<std::chrono::milliseconds>& timestamps)
{
timestamps.clear();
static const std::regex timeTagRegex{ R"(\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\])" };
std::cregex_iterator regexIt(line.begin(), line.end(), timeTagRegex);
std::cregex_iterator regexEnd;
while (regexIt != regexEnd)
{
std::cmatch match{ *regexIt };
int hour{ match[1].matched ? std::stoi(match[1].str()) : 0 };
int minute{ std::stoi(match[2].str()) };
int second{ std::stoi(match[3].str()) };
int fractional{ match[4].matched ? std::stoi(match[4].str()) : 0 };
std::chrono::milliseconds currentTimestamp{ std::chrono::hours{ hour } + std::chrono::minutes{ minute } + std::chrono::seconds{ second } };
if (match[4].length() == 2) // Centiseconds
{
currentTimestamp += std::chrono::milliseconds{ fractional * 10 };
}
else // Milliseconds
{
currentTimestamp += std::chrono::milliseconds{ fractional };
}
timestamps.push_back(currentTimestamp);
++regexIt;
}
}
// Extract the lyric text from a line, removing any timestamps
std::string_view extractLyricText(std::string_view line)
{
return line.substr(line.find_last_of(']') + 1);
}
} // namespace
// Main function to parse lyrics from an input stream
Lyrics parseLyrics(std::istream& is)
{
Lyrics lyrics;
enum class State
{
None,
SynchronizedLyrics,
UnsynchronizedLyrics,
};
State currentState{ State::None };
std::vector<std::chrono::milliseconds> lastTimestamps;
std::vector<std::chrono::milliseconds> timestamps;
std::string accumulatedLyrics;
auto applyAccumulatedLyrics = [&](bool skipTrailingEmptyLines = false)
{
if (lastTimestamps.empty())
return;
if (skipTrailingEmptyLines)
accumulatedLyrics.resize(core::stringUtils::stringTrimEnd(accumulatedLyrics, " \t\r\n").size());
if (accumulatedLyrics.empty())
return;
for (std::chrono::milliseconds timestamp : lastTimestamps)
{
std::string& synchronizedLine{ lyrics.synchronizedLines.find(timestamp)->second };
synchronizedLine += accumulatedLyrics;
}
accumulatedLyrics.clear();
};
std::string line;
while (std::getline(is, line))
{
std::string_view trimmedLine{ core::stringUtils::stringTrimEnd(line) };
// Skip comments
if (!trimmedLine.empty() && trimmedLine.front() == '#')
continue;
// Skip empty lines before actual lyrics
if (currentState == State::None && trimmedLine.empty())
continue;
if (parseIDTag(trimmedLine, lyrics))
continue;
extractTimestamps(trimmedLine, timestamps);
// If there are timestamps, add as synchronized lyrics
if (!timestamps.empty())
{
if (currentState == State::UnsynchronizedLyrics)
lyrics.unsynchronizedLines.clear(); // choice: discard all lyrics parsed so far
currentState = State::SynchronizedLyrics;
applyAccumulatedLyrics();
std::string_view lyricText{ extractLyricText(trimmedLine) };
for (std::chrono::milliseconds timestamp : timestamps)
lyrics.synchronizedLines.emplace(timestamp, lyricText);
lastTimestamps = timestamps;
}
else
{
if (!lastTimestamps.empty())
{
accumulatedLyrics += '\n';
accumulatedLyrics += trimmedLine;
}
else
{
assert(currentState != State::SynchronizedLyrics); // should be handled
currentState = State::UnsynchronizedLyrics;
lyrics.unsynchronizedLines.push_back(std::string{ trimmedLine });
}
}
}
if (currentState == State::SynchronizedLyrics)
applyAccumulatedLyrics(true);
return lyrics;
}
} // namespace lms::metadata
+14
View File
@@ -321,6 +321,20 @@ namespace lms::metadata
track.originalYear = utils::parseYear(*dateStr);
}
std::vector<std::string> lyricsEntries{ getTagValuesAs<std::string>(tagReader, TagType::Lyrics, {} /* no custom delimiter on lyrics */) };
for (const std::string& lyrics : lyricsEntries)
{
std::istringstream iss{ lyrics };
try
{
track.lyrics.emplace_back(parseLyrics(iss));
}
catch (const LyricsException& e)
{
LMS_LOG(METADATA, ERROR, "Failed to parse lyrics: " + std::string{ e.what() });
}
}
track.comments = getTagValuesAs<std::string>(tagReader, TagType::Comment, {} /* no custom delimiter on comments */);
track.copyright = getTagValueAs<std::string>(tagReader, TagType::Copyright).value_or("");
track.copyrightURL = getTagValueAs<std::string>(tagReader, TagType::CopyrightURL).value_or("");
@@ -0,0 +1,55 @@
/*
* Copyright (C) 2024 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <filesystem>
#include <iosfwd>
#include <map>
#include <memory>
#include <span>
#include <string>
#include <vector>
#include "metadata/Exception.hpp"
namespace lms::metadata
{
struct Lyrics
{
std::string language{ "und" };
std::chrono::milliseconds offset{};
std::string displayArtist;
std::string displayAlbum;
std::string displayTitle;
std::map<std::chrono::milliseconds, std::string> synchronizedLines;
std::vector<std::string> unsynchronizedLines;
};
class LyricsException : public metadata::Exception
{
public:
using metadata::Exception::Exception;
};
std::span<const std::filesystem::path> getSupportedLyricsFileExtensions();
Lyrics parseLyrics(std::istream& is);
} // namespace lms::metadata
@@ -30,6 +30,8 @@
#include "core/UUID.hpp"
#include "Lyrics.hpp"
namespace lms::metadata
{
using Tags = std::map<std::string /* type */, std::vector<std::string> /* values */>;
@@ -119,6 +121,7 @@ namespace lms::metadata
std::string copyright;
std::string copyrightURL;
std::vector<std::string> comments;
std::vector<Lyrics> lyrics;
std::optional<float> replayGain;
std::string artistDisplayName;
std::vector<Artist> artists;
+1
View File
@@ -1,6 +1,7 @@
include(GoogleTest)
add_executable(test-metadata
Lyrics.cpp
Metadata.cpp
Parser.cpp
Utils.cpp
+318
View File
@@ -0,0 +1,318 @@
/*
* Copyright (C) 2024 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "metadata/Lyrics.hpp"
namespace lms::metadata::tests
{
using namespace std::chrono_literals;
TEST(Lyrics, synchronized)
{
std::istringstream is{ R"([id: dqsxdkbu]
[ar: Lady Gaga]
[al: Lady Gaga]
[ti: Die With A Smile]
[la: eng]
[length: 04:12]
[offset: -34]
[00:03.30]Ooh, ooh
[00:06.75]
[00:09.16]I, I just woke up from a dream)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.displayArtist, "Lady Gaga");
EXPECT_EQ(lyrics.displayAlbum, "Lady Gaga");
EXPECT_EQ(lyrics.displayTitle, "Die With A Smile");
EXPECT_EQ(lyrics.language, "eng");
EXPECT_EQ(lyrics.offset, -34ms);
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 3);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
}
TEST(Lyrics, tagInMidleOfLyrics)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
[id: dqsxdkbu]
[00:09.16]I, I just woke up from a dream)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
}
TEST(Lyrics, skipEmptyBeginLines)
{
std::istringstream is{ R"(
[00:03.30]Ooh, ooh)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 1);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
}
TEST(Lyrics, skipEmptyEndLines)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 1);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
}
TEST(Lyrics, skipLeadingUnsynchronizedLyrics)
{
std::istringstream is{ R"(
Some unsynchronized lyrics
[00:03.30]Ooh, ooh)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 1);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
}
TEST(Lyrics, skipComments)
{
std::istringstream is{ R"(###
[00:03.30]Ooh, ooh
## just dance
[00:09.16]I, I just woke up from a dream
##end)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
}
TEST(Lyrics, synchronized_notags)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 1);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
}
TEST(Lyrics, synchronized_timestampFormats)
{
std::istringstream is{ R"([00:03.30]First line
[00:01.301]in milliseconds
[0:02.301]leading with only one digit
[61:01.30]more than 60 minutes
[02:01:01.30]With hours
[3:01:01.30]With hours with only one digit)" };
const Lyrics lyrics{ parseLyrics(is) };
ASSERT_EQ(lyrics.synchronizedLines.size(), 6);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(1s + 301ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(2s + 301ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(61min + 1s + 300ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(2h + 1min + 1s + 300ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(3h + 1min + 1s + 300ms));
}
TEST(Lyrics, synchronized_keepBlankLinesExceptEOF)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
[00:06.75]Foo
)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\n\n");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "Foo");
}
TEST(Lyrics, synchronized_blankLinesEnd)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
SecondLine
Even a third line!!
[00:06.75]Foo
)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\nSecondLine\n Even a third line!!");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "Foo");
}
TEST(Lyrics, synchronized_multitimestamps)
{
std::istringstream is{ R"([00:03.30][00:09.16] [00:15.16]Ooh, ooh
[00:06.75]I, I just woke up from a dream)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 4);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "I, I just woke up from a dream");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(15s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(15s + 160ms)->second, "Ooh, ooh");
}
TEST(Lyrics, synchronized_multitimestamps_blank)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
[00:06.75]
[00:09.16]I, I just woke up from a dream
[00:10.16]
)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 4);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
ASSERT_TRUE(lyrics.synchronizedLines.contains(10s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(10s + 160ms)->second, "");
}
TEST(Lyrics, synchronized_multitimestamps_multilines)
{
std::istringstream is{ R"([00:03.30][00:09.16]Ooh, ooh
Second line
Third line
Fifth line after an empty one...
[00:06.75]I, I just woke up from a dream
Cool)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 3);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\nSecond line\n Third line\n\nFifth line after an empty one...");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "I, I just woke up from a dream\nCool");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "Ooh, ooh\nSecond line\n Third line\n\nFifth line after an empty one...");
}
TEST(Lyrics, unsynchronized)
{
std::istringstream is{ R"([id: dqsxdkbu]
[ar: Lady Gaga]
[al: Lady Gaga]
[ti: Die With A Smile]
[length: 04:12]
[offset: -34]
Ooh, ooh
I, I just woke up from a dream
)" };
Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.displayArtist, "Lady Gaga");
EXPECT_EQ(lyrics.displayAlbum, "Lady Gaga");
EXPECT_EQ(lyrics.displayTitle, "Die With A Smile");
EXPECT_EQ(lyrics.offset, -34ms);
ASSERT_EQ(lyrics.unsynchronizedLines.size(), 5);
EXPECT_EQ(lyrics.unsynchronizedLines[0], "Ooh, ooh");
EXPECT_EQ(lyrics.unsynchronizedLines[1], "");
EXPECT_EQ(lyrics.unsynchronizedLines[2], "");
EXPECT_EQ(lyrics.unsynchronizedLines[3], "I, I just woke up from a dream");
EXPECT_EQ(lyrics.unsynchronizedLines[4], "");
}
} // namespace lms::metadata::tests
+47 -1
View File
@@ -66,6 +66,7 @@ namespace lms::metadata
{ TagType::Remixer, { "MyRemixer1", "MyRemixer2" } },
{ TagType::RecordLabel, { "Label1", "Label2" } },
{ TagType::Language, { "Language1", "Language2" } },
{ TagType::Lyrics, { "[00:00.00]First line\n[00:01.00]Second line" } },
{ TagType::Lyricist, { "MyLyricist1", "MyLyricist2" } },
{ TagType::OriginalReleaseDate, { "2019/02/03" } },
{ TagType::ReleaseType, { "Album", "Compilation" } },
@@ -84,7 +85,7 @@ namespace lms::metadata
static_cast<IParser&>(parser).setUserExtraTags(std::vector<std::string>{ "MY_AWESOME_TAG_A", "MY_AWESOME_TAG_B", "MY_AWESOME_MISSING_TAG" });
std::unique_ptr<Track> track{ parser.parse(testTags) };
const std::unique_ptr<Track> track{ parser.parse(testTags) };
// Audio properties
{
@@ -135,6 +136,12 @@ namespace lms::metadata
ASSERT_EQ(track->lyricistArtists.size(), 2);
EXPECT_EQ(track->lyricistArtists[0].name, "MyLyricist1");
EXPECT_EQ(track->lyricistArtists[1].name, "MyLyricist2");
ASSERT_EQ(track->lyrics.size(), 1);
ASSERT_EQ(track->lyrics.front().synchronizedLines.size(), 2);
ASSERT_TRUE(track->lyrics.front().synchronizedLines.contains(std::chrono::milliseconds{ 0 }));
EXPECT_EQ(track->lyrics.front().synchronizedLines.find(std::chrono::milliseconds{ 0 })->second, "First line");
ASSERT_TRUE(track->lyrics.front().synchronizedLines.contains(std::chrono::milliseconds{ 1000 }));
EXPECT_EQ(track->lyrics.front().synchronizedLines.find(std::chrono::milliseconds{ 1000 })->second, "Second line");
ASSERT_TRUE(track->mbid.has_value());
EXPECT_EQ(track->mbid.value(), core::UUID::fromString("0afb190a-6735-46df-a16d-199f48206e4a"));
ASSERT_EQ(track->mixerArtists.size(), 2);
@@ -352,6 +359,25 @@ namespace lms::metadata
EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstructed since a custom delimiter was hit for parsing
}
TEST(Parser, customDelimitersUsedForArtists)
{
const TestTagReader testTags{
{
{ TagType::Artists, { "Artist1 & Artist2" } },
}
};
Parser parser;
static_cast<IParser&>(parser).setArtistTagDelimiters(std::vector<std::string>{ " & " });
std::unique_ptr<Track> track{ parser.parse(testTags) };
ASSERT_EQ(track->artists.size(), 2);
EXPECT_EQ(track->artists[0].name, "Artist1");
EXPECT_EQ(track->artists[1].name, "Artist2");
EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstructed since a custom delimiter was hit for parsing
}
TEST(Parser, noArtistInArtist)
{
const TestTagReader testTags{
@@ -472,6 +498,26 @@ namespace lms::metadata
EXPECT_EQ(track->medium->release->artistDisplayName, "Artist1, Artist2"); // reconstruct artist display name since multiple entries are found
}
TEST(Parser, multipleArtistsInAlbumArtists_displayName)
{
const TestTagReader testTags{
{
{ TagType::Album, { "MyAlbum" } },
{ TagType::AlbumArtist, { "Artist1 & Artist2" } },
{ TagType::AlbumArtists, { "Artist1", "Artist2" } },
}
};
std::unique_ptr<Track> track{ Parser{}.parse(testTags) };
ASSERT_TRUE(track->medium);
ASSERT_TRUE(track->medium->release);
ASSERT_EQ(track->medium->release->artists.size(), 2);
EXPECT_EQ(track->medium->release->artists[0].name, "Artist1");
EXPECT_EQ(track->medium->release->artists[1].name, "Artist2");
EXPECT_EQ(track->medium->release->artistDisplayName, "Artist1 & Artist2");
}
TEST(Parser, multipleArtistsInAlbumArtists)
{
const TestTagReader testTags{