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