diff --git a/README.md b/README.md
index 823d520c..6be150d1 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,12 @@ _LMS_ automatically associates images with each disc in your collection. Name th
## 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.
+### Playlist image lookup
+_LMS_ resolves a playlist's cover image in this order:
+1. **`#EXTIMG:` directive**. An explicit path declared inside the playlist file (URLs are ignored).
+2. **Same-name image file**. An image file in the same directory as the playlist that shares the playlist's filename stem.
+3. **First track's artwork** – if neither of the above is found, the artwork of the first track in the playlist is used as a fallback.
+
## Lyrics support
_LMS_ supports lyrics in `lrc` files, `txt` files, and embedded track metadata. Both synchronized and unsynchronized lyrics are supported.
diff --git a/approot/messages.xml b/approot/messages.xml
index ba59403b..ba8507b7 100644
--- a/approot/messages.xml
+++ b/approot/messages.xml
@@ -144,6 +144,7 @@
Scanning: step {1}/{2}
Associating artist images: {1}%...
Associating external lyrics: {1}%...
+Associating playlist images: {1}%...
Associating playlist tracks: {1}%...
Associating release images: {1}%...
Associating track images: {1}%...
diff --git a/approot/messages_es.xml b/approot/messages_es.xml
index 80564832..7f041eea 100644
--- a/approot/messages_es.xml
+++ b/approot/messages_es.xml
@@ -144,6 +144,7 @@
Escaneando: etapa {1}/{2}
Association des images des artistes: {1}%...
Association des paroles externes: {1}%...
+Asignando imágenes de las listas de reproducción: {1}%...
Association des pistes des listes de lectures: {1}%...
Asignando imágenes de los álbumes: {1}%...
Asignando imágenes de las pistas: {1}%...
diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml
index fdebad34..5ea3e9f4 100644
--- a/approot/messages_fr.xml
+++ b/approot/messages_fr.xml
@@ -144,6 +144,7 @@
En cours de scan : étape {1}/{2}
Association des images des artistes: {1}%...
Association des paroles externes: {1}%...
+Association des images des listes de lectures: {1}%...
Association des pistes des listes de lectures: {1}%...
Association des images des albums: {1}%...
Association des images des pistes: {1}%...
diff --git a/approot/messages_it.xml b/approot/messages_it.xml
index 52ad6b21..10b38d3c 100644
--- a/approot/messages_it.xml
+++ b/approot/messages_it.xml
@@ -144,6 +144,7 @@
Scansione: passo {1}/{2}
Associando immagini degli artisti: {1}%...
Associazione dei testi esterni: {1}%...
+Associando immagini della playlist: {1}%...
Associando brani della playlist: {1}%...
Associando immagini degli album: {1}%...
Associando immagini delle tracce: {1}%...
diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml
index 1ab0d6a6..8e9d3d3d 100644
--- a/approot/messages_pl.xml
+++ b/approot/messages_pl.xml
@@ -153,6 +153,7 @@
Skanowanie: krok {1}/{2}
Kojarzenie obrazów artystów: {1}%...
Kojarzenie zewnętrznych tekstów: {1}%...
+Kojarzenie obrazów playlist: {1}%...
Kojarzenie utworów z playlisty: {1}%...
Kojarzenie obrazów albumów: {1}%...
Kojarzenie obrazów ścieżek: {1}%...
diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml
index 82404325..801d38f3 100644
--- a/approot/messages_zh.xml
+++ b/approot/messages_zh.xml
@@ -142,6 +142,7 @@
扫描中: 阶段 {1}/{2}
关联艺术家图片:{1}%...
关联外部歌词:{1}%...
+关联播放列表图片:{1}%...
关联播放列表曲目:{1}%...
关联专辑封面:{1}%...
关联曲目图片:{1}%...
diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp
index 9039ba1c..aadab808 100644
--- a/src/libs/database/impl/Migration.cpp
+++ b/src/libs/database/impl/Migration.cpp
@@ -35,7 +35,7 @@ namespace lms::db
{
namespace
{
- static constexpr Version LMS_DATABASE_VERSION{ 104 };
+ static constexpr Version LMS_DATABASE_VERSION{ 105 };
}
VersionInfo::VersionInfo()
@@ -1723,6 +1723,12 @@ FROM track)");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN musicnn_model_identifier TEXT NOT NULL DEFAULT ''");
}
+ void migrateFromV104(Session& session)
+ {
+ utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE "playlist_file" ADD COLUMN "preferred_artwork_id" bigint)");
+ utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE "playlist_file" ADD COLUMN "cover_image_file" text NOT NULL DEFAULT '')");
+ }
+
bool doDbMigration(Session& session)
{
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -1803,6 +1809,7 @@ FROM track)");
{ 101, migrateFromV101 },
{ 102, migrateFromV102 },
{ 103, migrateFromV103 },
+ { 104, migrateFromV104 },
};
bool migrationPerformed{};
diff --git a/src/libs/database/impl/objects/PlayListFile.cpp b/src/libs/database/impl/objects/PlayListFile.cpp
index e7b408d1..83db070e 100644
--- a/src/libs/database/impl/objects/PlayListFile.cpp
+++ b/src/libs/database/impl/objects/PlayListFile.cpp
@@ -24,6 +24,7 @@
#include "core/ILogger.hpp"
#include "database/Session.hpp"
+#include "database/objects/Artwork.hpp"
#include "database/objects/Directory.hpp"
#include "database/objects/MediaLibrary.hpp"
#include "database/objects/Track.hpp"
@@ -181,4 +182,19 @@ namespace lms::db
root["files"] = std::move(fileArray);
_entries = Wt::Json::serialize(root);
}
+
+ void PlayListFile::setCoverImageFile(const std::filesystem::path& file)
+ {
+ _coverImageFile = file;
+ }
+
+ void PlayListFile::updatePreferredArtwork(Session& session, PlayListFileId id, ArtworkId artworkId)
+ {
+ session.checkWriteTransaction();
+
+ if (artworkId.isValid())
+ utils::executeCommand(*session.getDboSession(), "UPDATE playlist_file SET preferred_artwork_id = ? WHERE id = ?", artworkId, id);
+ else
+ utils::executeCommand(*session.getDboSession(), "UPDATE playlist_file SET preferred_artwork_id = NULL WHERE id = ?", id);
+ }
} // namespace lms::db
diff --git a/src/libs/database/include/database/objects/PlayListFile.hpp b/src/libs/database/include/database/objects/PlayListFile.hpp
index c6443d0e..3719e52d 100644
--- a/src/libs/database/include/database/objects/PlayListFile.hpp
+++ b/src/libs/database/include/database/objects/PlayListFile.hpp
@@ -31,12 +31,14 @@
#include "database/IdRange.hpp"
#include "database/Object.hpp"
+#include "database/objects/ArtworkId.hpp"
#include "database/objects/DirectoryId.hpp"
LMS_DECLARE_IDTYPE(PlayListFileId)
namespace lms::db
{
+ class Artwork;
class Session;
class Directory;
class MediaLibrary;
@@ -61,6 +63,8 @@ namespace lms::db
const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; }
std::size_t getFileSize() const { return _fileSize; }
std::string_view getName() const { return _name; }
+ const std::filesystem::path& getCoverImageFile() const { return _coverImageFile; }
+ ArtworkId getPreferredArtworkId() const { return _preferredArtwork.id(); }
std::vector getFiles() const;
ObjectPtr getTrackList() const;
ObjectPtr getDirectory() const;
@@ -70,12 +74,15 @@ namespace lms::db
void setAbsoluteFilePath(const std::filesystem::path& filePath);
void setLastWriteTime(Wt::WDateTime time) { _fileLastWrite = time; }
void setFileSize(std::size_t fileSize) { _fileSize = fileSize; }
+ void setCoverImageFile(const std::filesystem::path& file);
void setMediaLibrary(ObjectPtr mediaLibrary) { _mediaLibrary = getDboPtr(mediaLibrary); }
void setDirectory(ObjectPtr directory);
void setTrackList(ObjectPtr trackList);
void setName(std::string_view name);
void setFiles(std::span files);
+ static void updatePreferredArtwork(Session& session, PlayListFileId id, ArtworkId artworkId);
+
template
void persist(Action& a)
{
@@ -85,9 +92,11 @@ namespace lms::db
Wt::Dbo::field(a, _fileLastWrite, "file_last_write");
Wt::Dbo::field(a, _name, "name");
Wt::Dbo::field(a, _entries, "entries");
+ Wt::Dbo::field(a, _coverImageFile, "cover_image_file");
Wt::Dbo::belongsTo(a, _mediaLibrary, "media_library", Wt::Dbo::OnDeleteSetNull); // don't delete playlist on media library removal, we want to wait for the next scan to have a chance to migrate files
Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade);
+ Wt::Dbo::belongsTo(a, _preferredArtwork, "preferred_artwork", Wt::Dbo::OnDeleteSetNull);
Wt::Dbo::hasOne(a, _trackList, "playlist_file");
}
@@ -106,9 +115,11 @@ namespace lms::db
std::string _name;
std::string _entries; // A json encoded list of files
+ std::filesystem::path _coverImageFile;
Wt::Dbo::ptr _mediaLibrary;
Wt::Dbo::ptr _directory;
+ Wt::Dbo::ptr _preferredArtwork;
Wt::Dbo::weak_ptr _trackList;
};
} // namespace lms::db
diff --git a/src/libs/database/include/database/objects/TrackList.hpp b/src/libs/database/include/database/objects/TrackList.hpp
index 81b712da..14b2c412 100644
--- a/src/libs/database/include/database/objects/TrackList.hpp
+++ b/src/libs/database/include/database/objects/TrackList.hpp
@@ -125,6 +125,7 @@ namespace lms::db
TrackListType getType() const { return _type; }
ObjectPtr getUser() const { return _user; }
UserId getUserId() const { return _user.id(); }
+ ObjectPtr getPlayListFile() const { return _playListFile; }
Wt::WDateTime getLastModifiedDateTime() const { return _lastModifiedDateTime; }
Wt::WDateTime getCreationDateTime() const { return _creationDateTime; }
diff --git a/src/libs/services/artwork/impl/ArtworkService.cpp b/src/libs/services/artwork/impl/ArtworkService.cpp
index 8dda3d27..8a8e7875 100644
--- a/src/libs/services/artwork/impl/ArtworkService.cpp
+++ b/src/libs/services/artwork/impl/ArtworkService.cpp
@@ -34,6 +34,7 @@
#include "database/objects/Artwork.hpp"
#include "database/objects/Image.hpp"
#include "database/objects/ImageId.hpp"
+#include "database/objects/PlayListFile.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackEmbeddedImage.hpp"
@@ -161,6 +162,12 @@ namespace lms::artwork
if (!trackList)
return artworkId;
+ if (const auto playListFile{ trackList->getPlayListFile() })
+ {
+ if (const db::ArtworkId id{ playListFile->getPreferredArtworkId() }; id.isValid())
+ return id;
+ }
+
const auto entries{ trackList->getEntries(db::Range{ 0, 10 }) };
for (const auto& entry : entries.results)
{
diff --git a/src/libs/services/scanner/CMakeLists.txt b/src/libs/services/scanner/CMakeLists.txt
index ca467e45..fe5a8bd0 100644
--- a/src/libs/services/scanner/CMakeLists.txt
+++ b/src/libs/services/scanner/CMakeLists.txt
@@ -20,6 +20,7 @@ add_library(lmsscanner STATIC
impl/steps/ScanStepAssociateArtistImages.cpp
impl/steps/ScanStepAssociateExternalLyrics.cpp
impl/steps/ScanStepAssociateMediumImages.cpp
+ impl/steps/ScanStepAssociatePlayListImages.cpp
impl/steps/ScanStepAssociatePlayListTracks.cpp
impl/steps/ScanStepAssociateReleaseImages.cpp
impl/steps/ScanStepAssociateTrackImages.cpp
diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp
index efa04576..ba513cc0 100644
--- a/src/libs/services/scanner/impl/ScannerService.cpp
+++ b/src/libs/services/scanner/impl/ScannerService.cpp
@@ -42,6 +42,7 @@
#include "steps/ScanStepAssociateArtistImages.hpp"
#include "steps/ScanStepAssociateExternalLyrics.hpp"
#include "steps/ScanStepAssociateMediumImages.hpp"
+#include "steps/ScanStepAssociatePlayListImages.hpp"
#include "steps/ScanStepAssociatePlayListTracks.hpp"
#include "steps/ScanStepAssociateReleaseImages.hpp"
#include "steps/ScanStepAssociateTrackImages.hpp"
@@ -516,6 +517,7 @@ namespace lms::scanner
_scanSteps.emplace_back(std::make_unique(params));
_scanSteps.emplace_back(std::make_unique(params));
_scanSteps.emplace_back(std::make_unique(params));
+ _scanSteps.emplace_back(std::make_unique(params));
_scanSteps.emplace_back(std::make_unique(params));
_scanSteps.emplace_back(std::make_unique(params));
_scanSteps.emplace_back(std::make_unique(params)); // must come after ScanStepAssociateReleaseImages (because and artist image can fallback on a release image)
diff --git a/src/libs/services/scanner/impl/scanners/playlist/PlayListFileScanner.cpp b/src/libs/services/scanner/impl/scanners/playlist/PlayListFileScanner.cpp
index 782eeff0..baa27133 100644
--- a/src/libs/services/scanner/impl/scanners/playlist/PlayListFileScanner.cpp
+++ b/src/libs/services/scanner/impl/scanners/playlist/PlayListFileScanner.cpp
@@ -97,6 +97,7 @@ namespace lms::scanner
else
playList.modify()->setName(getFilePath().stem().string());
playList.modify()->setFiles(_parsedPlayList->files);
+ playList.modify()->setCoverImageFile(_parsedPlayList->coverImage.value_or(std::filesystem::path{}));
db::MediaLibrary::pointer mediaLibrary{ db::MediaLibrary::find(dbSession, getMediaLibrary().id) }; // may be null if settings are updated in // => next scan will correct this
playList.modify()->setDirectory(utils::getOrCreateDirectory(dbSession, getFilePath().parent_path(), mediaLibrary));
@@ -106,11 +107,9 @@ namespace lms::scanner
LMS_LOG(DBUPDATER, DEBUG, "Added playlist file " << getFilePath());
return OperationResult::Added;
}
- else
- {
- LMS_LOG(DBUPDATER, DEBUG, "Updated playlist file '" << getFilePath());
- return OperationResult::Updated;
- }
+
+ LMS_LOG(DBUPDATER, DEBUG, "Updated playlist file '" << getFilePath());
+ return OperationResult::Updated;
}
} // namespace
diff --git a/src/libs/services/scanner/impl/scanners/playlist/PlayListParser.cpp b/src/libs/services/scanner/impl/scanners/playlist/PlayListParser.cpp
index b12c5b85..24980a6a 100644
--- a/src/libs/services/scanner/impl/scanners/playlist/PlayListParser.cpp
+++ b/src/libs/services/scanner/impl/scanners/playlist/PlayListParser.cpp
@@ -54,7 +54,7 @@ namespace lms::scanner
else
{
comment.directive = line.substr(0, parameterSeparator + 1);
- comment.parameter = line.substr(parameterSeparator + 1);
+ comment.parameter = core::stringUtils::stringTrim(line.substr(parameterSeparator + 1));
};
return comment;
@@ -87,6 +87,12 @@ namespace lms::scanner
{
if (comment->directive == "#PLAYLIST:")
playlist.name = comment->parameter;
+ else if (comment->directive == "#EXTIMG:")
+ {
+ // skip URLs (contain ':' in the path value)
+ if (!comment->parameter.empty() && comment->parameter.find(':') == std::string_view::npos)
+ playlist.coverImage = std::filesystem::path{ comment->parameter }.lexically_normal();
+ }
continue;
}
diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListImages.cpp
new file mode 100644
index 00000000..380e4acc
--- /dev/null
+++ b/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListImages.cpp
@@ -0,0 +1,152 @@
+/*
+ * 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 "ScanStepAssociatePlayListImages.hpp"
+
+#include
+
+#include "core/ILogger.hpp"
+#include "database/IDb.hpp"
+#include "database/Session.hpp"
+#include "database/objects/Artwork.hpp"
+#include "database/objects/Directory.hpp"
+#include "database/objects/Image.hpp"
+#include "database/objects/PlayListFile.hpp"
+
+#include "ScanContext.hpp"
+
+namespace lms::scanner
+{
+ namespace
+ {
+ struct PlayListArtworkAssociation
+ {
+ db::PlayListFileId playListFileId;
+ db::ArtworkId artworkId; // invalid = clear
+ };
+ using PlayListArtworkAssociationContainer = std::deque;
+
+ db::Artwork::pointer computePreferredArtwork(db::Session& session, const db::PlayListFile::pointer& playListFile)
+ {
+ // Priority 1: explicit #EXTIMG: hint stored in the playlist file
+ const std::filesystem::path& coverImageFile{ playListFile->getCoverImageFile() };
+ if (!coverImageFile.empty())
+ {
+ const std::filesystem::path coverImagePath{ coverImageFile.is_relative()
+ ? (playListFile->getDirectory()->getAbsolutePath() / coverImageFile).lexically_normal()
+ : coverImageFile };
+
+ const db::Image::pointer image{ db::Image::find(session, coverImagePath) };
+ if (image)
+ return db::Artwork::find(session, image->getId());
+ }
+
+ // Priority 2: image file with the same stem as the playlist in the same directory
+ db::Artwork::pointer artwork;
+ db::Image::FindParameters params;
+ params.setDirectory(playListFile->getDirectoryId());
+ params.setFileStem(playListFile->getAbsoluteFilePath().stem().string());
+
+ db::Image::find(session, params, [&](const db::Image::pointer& image) {
+ if (!artwork)
+ artwork = db::Artwork::find(session, image->getId());
+ });
+
+ return artwork;
+ }
+
+ void updatePlayListPreferredArtworks(db::Session& session, PlayListArtworkAssociationContainer& associations, bool forceFullBatch)
+ {
+ constexpr std::size_t writeBatchSize{ 50 };
+
+ while ((forceFullBatch && associations.size() >= writeBatchSize) || (!forceFullBatch && !associations.empty()))
+ {
+ auto transaction{ session.createWriteTransaction() };
+
+ for (std::size_t i{}; !associations.empty() && i < writeBatchSize; ++i)
+ {
+ const auto& assoc{ associations.front() };
+ db::PlayListFile::updatePreferredArtwork(session, assoc.playListFileId, assoc.artworkId);
+ associations.pop_front();
+ }
+ }
+ }
+
+ } // namespace
+
+ bool ScanStepAssociatePlayListImages::needProcess(const ScanContext& context) const
+ {
+ return context.stats.getChangesCount() > 0;
+ }
+
+ void ScanStepAssociatePlayListImages::process(ScanContext& context)
+ {
+ auto& session{ _db.getTLSSession() };
+
+ {
+ auto transaction{ session.createReadTransaction() };
+ context.currentStepStats.totalElems = db::PlayListFile::getCount(session);
+ }
+
+ PlayListArtworkAssociationContainer associations;
+
+ db::PlayListFileId lastRetrievedId;
+ db::IdRange idRange;
+
+ while (true)
+ {
+ {
+ auto transaction{ session.createReadTransaction() };
+ idRange = db::PlayListFile::findNextIdRange(session, lastRetrievedId, 100);
+ lastRetrievedId = idRange.last;
+ }
+
+ if (!idRange.isValid())
+ break;
+
+ {
+ auto transaction{ session.createReadTransaction() };
+ db::PlayListFile::find(session, idRange, [&](const db::PlayListFile::pointer& playListFile) {
+ const db::Artwork::pointer preferredArtwork{ computePreferredArtwork(session, playListFile) };
+ const db::ArtworkId newId{ preferredArtwork ? preferredArtwork->getId() : db::ArtworkId{} };
+
+ if (newId != playListFile->getPreferredArtworkId())
+ {
+ associations.push_back({ playListFile->getId(), newId });
+
+ if (preferredArtwork)
+ LMS_LOG(DBUPDATER, DEBUG, "Updating preferred artwork for playlist '" << playListFile->getName() << "' with image " << preferredArtwork->getAbsoluteFilePath());
+ else
+ LMS_LOG(DBUPDATER, DEBUG, "Removing preferred artwork from playlist '" << playListFile->getName() << "'");
+ }
+
+ context.currentStepStats.processedElems++;
+ });
+ }
+
+ updatePlayListPreferredArtworks(session, associations, true);
+ _progressCallback(context.currentStepStats);
+
+ if (_abortScan)
+ return;
+ }
+
+ updatePlayListPreferredArtworks(session, associations, false);
+ }
+} // namespace lms::scanner
diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListImages.hpp b/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListImages.hpp
new file mode 100644
index 00000000..22627718
--- /dev/null
+++ b/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListImages.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 "ScanStepBase.hpp"
+
+namespace lms::scanner
+{
+ class ScanStepAssociatePlayListImages : public ScanStepBase
+ {
+ public:
+ using ScanStepBase::ScanStepBase;
+
+ private:
+ ScanStep getStep() const override { return ScanStep::AssociatePlayListImages; }
+ core::LiteralString getStepName() const override { return "Associate playlist images"; }
+ bool needProcess(const ScanContext& context) const override;
+ void process(ScanContext& context) override;
+ };
+} // namespace lms::scanner
diff --git a/src/libs/services/scanner/impl/types/PlayList.hpp b/src/libs/services/scanner/impl/types/PlayList.hpp
index f849a9ba..91ae3ee6 100644
--- a/src/libs/services/scanner/impl/types/PlayList.hpp
+++ b/src/libs/services/scanner/impl/types/PlayList.hpp
@@ -20,6 +20,7 @@
#pragma once
#include
+#include
#include
#include
@@ -28,6 +29,7 @@ namespace lms::scanner
struct PlayList
{
std::string name;
+ std::optional coverImage;
std::vector files;
};
} // namespace lms::scanner
diff --git a/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp b/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp
index 912b583a..9dfa43e5 100644
--- a/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp
+++ b/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp
@@ -47,6 +47,7 @@ namespace lms::scanner
{
AssociateArtistImages,
AssociateExternalLyrics,
+ AssociatePlayListImages,
AssociatePlayListTracks,
AssociateReleaseImages,
AssociateTrackImages,
diff --git a/src/libs/services/scanner/test/PlayList.cpp b/src/libs/services/scanner/test/PlayList.cpp
index e067a56f..2872f6d0 100644
--- a/src/libs/services/scanner/test/PlayList.cpp
+++ b/src/libs/services/scanner/test/PlayList.cpp
@@ -53,6 +53,80 @@ one to be/../one to be/normalized/foo.mp3)" };
EXPECT_EQ(playlist.files[5], "one to be/normalized/foo.mp3");
}
+ TEST(Scanner, playlist_name_trimmed)
+ {
+ std::istringstream is{ R"(#EXTM3U
+#PLAYLIST: My super playlist
+01-Foo.mp3)" };
+
+ const PlayList playlist{ parsePlayList(is) };
+ EXPECT_EQ(playlist.name, "My super playlist");
+ }
+
+ TEST(Scanner, playlist_extimg_relative)
+ {
+ std::istringstream is{ R"(#EXTM3U
+#EXTIMG:cover.jpg
+01-Foo.mp3)" };
+
+ const PlayList playlist{ parsePlayList(is) };
+ ASSERT_TRUE(playlist.coverImage.has_value());
+ EXPECT_EQ(*playlist.coverImage, "cover.jpg");
+ ASSERT_EQ(playlist.files.size(), 1);
+ }
+
+ TEST(Scanner, playlist_extimg_absolute)
+ {
+ std::istringstream is{ R"(#EXTM3U
+#EXTIMG:/images/cover.jpg
+01-Foo.mp3)" };
+
+ const PlayList playlist{ parsePlayList(is) };
+ ASSERT_TRUE(playlist.coverImage.has_value());
+ EXPECT_EQ(*playlist.coverImage, "/images/cover.jpg");
+ }
+
+ TEST(Scanner, playlist_extimg_normalized)
+ {
+ std::istringstream is{ R"(#EXTM3U
+#EXTIMG:artwork/../artwork/cover.jpg
+01-Foo.mp3)" };
+
+ const PlayList playlist{ parsePlayList(is) };
+ ASSERT_TRUE(playlist.coverImage.has_value());
+ EXPECT_EQ(*playlist.coverImage, "artwork/cover.jpg");
+ }
+
+ TEST(Scanner, playlist_extimg_trimmed)
+ {
+ std::istringstream is{ R"(#EXTM3U
+#EXTIMG: cover.jpg
+01-Foo.mp3)" };
+
+ const PlayList playlist{ parsePlayList(is) };
+ ASSERT_TRUE(playlist.coverImage.has_value());
+ EXPECT_EQ(*playlist.coverImage, "cover.jpg");
+ }
+
+ TEST(Scanner, playlist_extimg_url_ignored)
+ {
+ std::istringstream is{ R"(#EXTM3U
+#EXTIMG:https://example.com/cover.jpg
+01-Foo.mp3)" };
+
+ const PlayList playlist{ parsePlayList(is) };
+ EXPECT_FALSE(playlist.coverImage.has_value());
+ }
+
+ TEST(Scanner, playlist_no_extimg)
+ {
+ std::istringstream is{ R"(#EXTM3U
+01-Foo.mp3)" };
+
+ const PlayList playlist{ parsePlayList(is) };
+ EXPECT_FALSE(playlist.coverImage.has_value());
+ }
+
TEST(Scanner, playlist_UTF8_bom)
{
const unsigned char content[] = { 0xEF, 0xBB, 0xBF, '#', 'E', 'X', 'T', 'M', '3', 'U', '\r', '\n', '\r', '\n', '.', '.', '/', 't', 'e', 's', 't', '.', 'm', 'p', '3', '\r', '\n' };
diff --git a/src/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp
index b2830bd1..103b8dd0 100644
--- a/src/lms/ui/admin/ScannerController.cpp
+++ b/src/lms/ui/admin/ScannerController.cpp
@@ -178,6 +178,11 @@ namespace lms::ui
.arg(stepStats.progress()));
break;
+ case ScanStep::AssociatePlayListImages:
+ _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-associating-playlist-images")
+ .arg(stepStats.progress()));
+ break;
+
case ScanStep::AssociatePlayListTracks:
_stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-associating-playlist-tracks")
.arg(stepStats.progress()));