Merge branch 'develop' for release v3.62.1

This commit is contained in:
emeric
2024-12-20 09:32:45 +01:00
17 changed files with 103 additions and 49 deletions
+11 -3
View File
@@ -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. A [demo instance](http://lms-demo.poupon.dev) is available. Note the administration settings are not available.
## Main features ## Main features
* Recommendation engine * [Subsonic/OpenSubsonic API](SUBSONIC.md) support
* Multi-valued tags: `genre`, `mood`, `artists`, ... * Multi-valued tags: `genre`, `mood`, `artists`, ...
* Artist relationships: `composer`, `conductor`, `lyricist`, `mixer`, `performer`, `producer`, `remixer` * Artist relationships: `composer`, `conductor`, `lyricist`, `mixer`, `performer`, `producer`, `remixer`
* [Release types](https://musicbrainz.org/doc/Release_Group/Type): `album`, `single`, `EP`, `compilation`, `live`, ... * [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: * [ListenBrainz](https://listenbrainz.org) support for:
* Scrobbling and synchronizing listens * Scrobbling and synchronizing listens
* Synchronizing 'love' feedbacks * Synchronizing 'love' feedbacks
* Audio transcoding for maximum interoperability and reduced bandwith requirements * Recommendation engine
* Multi-library support * Multi-library support
* ReplayGain support * ReplayGain support
* Audio transcoding for compatibility and reduced bandwidth
* User management, with several [authentication backends](INSTALL.md#authentication-backend) * User management, with several [authentication backends](INSTALL.md#authentication-backend)
* [Subsonic/OpenSubsonic API](SUBSONIC.md) support * Playlists support
* Lyrics support
## Music discovery ## Music discovery
_LMS_ provides several ways to help you find the music you like: _LMS_ provides several ways to help you find the music you like:
@@ -57,6 +59,12 @@ $setmulti(albumartists,%_albumartists%)
$setmulti(albumartistssort,%_albumartists_sort%) $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 ## Keyboard shortcuts
* Play/pause: <kbd>Space</kbd> * Play/pause: <kbd>Space</kbd>
* Previous track: <kbd>Ctrl</kbd> + <kbd>Left</kbd> * Previous track: <kbd>Ctrl</kbd> + <kbd>Left</kbd>
+1 -1
View File
@@ -195,7 +195,7 @@ namespace lms::db
break; break;
case TrackSortMethod::TrackList: case TrackSortMethod::TrackList:
assert(params.trackList.isValid()); assert(params.trackList.isValid());
query.orderBy("t_l.id"); query.orderBy("t_l_e.id");
} }
return query; return query;
+1 -1
View File
@@ -28,7 +28,7 @@ namespace lms::metadata
{ {
std::span<const std::filesystem::path> getSupportedPlayListFileExtensions() std::span<const std::filesystem::path> getSupportedPlayListFileExtensions()
{ {
static const std::array<std::filesystem::path, 2> fileExtensions{ ".m3u", "m3u8" }; static const std::array<std::filesystem::path, 2> fileExtensions{ ".m3u", ".m3u8" };
return fileExtensions; return fileExtensions;
} }
@@ -325,6 +325,7 @@ namespace lms::scanner
AudioFileScanOperation& operator=(const AudioFileScanOperation&) = delete; AudioFileScanOperation& operator=(const AudioFileScanOperation&) = delete;
private: private:
const std::filesystem::path& getFile() const override { return _file; };
core::LiteralString getName() const override { return "ScanAudioFile"; } core::LiteralString getName() const override { return "ScanAudioFile"; }
void scan() override; void scan() override;
void processResult(ScanContext& context) override; void processResult(ScanContext& context) override;
@@ -603,6 +604,11 @@ namespace lms::scanner
AudioFileScanner::~AudioFileScanner() = default; AudioFileScanner::~AudioFileScanner() = default;
core::LiteralString AudioFileScanner::getName() const
{
return "Audio scanner";
}
std::span<const std::filesystem::path> AudioFileScanner::getSupportedExtensions() const std::span<const std::filesystem::path> AudioFileScanner::getSupportedExtensions() const
{ {
return _metadataParser->getSupportedExtensions(); return _metadataParser->getSupportedExtensions();
@@ -50,6 +50,7 @@ namespace lms::scanner
AudioFileScanner& operator=(const AudioFileScanner&) = delete; AudioFileScanner& operator=(const AudioFileScanner&) = delete;
private: private:
core::LiteralString getName() const override;
std::span<const std::filesystem::path> getSupportedExtensions() const override; std::span<const std::filesystem::path> getSupportedExtensions() const override;
bool needsScan(ScanContext& context, const FileToScan& file) const override; bool needsScan(ScanContext& context, const FileToScan& file) const override;
std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override; std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override;
@@ -19,6 +19,8 @@
#pragma once #pragma once
#include <filesystem>
#include "core/LiteralString.hpp" #include "core/LiteralString.hpp"
namespace lms::scanner namespace lms::scanner
@@ -31,6 +33,8 @@ namespace lms::scanner
virtual ~IFileScanOperation() = default; virtual ~IFileScanOperation() = default;
virtual core::LiteralString getName() const = 0; virtual core::LiteralString getName() const = 0;
virtual const std::filesystem::path& getFile() const = 0;
virtual void scan() = 0; virtual void scan() = 0;
virtual void processResult(ScanContext& context) = 0; virtual void processResult(ScanContext& context) = 0;
}; };
@@ -22,6 +22,8 @@
#include <filesystem> #include <filesystem>
#include <span> #include <span>
#include "core/LiteralString.hpp"
#include "MediaLibraryInfo.hpp" #include "MediaLibraryInfo.hpp"
namespace lms::scanner namespace lms::scanner
@@ -41,6 +43,7 @@ namespace lms::scanner
public: public:
virtual ~IFileScanner() = default; virtual ~IFileScanner() = default;
virtual core::LiteralString getName() const = 0;
virtual std::span<const std::filesystem::path> getSupportedExtensions() const = 0; virtual std::span<const std::filesystem::path> getSupportedExtensions() const = 0;
virtual bool needsScan(ScanContext& context, const FileToScan& file) const = 0; virtual bool needsScan(ScanContext& context, const FileToScan& file) const = 0;
virtual std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const = 0; virtual std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const = 0;
@@ -46,6 +46,7 @@ namespace lms::scanner
, _db{ db } {} , _db{ db } {}
private: private:
const std::filesystem::path& getFile() const override { return _file; };
core::LiteralString getName() const override { return "ScanImageFile"; } core::LiteralString getName() const override { return "ScanImageFile"; }
void scan() override; void scan() override;
void processResult(ScanContext& context) override; void processResult(ScanContext& context) override;
@@ -134,6 +135,11 @@ namespace lms::scanner
{ {
} }
core::LiteralString ImageFileScanner::getName() const
{
return "Image scanner";
}
std::span<const std::filesystem::path> ImageFileScanner::getSupportedExtensions() const std::span<const std::filesystem::path> ImageFileScanner::getSupportedExtensions() const
{ {
return image::getSupportedFileExtensions(); return image::getSupportedFileExtensions();
@@ -42,6 +42,7 @@ namespace lms::scanner
ImageFileScanner& operator=(const ImageFileScanner&) = delete; ImageFileScanner& operator=(const ImageFileScanner&) = delete;
private: private:
core::LiteralString getName() const override;
std::span<const std::filesystem::path> getSupportedExtensions() const override; std::span<const std::filesystem::path> getSupportedExtensions() const override;
bool needsScan(ScanContext& context, const FileToScan& file) const override; bool needsScan(ScanContext& context, const FileToScan& file) const override;
std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override; std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override;
@@ -46,6 +46,7 @@ namespace lms::scanner
, _db{ db } {} , _db{ db } {}
private: private:
const std::filesystem::path& getFile() const override { return _file; };
core::LiteralString getName() const override { return "ScanLyricsFile"; } core::LiteralString getName() const override { return "ScanLyricsFile"; }
void scan() override; void scan() override;
void processResult(ScanContext& context) override; void processResult(ScanContext& context) override;
@@ -137,6 +138,11 @@ namespace lms::scanner
{ {
} }
core::LiteralString LyricsFileScanner::getName() const
{
return "Lyrics scanner";
}
std::span<const std::filesystem::path> LyricsFileScanner::getSupportedExtensions() const std::span<const std::filesystem::path> LyricsFileScanner::getSupportedExtensions() const
{ {
return metadata::getSupportedLyricsFileExtensions(); return metadata::getSupportedLyricsFileExtensions();
@@ -42,6 +42,7 @@ namespace lms::scanner
LyricsFileScanner& operator=(const LyricsFileScanner&) = delete; LyricsFileScanner& operator=(const LyricsFileScanner&) = delete;
private: private:
core::LiteralString getName() const override;
std::span<const std::filesystem::path> getSupportedExtensions() const override; std::span<const std::filesystem::path> getSupportedExtensions() const override;
bool needsScan(ScanContext& context, const FileToScan& file) const override; bool needsScan(ScanContext& context, const FileToScan& file) const override;
std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override; std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override;
@@ -50,6 +50,7 @@ namespace lms::scanner
PlayListFileScanOperation& operator=(const PlayListFileScanOperation&) = delete; PlayListFileScanOperation& operator=(const PlayListFileScanOperation&) = delete;
private: private:
const std::filesystem::path& getFile() const override { return _file; };
core::LiteralString getName() const override { return "ScanPlayListFile"; } core::LiteralString getName() const override { return "ScanPlayListFile"; }
void scan() override; void scan() override;
void processResult(ScanContext& context) override; void processResult(ScanContext& context) override;
@@ -136,6 +137,11 @@ namespace lms::scanner
{ {
} }
core::LiteralString PlayListFileScanner::getName() const
{
return "PlayList scanner";
}
std::span<const std::filesystem::path> PlayListFileScanner::getSupportedExtensions() const std::span<const std::filesystem::path> PlayListFileScanner::getSupportedExtensions() const
{ {
return metadata::getSupportedPlayListFileExtensions(); return metadata::getSupportedPlayListFileExtensions();
@@ -42,6 +42,7 @@ namespace lms::scanner
PlayListFileScanner& operator=(const PlayListFileScanner&) = delete; PlayListFileScanner& operator=(const PlayListFileScanner&) = delete;
private: private:
core::LiteralString getName() const override;
std::span<const std::filesystem::path> getSupportedExtensions() const override; std::span<const std::filesystem::path> getSupportedExtensions() const override;
bool needsScan(ScanContext& context, const FileToScan& file) const override; bool needsScan(ScanContext& context, const FileToScan& file) const override;
std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override; std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override;
@@ -21,6 +21,7 @@
#include <boost/asio/post.hpp> #include <boost/asio/post.hpp>
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp" #include "core/ITraceLogger.hpp"
#include "scanners/IFileScanOperation.hpp" #include "scanners/IFileScanOperation.hpp"
@@ -52,6 +53,7 @@ namespace lms::scanner
{ {
{ {
LMS_SCOPED_TRACE_OVERVIEW("Scanner", operation->getName()); LMS_SCOPED_TRACE_OVERVIEW("Scanner", operation->getName());
LMS_LOG(DBUPDATER, DEBUG, operation->getName() << ": scanning file '" << operation->getFile().string() << "'");
operation->scan(); operation->scan();
} }
@@ -57,6 +57,7 @@ namespace lms::scanner
{ {
[[maybe_unused]] auto [it, inserted]{ _scannerByExtension.emplace(extension, scanner) }; [[maybe_unused]] auto [it, inserted]{ _scannerByExtension.emplace(extension, scanner) };
assert(inserted); assert(inserted);
LMS_LOG(DBUPDATER, INFO, "Registered extension '" << extension.string() << "' for '" << scanner->getName() << "'");
} }
} }
@@ -139,6 +140,7 @@ namespace lms::scanner
if (_abortScan) if (_abortScan)
return; return;
LMS_LOG(DBUPDATER, DEBUG, scanOperation->getName() << ": processing result for '" << scanOperation->getFile().string() << "'");
scanOperation->processResult(context); scanOperation->processResult(context);
context.stats.scans++; context.stats.scans++;
} }
+1 -1
View File
@@ -34,7 +34,7 @@ namespace lms::api::subsonic
}; };
static inline constexpr ProtocolVersion defaultServerProtocolVersion{ 1, 16, 0 }; 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::api::subsonic
namespace lms::core::stringUtils namespace lms::core::stringUtils
+50 -43
View File
@@ -33,6 +33,24 @@ namespace lms::api::subsonic
{ {
using namespace db; 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) Response handleGetPlaylistsRequest(RequestContext& context)
{ {
auto transaction{ context.dbSession.createReadTransaction() }; auto transaction{ context.dbSession.createReadTransaction() };
@@ -60,10 +78,11 @@ namespace lms::api::subsonic
TrackList::FindParameters params; TrackList::FindParameters params;
params.setVisibility(TrackList::Visibility::Public); params.setVisibility(TrackList::Visibility::Public);
params.setType(TrackListType::PlayList); params.setType(TrackListType::PlayList);
params.setExcludedUser(context.user->getId());
db::TrackList::find(context.dbSession, params, [&](const db::TrackList::pointer& trackList) { db::TrackList::find(context.dbSession, params, [&](const db::TrackList::pointer& trackList) {
if (trackList->getUserId() != context.user->getId()) // skip already reported assert(trackList->getUserId() != context.user->getId());
addTrackList(trackList); addTrackList(trackList);
}); });
} }
@@ -77,14 +96,17 @@ namespace lms::api::subsonic
auto transaction{ context.dbSession.createReadTransaction() }; auto transaction{ context.dbSession.createReadTransaction() };
TrackList::pointer tracklist{ TrackList::find(context.dbSession, trackListId) }; TrackList::pointer trackList{ TrackList::find(context.dbSession, trackListId) };
if (!tracklist) if (!trackList || trackList->getType() != TrackListType::PlayList)
throw RequestedDataNotFoundError{};
if (trackList->getUserId() != context.user->getId() && trackList->getVisibility() != TrackList::Visibility::Public)
throw RequestedDataNotFoundError{}; throw RequestedDataNotFoundError{};
Response response{ Response::createOkResponse(context.serverProtocolVersion) }; 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) for (const TrackListEntry::pointer& entry : entries.results)
playlistNode.addArrayChild("entry", createSongNode(context, entry->getTrack(), context.user)); playlistNode.addArrayChild("entry", createSongNode(context, entry->getTrack(), context.user));
@@ -106,28 +128,23 @@ namespace lms::api::subsonic
auto transaction{ context.dbSession.createWriteTransaction() }; auto transaction{ context.dbSession.createWriteTransaction() };
TrackList::pointer tracklist; TrackList::pointer trackList;
if (id) if (id)
{ {
tracklist = TrackList::find(context.dbSession, *id); trackList = TrackList::find(context.dbSession, *id);
if (!tracklist checkTrackListModificationAccess(trackList, context.user->getId());
|| tracklist->getUser() != context.user
|| tracklist->getType() != TrackListType::PlayList)
{
throw RequestedDataNotFoundError{};
}
if (name) if (name)
tracklist.modify()->setName(*name); trackList.modify()->setName(*name);
tracklist.modify()->clear(); trackList.modify()->clear();
tracklist.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime()); trackList.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime());
} }
else else
{ {
tracklist = context.dbSession.create<TrackList>(*name, TrackListType::PlayList); trackList = context.dbSession.create<TrackList>(*name, TrackListType::PlayList);
tracklist.modify()->setUser(context.user); trackList.modify()->setUser(context.user);
tracklist.modify()->setVisibility(TrackList::Visibility::Private); trackList.modify()->setVisibility(TrackList::Visibility::Private);
} }
for (const TrackId trackId : trackIds) for (const TrackId trackId : trackIds)
@@ -136,13 +153,13 @@ namespace lms::api::subsonic
if (!track) if (!track)
continue; continue;
context.dbSession.create<TrackListEntry>(track, tracklist); context.dbSession.create<TrackListEntry>(track, trackList);
} }
Response response{ Response::createOkResponse(context.serverProtocolVersion) }; 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) for (const TrackListEntry::pointer& entry : entries.results)
playlistNode.addArrayChild("entry", createSongNode(context, entry->getTrack(), context.user)); playlistNode.addArrayChild("entry", createSongNode(context, entry->getTrack(), context.user));
@@ -165,19 +182,14 @@ namespace lms::api::subsonic
auto transaction{ context.dbSession.createWriteTransaction() }; auto transaction{ context.dbSession.createWriteTransaction() };
TrackList::pointer tracklist{ TrackList::find(context.dbSession, id) }; TrackList::pointer trackList{ TrackList::find(context.dbSession, id) };
if (!tracklist checkTrackListModificationAccess(trackList, context.user->getId());
|| tracklist->getUser() != context.user
|| tracklist->getType() != TrackListType::PlayList)
{
throw RequestedDataNotFoundError{};
}
if (name) if (name)
tracklist.modify()->setName(*name); trackList.modify()->setName(*name);
tracklist.modify()->setVisibility(isPublic ? db::TrackList::Visibility::Public : db::TrackList::Visibility::Private); trackList.modify()->setVisibility(isPublic ? db::TrackList::Visibility::Public : db::TrackList::Visibility::Private);
tracklist.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime()); trackList.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime());
{ {
// Remove from end to make indexes stable // Remove from end to make indexes stable
@@ -185,7 +197,7 @@ namespace lms::api::subsonic
for (std::size_t trackPositionToRemove : trackPositionsToRemove) for (std::size_t trackPositionToRemove : trackPositionsToRemove)
{ {
auto entry{ tracklist->getEntry(trackPositionToRemove) }; auto entry{ trackList->getEntry(trackPositionToRemove) };
if (entry) if (entry)
entry.remove(); entry.remove();
} }
@@ -198,7 +210,7 @@ namespace lms::api::subsonic
if (!track) if (!track)
continue; continue;
context.dbSession.create<TrackListEntry>(track, tracklist); context.dbSession.create<TrackListEntry>(track, trackList);
} }
return Response::createOkResponse(context.serverProtocolVersion); return Response::createOkResponse(context.serverProtocolVersion);
@@ -210,15 +222,10 @@ namespace lms::api::subsonic
auto transaction{ context.dbSession.createWriteTransaction() }; auto transaction{ context.dbSession.createWriteTransaction() };
TrackList::pointer tracklist{ TrackList::find(context.dbSession, id) }; TrackList::pointer trackList{ TrackList::find(context.dbSession, id) };
if (!tracklist checkTrackListModificationAccess(trackList, context.user->getId());
|| tracklist->getUser() != context.user
|| tracklist->getType() != TrackListType::PlayList)
{
throw RequestedDataNotFoundError{};
}
tracklist.remove(); trackList.remove();
return Response::createOkResponse(context.serverProtocolVersion); return Response::createOkResponse(context.serverProtocolVersion);
} }