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);
}