diff --git a/README.md b/README.md index 00820499..31818935 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ _LMS_ is a self-hosted music streaming software: access your music collection fr A [demo instance](http://lms-demo.poupon.dev) is available. Note the administration settings are not available. ## Main features -* Recommendation engine +* [Subsonic/OpenSubsonic API](SUBSONIC.md) support * Multi-valued tags: `genre`, `mood`, `artists`, ... * Artist relationships: `composer`, `conductor`, `lyricist`, `mixer`, `performer`, `producer`, `remixer` * [Release types](https://musicbrainz.org/doc/Release_Group/Type): `album`, `single`, `EP`, `compilation`, `live`, ... @@ -15,11 +15,13 @@ A [demo instance](http://lms-demo.poupon.dev) is available. Note the administrat * [ListenBrainz](https://listenbrainz.org) support for: * Scrobbling and synchronizing listens * Synchronizing 'love' feedbacks -* Audio transcoding for maximum interoperability and reduced bandwith requirements +* Recommendation engine * Multi-library support * ReplayGain support +* Audio transcoding for compatibility and reduced bandwidth * User management, with several [authentication backends](INSTALL.md#authentication-backend) -* [Subsonic/OpenSubsonic API](SUBSONIC.md) support +* Playlists support +* Lyrics support ## Music discovery _LMS_ provides several ways to help you find the music you like: @@ -57,6 +59,12 @@ $setmulti(albumartists,%_albumartists%) $setmulti(albumartistssort,%_albumartists_sort%) ``` +## Playlist support +_LMS_ supports playlist files in `m3u` and `m3u8` formats. These playlists are synced during the scan process and are available as public shared playlists. + +## Lyrics support +_LMS_ supports lyrics in `lrc` files and embedded track metadata. Both synchronized and unsynchronized lyrics are supported. + ## Keyboard shortcuts * Play/pause: Space * Previous track: Ctrl + Left diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 363591ac..43418b8e 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -195,7 +195,7 @@ namespace lms::db break; case TrackSortMethod::TrackList: assert(params.trackList.isValid()); - query.orderBy("t_l.id"); + query.orderBy("t_l_e.id"); } return query; diff --git a/src/libs/metadata/impl/PlayList.cpp b/src/libs/metadata/impl/PlayList.cpp index 2570aea5..4d15f9eb 100644 --- a/src/libs/metadata/impl/PlayList.cpp +++ b/src/libs/metadata/impl/PlayList.cpp @@ -28,7 +28,7 @@ namespace lms::metadata { std::span getSupportedPlayListFileExtensions() { - static const std::array fileExtensions{ ".m3u", "m3u8" }; + static const std::array fileExtensions{ ".m3u", ".m3u8" }; return fileExtensions; } diff --git a/src/libs/services/scanner/impl/scanners/AudioFileScanner.cpp b/src/libs/services/scanner/impl/scanners/AudioFileScanner.cpp index 9a37f3bd..e440a030 100644 --- a/src/libs/services/scanner/impl/scanners/AudioFileScanner.cpp +++ b/src/libs/services/scanner/impl/scanners/AudioFileScanner.cpp @@ -325,6 +325,7 @@ namespace lms::scanner AudioFileScanOperation& operator=(const AudioFileScanOperation&) = delete; private: + const std::filesystem::path& getFile() const override { return _file; }; core::LiteralString getName() const override { return "ScanAudioFile"; } void scan() override; void processResult(ScanContext& context) override; @@ -603,6 +604,11 @@ namespace lms::scanner AudioFileScanner::~AudioFileScanner() = default; + core::LiteralString AudioFileScanner::getName() const + { + return "Audio scanner"; + } + std::span AudioFileScanner::getSupportedExtensions() const { return _metadataParser->getSupportedExtensions(); diff --git a/src/libs/services/scanner/impl/scanners/AudioFileScanner.hpp b/src/libs/services/scanner/impl/scanners/AudioFileScanner.hpp index 16ddf5e1..a79fbe4a 100644 --- a/src/libs/services/scanner/impl/scanners/AudioFileScanner.hpp +++ b/src/libs/services/scanner/impl/scanners/AudioFileScanner.hpp @@ -50,6 +50,7 @@ namespace lms::scanner AudioFileScanner& operator=(const AudioFileScanner&) = delete; private: + core::LiteralString getName() const override; std::span getSupportedExtensions() const override; bool needsScan(ScanContext& context, const FileToScan& file) const override; std::unique_ptr createScanOperation(const FileToScan& fileToScan) const override; diff --git a/src/libs/services/scanner/impl/scanners/IFileScanOperation.hpp b/src/libs/services/scanner/impl/scanners/IFileScanOperation.hpp index 4a9a2867..cbf828af 100644 --- a/src/libs/services/scanner/impl/scanners/IFileScanOperation.hpp +++ b/src/libs/services/scanner/impl/scanners/IFileScanOperation.hpp @@ -19,6 +19,8 @@ #pragma once +#include + #include "core/LiteralString.hpp" namespace lms::scanner @@ -31,6 +33,8 @@ namespace lms::scanner virtual ~IFileScanOperation() = default; virtual core::LiteralString getName() const = 0; + + virtual const std::filesystem::path& getFile() const = 0; virtual void scan() = 0; virtual void processResult(ScanContext& context) = 0; }; diff --git a/src/libs/services/scanner/impl/scanners/IFileScanner.hpp b/src/libs/services/scanner/impl/scanners/IFileScanner.hpp index 0a77470f..c0245fda 100644 --- a/src/libs/services/scanner/impl/scanners/IFileScanner.hpp +++ b/src/libs/services/scanner/impl/scanners/IFileScanner.hpp @@ -22,6 +22,8 @@ #include #include +#include "core/LiteralString.hpp" + #include "MediaLibraryInfo.hpp" namespace lms::scanner @@ -41,6 +43,7 @@ namespace lms::scanner public: virtual ~IFileScanner() = default; + virtual core::LiteralString getName() const = 0; virtual std::span getSupportedExtensions() const = 0; virtual bool needsScan(ScanContext& context, const FileToScan& file) const = 0; virtual std::unique_ptr createScanOperation(const FileToScan& fileToScan) const = 0; diff --git a/src/libs/services/scanner/impl/scanners/ImageFileScanner.cpp b/src/libs/services/scanner/impl/scanners/ImageFileScanner.cpp index 8cb0362b..8f1278b8 100644 --- a/src/libs/services/scanner/impl/scanners/ImageFileScanner.cpp +++ b/src/libs/services/scanner/impl/scanners/ImageFileScanner.cpp @@ -46,6 +46,7 @@ namespace lms::scanner , _db{ db } {} private: + const std::filesystem::path& getFile() const override { return _file; }; core::LiteralString getName() const override { return "ScanImageFile"; } void scan() override; void processResult(ScanContext& context) override; @@ -134,6 +135,11 @@ namespace lms::scanner { } + core::LiteralString ImageFileScanner::getName() const + { + return "Image scanner"; + } + std::span ImageFileScanner::getSupportedExtensions() const { return image::getSupportedFileExtensions(); diff --git a/src/libs/services/scanner/impl/scanners/ImageFileScanner.hpp b/src/libs/services/scanner/impl/scanners/ImageFileScanner.hpp index d17d3225..a5ade2a6 100644 --- a/src/libs/services/scanner/impl/scanners/ImageFileScanner.hpp +++ b/src/libs/services/scanner/impl/scanners/ImageFileScanner.hpp @@ -42,6 +42,7 @@ namespace lms::scanner ImageFileScanner& operator=(const ImageFileScanner&) = delete; private: + core::LiteralString getName() const override; std::span getSupportedExtensions() const override; bool needsScan(ScanContext& context, const FileToScan& file) const override; std::unique_ptr createScanOperation(const FileToScan& fileToScan) const override; diff --git a/src/libs/services/scanner/impl/scanners/LyricsFileScanner.cpp b/src/libs/services/scanner/impl/scanners/LyricsFileScanner.cpp index 433e162d..12e3e604 100644 --- a/src/libs/services/scanner/impl/scanners/LyricsFileScanner.cpp +++ b/src/libs/services/scanner/impl/scanners/LyricsFileScanner.cpp @@ -46,6 +46,7 @@ namespace lms::scanner , _db{ db } {} private: + const std::filesystem::path& getFile() const override { return _file; }; core::LiteralString getName() const override { return "ScanLyricsFile"; } void scan() override; void processResult(ScanContext& context) override; @@ -137,6 +138,11 @@ namespace lms::scanner { } + core::LiteralString LyricsFileScanner::getName() const + { + return "Lyrics scanner"; + } + std::span LyricsFileScanner::getSupportedExtensions() const { return metadata::getSupportedLyricsFileExtensions(); diff --git a/src/libs/services/scanner/impl/scanners/LyricsFileScanner.hpp b/src/libs/services/scanner/impl/scanners/LyricsFileScanner.hpp index c6eba5a2..44fb2e1d 100644 --- a/src/libs/services/scanner/impl/scanners/LyricsFileScanner.hpp +++ b/src/libs/services/scanner/impl/scanners/LyricsFileScanner.hpp @@ -42,6 +42,7 @@ namespace lms::scanner LyricsFileScanner& operator=(const LyricsFileScanner&) = delete; private: + core::LiteralString getName() const override; std::span getSupportedExtensions() const override; bool needsScan(ScanContext& context, const FileToScan& file) const override; std::unique_ptr createScanOperation(const FileToScan& fileToScan) const override; diff --git a/src/libs/services/scanner/impl/scanners/PlayListFileScanner.cpp b/src/libs/services/scanner/impl/scanners/PlayListFileScanner.cpp index 953a2265..4efdb469 100644 --- a/src/libs/services/scanner/impl/scanners/PlayListFileScanner.cpp +++ b/src/libs/services/scanner/impl/scanners/PlayListFileScanner.cpp @@ -50,6 +50,7 @@ namespace lms::scanner PlayListFileScanOperation& operator=(const PlayListFileScanOperation&) = delete; private: + const std::filesystem::path& getFile() const override { return _file; }; core::LiteralString getName() const override { return "ScanPlayListFile"; } void scan() override; void processResult(ScanContext& context) override; @@ -136,6 +137,11 @@ namespace lms::scanner { } + core::LiteralString PlayListFileScanner::getName() const + { + return "PlayList scanner"; + } + std::span PlayListFileScanner::getSupportedExtensions() const { return metadata::getSupportedPlayListFileExtensions(); diff --git a/src/libs/services/scanner/impl/scanners/PlayListFileScanner.hpp b/src/libs/services/scanner/impl/scanners/PlayListFileScanner.hpp index 2a38a8ef..d61d7e67 100644 --- a/src/libs/services/scanner/impl/scanners/PlayListFileScanner.hpp +++ b/src/libs/services/scanner/impl/scanners/PlayListFileScanner.hpp @@ -42,6 +42,7 @@ namespace lms::scanner PlayListFileScanner& operator=(const PlayListFileScanner&) = delete; private: + core::LiteralString getName() const override; std::span getSupportedExtensions() const override; bool needsScan(ScanContext& context, const FileToScan& file) const override; std::unique_ptr createScanOperation(const FileToScan& fileToScan) const override; diff --git a/src/libs/services/scanner/impl/steps/FileScanQueue.cpp b/src/libs/services/scanner/impl/steps/FileScanQueue.cpp index 044d4482..d5b580a9 100644 --- a/src/libs/services/scanner/impl/steps/FileScanQueue.cpp +++ b/src/libs/services/scanner/impl/steps/FileScanQueue.cpp @@ -21,6 +21,7 @@ #include +#include "core/ILogger.hpp" #include "core/ITraceLogger.hpp" #include "scanners/IFileScanOperation.hpp" @@ -52,6 +53,7 @@ namespace lms::scanner { { LMS_SCOPED_TRACE_OVERVIEW("Scanner", operation->getName()); + LMS_LOG(DBUPDATER, DEBUG, operation->getName() << ": scanning file '" << operation->getFile().string() << "'"); operation->scan(); } diff --git a/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp index aafc0b63..cebece5b 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp @@ -57,6 +57,7 @@ namespace lms::scanner { [[maybe_unused]] auto [it, inserted]{ _scannerByExtension.emplace(extension, scanner) }; assert(inserted); + LMS_LOG(DBUPDATER, INFO, "Registered extension '" << extension.string() << "' for '" << scanner->getName() << "'"); } } @@ -139,6 +140,7 @@ namespace lms::scanner if (_abortScan) return; + LMS_LOG(DBUPDATER, DEBUG, scanOperation->getName() << ": processing result for '" << scanOperation->getFile().string() << "'"); scanOperation->processResult(context); context.stats.scans++; } diff --git a/src/libs/subsonic/impl/ProtocolVersion.hpp b/src/libs/subsonic/impl/ProtocolVersion.hpp index 9a8edfe3..337f39e9 100644 --- a/src/libs/subsonic/impl/ProtocolVersion.hpp +++ b/src/libs/subsonic/impl/ProtocolVersion.hpp @@ -34,7 +34,7 @@ namespace lms::api::subsonic }; static inline constexpr ProtocolVersion defaultServerProtocolVersion{ 1, 16, 0 }; - static inline constexpr std::string_view serverVersion{ "7" }; + static inline constexpr std::string_view serverVersion{ "8" }; } // namespace lms::api::subsonic namespace lms::core::stringUtils diff --git a/src/libs/subsonic/impl/endpoints/Playlists.cpp b/src/libs/subsonic/impl/endpoints/Playlists.cpp index 9f60a4a3..5224b988 100644 --- a/src/libs/subsonic/impl/endpoints/Playlists.cpp +++ b/src/libs/subsonic/impl/endpoints/Playlists.cpp @@ -33,6 +33,24 @@ namespace lms::api::subsonic { using namespace db; + namespace + { + void checkTrackListModificationAccess(const db::TrackList::pointer& trackList, const db::UserId currentUserId) + { + if (!trackList || trackList->getType() != TrackListType::PlayList) + throw RequestedDataNotFoundError{}; + + // Can only modify own playlists + if (trackList->getUserId() != currentUserId) + { + if (trackList->getVisibility() == TrackList::Visibility::Public) + throw UserNotAuthorizedError{}; + + throw RequestedDataNotFoundError{}; + } + } + } // namespace + Response handleGetPlaylistsRequest(RequestContext& context) { auto transaction{ context.dbSession.createReadTransaction() }; @@ -60,10 +78,11 @@ namespace lms::api::subsonic TrackList::FindParameters params; params.setVisibility(TrackList::Visibility::Public); params.setType(TrackListType::PlayList); + params.setExcludedUser(context.user->getId()); db::TrackList::find(context.dbSession, params, [&](const db::TrackList::pointer& trackList) { - if (trackList->getUserId() != context.user->getId()) // skip already reported - addTrackList(trackList); + assert(trackList->getUserId() != context.user->getId()); + addTrackList(trackList); }); } @@ -77,14 +96,17 @@ namespace lms::api::subsonic auto transaction{ context.dbSession.createReadTransaction() }; - TrackList::pointer tracklist{ TrackList::find(context.dbSession, trackListId) }; - if (!tracklist) + TrackList::pointer trackList{ TrackList::find(context.dbSession, trackListId) }; + if (!trackList || trackList->getType() != TrackListType::PlayList) + throw RequestedDataNotFoundError{}; + + if (trackList->getUserId() != context.user->getId() && trackList->getVisibility() != TrackList::Visibility::Public) throw RequestedDataNotFoundError{}; Response response{ Response::createOkResponse(context.serverProtocolVersion) }; - Response::Node playlistNode{ createPlaylistNode(tracklist, context.dbSession) }; + Response::Node playlistNode{ createPlaylistNode(trackList, context.dbSession) }; - auto entries{ tracklist->getEntries() }; + auto entries{ trackList->getEntries() }; for (const TrackListEntry::pointer& entry : entries.results) playlistNode.addArrayChild("entry", createSongNode(context, entry->getTrack(), context.user)); @@ -106,28 +128,23 @@ namespace lms::api::subsonic auto transaction{ context.dbSession.createWriteTransaction() }; - TrackList::pointer tracklist; + TrackList::pointer trackList; if (id) { - tracklist = TrackList::find(context.dbSession, *id); - if (!tracklist - || tracklist->getUser() != context.user - || tracklist->getType() != TrackListType::PlayList) - { - throw RequestedDataNotFoundError{}; - } + trackList = TrackList::find(context.dbSession, *id); + checkTrackListModificationAccess(trackList, context.user->getId()); if (name) - tracklist.modify()->setName(*name); + trackList.modify()->setName(*name); - tracklist.modify()->clear(); - tracklist.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime()); + trackList.modify()->clear(); + trackList.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime()); } else { - tracklist = context.dbSession.create(*name, TrackListType::PlayList); - tracklist.modify()->setUser(context.user); - tracklist.modify()->setVisibility(TrackList::Visibility::Private); + trackList = context.dbSession.create(*name, TrackListType::PlayList); + trackList.modify()->setUser(context.user); + trackList.modify()->setVisibility(TrackList::Visibility::Private); } for (const TrackId trackId : trackIds) @@ -136,13 +153,13 @@ namespace lms::api::subsonic if (!track) continue; - context.dbSession.create(track, tracklist); + context.dbSession.create(track, trackList); } Response response{ Response::createOkResponse(context.serverProtocolVersion) }; - Response::Node playlistNode{ createPlaylistNode(tracklist, context.dbSession) }; + Response::Node playlistNode{ createPlaylistNode(trackList, context.dbSession) }; - auto entries{ tracklist->getEntries() }; + auto entries{ trackList->getEntries() }; for (const TrackListEntry::pointer& entry : entries.results) playlistNode.addArrayChild("entry", createSongNode(context, entry->getTrack(), context.user)); @@ -165,19 +182,14 @@ namespace lms::api::subsonic auto transaction{ context.dbSession.createWriteTransaction() }; - TrackList::pointer tracklist{ TrackList::find(context.dbSession, id) }; - if (!tracklist - || tracklist->getUser() != context.user - || tracklist->getType() != TrackListType::PlayList) - { - throw RequestedDataNotFoundError{}; - } + TrackList::pointer trackList{ TrackList::find(context.dbSession, id) }; + checkTrackListModificationAccess(trackList, context.user->getId()); if (name) - tracklist.modify()->setName(*name); + trackList.modify()->setName(*name); - tracklist.modify()->setVisibility(isPublic ? db::TrackList::Visibility::Public : db::TrackList::Visibility::Private); - tracklist.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime()); + trackList.modify()->setVisibility(isPublic ? db::TrackList::Visibility::Public : db::TrackList::Visibility::Private); + trackList.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime()); { // Remove from end to make indexes stable @@ -185,7 +197,7 @@ namespace lms::api::subsonic for (std::size_t trackPositionToRemove : trackPositionsToRemove) { - auto entry{ tracklist->getEntry(trackPositionToRemove) }; + auto entry{ trackList->getEntry(trackPositionToRemove) }; if (entry) entry.remove(); } @@ -198,7 +210,7 @@ namespace lms::api::subsonic if (!track) continue; - context.dbSession.create(track, tracklist); + context.dbSession.create(track, trackList); } return Response::createOkResponse(context.serverProtocolVersion); @@ -210,15 +222,10 @@ namespace lms::api::subsonic auto transaction{ context.dbSession.createWriteTransaction() }; - TrackList::pointer tracklist{ TrackList::find(context.dbSession, id) }; - if (!tracklist - || tracklist->getUser() != context.user - || tracklist->getType() != TrackListType::PlayList) - { - throw RequestedDataNotFoundError{}; - } + TrackList::pointer trackList{ TrackList::find(context.dbSession, id) }; + checkTrackListModificationAccess(trackList, context.user->getId()); - tracklist.remove(); + trackList.remove(); return Response::createOkResponse(context.serverProtocolVersion); }