Added embedded/external lyrics support: parsing + indexing, ref #379
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
add_executable(bench-metadata
|
||||
LyricsBench.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(bench-metadata PRIVATE
|
||||
lmsmetadata
|
||||
benchmark
|
||||
)
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -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,6 +1,7 @@
|
||||
include(GoogleTest)
|
||||
|
||||
add_executable(test-metadata
|
||||
Lyrics.cpp
|
||||
Metadata.cpp
|
||||
Parser.cpp
|
||||
Utils.cpp
|
||||
|
||||
@@ -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
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user