diff --git a/SUBSONIC.md b/SUBSONIC.md index cc3a6d6f..a4bcefb6 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -51,3 +51,4 @@ The following extra fields are implemented: ## Supported extensions * [Transcode offset](https://opensubsonic.netlify.app/docs/extensions/transcodeoffset/) +* [Song Lyrics](https://opensubsonic.netlify.app/docs/extensions/songlyrics/) diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 4e0d32be..9371d526 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -384,7 +384,7 @@ namespace lms::db assert(filePath.is_relative()); assert(_absoluteFilePath.filename() == filePath.filename()); // must be compatible with previous setAbsoluteFilePath call - _fileStem = filePath.stem(); // lazy migration (_fileStem added later, could be set only with setAbsoluteFilePath) + _fileStem = filePath.stem(); // lazy migration (_fileStem added later, could be set only with setAbsoluteFilePath) _relativeFilePath = filePath; } diff --git a/src/libs/database/include/database/TrackLyrics.hpp b/src/libs/database/include/database/TrackLyrics.hpp index 502c4eaa..a1043814 100644 --- a/src/libs/database/include/database/TrackLyrics.hpp +++ b/src/libs/database/include/database/TrackLyrics.hpp @@ -61,6 +61,10 @@ namespace lms::db std::string_view getFileStem() const { return _fileStem; } const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; } std::size_t getFileSize() const { return _fileSize; } + std::string_view getLanguage() const { return _language; } + std::string_view getDisplayArtist() const { return _displayArtist; } + std::string_view getDisplayTitle() const { return _displayTitle; } + std::chrono::milliseconds getOffset() const { return _offset; } bool isSynchronized() const { return _synchronized; } SynchronizedLines getSynchronizedLines() const; std::vector getUnsynchronizedLines() const; diff --git a/src/libs/metadata/impl/Lyrics.cpp b/src/libs/metadata/impl/Lyrics.cpp index e698348b..a57a075c 100644 --- a/src/libs/metadata/impl/Lyrics.cpp +++ b/src/libs/metadata/impl/Lyrics.cpp @@ -136,8 +136,7 @@ namespace lms::metadata std::vector timestamps; std::string accumulatedLyrics; - auto applyAccumulatedLyrics = [&](bool skipTrailingEmptyLines = false) - { + auto applyAccumulatedLyrics = [&](bool skipTrailingEmptyLines = false) { if (lastTimestamps.empty()) return; diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index ce2988c7..afb82f32 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -290,7 +290,7 @@ namespace lms::scanner .currentStep = scanStep->getStep(), .totalElems = 0, .processedElems = 0 - }; + }; notifyInProgress(scanContext.currentStepStats); scanStep->process(scanContext); diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index c35720ee..7d9f81e6 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -18,6 +18,7 @@ add_library(lmssubsonic SHARED impl/responses/ItemDate.cpp impl/responses/ItemGenre.cpp impl/responses/Genre.cpp + impl/responses/Lyrics.cpp impl/responses/Playlist.cpp impl/responses/RecordLabel.cpp impl/responses/ReplayGain.cpp diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 7e67c05e..bf034115 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -200,7 +200,8 @@ namespace lms::api::subsonic // Media retrieval { "/hls", { handleNotImplemented } }, { "/getCaptions", { handleNotImplemented } }, - { "/getLyrics", { handleNotImplemented } }, + { "/getLyrics", { handleGetLyrics } }, + { "/getLyricsBySongId", { handleGetLyricsBySongId } }, { "/getAvatar", { handleNotImplemented } }, // Media annotation diff --git a/src/libs/subsonic/impl/SubsonicResponse.cpp b/src/libs/subsonic/impl/SubsonicResponse.cpp index 6788aead..76e4dee0 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.cpp +++ b/src/libs/subsonic/impl/SubsonicResponse.cpp @@ -196,7 +196,11 @@ namespace lms::api::subsonic auto valueToPropertyTree = [](const Node::ValueType& value) { boost::property_tree::ptree res; std::visit([&](const auto& rawValue) { - res.put_value(rawValue); + using RawValueType = std::decay_t; + if constexpr (std::is_same_v) + res.put_value(core::stringUtils::replaceInString(rawValue, "\n", "\\n")); + else + res.put_value(rawValue); }, value); diff --git a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp index d3c8c315..e428ce52 100644 --- a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp +++ b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp @@ -31,11 +31,13 @@ #include "core/Utils.hpp" #include "database/Session.hpp" #include "database/Track.hpp" +#include "database/TrackLyrics.hpp" #include "database/User.hpp" #include "services/artwork/IArtworkService.hpp" #include "ParameterParsing.hpp" #include "SubsonicId.hpp" +#include "responses/Lyrics.hpp" namespace lms::api::subsonic { @@ -189,6 +191,59 @@ namespace lms::api::subsonic } } // namespace + Response handleGetLyrics(RequestContext& context) + { + std::string artistName{ getParameterAs(context.parameters, "artist").value_or("") }; + std::string titleName{ getParameterAs(context.parameters, "title").value_or("") }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + // best effort search, as this API is really limited + auto transaction{ context.dbSession.createReadTransaction() }; + + db::Track::FindParameters params; + params.name = titleName; + params.artistName = artistName; + params.range = Range{ 0, 2 }; + + // Choice: we return nothing if there are too many results + const auto tracks{ db::Track::findIds(context.dbSession, params) }; + if (tracks.results.size() == 1) + { + // Choice: we return only the first lyrics if the track has many lyrics + bool lyricsSet{}; + db::TrackLyrics::find(context.dbSession, tracks.results[0], [&](const db::TrackLyrics::pointer& lyrics) { + if (lyricsSet) + return; + response.addNode("lyrics", createLyricsNode(context, lyrics)); + lyricsSet = true; + }); + } + + return response; + } + + Response handleGetLyricsBySongId(RequestContext& context) + { + // mandatory params + db::TrackId id{ getMandatoryParameterAs(context.parameters, "id") }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& lyricsList{ response.createNode("lyricsList") }; + lyricsList.createEmptyArrayChild("structuredLyrics"); + + auto transaction{ context.dbSession.createReadTransaction() }; + const db::Track::pointer track{ db::Track::find(context.dbSession, id) }; + if (track) + { + db::TrackLyrics::find(context.dbSession, track->getId(), [&](const db::TrackLyrics::pointer& lyrics) { + lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics)); + }); + } + + return response; + } + void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response) { std::shared_ptr resourceHandler; diff --git a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp index f99085d1..d5be9ac7 100644 --- a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp +++ b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp @@ -26,6 +26,9 @@ namespace lms::api::subsonic { + Response handleGetLyrics(RequestContext& context); + Response handleGetLyricsBySongId(RequestContext& context); + void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); diff --git a/src/libs/subsonic/impl/entrypoints/System.cpp b/src/libs/subsonic/impl/entrypoints/System.cpp index b6ae4be6..76ad01ca 100644 --- a/src/libs/subsonic/impl/entrypoints/System.cpp +++ b/src/libs/subsonic/impl/entrypoints/System.cpp @@ -35,6 +35,12 @@ namespace lms::api::subsonic formPostNode.addArrayValue("versions", 1); } + { + Response::Node& songLyricsNode{ response.createArrayNode("openSubsonicExtensions") }; + songLyricsNode.setAttribute("name", "songLyrics"); + songLyricsNode.addArrayValue("versions", 1); + } + return response; }; } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/responses/Lyrics.cpp b/src/libs/subsonic/impl/responses/Lyrics.cpp new file mode 100644 index 00000000..bc00620c --- /dev/null +++ b/src/libs/subsonic/impl/responses/Lyrics.cpp @@ -0,0 +1,121 @@ +/* + * 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 . + */ + +#include "responses/Lyrics.hpp" + +#include "database/TrackLyrics.hpp" + +#include "RequestContext.hpp" + +namespace lms::api::subsonic +{ + Response::Node createLyricsNode(RequestContext& context, const db::TrackLyrics::pointer& lyrics) + { + Response::Node lyricsNode; + + if (!lyrics->getDisplayArtist().empty()) + lyricsNode.setAttribute("artist", lyrics->getDisplayArtist()); + + if (!lyrics->getDisplayTitle().empty()) + lyricsNode.setAttribute("title", lyrics->getDisplayTitle()); + + std::string lyricsText; + auto addLine{ [&](std::string&& line) { + if (!lyricsText.empty()) + lyricsText += "\n"; + lyricsText += std::move(line); + } }; + if (!lyrics->isSynchronized()) + { + std::vector lines{ lyrics->getUnsynchronizedLines() }; + for (std::string& line : lines) + addLine(std::move(line)); + } + else + { + // Reconstruct the lyrics without timestamp if needed + db::TrackLyrics::SynchronizedLines lines{ lyrics->getSynchronizedLines() }; + for (auto& [timestamp, line] : lines) + addLine(std::move(line)); + } + + switch (context.responseFormat) + { + case ResponseFormat::json: + lyricsNode.setAttribute("value", lyricsText); + break; + case ResponseFormat::xml: + lyricsNode.setValue(lyricsText); + break; + } + + return lyricsNode; + } + + Response::Node createStructuredLyricsNode(RequestContext& context, const db::ObjectPtr& lyrics) + { + Response::Node lyricsNode; + + if (!lyrics->getDisplayArtist().empty()) + lyricsNode.setAttribute("artist", lyrics->getDisplayArtist()); + + if (!lyrics->getDisplayTitle().empty()) + lyricsNode.setAttribute("title", lyrics->getDisplayTitle()); + + lyricsNode.setAttribute("lang", lyrics->getLanguage()); + lyricsNode.setAttribute("synced", lyrics->isSynchronized()); + if (lyrics->getOffset() != std::chrono::milliseconds{}) + lyricsNode.setAttribute("offset", lyrics->getOffset().count()); + + lyricsNode.createEmptyArrayChild("lines"); + auto addLine{ [&](std::string&& line, std::optional timestamp = std::nullopt) { + Response::Node lineNode; + if (timestamp) + lineNode.setAttribute("start", std::chrono::duration_cast(*timestamp).count()); + + switch (context.responseFormat) + { + case ResponseFormat::json: + lineNode.setAttribute("value", std::move(line)); + break; + case ResponseFormat::xml: + lineNode.setValue(std::move(line)); + break; + } + lyricsNode.addArrayChild("lines", std::move(lineNode)); + } }; + + if (!lyrics->isSynchronized()) + { + std::vector lines{ lyrics->getUnsynchronizedLines() }; + + for (std::string& line : lines) + addLine(std::move(line)); + } + else + { + db::TrackLyrics::SynchronizedLines lines{ lyrics->getSynchronizedLines() }; + for (auto& [timestamp, line] : lines) + addLine(std::move(line), timestamp); + } + + return lyricsNode; + } + +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Lyrics.hpp b/src/libs/subsonic/impl/responses/Lyrics.hpp new file mode 100644 index 00000000..78c2f1b4 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Lyrics.hpp @@ -0,0 +1,37 @@ +/* + * 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 . + */ + +#pragma once + +#include "database/Object.hpp" + +#include "SubsonicResponse.hpp" + +namespace lms::db +{ + class TrackLyrics; +} + +namespace lms::api::subsonic +{ + struct RequestContext; + + Response::Node createLyricsNode(RequestContext& context, const db::ObjectPtr& lyrics); + Response::Node createStructuredLyricsNode(RequestContext& context, const db::ObjectPtr& lyrics); +} // namespace lms::api::subsonic