From 60ed201e74d29e17a392ce0d223f19e2f6e4acf0 Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 10 Aug 2025 13:47:20 +0200 Subject: [PATCH 01/12] Fixed bad migration directive to rescan audio files, fixes #728 --- src/libs/database/impl/Migration.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index e4b77bbf..79d538bb 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -1525,7 +1525,7 @@ FROM track)"); utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_backup RENAME TO track"); // Just increment the scan version of the settings to make the next scan rescan all audio files - utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET artist_info_scan_version = artist_info_scan_version + 1"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1"); } bool doDbMigration(Session& session) From af5fcfc979d5e46f64aaccd344e2b31791d2e42c Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 12 Sep 2025 08:38:02 +0200 Subject: [PATCH 02/12] Fixed typo in the reported explicit content value, fixes #743 --- src/libs/subsonic/impl/responses/Song.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp index 9f83c76d..6c03545a 100644 --- a/src/libs/subsonic/impl/responses/Song.cpp +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -247,7 +247,7 @@ namespace lms::api::subsonic case db::Advisory::Clean: return "clean"; case db::Advisory::Explicit: - return "expicit"; + return "explicit"; case db::Advisory::Unknown: case db::Advisory::UnSet: break; From ee41dd4592b258f1ad810b8d2eb606b97ad9dfd4 Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 12 Sep 2025 08:52:51 +0200 Subject: [PATCH 03/12] Added custom ARTISTSSORT tag support, ref #734 --- src/libs/metadata/impl/AudioFileParser.cpp | 2 +- src/libs/metadata/impl/ITagReader.hpp | 1 + .../impl/avformat/AvFormatTagReader.cpp | 1 + .../metadata/impl/taglib/TagLibTagReader.cpp | 1 + src/libs/metadata/test/AudioFileParser.cpp | 50 ++++++++++++++++++- src/libs/metadata/test/TestTagReader.hpp | 1 + 6 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/libs/metadata/impl/AudioFileParser.cpp b/src/libs/metadata/impl/AudioFileParser.cpp index 60f36eda..4812e734 100644 --- a/src/libs/metadata/impl/AudioFileParser.cpp +++ b/src/libs/metadata/impl/AudioFileParser.cpp @@ -453,7 +453,7 @@ namespace lms::metadata std::vector artistDelimiters{}; track.medium = getMedium(tagReader); - track.artists = getArtists(tagReader, { TagType::Artists, TagType::Artist }, { TagType::ArtistSortOrder }, { TagType::MusicBrainzArtistID }, _params); + track.artists = getArtists(tagReader, { TagType::Artists, TagType::Artist }, { TagType::ArtistsSortOrder, TagType::ArtistSortOrder }, { TagType::MusicBrainzArtistID }, _params); track.artistDisplayName = computeArtistDisplayName(track.artists, getTagValueAs(tagReader, TagType::Artist), _params.artistTagDelimiters); track.conductorArtists = getArtists(tagReader, { TagType::Conductors, TagType::Conductor }, { TagType::ConductorsSortOrder, TagType::ConductorSortOrder }, { TagType::MusicBrainzConductorID }, _params); diff --git a/src/libs/metadata/impl/ITagReader.hpp b/src/libs/metadata/impl/ITagReader.hpp index 0cf72856..fe5f76a0 100644 --- a/src/libs/metadata/impl/ITagReader.hpp +++ b/src/libs/metadata/impl/ITagReader.hpp @@ -43,6 +43,7 @@ namespace lms::metadata Artist, ArtistSortOrder, Artists, + ArtistsSortOrder, // non standard ASIN, Barcode, BPM, diff --git a/src/libs/metadata/impl/avformat/AvFormatTagReader.cpp b/src/libs/metadata/impl/avformat/AvFormatTagReader.cpp index 30134f3e..fb7981c1 100644 --- a/src/libs/metadata/impl/avformat/AvFormatTagReader.cpp +++ b/src/libs/metadata/impl/avformat/AvFormatTagReader.cpp @@ -44,6 +44,7 @@ namespace lms::metadata::avformat { TagType::Artist, { "ARTIST" } }, { TagType::ArtistSortOrder, { "ARTISTSORT", "ARTIST-SORT", "WM/ARTISTSORTORDER" } }, { TagType::Artists, { "ARTISTS", "WM/ARTISTS" } }, + { TagType::ArtistsSortOrder, { "ARTISTSSORT", "ARTISTS-SORT", "WM/ARTISTSSORTORDER" } }, { TagType::ASIN, { "ASIN" } }, { TagType::Barcode, { "BARCODE", "WM/BARCODE" } }, { TagType::BPM, { "BPM" } }, diff --git a/src/libs/metadata/impl/taglib/TagLibTagReader.cpp b/src/libs/metadata/impl/taglib/TagLibTagReader.cpp index 44a0d7ab..b192a2d0 100644 --- a/src/libs/metadata/impl/taglib/TagLibTagReader.cpp +++ b/src/libs/metadata/impl/taglib/TagLibTagReader.cpp @@ -79,6 +79,7 @@ namespace lms::metadata::taglib { TagType::Artist, { "ARTIST" } }, { TagType::ArtistSortOrder, { "ARTISTSORT" } }, { TagType::Artists, { "ARTISTS" } }, + { TagType::ArtistsSortOrder, { "ARTISTSSORT" } }, { TagType::ASIN, { "ASIN" } }, { TagType::Barcode, { "BARCODE" } }, { TagType::BPM, { "BPM" } }, diff --git a/src/libs/metadata/test/AudioFileParser.cpp b/src/libs/metadata/test/AudioFileParser.cpp index 3e90e5b6..2b72d83e 100644 --- a/src/libs/metadata/test/AudioFileParser.cpp +++ b/src/libs/metadata/test/AudioFileParser.cpp @@ -61,10 +61,10 @@ namespace lms::metadata::tests EXPECT_EQ(track->artistDisplayName, "MyArtist1 & MyArtist2"); ASSERT_EQ(track->artists.size(), 2); EXPECT_EQ(track->artists[0].name, "MyArtist1"); - EXPECT_EQ(track->artists[0].sortName, "MyArtist1SortName"); + EXPECT_EQ(track->artists[0].sortName, "MyArtists1SortName"); EXPECT_EQ(track->artists[0].mbid, core::UUID::fromString("9d2e0c8c-8c5e-4372-a061-590955eaeaae")); EXPECT_EQ(track->artists[1].name, "MyArtist2"); - EXPECT_EQ(track->artists[1].sortName, "MyArtist2SortName"); + EXPECT_EQ(track->artists[1].sortName, "MyArtists2SortName"); EXPECT_EQ(track->artists[1].mbid, core::UUID::fromString("5e2cf87f-c8d7-4504-8a86-954dc0840229")); ASSERT_EQ(track->comments.size(), 2); EXPECT_EQ(track->comments[0], "Comment1"); @@ -751,6 +751,52 @@ namespace lms::metadata::tests EXPECT_EQ(track->medium->release->sortName, "MyAlbum"); } + TEST(AudioFileParser, artist_sortNameFallback) + { + { + const TestTagReader testTags{ + { + { TagType::Artist, { "MyArtist" } }, + { TagType::ArtistSortOrder, { "MyArtistSortName" } }, + // No ArtistSortOrder + } + }; + std::unique_ptr track{ TestAudioFileParser{}.parseMetaData(testTags) }; + + ASSERT_EQ(track->artists.size(), 1); + EXPECT_EQ(track->artists[0].sortName, "MyArtistSortName"); + } + + { + const TestTagReader testTags{ + { + { TagType::Artist, { "MyArtist" } }, + { TagType::ArtistsSortOrder, { "MyArtistSortName" } }, + // No ArtistSortOrder + } + }; + std::unique_ptr track{ TestAudioFileParser{}.parseMetaData(testTags) }; + + ASSERT_EQ(track->artists.size(), 1); + EXPECT_EQ(track->artists[0].sortName, "MyArtistSortName"); + } + + { + const TestTagReader testTags{ + { + { TagType::Artist, { "MyArtist" } }, + { TagType::ArtistSortOrder, { "MyArtistSortNameNotUsed" } }, + { TagType::ArtistsSortOrder, { "MyArtistSortName" } }, + // No ArtistSortOrder + } + }; + std::unique_ptr track{ TestAudioFileParser{}.parseMetaData(testTags) }; + + ASSERT_EQ(track->artists.size(), 1); + EXPECT_EQ(track->artists[0].sortName, "MyArtistSortName"); + } + } + TEST(AudioFileParser, advisory) { auto doTest = [](std::string_view value, std::optional expectedValue) { diff --git a/src/libs/metadata/test/TestTagReader.hpp b/src/libs/metadata/test/TestTagReader.hpp index 35040052..12aa2efe 100644 --- a/src/libs/metadata/test/TestTagReader.hpp +++ b/src/libs/metadata/test/TestTagReader.hpp @@ -117,6 +117,7 @@ namespace lms::metadata::tests { TagType::Artist, { "MyArtist1 & MyArtist2" } }, { TagType::Artists, { "MyArtist1", "MyArtist2" } }, { TagType::ArtistSortOrder, { "MyArtist1SortName", "MyArtist2SortName" } }, + { TagType::ArtistsSortOrder, { "MyArtists1SortName", "MyArtists2SortName" } }, { TagType::AlbumArtist, { "MyAlbumArtist1 & MyAlbumArtist2" } }, { TagType::AlbumArtists, { "MyAlbumArtist1", "MyAlbumArtist2" } }, { TagType::AlbumArtistsSortOrder, { "MyAlbumArtist1SortName", "MyAlbumArtist2SortName" } }, From 4c5c9f3431b5cc6ef002e302cc62a1ff93c4b99c Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 12 Sep 2025 09:01:45 +0200 Subject: [PATCH 04/12] Added unit tests for custom ALBUMARTISTSSORT tag support, ref #734 --- src/libs/metadata/test/AudioFileParser.cpp | 66 +++++++++++++++++++++- src/libs/metadata/test/TestTagReader.hpp | 3 +- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/libs/metadata/test/AudioFileParser.cpp b/src/libs/metadata/test/AudioFileParser.cpp index 2b72d83e..c98b179c 100644 --- a/src/libs/metadata/test/AudioFileParser.cpp +++ b/src/libs/metadata/test/AudioFileParser.cpp @@ -160,10 +160,10 @@ namespace lms::metadata::tests EXPECT_EQ(release.artistDisplayName, "MyAlbumArtist1 & MyAlbumArtist2"); ASSERT_EQ(release.artists.size(), 2); EXPECT_EQ(release.artists[0].name, "MyAlbumArtist1"); - EXPECT_EQ(release.artists[0].sortName, "MyAlbumArtist1SortName"); + EXPECT_EQ(release.artists[0].sortName, "MyAlbumArtists1SortName"); EXPECT_EQ(release.artists[0].mbid, core::UUID::fromString("6fbf097c-1487-43e8-874b-50dd074398a7")); EXPECT_EQ(release.artists[1].name, "MyAlbumArtist2"); - EXPECT_EQ(release.artists[1].sortName, "MyAlbumArtist2SortName"); + EXPECT_EQ(release.artists[1].sortName, "MyAlbumArtists2SortName"); EXPECT_EQ(release.artists[1].mbid, core::UUID::fromString("5ed3d6b3-2aed-4a03-828c-3c4d4f7406e1")); EXPECT_TRUE(release.isCompilation); EXPECT_EQ(release.barcode, "MyBarcode"); @@ -797,6 +797,68 @@ namespace lms::metadata::tests } } + TEST(AudioFileParser, albumartist_sortNameFallback) + { + { + const TestTagReader testTags{ + { + { TagType::Album, { "MyAlbum" } }, + { TagType::AlbumArtist, { "MyArtist" } }, + { TagType::AlbumArtistSortOrder, { "MyArtistSortName" } }, + // No ArtistSortOrder + } + }; + std::unique_ptr track{ TestAudioFileParser{}.parseMetaData(testTags) }; + + ASSERT_TRUE(track->medium.has_value()); + ASSERT_TRUE(track->medium->release.has_value()); + + const auto& artists{ track->medium->release->artists }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists[0].sortName, "MyArtistSortName"); + } + + { + const TestTagReader testTags{ + { + { TagType::Album, { "MyAlbum" } }, + { TagType::AlbumArtist, { "MyArtist" } }, + { TagType::AlbumArtistsSortOrder, { "MyArtistSortName" } }, + // No ArtistSortOrder + } + }; + std::unique_ptr track{ TestAudioFileParser{}.parseMetaData(testTags) }; + + ASSERT_TRUE(track->medium.has_value()); + ASSERT_TRUE(track->medium->release.has_value()); + + const auto& artists{ track->medium->release->artists }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists[0].sortName, "MyArtistSortName"); + } + + { + const TestTagReader testTags{ + { + { TagType::Album, { "MyAlbum" } }, + + { TagType::AlbumArtist, { "MyArtist" } }, + { TagType::AlbumArtistSortOrder, { "MyArtistSortNameNotUsed" } }, + { TagType::AlbumArtistsSortOrder, { "MyArtistSortName" } }, + // No ArtistSortOrder + } + }; + std::unique_ptr track{ TestAudioFileParser{}.parseMetaData(testTags) }; + + ASSERT_TRUE(track->medium.has_value()); + ASSERT_TRUE(track->medium->release.has_value()); + + const auto& artists{ track->medium->release->artists }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists[0].sortName, "MyArtistSortName"); + } + } + TEST(AudioFileParser, advisory) { auto doTest = [](std::string_view value, std::optional expectedValue) { diff --git a/src/libs/metadata/test/TestTagReader.hpp b/src/libs/metadata/test/TestTagReader.hpp index 12aa2efe..32f47f39 100644 --- a/src/libs/metadata/test/TestTagReader.hpp +++ b/src/libs/metadata/test/TestTagReader.hpp @@ -120,7 +120,8 @@ namespace lms::metadata::tests { TagType::ArtistsSortOrder, { "MyArtists1SortName", "MyArtists2SortName" } }, { TagType::AlbumArtist, { "MyAlbumArtist1 & MyAlbumArtist2" } }, { TagType::AlbumArtists, { "MyAlbumArtist1", "MyAlbumArtist2" } }, - { TagType::AlbumArtistsSortOrder, { "MyAlbumArtist1SortName", "MyAlbumArtist2SortName" } }, + { TagType::AlbumArtistSortOrder, { "MyAlbumArtist1SortName", "MyAlbumArtist2SortName" } }, + { TagType::AlbumArtistsSortOrder, { "MyAlbumArtists1SortName", "MyAlbumArtists2SortName" } }, { TagType::AlbumComment, { "MyAlbumComment" } }, { TagType::Barcode, { "MyBarcode" } }, { TagType::Comment, { "Comment1", "Comment2" } }, From 1d584ebcc68375aace11d75c65ee9c77e8182ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Enrique=20Garc=C3=ADa=20Dab=C3=B3?= Date: Fri, 22 Aug 2025 21:30:37 +0200 Subject: [PATCH 05/12] Update installation instructions with Debian trixie --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index 98cad01f..70f879dc 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -31,7 +31,7 @@ apt install lms The _lms_ service is started just after the package installation, run by a dedicated _lms_ system user.
Please refer to [Deployment](#deployment) for further configuration options. ## From source -__Note__: this installation process and the default values of the configuration files have been written for _Debian Bookworm_. Therefore, you may have to adapt commands and/or paths in order to fit to your distribution. +__Note__: this installation process and the default values of the configuration files have been written for _Debian Bookworm_ and _Debian Trixie_. Therefore, you may have to adapt commands and/or paths in order to fit to your distribution. ### Build dependencies __Notes__: * a C++20 compiler is needed From 932e7715f57c35a31e41c5a87eea06f5c201c168 Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Sep 2025 15:04:13 +0200 Subject: [PATCH 06/12] Added podcast support, only from subsonic API for now, ref #726 --- .github/workflows/build-freebsd-basic.yml | 2 +- .github/workflows/codeql.yml | 2 +- CMakeLists.txt | 1 + Dockerfile-build-alpine | 1 + Dockerfile-build-arch | 1 + Dockerfile-release | 2 + INSTALL.md | 3 +- README.md | 1 + SUBSONIC.md | 1 + conf/lms.conf | 11 +- src/libs/core/CMakeLists.txt | 1 + src/libs/core/impl/Logger.cpp | 2 + src/libs/core/impl/MimeTypes.cpp | 1 + src/libs/core/impl/Path.cpp | 11 +- src/libs/core/impl/String.cpp | 126 ++++++- src/libs/core/impl/http/Client.cpp | 5 + src/libs/core/impl/http/Client.hpp | 1 + src/libs/core/impl/http/SendQueue.cpp | 162 +++++++-- src/libs/core/impl/http/SendQueue.hpp | 16 +- src/libs/core/include/core/ILogger.hpp | 1 + src/libs/core/include/core/Path.hpp | 6 - src/libs/core/include/core/SizeLiterals.hpp | 50 +++ src/libs/core/include/core/String.hpp | 4 +- .../core/http/ClientRequestParameters.hpp | 22 +- src/libs/core/include/core/http/IClient.hpp | 4 + src/libs/core/test/String.cpp | 30 ++ src/libs/database/CMakeLists.txt | 2 + src/libs/database/impl/Db.cpp | 3 + src/libs/database/impl/Migration.cpp | 56 ++- src/libs/database/impl/Object.cpp | 2 +- src/libs/database/impl/Session.cpp | 4 + src/libs/database/impl/objects/Artwork.cpp | 22 ++ src/libs/database/impl/objects/Podcast.cpp | 87 +++++ .../database/impl/objects/PodcastEpisode.cpp | 115 ++++++ .../database/include/database/Session.hpp | 2 +- src/libs/database/include/database/Types.hpp | 7 + .../include/database/objects/Artwork.hpp | 7 +- .../include/database/objects/Image.hpp | 4 + .../include/database/objects/Podcast.hpp | 146 ++++++++ .../database/objects/PodcastEpisode.hpp | 186 ++++++++++ .../database/objects/PodcastEpisodeId.hpp | 24 ++ .../include/database/objects/PodcastId.hpp | 24 ++ src/libs/database/test/Artwork.cpp | 23 ++ src/libs/database/test/CMakeLists.txt | 1 + src/libs/database/test/Migration.cpp | 4 + src/libs/database/test/Podcast.cpp | 99 ++++++ src/libs/image/impl/EncodedImage.cpp | 8 +- src/libs/image/impl/EncodedImage.hpp | 2 +- src/libs/image/include/image/Image.hpp | 2 +- src/libs/services/CMakeLists.txt | 1 + .../services/artwork/impl/ArtworkService.cpp | 24 +- .../services/artwork/impl/ArtworkService.hpp | 2 +- .../listenbrainz/FeedbacksSynchronizer.cpp | 14 +- src/libs/services/podcast/CMakeLists.txt | 40 +++ src/libs/services/podcast/impl/Exception.hpp | 31 ++ src/libs/services/podcast/impl/Executor.cpp | 38 ++ src/libs/services/podcast/impl/Executor.hpp | 38 ++ .../services/podcast/impl/PodcastParsing.cpp | 202 +++++++++++ .../services/podcast/impl/PodcastParsing.hpp | 37 ++ .../services/podcast/impl/PodcastService.cpp | 330 ++++++++++++++++++ .../services/podcast/impl/PodcastService.hpp | 91 +++++ .../services/podcast/impl/PodcastTypes.hpp | 78 +++++ .../services/podcast/impl/RefreshContext.hpp | 60 ++++ .../impl/steps/CheckForMissingFilesStep.cpp | 144 ++++++++ .../impl/steps/CheckForMissingFilesStep.hpp | 37 ++ .../impl/steps/ClearTmpDirectoryStep.cpp | 62 ++++ .../impl/steps/ClearTmpDirectoryStep.hpp | 36 ++ .../steps/DownloadEpisodeArtworksStep.cpp | 154 ++++++++ .../steps/DownloadEpisodeArtworksStep.hpp | 43 +++ .../impl/steps/DownloadEpisodesStep.cpp | 196 +++++++++++ .../impl/steps/DownloadEpisodesStep.hpp | 50 +++ .../steps/DownloadPodcastArtworksStep.cpp | 153 ++++++++ .../steps/DownloadPodcastArtworksStep.hpp | 43 +++ .../impl/steps/RefreshPodcastsStep.cpp | 204 +++++++++++ .../impl/steps/RefreshPodcastsStep.hpp | 44 +++ .../podcast/impl/steps/RefreshStep.hpp | 99 ++++++ .../podcast/impl/steps/RemoveEpisodesStep.cpp | 108 ++++++ .../podcast/impl/steps/RemoveEpisodesStep.hpp | 39 +++ .../podcast/impl/steps/RemovePodcastsStep.cpp | 95 +++++ .../podcast/impl/steps/RemovePodcastsStep.hpp | 34 ++ .../services/podcast/impl/steps/Utils.cpp | 93 +++++ .../services/podcast/impl/steps/Utils.hpp | 38 ++ .../services/podcast/IPodcastService.hpp | 53 +++ src/libs/services/podcast/test/CMakeLists.txt | 19 + .../services/podcast/test/PodcastParser.cpp | 151 ++++++++ .../services/podcast/test/PodcastService.cpp | 32 ++ .../services/scanner/impl/ScannerService.cpp | 8 +- .../services/scanner/impl/ScannerService.hpp | 8 +- .../scanner/impl/steps/ScanStepBase.cpp | 1 + .../scanner/impl/steps/ScanStepBase.hpp | 3 + .../steps/ScanStepCheckForRemovedFiles.cpp | 15 +- .../services/scanner/IScannerService.hpp | 3 +- .../impl/listenbrainz/ListensSynchronizer.cpp | 16 +- .../transcoding/impl/TranscodingService.cpp | 24 +- .../transcoding/ITranscodingService.hpp | 10 +- src/libs/subsonic/CMakeLists.txt | 3 + src/libs/subsonic/impl/ProtocolVersion.hpp | 1 - src/libs/subsonic/impl/SubsonicId.cpp | 44 ++- src/libs/subsonic/impl/SubsonicId.hpp | 10 + src/libs/subsonic/impl/SubsonicResource.cpp | 29 +- src/libs/subsonic/impl/SubsonicResponse.cpp | 4 +- .../impl/endpoints/MediaRetrieval.cpp | 97 +++-- src/libs/subsonic/impl/endpoints/Podcast.cpp | 158 +++++++++ src/libs/subsonic/impl/endpoints/Podcast.hpp | 35 ++ src/libs/subsonic/impl/endpoints/System.cpp | 6 + src/libs/subsonic/impl/responses/Podcast.cpp | 135 +++++++ src/libs/subsonic/impl/responses/Podcast.hpp | 38 ++ src/libs/subsonic/impl/responses/User.cpp | 22 +- src/lms/CMakeLists.txt | 1 + src/lms/main.cpp | 10 +- .../ui/resource/AudioTranscodingResource.cpp | 14 +- 111 files changed, 4717 insertions(+), 188 deletions(-) create mode 100644 src/libs/core/include/core/SizeLiterals.hpp create mode 100644 src/libs/database/impl/objects/Podcast.cpp create mode 100644 src/libs/database/impl/objects/PodcastEpisode.cpp create mode 100644 src/libs/database/include/database/objects/Podcast.hpp create mode 100644 src/libs/database/include/database/objects/PodcastEpisode.hpp create mode 100644 src/libs/database/include/database/objects/PodcastEpisodeId.hpp create mode 100644 src/libs/database/include/database/objects/PodcastId.hpp create mode 100644 src/libs/database/test/Podcast.cpp create mode 100644 src/libs/services/podcast/CMakeLists.txt create mode 100644 src/libs/services/podcast/impl/Exception.hpp create mode 100644 src/libs/services/podcast/impl/Executor.cpp create mode 100644 src/libs/services/podcast/impl/Executor.hpp create mode 100644 src/libs/services/podcast/impl/PodcastParsing.cpp create mode 100644 src/libs/services/podcast/impl/PodcastParsing.hpp create mode 100644 src/libs/services/podcast/impl/PodcastService.cpp create mode 100644 src/libs/services/podcast/impl/PodcastService.hpp create mode 100644 src/libs/services/podcast/impl/PodcastTypes.hpp create mode 100644 src/libs/services/podcast/impl/RefreshContext.hpp create mode 100644 src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.cpp create mode 100644 src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/ClearTmpDirectoryStep.cpp create mode 100644 src/libs/services/podcast/impl/steps/ClearTmpDirectoryStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp create mode 100644 src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp create mode 100644 src/libs/services/podcast/impl/steps/DownloadEpisodesStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp create mode 100644 src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/RefreshPodcastsStep.cpp create mode 100644 src/libs/services/podcast/impl/steps/RefreshPodcastsStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/RefreshStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/RemoveEpisodesStep.cpp create mode 100644 src/libs/services/podcast/impl/steps/RemoveEpisodesStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/RemovePodcastsStep.cpp create mode 100644 src/libs/services/podcast/impl/steps/RemovePodcastsStep.hpp create mode 100644 src/libs/services/podcast/impl/steps/Utils.cpp create mode 100644 src/libs/services/podcast/impl/steps/Utils.hpp create mode 100644 src/libs/services/podcast/include/services/podcast/IPodcastService.hpp create mode 100644 src/libs/services/podcast/test/CMakeLists.txt create mode 100644 src/libs/services/podcast/test/PodcastParser.cpp create mode 100644 src/libs/services/podcast/test/PodcastService.cpp create mode 100644 src/libs/subsonic/impl/endpoints/Podcast.cpp create mode 100644 src/libs/subsonic/impl/endpoints/Podcast.hpp create mode 100644 src/libs/subsonic/impl/responses/Podcast.cpp create mode 100644 src/libs/subsonic/impl/responses/Podcast.hpp diff --git a/.github/workflows/build-freebsd-basic.yml b/.github/workflows/build-freebsd-basic.yml index 9e20df03..f6d115dc 100644 --- a/.github/workflows/build-freebsd-basic.yml +++ b/.github/workflows/build-freebsd-basic.yml @@ -10,7 +10,7 @@ jobs: with: usesh: true prepare: | - pkg install -y cmake pkgconf boost-libs ffmpeg stb libconfig taglib libarchive xxhash wt googletest + pkg install -y cmake pkgconf boost-libs ffmpeg stb libconfig taglib libarchive xxhash wt googletest pugixml run: | mkdir build diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 444eb88c..8ed1c3db 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: name: Install dependencies (cpp) run: | sudo apt-get update - sudo apt-get install --yes build-essential cmake libboost-all-dev libconfig++-dev libavcodec-dev libavutil-dev libavformat-dev libstb-dev libtag1-dev libpam0g-dev libgtest-dev libarchive-dev libxxhash-dev + sudo apt-get install --yes build-essential cmake libboost-all-dev libconfig++-dev libavcodec-dev libavutil-dev libavformat-dev libstb-dev libtag1-dev libpam0g-dev libgtest-dev libarchive-dev libxxhash-dev libpugixml-dev export WT_VERSION=4.11.3 export WT_INSTALL_PREFIX=/usr git clone https://github.com/emweb/wt.git /tmp/wt diff --git a/CMakeLists.txt b/CMakeLists.txt index 454f7397..1983ec43 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,7 @@ if(ENABLE_TESTS) endif() # Common dependencies +find_package(OpenSSL REQUIRED) find_package(PkgConfig REQUIRED) find_package(Threads REQUIRED) find_package(Filesystem REQUIRED) diff --git a/Dockerfile-build-alpine b/Dockerfile-build-alpine index d38f71ab..200b6d7d 100644 --- a/Dockerfile-build-alpine +++ b/Dockerfile-build-alpine @@ -20,6 +20,7 @@ ARG LMS_BUILD_PACKAGES=" \ ffmpeg-dev \ libarchive-dev \ libconfig-dev \ + pugixml-dev \ taglib-dev \ stb \ wt-dev \ diff --git a/Dockerfile-build-arch b/Dockerfile-build-arch index 0da7c55b..b49c9477 100644 --- a/Dockerfile-build-arch +++ b/Dockerfile-build-arch @@ -12,6 +12,7 @@ ARG BUILD_PACKAGES="\ libconfig \ make \ pkgconfig \ + pugixml \ stb \ taglib \ wt \ diff --git a/Dockerfile-release b/Dockerfile-release index 2521139e..7bcf39f7 100644 --- a/Dockerfile-release +++ b/Dockerfile-release @@ -22,6 +22,7 @@ ARG BUILD_PACKAGES=" \ curl \ libogg-dev \ opus-dev \ + pugixml-dev \ libvorbis-dev \ lame-dev \ cmake \ @@ -164,6 +165,7 @@ ARG RUNTIME_PACKAGES=" \ boost-thread \ libarchive \ libconfig++ \ + pugixml \ sqlite-libs" ARG LMS_USER=lms diff --git a/INSTALL.md b/INSTALL.md index 70f879dc..621071aa 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -37,13 +37,12 @@ __Notes__: * a C++20 compiler is needed * ffmpeg version 4 minimum is required ```sh -apt-get install g++ cmake libboost-program-options-dev libboost-system-dev libavutil-dev libavformat-dev libstb-dev libconfig++-dev ffmpeg libtag1-dev libpam0g-dev libgtest-dev libarchive-dev libxxhash-dev +apt-get install g++ cmake libboost-program-options-dev libboost-system-dev libavutil-dev libavformat-dev libstb-dev libconfig++-dev ffmpeg libtag1-dev libpam0g-dev libpugixml-dev libgtest-dev libarchive-dev libxxhash-dev libssl-dev ``` __Notes__: * libpam0g-dev is optional (only for using PAM authentication) * libstb-dev can be replaced by libgraphicsmagick++1-dev (the latter will likely use more RAM) You also need _Wt4_, which is not packaged on _Debian_. See [installation instructions](https://www.webtoolkit.eu/wt/doc/reference/html/InstallationUnix.html).
-No optional requirement is needed, except openSSL if you plan not to deploy behind a reverse proxy (which is not recommended). ### Build Get the latest stable release and build it: ```sh diff --git a/README.md b/README.md index a327bd39..b069afa2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A [demo instance](http://lms-demo.poupon.dev) is available. Note the administrat * ReplayGain support * Audio transcoding for compatibility and reduced bandwidth * User management, with several [authentication backends](INSTALL.md#authentication-backend) +* Podcasts support * Playlists support * Lyrics support diff --git a/SUBSONIC.md b/SUBSONIC.md index 1781a5f9..c6a4fe76 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -62,6 +62,7 @@ The following extra fields are implemented: ## Supported extensions * [API Key Authentication](https://opensubsonic.netlify.app/docs/extensions/apikeyauth/) +* [getPodcastEpisode](https://opensubsonic.netlify.app/docs/extensions/getpodcastepisode/) * [HTTP form POST](https://opensubsonic.netlify.app/docs/extensions/formpost/) * [Transcode offset](https://opensubsonic.netlify.app/docs/extensions/transcodeoffset/) * [Song Lyrics](https://opensubsonic.netlify.app/docs/extensions/songlyrics/) diff --git a/conf/lms.conf b/conf/lms.conf index 124bde9f..231fb777 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -126,4 +126,13 @@ scanner-skip-duplicate-mbid = false; scanner-parser-read-style = "average"; # Number of threads to use for parallelized tasks (e.g., scanning file metadata). 0 means half the number of logical CPUs. -scanner-thread-count = 0; \ No newline at end of file +scanner-thread-count = 0; + +# Refresh period for podcast feeds in hours (must be greater or equal than 1) +podcast-refresh-period-hours = 2; + +# Automatically download new episodes +podcast-auto-download-episodes = true; + +# Max age in days for auto-downloaded episodes before deletion +podcast-auto-download-episodes-max-age-days = 30; diff --git a/src/libs/core/CMakeLists.txt b/src/libs/core/CMakeLists.txt index 06ae031b..a1ef7c32 100644 --- a/src/libs/core/CMakeLists.txt +++ b/src/libs/core/CMakeLists.txt @@ -46,6 +46,7 @@ target_include_directories(lmscore PRIVATE target_link_libraries(lmscore PRIVATE PkgConfig::Config++ PkgConfig::Archive + OpenSSL::Crypto ) target_link_libraries(lmscore PUBLIC diff --git a/src/libs/core/impl/Logger.cpp b/src/libs/core/impl/Logger.cpp index 3be471c5..6173858b 100644 --- a/src/libs/core/impl/Logger.cpp +++ b/src/libs/core/impl/Logger.cpp @@ -59,6 +59,8 @@ namespace lms::core::logging return "MAIN"; case Module::METADATA: return "METADATA"; + case Module::PODCAST: + return "PODCAST"; case Module::REMOTE: return "REMOTE"; case Module::SCROBBLING: diff --git a/src/libs/core/impl/MimeTypes.cpp b/src/libs/core/impl/MimeTypes.cpp index 337800ac..1c8e5688 100644 --- a/src/libs/core/impl/MimeTypes.cpp +++ b/src/libs/core/impl/MimeTypes.cpp @@ -67,6 +67,7 @@ namespace lms::core { ".jpg", "image/jpeg" }, { ".jpeg", "image/jpeg" }, { ".png", "image/png" }, + { ".svg", "image/svg+xml" }, { ".webp", "image/webp" }, }; diff --git a/src/libs/core/impl/Path.cpp b/src/libs/core/impl/Path.cpp index d17c5bc3..886cc3dc 100644 --- a/src/libs/core/impl/Path.cpp +++ b/src/libs/core/impl/Path.cpp @@ -19,22 +19,15 @@ #include "core/Path.hpp" +#include #include +#include #include -#include "core/ILogger.hpp" #include "core/String.hpp" namespace lms::core::pathUtils { - bool ensureDirectory(const std::filesystem::path& dir) - { - if (std::filesystem::exists(dir)) - return std::filesystem::is_directory(dir); - else - return std::filesystem::create_directory(dir); - } - bool hasFileAnyExtension(const std::filesystem::path& file, std::span supportedExtensions) { const std::filesystem::path extension{ stringUtils::stringToLower(file.extension().c_str()) }; diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index d976d26d..64650c77 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -20,6 +20,8 @@ #include "core/String.hpp" #include +#include +#include #include #include #include @@ -133,6 +135,64 @@ namespace lms::core::stringUtils res.push_back(str.substr(currentPos)); return res; } + + static std::optional getRFC822ZoneOffset(std::string_view zoneStr) + { + if (zoneStr == "UT" || zoneStr == "GMT" || zoneStr == "Z") return std::chrono::hours{ 0 }; + if (zoneStr == "EST") return -std::chrono::hours{ 5 }; + if (zoneStr == "EDT") return -std::chrono::hours{ 4 }; + if (zoneStr == "CST") return -std::chrono::hours{ 6 }; + if (zoneStr == "CDT") return -std::chrono::hours{ 5 }; + if (zoneStr == "MST") return -std::chrono::hours{ 7 }; + if (zoneStr == "MDT") return -std::chrono::hours{ 6 }; + if (zoneStr == "PST") return -std::chrono::hours{ 8 }; + if (zoneStr == "PDT") return -std::chrono::hours{ 7 }; + if (zoneStr.size() == 1) + { + const char c{ zoneStr[0] }; + if (c == 'A') return -std::chrono::hours{ 1 }; + if (c == 'M') return -std::chrono::hours{ 12 }; + if (c == 'N') return std::chrono::hours{ 1 }; + if (c == 'Y') return std::chrono::hours{ 12 }; + + return std::nullopt; + } + + // (+/-)HHMM + if (zoneStr[0] != '+' && zoneStr[0] != '-') + return std::nullopt; + + if (zoneStr.size() != 5) + return std::nullopt; + + if (!std::all_of(std::cbegin(zoneStr) + 1, std::cend(zoneStr), [](char c) { return std::isdigit(c); })) + return std::nullopt; + + int hours{}; + const auto [p, ec]{ std::from_chars(zoneStr.data() + 1, zoneStr.data() + 3, hours) }; + if (ec != std::errc()) + return std::nullopt; + + int minutes{}; + const auto [p2, ec2] = std::from_chars(zoneStr.data() + 3, zoneStr.data() + 5, minutes); + if (ec2 != std::errc()) + return std::nullopt; + + std::chrono::minutes res{ std::chrono::hours{ hours } + std::chrono::minutes{ minutes } }; + if (zoneStr[0] == '-') + res = -res; + return res; + } + + std::optional getRFC822Month(std::string_view monthStr) + { + static const std::array months{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + const std::string_view* str{ std::find(std::cbegin(months), std::cend(months), monthStr) }; + if (str == std::cend(months)) + return {}; + + return std::distance(std::cbegin(months), str) + 1; + } } // namespace details template<> @@ -435,9 +495,9 @@ namespace lms::core::stringUtils return str.substr(str.length() - ending.length()) == ending; } - std::optional stringFromHex(const std::string& str) + std::optional stringFromHex(std::string_view str) { - static const char lut[]{ "0123456789ABCDEF" }; + constexpr char lut[]{ "0123456789ABCDEF" }; if (str.length() % 2 != 0) return std::nullopt; @@ -445,13 +505,13 @@ namespace lms::core::stringUtils std::string res; res.reserve(str.length() / 2); - auto it{ std::cbegin(str) }; + const char* it{ std::cbegin(str) }; while (it != std::cend(str)) { unsigned val{}; - auto itHigh{ std::lower_bound(std::cbegin(lut), std::cend(lut), std::toupper(*(it++))) }; - auto itLow{ std::lower_bound(std::cbegin(lut), std::cend(lut), std::toupper(*(it++))) }; + const char* itHigh{ std::lower_bound(std::cbegin(lut), std::cend(lut), std::toupper(*(it++))) }; + const char* itLow{ std::lower_bound(std::cbegin(lut), std::cend(lut), std::toupper(*(it++))) }; if (itHigh == std::cend(lut) || itLow == std::cend(lut)) return {}; @@ -465,6 +525,21 @@ namespace lms::core::stringUtils return res; } + std::string toHexString(std::string_view str) + { + constexpr char lut[]{ "0123456789ABCDEF" }; + + std::string res; + + for (char c : str) + { + res.push_back(lut[(c >> 4) & 0xF]); + res.push_back(lut[c & 0xF]); + } + + return res; + } + std::string toISO8601String(const Wt::WDateTime& dateTime) { if (dateTime.isValid()) @@ -496,6 +571,47 @@ namespace lms::core::stringUtils return Wt::WDateTime::fromString(Wt::WString{ std::string{ dateTime } }, "yyyy-MM-ddThh:mm:ss.zzz"); } + Wt::WDateTime fromRFC822String(std::string_view dateTime) + { + // Expect something like "[Sun,] 6 Nov 1994 08:49[:37] GMT" + if (dateTime.size() > 4 && dateTime[3] == ',') + dateTime.remove_prefix(5); + + // Extract parts + const std::vector subParts{ splitString(dateTime, ' ') }; + if (subParts.size() != 5) + return {}; + + const std::string_view dayStr{ subParts[0] }; + const std::string_view monthStr{ subParts[1] }; + const std::string_view yearStr{ subParts[2] }; + std::string timeStr{ subParts[3] }; + const std::string_view zoneStr{ subParts[4] }; + if (std::count(std::cbegin(timeStr), std::cend(timeStr), ':') == 1) + timeStr += ":00"; + + // Normalize zone + const std::optional offset{ details::getRFC822ZoneOffset(zoneStr) }; + if (!offset) + return {}; + + std::optional month{ details::getRFC822Month(monthStr) }; + if (!month) + return {}; + + std::string datetimeStr; + datetimeStr = dayStr; + datetimeStr += " "; + datetimeStr += std::to_string(month.value()); + datetimeStr += " "; + datetimeStr += yearStr; + datetimeStr += " "; + datetimeStr += timeStr; + + const Wt::WDateTime res{ Wt::WDateTime::fromString(Wt::WString{ datetimeStr }, "d M yyyy HH:mm:ss") }; + return res.addSecs(static_cast(std::chrono::duration_cast(offset.value()).count())); + } + std::string formatTimestamp(std::chrono::milliseconds timestamp) { using namespace std::chrono; diff --git a/src/libs/core/impl/http/Client.cpp b/src/libs/core/impl/http/Client.cpp index a6040122..abcd6e92 100644 --- a/src/libs/core/impl/http/Client.cpp +++ b/src/libs/core/impl/http/Client.cpp @@ -36,4 +36,9 @@ namespace lms::core::http { _sendQueue.sendRequest(std::make_unique(std::move(POSTParams))); } + + void Client::abortAllRequests() + { + _sendQueue.abortAllRequests(); + } } // namespace lms::core::http \ No newline at end of file diff --git a/src/libs/core/impl/http/Client.hpp b/src/libs/core/impl/http/Client.hpp index c7cf32e1..7fca9022 100644 --- a/src/libs/core/impl/http/Client.hpp +++ b/src/libs/core/impl/http/Client.hpp @@ -40,6 +40,7 @@ namespace lms::core::http private: void sendGETRequest(ClientGETRequestParameters&& request) override; void sendPOSTRequest(ClientPOSTRequestParameters&& request) override; + void abortAllRequests() override; SendQueue _sendQueue; }; diff --git a/src/libs/core/impl/http/SendQueue.cpp b/src/libs/core/impl/http/SendQueue.cpp index 3becfb26..0278fc9c 100644 --- a/src/libs/core/impl/http/SendQueue.cpp +++ b/src/libs/core/impl/http/SendQueue.cpp @@ -19,16 +19,19 @@ #include "SendQueue.hpp" +#include + #include #include #include +#include #include "core/Exception.hpp" #include "core/ILogger.hpp" #include "core/ITraceLogger.hpp" #include "core/String.hpp" -#define LOG(sev, message) LMS_LOG(SCROBBLING, sev, "[Http SendQueue] - " << message) +#define LOG(sev, message) LMS_LOG(HTTP, sev, "[Http SendQueue] - " << message) namespace lms::core::stringUtils { @@ -63,7 +66,21 @@ namespace lms::core::http SendQueue::SendQueue(boost::asio::io_context& ioContext, std::string_view baseUrl) : _ioContext{ ioContext } , _baseUrl{ baseUrl } + , _abortAllRequests{ false } + , _state{ State::Idle } + , _client{ _ioContext } { + _client.setFollowRedirect(true); + _client.setTimeout(std::chrono::seconds{ 5 }); + + // not very efficient (response bodies are copied for each callback), but Wt's code already makes copies anyway + + _client.bodyDataReceived().connect([this](const std::string& data) { + boost::asio::post(boost::asio::bind_executor(_strand, [this, data] { + onClientBodyDataReceived(data); + })); + }); + _client.done().connect([this](Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg) { boost::asio::post(boost::asio::bind_executor(_strand, [this, ec, msg = std::move(msg)] { onClientDone(ec, msg); @@ -73,12 +90,60 @@ namespace lms::core::http SendQueue::~SendQueue() { - _client.abort(); + abortAllRequests(); + } + + void SendQueue::abortAllRequests() + { + LOG(DEBUG, "Aborting all requests..."); + + assert(!_abortAllRequests); + _abortAllRequests = true; + + std::latch abortLatch{ 1 }; + + boost::asio::post(boost::asio::bind_executor(_strand, [this, &abortLatch] { + for (auto& [prio, requests] : _sendQueue) + { + while (!requests.empty()) + { + std::unique_ptr request{ std::move(requests.front()) }; + requests.pop_front(); + if (request->getParameters().onAbortFunc) + request->getParameters().onAbortFunc(); + } + } + + if (_state == State::Throttled) + _throttleTimer.cancel(); + else if (_state == State::Sending) + _client.abort(); + + abortLatch.count_down(); + })); + + abortLatch.wait(); + + while (_state != State::Idle) + std::this_thread::yield(); + + _abortAllRequests = false; + + LOG(DEBUG, "All requests aborted!"); } void SendQueue::sendRequest(std::unique_ptr request) { - boost::asio::dispatch(_strand, [this, request = std::move(request)]() mutable { + boost::asio::post(_strand, [this, request = std::move(request)]() mutable { + if (_abortAllRequests) + { + LOG(DEBUG, "Not posting request because abortAllRequests() in progress"); + if (request->getParameters().onAbortFunc) + request->getParameters().onAbortFunc(); + + return; + } + _sendQueue[request->getParameters().priority].emplace_back(std::move(request)); if (_state == State::Idle) @@ -88,7 +153,7 @@ namespace lms::core::http void SendQueue::sendNextQueuedRequest() { - assert(_state == State::Idle); + assert(_strand.running_in_this_thread()); assert(!_currentRequest); for (auto& [prio, requests] : _sendQueue) @@ -100,21 +165,31 @@ namespace lms::core::http requests.pop_front(); if (!sendRequest(*request)) + { + if (request->getParameters().onFailureFunc) + request->getParameters().onFailureFunc(); continue; + } - _state = State::Sending; + setState(State::Sending); _currentRequest = std::move(request); return; } } + + setState(State::Idle); } bool SendQueue::sendRequest(const ClientRequest& request) { + assert(_strand.running_in_this_thread()); + LMS_SCOPED_TRACE_DETAILED("SendQueue", "SendRequest"); - std::string url{ _baseUrl + request.getParameters().relativeUrl }; - LOG(DEBUG, "Sending request to url '" << url << "'"); + const std::string url{ _baseUrl + request.getParameters().relativeUrl }; + LOG(DEBUG, "Sending " << (request.getType() == ClientRequest::Type::GET ? "GET" : "POST") << " request to url '" << url << "'"); + + _client.setMaximumResponseSize(request.getParameters().onChunkReceived ? 0 : request.getParameters().responseBufferSize); bool res{}; switch (request.getType()) @@ -134,29 +209,50 @@ namespace lms::core::http return res; } + void SendQueue::onClientBodyDataReceived(const std::string& data) + { + assert(_strand.running_in_this_thread()); + assert(_currentRequest); + + if (_currentRequest->getParameters().onChunkReceived) + { + const auto byteSpan{ std::as_bytes(std::span{ data.data(), data.size() }) }; + if (_currentRequest->getParameters().onChunkReceived(byteSpan) == ClientRequestParameters::ChunckReceivedResult::Abort) + _client.abort(); + } + } + void SendQueue::onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg) { LMS_SCOPED_TRACE_DETAILED("SendQueue", "OnClientDone"); - if (ec == boost::asio::error::operation_aborted) - { - LOG(DEBUG, "Client aborted"); - return; - } - assert(_currentRequest); - _state = State::Idle; - LOG(DEBUG, "Client done. status = " << msg.status()); - if (ec) + LOG(DEBUG, "Client done. ec = " << ec.category().name() << " - " << ec.message() << " (" << ec.value() << "), status = " << msg.status()); + + if (_abortAllRequests || ec == boost::asio::error::operation_aborted) + onClientAborted(std::move(_currentRequest)); + else if (ec && (ec != boost::asio::ssl::error::stream_truncated)) onClientDoneError(std::move(_currentRequest), ec); else onClientDoneSuccess(std::move(_currentRequest), msg); } + void SendQueue::onClientAborted(std::unique_ptr request) + { + assert(_strand.running_in_this_thread()); + + if (request->getParameters().onAbortFunc) + request->getParameters().onAbortFunc(); + + sendNextQueuedRequest(); + } + void SendQueue::onClientDoneError(std::unique_ptr request, Wt::AsioWrapper::error_code ec) { - LOG(ERROR, "Retry " << request->retryCount << ", client error: '" << ec.message() << "'"); + assert(_strand.running_in_this_thread()); + + LOG(WARNING, "Retry " << request->retryCount << ", client error: '" << ec.message() << "'"); // may be a network error, try again later throttle(_defaultRetryWaitDuration); @@ -196,42 +292,48 @@ namespace lms::core::http if (msg.status() == 200) { if (requestParameters.onSuccessFunc) - requestParameters.onSuccessFunc(msg.body()); + requestParameters.onSuccessFunc(msg); } else { - LOG(ERROR, "Send error: '" << msg.body() << "'"); + LOG(ERROR, "Send error, status = " << msg.status() << ", body = '" << msg.body() << "'"); if (requestParameters.onFailureFunc) requestParameters.onFailureFunc(); } } - if (_state == State::Idle) + if (_state != State::Throttled) sendNextQueuedRequest(); } void SendQueue::throttle(std::chrono::seconds requestedDuration) { - assert(_state == State::Idle); - const std::chrono::seconds duration{ clamp(requestedDuration, _minRetryWaitDuration, _maxRetryWaitDuration) }; LOG(DEBUG, "Throttling for " << duration.count() << " seconds"); _throttleTimer.expires_after(duration); _throttleTimer.async_wait([this](const boost::system::error_code& ec) { if (ec == boost::asio::error::operation_aborted) - { LOG(DEBUG, "Throttle aborted"); - return; - } else if (ec) - { throw LmsException{ "Throttle timer failure: " + std::string{ ec.message() } }; - } - _state = State::Idle; - sendNextQueuedRequest(); + setState(State::Idle); + if (!ec) + sendNextQueuedRequest(); }); - _state = State::Throttled; + + setState(State::Throttled); + } + + void SendQueue::setState(State state) + { + assert(_strand.running_in_this_thread()); + if (_state != state) + { + LOG(DEBUG, "Changing state to " << (state == State::Idle ? "Idle" : state == State::Sending ? "Sending" : + "Throttled")); + _state = state; + } } } // namespace lms::core::http diff --git a/src/libs/core/impl/http/SendQueue.hpp b/src/libs/core/impl/http/SendQueue.hpp index 8da04d9c..8893a774 100644 --- a/src/libs/core/impl/http/SendQueue.hpp +++ b/src/libs/core/impl/http/SendQueue.hpp @@ -19,9 +19,9 @@ #pragma once +#include #include #include -#include #include #include @@ -44,10 +44,13 @@ namespace lms::core::http SendQueue& operator=(const SendQueue&&) = delete; void sendRequest(std::unique_ptr request); + void abortAllRequests(); private: void sendNextQueuedRequest(); bool sendRequest(const ClientRequest& request); + void onClientBodyDataReceived(const std::string& data); + void onClientAborted(std::unique_ptr request); void onClientDone(Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg); void onClientDoneError(std::unique_ptr request, Wt::AsioWrapper::error_code ec); void onClientDoneSuccess(std::unique_ptr request, const Wt::Http::Message& msg); @@ -59,9 +62,9 @@ namespace lms::core::http const std::chrono::seconds _maxRetryWaitDuration{ 300 }; boost::asio::io_context& _ioContext; - boost::asio::io_context::strand _strand{ _ioContext }; + boost::asio::io_context::strand _strand{ _ioContext }; // protect _state, _sendQueue and _currentRequest boost::asio::steady_timer _throttleTimer{ _ioContext }; - std::string _baseUrl; + const std::string _baseUrl; enum class State { @@ -69,10 +72,11 @@ namespace lms::core::http Throttled, Sending, }; - State _state{ State::Idle }; - Wt::Http::Client _client{ _ioContext }; + void setState(State state); + std::atomic _abortAllRequests; + State _state; + Wt::Http::Client _client; std::map>> _sendQueue; std::unique_ptr _currentRequest; }; - } // namespace lms::core::http \ No newline at end of file diff --git a/src/libs/core/include/core/ILogger.hpp b/src/libs/core/include/core/ILogger.hpp index 89ac41c3..3c300e7d 100644 --- a/src/libs/core/include/core/ILogger.hpp +++ b/src/libs/core/include/core/ILogger.hpp @@ -51,6 +51,7 @@ namespace lms::core::logging HTTP, MAIN, METADATA, + PODCAST, REMOTE, SCROBBLING, SERVICE, diff --git a/src/libs/core/include/core/Path.hpp b/src/libs/core/include/core/Path.hpp index 38ab0790..e748cb36 100644 --- a/src/libs/core/include/core/Path.hpp +++ b/src/libs/core/include/core/Path.hpp @@ -24,14 +24,8 @@ #include #include -#include - namespace lms::core::pathUtils { - // Make sure the given path is a directory - // Create it if needed - bool ensureDirectory(const std::filesystem::path& dir); - // Check if file's extension is one of provided extensions bool hasFileAnyExtension(const std::filesystem::path& file, std::span extensions); diff --git a/src/libs/core/include/core/SizeLiterals.hpp b/src/libs/core/include/core/SizeLiterals.hpp new file mode 100644 index 00000000..94e2b524 --- /dev/null +++ b/src/libs/core/include/core/SizeLiterals.hpp @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2025 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 + +namespace lms::core::literals +{ + constexpr std::size_t operator""_KiB(unsigned long long int x) + { + return 1024ULL * x; + } + + constexpr std::size_t operator""_MiB(unsigned long long int x) + { + return 1024_KiB * x; + } + + constexpr std::size_t operator""_GiB(unsigned long long int x) + { + return 1024_MiB * x; + } + + constexpr std::size_t operator""_TiB(unsigned long long int x) + { + return 1024_GiB * x; + } + + constexpr std::size_t operator""_PiB(unsigned long long int x) + { + return 1024_TiB * x; + } +} // namespace lms::core::literals \ No newline at end of file diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp index b65df63a..f096c8e2 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -110,12 +110,14 @@ namespace lms::core::stringUtils [[nodiscard]] bool stringEndsWith(std::string_view str, std::string_view ending); - [[nodiscard]] std::optional stringFromHex(const std::string& str); + [[nodiscard]] std::optional stringFromHex(std::string_view str); + [[nodiscard]] std::string toHexString(std::string_view str); [[nodiscard]] std::string toISO8601String(const Wt::WDateTime& dateTime); [[nodiscard]] std::string toISO8601String(const Wt::WDate& date); [[nodiscard]] Wt::WDateTime fromISO8601String(std::string_view dateTime); + [[nodiscard]] Wt::WDateTime fromRFC822String(std::string_view dateTime); // to "[minutes:seconds.milliseconds]" std::string formatTimestamp(std::chrono::milliseconds timestamp); diff --git a/src/libs/core/include/core/http/ClientRequestParameters.hpp b/src/libs/core/include/core/http/ClientRequestParameters.hpp index 9e7e49e9..a9e7d844 100644 --- a/src/libs/core/include/core/http/ClientRequestParameters.hpp +++ b/src/libs/core/include/core/http/ClientRequestParameters.hpp @@ -19,8 +19,9 @@ #pragma once +#include #include -#include +#include #include #include @@ -37,13 +38,28 @@ namespace lms::core::http }; Priority priority{ Priority::Normal }; - std::string relativeUrl; // relative to baseUrl used by the client + std::string relativeUrl; // relative to baseUrl used by the client + std::size_t responseBufferSize{ 10 * 1024 * 1024 }; // only used if onChunkReceived is not set - using OnSuccessFunc = std::function; + // If `onChunkReceived` is set, the response will be streamed in chunks. + // In that case, `onSuccessFunc` is still called at the end (with an empty msgBody). + // If `onChunkReceived` is not set, the response will be fully buffered and passed to `onSuccessFunc`. + enum class ChunckReceivedResult + { + Continue, + Abort, + }; + using OnChunkReceived = std::function chunk)>; // return false to stop (onFailureFunc callback will be called) + OnChunkReceived onChunkReceived; + + using OnSuccessFunc = std::function; OnSuccessFunc onSuccessFunc; using OnFailureFunc = std::function; OnFailureFunc onFailureFunc; + + using OnAbortFunc = std::function; + OnAbortFunc onAbortFunc; }; struct ClientGETRequestParameters final : public ClientRequestParameters diff --git a/src/libs/core/include/core/http/IClient.hpp b/src/libs/core/include/core/http/IClient.hpp index f32cc2ab..ca635da4 100644 --- a/src/libs/core/include/core/http/IClient.hpp +++ b/src/libs/core/include/core/http/IClient.hpp @@ -27,6 +27,8 @@ namespace lms::core::http { + // Very simple http client, will handle all requests sequentially. + // User callbacks are dispatched within a strand. class IClient { public: @@ -34,6 +36,8 @@ namespace lms::core::http virtual void sendGETRequest(ClientGETRequestParameters&& request) = 0; virtual void sendPOSTRequest(ClientPOSTRequestParameters&& request) = 0; + + virtual void abortAllRequests() = 0; }; std::unique_ptr createClient(boost::asio::io_context& ioContext, std::string_view baseUrl); diff --git a/src/libs/core/test/String.cpp b/src/libs/core/test/String.cpp index 51075fc8..12253fd6 100644 --- a/src/libs/core/test/String.cpp +++ b/src/libs/core/test/String.cpp @@ -338,6 +338,20 @@ namespace lms::core::stringUtils::tests EXPECT_EQ(fromISO8601String(""), Wt::WDateTime{}); } + TEST(Stringutils, DateTimeFromRFC822String) + { + EXPECT_EQ(fromRFC822String(""), Wt::WDateTime{}); + EXPECT_EQ(fromRFC822String("Mon, 3 Jan 2020 09:08:11 UT"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 9, 8, 11, 0 } })); + EXPECT_EQ(fromRFC822String("3 Jan 2020 09:08:11 UT"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 9, 8, 11, 0 } })); + EXPECT_EQ(fromRFC822String("3 Jan 2020 09:08:11 +0200"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 11, 8, 11, 0 } })); + EXPECT_EQ(fromRFC822String("3 Jan 2020 09:08 +0200"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 11, 8, 00, 0 } })); + EXPECT_EQ(fromRFC822String("3 Jan 2020 09:08:11 -0200"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 7, 8, 11, 0 } })); + EXPECT_EQ(fromRFC822String("3 Jan 2020 01:08:11 -0200"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 02 }, Wt::WTime{ 23, 8, 11, 0 } })); + EXPECT_EQ(fromRFC822String("3 Jan 2020 01:08:11 -0230"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 02 }, Wt::WTime{ 22, 38, 11, 0 } })); + EXPECT_EQ(fromRFC822String("3 Jan 2020 10:08:11 CST"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 04, 8, 11, 0 } })); + EXPECT_EQ(fromRFC822String("Sat, 09 Aug 2025 21:34:32 +0200"), (Wt::WDateTime{ Wt::WDate{ 2025, 8, 9 }, Wt::WTime{ 23, 34, 32 } })); + } + TEST(StringUtils, stringEndsWith) { EXPECT_TRUE(stringEndsWith("FooBar", "Bar")); @@ -361,4 +375,20 @@ namespace lms::core::stringUtils::tests EXPECT_TRUE(stringCaseInsensitiveContains("", "")); EXPECT_FALSE(stringCaseInsensitiveContains("", "Foo")); } + + TEST(StringUtils, toHexString) + { + EXPECT_EQ(toHexString(""), ""); + EXPECT_EQ(toHexString("123"), "313233"); + EXPECT_EQ(toHexString("1234"), "31323334"); + EXPECT_EQ(toHexString("12345"), "3132333435"); + EXPECT_EQ(toHexString("Test"), "54657374"); + + // test back stringFromHex + EXPECT_EQ(stringFromHex(""), ""); + EXPECT_EQ(stringFromHex("313233"), "123"); + EXPECT_EQ(stringFromHex("31323334"), "1234"); + EXPECT_EQ(stringFromHex("3132333435"), "12345"); + EXPECT_EQ(stringFromHex("54657374"), "Test"); + } } // namespace lms::core::stringUtils::tests \ No newline at end of file diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index 5a57c591..4e79b43a 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -11,6 +11,8 @@ add_library(lmsdatabase STATIC impl/objects/Medium.cpp impl/objects/PlayListFile.cpp impl/objects/PlayQueue.cpp + impl/objects/Podcast.cpp + impl/objects/PodcastEpisode.cpp impl/objects/TrackArtistLink.cpp impl/objects/TrackFeatures.cpp impl/objects/TrackList.cpp diff --git a/src/libs/database/impl/Db.cpp b/src/libs/database/impl/Db.cpp index 057974ab..550f6d4a 100644 --- a/src/libs/database/impl/Db.cpp +++ b/src/libs/database/impl/Db.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include "core/IConfig.hpp" @@ -193,6 +194,8 @@ namespace lms::db // Session living class handling the database and the login Db::Db(const std::filesystem::path& dbPath, std::size_t connectionCount) { + Wt::Dbo::logToWt(); + std::string checkType{ "quick" }; LMS_LOG(DB, INFO, "Creating connection pool on file " << dbPath); diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index 79d538bb..51b39d64 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -34,7 +34,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 99 }; + static constexpr Version LMS_DATABASE_VERSION{ 100 }; } VersionInfo::VersionInfo() @@ -1528,6 +1528,59 @@ FROM track)"); utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1"); } + void migrateFromV99(Session& session) + { + // Podcast support + + utils::executeCommand(*session.getDboSession(), "ALTER TABLE image ADD COLUMN mime_type TEXT NOT NULL DEFAULT ''"); + + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "podcast" ( + "id" integer primary key autoincrement, + "version" integer not null, + "url" text not null, + "delete_requested" boolean not null, + "title" text not null, + "link" text not null, + "description" text not null, + "language" text not null, + "copyright" text not null, + "last_build_date" text, + "author" text not null, + "category" text not null, + "explicit" boolean not null, + "image_url" text not null, + "owner_email" text not null, + "owner_name" text not null, + "subtitle" text not null, + "summary" text not null, + "artwork_id" bigint, + constraint "fk_podcast_artwork" foreign key ("artwork_id") references "artwork" ("id") on delete set null deferrable initially deferred))"); + + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "podcast_episode" ( + "id" integer primary key autoincrement, + "version" integer not null, + "manual_download_state" integer not null, + "audio_relative_file_path" text not null, + "title" text not null, + "link" text not null, + "description" text not null, + "author" text not null, + "category" text not null, + "enclosure_url" text not null, + "enclosure_content_type" text not null, + "enclosure_size" integer not null, + "pub_date" text, + "image_url" text not null, + "subtitle" text not null, + "summary" text not null, + "explicit" boolean not null, + "duration" integer, + "artwork_id" bigint, + "podcast_id" bigint, + constraint "fk_podcast_episode_artwork" foreign key ("artwork_id") references "artwork" ("id") on delete set null deferrable initially deferred, + constraint "fk_podcast_episode_podcast" foreign key ("podcast_id") references "podcast" ("id") on delete cascade deferrable initially deferred))"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -1603,6 +1656,7 @@ FROM track)"); { 96, migrateFromV96 }, { 97, migrateFromV97 }, { 98, migrateFromV98 }, + { 99, migrateFromV99 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/Object.cpp b/src/libs/database/impl/Object.cpp index 18e56f77..eadbdace 100644 --- a/src/libs/database/impl/Object.cpp +++ b/src/libs/database/impl/Object.cpp @@ -23,7 +23,7 @@ namespace lms::db { - void ObjectPtrBase::checkWriteTransaction(Wt::Dbo::Session& session) + void ObjectPtrBase::checkWriteTransaction([[maybe_unused]] Wt::Dbo::Session& session) { #if LMS_CHECK_TRANSACTION_ACCESSES TransactionChecker::checkWriteTransaction(session); diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index f467b01a..48127a71 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -36,6 +36,8 @@ #include "database/objects/Medium.hpp" #include "database/objects/PlayListFile.hpp" #include "database/objects/PlayQueue.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" #include "database/objects/RatedArtist.hpp" #include "database/objects/RatedRelease.hpp" #include "database/objects/RatedTrack.hpp" @@ -86,6 +88,8 @@ namespace lms::db _session.mapClass("medium"); _session.mapClass("playlist_file"); _session.mapClass("playqueue"); + _session.mapClass("podcast"); + _session.mapClass("podcast_episode"); _session.mapClass("rated_artist"); _session.mapClass("rated_release"); _session.mapClass("rated_track"); diff --git a/src/libs/database/impl/objects/Artwork.cpp b/src/libs/database/impl/objects/Artwork.cpp index e91b7d84..2dda27ee 100644 --- a/src/libs/database/impl/objects/Artwork.cpp +++ b/src/libs/database/impl/objects/Artwork.cpp @@ -80,6 +80,18 @@ namespace lms::db return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT a FROM artwork a").where("a.image_id = ?").bind(id)); } + Artwork::UnderlyingId Artwork::getUnderlyingId() const + { + Artwork::UnderlyingId res; + + if (const TrackEmbeddedImageId embeddedImageId{ _trackEmbeddedImage.id() }; embeddedImageId.isValid()) + res = embeddedImageId; + else if (const ImageId imageId{ _image.id() }; imageId.isValid()) + res = imageId; + + return res; + } + Wt::WDateTime Artwork::getLastWrittenTime() const { auto query{ session()->query("SELECT MAX(COALESCE(image.file_last_write, track.file_last_write)) AS last_written_datetime FROM artwork") }; @@ -104,4 +116,14 @@ namespace lms::db return utils::fetchQuerySingleResult(query); } + + ObjectPtr Artwork::getImage() const + { + return _image; + } + + ImageId Artwork::getImageId() const + { + return _image.id(); + } } // namespace lms::db diff --git a/src/libs/database/impl/objects/Podcast.cpp b/src/libs/database/impl/objects/Podcast.cpp new file mode 100644 index 00000000..3240df82 --- /dev/null +++ b/src/libs/database/impl/objects/Podcast.cpp @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2020 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 "database/objects/Podcast.hpp" + +#include +#include + +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/PodcastEpisode.hpp" + +#include "Utils.hpp" +#include "traits/IdTypeTraits.hpp" +#include "traits/StringViewTraits.hpp" + +DBO_INSTANTIATE_TEMPLATES(lms::db::Podcast) + +namespace lms::db +{ + Podcast::Podcast(std::string_view url) + : _url{ url } + { + } + + Podcast::pointer Podcast::create(Session& session, std::string_view url) + { + return session.getDboSession()->add(std::unique_ptr{ new Podcast{ url } }); + } + + std::size_t Podcast::getCount(Session& session) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM podcast")); + } + + Podcast::pointer Podcast::find(Session& session, PodcastId id) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT p from podcast p").where("p.id = ?").bind(id)); + } + + Podcast::pointer Podcast::find(Session& session, std::string_view url) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT p from podcast p").where("p.url = ?").bind(url)); + } + + void Podcast::find(Session& session, std::function func) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->query>("SELECT p from podcast p") }; + utils::forEachQueryResult(query, func); + } + + ObjectPtr Podcast::getArtwork() const + { + return _artwork; + } + + ArtworkId Podcast::getArtworkId() const + { + return _artwork.id(); + } + + void Podcast::setArtwork(ObjectPtr artwork) + { + _artwork = getDboPtr(artwork); + } +} // namespace lms::db diff --git a/src/libs/database/impl/objects/PodcastEpisode.cpp b/src/libs/database/impl/objects/PodcastEpisode.cpp new file mode 100644 index 00000000..a0d52d91 --- /dev/null +++ b/src/libs/database/impl/objects/PodcastEpisode.cpp @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2020 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 "database/objects/PodcastEpisode.hpp" + +#include +#include + +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Podcast.hpp" + +#include "Utils.hpp" +#include "traits/IdTypeTraits.hpp" +#include "traits/PathTraits.hpp" + +DBO_INSTANTIATE_TEMPLATES(lms::db::PodcastEpisode) + +namespace lms::db +{ + namespace + { + Wt::Dbo::Query> createQuery(Session& session, const PodcastEpisode::FindParameters& params) + { + auto query{ session.getDboSession()->query>("SELECT p_e from podcast_episode p_e") }; + + if (params.manualDownloadState.has_value()) + query.where("p_e.manual_download_state = ?").bind(static_cast(params.manualDownloadState.value())); + + if (params.podcast.isValid()) + query.where("p_e.podcast_id = ?").bind(params.podcast); + + switch (params.sortMode) + { + case PodcastEpisodeSortMode::None: + break; + case PodcastEpisodeSortMode::PubDateAsc: + query.orderBy("p_e.pub_date ASC"); + break; + case PodcastEpisodeSortMode::PubDateDesc: + query.orderBy("p_e.pub_date DESC"); + break; + } + return query; + } + } // namespace + + PodcastEpisode::PodcastEpisode(ObjectPtr podcast) + : _podcast{ getDboPtr(podcast) } + { + } + + PodcastEpisode::pointer PodcastEpisode::create(Session& session, ObjectPtr podcast) + { + return session.getDboSession()->add(std::unique_ptr{ new PodcastEpisode{ podcast } }); + } + + std::size_t PodcastEpisode::getCount(Session& session) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM podcast_episode")); + } + + PodcastEpisode::pointer PodcastEpisode::find(Session& session, PodcastEpisodeId id) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT p_e from podcast_episode p_e").where("p_e.id = ?").bind(id)); + } + + PodcastEpisode::pointer PodcastEpisode::findNewtestEpisode(Session& session, PodcastId podcastId) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT p_e from podcast_episode p_e").where("p_e.podcast_id = ?").bind(podcastId).orderBy("p_e.pub_date DESC").limit(1)); + } + + void PodcastEpisode::find(Session& session, const FindParameters& params, std::function func) + { + session.checkReadTransaction(); + + auto query{ createQuery(session, params) }; + utils::forEachQueryRangeResult(query, params.range, func); + } + + ObjectPtr PodcastEpisode::getArtwork() const + { + return _artwork; + } + + ArtworkId PodcastEpisode::getArtworkId() const + { + return _artwork.id(); + } + + void PodcastEpisode::setArtwork(ObjectPtr artwork) + { + _artwork = getDboPtr(artwork); + } +} // namespace lms::db diff --git a/src/libs/database/include/database/Session.hpp b/src/libs/database/include/database/Session.hpp index 90c0448c..a551b17d 100644 --- a/src/libs/database/include/database/Session.hpp +++ b/src/libs/database/include/database/Session.hpp @@ -82,7 +82,7 @@ namespace lms::db template void destroy(typename Object::IdType id) { - destroy(std::span{ &id, 1 }); + destroy(std::span{ &id, 1 }); } template diff --git a/src/libs/database/include/database/Types.hpp b/src/libs/database/include/database/Types.hpp index 34b13163..b5845738 100644 --- a/src/libs/database/include/database/Types.hpp +++ b/src/libs/database/include/database/Types.hpp @@ -163,6 +163,13 @@ namespace lms::db PositionAsc, }; + enum class PodcastEpisodeSortMode + { + None, + PubDateAsc, + PubDateDesc, + }; + enum class ReleaseSortMethod { None, diff --git a/src/libs/database/include/database/objects/Artwork.hpp b/src/libs/database/include/database/objects/Artwork.hpp index 50b7626a..284594f2 100644 --- a/src/libs/database/include/database/objects/Artwork.hpp +++ b/src/libs/database/include/database/objects/Artwork.hpp @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include @@ -47,10 +48,12 @@ namespace lms::db static pointer find(Session& session, ImageId id); // getters - TrackEmbeddedImageId getTrackEmbeddedImageId() const { return _trackEmbeddedImage.id(); } - ImageId getImageId() const { return _image.id(); } + using UnderlyingId = std::variant; + UnderlyingId getUnderlyingId() const; Wt::WDateTime getLastWrittenTime() const; std::filesystem::path getAbsoluteFilePath() const; + ObjectPtr getImage() const; + ImageId getImageId() const; template void persist(Action& a) diff --git a/src/libs/database/include/database/objects/Image.hpp b/src/libs/database/include/database/objects/Image.hpp index 8050c33c..fcbdd51d 100644 --- a/src/libs/database/include/database/objects/Image.hpp +++ b/src/libs/database/include/database/objects/Image.hpp @@ -85,6 +85,7 @@ namespace lms::db std::size_t getFileSize() const { return _fileSize; } std::size_t getWidth() const { return _width; } std::size_t getHeight() const { return _height; } + std::string_view getMimeType() const { return _mimeType; } // setters void setAbsoluteFilePath(const std::filesystem::path& p); @@ -92,6 +93,7 @@ namespace lms::db void setFileSize(std::size_t fileSize) { _fileSize = fileSize; } void setWidth(std::size_t width) { _width = width; } void setHeight(std::size_t height) { _height = height; } + void setMimeType(std::string_view mimeType) { _mimeType = mimeType; } void setDirectory(const ObjectPtr& directory) { _directory = getDboPtr(directory); } template @@ -104,6 +106,7 @@ namespace lms::db Wt::Dbo::field(a, _width, "width"); Wt::Dbo::field(a, _height, "height"); + Wt::Dbo::field(a, _mimeType, "mime_type"); Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade); } @@ -119,6 +122,7 @@ namespace lms::db int _fileSize{}; int _width{}; int _height{}; + std::string _mimeType; Wt::Dbo::ptr _directory; }; diff --git a/src/libs/database/include/database/objects/Podcast.hpp b/src/libs/database/include/database/objects/Podcast.hpp new file mode 100644 index 00000000..f3384b9f --- /dev/null +++ b/src/libs/database/include/database/objects/Podcast.hpp @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2025 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 +#include +#include + +#include +#include +#include + +#include "database/Object.hpp" +#include "database/objects/ArtworkId.hpp" +#include "database/objects/PodcastId.hpp" + +namespace lms::db +{ + class Artwork; + class PodcastEpisode; + class Session; + + class Podcast final : public Object + { + public: + static const std::size_t maxMediaLength{ 64 }; + + Podcast() = default; + static std::size_t getCount(Session& session); + static pointer find(Session& session, PodcastId id); + static pointer find(Session& session, std::string_view url); + static void find(Session& session, std::function func); + + // getters + std::string_view getUrl() const { return _url; } + + bool isDeleteRequested() const { return _deleteRequested; } + std::string_view getTitle() const { return _title; } + std::string_view getLink() const { return _link; } + std::string_view getDescription() const { return _description; } + std::string_view getLanguage() const { return _language; } + std::string_view getCopyright() const { return _copyright; } + Wt::WDateTime getLastBuildDate() const { return _lastBuildDate; } + std::string_view getAuthor() const { return _author; } + std::string_view getCategory() const { return _category; } + bool isExplicit() const { return _explicit; } + std::string_view getImageUrl() const { return _imageUrl; } + std::string_view getOwnerEmail() const { return _ownerEmail; } + std::string_view getOwnerName() const { return _ownerName; } + std::string_view getSubtitle() const { return _subtitle; } + std::string_view getSummary() const { return _summary; } + ObjectPtr getArtwork() const; + ArtworkId getArtworkId() const; + + // setters + void setUrl(std::string_view url) { _url = url; } + + void setDeleteRequested(bool deleteRequested) { _deleteRequested = deleteRequested; } + void setTitle(std::string_view title) { _title = title; } + void setLink(std::string_view link) { _link = link; } + void setDescription(std::string_view description) { _description = description; } + void setLanguage(std::string_view language) { _language = language; } + void setCopyright(std::string_view copyright) { _copyright = copyright; } + void setLastBuildDate(const Wt::WDateTime& lastBuildDate) { _lastBuildDate = lastBuildDate; } + void setAuthor(std::string_view author) { _author = author; } + void setCategory(std::string_view category) { _category = category; } + void setExplicit(bool explicit_) { _explicit = explicit_; } + void setImageUrl(std::string_view imageUrl) { _imageUrl = imageUrl; } + void setOwnerEmail(std::string_view ownerEmail) { _ownerEmail = ownerEmail; } + void setOwnerName(std::string_view ownerName) { _ownerName = ownerName; } + void setSubtitle(std::string_view subtitle) { _subtitle = subtitle; } + void setSummary(std::string_view summary) { _summary = summary; } + void setArtwork(ObjectPtr artwork); + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _url, "url"); + + Wt::Dbo::field(a, _deleteRequested, "delete_requested"); + Wt::Dbo::field(a, _title, "title"); + Wt::Dbo::field(a, _link, "link"); + Wt::Dbo::field(a, _description, "description"); + Wt::Dbo::field(a, _language, "language"); + Wt::Dbo::field(a, _copyright, "copyright"); + Wt::Dbo::field(a, _lastBuildDate, "last_build_date"); + + Wt::Dbo::field(a, _author, "author"); + Wt::Dbo::field(a, _category, "category"); + Wt::Dbo::field(a, _explicit, "explicit"); + Wt::Dbo::field(a, _imageUrl, "image_url"); + Wt::Dbo::field(a, _ownerEmail, "owner_email"); + Wt::Dbo::field(a, _ownerName, "owner_name"); + Wt::Dbo::field(a, _subtitle, "subtitle"); + Wt::Dbo::field(a, _summary, "summary"); + + Wt::Dbo::belongsTo(a, _artwork, "artwork", Wt::Dbo::OnDeleteSetNull); + Wt::Dbo::hasMany(a, _episodes, Wt::Dbo::ManyToOne, "podcast"); + } + + private: + friend class Session; + Podcast(std::string_view url); + static pointer create(Session& session, std::string_view url); + + std::string _url; + + bool _deleteRequested{}; + std::string _title; + std::string _link; + std::string _description; + std::string _language; + std::string _copyright; + Wt::WDateTime _lastBuildDate; + + // itunes fields + std::string _author; + std::string _category; + bool _explicit{}; + std::string _imageUrl; + std::string _ownerEmail; + std::string _ownerName; + std::string _subtitle; + std::string _summary; + + Wt::Dbo::ptr _artwork; + Wt::Dbo::collection> _episodes; + }; +} // namespace lms::db diff --git a/src/libs/database/include/database/objects/PodcastEpisode.hpp b/src/libs/database/include/database/objects/PodcastEpisode.hpp new file mode 100644 index 00000000..40389793 --- /dev/null +++ b/src/libs/database/include/database/objects/PodcastEpisode.hpp @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2025 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 +#include +#include +#include +#include + +#include +#include +#include + +#include "database/Object.hpp" +#include "database/Types.hpp" +#include "database/objects/ArtworkId.hpp" +#include "database/objects/PodcastEpisodeId.hpp" +#include "database/objects/PodcastId.hpp" + +namespace lms::db +{ + class Artwork; + class Podcast; + class Session; + + class PodcastEpisode final : public Object + { + public: + enum class ManualDownloadState + { + None = 0, + DownloadRequested = 1, + DeleteRequested = 3, + }; + + struct FindParameters + { + PodcastEpisodeSortMode sortMode = PodcastEpisodeSortMode::None; + db::PodcastId podcast; // if set, only episodes from this podcast + std::optional range; + std::optional manualDownloadState; // if set, only episodes that matches one of these states + + FindParameters& setSortMode(PodcastEpisodeSortMode _sortMode) + { + sortMode = _sortMode; + return *this; + } + FindParameters& setPodcast(db::PodcastId _podcast) + { + podcast = _podcast; + return *this; + } + FindParameters& setRange(const std::optional& _range) + { + range = _range; + return *this; + } + FindParameters& setManualDownloadState(std::optional state) + { + manualDownloadState = state; + return *this; + } + }; + + PodcastEpisode() = default; + static std::size_t getCount(Session& session); + static pointer find(Session& session, PodcastEpisodeId id); + static pointer findNewtestEpisode(Session& session, PodcastId id); + static void find(Session& session, const FindParameters& params, std::function func); + + // getters + ManualDownloadState getManualDownloadState() const { return _manualDownloadState; } + const std::filesystem::path& getAudioRelativeFilePath() const { return _audioRelativeFilePath; } + + std::string_view getTitle() const { return _title; } + std::string_view getLink() const { return _link; } + std::string_view getDescription() const { return _description; } + std::string_view getAuthor() const { return _author; } + std::string_view getCategory() const { return _category; } + std::string_view getEnclosureUrl() const { return _enclosureUrl; } + std::string_view getEnclosureContentType() const { return _enclosureContentType; } + std::int64_t getEnclosureLength() const { return _enclosureLength; } + const Wt::WDateTime& getPubDate() const { return _pubDate; } + std::string_view getImageUrl() const { return _imageUrl; } + std::string_view getSubtitle() const { return _subtitle; } + std::string_view getSummary() const { return _summary; } + bool isExplicit() const { return _explicit; } + std::chrono::duration getDuration() const { return _duration; } + ObjectPtr getPodcast() const { return _podcast; } + PodcastId getPodcastId() const { return _podcast.id(); } + ObjectPtr getArtwork() const; + ArtworkId getArtworkId() const; + + // setters + void setManualDownloadState(ManualDownloadState state) { _manualDownloadState = state; } + void setAudioRelativeFilePath(const std::filesystem::path& relativeFilePath) { _audioRelativeFilePath = relativeFilePath; } + + void setTitle(std::string_view title) { _title = title; } + void setLink(std::string_view link) { _link = link; } + void setDescription(std::string_view description) { _description = description; } + void setAuthor(std::string_view author) { _author = author; } + void setCategory(std::string_view category) { _category = category; } + void setEnclosureUrl(std::string_view enclosureUrl) { _enclosureUrl = enclosureUrl; } + void setEnclosureContentType(std::string_view enclosureContentType) { _enclosureContentType = enclosureContentType; } + void setEnclosureLength(uint64_t enclosureLength) { _enclosureLength = enclosureLength; } + void setPubDate(const Wt::WDateTime& pubDate) { _pubDate = pubDate; } + void setImageUrl(std::string_view imageUrl) { _imageUrl = imageUrl; } + void setSubtitle(std::string_view subtitle) { _subtitle = subtitle; } + void setSummary(std::string_view summary) { _summary = summary; } + void setExplicit(bool explicit_) { _explicit = explicit_; } + void setDuration(std::chrono::duration duration) { _duration = duration; } + void setArtwork(ObjectPtr artwork); + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _manualDownloadState, "manual_download_state"); + Wt::Dbo::field(a, _audioRelativeFilePath, "audio_relative_file_path"); + + Wt::Dbo::field(a, _title, "title"); + Wt::Dbo::field(a, _link, "link"); + Wt::Dbo::field(a, _description, "description"); + Wt::Dbo::field(a, _author, "author"); + Wt::Dbo::field(a, _category, "category"); + Wt::Dbo::field(a, _enclosureUrl, "enclosure_url"); + Wt::Dbo::field(a, _enclosureContentType, "enclosure_content_type"); + Wt::Dbo::field(a, _enclosureLength, "enclosure_size"); + Wt::Dbo::field(a, _pubDate, "pub_date"); + Wt::Dbo::field(a, _imageUrl, "image_url"); + Wt::Dbo::field(a, _subtitle, "subtitle"); + Wt::Dbo::field(a, _summary, "summary"); + Wt::Dbo::field(a, _explicit, "explicit"); + Wt::Dbo::field(a, _duration, "duration"); + + Wt::Dbo::belongsTo(a, _artwork, "artwork", Wt::Dbo::OnDeleteSetNull); + Wt::Dbo::belongsTo(a, _podcast, "podcast", Wt::Dbo::OnDeleteCascade); + } + + private: + friend class Session; + PodcastEpisode(ObjectPtr podcast); + static pointer create(Session& session, ObjectPtr podcast); + + ManualDownloadState _manualDownloadState{ ManualDownloadState::None }; + std::filesystem::path _audioRelativeFilePath; // relative to cache dir, only set if downloaded + + std::string _url; + std::string _title; + std::string _link; + std::string _description; + std::string _author; + std::string _category; + std::string _enclosureUrl; + std::string _enclosureContentType; + int _enclosureLength{ 0 }; + Wt::WDateTime _pubDate; + + // itunes fields + std::string _imageUrl; + std::string _subtitle; + std::string _summary; + bool _explicit{}; + std::chrono::duration _duration{ 0 }; + + Wt::Dbo::ptr _artwork; + Wt::Dbo::ptr _podcast; + }; +} // namespace lms::db diff --git a/src/libs/database/include/database/objects/PodcastEpisodeId.hpp b/src/libs/database/include/database/objects/PodcastEpisodeId.hpp new file mode 100644 index 00000000..78a2acbf --- /dev/null +++ b/src/libs/database/include/database/objects/PodcastEpisodeId.hpp @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025 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 "database/IdType.hpp" + +LMS_DECLARE_IDTYPE(PodcastEpisodeId) diff --git a/src/libs/database/include/database/objects/PodcastId.hpp b/src/libs/database/include/database/objects/PodcastId.hpp new file mode 100644 index 00000000..3d1ac05f --- /dev/null +++ b/src/libs/database/include/database/objects/PodcastId.hpp @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025 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 "database/IdType.hpp" + +LMS_DECLARE_IDTYPE(PodcastId) diff --git a/src/libs/database/test/Artwork.cpp b/src/libs/database/test/Artwork.cpp index 88c020cd..1a9306c5 100644 --- a/src/libs/database/test/Artwork.cpp +++ b/src/libs/database/test/Artwork.cpp @@ -84,4 +84,27 @@ namespace lms::db::tests EXPECT_EQ(artwork.get()->getAbsoluteFilePath(), "/tmp/foo"); } } + + TEST_F(DatabaseFixture, Artwork_underlyingId) + { + ScopedImage image1{ session, "/MyImage" }; + ScopedArtwork artwork1{ session, image1.lockAndGet() }; + + ScopedTrackEmbeddedImage image2{ session }; + ScopedArtwork artwork2{ session, image2.lockAndGet() }; + + { + auto transaction{ session.createReadTransaction() }; + const auto underlyingId{ artwork1.get()->getUnderlyingId() }; + ASSERT_TRUE(std::holds_alternative(underlyingId)); + EXPECT_EQ(std::get(underlyingId), image1.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + const auto underlyingId{ artwork2.get()->getUnderlyingId() }; + ASSERT_TRUE(std::holds_alternative(underlyingId)); + EXPECT_EQ(std::get(underlyingId), image2.getId()); + } + } } // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/database/test/CMakeLists.txt b/src/libs/database/test/CMakeLists.txt index bf88aaf1..3bbcf2bb 100644 --- a/src/libs/database/test/CMakeLists.txt +++ b/src/libs/database/test/CMakeLists.txt @@ -13,6 +13,7 @@ add_executable(test-database Medium.cpp Migration.cpp PlayListFile.cpp + Podcast.cpp RatedArtist.cpp RatedRelease.cpp RatedTrack.cpp diff --git a/src/libs/database/test/Migration.cpp b/src/libs/database/test/Migration.cpp index dfb7e817..09de4d17 100644 --- a/src/libs/database/test/Migration.cpp +++ b/src/libs/database/test/Migration.cpp @@ -28,6 +28,8 @@ #include "database/objects/Medium.hpp" #include "database/objects/PlayListFile.hpp" #include "database/objects/PlayQueue.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" #include "database/objects/RatedArtist.hpp" #include "database/objects/RatedRelease.hpp" #include "database/objects/RatedTrack.hpp" @@ -359,6 +361,8 @@ VALUES EXPECT_FALSE(Listen::find(session, ListenId{})); EXPECT_FALSE(PlayListFile::find(session, PlayListFileId{})); EXPECT_FALSE(PlayQueue::find(session, PlayQueueId{})); + EXPECT_FALSE(Podcast::find(session, PodcastId{})); + EXPECT_FALSE(PodcastEpisode::find(session, PodcastEpisodeId{})); EXPECT_FALSE(RatedArtist::find(session, RatedArtistId{})); EXPECT_FALSE(RatedRelease::find(session, RatedReleaseId{})); EXPECT_FALSE(RatedTrack::find(session, RatedTrackId{})); diff --git a/src/libs/database/test/Podcast.cpp b/src/libs/database/test/Podcast.cpp new file mode 100644 index 00000000..11ef857b --- /dev/null +++ b/src/libs/database/test/Podcast.cpp @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2025 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 "Common.hpp" + +#include "database/objects/Artwork.hpp" +#include "database/objects/Podcast.hpp" + +namespace lms::db::tests +{ + using ScopedDirectory = ScopedEntity; + using ScopedPodcast = ScopedEntity; + + TEST_F(DatabaseFixture, Podcast) + { + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(Podcast::getCount(session), 0); + } + + ScopedPodcast podcast{ session, "podcastUrl" }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(Podcast::getCount(session), 1); + + Podcast::pointer p{ Podcast::find(session, podcast.getId()) }; + ASSERT_NE(p, Podcast::pointer{}); + EXPECT_EQ(p->getUrl(), "podcastUrl"); + EXPECT_EQ(p->getTitle(), ""); + EXPECT_EQ(p->getLink(), ""); + EXPECT_EQ(p->getDescription(), ""); + EXPECT_EQ(p->getLanguage(), ""); + EXPECT_EQ(p->getCopyright(), ""); + EXPECT_EQ(p->getLastBuildDate(), Wt::WDateTime()); + EXPECT_EQ(p->getAuthor(), ""); + EXPECT_EQ(p->getCategory(), ""); + EXPECT_EQ(p->isExplicit(), false); + EXPECT_EQ(p->getImageUrl(), ""); + EXPECT_EQ(p->getOwnerEmail(), ""); + } + + { + auto transaction{ session.createWriteTransaction() }; + Podcast::pointer p{ Podcast::find(session, podcast.getId()) }; + ASSERT_NE(p, Podcast::pointer{}); + p.modify()->setUrl("newPodcastUrl"); + p.modify()->setTitle("newTitle"); + p.modify()->setLink("newLink"); + p.modify()->setDescription("newDescription"); + p.modify()->setLanguage("newLanguage"); + p.modify()->setCopyright("newCopyright"); + p.modify()->setLastBuildDate(Wt::WDateTime::currentDateTime()); + p.modify()->setAuthor("newAuthor"); + p.modify()->setCategory("newCategory"); + p.modify()->setExplicit(true); + p.modify()->setImageUrl("newImageUrl"); + p.modify()->setOwnerEmail("newOwnerEmail"); + p.modify()->setOwnerName("newOwnerName"); + } + + { + auto transaction{ session.createReadTransaction() }; + + Podcast::pointer img{ Podcast::find(session, podcast.getId()) }; + ASSERT_NE(img, Podcast::pointer{}); + EXPECT_EQ(img->getUrl(), "newPodcastUrl"); + EXPECT_EQ(img->getTitle(), "newTitle"); + EXPECT_EQ(img->getLink(), "newLink"); + EXPECT_EQ(img->getDescription(), "newDescription"); + EXPECT_EQ(img->getLanguage(), "newLanguage"); + EXPECT_EQ(img->getCopyright(), "newCopyright"); + EXPECT_TRUE(img->getLastBuildDate().isValid()); + EXPECT_EQ(img->getAuthor(), "newAuthor"); + EXPECT_EQ(img->getCategory(), "newCategory"); + EXPECT_TRUE(img->isExplicit()); + EXPECT_EQ(img->getImageUrl(), "newImageUrl"); + EXPECT_EQ(img->getOwnerEmail(), "newOwnerEmail"); + EXPECT_EQ(img->getOwnerName(), "newOwnerName"); + } + } + +} // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/image/impl/EncodedImage.cpp b/src/libs/image/impl/EncodedImage.cpp index 53ea72c2..01c4168f 100644 --- a/src/libs/image/impl/EncodedImage.cpp +++ b/src/libs/image/impl/EncodedImage.cpp @@ -74,9 +74,9 @@ namespace lms::image } // namespace - std::unique_ptr readImage(const std::filesystem::path& path) + std::unique_ptr readImage(const std::filesystem::path& path, std::string_view mimeType) { - return std::make_unique(path); + return std::make_unique(path, mimeType); } std::unique_ptr readImage(std::span encodedData, std::string_view mimeType) @@ -96,8 +96,8 @@ namespace lms::image { } - EncodedImage::EncodedImage(const std::filesystem::path& p) - : EncodedImage::EncodedImage{ fileToBuffer(p), extensionToMimeType(p.extension()) } + EncodedImage::EncodedImage(const std::filesystem::path& p, std::string_view mimeType) + : EncodedImage::EncodedImage{ fileToBuffer(p), mimeType.empty() ? extensionToMimeType(p.extension()) : mimeType } { } } // namespace lms::image \ No newline at end of file diff --git a/src/libs/image/impl/EncodedImage.hpp b/src/libs/image/impl/EncodedImage.hpp index fcf87691..dc6142de 100644 --- a/src/libs/image/impl/EncodedImage.hpp +++ b/src/libs/image/impl/EncodedImage.hpp @@ -29,7 +29,7 @@ namespace lms::image class EncodedImage : public IEncodedImage { public: - EncodedImage(const std::filesystem::path& path); + EncodedImage(const std::filesystem::path& path, std::string_view mimeType = ""); EncodedImage(std::vector&& data, std::string_view mimeType); EncodedImage(std::span data, std::string_view mimeType); ~EncodedImage() override = default; diff --git a/src/libs/image/include/image/Image.hpp b/src/libs/image/include/image/Image.hpp index 31e46ba8..0e363fac 100644 --- a/src/libs/image/include/image/Image.hpp +++ b/src/libs/image/include/image/Image.hpp @@ -39,7 +39,7 @@ namespace lms::image std::unique_ptr decodeImage(const std::filesystem::path& path); std::unique_ptr readImage(std::span encodedData, std::string_view mimeType); - std::unique_ptr readImage(const std::filesystem::path& path); + std::unique_ptr readImage(const std::filesystem::path& path, std::string_view mimeType = ""); // mimeType may already been known, otherwise, it is guessed based on the file extension std::unique_ptr encodeToJPEG(const IRawImage& rawImage, unsigned quality); } // namespace lms::image \ No newline at end of file diff --git a/src/libs/services/CMakeLists.txt b/src/libs/services/CMakeLists.txt index 02b6c6aa..9c06dbce 100644 --- a/src/libs/services/CMakeLists.txt +++ b/src/libs/services/CMakeLists.txt @@ -1,6 +1,7 @@ add_subdirectory(artwork) add_subdirectory(auth) add_subdirectory(feedback) +add_subdirectory(podcast) add_subdirectory(recommendation) add_subdirectory(scanner) add_subdirectory(scrobbling) diff --git a/src/libs/services/artwork/impl/ArtworkService.cpp b/src/libs/services/artwork/impl/ArtworkService.cpp index 0480b756..2781d1cc 100644 --- a/src/libs/services/artwork/impl/ArtworkService.cpp +++ b/src/libs/services/artwork/impl/ArtworkService.cpp @@ -63,7 +63,7 @@ namespace lms::artwork ArtworkService::~ArtworkService() = default; - std::unique_ptr ArtworkService::getFromImageFile(const std::filesystem::path& p, std::optional width) const + std::unique_ptr ArtworkService::getFromImageFile(const std::filesystem::path& p, std::string_view mimeType, std::optional width) const { std::unique_ptr image; @@ -71,7 +71,7 @@ namespace lms::artwork { if (!width) { - image = image::readImage(p); + image = image::readImage(p, mimeType); } else { @@ -177,8 +177,7 @@ namespace lms::artwork if (image) return image; - db::TrackEmbeddedImageId trackEmbeddedImageId; - db::ImageId imageId; + db::Artwork::UnderlyingId underlyingArtworkId; { db::Session& session{ _db.getTLSSession() }; @@ -186,16 +185,13 @@ namespace lms::artwork db::Artwork::pointer artwork{ db::Artwork::find(session, artworkId) }; if (artwork) - { - trackEmbeddedImageId = artwork->getTrackEmbeddedImageId(); - imageId = artwork->getImageId(); - } + underlyingArtworkId = artwork->getUnderlyingId(); } - if (trackEmbeddedImageId.isValid()) - image = getTrackEmbeddedImage(trackEmbeddedImageId, width); - else if (imageId.isValid()) - image = getImage(imageId, width); + if (const auto* trackEmbeddedImageId = std::get_if(&underlyingArtworkId)) + image = getTrackEmbeddedImage(*trackEmbeddedImageId, width); + else if (const auto* imageId = std::get_if(&underlyingArtworkId)) + image = getImage(*imageId, width); if (image) _cache.addImage(cacheEntryDesc, image); @@ -206,6 +202,7 @@ namespace lms::artwork std::shared_ptr ArtworkService::getImage(db::ImageId imageId, std::optional width) { std::filesystem::path imageFile; + std::string mimeType; { db::Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; @@ -215,9 +212,10 @@ namespace lms::artwork return nullptr; imageFile = image->getAbsoluteFilePath(); + mimeType = image->getMimeType(); } - return getFromImageFile(imageFile, width); + return getFromImageFile(imageFile, mimeType, width); } std::shared_ptr ArtworkService::getTrackEmbeddedImage(db::TrackEmbeddedImageId trackEmbeddedImageId, std::optional width) diff --git a/src/libs/services/artwork/impl/ArtworkService.hpp b/src/libs/services/artwork/impl/ArtworkService.hpp index 6eb0a691..feb5b837 100644 --- a/src/libs/services/artwork/impl/ArtworkService.hpp +++ b/src/libs/services/artwork/impl/ArtworkService.hpp @@ -62,7 +62,7 @@ namespace lms::artwork std::shared_ptr getImage(db::ImageId imageId, std::optional width); std::shared_ptr getTrackEmbeddedImage(db::TrackEmbeddedImageId trackEmbeddedImageId, std::optional width); - std::unique_ptr getFromImageFile(const std::filesystem::path& p, std::optional width) const; + std::unique_ptr getFromImageFile(const std::filesystem::path& p, std::string_view mimeType, std::optional width) const; std::unique_ptr getTrackImage(const std::filesystem::path& path, std::size_t index, std::optional width) const; db::IDb& _db; diff --git a/src/libs/services/feedback/impl/listenbrainz/FeedbacksSynchronizer.cpp b/src/libs/services/feedback/impl/listenbrainz/FeedbacksSynchronizer.cpp index 1a07b794..4e9133a0 100644 --- a/src/libs/services/feedback/impl/listenbrainz/FeedbacksSynchronizer.cpp +++ b/src/libs/services/feedback/impl/listenbrainz/FeedbacksSynchronizer.cpp @@ -133,7 +133,7 @@ namespace lms::feedback::listenBrainz request.message.addBodyText(Wt::Json::serialize(root)); request.message.addHeader("Content-Type", "application/json"); - request.onSuccessFunc = [this, type, starredTrackId](std::string_view /*msgBody*/) { + request.onSuccessFunc = [this, type, starredTrackId](const Wt::Http::Message&) { boost::asio::post(boost::asio::bind_executor(_strand, [this, type, starredTrackId] { onFeedbackSent(type, starredTrackId); })); @@ -321,8 +321,8 @@ namespace lms::feedback::listenBrainz request.priority = core::http::ClientRequestParameters::Priority::Low; request.relativeUrl = "/1/validate-token"; request.headers = { { "Authorization", "Token " + std::string{ listenBrainzToken->getAsString() } } }; - request.onSuccessFunc = [this, &context](std::string_view msgBody) { - context.listenBrainzUserName = utils::parseValidateToken(msgBody); + request.onSuccessFunc = [this, &context](const Wt::Http::Message& msg) { + context.listenBrainzUserName = utils::parseValidateToken(msg.body()); if (context.listenBrainzUserName.empty()) { onSyncEnded(context); @@ -344,8 +344,8 @@ namespace lms::feedback::listenBrainz core::http::ClientGETRequestParameters request; request.relativeUrl = "/1/feedback/user/" + std::string{ context.listenBrainzUserName } + "/get-feedback?score=1&count=0"; request.priority = core::http::ClientRequestParameters::Priority::Low; - request.onSuccessFunc = [this, &context](std::string_view msgBody) { - std::string msgBodyCopy{ msgBody }; + request.onSuccessFunc = [this, &context](const Wt::Http::Message& msg) { + std::string msgBodyCopy{ msg.body() }; boost::asio::post(boost::asio::bind_executor(_strand, [this, msgBodyCopy, &context] { LOG(DEBUG, "Current feedback count = " << (context.feedbackCount ? *context.feedbackCount : 0) << " for user '" << context.listenBrainzUserName << "'"); @@ -376,8 +376,8 @@ namespace lms::feedback::listenBrainz core::http::ClientGETRequestParameters request; request.relativeUrl = "/1/feedback/user/" + context.listenBrainzUserName + "/get-feedback?offset=" + std::to_string(context.fetchedFeedbackCount); request.priority = core::http::ClientRequestParameters::Priority::Low; - request.onSuccessFunc = [this, &context](std::string_view msgBody) { - std::string msgBodyCopy{ msgBody }; + request.onSuccessFunc = [this, &context](const Wt::Http::Message& msg) { + std::string msgBodyCopy{ msg.body() }; boost::asio::post(boost::asio::bind_executor(_strand, [this, msgBodyCopy, &context] { const std::size_t fetchedFeedbackCount{ processGetFeedbacks(msgBodyCopy, context) }; if (fetchedFeedbackCount == 0 // no more thing available on server diff --git a/src/libs/services/podcast/CMakeLists.txt b/src/libs/services/podcast/CMakeLists.txt new file mode 100644 index 00000000..889d2b53 --- /dev/null +++ b/src/libs/services/podcast/CMakeLists.txt @@ -0,0 +1,40 @@ +pkg_check_modules(PUGIXML REQUIRED IMPORTED_TARGET pugixml) + +add_library(lmspodcast STATIC + impl/steps/CheckForMissingFilesStep.cpp + impl/steps/ClearTmpDirectoryStep.cpp + impl/steps/DownloadEpisodeArtworksStep.cpp + impl/steps/DownloadEpisodesStep.cpp + impl/steps/DownloadPodcastArtworksStep.cpp + impl/steps/RefreshPodcastsStep.cpp + impl/steps/RemoveEpisodesStep.cpp + impl/steps/RemovePodcastsStep.cpp + impl/steps/Utils.cpp + impl/Executor.cpp + impl/PodcastParsing.cpp + impl/PodcastService.cpp + ) + +target_include_directories(lmspodcast INTERFACE + include + ) + +target_include_directories(lmspodcast PRIVATE + include + impl + ${PUGIXML_INCLUDE_DIRS} + ) + +target_link_libraries(lmspodcast PRIVATE + lmscore + lmsimage + PkgConfig::PUGIXML + ) + +target_link_libraries(lmspodcast PUBLIC + lmsdatabase + ) + +if(BUILD_TESTING) + add_subdirectory(test) +endif() diff --git a/src/libs/services/podcast/impl/Exception.hpp b/src/libs/services/podcast/impl/Exception.hpp new file mode 100644 index 00000000..ffd28948 --- /dev/null +++ b/src/libs/services/podcast/impl/Exception.hpp @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2025 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 "core/Exception.hpp" + +namespace lms::podcast +{ + class Exception : public core::LmsException + { + public: + using LmsException::LmsException; + }; +} // namespace lms::podcast diff --git a/src/libs/services/podcast/impl/Executor.cpp b/src/libs/services/podcast/impl/Executor.cpp new file mode 100644 index 00000000..6d5c9127 --- /dev/null +++ b/src/libs/services/podcast/impl/Executor.cpp @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2025 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 "Executor.hpp" + +#include + +#include +#include + +namespace lms::podcast +{ + Executor::Executor(boost::asio::io_context& ioContext) + : _strand{ ioContext } + { + } + + void Executor::post(std::function callback) + { + boost::asio::post(boost::asio::bind_executor(_strand, std::move(callback))); + } +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/Executor.hpp b/src/libs/services/podcast/impl/Executor.hpp new file mode 100644 index 00000000..34f432f4 --- /dev/null +++ b/src/libs/services/podcast/impl/Executor.hpp @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2025 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 +#include + +namespace lms::podcast +{ + class Executor + { + public: + Executor(boost::asio::io_context& ioContext); + + void post(std::function callback); + + private: + boost::asio::io_context::strand _strand; + }; + +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/PodcastParsing.cpp b/src/libs/services/podcast/impl/PodcastParsing.cpp new file mode 100644 index 00000000..d0085ede --- /dev/null +++ b/src/libs/services/podcast/impl/PodcastParsing.cpp @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2025 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 "PodcastParsing.hpp" + +#include +#include + +#include + +#include "core/ILogger.hpp" +#include "core/String.hpp" + +namespace lms::podcast +{ + namespace + { + std::optional parseDuration(std::string_view str) + { + auto parse_int{ [](std::string_view sv) -> std::optional { + int value{}; + const auto [ptr, ec]{ std::from_chars(sv.data(), sv.data() + sv.size(), value) }; + return (ec == std::errc()) ? std::optional{ value } : std::nullopt; + } }; + + std::array parts{ 0, 0, 0 }; + int index{ 3 }; + while (!str.empty() && --index >= 0) + { + const std::size_t pos{ str.rfind(':') }; + const std::string_view token{ (pos == std::string_view::npos) ? str : str.substr(pos + 1) }; + + const auto val{ parse_int(token) }; + if (!val) + return std::nullopt; + + parts[index] = *val; + if (pos == std::string_view::npos) + break; + + str.remove_suffix(str.size() - pos); + } + + return std::chrono::hours{ parts[0] } + std::chrono::minutes{ parts[1] } + std::chrono::seconds{ parts[2] }; + } + + std::optional getDuration(const pugi::xml_node& node, const char* tag) + { + std::optional res; + + if (const pugi::xml_node child{ node.child(tag) }) + { + std::string_view value{ child.child_value() }; + res = parseDuration(value); + } + + return res; + } + + std::optional getBool(const pugi::xml_node& node, const char* tag) + { + std::optional res; + + if (const pugi::xml_node child{ node.child(tag) }) + { + std::string_view value{ child.child_value() }; + if (value == "true" || value == "1" || value == "on" || value == "yes") + res = true; + else if (value == "false" || value == "0" || value == "off" || value == "no") + res = false; + } + + return res; + } + + std::string_view getText(const pugi::xml_node& node, const char* tag) + { + std::string_view res; + + if (const pugi::xml_node child{ node.child(tag) }) + res = child.child_value(); + + return res; + } + + std::string getRawText(const pugi::xml_node& node, const char* tag) + { + std::string res; + + if (const pugi::xml_node child{ node.child(tag) }) + { + std::ostringstream oss; + for (const pugi::xml_node& n : child.children()) + n.print(oss, "", pugi::format_raw); + res = oss.str(); + } + + return res; + } + + std::string_view getAttribute(const pugi::xml_node& node, const char* tag, const char* attribute) + { + std::string_view res; + + if (const pugi::xml_node child{ node.child(tag) }) + res = child.attribute(attribute).value(); + + return res; + } + } // namespace + + Podcast parsePodcastRssFeed(std::string_view rssXml) + { + Podcast podcast; + + pugi::xml_document doc; + pugi::xml_parse_result result{ doc.load_buffer(rssXml.data(), rssXml.size()) }; + if (!result) + { + LMS_LOG(METADATA, ERROR, "Cannot read xml: " << result.description()); + throw ParseException{ result.description() }; + } + + const pugi::xml_node channel{ doc.child("rss").child("channel") }; + if (!channel) + throw ParseException{ "No element found in podcast XML" }; + + podcast.title = getText(channel, "title"); + podcast.link = getText(channel, "link"); + podcast.description = getRawText(channel, "description"); + podcast.language = getText(channel, "language"); + podcast.copyright = getText(channel, "copyright"); + podcast.lastBuildDate = core::stringUtils::fromRFC822String(getText(channel, "lastBuildDate")); + + // itunes fields + podcast.newUrl = getText(channel, "itunes:new-feed-url"); + podcast.author = getText(channel, "itunes:author"); + podcast.category = getAttribute(channel, "itunes:category", "text"); + podcast.imageUrl = getText(channel, "itunes:image"); + if (podcast.imageUrl.empty()) + { + if (const pugi::xml_node image{ channel.child("image") }) + podcast.imageUrl = getText(image, "url"); + } + if (const pugi::xml_node owner{ channel.child("itunes:owner") }) + { + podcast.ownerEmail = getText(owner, "itunes:email"); + podcast.ownerName = getText(owner, "itunes:name"); + } + podcast.subtitle = getText(channel, "itunes:subtitle"); + podcast.summary = getRawText(channel, "itunes:summary"); + podcast.explicitContent = getBool(channel, "itunes:explicit"); + + // parse nested episodes + for (pugi::xml_node episode{ channel.child("item") }; episode; episode = episode.next_sibling("item")) + { + PodcastEpisode e; + e.title = getText(episode, "title"); + // + if (const pugi::xml_node enclosure{ episode.child("enclosure") }) + e.url = getText(enclosure, "url"); + e.pubDate = core::stringUtils::fromRFC822String(getText(episode, "pubDate")); + e.description = getRawText(episode, "description"); + e.link = getText(episode, "link"); + e.author = getText(episode, "itunes:author"); + if (e.author.empty()) + e.author = getText(episode, "author"); + + e.enclosureUrl.url = getAttribute(episode, "enclosure", "url"); + e.enclosureUrl.length = core::stringUtils::readAs(getAttribute(episode, "enclosure", "length")).value_or(0); + e.enclosureUrl.type = getAttribute(episode, "enclosure", "type"); + + e.category = getAttribute(episode, "itunes:category", "text"); + e.duration = getDuration(episode, "itunes:duration").value_or(std::chrono::seconds::zero()); + e.guid = getText(episode, "guid"); + + e.imageUrl = getAttribute(episode, "itunes:image", "href"); + e.explicitContent = getBool(episode, "itunes:explicit"); + + podcast.episodes.push_back(std::move(e)); + } + + return podcast; + } + +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/PodcastParsing.hpp b/src/libs/services/podcast/impl/PodcastParsing.hpp new file mode 100644 index 00000000..3b4e9189 --- /dev/null +++ b/src/libs/services/podcast/impl/PodcastParsing.hpp @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025 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 + +#include "Exception.hpp" + +#include "PodcastTypes.hpp" + +namespace lms::podcast +{ + class ParseException : public Exception + { + public: + using Exception::Exception; + }; + + Podcast parsePodcastRssFeed(std::string_view rssXml); +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/PodcastService.cpp b/src/libs/services/podcast/impl/PodcastService.cpp new file mode 100644 index 00000000..f5e0400e --- /dev/null +++ b/src/libs/services/podcast/impl/PodcastService.cpp @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2025 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 "PodcastService.hpp" + +#include + +#include "core/IConfig.hpp" +#include "core/ILogger.hpp" +#include "core/ITraceLogger.hpp" +#include "core/Service.hpp" +#include "core/http/IClient.hpp" +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" + +#include "steps/CheckForMissingFilesStep.hpp" +#include "steps/ClearTmpDirectoryStep.hpp" +#include "steps/DownloadEpisodeArtworksStep.hpp" +#include "steps/DownloadEpisodesStep.hpp" +#include "steps/DownloadPodcastArtworksStep.hpp" +#include "steps/RefreshPodcastsStep.hpp" +#include "steps/RemoveEpisodesStep.hpp" +#include "steps/RemovePodcastsStep.hpp" + +#include "Exception.hpp" + +namespace lms::podcast +{ + std::unique_ptr createPodcastService(boost::asio::io_context& ioContext, db::IDb& db, const std::filesystem::path& cachePath) + { + return std::make_unique(ioContext, db, cachePath); + } + + PodcastService::PodcastService(boost::asio::io_context& ioContext, db::IDb& db, const std::filesystem::path& cachePath) + : _executor{ ioContext } + , _refreshTimer(ioContext) + , _httpClient{ core::http::createClient(ioContext, "") } + , _refreshContext{ _executor, db, *_httpClient, cachePath } + , _refreshPeriod{ core::Service::get()->getULong("podcast-refresh-period-hours", 2) } + , _refreshInProgress{ false } + , _abortRequested{ false } + , _refreshStepIndex{ 0 } + { + if (_refreshPeriod.count() < 1) + { + LMS_LOG(PODCAST, ERROR, "Podcast refresh period must be at least 1 hour"); + throw Exception{ "Podcast refresh period must be at least 1 hour" }; + } + + setupSteps(); + + std::filesystem::create_directories(_refreshContext.cachePath); + std::filesystem::create_directories(_refreshContext.tmpCachePath); + + LMS_LOG(PODCAST, INFO, "Starting service..."); + scheduleRefresh(std::chrono::seconds{ 1 }); + LMS_LOG(PODCAST, INFO, "Service started!"); + } + + PodcastService::~PodcastService() + { + std::unique_lock lock{ _controlMutex }; + + abortCurrentRefresh(lock); + LMS_LOG(PODCAST, INFO, "Service stopped!"); + } + + std::filesystem::path PodcastService::getCachePath() const + { + return _refreshContext.cachePath; + } + + db::PodcastId PodcastService::addPodcast(std::string_view url) + { + std::unique_lock lock{ _controlMutex }; + + abortCurrentRefresh(lock); + + db::PodcastId podcastId; + { + db::Session& session{ _refreshContext.db.getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + db::Podcast::pointer podcast{ db::Podcast::find(session, url) }; + if (!podcast) + podcast = session.create(url); + + podcastId = podcast->getId(); + } + + allowRefresh(); + scheduleRefresh(); + + return podcastId; + } + + bool PodcastService::removePodcast(db::PodcastId podcastId) + { + bool res{}; + std::unique_lock lock{ _controlMutex }; + + abortCurrentRefresh(lock); + + { + db::Session& session{ _refreshContext.db.getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + db::Podcast::pointer podcast{ db::Podcast::find(session, podcastId) }; + if (podcast) + { + podcast.modify()->setDeleteRequested(true); + res = true; + } + } + + allowRefresh(); + scheduleRefresh(); + + return res; + } + + void PodcastService::refreshPodcasts() + { + std::unique_lock lock{ _controlMutex }; + + abortCurrentRefresh(lock); + allowRefresh(); + scheduleRefresh(); + } + + bool PodcastService::downloadPodcastEpisode(db::PodcastEpisodeId episodeId) + { + bool res{}; + std::unique_lock lock{ _controlMutex }; + + abortCurrentRefresh(lock); + + { + db::Session& session{ _refreshContext.db.getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, episodeId) }; + if (episode) + { + episode.modify()->setManualDownloadState(db::PodcastEpisode::ManualDownloadState::DownloadRequested); + res = true; + } + } + + allowRefresh(); + scheduleRefresh(); + + return res; + } + + bool PodcastService::deletePodcastEpisode(db::PodcastEpisodeId episodeId) + { + bool res{}; + std::unique_lock lock{ _controlMutex }; + + abortCurrentRefresh(lock); + + { + db::Session& session{ _refreshContext.db.getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, episodeId) }; + if (episode) + { + episode.modify()->setManualDownloadState(db::PodcastEpisode::ManualDownloadState::DeleteRequested); + res = true; + } + } + + allowRefresh(); + scheduleRefresh(); + + return res; + } + + bool PodcastService::hasPodcasts() const + { + db::Session& session{ _refreshContext.db.getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + return db::Podcast::getCount(session) > 0; + } + + void PodcastService::abortCurrentRefresh(std::unique_lock& lock) + { + LMS_LOG(PODCAST, DEBUG, "Aborting current refresh..."); + + _abortRequested = true; + for (auto& step : _refreshSteps) + step->requestAbort(true); + + _httpClient->abortAllRequests(); + _refreshTimer.cancel(); + + _controlCv.wait(lock, [this] { + return !_refreshInProgress; + }); + + LMS_LOG(PODCAST, DEBUG, "Current refresh aborted!"); + } + + void PodcastService::allowRefresh() + { + assert(!_refreshInProgress); + assert(_abortRequested); + + _abortRequested = false; + for (auto& step : _refreshSteps) + step->requestAbort(false); + } + + void PodcastService::scheduleRefresh(std::chrono::seconds fromNow) + { + if (!hasPodcasts()) + { + LMS_LOG(PODCAST, DEBUG, "No podcast: not scheduling refresh"); + return; + } + + LMS_LOG(PODCAST, DEBUG, "Scheduled podcast refresh in " << fromNow.count() << " seconds..."); + + _refreshTimer.expires_after(fromNow); + _refreshTimer.async_wait([this](const boost::system::error_code& ec) { + if (ec == boost::asio::error::operation_aborted) + return; + + if (ec) + throw Exception{ "Steady timer failure: " + std::string{ ec.message() } }; + + _executor.post([this] { startRefresh(); }); + }); + } + + void PodcastService::startRefresh() + { + LMS_LOG(PODCAST, DEBUG, "Starting podcast refresh"); + + _refreshInProgress = true; + _refreshStepIndex = 0; + runStep(_refreshStepIndex); + } + + void PodcastService::setupSteps() + { + auto onDoneCallback{ [this](bool success) { + onCurrentStepDone(success); + } }; + + _refreshSteps.clear(); + + // order is important, each step is done only when the previous one is done + _refreshSteps.emplace_back(std::make_unique(_refreshContext, onDoneCallback)); + _refreshSteps.emplace_back(std::make_unique(_refreshContext, onDoneCallback)); + _refreshSteps.emplace_back(std::make_unique(_refreshContext, onDoneCallback)); + _refreshSteps.emplace_back(std::make_unique(_refreshContext, onDoneCallback)); + _refreshSteps.emplace_back(std::make_unique(_refreshContext, onDoneCallback)); + _refreshSteps.emplace_back(std::make_unique(_refreshContext, onDoneCallback)); + _refreshSteps.emplace_back(std::make_unique(_refreshContext, onDoneCallback)); + _refreshSteps.emplace_back(std::make_unique(_refreshContext, onDoneCallback)); + } + + void PodcastService::onCurrentStepDone(bool success) + { + LMS_LOG(PODCAST, DEBUG, "Step '" << _refreshSteps[_refreshStepIndex]->getName() << "' done: " << (success ? "success" : _abortRequested ? "aborted" : + "failure")); + + if (success && !_abortRequested) + runNextStep(); + else + onRefreshDone(); + } + + void PodcastService::runNextStep() + { + if (++_refreshStepIndex < _refreshSteps.size()) + runStep(_refreshStepIndex); + else + onRefreshDone(); + } + + void PodcastService::runStep(std::size_t stepIndex) + { + _refreshContext.executor.post([stepIndex, this] { + assert(stepIndex < _refreshSteps.size()); + RefreshStep& step{ *_refreshSteps[stepIndex] }; + + LMS_LOG(PODCAST, DEBUG, "Running step '" << step.getName() << "'"); + { + LMS_SCOPED_TRACE_OVERVIEW("Podcast", step.getName()); + step.run(); + } + }); + } + + void PodcastService::onRefreshDone() + { + LMS_LOG(PODCAST, DEBUG, "Refresh done" << (_abortRequested ? " (aborted)" : "")); + + const bool rescheduleRefresh{ !_abortRequested }; + + _refreshInProgress = false; + _controlCv.notify_all(); + + if (rescheduleRefresh) + scheduleRefresh(_refreshPeriod); + } +} // namespace lms::podcast diff --git a/src/libs/services/podcast/impl/PodcastService.hpp b/src/libs/services/podcast/impl/PodcastService.hpp new file mode 100644 index 00000000..15d66360 --- /dev/null +++ b/src/libs/services/podcast/impl/PodcastService.hpp @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2025 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 +#include +#include +#include +#include + +#include + +#include "database/objects/PodcastId.hpp" +#include "services/podcast/IPodcastService.hpp" + +#include "Executor.hpp" +#include "RefreshContext.hpp" + +namespace lms::core::http +{ + class IClient; +} + +namespace lms::podcast +{ + class RefreshStep; + + class PodcastService : public IPodcastService + { + public: + PodcastService(boost::asio::io_context& ioContext, db::IDb& db, const std::filesystem::path& cachePath); + ~PodcastService() override; + + PodcastService(const PodcastService&) = delete; + PodcastService& operator=(const PodcastService&) = delete; + + private: + std::filesystem::path getCachePath() const override; + + db::PodcastId addPodcast(std::string_view url) override; + bool removePodcast(db::PodcastId podcast) override; + void refreshPodcasts() override; + + bool downloadPodcastEpisode(db::PodcastEpisodeId episode) override; + bool deletePodcastEpisode(db::PodcastEpisodeId episode) override; + + bool hasPodcasts() const; + void abortCurrentRefresh(std::unique_lock& lock); + void allowRefresh(); + void scheduleRefresh(std::chrono::seconds fromNow = std::chrono::seconds::zero()); + void startRefresh(); + void onRefreshDone(); + + void setupSteps(); + void onCurrentStepDone(bool success); + void runNextStep(); + void runStep(std::size_t stepIndex); + + Executor _executor; + boost::asio::steady_timer _refreshTimer; + std::unique_ptr _httpClient; + RefreshContext _refreshContext; + + const std::chrono::hours _refreshPeriod; + + std::mutex _controlMutex; + std::condition_variable _controlCv; + std::atomic _refreshInProgress; + + std::atomic _abortRequested; + std::vector> _refreshSteps; + std::size_t _refreshStepIndex; + }; +} // namespace lms::podcast diff --git a/src/libs/services/podcast/impl/PodcastTypes.hpp b/src/libs/services/podcast/impl/PodcastTypes.hpp new file mode 100644 index 00000000..3812ef1b --- /dev/null +++ b/src/libs/services/podcast/impl/PodcastTypes.hpp @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2025 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 +#include +#include +#include + +#include + +namespace lms::podcast +{ + + struct EnclosureUrl + { + std::string url; + std::size_t length; + std::string type; + }; + + struct PodcastEpisode + { + std::string url; + std::string title; + std::string link; + std::string description; + Wt::WDateTime pubDate; + std::string author; + std::string category; + std::optional explicitContent; + std::string imageUrl; + std::string ownerEmail; + std::string guid; + EnclosureUrl enclosureUrl; + std::chrono::milliseconds duration{ 0 }; + }; + + struct Podcast + { + std::string title; + std::string link; + std::string description; + std::string language; + std::string copyright; + Wt::WDateTime lastBuildDate; + // itunes fields + std::string newUrl; + std::string author; + std::string category; + std::optional explicitContent; + std::string imageUrl; + std::string ownerEmail; + std::string ownerName; + std::string subtitle; + std::string summary; + + std::vector episodes; // List of episodes in the podcast + }; + +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/RefreshContext.hpp b/src/libs/services/podcast/impl/RefreshContext.hpp new file mode 100644 index 00000000..bcabcacd --- /dev/null +++ b/src/libs/services/podcast/impl/RefreshContext.hpp @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 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 + +namespace lms +{ + namespace db + { + class IDb; + } + namespace core::http + { + class IClient; + } +} // namespace lms + +namespace lms::podcast +{ + class Executor; + + struct RefreshContext + { + RefreshContext(Executor& executor, db::IDb& db, core::http::IClient& client, const std::filesystem::path& cachePath) + : executor{ executor } + , client{ client } + , db{ db } + , cachePath{ cachePath } + , tmpCachePath{ cachePath / "tmp" } + { + } + ~RefreshContext() = default; + RefreshContext(const RefreshContext&) = delete; + RefreshContext& operator=(const RefreshContext&) = delete; + + Executor& executor; + core::http::IClient& client; + db::IDb& db; + const std::filesystem::path cachePath; + const std::filesystem::path tmpCachePath; + }; +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.cpp b/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.cpp new file mode 100644 index 00000000..9ff46aa4 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.cpp @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2025 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 "CheckForMissingFilesStep.hpp" + +#include + +#include "core/ILogger.hpp" +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Image.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" + +namespace lms::podcast +{ + namespace + { + bool fileExists(const std::string& path) + { + std::error_code ec; + bool res{ std::filesystem::exists(path, ec) }; + if (ec) + { + LMS_LOG(PODCAST, ERROR, "Error checking file existence for path " << path << ": " << ec.message()); + return false; + } + + return res; + } + + bool checkArtworkFile(const db::Artwork::pointer& artwork) + { + assert(std::holds_alternative(artwork->getUnderlyingId())); // these artworks can only be an image + + const std::filesystem::path filePath{ artwork->getAbsoluteFilePath() }; + if (!fileExists(filePath.string())) + { + LMS_LOG(PODCAST, DEBUG, "Artwork file is missing: " << filePath); + return false; + } + + return true; + } + } // namespace + + core::LiteralString CheckForMissingFilesStep::getName() const + { + return "Check for missing files"; + } + + void CheckForMissingFilesStep::run() + { + checkMissingImages(); + checkMissingEpisodes(); + + onDone(); + } + + void CheckForMissingFilesStep::checkMissingImages() + { + std::vector missingImages; + + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + db::Podcast::find(session, [&](const db::Podcast::pointer& podcast) { + if (const db::Artwork::pointer artwork{ podcast->getArtwork() }) + { + if (!checkArtworkFile(artwork)) + missingImages.push_back(artwork->getImageId()); + } + }); + + db::PodcastEpisode::find(session, db::PodcastEpisode::FindParameters{}, [&](const db::PodcastEpisode::pointer& episode) { + if (const db::Artwork::pointer artwork{ episode->getArtwork() }) + { + if (!checkArtworkFile(artwork)) + missingImages.push_back(artwork->getImageId()); + } + }); + } + + if (!missingImages.empty()) + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + session.destroy(missingImages); // will propagate to artworks and podcasts/episodes + } + } + + void CheckForMissingFilesStep::checkMissingEpisodes() + { + std::vector missingEpisodes; + + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + db::PodcastEpisode::find(session, db::PodcastEpisode::FindParameters{}, [&](const db::PodcastEpisode::pointer& episode) { + if (episode->getAudioRelativeFilePath().empty()) + return; + + const std::filesystem::path filePath{ getCachePath() / episode->getAudioRelativeFilePath() }; + if (!fileExists(filePath)) + { + LMS_LOG(PODCAST, INFO, "Episode file " << filePath << " is missing for episode '" << episode->getTitle() << "'"); + missingEpisodes.push_back(episode->getId()); + } + }); + } + + if (!missingEpisodes.empty()) + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + for (const auto& episodeId : missingEpisodes) + { + db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, episodeId) }; + episode.modify()->setAudioRelativeFilePath({}); + } + } + } +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.hpp b/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.hpp new file mode 100644 index 00000000..58db745a --- /dev/null +++ b/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.hpp @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025 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 "RefreshStep.hpp" + +namespace lms::podcast +{ + class CheckForMissingFilesStep : public RefreshStep + { + using RefreshStep::RefreshStep; + + private: + core::LiteralString getName() const override; + void run() override; + + void checkMissingImages(); + void checkMissingEpisodes(); + }; +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/ClearTmpDirectoryStep.cpp b/src/libs/services/podcast/impl/steps/ClearTmpDirectoryStep.cpp new file mode 100644 index 00000000..806df6b2 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/ClearTmpDirectoryStep.cpp @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2025 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 "ClearTmpDirectoryStep.hpp" + +#include "core/ILogger.hpp" + +namespace lms::podcast +{ + namespace + { + bool clearDirectory(const std::filesystem::path& _rootPath) + { + for (const auto& entry : std::filesystem::directory_iterator{ _rootPath }) + { + std::error_code ec; + std::filesystem::remove_all(entry, ec); + if (ec) + { + LMS_LOG(PODCAST, ERROR, "Failed to remove " << entry << ": " << ec.message()); + return false; + } + } + + return true; + } + } // namespace + + core::LiteralString ClearTmpDirectoryStep::getName() const + { + return "Clear tmp Directory"; + } + + void ClearTmpDirectoryStep::run() + { + if (!clearDirectory(getTmpCachePath())) + { + LMS_LOG(PODCAST, ERROR, "Failed to delete tmp directory " << getTmpCachePath() << ": aborting refresh"); + onAbort(); + return; + } + + onDone(); + } + +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/ClearTmpDirectoryStep.hpp b/src/libs/services/podcast/impl/steps/ClearTmpDirectoryStep.hpp new file mode 100644 index 00000000..d7e9848e --- /dev/null +++ b/src/libs/services/podcast/impl/steps/ClearTmpDirectoryStep.hpp @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2025 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 "RefreshStep.hpp" + +namespace lms::podcast +{ + class ClearTmpDirectoryStep : public RefreshStep + { + public: + using RefreshStep::RefreshStep; + + private: + core::LiteralString getName() const override; + void run() override; + }; + +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp b/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp new file mode 100644 index 00000000..86f3d526 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2025 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 "DownloadEpisodeArtworksStep.hpp" + +#include +#include +#include + +#include "core/ILogger.hpp" +#include "core/http/IClient.hpp" +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Image.hpp" +#include "database/objects/PodcastEpisode.hpp" + +#include "Executor.hpp" +#include "Utils.hpp" + +namespace lms::podcast +{ + namespace + { + void createEpisodeArtwork(db::Session& session, db::PodcastEpisodeId episodeId, const std::filesystem::path& filePath, std::string_view contentType) + { + auto transaction{ session.createWriteTransaction() }; + + db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, episodeId) }; + if (!episode) + return; + + if (db::Artwork::pointer artwork{ utils::createArtworkFromImage(session, filePath, contentType) }) + episode.modify()->setArtwork(artwork); + } + } // namespace + + core::LiteralString DownloadEpisodeArtworksStep::getName() const + { + return "Download episode artworks"; + } + + void DownloadEpisodeArtworksStep::run() + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + _episodeArtworksToDownload.clear(); + db::PodcastEpisode::find(session, db::PodcastEpisode::FindParameters{}, [&](const db::PodcastEpisode::pointer& episode) { + if (episode->getImageUrl().empty()) + return; + + if (episode->getArtworkId().isValid()) + return; + + _episodeArtworksToDownload.push_back(episode->getId()); + }); + + processNext(); + } + + void DownloadEpisodeArtworksStep::processNext() + { + if (abortRequested()) + { + onAbort(); + return; + } + + getExecutor().post([this] { + if (_episodeArtworksToDownload.empty()) + { + onDone(); + return; + } + + const db::PodcastEpisodeId podcastEpisodeId{ _episodeArtworksToDownload.front() }; + _episodeArtworksToDownload.pop_front(); + process(podcastEpisodeId); + }); + } + + void DownloadEpisodeArtworksStep::process(db::PodcastEpisodeId episodeId) + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + const auto episode{ db::PodcastEpisode::find(getDb().getTLSSession(), episodeId) }; + if (!episode) + { + LMS_LOG(PODCAST, DEBUG, "Cannot find episode: removed?"); + processNext(); + return; + } + + const std::string url{ episode->getImageUrl() }; + const std::filesystem::path finalFilePath{ getCachePath() / utils::generateRandomFileName() }; + + core::http::ClientGETRequestParameters params; + params.relativeUrl = episode->getImageUrl(); + params.onFailureFunc = [this, episode] { + LMS_LOG(PODCAST, ERROR, "Failed to download episode image from '" << episode->getImageUrl() << "'"); + processNext(); + }; + params.onSuccessFunc = [=, this](const Wt::Http::Message& msg) { + const std::string body{ msg.body() }; // API enforces a copy here + + std::ofstream file{ finalFilePath, std::ios::binary | std::ios::trunc }; + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to open file " << finalFilePath << " for writing: " << ec.message()); + processNext(); + return; + } + + file.write(body.data(), body.size()); + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to write to file " << finalFilePath << ": " << ec.message()); + processNext(); + return; + } + + const std::string* contentType{ msg.getHeader("Content-Type") }; + LMS_LOG(PODCAST, INFO, "Downloaded episode artwork for episode '" << episode->getTitle() << "' to " << finalFilePath << " with content type '" << (contentType ? *contentType : "unknown") << "', size = " << body.size() << " bytes"); + createEpisodeArtwork(getDb().getTLSSession(), episodeId, finalFilePath, contentType ? *contentType : "application/octet-stream"); + + processNext(); + }; + params.onAbortFunc = [this] { + onAbort(); + }; + + getClient().sendGETRequest(std::move(params)); + } +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.hpp b/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.hpp new file mode 100644 index 00000000..ea6ab5de --- /dev/null +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.hpp @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2025 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 + +#include "database/objects/PodcastEpisodeId.hpp" + +#include "RefreshStep.hpp" + +namespace lms::podcast +{ + class DownloadEpisodeArtworksStep : public RefreshStep + { + using RefreshStep::RefreshStep; + + private: + core::LiteralString getName() const override; + void run() override; + + void processNext(); + void process(db::PodcastEpisodeId episodeId); + + std::deque _episodeArtworksToDownload; + }; +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp new file mode 100644 index 00000000..8051ab32 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2025 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 "DownloadEpisodesStep.hpp" + +#include + +#include "core/IConfig.hpp" +#include "core/ILogger.hpp" +#include "core/Service.hpp" +#include "core/http/IClient.hpp" + +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" + +#include "Executor.hpp" +#include "Utils.hpp" + +namespace lms::podcast +{ + namespace + { + void updateEpisode(db::Session& session, db::PodcastEpisodeId episodeId, const std::filesystem::path& relativeFilePath) + { + auto transaction{ session.createWriteTransaction() }; + + db::PodcastEpisode::pointer dbEpisode{ db::PodcastEpisode::find(session, episodeId) }; + if (!dbEpisode) + return; // may have been deleted by admin + + dbEpisode.modify()->setAudioRelativeFilePath(relativeFilePath); + } + } // namespace + + DownloadEpisodesStep::DownloadEpisodesStep(RefreshContext& context, OnDoneCallback callback) + : RefreshStep{ context, std::move(callback) } + , _autoDownloadEpisodes{ core::Service::get()->getBool("podcast-auto-download-episodes", true) } + , _autoDownloadEpisodesMaxAge{ core::Service::get()->getULong("podcast-auto-download-episodes-max-age-days", 30) } + + { + } + + core::LiteralString DownloadEpisodesStep::getName() const + { + return "Download episodes"; + } + + void DownloadEpisodesStep::run() + { + collectEpisodes(); + processNext(); + } + + void DownloadEpisodesStep::collectEpisodes() + { + const Wt::WDateTime now{ Wt::WDateTime::currentDateTime() }; + + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + db::PodcastEpisode::FindParameters params; + + _episodesToDownload.clear(); + db::PodcastEpisode::find(session, params, [&](const db::PodcastEpisode::pointer& episode) { + if (!episode->getAudioRelativeFilePath().empty()) + return; // already downloaded + + switch (episode->getManualDownloadState()) + { + case db::PodcastEpisode::ManualDownloadState::DownloadRequested: + + LMS_LOG(PODCAST, DEBUG, "Adding episode '" << episode->getTitle() << "' from podcast '" << episode->getPodcast()->getTitle() << "' to download queue (manually requested)"); + _episodesToDownload.push_back(episode->getId()); + + break; + + case db::PodcastEpisode::ManualDownloadState::None: + if (_autoDownloadEpisodes && now < episode->getPubDate().addDays(_autoDownloadEpisodesMaxAge.count())) + { + LMS_LOG(PODCAST, DEBUG, "Adding episode '" << episode->getTitle() << "' from podcast '" << episode->getPodcast()->getTitle() << "' to download queue (auto-download enabled)"); + _episodesToDownload.push_back(episode->getId()); + } + break; + + case db::PodcastEpisode::ManualDownloadState::DeleteRequested: + break; + } + }); + } + + void DownloadEpisodesStep::processNext() + { + getExecutor().post([this] { + if (_episodesToDownload.empty()) + { + LMS_LOG(PODCAST, DEBUG, "All pending episodes downloaded"); + onDone(); + return; + } + + const db::PodcastEpisodeId podcastEpisodeId{ _episodesToDownload.front() }; + _episodesToDownload.pop_front(); + process(podcastEpisodeId); + }); + } + + void DownloadEpisodesStep::process(db::PodcastEpisodeId episodeId) + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, episodeId) }; + if (!episode) + { + LMS_LOG(PODCAST, DEBUG, "Cannot find episode: removed?"); // TODO if removed, need to keep it in the db to check for new episodes... + processNext(); + return; + } + + const std::string randomName{ utils::generateRandomFileName() }; + const std::filesystem::path tmpFilePath{ getTmpCachePath() / randomName }; + const std::filesystem::path finalFilePath{ getCachePath() / randomName }; + LMS_LOG(PODCAST, DEBUG, "Downloading episode '" << episode->getTitle() << "' from '" << episode->getEnclosureUrl() << "' in tmp file '" << tmpFilePath << "'"); + + core::http::ClientGETRequestParameters params; + const std::string url{ episode->getEnclosureUrl() }; + params.relativeUrl = url; + params.onFailureFunc = [this, episode] { + LMS_LOG(PODCAST, ERROR, "Failed to download podcast episode from '" << episode->getEnclosureUrl() << "'"); + processNext(); + }; + params.onChunkReceived = [url, tmpFilePath](std::span chunk) { + std::ofstream file{ tmpFilePath, std::ios::binary | std::ios::app }; + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to open file '" << tmpFilePath << "' for writing: " << ec.message()); + return core::http::ClientGETRequestParameters::ChunckReceivedResult::Abort; + } + + // check write status + file.write(reinterpret_cast(chunk.data()), chunk.size()); + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to write to file '" << tmpFilePath << "': " << ec.message()); + return core::http::ClientGETRequestParameters::ChunckReceivedResult::Abort; + } + + return core::http::ClientGETRequestParameters::ChunckReceivedResult::Continue; + }; + params.onSuccessFunc = [=, this]([[maybe_unused]] const Wt::Http::Message& msg) { + assert(msg.body().empty()); + getExecutor().post([=, this] { + LMS_LOG(PODCAST, DEBUG, "Download episode from '" << url << "' complete"); + LMS_LOG(PODCAST, DEBUG, "Renaming temp file " << tmpFilePath << " to " << finalFilePath); + + std::error_code ec; + std::filesystem::rename(tmpFilePath, finalFilePath, ec); + if (ec) + LMS_LOG(PODCAST, ERROR, "Failed to rename temp file " << tmpFilePath << " to " << finalFilePath << ": " << ec.message()); + else + updateEpisode(getDb().getTLSSession(), episodeId, randomName); + + // TODO: now the file is complete, should we attempt to read it and get the real information like duration and size? + + LMS_LOG(PODCAST, INFO, "Successfully downloaded episode '" << episode->getTitle() << "'"); + processNext(); + }); + }; + params.onAbortFunc = [this] { + onAbort(); + }; + + LMS_LOG(PODCAST, DEBUG, "Downloading episode from '" << url << "'..."); + getClient().sendGETRequest(std::move(params)); + } +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.hpp b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.hpp new file mode 100644 index 00000000..35d23265 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.hpp @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2025 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 + +#include "database/objects/PodcastEpisodeId.hpp" + +#include "RefreshStep.hpp" + +namespace lms::podcast +{ + class DownloadEpisodesStep : public RefreshStep + { + public: + DownloadEpisodesStep(RefreshContext& context, OnDoneCallback callback); + + private: + core::LiteralString getName() const override; + void run() override; + + void collectEpisodes(); + + void processNext(); + void process(db::PodcastEpisodeId episodeId); + + const bool _autoDownloadEpisodes; + const std::chrono::days _autoDownloadEpisodesMaxAge; + + std::deque _episodesToDownload; + }; + +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp b/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp new file mode 100644 index 00000000..e2f08372 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2025 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 "DownloadPodcastArtworksStep.hpp" + +#include +#include +#include + +#include "core/ILogger.hpp" +#include "core/http/IClient.hpp" +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Podcast.hpp" + +#include "Executor.hpp" +#include "Utils.hpp" + +namespace lms::podcast +{ + namespace + { + void createPodcastArtwork(db::Session& session, db::PodcastId podcastId, const std::filesystem::path& filePath, std::string_view contentType) + { + auto transaction{ session.createWriteTransaction() }; + + db::Podcast::pointer dbPodcast{ db::Podcast::find(session, podcastId) }; + if (!dbPodcast) + return; // may have been deleted by admin + + if (db::Artwork::pointer artwork{ utils::createArtworkFromImage(session, filePath, contentType) }) + dbPodcast.modify()->setArtwork(artwork); + } + } // namespace + + core::LiteralString DownloadPodcastArtworksStep::getName() const + { + return "Download podcast artworks"; + } + + void DownloadPodcastArtworksStep::run() + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + _podcastArtworksToDownload.clear(); + db::Podcast::find(session, [&](const db::Podcast::pointer& podcast) { + if (podcast->getImageUrl().empty() || podcast->getTitle().empty()) + return; + + if (podcast->getArtworkId().isValid()) + return; + + _podcastArtworksToDownload.push_back(podcast->getId()); + }); + + processNext(); + } + + void DownloadPodcastArtworksStep::processNext() + { + if (abortRequested()) + { + onAbort(); + return; + } + + getExecutor().post([this] { + if (_podcastArtworksToDownload.empty()) + { + onDone(); + return; + } + + const db::PodcastId podcastId{ _podcastArtworksToDownload.front() }; + _podcastArtworksToDownload.pop_front(); + process(podcastId); + }); + } + + void DownloadPodcastArtworksStep::process(db::PodcastId podcastId) + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + const auto podcast{ db::Podcast::find(getDb().getTLSSession(), podcastId) }; + if (!podcast) + { + LMS_LOG(PODCAST, DEBUG, "Cannot find podcast: removed?"); + processNext(); + return; + } + + const std::string url{ podcast->getImageUrl() }; + const std::filesystem::path finalFilePath{ getCachePath() / utils::generateRandomFileName() }; + + core::http::ClientGETRequestParameters params; + params.relativeUrl = podcast->getImageUrl(); + params.onFailureFunc = [this, podcast] { + LMS_LOG(PODCAST, ERROR, "Failed to download podcast image from '" << podcast->getImageUrl() << "'"); + processNext(); + }; + params.onSuccessFunc = [=, this](const Wt::Http::Message& msg) { + const std::string body{ msg.body() }; // API enforces a copy here + + std::ofstream file{ finalFilePath, std::ios::binary | std::ios::app }; + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to open file " << finalFilePath << " for writing: " << ec.message()); + processNext(); + return; + } + + file.write(body.data(), body.size()); + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to write to file " << finalFilePath << ": " << ec.message()); + processNext(); + return; + } + + const std::string* contentType{ msg.getHeader("Content-Type") }; + LMS_LOG(PODCAST, INFO, "Downloaded podcast artwork for podcast '" << podcast->getTitle() << "' to " << finalFilePath << " with content type '" << (contentType ? *contentType : "unknown") << "', size = " << body.size()); + createPodcastArtwork(getDb().getTLSSession(), podcastId, finalFilePath, contentType ? *contentType : "application/octet-stream"); + + processNext(); + }; + params.onAbortFunc = [this] { + onAbort(); + }; + + getClient().sendGETRequest(std::move(params)); + } +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.hpp b/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.hpp new file mode 100644 index 00000000..2e0b4c8d --- /dev/null +++ b/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.hpp @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2025 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 + +#include "database/objects/PodcastId.hpp" + +#include "RefreshStep.hpp" + +namespace lms::podcast +{ + class DownloadPodcastArtworksStep : public RefreshStep + { + using RefreshStep::RefreshStep; + + private: + core::LiteralString getName() const override; + void run() override; + + void processNext(); + void process(db::PodcastId podcastId); + + std::deque _podcastArtworksToDownload; + }; +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/RefreshPodcastsStep.cpp b/src/libs/services/podcast/impl/steps/RefreshPodcastsStep.cpp new file mode 100644 index 00000000..ea685dcf --- /dev/null +++ b/src/libs/services/podcast/impl/steps/RefreshPodcastsStep.cpp @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2025 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 "RefreshPodcastsStep.hpp" + +#include "core/ILogger.hpp" +#include "core/http/IClient.hpp" +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Image.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" + +#include "Executor.hpp" +#include "PodcastParsing.hpp" +#include "PodcastTypes.hpp" + +namespace lms::podcast +{ + namespace + { + void removeArtwork(db::Session& session, const db::Artwork::pointer& artwork) + { + const auto underlyingImageId{ artwork->getUnderlyingId() }; + const auto* imageId{ std::get_if(&underlyingImageId) }; + assert(imageId); // these artworks can only be an image + + std::error_code ec; + std::filesystem::remove(artwork->getAbsoluteFilePath(), ec); + if (ec) + LMS_LOG(PODCAST, WARNING, "Failed to remove old podcast artwork file '" << artwork->getAbsoluteFilePath() << "': " << ec.message()); + + session.destroy(*imageId); + } + + void updatePodcast(db::Session& session, db::PodcastId podcastId, const Podcast& podcast) + { + auto transaction{ session.createWriteTransaction() }; + + db::Podcast::pointer dbPodcast{ db::Podcast::find(session, podcastId) }; + if (!dbPodcast) + return; // may have been deleted by admin + + LMS_LOG(PODCAST, DEBUG, "Refreshing podcast '" << podcast.title << "' received from '" << dbPodcast->getUrl() << "'"); + + // force update the podcast data + if (!podcast.newUrl.empty() && podcast.newUrl != dbPodcast->getUrl()) + { + LMS_LOG(PODCAST, INFO, "Podcast '" << podcast.title << "' : URL changed from '" << dbPodcast->getUrl() << "' to '" << podcast.newUrl << "'"); + dbPodcast.modify()->setUrl(podcast.newUrl); + } + dbPodcast.modify()->setAuthor(podcast.author); + dbPodcast.modify()->setCategory(podcast.category); + dbPodcast.modify()->setCopyright(podcast.copyright); + dbPodcast.modify()->setDescription(podcast.description); + dbPodcast.modify()->setExplicit(podcast.explicitContent ? *podcast.explicitContent : false); + dbPodcast.modify()->setLanguage(podcast.language); + dbPodcast.modify()->setLastBuildDate(podcast.lastBuildDate); + dbPodcast.modify()->setLink(podcast.link); + dbPodcast.modify()->setOwnerEmail(podcast.ownerEmail); + dbPodcast.modify()->setOwnerName(podcast.ownerName); + dbPodcast.modify()->setSubtitle(podcast.subtitle); + dbPodcast.modify()->setSummary(podcast.summary); + dbPodcast.modify()->setTitle(podcast.title); + if (dbPodcast->getImageUrl() != podcast.imageUrl) + { + LMS_LOG(PODCAST, INFO, "Podcast '" << podcast.title << "' : image url changed from '" << dbPodcast->getImageUrl() << "' to '" << podcast.imageUrl << "'"); + if (db::Artwork::pointer currentArtwork{ dbPodcast->getArtwork() }) + removeArtwork(session, currentArtwork); + + dbPodcast.modify()->setImageUrl(podcast.imageUrl); + } + + // Only create episodes if they are new, do not modify/update existing entries for now + // TODO: update existing episodes, remove artwork if url changed + Wt::WDateTime previousNewestEpisodeDateTime{}; + if (db::PodcastEpisode::pointer dbEpisode{ db::PodcastEpisode::findNewtestEpisode(session, podcastId) }) + previousNewestEpisodeDateTime = dbEpisode->getPubDate(); + + // TODO: mark for deletion old episodes that are no longer referenced!! + for (const auto& episode : podcast.episodes) + { + if (previousNewestEpisodeDateTime.isValid() && episode.pubDate <= previousNewestEpisodeDateTime) + continue; // consider already in db + + LMS_LOG(PODCAST, DEBUG, "Adding episode '" << episode.title << "' to podcast '" << podcast.title << "'"); + + auto dbEpisode{ session.create(dbPodcast) }; + + dbEpisode.modify()->setAuthor(episode.author); + dbEpisode.modify()->setCategory(episode.category); + dbEpisode.modify()->setDescription(episode.description); + dbEpisode.modify()->setEnclosureUrl(episode.enclosureUrl.url); + dbEpisode.modify()->setEnclosureContentType(episode.enclosureUrl.type); + dbEpisode.modify()->setEnclosureLength(episode.enclosureUrl.length); + dbEpisode.modify()->setExplicit(episode.explicitContent ? *episode.explicitContent : false); + dbEpisode.modify()->setLink(episode.link); + dbEpisode.modify()->setPubDate(episode.pubDate); + dbEpisode.modify()->setTitle(episode.title); + dbEpisode.modify()->setImageUrl(episode.imageUrl); + dbEpisode.modify()->setDuration(episode.duration); + } + } + } // namespace + + core::LiteralString RefreshPodcastsStep::getName() const + { + return "Refresh podcasts"; + } + + void RefreshPodcastsStep::run() + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + db::Podcast::find(session, [this](const db::Podcast::pointer& podcast) { + LMS_LOG(PODCAST, DEBUG, "Found podcast to refresh at '" << podcast->getUrl() << "'"); + podcastsToRefresh.push(podcast->getId()); + }); + + refreshNextPodcast(); + } + + void RefreshPodcastsStep::refreshNextPodcast() + { + if (abortRequested()) + { + onAbort(); + return; + } + + getExecutor().post([this] { + if (podcastsToRefresh.empty()) + { + LMS_LOG(PODCAST, DEBUG, "All podcasts refreshed"); + onDone(); + return; + } + + const db::PodcastId podcastId{ podcastsToRefresh.front() }; + podcastsToRefresh.pop(); + refreshPodcast(podcastId); + }); + } + + void RefreshPodcastsStep::refreshPodcast(db::PodcastId podcastId) + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + const db::Podcast::pointer podcast{ db::Podcast::find(session, podcastId) }; + if (!podcast) + { + refreshNextPodcast(); // maybe removed in the meantime by admin + return; + } + + LMS_LOG(PODCAST, DEBUG, "Syncing podcast from '" << podcast->getUrl() << "'"); + + const std::string url{ podcast->getUrl() }; + core::http::ClientGETRequestParameters params; + params.relativeUrl = podcast->getUrl(); + params.onFailureFunc = [this, podcast] { + LMS_LOG(PODCAST, ERROR, "Failed to sync podcast from '" << podcast->getUrl() << "'"); + refreshNextPodcast(); + }; + params.onSuccessFunc = [this, podcast, podcastId](const Wt::Http::Message& msg) { + getExecutor().post([this, podcast, podcastId, msgBody = msg.body()] { + try + { + const auto podcast{ parsePodcastRssFeed(msgBody) }; + updatePodcast(getDb().getTLSSession(), podcastId, podcast); + } + catch (const ParseException& e) + { + LMS_LOG(PODCAST, ERROR, "Failed to parse rss feed from '" << podcast->getUrl() << "': " << e.what()); + } + refreshNextPodcast(); + }); + }; + params.onAbortFunc = [this] { + onAbort(); + }; + + getClient().sendGETRequest(std::move(params)); + } +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/RefreshPodcastsStep.hpp b/src/libs/services/podcast/impl/steps/RefreshPodcastsStep.hpp new file mode 100644 index 00000000..9cc2746f --- /dev/null +++ b/src/libs/services/podcast/impl/steps/RefreshPodcastsStep.hpp @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2025 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 + +#include "database/objects/PodcastId.hpp" + +#include "RefreshStep.hpp" + +namespace lms::podcast +{ + class RefreshPodcastsStep : public RefreshStep + { + using RefreshStep::RefreshStep; + + private: + core::LiteralString getName() const override; + void run() override; + + void refreshNextPodcast(); + void refreshPodcast(db::PodcastId podcastId); + + std::queue podcastsToRefresh; + }; + +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/RefreshStep.hpp b/src/libs/services/podcast/impl/steps/RefreshStep.hpp new file mode 100644 index 00000000..a8e890c2 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/RefreshStep.hpp @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2025 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 +#include +#include + +#include "core/LiteralString.hpp" + +#include "RefreshContext.hpp" + +namespace lms::podcast +{ + class RefreshStep + { + public: + using OnDoneCallback = std::function; + + RefreshStep(RefreshContext& context, OnDoneCallback callback) + : _context{ context } + , _onDoneCallback{ std::move(callback) } {} + virtual ~RefreshStep() = default; + + virtual core::LiteralString getName() const = 0; + virtual void run() = 0; + + void requestAbort(bool value) + { + _abortRequested = value; + } + + protected: + bool abortRequested() const + { + return _abortRequested; + } + + // Called by the step implementation when done + void onDone() + { + _onDoneCallback(true); + } + + // Called by the step implementation when it wants to abort the whole refresh process + void onAbort() + { + _onDoneCallback(false); + } + + Executor& getExecutor() + { + return _context.executor; + } + + db::IDb& getDb() + { + return _context.db; + } + + const std::filesystem::path& getCachePath() const + { + return _context.cachePath; + } + + const std::filesystem::path& getTmpCachePath() const + { + return _context.tmpCachePath; + } + + core::http::IClient& getClient() + { + return _context.client; + } + + private: + RefreshContext& _context; + OnDoneCallback _onDoneCallback; + std::atomic _abortRequested; + }; + +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/RemoveEpisodesStep.cpp b/src/libs/services/podcast/impl/steps/RemoveEpisodesStep.cpp new file mode 100644 index 00000000..57380c33 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/RemoveEpisodesStep.cpp @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2025 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 "RemoveEpisodesStep.hpp" + +#include +#include + +#include "core/IConfig.hpp" +#include "core/ILogger.hpp" +#include "core/Service.hpp" +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/PodcastEpisode.hpp" + +#include "Utils.hpp" + +namespace lms::podcast +{ + core::LiteralString RemoveEpisodesStep::getName() const + { + return "Remove podcast episodes"; + } + + RemoveEpisodesStep::RemoveEpisodesStep(RefreshContext& context, OnDoneCallback callback) + : RefreshStep{ context, std::move(callback) } + , _autoDownloadEpisodesMaxAge{ core::Service::get()->getULong("podcast-auto-download-episodes-max-age-days", 30) } + { + } + + void RemoveEpisodesStep::run() + { + std::vector episodesToRemove; + std::vector imagesToRemove; + + // Step 1 collect the episodes to remove + { + auto removePodcastFile{ [&](const db::PodcastEpisode::pointer& episode) { + // We keep the artwork of the episode (TODO, not if the episode is no longer referenced by the podcast?) + assert(!episode->getAudioRelativeFilePath().empty()); + utils::removeFile(getCachePath() / episode->getAudioRelativeFilePath()); + episodesToRemove.emplace_back(episode->getId()); + } }; + + const Wt::WDateTime now{ Wt::WDateTime::currentDateTime() }; + + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + db::PodcastEpisode::find(session, db::PodcastEpisode::FindParameters{}, [&](const db::PodcastEpisode::pointer& episode) { + switch (episode->getManualDownloadState()) + { + case db::PodcastEpisode::ManualDownloadState::None: + if (!episode->getAudioRelativeFilePath().empty() && now > episode->getPubDate().addDays(_autoDownloadEpisodesMaxAge.count())) // TODO make this configurable per podcast + { + LMS_LOG(PODCAST, INFO, "Removing episode '" << episode->getTitle() << "' because it is older than " << _autoDownloadEpisodesMaxAge.count() << " days"); + removePodcastFile(episode); + } + break; + + case db::PodcastEpisode::ManualDownloadState::DownloadRequested: + // always keep the manually downloaded episodes + break; + + case db::PodcastEpisode::ManualDownloadState::DeleteRequested: + if (!episode->getAudioRelativeFilePath().empty()) + { + LMS_LOG(PODCAST, DEBUG, "Removing episode '" << episode->getTitle() << "' because it was manually deleted"); + removePodcastFile(episode); + } + break; + } + }); + } + + // second step, remove the database entries (must be consistent with first step!) + if (!episodesToRemove.empty()) + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + for (db::PodcastEpisodeId episodeId : episodesToRemove) + { + if (db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, episodeId) }) + episode.modify()->setAudioRelativeFilePath(std::filesystem::path{}); + } + } + + onDone(); + } +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/RemoveEpisodesStep.hpp b/src/libs/services/podcast/impl/steps/RemoveEpisodesStep.hpp new file mode 100644 index 00000000..57ab5406 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/RemoveEpisodesStep.hpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2025 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 + +#include "RefreshStep.hpp" + +namespace lms::podcast +{ + class RemoveEpisodesStep : public RefreshStep + { + public: + RemoveEpisodesStep(RefreshContext& context, OnDoneCallback callback); + + private: + core::LiteralString getName() const override; + void run() override; + + const std::chrono::days _autoDownloadEpisodesMaxAge; + }; +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/RemovePodcastsStep.cpp b/src/libs/services/podcast/impl/steps/RemovePodcastsStep.cpp new file mode 100644 index 00000000..b94f88cd --- /dev/null +++ b/src/libs/services/podcast/impl/steps/RemovePodcastsStep.cpp @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2025 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 "RemovePodcastsStep.hpp" + +#include +#include + +#include "core/ILogger.hpp" +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Image.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" + +#include "Utils.hpp" + +namespace lms::podcast +{ + + core::LiteralString RemovePodcastsStep::getName() const + { + return "Remove podcasts"; + } + + void RemovePodcastsStep::run() + { + std::vector podcastsToRemove; + std::vector imagesToRemove; + + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + db::Podcast::find(session, [&](const db::Podcast::pointer& podcast) { + if (!podcast->isDeleteRequested()) + return; + + LMS_LOG(PODCAST, DEBUG, "Removing podcast '" << podcast->getUrl() << "'. Title: '" << podcast->getTitle() << "'"); + + // remove podcast artwork + if (const db::Artwork::pointer artwork{ podcast->getArtwork() }) + { + utils::removeFile(artwork->getAbsoluteFilePath()); + imagesToRemove.emplace_back(std::get(artwork->getUnderlyingId())); + } + + db::PodcastEpisode::FindParameters params; + params.setPodcast(podcast->getId()); + + db::PodcastEpisode::find(session, params, [&](const db::PodcastEpisode::pointer& episode) { + if (const db::Artwork::pointer artwork{ episode->getArtwork() }) + { + utils::removeFile(artwork->getAbsoluteFilePath()); + imagesToRemove.emplace_back(std::get(artwork->getUnderlyingId())); + } + + if (!episode->getAudioRelativeFilePath().empty()) + utils::removeFile(getCachePath() / episode->getAudioRelativeFilePath()); + }); + + podcastsToRemove.emplace_back(podcast->getId()); + }); + } + + // second step, remove the database entries (must be consistent with first step!) + if (!podcastsToRemove.empty() || !imagesToRemove.empty()) + { + auto& session{ getDb().getTLSSession() }; + auto transaction{ session.createWriteTransaction() }; + + session.destroy(podcastsToRemove); // will propagate to episodes + session.destroy(imagesToRemove); // will propagate to artworks + } + + onDone(); + } +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/RemovePodcastsStep.hpp b/src/libs/services/podcast/impl/steps/RemovePodcastsStep.hpp new file mode 100644 index 00000000..4e05fb34 --- /dev/null +++ b/src/libs/services/podcast/impl/steps/RemovePodcastsStep.hpp @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2025 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 "RefreshStep.hpp" + +namespace lms::podcast +{ + class RemovePodcastsStep : public RefreshStep + { + using RefreshStep::RefreshStep; + + private: + core::LiteralString getName() const override; + void run() override; + }; +} // namespace lms::podcast \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/Utils.cpp b/src/libs/services/podcast/impl/steps/Utils.cpp new file mode 100644 index 00000000..f60adaaa --- /dev/null +++ b/src/libs/services/podcast/impl/steps/Utils.cpp @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2025 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 "Utils.hpp" + +#include +#include + +#include "core/ILogger.hpp" +#include "core/UUID.hpp" +#include "database/Session.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Image.hpp" +#include "database/objects/Podcast.hpp" +#include "image/Exception.hpp" +#include "image/Image.hpp" +#include "image/Types.hpp" + +namespace lms::podcast::utils +{ + std::filesystem::path getPodcastRelativePath(const db::Podcast::pointer& podcast) + { + assert(podcast); + return podcast->getId().toString(); + } + + static std::optional probeImage(const std::filesystem::path& path) + { + try + { + return image::probeImage(path); + } + catch (const image::Exception& e) + { + LMS_LOG(PODCAST, WARNING, "Failed to probe artwork image " << path << ": " << e.what()); + return std::nullopt; + } + } + + db::Artwork::pointer createArtworkFromImage(db::Session& session, const std::filesystem::path& filePath, std::string_view mimeType) + { + std::error_code ec; + const auto fileSize{ std::filesystem::file_size(filePath, ec) }; + if (ec) + { + LMS_LOG(PODCAST, ERROR, "Failed to get file size of " << filePath << ": " << ec.message()); + return db::Artwork::pointer{}; + } + + db::Image::pointer image{ session.create(filePath) }; + image.modify()->setFileSize(static_cast(fileSize)); + if (const std::optional imageProperties{ probeImage(filePath) }) + { + image.modify()->setWidth(imageProperties->width); + image.modify()->setHeight(imageProperties->height); + } + image.modify()->setLastWriteTime(Wt::WDateTime::currentDateTime()); + image.modify()->setMimeType(mimeType); + + return session.create(image); + } + + std::string generateRandomFileName() + { + return std::string{ core::UUID::generate().getAsString() }; + } + + void removeFile(const std::filesystem::path& filePath) + { + std::error_code ec; + std::filesystem::remove(filePath, ec); + if (ec) + LMS_LOG(PODCAST, WARNING, "Failed to remove file " << filePath << ": " << ec.message()); + else + LMS_LOG(PODCAST, DEBUG, "Removed file " << filePath); + } +} // namespace lms::podcast::utils \ No newline at end of file diff --git a/src/libs/services/podcast/impl/steps/Utils.hpp b/src/libs/services/podcast/impl/steps/Utils.hpp new file mode 100644 index 00000000..cf7034bd --- /dev/null +++ b/src/libs/services/podcast/impl/steps/Utils.hpp @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2025 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 +#include + +#include "database/Object.hpp" + +namespace lms::db +{ + class Artwork; + class Session; +} // namespace lms::db + +namespace lms::podcast::utils +{ + db::ObjectPtr createArtworkFromImage(db::Session& session, const std::filesystem::path& filePath, std::string_view mimeType); + std::string generateRandomFileName(); + void removeFile(const std::filesystem::path& filePath); +} // namespace lms::podcast::utils \ No newline at end of file diff --git a/src/libs/services/podcast/include/services/podcast/IPodcastService.hpp b/src/libs/services/podcast/include/services/podcast/IPodcastService.hpp new file mode 100644 index 00000000..38d6768a --- /dev/null +++ b/src/libs/services/podcast/include/services/podcast/IPodcastService.hpp @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2025 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 +#include + +#include + +#include "database/objects/PodcastEpisodeId.hpp" +#include "database/objects/PodcastId.hpp" + +namespace lms::db +{ + class IDb; +} + +namespace lms::podcast +{ + class IPodcastService + { + public: + virtual ~IPodcastService() = default; + + virtual std::filesystem::path getCachePath() const = 0; + + virtual db::PodcastId addPodcast(std::string_view url) = 0; + virtual bool removePodcast(db::PodcastId podcast) = 0; + virtual void refreshPodcasts() = 0; + + virtual bool downloadPodcastEpisode(db::PodcastEpisodeId episode) = 0; + virtual bool deletePodcastEpisode(db::PodcastEpisodeId episode) = 0; + }; + + std::unique_ptr createPodcastService(boost::asio::io_context& ioContext, db::IDb& db, const std::filesystem::path& cachePath); +} // namespace lms::podcast diff --git a/src/libs/services/podcast/test/CMakeLists.txt b/src/libs/services/podcast/test/CMakeLists.txt new file mode 100644 index 00000000..d83e9033 --- /dev/null +++ b/src/libs/services/podcast/test/CMakeLists.txt @@ -0,0 +1,19 @@ +add_executable(test-podcast + PodcastParser.cpp + PodcastService.cpp + ) + +target_link_libraries(test-podcast PRIVATE + lmscore + lmspodcast + GTest::GTest + ) + +target_include_directories(test-podcast PRIVATE + ../impl + ) + +if (NOT CMAKE_CROSSCOMPILING) + gtest_discover_tests(test-podcast) +endif() + diff --git a/src/libs/services/podcast/test/PodcastParser.cpp b/src/libs/services/podcast/test/PodcastParser.cpp new file mode 100644 index 00000000..d5ec1de9 --- /dev/null +++ b/src/libs/services/podcast/test/PodcastParser.cpp @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2025 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 + +#include +#include +#include + +#include "PodcastParsing.hpp" + +namespace lms::podcast::tests +{ + TEST(Podcast, PodcastParsing) + { + constexpr std::string_view xmlData{ R"( + +Affaires sensibles +https://www.franceinter.fr/emission-affaires-sensibles +Les grandes affaires, les aventures et les procès qui ont marqué les cinquante dernières années. Vous aimez ce podcast ? Pour écouter tous les épisodes sans limite, rendez-vous sur Radio France. +fr +Radio France +Sat, 09 Aug 2025 21:34:32 +0200 +Radio France + +https://www.radiofrance.fr/s3/cruiser-production/2023/05/5f197ac5-b950-4149-8578-ae12aa6695b0/1400x1400_sc_rf_omm_0000038645_ite.jpg +Affaires sensibles +https://www.franceinter.fr/emission-affaires-sensibles + +France Inter + +no + + +podcast@radiofrance.com +Radio France + +Affaires sensibles +Les grandes affaires, les aventures et les procès qui ont marqué les cinquante dernières années. Vous aimez ce podcast ? Pour écouter tous les épisodes sans limite, rendez-vous sur Radio France. +https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/rss_13940.xml +https://radiofrance-podcast.net/podcast09/d4463877-caa3-4507-9399-f5eb00fde027/rss_13940.xml +1 +yes + +Apollo 13 ou les naufragés de l’espace +https://www.radiofrance.fr/franceinter/podcasts/affaires-sensibles/affaires-sensibles-du-jeudi-30-decembre-2021-4186094 +durée : 00:53:58 - Affaires sensibles - par : Christophe Barreyre, Fabrice Drouelle - C'est une épopée qui réunit héroïsme, génie technologique, audace diplomatique, sens du tragique. L’odyssée d'Apollo 13 c’est un vaisseau spatial en perdition, là-haut, flirtant avec la lune mais qui au dernier moment se dérobe pour une vulgaire panne électrique dans la soute du vaisseau. - réalisé par : Flora BERNARD, Marion Le Lay, Stéphane COSME Vous aimez ce podcast ? Pour écouter tous les autres épisodes sans limite, rendez-vous sur Radio France. +podcast@radiofrance.com (Radio France) +Society & Culture + +9f587824-01f5-443d-aa72-a56519d25857-NET_MFI_F7191B05-DD5B-4AFE-BB96-3BD8ADB3240A +Sat, 09 Aug 2025 15:59:59 +0200 +22805 +2021F22805S0364 +Apollo 13 ou les naufragés de l’espace + +Christophe Barreyre, Fabrice Drouelle +no +Apollo,13,ou,les,naufragés,de,l’espace +Apollo 13 ou les naufragés de l’espace +durée : 00:53:58 - Affaires sensibles - par : Christophe Barreyre, Fabrice Drouelle - C'est une épopée qui réunit héroïsme, génie technologique, audace diplomatique, sens du tragique. L’odyssée d'Apollo 13 c’est un vaisseau spatial en perdition, là-haut, flirtant avec la lune mais qui au dernier moment se dérobe pour une vulgaire panne électrique dans la soute du vaisseau. - réalisé par : Flora BERNARD, Marion Le Lay, Stéphane COSME Vous aimez ce podcast ? Pour écouter tous les autres épisodes sans limite, rendez-vous sur Radio France. +00:53:58 +yes + + +Stéphane Breitwieser, le pilleur de musées +https://www.radiofrance.fr/franceinter/podcasts/affaires-sensibles/affaires-sensibles-du-mercredi-02-avril-2025-9048848 +durée : 00:47:45 - Affaires sensibles - par : Fabrice Drouelle, Franck COGNARD - Aujourd’hui dans Affaires Sensibles : l’affaire Stéphane Breitwieser, le pilleur de musées - réalisé par : Etienne BERTIN Vous aimez ce podcast ? Pour écouter tous les autres épisodes sans limite, rendez-vous sur Radio France. +podcast@radiofrance.com (Radio France) +Society & Culture + +719c21be-3832-4b67-8d65-9d07b3715d7b-NET_MFI_FF964E00-DFB6-45AC-A9D2-2D1211F37586 +Fri, 08 Aug 2025 20:59:59 +0200 +22805 +2025F22805S0092 +Stéphane Breitwieser, le pilleur de musées + +Fabrice Drouelle, Franck COGNARD +no +Stéphane,Breitwieser,,le,pilleur,de,musées +Stéphane Breitwieser, le pilleur de musées +durée : 00:47:45 - Affaires sensibles - par : Fabrice Drouelle, Franck COGNARD - Aujourd’hui dans Affaires Sensibles : l’affaire Stéphane Breitwieser, le pilleur de musées - réalisé par : Etienne BERTIN Vous aimez ce podcast ? Pour écouter tous les autres épisodes sans limite, rendez-vous sur Radio France. +00:47:45 +yes + + +)" }; + + const Podcast podcast{ parsePodcastRssFeed(xmlData) }; + EXPECT_EQ(podcast.title, "Affaires sensibles"); + EXPECT_EQ(podcast.description, R"(Les grandes affaires, les aventures et les procès qui ont marqué les cinquante dernières années. Vous aimez ce podcast ? Pour écouter tous les épisodes sans limite, rendez-vous sur Radio France.)"); + EXPECT_EQ(podcast.author, "France Inter"); + EXPECT_EQ(podcast.link, "https://www.franceinter.fr/emission-affaires-sensibles"); + EXPECT_EQ(podcast.language, "fr"); + EXPECT_EQ(podcast.copyright, "Radio France"); + EXPECT_EQ(podcast.lastBuildDate, (Wt::WDateTime{ Wt::WDate{ 2025, 8, 9 }, Wt::WTime{ 23, 34, 32 } })); + EXPECT_EQ(podcast.imageUrl, "https://www.radiofrance.fr/s3/cruiser-production/2023/05/5f197ac5-b950-4149-8578-ae12aa6695b0/1400x1400_sc_rf_omm_0000038645_ite.jpg"); + // itunes + EXPECT_EQ(podcast.copyright, "Radio France"); + EXPECT_EQ(podcast.author, "France Inter"); + EXPECT_EQ(podcast.category, "Society & Culture"); + EXPECT_EQ(podcast.explicitContent, false); + EXPECT_EQ(podcast.ownerEmail, "podcast@radiofrance.com"); + EXPECT_EQ(podcast.ownerName, "Radio France"); + EXPECT_EQ(podcast.subtitle, "Affaires sensibles"); + EXPECT_EQ(podcast.summary, R"(Les grandes affaires, les aventures et les procès qui ont marqué les cinquante dernières années. Vous aimez ce podcast ? Pour écouter tous les épisodes sans limite, rendez-vous sur Radio France.)"); + + ASSERT_EQ(podcast.episodes.size(), 2); + EXPECT_EQ(podcast.episodes[0].title, R"(Apollo 13 ou les naufragés de l’espace)"); + EXPECT_EQ(podcast.episodes[0].description, R"(durée : 00:53:58 - Affaires sensibles - par : Christophe Barreyre, Fabrice Drouelle - C'est une épopée qui réunit héroïsme, génie technologique, audace diplomatique, sens du tragique. L’odyssée d'Apollo 13 c’est un vaisseau spatial en perdition, là-haut, flirtant avec la lune mais qui au dernier moment se dérobe pour une vulgaire panne électrique dans la soute du vaisseau. - réalisé par : Flora BERNARD, Marion Le Lay, Stéphane COSME Vous aimez ce podcast ? Pour écouter tous les autres épisodes sans limite, rendez-vous sur Radio France.)"); + EXPECT_EQ(podcast.episodes[0].author, "Christophe Barreyre, Fabrice Drouelle"); + EXPECT_EQ(podcast.episodes[0].explicitContent, false); + EXPECT_EQ(podcast.episodes[0].imageUrl, "https://www.radiofrance.fr/s3/cruiser-production/2021/04/ec0f1c5d-ecfa-4ec4-a5a5-30f446d25aea/1400x1400_affaires_sensibles.jpg"); + EXPECT_EQ(podcast.episodes[0].link, "https://www.radiofrance.fr/franceinter/podcasts/affaires-sensibles/affaires-sensibles-du-jeudi-30-decembre-2021-4186094"); + EXPECT_EQ(podcast.episodes[0].pubDate, (Wt::WDateTime{ Wt::WDate{ 2025, 8, 9 }, Wt::WTime{ 17, 59, 59 } })); + EXPECT_EQ(podcast.episodes[0].guid, "9f587824-01f5-443d-aa72-a56519d25857-NET_MFI_F7191B05-DD5B-4AFE-BB96-3BD8ADB3240A"); + EXPECT_EQ(podcast.episodes[0].enclosureUrl.url, "https://proxycast.radiofrance.fr/e2a713a6-aba0-4d2a-a4a1-d135d98f1f8a/13940-09.08.2025-ITEMA_24214125-2021F22805S0364-NET_MFI_F7191B05-DD5B-4AFE-BB96-3BD8ADB3240A-22.mp3"); + EXPECT_EQ(podcast.episodes[0].enclosureUrl.length, 51842568); + EXPECT_EQ(podcast.episodes[0].enclosureUrl.type, "audio/mpeg"); + EXPECT_EQ(podcast.episodes[0].duration, std::chrono::minutes{ 53 } + std::chrono::seconds{ 58 }); + + EXPECT_EQ(podcast.episodes[1].title, R"(Stéphane Breitwieser, le pilleur de musées)"); + EXPECT_EQ(podcast.episodes[1].description, R"(durée : 00:47:45 - Affaires sensibles - par : Fabrice Drouelle, Franck COGNARD - Aujourd’hui dans Affaires Sensibles : l’affaire Stéphane Breitwieser, le pilleur de musées - réalisé par : Etienne BERTIN Vous aimez ce podcast ? Pour écouter tous les autres épisodes sans limite, rendez-vous sur Radio France.)"); + EXPECT_EQ(podcast.episodes[1].author, "Fabrice Drouelle, Franck COGNARD"); + EXPECT_EQ(podcast.episodes[1].explicitContent, false); + EXPECT_EQ(podcast.episodes[1].imageUrl, "https://www.radiofrance.fr/s3/cruiser-production/2023/04/7b50cf5f-f5bd-4dc4-8b1d-b08666768dcf/1400x1400_sc_affaires-sensibles.jpg"); + EXPECT_EQ(podcast.episodes[1].link, "https://www.radiofrance.fr/franceinter/podcasts/affaires-sensibles/affaires-sensibles-du-mercredi-02-avril-2025-9048848"); + EXPECT_EQ(podcast.episodes[1].pubDate, (Wt::WDateTime{ Wt::WDate{ 2025, 8, 8 }, Wt::WTime{ 22, 59, 59 } })); + EXPECT_EQ(podcast.episodes[1].guid, "719c21be-3832-4b67-8d65-9d07b3715d7b-NET_MFI_FF964E00-DFB6-45AC-A9D2-2D1211F37586"); + EXPECT_EQ(podcast.episodes[1].enclosureUrl.url, "https://proxycast.radiofrance.fr/d0895b0b-a99c-4e9d-9d99-13a029960d04/13940-08.08.2025-ITEMA_24213067-2025F22805S0092-NET_MFI_FF964E00-DFB6-45AC-A9D2-2D1211F37586-22.mp3"); + EXPECT_EQ(podcast.episodes[1].enclosureUrl.length, 45869054); + EXPECT_EQ(podcast.episodes[1].enclosureUrl.type, "audio/mpeg"); + EXPECT_EQ(podcast.episodes[1].duration, std::chrono::minutes{ 47 } + std::chrono::seconds{ 45 }); + } +} // namespace lms::podcast::tests \ No newline at end of file diff --git a/src/libs/services/podcast/test/PodcastService.cpp b/src/libs/services/podcast/test/PodcastService.cpp new file mode 100644 index 00000000..23dad31e --- /dev/null +++ b/src/libs/services/podcast/test/PodcastService.cpp @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2025 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 + +#include "core/ILogger.hpp" +#include "core/Service.hpp" + +int main(int argc, char** argv) +{ + using namespace lms; + core::Service logger{ core::logging::createLogger(core::logging::Severity::ERROR) }; + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index f321a31e..d80090ad 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -153,14 +153,15 @@ namespace lms::scanner } // namespace - std::unique_ptr createScannerService(db::IDb& db) + std::unique_ptr createScannerService(db::IDb& db, const std::filesystem::path& cachePath) { - return std::make_unique(db); + return std::make_unique(db, cachePath); } - ScannerService::ScannerService(db::IDb& db) + ScannerService::ScannerService(db::IDb& db, const std::filesystem::path& cachePath) : _db{ db } , _jobScheduler{ core::createJobScheduler("Scanner", getScannerThreadCount()) } + , _cachePath{ cachePath } { _ioService.setThreadCount(1); @@ -496,6 +497,7 @@ namespace lms::scanner .abortScan = _abortScan, .db = _db, .fileScanners = _fileScanners, + .cachePath = _cachePath }; // Order is important: steps are sequential diff --git a/src/libs/services/scanner/impl/ScannerService.hpp b/src/libs/services/scanner/impl/ScannerService.hpp index 88779ae9..5c6709ec 100644 --- a/src/libs/services/scanner/impl/ScannerService.hpp +++ b/src/libs/services/scanner/impl/ScannerService.hpp @@ -31,11 +31,12 @@ #include #include -#include "FileScanners.hpp" -#include "ScannerSettings.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" #include "services/scanner/IScannerService.hpp" + +#include "FileScanners.hpp" +#include "ScannerSettings.hpp" #include "steps/IScanStep.hpp" namespace lms::core @@ -53,7 +54,7 @@ namespace lms::scanner class ScannerService : public IScannerService { public: - ScannerService(db::IDb& db); + ScannerService(db::IDb& db, const std::filesystem::path& cachePath); ~ScannerService() override; ScannerService(const ScannerService&) = delete; ScannerService& operator=(const ScannerService&) = delete; @@ -89,6 +90,7 @@ namespace lms::scanner db::IDb& _db; std::unique_ptr _jobScheduler; + const std::filesystem::path _cachePath; FileScanners _fileScanners; std::vector> _scanSteps; diff --git a/src/libs/services/scanner/impl/steps/ScanStepBase.cpp b/src/libs/services/scanner/impl/steps/ScanStepBase.cpp index ba1be78c..d4ebd61a 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepBase.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepBase.cpp @@ -30,6 +30,7 @@ namespace lms::scanner , _db{ initParams.db } , _jobScheduler{ initParams.jobScheduler } , _fileScanners(initParams.fileScanners) + , _cachePath{ initParams.cachePath } , _lastScanSettings{ initParams.lastScanSettings } { } diff --git a/src/libs/services/scanner/impl/steps/ScanStepBase.hpp b/src/libs/services/scanner/impl/steps/ScanStepBase.hpp index 93b35343..980628ae 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepBase.hpp +++ b/src/libs/services/scanner/impl/steps/ScanStepBase.hpp @@ -55,6 +55,7 @@ namespace lms::scanner bool& abortScan; db::IDb& db; const FileScanners& fileScanners; + const std::filesystem::path& cachePath; }; ScanStepBase(InitParams& initParams); ~ScanStepBase() override; @@ -65,6 +66,7 @@ namespace lms::scanner core::IJobScheduler& getJobScheduler() { return _jobScheduler; }; const ScannerSettings* getLastScanSettings() const { return _lastScanSettings; } const FileScanners& getFileScanners() const { return _fileScanners; } + const std::filesystem::path& getCachePath() const { return _cachePath; } void addError(ScanContext& context, std::shared_ptr error); @@ -83,6 +85,7 @@ namespace lms::scanner private: core::IJobScheduler& _jobScheduler; const FileScanners& _fileScanners; + const std::filesystem::path& _cachePath; const ScannerSettings* _lastScanSettings{}; ScanErrorLogger _scanErrorLogger; diff --git a/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp b/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp index 9a6bee1a..c45903ed 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp @@ -63,6 +63,9 @@ namespace lms::scanner { } + CheckForRemovedFilesJob(const CheckForRemovedFilesJob&) = delete; + CheckForRemovedFilesJob& operator=(const CheckForRemovedFilesJob&) = delete; + std::size_t getProcessedCount() const { return _processedCount; } std::span getObjectsToRemove() const { return _objectsToRemove; } @@ -90,8 +93,7 @@ namespace lms::scanner return false; } - // For each track, make sure the the file still exists - // and still belongs to a media directory + // For file, make sure the the file still exists, is a regular file, is in a media directory and is of a supported format if (!fileEntry.exists() || !fileEntry.is_regular_file()) { LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": missing"); @@ -151,7 +153,7 @@ namespace lms::scanner } template - bool fetchNextFilesToCheck(db::Session& session, typename Object::IdType& lastCheckedId, std::vector>& filesToCheck) + bool fetchNextFilesToCheck(db::Session& session, typename Object::IdType& lastCheckedId, const std::filesystem::path& cachepath, std::vector>& filesToCheck) { constexpr std::size_t batchSize{ 200 }; @@ -164,6 +166,10 @@ namespace lms::scanner { const typename Object::IdType previousLastCheckedId{ lastCheckedId }; Object::findAbsoluteFilePath(session, lastCheckedId, batchSize, [&](Object::IdType objectId, const std::filesystem::path& filePath) { + // Do not consider files in the cache directory as they are not managed by the scanner itself + if (core::pathUtils::isPathInRootPath(filePath, cachepath)) + return; + // special case for track lyrics, only check external lyrics if constexpr (std::is_same_v) { @@ -242,12 +248,11 @@ namespace lms::scanner ObjectIdType lastCheckedId; std::vector> filesToCheck; - while (fetchNextFilesToCheck(session, lastCheckedId, filesToCheck)) + while (fetchNextFilesToCheck(session, lastCheckedId, getCachePath(), filesToCheck)) queue.push(std::make_unique>(_settings, getFileScanners(), filesToCheck)); } // process all remaining objects context.stats.deletions += removeObjects(session, objectIdsToRemove, false); } - } // namespace lms::scanner diff --git a/src/libs/services/scanner/include/services/scanner/IScannerService.hpp b/src/libs/services/scanner/include/services/scanner/IScannerService.hpp index fa272396..70b06292 100644 --- a/src/libs/services/scanner/include/services/scanner/IScannerService.hpp +++ b/src/libs/services/scanner/include/services/scanner/IScannerService.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include "ScannerEvents.hpp" @@ -61,5 +62,5 @@ namespace lms::scanner virtual Events& getEvents() = 0; }; - std::unique_ptr createScannerService(db::IDb& db); + std::unique_ptr createScannerService(db::IDb& db, const std::filesystem::path& cachePath); } // namespace lms::scanner diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp index 192695fa..a1ba0590 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -27,7 +27,6 @@ #include #include -#include "ListensParser.hpp" #include "core/IConfig.hpp" #include "core/Service.hpp" #include "core/http/IClient.hpp" @@ -40,6 +39,7 @@ #include "database/objects/User.hpp" #include "services/scrobbling/Exception.hpp" +#include "ListensParser.hpp" #include "Utils.hpp" namespace lms::scrobbling::listenBrainz @@ -246,7 +246,7 @@ namespace lms::scrobbling::listenBrainz saveListen(timedListen, db::SyncState::PendingAdd); request.priority = core::http::ClientRequestParameters::Priority::Normal; - request.onSuccessFunc = [this, timedListen](std::string_view) { + request.onSuccessFunc = [this, timedListen](const Wt::Http::Message&) { boost::asio::post(boost::asio::bind_executor(_strand, [this, timedListen] { if (saveListen(timedListen, db::SyncState::Synchronized)) { @@ -456,8 +456,8 @@ namespace lms::scrobbling::listenBrainz request.priority = core::http::ClientRequestParameters::Priority::Low; request.relativeUrl = "/1/validate-token"; request.headers = { { "Authorization", "Token " + std::string{ listenBrainzToken->getAsString() } } }; - request.onSuccessFunc = [this, &context](std::string_view msgBody) { - context.listenBrainzUserName = utils::parseValidateToken(msgBody); + request.onSuccessFunc = [this, &context](const Wt::Http::Message& msg) { + context.listenBrainzUserName = utils::parseValidateToken(msg.body()); if (context.listenBrainzUserName.empty()) { onSyncEnded(context); @@ -479,8 +479,8 @@ namespace lms::scrobbling::listenBrainz core::http::ClientGETRequestParameters request; request.relativeUrl = "/1/user/" + std::string{ context.listenBrainzUserName } + "/listen-count"; request.priority = core::http::ClientRequestParameters::Priority::Low; - request.onSuccessFunc = [this, &context](std::string_view msgBody) { - const auto listenCount{ parseListenCount(msgBody) }; + request.onSuccessFunc = [this, &context](const Wt::Http::Message& msg) { + const auto listenCount{ parseListenCount(msg.body()) }; boost::asio::post(boost::asio::bind_executor(_strand, [this, listenCount, &context] { if (listenCount) LOG(DEBUG, "Listen count for listenbrainz user '" << context.listenBrainzUserName << "' = " << *listenCount); @@ -512,8 +512,8 @@ namespace lms::scrobbling::listenBrainz core::http::ClientGETRequestParameters request; request.relativeUrl = "/1/user/" + context.listenBrainzUserName + "/listens?max_ts=" + std::to_string(context.maxDateTime.toTime_t()); request.priority = core::http::ClientRequestParameters::Priority::Low; - request.onSuccessFunc = [this, &context](std::string_view msgBody) { - processGetListensResponse(msgBody, context); + request.onSuccessFunc = [this, &context](const Wt::Http::Message& msg) { + processGetListensResponse(msg.body(), context); if (context.fetchedListenCount >= _maxSyncListenCount || !context.maxDateTime.isValid()) { onSyncEnded(context); diff --git a/src/libs/services/transcoding/impl/TranscodingService.cpp b/src/libs/services/transcoding/impl/TranscodingService.cpp index 5a14820c..31c32f89 100644 --- a/src/libs/services/transcoding/impl/TranscodingService.cpp +++ b/src/libs/services/transcoding/impl/TranscodingService.cpp @@ -23,7 +23,6 @@ #include "core/ILogger.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" -#include "database/objects/Track.hpp" #include "TranscodingResourceHandler.hpp" @@ -64,20 +63,17 @@ namespace lms::transcoding { av::InputParameters avInputParams; std::optional estimatedContentLength; + + avInputParams.file = inputParameters.filePath; + avInputParams.offset = inputParameters.offset; + avInputParams.streamIndex = inputParameters.streamIndex; + + if (estimateContentLength) { - auto& session{ _db.getTLSSession() }; - auto transaction{ session.createReadTransaction() }; - - db::Track::pointer track{ db::Track::find(session, inputParameters.trackId) }; - if (!track) - return nullptr; - - avInputParams.file = track->getAbsoluteFilePath(); - avInputParams.offset = inputParameters.offset; - avInputParams.streamIndex = inputParameters.streamIndex; - - if (estimateContentLength) - estimatedContentLength = doEstimateContentLength(outputParameters.bitrate, track->getDuration()); + if (inputParameters.offset < inputParameters.duration) + estimatedContentLength = doEstimateContentLength(outputParameters.bitrate, inputParameters.duration - inputParameters.offset); + else + LMS_LOG(TRANSCODING, WARNING, "Offset " << inputParameters.offset << " is greater than audio file duration " << inputParameters.duration << ": not estimating content length"); } return std::make_unique(avInputParams, toAv(outputParameters), estimatedContentLength); diff --git a/src/libs/services/transcoding/include/services/transcoding/ITranscodingService.hpp b/src/libs/services/transcoding/include/services/transcoding/ITranscodingService.hpp index 593a47e6..28bf4943 100644 --- a/src/libs/services/transcoding/include/services/transcoding/ITranscodingService.hpp +++ b/src/libs/services/transcoding/include/services/transcoding/ITranscodingService.hpp @@ -19,12 +19,11 @@ #pragma once +#include #include #include #include -#include "database/objects/TrackId.hpp" - namespace lms { namespace core @@ -43,9 +42,10 @@ namespace lms::transcoding { struct InputParameters { - db::TrackId trackId; - std::chrono::milliseconds offset{}; // Offset in the track file to start transcoding from - std::optional streamIndex; // Index of the stream to be transcoded (select "best" audio stream if not set) + std::filesystem::path filePath; + std::chrono::milliseconds duration{}; // Duration of the audio file + std::chrono::milliseconds offset{}; // Offset in the audio file to start transcoding from + std::optional streamIndex; // Index of the stream to be transcoded (select the "best" audio stream if not set) }; enum class OutputFormat diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index e1e9fe23..e82ccfce 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(lmssubsonic STATIC impl/endpoints/MediaLibraryScanning.cpp impl/endpoints/MediaRetrieval.cpp impl/endpoints/Playlists.cpp + impl/endpoints/Podcast.cpp impl/endpoints/Searching.cpp impl/endpoints/System.cpp impl/endpoints/UserManagement.cpp @@ -21,6 +22,7 @@ add_library(lmssubsonic STATIC impl/responses/Genre.cpp impl/responses/Lyrics.cpp impl/responses/Playlist.cpp + impl/responses/Podcast.cpp impl/responses/RecordLabel.cpp impl/responses/ReplayGain.cpp impl/responses/Song.cpp @@ -49,6 +51,7 @@ target_link_libraries(lmssubsonic PRIVATE lmsav lmsdatabase lmsfeedback + lmspodcast lmsrecommendation lmsscanner lmsscrobbling diff --git a/src/libs/subsonic/impl/ProtocolVersion.hpp b/src/libs/subsonic/impl/ProtocolVersion.hpp index 337f39e9..86fbab79 100644 --- a/src/libs/subsonic/impl/ProtocolVersion.hpp +++ b/src/libs/subsonic/impl/ProtocolVersion.hpp @@ -34,7 +34,6 @@ namespace lms::api::subsonic }; static inline constexpr ProtocolVersion defaultServerProtocolVersion{ 1, 16, 0 }; - static inline constexpr std::string_view serverVersion{ "8" }; } // namespace lms::api::subsonic namespace lms::core::stringUtils diff --git a/src/libs/subsonic/impl/SubsonicId.cpp b/src/libs/subsonic/impl/SubsonicId.cpp index 4b3ae57d..11afc904 100644 --- a/src/libs/subsonic/impl/SubsonicId.cpp +++ b/src/libs/subsonic/impl/SubsonicId.cpp @@ -19,8 +19,6 @@ #include "SubsonicId.hpp" -#include "core/String.hpp" - namespace lms::api::subsonic { std::string idToString(db::ArtistId id) @@ -33,6 +31,16 @@ namespace lms::api::subsonic return "dir-" + id.toString(); } + std::string idToString(db::PodcastEpisodeId id) + { + return "podep-" + id.toString(); + } + + std::string idToString(db::PodcastId id) + { + return "pod-" + id.toString(); + } + std::string idToString(db::ReleaseId id) { return "al-" + id.toString(); @@ -92,6 +100,38 @@ namespace lms::core::stringUtils return std::nullopt; } + template<> + std::optional readAs(std::string_view str) + { + std::vector values{ core::stringUtils::splitString(str, '-') }; + if (values.size() != 2) + return std::nullopt; + + if (values[0] != "podep") + return std::nullopt; + + if (const auto value{ core::stringUtils::readAs(values[1]) }) + return db::PodcastEpisodeId{ *value }; + + return std::nullopt; + } + + template<> + std::optional readAs(std::string_view str) + { + std::vector values{ core::stringUtils::splitString(str, '-') }; + if (values.size() != 2) + return std::nullopt; + + if (values[0] != "pod") + return std::nullopt; + + if (const auto value{ core::stringUtils::readAs(values[1]) }) + return db::PodcastId{ *value }; + + return std::nullopt; + } + template<> std::optional readAs(std::string_view str) { diff --git a/src/libs/subsonic/impl/SubsonicId.hpp b/src/libs/subsonic/impl/SubsonicId.hpp index 7dac636c..4f1983e4 100644 --- a/src/libs/subsonic/impl/SubsonicId.hpp +++ b/src/libs/subsonic/impl/SubsonicId.hpp @@ -23,6 +23,8 @@ #include "database/objects/ArtistId.hpp" #include "database/objects/DirectoryId.hpp" #include "database/objects/MediaLibraryId.hpp" +#include "database/objects/PodcastEpisodeId.hpp" +#include "database/objects/PodcastId.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" #include "database/objects/TrackListId.hpp" @@ -31,6 +33,8 @@ namespace lms::api::subsonic { std::string idToString(db::ArtistId id); std::string idToString(db::DirectoryId id); + std::string idToString(db::PodcastEpisodeId id); + std::string idToString(db::PodcastId id); std::string idToString(db::ReleaseId id); std::string idToString(db::TrackId id); std::string idToString(db::TrackListId id); @@ -48,6 +52,12 @@ namespace lms::core::stringUtils template<> std::optional readAs(std::string_view str); + template<> + std::optional readAs(std::string_view str); + + template<> + std::optional readAs(std::string_view str); + template<> std::optional readAs(std::string_view str); diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 91871541..966ebcf4 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -46,6 +46,7 @@ #include "endpoints/MediaLibraryScanning.hpp" #include "endpoints/MediaRetrieval.hpp" #include "endpoints/Playlists.hpp" +#include "endpoints/Podcast.hpp" #include "endpoints/Searching.hpp" #include "endpoints/System.hpp" #include "endpoints/UserManagement.hpp" @@ -97,8 +98,13 @@ namespace lms::api::subsonic std::string res; + bool firstParameter{ true }; for (const auto& [type, values] : parameterMap) { + if (!firstParameter) + res += ", "; + firstParameter = false; + res += "{" + type + "="; if (values.size() == 1) { @@ -107,14 +113,18 @@ namespace lms::api::subsonic else { res += "{"; + bool firstValue{ true }; for (const std::string& value : values) { + if (!firstValue) + res += ','; + firstValue = false; + res += redactValueIfNeeded(type, value); - res += ','; } res += "}"; } - res += "}, "; + res += "}"; } return res; @@ -210,13 +220,14 @@ namespace lms::api::subsonic { "/deleteShare", { handleNotImplemented } }, // Podcast - { "/getPodcasts", { handleNotImplemented } }, - { "/getNewestPodcasts", { handleNotImplemented } }, - { "/refreshPodcasts", { handleNotImplemented } }, - { "/createPodcastChannel", { handleNotImplemented } }, - { "/deletePodcastChannel", { handleNotImplemented } }, - { "/deletePodcastEpisode", { handleNotImplemented } }, - { "/downloadPodcastEpisode", { handleNotImplemented } }, + { "/getPodcasts", { handleGetPodcasts } }, + { "/getNewestPodcasts", { handleGetNewestPodcasts } }, + { "/refreshPodcasts", { handleRefreshPodcasts, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } }, + { "/createPodcastChannel", { handleCreatePodcastChannel, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } }, + { "/deletePodcastChannel", { handleDeletePodcastChannel, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } }, + { "/deletePodcastEpisode", { handleDeletePodcastEpisode, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } }, + { "/downloadPodcastEpisode", { handleDownloadPodcastEpisode, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } }, + { "/getPodcastEpisode", { handleGetPodcastEpisode } }, // Jukebox { "/jukeboxControl", { handleNotImplemented } }, diff --git a/src/libs/subsonic/impl/SubsonicResponse.cpp b/src/libs/subsonic/impl/SubsonicResponse.cpp index 5bf5acd3..39609315 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.cpp +++ b/src/libs/subsonic/impl/SubsonicResponse.cpp @@ -26,6 +26,7 @@ #include #include "core/String.hpp" +#include "core/Version.hpp" #include "ProtocolVersion.hpp" @@ -140,7 +141,7 @@ namespace lms::api::subsonic // OpenSubsonic mandatory fields // No big deal to send them even for legacy clients responseNode.setAttribute("type", "lms"); - responseNode.setAttribute("serverVersion", serverVersion); + responseNode.setAttribute("serverVersion", core::getVersion()); responseNode.setAttribute("openSubsonic", true); return response; @@ -362,5 +363,4 @@ namespace lms::api::subsonic JsonSerializer serializer; serializer.serializeNode(os, _root); } - } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp b/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp index 8aa5c93a..189048a0 100644 --- a/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp +++ b/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp @@ -19,31 +19,37 @@ #include "MediaRetrieval.hpp" -#include "av/Exception.hpp" -#include "av/IAudioFile.hpp" +#include + #include "core/FileResourceHandlerCreator.hpp" #include "core/ILogger.hpp" #include "core/IResourceHandler.hpp" #include "core/String.hpp" #include "core/Utils.hpp" + +#include "av/Exception.hpp" +#include "av/IAudioFile.hpp" + #include "database/Session.hpp" +#include "database/objects/PodcastEpisode.hpp" +#include "database/objects/PodcastEpisodeId.hpp" #include "database/objects/Track.hpp" -#include "database/objects/TrackEmbeddedImageId.hpp" #include "database/objects/TrackLyrics.hpp" #include "database/objects/User.hpp" + #include "services/artwork/IArtworkService.hpp" +#include "services/podcast/IPodcastService.hpp" #include "services/transcoding/ITranscodingService.hpp" #include "CoverArtId.hpp" #include "ParameterParsing.hpp" #include "RequestContext.hpp" #include "SubsonicId.hpp" +#include "SubsonicResponse.hpp" #include "responses/Lyrics.hpp" namespace lms::api::subsonic { - using namespace db; - namespace { std::optional subsonicStreamFormatToAvOutputFormat(std::string_view format) @@ -100,8 +106,8 @@ namespace lms::api::subsonic struct StreamParameters { transcoding::InputParameters inputParameters; + std::string inputMimeType; // set if known std::optional outputParameters; - std::filesystem::path trackPath; bool estimateContentLength{}; }; @@ -125,10 +131,57 @@ namespace lms::api::subsonic } } + using AudioFileId = std::variant; + struct AudioFileInfo + { + std::filesystem::path path; + std::chrono::milliseconds duration{}; + std::size_t bitrate{}; + std::string mimeType; // set if known + }; + + AudioFileInfo getAudioFileInfo(db::Session& session, AudioFileId audioFileId) + { + AudioFileInfo res; + + auto transaction{ session.createReadTransaction() }; + + if (const db::TrackId * trackId{ std::get_if(&audioFileId) }) + { + const db::Track::pointer track{ db::Track::find(session, *trackId) }; + if (!track) + throw RequestedDataNotFoundError{}; + + res.path = track->getAbsoluteFilePath(); + res.duration = track->getDuration(); + res.bitrate = track->getBitrate(); + } + else if (const db::PodcastEpisodeId * episodeId{ std::get_if(&audioFileId) }) + { + const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, *episodeId) }; + if (!episode) + throw RequestedDataNotFoundError{}; + + std::filesystem::path podcastCachePath{ core::Service::get()->getCachePath() }; + + res.path = podcastCachePath / episode->getAudioRelativeFilePath(); + res.duration = episode->getDuration(); + res.bitrate = episode->getEnclosureLength() / std::chrono::duration_cast(episode->getDuration()).count() * 8; + res.mimeType = episode->getEnclosureContentType(); + } + + return res; + } + StreamParameters getStreamParameters(RequestContext& context) { // Mandatory params - const TrackId id{ getMandatoryParameterAs(context.parameters, "id") }; + const auto trackId{ getParameterAs(context.parameters, "id") }; + const auto podcastEpisodeId{ getParameterAs(context.parameters, "id") }; + if (!trackId && !podcastEpisodeId) + throw RequiredParameterMissingError{ "id" }; + + const AudioFileId audioId{ trackId ? AudioFileId{ *trackId } : AudioFileId{ *podcastEpisodeId } }; // Optional params std::size_t maxBitRate{ getParameterAs(context.parameters, "maxBitRate").value_or(0) * 1000 }; // "If set to zero, no limit is imposed", given in kpbs @@ -136,21 +189,18 @@ namespace lms::api::subsonic std::size_t timeOffset{ getParameterAs(context.parameters, "timeOffset").value_or(0) }; bool estimateContentLength{ getParameterAs(context.parameters, "estimateContentLength").value_or(false) }; + const AudioFileInfo audioFileInfo{ getAudioFileInfo(context.dbSession, audioId) }; + StreamParameters parameters; - auto transaction{ context.dbSession.createReadTransaction() }; - - const auto track{ Track::find(context.dbSession, id) }; - if (!track) - throw RequestedDataNotFoundError{}; - - parameters.inputParameters.trackId = id; + parameters.inputParameters.filePath = audioFileInfo.path; + parameters.inputParameters.duration = audioFileInfo.duration; parameters.inputParameters.offset = std::chrono::seconds{ timeOffset }; + parameters.inputMimeType = audioFileInfo.mimeType; parameters.estimateContentLength = estimateContentLength; - parameters.trackPath = track->getAbsoluteFilePath(); - if (format == "raw") // raw => no transcoding - return parameters; + if (format == "raw") // raw => no transcoding + return parameters; // TODO: what if offset is not 0? std::optional requestedFormat{ subsonicStreamFormatToAvOutputFormat(format) }; if (!requestedFormat) @@ -159,7 +209,7 @@ namespace lms::api::subsonic requestedFormat = userTranscodeFormatToAvFormat(context.user->getSubsonicDefaultTranscodingOutputFormat()); } - if (!requestedFormat && (maxBitRate == 0 || track->getBitrate() <= maxBitRate)) + if (!requestedFormat && (maxBitRate == 0 || audioFileInfo.bitrate <= maxBitRate)) { LMS_LOG(API_SUBSONIC, DEBUG, "File's bitrate is compatible with parameters => no transcoding"); return parameters; // no transcoding needed @@ -169,9 +219,9 @@ namespace lms::api::subsonic // same codec => apply max bitrate // otherwise => apply default bitrate (because we can't really compare bitrates between formats) + max bitrate) std::size_t bitrate{}; - if (requestedFormat && isOutputFormatCompatible(track->getAbsoluteFilePath(), *requestedFormat)) + if (requestedFormat && isOutputFormatCompatible(audioFileInfo.path, *requestedFormat)) { - if (maxBitRate == 0 || track->getBitrate() <= maxBitRate) + if (maxBitRate == 0 || audioFileInfo.bitrate <= maxBitRate) { LMS_LOG(API_SUBSONIC, DEBUG, "File's bitrate and format are compatible with parameters => no transcoding"); return parameters; // no transcoding needed @@ -218,7 +268,7 @@ namespace lms::api::subsonic // Choice: we return only the first lyrics if the track has many lyrics db::TrackLyrics::FindParameters lyricsParams; lyricsParams.setTrack(tracks.results[0]); - lyricsParams.setSortMethod(TrackLyricsSortMethod::ExternalFirst); + lyricsParams.setSortMethod(db::TrackLyricsSortMethod::ExternalFirst); lyricsParams.setRange(db::Range{ 0, 1 }); db::TrackLyrics::find(context.dbSession, lyricsParams, [&](const db::TrackLyrics::pointer& lyrics) { @@ -278,7 +328,7 @@ namespace lms::api::subsonic { auto transaction{ context.dbSession.createReadTransaction() }; - auto track{ Track::find(context.dbSession, id) }; + auto track{ db::Track::find(context.dbSession, id) }; if (!track) throw RequestedDataNotFoundError{}; @@ -310,7 +360,7 @@ namespace lms::api::subsonic if (streamParameters.outputParameters) resourceHandler = core::Service::get()->createResourceHandler(streamParameters.inputParameters, *streamParameters.outputParameters, streamParameters.estimateContentLength); else - resourceHandler = core::createFileResourceHandler(streamParameters.trackPath); + resourceHandler = core::createFileResourceHandler(streamParameters.inputParameters.filePath, streamParameters.inputMimeType); } else { @@ -323,6 +373,7 @@ namespace lms::api::subsonic } catch (const av::Exception& e) { + response.setStatus(404); // report not found if something wrong happened LMS_LOG(API_SUBSONIC, ERROR, "Caught Av exception: " << e.what()); } } diff --git a/src/libs/subsonic/impl/endpoints/Podcast.cpp b/src/libs/subsonic/impl/endpoints/Podcast.cpp new file mode 100644 index 00000000..3d740c0d --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/Podcast.cpp @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2025 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 "Podcast.hpp" + +#include "database/Session.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" +#include "services/podcast/IPodcastService.hpp" + +#include "ParameterParsing.hpp" +#include "RequestContext.hpp" +#include "SubsonicId.hpp" +#include "SubsonicResponse.hpp" +#include "responses/Podcast.hpp" + +namespace lms::api::subsonic +{ + Response handleGetPodcasts(RequestContext& context) + { + const bool includeEpisodes{ getParameterAs(context.parameters, "includeEpisodes").value_or(true) }; + const std::optional podcastId{ getParameterAs(context.parameters, "id") }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& podcastsNode{ response.createNode("podcasts") }; + podcastsNode.createEmptyArrayChild("channel"); + + auto transaction{ context.dbSession.createReadTransaction() }; + + auto processPodcast{ [&](const db::Podcast::pointer& podcast) { + podcastsNode.addArrayChild("channel", createPodcastNode(context, podcast, includeEpisodes)); + } }; + + if (podcastId.has_value()) + { + db::Podcast::pointer podcast{ db::Podcast::find(context.dbSession, podcastId.value()) }; + if (!podcast) + throw RequestedDataNotFoundError{}; + + processPodcast(podcast); + } + else + db::Podcast::find(context.dbSession, processPodcast); + + return response; + } + + Response handleGetNewestPodcasts(RequestContext& context) + { + std::size_t count{ getParameterAs(context.parameters, "count").value_or(20) }; + count = std::min(count, 100); + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& newestPodcastsNode{ response.createNode("newestPodcasts") }; + newestPodcastsNode.createEmptyArrayChild("episode"); + + { + auto transaction{ context.dbSession.createReadTransaction() }; + + db::PodcastEpisode::FindParameters findParameters; + findParameters.setRange(db::Range{ .offset = 0, .size = count }); + + db::PodcastEpisode::find(context.dbSession, findParameters, [&](const db::PodcastEpisode::pointer& episode) { + newestPodcastsNode.addArrayChild("episode", createPodcastEpisodeNode(episode)); + }); + } + + return response; + } + + Response handleRefreshPodcasts(RequestContext& context) + { + core::Service::get()->refreshPodcasts(); + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleCreatePodcastChannel(RequestContext& context) + { + // Mandatory parameters + const std::string url{ getMandatoryParameterAs(context.parameters, "url") }; + + if (url.empty() || !(url.starts_with("http://") || url.starts_with("https://"))) + throw BadParameterGenericError{ "Invalid url" }; + + // no effect if podcast already exists + core::Service::get()->addPodcast(url); + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleDeletePodcastChannel(RequestContext& context) + { + // Mandatory parameters + const db::PodcastId podcastId{ getMandatoryParameterAs(context.parameters, "id") }; + + if (!core::Service::get()->removePodcast(podcastId)) + throw RequestedDataNotFoundError{}; + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleDeletePodcastEpisode(RequestContext& context) + { + // Mandatory parameters + const db::PodcastEpisodeId episodeId{ getMandatoryParameterAs(context.parameters, "id") }; + + if (!core::Service::get()->deletePodcastEpisode(episodeId)) + throw RequestedDataNotFoundError{}; + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleDownloadPodcastEpisode(RequestContext& context) + { + // Mandatory parameters + const db::PodcastEpisodeId episodeId{ getMandatoryParameterAs(context.parameters, "id") }; + + if (!core::Service::get()->downloadPodcastEpisode(episodeId)) + throw RequestedDataNotFoundError{}; + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleGetPodcastEpisode(RequestContext& context) + { + // Mandatory parameters + const db::PodcastEpisodeId episodeId{ getMandatoryParameterAs(context.parameters, "id") }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + auto transaction{ context.dbSession.createReadTransaction() }; + + const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(context.dbSession, episodeId) }; + if (!episode) + throw RequestedDataNotFoundError{}; + + response.addNode("podcastEpisode", createPodcastEpisodeNode(episode)); + + return response; + } +} // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/endpoints/Podcast.hpp b/src/libs/subsonic/impl/endpoints/Podcast.hpp new file mode 100644 index 00000000..5022dc6b --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/Podcast.hpp @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2025 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 "RequestContext.hpp" +#include "SubsonicResponse.hpp" + +namespace lms::api::subsonic +{ + Response handleGetPodcasts(RequestContext& context); + Response handleGetNewestPodcasts(RequestContext& context); + Response handleRefreshPodcasts(RequestContext& context); + Response handleCreatePodcastChannel(RequestContext& context); + Response handleDeletePodcastChannel(RequestContext& context); + Response handleDeletePodcastEpisode(RequestContext& context); + Response handleDownloadPodcastEpisode(RequestContext& context); + Response handleGetPodcastEpisode(RequestContext& context); +} // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/endpoints/System.cpp b/src/libs/subsonic/impl/endpoints/System.cpp index 260b1789..fd89ccf2 100644 --- a/src/libs/subsonic/impl/endpoints/System.cpp +++ b/src/libs/subsonic/impl/endpoints/System.cpp @@ -47,6 +47,12 @@ namespace lms::api::subsonic apiKeyAuthentication.addArrayValue("versions", 1); } + { + Response::Node& apiKeyAuthentication{ response.createArrayNode("openSubsonicExtensions") }; + apiKeyAuthentication.setAttribute("name", "getPodcastEpisode"); + apiKeyAuthentication.addArrayValue("versions", 1); + } + return response; }; } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/responses/Podcast.cpp b/src/libs/subsonic/impl/responses/Podcast.cpp new file mode 100644 index 00000000..aab4be08 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Podcast.cpp @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2025 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 "Podcast.hpp" + +#include + +#include + +#include "core/String.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Podcast.hpp" +#include "database/objects/PodcastEpisode.hpp" + +#include "CoverArtId.hpp" +#include "RequestContext.hpp" +#include "SubsonicId.hpp" + +namespace lms::api::subsonic +{ + std::string_view getStatus(const db::PodcastEpisode::pointer& episode) + { + if (episode->getManualDownloadState() == db::PodcastEpisode::ManualDownloadState::DeleteRequested) + return "deleted"; + + if (!episode->getAudioRelativeFilePath().empty()) + return "completed"; + + return "new"; + } + + Response::Node createPodcastEpisodeNode(const db::PodcastEpisode::pointer& episode) + { + Response::Node episodeNode; + + // Child attributes + episodeNode.setAttribute("id", idToString(episode->getId())); + episodeNode.setAttribute("title", episode->getTitle()); + if (episode->getPubDate().isValid()) + episodeNode.setAttribute("year", std::to_string(episode->getPubDate().date().year())); + if (!episode->getEnclosureContentType().empty()) + episodeNode.setAttribute("contentType", episode->getEnclosureContentType()); + episodeNode.setAttribute("duration", std::chrono::duration_cast(episode->getDuration()).count()); + if (episode->getEnclosureLength() > 0) + episodeNode.setAttribute("size", episode->getEnclosureLength()); + episodeNode.setAttribute("isDir", "false"); // TODO parent? + if (!episode->getEnclosureUrl().empty()) + { + const auto pos{ episode->getEnclosureUrl().find_last_of('.') }; + if (pos != std::string_view::npos) + episodeNode.setAttribute("suffix", episode->getEnclosureUrl().substr(pos + 1)); + } + // estimated bitrate + if (episode->getEnclosureLength() > 0 && episode->getDuration() > std::chrono::milliseconds::zero()) + episodeNode.setAttribute("bitrate", episode->getEnclosureLength() * 8 / std::chrono::duration_cast(episode->getDuration()).count()); + if (const auto artwork{ episode->getArtwork() }) + { + CoverArtId coverArtId{ artwork->getId(), artwork->getLastWrittenTime().toTime_t() }; + episodeNode.setAttribute("coverArt", idToString(coverArtId)); + } + + // Podcast specific attributes + // Expose the streamId only if the episode is actually downloaded + if (!episode->getAudioRelativeFilePath().empty()) + episodeNode.setAttribute("streamId", idToString(episode->getId())); // Use this ID for streaming the podcast + episodeNode.setAttribute("channelId", idToString(episode->getPodcastId())); + episodeNode.setAttribute("description", episode->getDescription()); + episodeNode.setAttribute("status", getStatus(episode)); + if (episode->getPubDate().isValid()) + episodeNode.setAttribute("publishDate", core::stringUtils::toISO8601String(episode->getPubDate())); + + return episodeNode; + } + + std::string_view getStatus(const db::Podcast::pointer& podcast) + { + if (podcast->getTitle().empty()) + return "new"; + + return "completed"; + } + + Response::Node createPodcastNode(RequestContext& context, const db::Podcast::pointer& podcast, bool includeEpisodes) + { + Response::Node podcastNode; + + podcastNode.setAttribute("id", idToString(podcast->getId())); + podcastNode.setAttribute("url", podcast->getLink()); // TODO + if (!podcast->getTitle().empty()) + podcastNode.setAttribute("title", podcast->getTitle()); + if (!podcast->getDescription().empty()) + podcastNode.setAttribute("description", podcast->getDescription()); + if (!podcast->getImageUrl().empty()) + podcastNode.setAttribute("originalImageUrl", podcast->getImageUrl()); + + podcastNode.setAttribute("status", getStatus(podcast)); + + if (const auto artwork{ podcast->getArtwork() }) + { + CoverArtId coverArtId{ artwork->getId(), artwork->getLastWrittenTime().toTime_t() }; + podcastNode.setAttribute("coverArt", idToString(coverArtId)); + } + + if (includeEpisodes) + { + podcastNode.createEmptyArrayChild("episode "); + + db::PodcastEpisode::FindParameters params; + params.setPodcast(podcast->getId()); + params.setSortMode(db::PodcastEpisodeSortMode::PubDateDesc); + + db::PodcastEpisode::find(context.dbSession, params, [&](const db::PodcastEpisode::pointer& episode) { + podcastNode.addArrayChild("episode", createPodcastEpisodeNode(episode)); + }); + } + + return podcastNode; + } +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Podcast.hpp b/src/libs/subsonic/impl/responses/Podcast.hpp new file mode 100644 index 00000000..51eda2c9 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Podcast.hpp @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2025 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 "database/Object.hpp" + +#include "SubsonicResponse.hpp" + +namespace lms::db +{ + class Podcast; + class PodcastEpisode; +} // namespace lms::db + +namespace lms::api::subsonic +{ + struct RequestContext; + + Response::Node createPodcastEpisodeNode(const db::ObjectPtr& episode); + Response::Node createPodcastNode(RequestContext& context, const db::ObjectPtr& podcast, bool includeEpisodes); +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/User.cpp b/src/libs/subsonic/impl/responses/User.cpp index 8859b2c1..81d4d786 100644 --- a/src/libs/subsonic/impl/responses/User.cpp +++ b/src/libs/subsonic/impl/responses/User.cpp @@ -33,17 +33,17 @@ namespace lms::api::subsonic userNode.setAttribute("username", user->getLoginName()); userNode.setAttribute("scrobblingEnabled", true); - userNode.setAttribute("adminRole", user->isAdmin()); - userNode.setAttribute("settingsRole", true); - userNode.setAttribute("downloadRole", true); - userNode.setAttribute("uploadRole", false); - userNode.setAttribute("playlistRole", true); - userNode.setAttribute("coverArtRole", false); - userNode.setAttribute("commentRole", false); - userNode.setAttribute("podcastRole", false); // not supported - userNode.setAttribute("streamRole", true); - userNode.setAttribute("jukeboxRole", false); // not supported - userNode.setAttribute("shareRole", false); // not supported + userNode.setAttribute("adminRole", user->isAdmin()); // Whether the user is administrator + userNode.setAttribute("settingsRole", true); // Whether the user is allowed to change personal settings and password + userNode.setAttribute("downloadRole", true); // Whether the user is allowed to download files + userNode.setAttribute("uploadRole", false); // Whether the user is allowed to upload files + userNode.setAttribute("playlistRole", true); // Whether the user is allowed to create and delete playlists + userNode.setAttribute("coverArtRole", false); // Whether the user is allowed to change cover art and tags. + userNode.setAttribute("commentRole", false); // Whether the user is allowed to create and edit comments and ratings + userNode.setAttribute("podcastRole", user->isAdmin()); // Whether the user is allowed to administrate Podcasts + userNode.setAttribute("streamRole", true); // Whether the user is allowed to play files + userNode.setAttribute("jukeboxRole", false); // not supported + userNode.setAttribute("shareRole", false); // not supported // users can access all libraries db::MediaLibrary::find(context.dbSession, [&](const db::MediaLibrary::pointer& library) { diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt index d8011f72..1d1256cd 100644 --- a/src/lms/CMakeLists.txt +++ b/src/lms/CMakeLists.txt @@ -74,6 +74,7 @@ target_link_libraries(lms PRIVATE lmsrecommendation lmsscanner lmsscrobbling + lmspodcast lmsartwork lmssubsonic lmstranscoding diff --git a/src/lms/main.cpp b/src/lms/main.cpp index ab13fca4..2d488700 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -42,6 +42,7 @@ #include "services/auth/IEnvService.hpp" #include "services/auth/IPasswordService.hpp" #include "services/feedback/IFeedbackService.hpp" +#include "services/podcast/IPodcastService.hpp" #include "services/recommendation/IPlaylistGeneratorService.hpp" #include "services/recommendation/IRecommendationService.hpp" #include "services/scanner/IScannerService.hpp" @@ -337,8 +338,10 @@ namespace lms LMS_LOG(MAIN, WARNING, "Cannot set locale from system"); // Make sure the working directory exists - std::filesystem::create_directories(config->getPath("working-dir", "/var/lms")); - std::filesystem::create_directories(config->getPath("working-dir", "/var/lms") / "cache"); + const std::filesystem::path workingDirectoryPath{ config->getPath("working-dir", "/var/lms") }; + const std::filesystem::path cachePath{ workingDirectoryPath / "cache" }; + std::filesystem::create_directories(workingDirectoryPath); + std::filesystem::create_directories(cachePath); // Construct WT configuration and get the argc/argv back const std::vector wtServerArgs{ generateWtConfig(argv[0]) }; @@ -424,8 +427,9 @@ namespace lms core::Service artworkService{ artwork::createArtworkService(*database, server.appRoot() + "/images/unknown-cover.svg", server.appRoot() + "/images/unknown-artist.svg") }; core::Service recommendationService{ recommendation::createRecommendationService(*database) }; core::Service playlistGeneratorService{ recommendation::createPlaylistGeneratorService(*database, *recommendationService) }; - core::Service scannerService{ scanner::createScannerService(*database) }; + core::Service scannerService{ scanner::createScannerService(*database, cachePath) }; core::Service transcodingService{ transcoding::createTranscodingService(*database, *childProcessManagerService) }; + core::Service podcastService{ podcast::createPodcastService(ioContext, *database, cachePath / "podcasts") }; scannerService->getEvents().scanComplete.connect([&] { // Flush cover cache even if no changes: diff --git a/src/lms/ui/resource/AudioTranscodingResource.cpp b/src/lms/ui/resource/AudioTranscodingResource.cpp index 0d67d7e3..8e26930a 100644 --- a/src/lms/ui/resource/AudioTranscodingResource.cpp +++ b/src/lms/ui/resource/AudioTranscodingResource.cpp @@ -141,7 +141,19 @@ namespace lms::ui // optional parameter std::size_t offset{ readParameterAs(request, "offset").value_or(0) }; - parameters.inputParameters.trackId = *trackId; + { + db::Session& session{ LmsApp->getDbSession() }; + + const auto transaction{ session.createReadTransaction() }; + + const db::Track::pointer track{ db::Track::find(session, *trackId) }; + if (!track) + return std::nullopt; + + parameters.inputParameters.filePath = track->getAbsoluteFilePath(); + parameters.inputParameters.duration = track->getDuration(); + } + parameters.inputParameters.offset = std::chrono::seconds{ offset }; parameters.outputParameters.stripMetadata = true; parameters.outputParameters.format = *avFormat; From fc70483791ed537a905af97a953e9daf9559604a Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Sep 2025 15:18:32 +0200 Subject: [PATCH 07/12] Removed obsolete func --- src/libs/core/impl/http/SendQueue.cpp | 3 ++- src/libs/core/include/core/Utils.hpp | 17 +++++++---------- .../services/artwork/impl/ArtworkService.cpp | 5 +++-- .../subsonic/impl/endpoints/MediaRetrieval.cpp | 4 ++-- src/lms/ui/MediaPlayer.cpp | 5 +++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/libs/core/impl/http/SendQueue.cpp b/src/libs/core/impl/http/SendQueue.cpp index 0278fc9c..bdbe8931 100644 --- a/src/libs/core/impl/http/SendQueue.cpp +++ b/src/libs/core/impl/http/SendQueue.cpp @@ -19,6 +19,7 @@ #include "SendQueue.hpp" +#include #include #include @@ -308,7 +309,7 @@ namespace lms::core::http void SendQueue::throttle(std::chrono::seconds requestedDuration) { - const std::chrono::seconds duration{ clamp(requestedDuration, _minRetryWaitDuration, _maxRetryWaitDuration) }; + const std::chrono::seconds duration{ std::clamp(requestedDuration, _minRetryWaitDuration, _maxRetryWaitDuration) }; LOG(DEBUG, "Throttling for " << duration.count() << " seconds"); _throttleTimer.expires_after(duration); diff --git a/src/libs/core/include/core/Utils.hpp b/src/libs/core/include/core/Utils.hpp index 1a13f33f..46430baa 100644 --- a/src/libs/core/include/core/Utils.hpp +++ b/src/libs/core/include/core/Utils.hpp @@ -24,19 +24,16 @@ namespace lms::core::utils { - template> - constexpr T clamp(T v, T lo, T hi, Compare comp = {}) - { - assert(!comp(hi, lo)); - return comp(v, lo) ? lo : comp(hi, v) ? hi : - v; - } - template - void - push_back_if_not_present(Container& container, const T& val) + void push_back_if_not_present(Container& container, const T& val) { if (std::find(std::cbegin(container), std::cend(container), val) == std::cend(container)) container.push_back(val); } + + template + struct overloads : Ts... + { + using Ts::operator()...; + }; } // namespace lms::core::utils \ No newline at end of file diff --git a/src/libs/services/artwork/impl/ArtworkService.cpp b/src/libs/services/artwork/impl/ArtworkService.cpp index 2781d1cc..eb380d1b 100644 --- a/src/libs/services/artwork/impl/ArtworkService.cpp +++ b/src/libs/services/artwork/impl/ArtworkService.cpp @@ -19,9 +19,10 @@ #include "ArtworkService.hpp" +#include + #include "core/IConfig.hpp" #include "core/ILogger.hpp" -#include "core/Utils.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/objects/Artist.hpp" @@ -243,7 +244,7 @@ namespace lms::artwork void ArtworkService::setJpegQuality(unsigned quality) { - _jpegQuality = core::utils::clamp(quality, 1, 100); + _jpegQuality = std::clamp(quality, 1, 100); LMS_LOG(COVER, INFO, "JPEG export quality = " << _jpegQuality); } diff --git a/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp b/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp index 189048a0..1ad9d273 100644 --- a/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp +++ b/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp @@ -19,13 +19,13 @@ #include "MediaRetrieval.hpp" +#include #include #include "core/FileResourceHandlerCreator.hpp" #include "core/ILogger.hpp" #include "core/IResourceHandler.hpp" #include "core/String.hpp" -#include "core/Utils.hpp" #include "av/Exception.hpp" #include "av/IAudioFile.hpp" @@ -385,7 +385,7 @@ namespace lms::api::subsonic std::optional size{ getParameterAs(context.parameters, "size") }; if (size) - *size = core::utils::clamp(*size, std::size_t{ 32 }, std::size_t{ 2048 }); + *size = std::clamp(*size, std::size_t{ 32 }, std::size_t{ 2048 }); std::shared_ptr image{ core::Service::get()->getImage(coverArtId.id, size) }; if (!image) diff --git a/src/lms/ui/MediaPlayer.cpp b/src/lms/ui/MediaPlayer.cpp index f423ebf7..6fa7e23f 100644 --- a/src/lms/ui/MediaPlayer.cpp +++ b/src/lms/ui/MediaPlayer.cpp @@ -19,6 +19,8 @@ #include "MediaPlayer.hpp" +#include + #include #include #include @@ -27,7 +29,6 @@ #include "core/ILogger.hpp" #include "core/String.hpp" -#include "core/Utils.hpp" #include "database/Session.hpp" #include "database/Types.hpp" #include "database/objects/Artist.hpp" @@ -142,7 +143,7 @@ namespace lms::ui if (!value) return std::nullopt; - return core::utils::clamp(*value, (double)MediaPlayer::Settings::ReplayGain::minPreAmpGain, (double)MediaPlayer::Settings::ReplayGain::maxPreAmpGain); + return std::clamp(*value, (double)MediaPlayer::Settings::ReplayGain::minPreAmpGain, (double)MediaPlayer::Settings::ReplayGain::maxPreAmpGain); } MediaPlayer::Settings settingsfromJSString(const std::string& strSettings) From dd3ac60b0dcc9feb27589cd1ff60b8f74785f4dd Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Sep 2025 15:46:40 +0200 Subject: [PATCH 08/12] lms-metadata: fixed exception when trying to read a file without extension --- src/libs/metadata/impl/taglib/Utils.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/metadata/impl/taglib/Utils.cpp b/src/libs/metadata/impl/taglib/Utils.cpp index 94dcc168..a8320fa2 100644 --- a/src/libs/metadata/impl/taglib/Utils.cpp +++ b/src/libs/metadata/impl/taglib/Utils.cpp @@ -102,10 +102,13 @@ namespace lms::metadata::taglib::utils std::unique_ptr parseFileByExtension(TagLib::FileStream* stream, const std::filesystem::path& extension, TagLib::AudioProperties::ReadStyle audioPropertiesStyle, bool readAudioProperties) { - const std::string ext{ core::stringUtils::stringToUpper(extension.string().substr(1)) }; - std::unique_ptr file; + if (extension.empty()) + return file; + + const std::string ext{ core::stringUtils::stringToUpper(extension.string().substr(1)) }; + // MP3 if (ext == "MP3" || ext == "MP2" || ext == "AAC") file = std::make_unique(stream, TagLib::ID3v2::FrameFactory::instance(), readAudioProperties, audioPropertiesStyle); From 9560bdae2bb75ff3783c24b9a3bdbfb132ab85f0 Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Sep 2025 16:02:47 +0200 Subject: [PATCH 09/12] Allow to pick up a 'Other' image if no 'Front' or 'Media' image is found, fixes #718 --- .../services/scanner/impl/ScannerService.cpp | 6 ++--- .../steps/ScanStepAssociateReleaseImages.cpp | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index d80090ad..d29bdb70 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -508,9 +508,9 @@ 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)); // must come after ScanStepAssociateReleaseImages - _scanSteps.emplace_back(std::make_unique(params)); // must come after ScanStepAssociateReleaseImages - _scanSteps.emplace_back(std::make_unique(params)); // must come after ScanStepAssociateMediumImages and ScanStepAssociateReleaseImages + _scanSteps.emplace_back(std::make_unique(params)); // must come after ScanStepAssociateReleaseImages (because and artist image can fallback on a release image) + _scanSteps.emplace_back(std::make_unique(params)); // must come after ScanStepAssociateReleaseImages (because and medium image can fallback on a release image) + _scanSteps.emplace_back(std::make_unique(params)); // must come after ScanStepAssociateMediumImages and ScanStepAssociateReleaseImages (because and track image can fallback on a medium or release image) _scanSteps.emplace_back(std::make_unique(params)); _scanSteps.emplace_back(std::make_unique(params)); _scanSteps.emplace_back(std::make_unique(params)); diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp index 158fba59..1b7038c9 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp @@ -151,7 +151,7 @@ namespace lms::scanner if (artwork) return artwork; - // Fallback on embedded media image + // Fallback on embedded Media image { db::TrackEmbeddedImage::FindParameters params; params.setRelease(release->getId()); @@ -163,6 +163,18 @@ namespace lms::scanner }); } + // Fallback on embedded Other image, as some tracks may be badly tagged + { + db::TrackEmbeddedImage::FindParameters params; + params.setRelease(release->getId()); + params.setImageType(db::ImageType::Other); + params.setSortMethod(db::TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc); + db::TrackEmbeddedImage::find(session, params, [&](const db::TrackEmbeddedImage::pointer& image) { + if (!artwork) + artwork = db::Artwork::find(session, image->getId()); + }); + } + return artwork; } @@ -282,7 +294,7 @@ namespace lms::scanner .releaseImageFileNames = _releaseImageFileNames, }; - ReleaseArtworkAssociationContainer artistArtworkAssociations; + ReleaseArtworkAssociationContainer releaseArtworkAssociations; auto processJobsDone = [&](std::span> jobs) { if (_abortScan) return; @@ -292,12 +304,12 @@ namespace lms::scanner const auto& associationJob{ static_cast(*job) }; const auto& artistAssociations{ associationJob.getAssociations() }; - artistArtworkAssociations.insert(std::end(artistArtworkAssociations), std::cbegin(artistAssociations), std::cend(artistAssociations)); + releaseArtworkAssociations.insert(std::end(releaseArtworkAssociations), std::cbegin(artistAssociations), std::cend(artistAssociations)); context.currentStepStats.processedElems += associationJob.getProcessedReleaseCount(); } - updateReleasePreferredArtworks(session, artistArtworkAssociations, true); + updateReleasePreferredArtworks(session, releaseArtworkAssociations, true); _progressCallback(context.currentStepStats); }; @@ -311,6 +323,6 @@ namespace lms::scanner queue.finish(); // process all remaining associations - updateReleasePreferredArtworks(session, artistArtworkAssociations, false); + updateReleasePreferredArtworks(session, releaseArtworkAssociations, false); } } // namespace lms::scanner From 90fc3fc04b4943b61434d838077aa052212fbdd5 Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Sep 2025 17:56:47 +0200 Subject: [PATCH 10/12] Bumped version to v3.70.0 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1983ec43..a51a50e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.12) -project(lms VERSION 3.69.0) +project(lms VERSION 3.70.0) set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_SOURCE_DIR}/cmake/modules/) From f786b8b705b781f546eb94dea4d606710af78b34 Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Sep 2025 18:14:55 +0200 Subject: [PATCH 11/12] Adjusted logs --- .../steps/DownloadEpisodeArtworksStep.cpp | 53 ++++++++++--------- .../impl/steps/DownloadEpisodesStep.cpp | 2 +- .../steps/DownloadPodcastArtworksStep.cpp | 50 +++++++++-------- 3 files changed, 56 insertions(+), 49 deletions(-) diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp b/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp index 86f3d526..0d95573e 100644 --- a/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp @@ -112,38 +112,41 @@ namespace lms::podcast const std::string url{ episode->getImageUrl() }; const std::filesystem::path finalFilePath{ getCachePath() / utils::generateRandomFileName() }; + LMS_LOG(PODCAST, DEBUG, "Downloading episode artwork for episode '" << episode->getTitle() << "' from '" << url << "' in file '" << finalFilePath << "'"); + core::http::ClientGETRequestParameters params; params.relativeUrl = episode->getImageUrl(); - params.onFailureFunc = [this, episode] { - LMS_LOG(PODCAST, ERROR, "Failed to download episode image from '" << episode->getImageUrl() << "'"); + params.onFailureFunc = [=, this] { + LMS_LOG(PODCAST, ERROR, "Failed to download episode artwork for episode '" << episode->getTitle() << "' from '" << url << "'"); processNext(); }; params.onSuccessFunc = [=, this](const Wt::Http::Message& msg) { - const std::string body{ msg.body() }; // API enforces a copy here + getExecutor().post([=, this] { + std::ofstream file{ finalFilePath, std::ios::binary | std::ios::trunc }; + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to open file " << finalFilePath << " for writing: " << ec.message()); + processNext(); + return; + } + + const std::string body{ msg.body() }; // API enforces a copy here + file.write(body.data(), body.size()); + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to write to file " << finalFilePath << ": " << ec.message()); + processNext(); + return; + } + + LMS_LOG(PODCAST, INFO, "Downloaded episode artwork for episode '" << episode->getTitle() << "'"); + const std::string* contentType{ msg.getHeader("Content-Type") }; + createEpisodeArtwork(getDb().getTLSSession(), episodeId, finalFilePath, contentType ? *contentType : "application/octet-stream"); - std::ofstream file{ finalFilePath, std::ios::binary | std::ios::trunc }; - if (!file) - { - std::error_code ec{ errno, std::generic_category() }; - LMS_LOG(PODCAST, ERROR, "Failed to open file " << finalFilePath << " for writing: " << ec.message()); processNext(); - return; - } - - file.write(body.data(), body.size()); - if (!file) - { - std::error_code ec{ errno, std::generic_category() }; - LMS_LOG(PODCAST, ERROR, "Failed to write to file " << finalFilePath << ": " << ec.message()); - processNext(); - return; - } - - const std::string* contentType{ msg.getHeader("Content-Type") }; - LMS_LOG(PODCAST, INFO, "Downloaded episode artwork for episode '" << episode->getTitle() << "' to " << finalFilePath << " with content type '" << (contentType ? *contentType : "unknown") << "', size = " << body.size() << " bytes"); - createEpisodeArtwork(getDb().getTLSSession(), episodeId, finalFilePath, contentType ? *contentType : "application/octet-stream"); - - processNext(); + }); }; params.onAbortFunc = [this] { onAbort(); diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp index 8051ab32..26cb2cf7 100644 --- a/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp @@ -182,7 +182,7 @@ namespace lms::podcast // TODO: now the file is complete, should we attempt to read it and get the real information like duration and size? - LMS_LOG(PODCAST, INFO, "Successfully downloaded episode '" << episode->getTitle() << "'"); + LMS_LOG(PODCAST, INFO, "Downloaded episode '" << episode->getTitle() << "'"); processNext(); }); }; diff --git a/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp b/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp index e2f08372..c27e1912 100644 --- a/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp +++ b/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp @@ -111,6 +111,8 @@ namespace lms::podcast const std::string url{ podcast->getImageUrl() }; const std::filesystem::path finalFilePath{ getCachePath() / utils::generateRandomFileName() }; + LMS_LOG(PODCAST, DEBUG, "Downloading podcast artwork '" << podcast->getTitle() << "' from '" << url << "' in file '" << finalFilePath << "'"); + core::http::ClientGETRequestParameters params; params.relativeUrl = podcast->getImageUrl(); params.onFailureFunc = [this, podcast] { @@ -118,31 +120,33 @@ namespace lms::podcast processNext(); }; params.onSuccessFunc = [=, this](const Wt::Http::Message& msg) { - const std::string body{ msg.body() }; // API enforces a copy here + getExecutor().post([=, this] { + const std::string body{ msg.body() }; // API enforces a copy here + + std::ofstream file{ finalFilePath, std::ios::binary | std::ios::app }; + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to open file " << finalFilePath << " for writing: " << ec.message()); + processNext(); + return; + } + + file.write(body.data(), body.size()); + if (!file) + { + std::error_code ec{ errno, std::generic_category() }; + LMS_LOG(PODCAST, ERROR, "Failed to write to file " << finalFilePath << ": " << ec.message()); + processNext(); + return; + } + + LMS_LOG(PODCAST, INFO, "Downloaded podcast artwork for podcast '" << podcast->getTitle()); + const std::string* contentType{ msg.getHeader("Content-Type") }; + createPodcastArtwork(getDb().getTLSSession(), podcastId, finalFilePath, contentType ? *contentType : "application/octet-stream"); - std::ofstream file{ finalFilePath, std::ios::binary | std::ios::app }; - if (!file) - { - std::error_code ec{ errno, std::generic_category() }; - LMS_LOG(PODCAST, ERROR, "Failed to open file " << finalFilePath << " for writing: " << ec.message()); processNext(); - return; - } - - file.write(body.data(), body.size()); - if (!file) - { - std::error_code ec{ errno, std::generic_category() }; - LMS_LOG(PODCAST, ERROR, "Failed to write to file " << finalFilePath << ": " << ec.message()); - processNext(); - return; - } - - const std::string* contentType{ msg.getHeader("Content-Type") }; - LMS_LOG(PODCAST, INFO, "Downloaded podcast artwork for podcast '" << podcast->getTitle() << "' to " << finalFilePath << " with content type '" << (contentType ? *contentType : "unknown") << "', size = " << body.size()); - createPodcastArtwork(getDb().getTLSSession(), podcastId, finalFilePath, contentType ? *contentType : "application/octet-stream"); - - processNext(); + }); }; params.onAbortFunc = [this] { onAbort(); From 98b7e6c5423e47f96a758c0fc8cfec4ce03de49d Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 13 Sep 2025 18:20:09 +0200 Subject: [PATCH 12/12] Removed unused category in toasts --- src/lms/ui/LmsApplication.cpp | 7 +++---- src/lms/ui/LmsApplication.hpp | 2 +- src/lms/ui/NotificationContainer.cpp | 9 ++++----- src/lms/ui/NotificationContainer.hpp | 4 +--- src/lms/ui/SettingsView.cpp | 4 ++-- src/lms/ui/admin/MediaLibrariesView.cpp | 6 +++--- src/lms/ui/admin/ScanSettingsView.cpp | 2 +- src/lms/ui/admin/ScannerController.cpp | 4 ++-- src/lms/ui/admin/UserView.cpp | 4 +--- src/lms/ui/explore/Filters.cpp | 4 +--- 10 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index ee067557..45e47fe2 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -546,7 +546,6 @@ namespace lms::ui { _scannerEvents.scanComplete.connect([this](const scanner::ScanStats& stats) { notifyMsg(Notification::Type::Info, - Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.scan-complete") .arg(static_cast(stats.getTotalFileCount())) .arg(static_cast(stats.additions)) @@ -596,9 +595,9 @@ namespace lms::ui WApplication::setTitle(title); } - void LmsApplication::notifyMsg(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration) + void LmsApplication::notifyMsg(Notification::Type type, const Wt::WString& message, std::chrono::milliseconds duration) { - LMS_LOG(UI, INFO, "Notifying message '" << message.toUTF8() << "' for category '" << category.toUTF8() << "'"); - _notificationContainer->add(type, category, message, duration); + LMS_LOG(UI, INFO, "Notifying message '" << message.toUTF8() << "'"); + _notificationContainer->add(type, message, duration); } } // namespace lms::ui \ No newline at end of file diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index f5a54586..75dfee52 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -80,7 +80,7 @@ namespace lms::ui void setTitle(const Wt::WString& title = ""); // Used to classify the message sent to the user - void notifyMsg(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration = std::chrono::milliseconds{ 5000 }); + void notifyMsg(Notification::Type type, const Wt::WString& message, std::chrono::milliseconds duration = std::chrono::milliseconds{ 5000 }); MediaPlayer& getMediaPlayer() const { return *_mediaPlayer; } PlayQueue& getPlayQueue() const { return *_playQueue; } diff --git a/src/lms/ui/NotificationContainer.cpp b/src/lms/ui/NotificationContainer.cpp index d76f2ea2..9f3196b5 100644 --- a/src/lms/ui/NotificationContainer.cpp +++ b/src/lms/ui/NotificationContainer.cpp @@ -34,12 +34,12 @@ namespace lms::ui class NotificationWidget : public Wt::WTemplate { public: - NotificationWidget(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration); + NotificationWidget(Notification::Type type, const Wt::WString& message, std::chrono::milliseconds duration); Wt::JSignal<> closed{ this, "closed" }; }; } // namespace - NotificationWidget::NotificationWidget(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration) + NotificationWidget::NotificationWidget(Notification::Type type, const Wt::WString& message, std::chrono::milliseconds duration) : Wt::WTemplate{ Wt::WString::tr("Lms.notifications.template.entry") } { switch (type) @@ -58,7 +58,6 @@ namespace lms::ui break; } - bindString("category", category); bindString("message", message); bindInt("duration", duration.count()); @@ -78,9 +77,9 @@ namespace lms::ui doJavaScript(oss.str()); } - void NotificationContainer::add(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration) + void NotificationContainer::add(Notification::Type type, const Wt::WString& message, std::chrono::milliseconds duration) { - NotificationWidget* notification{ addNew(type, category, message, duration) }; + NotificationWidget* notification{ addNew(type, message, duration) }; notification->closed.connect([this, notification] { removeWidget(notification); diff --git a/src/lms/ui/NotificationContainer.hpp b/src/lms/ui/NotificationContainer.hpp index d4ba3921..828ef4e6 100644 --- a/src/lms/ui/NotificationContainer.hpp +++ b/src/lms/ui/NotificationContainer.hpp @@ -31,8 +31,6 @@ namespace lms::ui class NotificationContainer : public Wt::WContainerWidget { public: - void add(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration); - - private: + void add(Notification::Type type, const Wt::WString& message, std::chrono::milliseconds duration); }; } // namespace lms::ui diff --git a/src/lms/ui/SettingsView.cpp b/src/lms/ui/SettingsView.cpp index 70d891b0..94a3078a 100644 --- a/src/lms/ui/SettingsView.cpp +++ b/src/lms/ui/SettingsView.cpp @@ -758,7 +758,7 @@ namespace lms::ui { if (LmsApp->getUserType() == db::UserType::DEMO) { - LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Settings.settings"), Wt::WString::tr("Lms.Settings.demo-cannot-save")); + LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Settings.demo-cannot-save")); return; } } @@ -768,7 +768,7 @@ namespace lms::ui if (model->validate()) { model->saveData(); - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Settings.settings"), Wt::WString::tr("Lms.Settings.settings-saved")); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Settings.settings-saved")); } // Udate the view: Delete any validation message in the view, etc. diff --git a/src/lms/ui/admin/MediaLibrariesView.cpp b/src/lms/ui/admin/MediaLibrariesView.cpp index 315ec5e6..633c8860 100644 --- a/src/lms/ui/admin/MediaLibrariesView.cpp +++ b/src/lms/ui/admin/MediaLibrariesView.cpp @@ -47,7 +47,7 @@ namespace lms::ui Wt::WTemplate* entry{ addEntry() }; updateEntry(newMediaLibraryId, entry); // No need to stop the current scan if we add stuff - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.MediaLibraries.media-libraries"), Wt::WString::tr("Lms.Admin.MediaLibrary.library-created")); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.MediaLibrary.library-created")); LmsApp->getModalManager().dispose(mediaLibraryModalPtr); }); @@ -99,7 +99,7 @@ namespace lms::ui // Don't want the scanner to go on with wrong settings core::Service::get()->requestReload(); - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.MediaLibraries.media-libraries"), Wt::WString::tr("Lms.Admin.MediaLibrary.library-deleted")); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.MediaLibrary.library-deleted")); _libraries->removeWidget(libraryEntry); @@ -138,7 +138,7 @@ namespace lms::ui // Don't want the scanner to go on with wrong settings core::Service::get()->requestReload(); - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.MediaLibraries.media-libraries"), Wt::WString::tr("Lms.settings-saved")); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.settings-saved")); LmsApp->getModalManager().dispose(mediaLibraryModalPtr); }); diff --git a/src/lms/ui/admin/ScanSettingsView.cpp b/src/lms/ui/admin/ScanSettingsView.cpp index 2d2ec69e..020df4bc 100644 --- a/src/lms/ui/admin/ScanSettingsView.cpp +++ b/src/lms/ui/admin/ScanSettingsView.cpp @@ -493,7 +493,7 @@ namespace lms::ui core::Service::get()->load(); // Don't want the scanner to go on with wrong settings core::Service::get()->requestReload(); - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.settings-saved")); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.settings-saved")); } // Udate the view: Delete any validation message in the view, etc. diff --git a/src/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp index fd45bb0b..df431fe0 100644 --- a/src/lms/ui/admin/ScannerController.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -88,10 +88,10 @@ namespace lms::ui auto onDbEvent{ [&]() { refreshContents(); } }; LmsApp->getScannerEvents().scanAborted.connect(this, [] { - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.scan-aborted")); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.scan-aborted")); }); LmsApp->getScannerEvents().scanStarted.connect(this, [] { - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.scan-launched")); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.scan-launched")); }); LmsApp->getScannerEvents().scanComplete.connect(this, onDbEvent); LmsApp->getScannerEvents().scanInProgress.connect(this, onDbEvent); diff --git a/src/lms/ui/admin/UserView.cpp b/src/lms/ui/admin/UserView.cpp index 631a9482..8ab44a40 100644 --- a/src/lms/ui/admin/UserView.cpp +++ b/src/lms/ui/admin/UserView.cpp @@ -263,9 +263,7 @@ namespace lms::ui if (model->validate()) { model->saveData(); - LmsApp->notifyMsg(Notification::Type::Info, - Wt::WString::tr("Lms.Admin.Users.users"), - Wt::WString::tr(userId ? "Lms.Admin.User.user-updated" : "Lms.Admin.User.user-created")); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr(userId ? "Lms.Admin.User.user-updated" : "Lms.Admin.User.user-created")); LmsApp->setInternalPath("/admin/users", true); } else diff --git a/src/lms/ui/explore/Filters.cpp b/src/lms/ui/explore/Filters.cpp index e618e9e1..7d90ef87 100644 --- a/src/lms/ui/explore/Filters.cpp +++ b/src/lms/ui/explore/Filters.cpp @@ -326,9 +326,7 @@ namespace lms::ui void Filters::emitFilterAddedNotification() { - LmsApp->notifyMsg(Notification::Type::Info, - Wt::WString::tr("Lms.Explore.filters"), - Wt::WString::tr("Lms.Explore.filter-added"), std::chrono::seconds{ 2 }); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Explore.filter-added"), std::chrono::seconds{ 2 }); _sigUpdated.emit(); }