220 lines
7.6 KiB
C++
220 lines
7.6 KiB
C++
/*
|
|
* 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
|
|
{
|
|
// Parse a single line with a tag like [ar: Artist] and set the appropriate fields in the Lyrics object
|
|
bool parseTag(std::string_view line, Lyrics& lyrics)
|
|
{
|
|
if (line.empty())
|
|
return false;
|
|
|
|
if (line.front() != '[' || line.back() != ']') // consider lines are trimmed
|
|
return false;
|
|
|
|
const auto separator{ line.find(':') };
|
|
if (separator == std::string_view::npos)
|
|
return false;
|
|
|
|
const std::string_view tagType{ core::stringUtils::stringTrim(line.substr(1, separator - 1)) };
|
|
const std::string_view tagValue{ core::stringUtils::stringTrim(line.substr(separator + 1, line.size() - separator - 2)) };
|
|
|
|
if (tagType.empty())
|
|
return false;
|
|
|
|
// check for timestamps
|
|
if (std::any_of(tagType.begin(), tagType.end(), [](char c) { return std::isdigit(c); }))
|
|
return false;
|
|
|
|
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;
|
|
}
|
|
|
|
// 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) };
|
|
|
|
// Remove potential UTF8 BOM
|
|
constexpr std::string_view utf8BOM{ "\xEF\xBB\xBF" };
|
|
if (trimmedLine.starts_with(utf8BOM))
|
|
trimmedLine = trimmedLine.substr(utf8BOM.size());
|
|
|
|
// Skip comments
|
|
if (!trimmedLine.empty() && trimmedLine.front() == '#')
|
|
continue;
|
|
|
|
// Skip empty lines before actual lyrics
|
|
if (currentState == State::None && trimmedLine.empty())
|
|
continue;
|
|
|
|
if (parseTag(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
|