diff --git a/.github/workflows/build-alpine.yml b/.github/workflows/build-alpine.yml index a6d4874b..2c38d50c 100644 --- a/.github/workflows/build-alpine.yml +++ b/.github/workflows/build-alpine.yml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check Out Repo - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -20,10 +20,10 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Build (alpine) - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: ./ file: ./Dockerfile-build-alpine diff --git a/.github/workflows/build-arch.yml b/.github/workflows/build-arch.yml index ea06617c..d9fee967 100644 --- a/.github/workflows/build-arch.yml +++ b/.github/workflows/build-arch.yml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check Out Repo - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -20,10 +20,10 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: ./ file: ./Dockerfile-build-arch diff --git a/INSTALL.md b/INSTALL.md index 79022910..0e3d7b83 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -19,7 +19,7 @@ _Docker_ images are available, please see detailed instructions on https://hub.d _Bookworm_ packages are provided for _amd64_ architectures. As root, trust the following debian package provider and add it in your list of repositories: ```sh -wget https://debian.poupon.dev/apt/debian/epoupon.gpg -P /usr/share/keyrings +wget --backups=1 https://debian.poupon.dev/apt/debian/epoupon.gpg -P /usr/share/keyrings echo "deb [signed-by=/usr/share/keyrings/epoupon.gpg] https://debian.poupon.dev/apt/debian bookworm main" > /etc/apt/sources.list.d/epoupon.list ``` To install or upgrade _LMS_: diff --git a/README.md b/README.md index 11ffd899..e21def02 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A [demo instance](http://lms-demo.poupon.dev) is available. Note the administrat * Synchronizing 'love' feedbacks * ReplayGain support * User management, with several [authentication backends](INSTALL.md#authentication-backend) -* Subsonic API +* [Subsonic/OpenSubsonic API](SUBSONIC.md) support ## Music discovery _LMS_ provides several ways to help you find the music you like: @@ -34,13 +34,6 @@ _LMS_ provides several ways to help you find the music you like: * Starred _Jazz_ albums * ... -## Subsonic API -The API version implemented is 1.16.0 and has been tested on _Android_ using _Subsonic Player_, _Ultrasonic_, _Symfonium_, and _DSub_. -Since _LMS_ uses metadata tags to organize music, a compatibility mode is used to browse the collection when using the directory browsing commands. -The Subsonic API is enabled by default. - -__Note__: since _LMS_ may store hashed and salted passwords or may forward authentication requests to external services, it cannot handle the __token authentication__ method. You may need to check your client to make sure to use the __password__ authentication method. - ## About tags _LMS_ relies exclusively on tags to organize your music collection. diff --git a/SUBSONIC.md b/SUBSONIC.md new file mode 100644 index 00000000..7bb4b93c --- /dev/null +++ b/SUBSONIC.md @@ -0,0 +1,36 @@ +# Subsonic API +The API version implemented is 1.16.0 and has been tested on _Android_ using _Subsonic Player_, _Ultrasonic_, _Symfonium_, and _DSub_. +Since _LMS_ uses metadata tags to organize music, a compatibility mode is used to browse the collection when using the directory browsing commands. +The Subsonic API is enabled by default. + +__Note__: since _LMS_ may store hashed and salted passwords or may forward authentication requests to external services, it cannot handle the __token authentication__ method. You may need to check your client to make sure to use the __password__ authentication method. + +# OpenSubsonic API +OpenSubsonic is an initiative to patch and extend the legacy Subsonic API. You'll find more details in the [official documentation](https://opensubsonic.netlify.app/) + +## Extra fields +The following extra fields are implemented: +* `Album` response: + * `musicBrainzId` + * `genres` + * `artists` + * `releaseTypes` + * `moods` + * `originalReleaseDate` + * `isCompilation` + * `discTitles`: discs with no subtitle are omitted +* `Child` response: + * `musicBrainzId`: note this is actually the recording MBID when this response refers to a song + * `genres` + * `artists` + * `albumArtists` + * `contributors` + * `moods` + * `replayGain` +* `Artist` response: + * `musicBrainzId` + * `sortName` + * `roles` + +## Supported extensions +* [Transcode offset](https://opensubsonic.netlify.app/docs/extensions/transcodeoffset/) diff --git a/approot/search.xml b/approot/search.xml index fc357d26..4f248d70 100644 --- a/approot/search.xml +++ b/approot/search.xml @@ -16,7 +16,14 @@
${releases}
-
${artists}
+
+
+
+ ${link-type class="form-select"} +
+
+ ${artists} +
${tracks}
diff --git a/src/libs/metadata/impl/TagLibParser.cpp b/src/libs/metadata/impl/TagLibParser.cpp index dad243ed..d5fb733b 100644 --- a/src/libs/metadata/impl/TagLibParser.cpp +++ b/src/libs/metadata/impl/TagLibParser.cpp @@ -491,7 +491,7 @@ TagLibParser::parse(const std::filesystem::path& p, bool debug) //MP4 else if (TagLib::MP4::File* mp4File {dynamic_cast(f.file())}) { - auto& coverItem {mp4File->tag()->itemListMap()["covr"]}; + TagLib::MP4::Item coverItem {mp4File->tag()->item("covr")}; TagLib::MP4::CoverArtList coverArtList {coverItem.toCoverArtList()}; if (!coverArtList.isEmpty()) track.hasCover = true; diff --git a/src/libs/services/database/impl/Cluster.cpp b/src/libs/services/database/impl/Cluster.cpp index ed01031a..6acc874a 100644 --- a/src/libs/services/database/impl/Cluster.cpp +++ b/src/libs/services/database/impl/Cluster.cpp @@ -28,177 +28,183 @@ #include "SqlQuery.hpp" #include "Utils.hpp" -namespace Database { - -Cluster::Cluster(ObjectPtr type, std::string_view name) - : _name {std::string {name, 0, _maxNameLength}}, - _clusterType {getDboPtr(type)} +namespace Database { -} + namespace + { + Wt::Dbo::Query createQuery(Session& session, const Cluster::FindParameters& params) + { + session.checkSharedLocked(); -Cluster::pointer -Cluster::create(Session& session, ObjectPtr type, std::string_view name) -{ - return session.getDboSession().add(std::unique_ptr {new Cluster {type, name}}); -} + auto query{ session.getDboSession().query("SELECT DISTINCT c.id FROM cluster c") }; -std::size_t -Cluster::getCount(Session& session) -{ - session.checkSharedLocked(); + if (params.track.isValid() || params.release.isValid()) + { + query.join("track_cluster t_c ON t_c.cluster_id = c.id"); + query.join("track t ON t.id = t_c.track_id"); + } - return session.getDboSession().query("SELECT COUNT(*) FROM cluster"); -} + if (params.track.isValid()) + query.where("t.id = ?").bind(params.track); + if (params.release.isValid()) + query.where("t.release_id = ?").bind(params.release); -RangeResults -Cluster::find(Session& session, Range range) -{ - session.checkSharedLocked(); - auto query {session.getDboSession().query("SELECT id FROM cluster")}; + if (params.clusterType.isValid()) + query.where("c.cluster_type_id = ?").bind(params.clusterType); - return Utils::execQuery(query, range); -} + return query; + } + } -RangeResults -Cluster::findOrphans(Session& session, Range range) -{ - session.checkSharedLocked(); - auto query {session.getDboSession().query("SELECT DISTINCT c.id FROM cluster c WHERE NOT EXISTS(SELECT 1 FROM track_cluster t_c WHERE t_c.cluster_id = c.id)")}; + Cluster::Cluster(ObjectPtr type, std::string_view name) + : _name{ std::string {name, 0, _maxNameLength} }, + _clusterType{ getDboPtr(type) } + { + } - return Utils::execQuery(query, range); -} + Cluster::pointer Cluster::create(Session& session, ObjectPtr type, std::string_view name) + { + return session.getDboSession().add(std::unique_ptr {new Cluster{ type, name }}); + } -Cluster::pointer -Cluster::find(Session& session, ClusterId id) -{ - session.checkSharedLocked(); + std::size_t Cluster::getCount(Session& session) + { + session.checkSharedLocked(); - return session.getDboSession().find().where("id = ?").bind(id).resultValue(); -} + return session.getDboSession().query("SELECT COUNT(*) FROM cluster"); + } -void -Cluster::addTrack(ObjectPtr track) -{ - _tracks.insert(getDboPtr(track)); -} + RangeResults Cluster::find(Session& session, const FindParameters& params) + { + session.checkSharedLocked(); + auto query{ createQuery(session, params) }; -RangeResults -Cluster::getTracks(Range range) const -{ - assert(session()); + return Utils::execQuery(query, params.range); + } - auto query {session()->query("SELECT t.id FROM track t INNER JOIN cluster c ON c.id = t_c.cluster_id INNER JOIN track_cluster t_c ON t_c.track_id = t.id") - .where("c.id = ?").bind(getId())}; + RangeResults Cluster::findOrphans(Session& session, Range range) + { + session.checkSharedLocked(); + auto query{ session.getDboSession().query("SELECT DISTINCT c.id FROM cluster c WHERE NOT EXISTS(SELECT 1 FROM track_cluster t_c WHERE t_c.cluster_id = c.id)") }; - return Utils::execQuery(query, range); -} + return Utils::execQuery(query, range); + } -std::size_t -Cluster::getReleasesCount() const -{ - assert(session()); + Cluster::pointer Cluster::find(Session& session, ClusterId id) + { + session.checkSharedLocked(); - return session()->query("SELECT COUNT(DISTINCT r.id) FROM release r INNER JOIN track t on t.release_id = r.id INNER JOIN cluster c ON c.id = t_c.cluster_id INNER JOIN track_cluster t_c ON t_c.track_id = t.id") - .where("c.id = ?").bind(getId()); -} + return session.getDboSession().find().where("id = ?").bind(id).resultValue(); + } + + void Cluster::addTrack(ObjectPtr track) + { + _tracks.insert(getDboPtr(track)); + } + + RangeResults Cluster::getTracks(Range range) const + { + assert(session()); + + auto query{ session()->query("SELECT t.id FROM track t INNER JOIN cluster c ON c.id = t_c.cluster_id INNER JOIN track_cluster t_c ON t_c.track_id = t.id") + .where("c.id = ?").bind(getId()) }; + + return Utils::execQuery(query, range); + } + + std::size_t Cluster::getReleasesCount() const + { + assert(session()); + + return session()->query("SELECT COUNT(DISTINCT r.id) FROM release r INNER JOIN track t on t.release_id = r.id INNER JOIN cluster c ON c.id = t_c.cluster_id INNER JOIN track_cluster t_c ON t_c.track_id = t.id") + .where("c.id = ?").bind(getId()); + } -ClusterType::ClusterType(std::string_view name) - : _name {name} -{ -} + ClusterType::ClusterType(std::string_view name) + : _name{ name } + { + } -ClusterType::pointer -ClusterType::create(Session& session, const std::string& name) -{ - return session.getDboSession().add(std::unique_ptr {new ClusterType {name}}); -} + ClusterType::pointer ClusterType::create(Session& session, const std::string& name) + { + return session.getDboSession().add(std::unique_ptr {new ClusterType{ name }}); + } -std::size_t -ClusterType::getCount(Session& session) -{ - session.checkSharedLocked(); + std::size_t ClusterType::getCount(Session& session) + { + session.checkSharedLocked(); - return session.getDboSession().query("SELECT COUNT(*) FROM cluster_type"); -} + return session.getDboSession().query("SELECT COUNT(*) FROM cluster_type"); + } -RangeResults -ClusterType::findOrphans(Session& session, Range range) -{ - session.checkSharedLocked(); + RangeResults ClusterType::findOrphans(Session& session, Range range) + { + session.checkSharedLocked(); - auto query {session.getDboSession().query( - "SELECT c_t.id from cluster_type c_t" - " LEFT OUTER JOIN cluster c ON c_t.id = c.cluster_type_id") - .where("c.id IS NULL")}; + auto query{ session.getDboSession().query( + "SELECT c_t.id from cluster_type c_t" + " LEFT OUTER JOIN cluster c ON c_t.id = c.cluster_type_id") + .where("c.id IS NULL") }; - return Utils::execQuery(query, range); -} + return Utils::execQuery(query, range); + } -RangeResults -ClusterType::findUsed(Session& session, Range range) -{ - session.checkSharedLocked(); + RangeResults ClusterType::findUsed(Session& session, Range range) + { + session.checkSharedLocked(); - auto query {session.getDboSession().query( - "SELECT DISTINCT c_t.id from cluster_type c_t") - .join("cluster c ON c_t.id = c.cluster_type_id")}; + auto query{ session.getDboSession().query( + "SELECT DISTINCT c_t.id from cluster_type c_t") + .join("cluster c ON c_t.id = c.cluster_type_id") }; - return Utils::execQuery(query, range); -} + return Utils::execQuery(query, range); + } -ClusterType::pointer -ClusterType::find(Session& session, const std::string& name) -{ - session.checkSharedLocked(); + ClusterType::pointer ClusterType::find(Session& session, std::string_view name) + { + session.checkSharedLocked(); - return session.getDboSession().find().where("name = ?").bind(name).resultValue(); -} + return session.getDboSession().find().where("name = ?").bind(std::string{ name }).resultValue(); + } -ClusterType::pointer -ClusterType::find(Session& session, ClusterTypeId id) -{ - session.checkSharedLocked(); + ClusterType::pointer ClusterType::find(Session& session, ClusterTypeId id) + { + session.checkSharedLocked(); - return session.getDboSession().find().where("id = ?").bind(id).resultValue(); -} + return session.getDboSession().find().where("id = ?").bind(id).resultValue(); + } -RangeResults -ClusterType::find(Session& session, Range range) -{ - session.checkSharedLocked(); + RangeResults ClusterType::find(Session& session, Range range) + { + session.checkSharedLocked(); - auto query {session.getDboSession().query("SELECT id from cluster_type")}; + auto query{ session.getDboSession().query("SELECT id from cluster_type") }; - return Utils::execQuery(query, range); -} + return Utils::execQuery(query, range); + } -Cluster::pointer -ClusterType::getCluster(const std::string& name) const -{ - assert(self()); - assert(session()); + Cluster::pointer ClusterType::getCluster(const std::string& name) const + { + assert(self()); + assert(session()); - return session()->find() - .where("name = ?").bind(name) - .where("cluster_type_id = ?").bind(getId()).resultValue(); -} + return session()->find() + .where("name = ?").bind(name) + .where("cluster_type_id = ?").bind(getId()).resultValue(); + } -std::vector -ClusterType::getClusters() const -{ - assert(self()); - assert(session()); - - auto res = session()->find() - .where("cluster_type_id = ?").bind(getId()) - .orderBy("name") - .resultList(); - - return std::vector(res.begin(), res.end()); -} + std::vector ClusterType::getClusters() const + { + assert(self()); + assert(session()); + auto res = session()->find() + .where("cluster_type_id = ?").bind(getId()) + .orderBy("name") + .resultList(); + return std::vector(res.begin(), res.end()); + } } // namespace Database - diff --git a/src/libs/services/database/impl/Release.cpp b/src/libs/services/database/impl/Release.cpp index 781a3017..109c24b7 100644 --- a/src/libs/services/database/impl/Release.cpp +++ b/src/libs/services/database/impl/Release.cpp @@ -35,441 +35,443 @@ namespace Database { -Wt::Dbo::Query -createQuery(Session& session, const Release::FindParameters& params) -{ - auto query {session.getDboSession().query("SELECT DISTINCT r.id from release r")}; - - if (params.sortMethod == ReleaseSortMethod::LastWritten - || params.sortMethod == ReleaseSortMethod::Date - || params.sortMethod == ReleaseSortMethod::OriginalDate - || params.sortMethod == ReleaseSortMethod::OriginalDateDesc - || params.writtenAfter.isValid() - || params.dateRange - || params.artist.isValid()) - { - query.join("track t ON t.release_id = r.id"); - } - - if (params.writtenAfter.isValid()) - query.where("t.file_last_write > ?").bind(params.writtenAfter); - - if (params.dateRange) - { - query.where("t.date >= ?").bind(params.dateRange->begin); - query.where("t.date <= ?").bind(params.dateRange->end); - } - - for (std::string_view keyword : params.keywords) - query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + Utils::escapeLikeKeyword(keyword) + "%"); - - if (params.starringUser.isValid()) - { - assert(params.scrobbler); - query.join("starred_release s_r ON s_r.release_id = r.id") - .where("s_r.user_id = ?").bind(params.starringUser) - .where("s_r.scrobbler = ?").bind(*params.scrobbler) - .where("s_r.scrobbling_state <> ?").bind(ScrobblingState::PendingRemove); - } - - if (params.artist.isValid()) - { - query.join("artist a ON a.id = t_a_l.artist_id") - .join("track_artist_link t_a_l ON t_a_l.track_id = t.id") - .where("a.id = ?").bind(params.artist); - - if (!params.trackArtistLinkTypes.empty()) - { - std::ostringstream oss; - - bool first {true}; - for (TrackArtistLinkType linkType : params.trackArtistLinkTypes) - { - if (!first) - oss << " OR "; - oss << "t_a_l.type = ?"; - query.bind(linkType); - - first = false; - } - query.where(oss.str()); - } - - if (!params.excludedTrackArtistLinkTypes.empty()) - { - std::ostringstream oss; - oss << "r.id NOT IN (SELECT DISTINCT r.id FROM release r" - " INNER JOIN artist a ON a.id = t_a_l.artist_id" - " INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id" - " INNER JOIN track t ON t.release_id = r.id" - " WHERE (a.id = ? AND ("; - - query.bind(params.artist); - - bool first {true}; - for (const TrackArtistLinkType linkType : params.excludedTrackArtistLinkTypes) - { - if (!first) - oss << " OR "; - oss << "t_a_l.type = ?"; - query.bind(linkType); - - first = false; - } - oss << ")))"; - query.where(oss.str()); - } - } - - if (!params.clusters.empty()) - { - std::ostringstream oss; - oss << "r.id IN (SELECT DISTINCT r.id FROM release r" - " INNER JOIN track t ON t.release_id = r.id" - " INNER JOIN cluster c ON c.id = t_c.cluster_id" - " INNER JOIN track_cluster t_c ON t_c.track_id = t.id"; - - WhereClause clusterClause; - for (const ClusterId clusterId : params.clusters) - { - clusterClause.Or(WhereClause("c.id = ?")); - query.bind(clusterId); - } - - oss << " " << clusterClause.get(); - oss << " GROUP BY t.id HAVING COUNT(*) = " << params.clusters.size() << ")"; - - query.where(oss.str()); - } - - if (params.primaryType) - query.where("primary_type = ?").bind(*params.primaryType); - if (!params.secondaryTypes.empty()) - query.where("secondary_type = ?").bind(params.secondaryTypes); - - switch (params.sortMethod) - { - case ReleaseSortMethod::None: - break; - case ReleaseSortMethod::Name: - query.orderBy("r.name COLLATE NOCASE"); - break; - case ReleaseSortMethod::Random: - query.orderBy("RANDOM()"); - break; - case ReleaseSortMethod::LastWritten: - query.orderBy("t.file_last_write DESC"); - break; - case ReleaseSortMethod::Date: - query.orderBy("t.date, r.name COLLATE NOCASE"); - break; - case ReleaseSortMethod::OriginalDate: - query.orderBy("CASE WHEN t.original_date IS NULL THEN t.date ELSE t.original_date END, t.date, r.name COLLATE NOCASE"); - break; - case ReleaseSortMethod::OriginalDateDesc: - query.orderBy("CASE WHEN t.original_date IS NULL THEN t.date ELSE t.original_date END DESC, t.date, r.name COLLATE NOCASE"); - break; - case ReleaseSortMethod::StarredDateDesc: - assert(params.starringUser.isValid()); - query.orderBy("s_r.date_time DESC"); - break; - } - - return query; -} - -Release::Release(const std::string& name, const std::optional& MBID) -: _name {std::string(name, 0 , _maxNameLength)}, -_MBID {MBID ? MBID->getAsString() : ""} -{ -} - -Release::pointer -Release::create(Session& session, const std::string& name, const std::optional& MBID) -{ - return session.getDboSession().add(std::unique_ptr {new Release {name, MBID}}); -} - -std::vector -Release::find(Session& session, const std::string& name) -{ - session.checkUniqueLocked(); - - auto res {session.getDboSession() - .find() - .where("name = ?").bind( std::string(name, 0, _maxNameLength) ) - .resultList()}; - - return std::vector(res.begin(), res.end()); -} - -Release::pointer -Release::find(Session& session, const UUID& mbid) -{ - session.checkSharedLocked(); - - return session.getDboSession() - .find() - .where("mbid = ?").bind(std::string {mbid.getAsString()}) - .resultValue();; -} - -Release::pointer -Release::find(Session& session, ReleaseId id) -{ - session.checkSharedLocked(); - - return session.getDboSession() - .find() - .where("id = ?").bind(id) - .resultValue(); -} - -bool -Release::exists(Session& session, ReleaseId id) -{ - session.checkSharedLocked(); - return session.getDboSession().query("SELECT 1 FROM release").where("id = ?").bind(id).resultValue() == 1; -} - -std::size_t -Release::getCount(Session& session) -{ - session.checkSharedLocked(); - - return session.getDboSession().query("SELECT COUNT(*) FROM release"); -} - -RangeResults -Release::findOrderedByArtist(Session& session, Range range) -{ - session.checkSharedLocked(); - - // TODO merge with find - auto query {session.getDboSession().query( - "SELECT DISTINCT r.id FROM release r" - " INNER JOIN track t ON r.id = t.release_id" - " INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id" - " INNER JOIN artist a ON t_a_l.artist_id = a.id") - .orderBy("a.name COLLATE NOCASE, r.name COLLATE NOCASE")}; - - return Utils::execQuery(query, range); -} - -RangeResults -Release::findOrphans(Session& session, Range range) -{ - session.checkSharedLocked(); - - auto query {session.getDboSession().query("select r.id from release r LEFT OUTER JOIN Track t ON r.id = t.release_id WHERE t.id IS NULL")}; - return Utils::execQuery(query, range); -} - -RangeResults -Release::find(Session& session, const FindParameters& params) -{ - session.checkSharedLocked(); - - auto query {createQuery(session, params)}; - - return Utils::execQuery(query, params.range); -} - -std::size_t -Release::getDiscCount() const -{ - assert(session()); - int res {session()->query("SELECT COUNT(DISTINCT disc_number) FROM track t") - .join("release r ON r.id = t.release_id") - .where("r.id = ?") - .bind(getId())}; - - return res; -} - -std::optional -Release::getReleaseYear(bool original) const -{ - assert(session()); - - const char* field {original ? "original_date" : "date"}; - - auto dates {session()->query( - std::string {"SELECT "} + "t." + field + " FROM track t INNER JOIN release r ON r.id = t.release_id") - .where("r.id = ?") - .groupBy(field) - .bind(getId()) - .resultList()}; - - // various dates => no date - if (dates.empty() || dates.size() > 1) - return std::nullopt; - - auto date {dates.front().year()}; - - if (date > 0) - return date; - - return std::nullopt; -} - -std::optional -Release::getCopyright() const -{ - assert(session()); - - Wt::Dbo::collection copyrights = session()->query - ("SELECT copyright FROM track t INNER JOIN release r ON r.id = t.release_id") - .where("r.id = ?") - .groupBy("copyright") - .bind(getId()); - - std::vector values(copyrights.begin(), copyrights.end()); - - // various copyrights => no copyright - if (values.empty() || values.size() > 1 || values.front().empty()) - return std::nullopt; - - return values.front(); -} - -std::optional -Release::getCopyrightURL() const -{ - assert(session()); - - Wt::Dbo::collection copyrights = session()->query - ("SELECT copyright_url FROM track t INNER JOIN release r ON r.id = t.release_id") - .where("r.id = ?") - .groupBy("copyright_url") - .bind(getId()); - - std::vector values(copyrights.begin(), copyrights.end()); - - // various copyright URLs => no copyright URL - if (values.empty() || values.size() > 1 || values.front().empty()) - return std::nullopt; - - return values.front(); -} - -std::vector -Release::getArtists(TrackArtistLinkType linkType) const -{ - assert(session()); - - auto res {session()->query>( - "SELECT DISTINCT a FROM artist a" - " INNER JOIN track_artist_link t_a_l ON t_a_l.artist_id = a.id" - " INNER JOIN track t ON t.id = t_a_l.track_id" - " INNER JOIN release r ON r.id = t.release_id") - .where("r.id = ?").bind(getId()) - .where("t_a_l.type = ?").bind(linkType) - .resultList()}; - - return std::vector(res.begin(), res.end()); -} - -std::vector -Release::getSimilarReleases(std::optional offset, std::optional count) const -{ - assert(session()); - - auto res {session()->query>( - "SELECT r FROM release r" - " INNER JOIN track t ON t.release_id = r.id" - " INNER JOIN track_cluster t_c ON t_c.track_id = t.id" - " WHERE " - " t_c.cluster_id IN (SELECT c.id from cluster c INNER JOIN track t ON c.id = t_c.cluster_id INNER JOIN track_cluster t_c ON t_c.track_id = t.id INNER JOIN release r ON r.id = t.release_id WHERE r.id = ?)" - " AND r.id <> ?" - ) - .bind(getId()) - .bind(getId()) - .groupBy("r.id") - .orderBy("COUNT(*) DESC, RANDOM()") - .limit(count ? static_cast(*count) : -1) - .offset(offset ? static_cast(*offset) : -1) - .resultList()}; - - return std::vector(res.begin(), res.end()); -} - -bool -Release::hasVariousArtists() const -{ - // TODO optimize - return getArtists().size() > 1; -} - -std::size_t -Release::getTracksCount() const -{ - return _tracks.size(); -} - -std::chrono::milliseconds -Release::getDuration() const -{ - assert(session()); - - using milli = std::chrono::duration; - - Wt::Dbo::Query query {session()->query("SELECT COALESCE(SUM(duration), 0) FROM track t INNER JOIN release r ON t.release_id = r.id") - .where("r.id = ?").bind(getId())}; - - return query.resultValue(); -} - -Wt::WDateTime -Release::getLastWritten() const -{ - assert(session()); - - Wt::Dbo::Query query {session()->query("SELECT COALESCE(MAX(file_last_write), '1970-01-01T00:00:00') FROM track t INNER JOIN release r ON t.release_id = r.id") - .where("r.id = ?").bind(getId())}; - - return query.resultValue(); -} - -std::vector> -Release::getClusterGroups(const std::vector& clusterTypes, std::size_t size) const -{ - assert(session()); - - WhereClause where; - - std::ostringstream oss; - - oss << "SELECT c from cluster c INNER JOIN track t ON c.id = t_c.cluster_id INNER JOIN track_cluster t_c ON t_c.track_id = t.id INNER JOIN cluster_type c_type ON c.cluster_type_id = c_type.id INNER JOIN release r ON t.release_id = r.id "; - - where.And(WhereClause("r.id = ?")).bind(getId().toString()); - { - WhereClause clusterClause; - for (auto clusterType : clusterTypes) - clusterClause.Or(WhereClause("c_type.id = ?")).bind(clusterType->getId().toString()); - where.And(clusterClause); - } - oss << " " << where.get(); - oss << " GROUP BY c.id ORDER BY COUNT(c.id) DESC"; - - auto query {session()->query>(oss.str())}; - for (const std::string& bindArg : where.getBindArgs()) - query.bind(bindArg); - - auto queryRes {query.resultList()}; - - std::map> clustersByType; - for (const Wt::Dbo::ptr& cluster : queryRes) - { - if (clustersByType[cluster->getType()->getId()].size() < size) - clustersByType[cluster->getType()->getId()].push_back(cluster); - } - - std::vector> res; - for (const auto& [clusterTypeId, clusters] : clustersByType) - res.push_back(clusters); - - return res; -} + Wt::Dbo::Query createQuery(Session& session, const Release::FindParameters& params) + { + auto query{ session.getDboSession().query("SELECT DISTINCT r.id from release r") }; + + if (params.sortMethod == ReleaseSortMethod::LastWritten + || params.sortMethod == ReleaseSortMethod::Date + || params.sortMethod == ReleaseSortMethod::OriginalDate + || params.sortMethod == ReleaseSortMethod::OriginalDateDesc + || params.writtenAfter.isValid() + || params.dateRange + || params.artist.isValid()) + { + query.join("track t ON t.release_id = r.id"); + } + + if (params.writtenAfter.isValid()) + query.where("t.file_last_write > ?").bind(params.writtenAfter); + + if (params.dateRange) + { + query.where("t.date >= ?").bind(params.dateRange->begin); + query.where("t.date <= ?").bind(params.dateRange->end); + } + + for (std::string_view keyword : params.keywords) + query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + Utils::escapeLikeKeyword(keyword) + "%"); + + if (params.starringUser.isValid()) + { + assert(params.scrobbler); + query.join("starred_release s_r ON s_r.release_id = r.id") + .where("s_r.user_id = ?").bind(params.starringUser) + .where("s_r.scrobbler = ?").bind(*params.scrobbler) + .where("s_r.scrobbling_state <> ?").bind(ScrobblingState::PendingRemove); + } + + if (params.artist.isValid()) + { + query.join("artist a ON a.id = t_a_l.artist_id") + .join("track_artist_link t_a_l ON t_a_l.track_id = t.id") + .where("a.id = ?").bind(params.artist); + + if (!params.trackArtistLinkTypes.empty()) + { + std::ostringstream oss; + + bool first{ true }; + for (TrackArtistLinkType linkType : params.trackArtistLinkTypes) + { + if (!first) + oss << " OR "; + oss << "t_a_l.type = ?"; + query.bind(linkType); + + first = false; + } + query.where(oss.str()); + } + + if (!params.excludedTrackArtistLinkTypes.empty()) + { + std::ostringstream oss; + oss << "r.id NOT IN (SELECT DISTINCT r.id FROM release r" + " INNER JOIN artist a ON a.id = t_a_l.artist_id" + " INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id" + " INNER JOIN track t ON t.release_id = r.id" + " WHERE (a.id = ? AND ("; + + query.bind(params.artist); + + bool first{ true }; + for (const TrackArtistLinkType linkType : params.excludedTrackArtistLinkTypes) + { + if (!first) + oss << " OR "; + oss << "t_a_l.type = ?"; + query.bind(linkType); + + first = false; + } + oss << ")))"; + query.where(oss.str()); + } + } + + if (!params.clusters.empty()) + { + std::ostringstream oss; + oss << "r.id IN (SELECT DISTINCT r.id FROM release r" + " INNER JOIN track t ON t.release_id = r.id" + " INNER JOIN cluster c ON c.id = t_c.cluster_id" + " INNER JOIN track_cluster t_c ON t_c.track_id = t.id"; + + WhereClause clusterClause; + for (const ClusterId clusterId : params.clusters) + { + clusterClause.Or(WhereClause("c.id = ?")); + query.bind(clusterId); + } + + oss << " " << clusterClause.get(); + oss << " GROUP BY t.id HAVING COUNT(*) = " << params.clusters.size() << ")"; + + query.where(oss.str()); + } + + if (params.primaryType) + query.where("primary_type = ?").bind(*params.primaryType); + if (!params.secondaryTypes.empty()) + query.where("secondary_type = ?").bind(params.secondaryTypes); + + switch (params.sortMethod) + { + case ReleaseSortMethod::None: + break; + case ReleaseSortMethod::Name: + query.orderBy("r.name COLLATE NOCASE"); + break; + case ReleaseSortMethod::Random: + query.orderBy("RANDOM()"); + break; + case ReleaseSortMethod::LastWritten: + query.orderBy("t.file_last_write DESC"); + break; + case ReleaseSortMethod::Date: + query.orderBy("t.date, r.name COLLATE NOCASE"); + break; + case ReleaseSortMethod::OriginalDate: + query.orderBy("CASE WHEN t.original_date IS NULL THEN t.date ELSE t.original_date END, t.date, r.name COLLATE NOCASE"); + break; + case ReleaseSortMethod::OriginalDateDesc: + query.orderBy("CASE WHEN t.original_date IS NULL THEN t.date ELSE t.original_date END DESC, t.date, r.name COLLATE NOCASE"); + break; + case ReleaseSortMethod::StarredDateDesc: + assert(params.starringUser.isValid()); + query.orderBy("s_r.date_time DESC"); + break; + } + + return query; + } + + Release::Release(const std::string& name, const std::optional& MBID) + : _name{ std::string(name, 0 , _maxNameLength) }, + _MBID{ MBID ? MBID->getAsString() : "" } + { + } + + Release::pointer Release::create(Session& session, const std::string& name, const std::optional& MBID) + { + return session.getDboSession().add(std::unique_ptr {new Release{ name, MBID }}); + } + + std::vector Release::find(Session& session, const std::string& name) + { + session.checkUniqueLocked(); + + auto res{ session.getDboSession() + .find() + .where("name = ?").bind(std::string(name, 0, _maxNameLength)) + .resultList() }; + + return std::vector(res.begin(), res.end()); + } + + Release::pointer Release::find(Session& session, const UUID& mbid) + { + session.checkSharedLocked(); + + return session.getDboSession() + .find() + .where("mbid = ?").bind(std::string{ mbid.getAsString() }) + .resultValue();; + } + + Release::pointer Release::find(Session& session, ReleaseId id) + { + session.checkSharedLocked(); + + return session.getDboSession() + .find() + .where("id = ?").bind(id) + .resultValue(); + } + + bool Release::exists(Session& session, ReleaseId id) + { + session.checkSharedLocked(); + return session.getDboSession().query("SELECT 1 FROM release").where("id = ?").bind(id).resultValue() == 1; + } + + std::size_t Release::getCount(Session& session) + { + session.checkSharedLocked(); + + return session.getDboSession().query("SELECT COUNT(*) FROM release"); + } + + RangeResults Release::findOrderedByArtist(Session& session, Range range) + { + session.checkSharedLocked(); + + // TODO merge with find + auto query{ session.getDboSession().query( + "SELECT DISTINCT r.id FROM release r" + " INNER JOIN track t ON r.id = t.release_id" + " INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id" + " INNER JOIN artist a ON t_a_l.artist_id = a.id") + .orderBy("a.name COLLATE NOCASE, r.name COLLATE NOCASE") }; + + return Utils::execQuery(query, range); + } + + RangeResults Release::findOrphans(Session& session, Range range) + { + session.checkSharedLocked(); + + auto query{ session.getDboSession().query("select r.id from release r LEFT OUTER JOIN Track t ON r.id = t.release_id WHERE t.id IS NULL") }; + return Utils::execQuery(query, range); + } + + RangeResults Release::find(Session& session, const FindParameters& params) + { + session.checkSharedLocked(); + + auto query{ createQuery(session, params) }; + + return Utils::execQuery(query, params.range); + } + + std::size_t Release::getDiscCount() const + { + assert(session()); + int res{ session()->query("SELECT COUNT(DISTINCT disc_number) FROM track t") + .join("release r ON r.id = t.release_id") + .where("r.id = ?") + .bind(getId()) }; + + return res; + } + + std::vector Release::getDiscs() const + { + assert(session()); + using ResultType = std::tuple; + auto results{ session()->query("SELECT DISTINCT disc_number, disc_subtitle FROM track t") + .join("release r ON r.id = t.release_id") + .where("r.id = ?") + .orderBy("disc_number") + .bind(getId()) + .resultList() }; + + std::vector discs; + for (const auto& res : results) + discs.emplace_back(DiscInfo{ static_cast(std::get(res)), std::get(res) }); + + return discs; + } + + Wt::WDate Release::getReleaseDate() const + { + return getReleaseDate(false); + } + + Wt::WDate Release::getOriginalReleaseDate() const + { + return getReleaseDate(true); + } + + Wt::WDate Release::getReleaseDate(bool original) const + { + assert(session()); + + const char* field{ original ? "original_date" : "date" }; + + auto dates{ session()->query( + std::string {"SELECT "} + "t." + field + " FROM track t INNER JOIN release r ON r.id = t.release_id") + .where("r.id = ?") + .groupBy(field) + .bind(getId()) + .resultList() }; + + // various dates => invalid date + if (dates.empty() || dates.size() > 1) + return {}; + + return dates.front(); + } + + std::optional Release::getCopyright() const + { + assert(session()); + + Wt::Dbo::collection copyrights = session()->query + ("SELECT copyright FROM track t INNER JOIN release r ON r.id = t.release_id") + .where("r.id = ?") + .groupBy("copyright") + .bind(getId()); + + std::vector values(copyrights.begin(), copyrights.end()); + + // various copyrights => no copyright + if (values.empty() || values.size() > 1 || values.front().empty()) + return std::nullopt; + + return values.front(); + } + + std::optional Release::getCopyrightURL() const + { + assert(session()); + + Wt::Dbo::collection copyrights = session()->query + ("SELECT copyright_url FROM track t INNER JOIN release r ON r.id = t.release_id") + .where("r.id = ?") + .groupBy("copyright_url") + .bind(getId()); + + std::vector values(copyrights.begin(), copyrights.end()); + + // various copyright URLs => no copyright URL + if (values.empty() || values.size() > 1 || values.front().empty()) + return std::nullopt; + + return values.front(); + } + + std::vector Release::getArtists(TrackArtistLinkType linkType) const + { + assert(session()); + + auto res{ session()->query>( + "SELECT DISTINCT a FROM artist a" + " INNER JOIN track_artist_link t_a_l ON t_a_l.artist_id = a.id" + " INNER JOIN track t ON t.id = t_a_l.track_id" + " INNER JOIN release r ON r.id = t.release_id") + .where("r.id = ?").bind(getId()) + .where("t_a_l.type = ?").bind(linkType) + .resultList() }; + + return std::vector(res.begin(), res.end()); + } + + std::vector Release::getSimilarReleases(std::optional offset, std::optional count) const + { + assert(session()); + + auto res{ session()->query>( + "SELECT r FROM release r" + " INNER JOIN track t ON t.release_id = r.id" + " INNER JOIN track_cluster t_c ON t_c.track_id = t.id" + " WHERE " + " t_c.cluster_id IN (SELECT c.id from cluster c INNER JOIN track t ON c.id = t_c.cluster_id INNER JOIN track_cluster t_c ON t_c.track_id = t.id INNER JOIN release r ON r.id = t.release_id WHERE r.id = ?)" + " AND r.id <> ?" + ) + .bind(getId()) + .bind(getId()) + .groupBy("r.id") + .orderBy("COUNT(*) DESC, RANDOM()") + .limit(count ? static_cast(*count) : -1) + .offset(offset ? static_cast(*offset) : -1) + .resultList() }; + + return std::vector(res.begin(), res.end()); + } + + bool Release::hasVariousArtists() const + { + // TODO optimize + return getArtists().size() > 1; + } + + std::size_t Release::getTracksCount() const + { + return _tracks.size(); + } + + std::chrono::milliseconds Release::getDuration() const + { + assert(session()); + + using milli = std::chrono::duration; + + Wt::Dbo::Query query{ session()->query("SELECT COALESCE(SUM(duration), 0) FROM track t INNER JOIN release r ON t.release_id = r.id") + .where("r.id = ?").bind(getId()) }; + + return query.resultValue(); + } + + Wt::WDateTime Release::getLastWritten() const + { + assert(session()); + + Wt::Dbo::Query query{ session()->query("SELECT COALESCE(MAX(file_last_write), '1970-01-01T00:00:00') FROM track t INNER JOIN release r ON t.release_id = r.id") + .where("r.id = ?").bind(getId()) }; + + return query.resultValue(); + } + + std::vector> Release::getClusterGroups(const std::vector& clusterTypes, std::size_t size) const + { + assert(session()); + + WhereClause where; + + std::ostringstream oss; + + oss << "SELECT c from cluster c INNER JOIN track t ON c.id = t_c.cluster_id INNER JOIN track_cluster t_c ON t_c.track_id = t.id INNER JOIN cluster_type c_type ON c.cluster_type_id = c_type.id INNER JOIN release r ON t.release_id = r.id "; + + where.And(WhereClause("r.id = ?")).bind(getId().toString()); + { + WhereClause clusterClause; + for (auto clusterType : clusterTypes) + clusterClause.Or(WhereClause("c_type.id = ?")).bind(clusterType->getId().toString()); + where.And(clusterClause); + } + oss << " " << where.get(); + oss << " GROUP BY c.id ORDER BY COUNT(c.id) DESC"; + + auto query{ session()->query>(oss.str()) }; + for (const std::string& bindArg : where.getBindArgs()) + query.bind(bindArg); + + auto queryRes{ query.resultList() }; + + std::map> clustersByType; + for (const Wt::Dbo::ptr& cluster : queryRes) + { + if (clustersByType[cluster->getType()->getId()].size() < size) + clustersByType[cluster->getType()->getId()].push_back(cluster); + } + + std::vector> res; + for (const auto& [clusterTypeId, clusters] : clustersByType) + res.push_back(clusters); + + return res; + } } // namespace Database diff --git a/src/libs/services/database/impl/TrackArtistLink.cpp b/src/libs/services/database/impl/TrackArtistLink.cpp index bffd8d4d..ab64672a 100644 --- a/src/libs/services/database/impl/TrackArtistLink.cpp +++ b/src/libs/services/database/impl/TrackArtistLink.cpp @@ -28,74 +28,84 @@ namespace Database { + namespace + { + Wt::Dbo::Query createQuery(Session& session, const TrackArtistLink::FindParameters& params) + { + session.checkSharedLocked(); - static - Wt::Dbo::Query - createQuery(Session& session, const TrackArtistLink::FindParameters& params) - { - session.checkSharedLocked(); + auto query{ session.getDboSession().query("SELECT DISTINCT t_a_l.id FROM track_artist_link t_a_l") }; - auto query {session.getDboSession().query("SELECT DISTINCT t_a_l.id FROM track_artist_link t_a_l")}; + if (params.linkType) + query.where("t_a_l.type = ?").bind(*params.linkType); - if (params.linkType) - query.where("t_a_l.type = ?").bind(*params.linkType); + if (params.track.isValid() || params.release.isValid()) + query.join("track t ON t.id = t_a_l.track_id"); - if (params.track.isValid() || params.release.isValid()) - query.join("track t ON t.id = t_a_l.track_id"); + if (params.artist.isValid()) + query.join("artist a ON a.id = t_a_l.artist_id"); - if (params.track.isValid()) - query.where("t.id = ?").bind(params.track); + if (params.release.isValid()) + query.where("t.release_id = ?").bind(params.release); - if (params.release.isValid()) - query.where("t.release_id = ?").bind(params.release); + if (params.track.isValid()) + query.where("t.id = ?").bind(params.track); - return query; - } + return query; + } + } - TrackArtistLink::TrackArtistLink(ObjectPtr track, ObjectPtr artist, TrackArtistLinkType type, std::string_view subType) - : _type {type} - , _subType {subType} - , _track {getDboPtr(track)} - , _artist {getDboPtr(artist)} - { - } + TrackArtistLink::TrackArtistLink(ObjectPtr track, ObjectPtr artist, TrackArtistLinkType type, std::string_view subType) + : _type{ type } + , _subType{ subType } + , _track{ getDboPtr(track) } + , _artist{ getDboPtr(artist) } + { + } - TrackArtistLink::pointer - TrackArtistLink::create(Session& session, ObjectPtr track, ObjectPtr artist, TrackArtistLinkType type, std::string_view subType) - { - session.checkUniqueLocked(); + TrackArtistLink::pointer TrackArtistLink::create(Session& session, ObjectPtr track, ObjectPtr artist, TrackArtistLinkType type, std::string_view subType) + { + session.checkUniqueLocked(); - TrackArtistLink::pointer res {session.getDboSession().add(std::make_unique(track, artist, type, subType))}; - session.getDboSession().flush(); + TrackArtistLink::pointer res{ session.getDboSession().add(std::make_unique(track, artist, type, subType)) }; + session.getDboSession().flush(); - return res; - } + return res; + } - TrackArtistLink::pointer - TrackArtistLink::find(Session& session, TrackArtistLinkId id) - { - session.checkSharedLocked(); - return session.getDboSession().find().where("id = ?").bind(id).resultValue(); - } + TrackArtistLink::pointer TrackArtistLink::find(Session& session, TrackArtistLinkId id) + { + session.checkSharedLocked(); + return session.getDboSession().find().where("id = ?").bind(id).resultValue(); + } - RangeResults - TrackArtistLink::find(Session& session, const FindParameters& params) - { - session.checkSharedLocked(); + RangeResults TrackArtistLink::find(Session& session, const FindParameters& params) + { + session.checkSharedLocked(); - auto query {createQuery(session, params)}; - return Utils::execQuery(query, params.range); - } + auto query{ createQuery(session, params) }; + return Utils::execQuery(query, params.range); + } + EnumSet TrackArtistLink::findUsedTypes(Session& session) + { + session.checkSharedLocked(); - EnumSet - TrackArtistLink::findUsedTypes(Session& session) - { - session.checkSharedLocked(); + auto res{ session.getDboSession().query("SELECT DISTINCT type from track_artist_link").resultList() }; - auto res {session.getDboSession().query("SELECT DISTINCT type from track_artist_link").resultList()}; + return EnumSet(std::begin(res), std::end(res)); + } - return EnumSet(std::begin(res), std::end(res)); - } + EnumSet TrackArtistLink::findUsedTypes(Session& session, ArtistId artistId) + { + session.checkSharedLocked(); + + auto res{ session.getDboSession() + .query("SELECT DISTINCT type from track_artist_link") + .where("artist_id = ?").bind(artistId) + .resultList() }; + + return EnumSet(std::begin(res), std::end(res)); + } } diff --git a/src/libs/services/database/include/services/database/Cluster.hpp b/src/libs/services/database/include/services/database/Cluster.hpp index a7113713..4f416e7f 100644 --- a/src/libs/services/database/include/services/database/Cluster.hpp +++ b/src/libs/services/database/include/services/database/Cluster.hpp @@ -26,100 +26,114 @@ #include #include -#include "services/database/Object.hpp" #include "services/database/ClusterId.hpp" +#include "services/database/Object.hpp" +#include "services/database/Release.hpp" #include "services/database/TrackId.hpp" #include "services/database/Types.hpp" namespace Database { -class Track; -class ClusterType; -class ScanSettings; -class Session; + class Track; + class ClusterType; + class ScanSettings; + class Session; -class Cluster final : public Object -{ - public: - Cluster() = default; + class Cluster final : public Object + { + public: + struct FindParameters + { + Range range; + ClusterTypeId clusterType; // if non empty, clusters that belong to this cluster type + TrackId track; // if set, clusters involved in this track + ReleaseId release; // if set, clusters involved in this release - // Find utility - static std::size_t getCount(Session& session); - static RangeResults find(Session& session, Range range); - static pointer find(Session& session, ClusterId id); - static RangeResults findOrphans(Session& session, Range range); + FindParameters& setRange(Range _range) { range = _range; return *this; } + FindParameters& setClusterType(ClusterTypeId _clusterType) { clusterType = _clusterType; return *this; } + FindParameters& setTrack(TrackId _track) { track = _track; return *this; } + FindParameters& setRelease(ReleaseId _release) { release = _release; return *this; } + }; - // Accessors - const std::string& getName() const { return _name; } - ObjectPtr getType() const { return _clusterType; } - std::size_t getTracksCount() const { return _tracks.size(); } - RangeResults getTracks(Range range) const; - std::size_t getReleasesCount() const; + Cluster() = default; - void addTrack(ObjectPtr track); + // Find utility + static std::size_t getCount(Session& session); + static RangeResults find(Session& session, const FindParameters& range); + static pointer find(Session& session, ClusterId id); + static RangeResults findOrphans(Session& session, Range range); - template - void persist(Action& a) - { - Wt::Dbo::field(a, _name, "name"); + // Accessors + const std::string& getName() const { return _name; } + ObjectPtr getType() const { return _clusterType; } + std::size_t getTracksCount() const { return _tracks.size(); } + RangeResults getTracks(Range range) const; + std::size_t getReleasesCount() const; - Wt::Dbo::belongsTo(a, _clusterType, "cluster_type", Wt::Dbo::OnDeleteCascade); - Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToMany, "track_cluster", "", Wt::Dbo::OnDeleteCascade); - } + void addTrack(ObjectPtr track); - private: - friend class Session; - Cluster(ObjectPtr type, std::string_view name); - static pointer create(Session& session, ObjectPtr type, std::string_view name); + template + void persist(Action& a) + { + Wt::Dbo::field(a, _name, "name"); - static const std::size_t _maxNameLength = 128; + Wt::Dbo::belongsTo(a, _clusterType, "cluster_type", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToMany, "track_cluster", "", Wt::Dbo::OnDeleteCascade); + } - std::string _name; + private: + friend class Session; + Cluster(ObjectPtr type, std::string_view name); + static pointer create(Session& session, ObjectPtr type, std::string_view name); - Wt::Dbo::ptr _clusterType; - Wt::Dbo::collection< Wt::Dbo::ptr > _tracks; -}; + static const std::size_t _maxNameLength = 128; + + std::string _name; + + Wt::Dbo::ptr _clusterType; + Wt::Dbo::collection< Wt::Dbo::ptr > _tracks; + }; -class ClusterType final : public Object -{ - public: - ClusterType() = default; + class ClusterType final : public Object + { + public: + ClusterType() = default; - // Getters - static std::size_t getCount(Session& session); - static RangeResults find(Session& session, Range range); - static pointer find(Session& session, const std::string& name); - static pointer find(Session& session, ClusterTypeId id); - static RangeResults findOrphans(Session& session, Range range); - static RangeResults findUsed(Session& session, Range range); + // Getters + static std::size_t getCount(Session& session); + static RangeResults find(Session& session, Range range); + static pointer find(Session& session, std::string_view name); + static pointer find(Session& session, ClusterTypeId id); + static RangeResults findOrphans(Session& session, Range range); + static RangeResults findUsed(Session& session, Range range); - static void remove(Session& session, const std::string& name); + static void remove(Session& session, const std::string& name); - // Accessors - const std::string& getName() const { return _name; } - std::vector getClusters() const; - Cluster::pointer getCluster(const std::string& name) const; + // Accessors + const std::string& getName() const { return _name; } + std::vector getClusters() const; + Cluster::pointer getCluster(const std::string& name) const; - template - void persist(Action& a) - { - Wt::Dbo::field(a, _name, "name"); - Wt::Dbo::hasMany(a, _clusters, Wt::Dbo::ManyToOne, "cluster_type"); - Wt::Dbo::belongsTo(a, _scanSettings, "scan_settings", Wt::Dbo::OnDeleteCascade); - } + template + void persist(Action& a) + { + Wt::Dbo::field(a, _name, "name"); + Wt::Dbo::hasMany(a, _clusters, Wt::Dbo::ManyToOne, "cluster_type"); + Wt::Dbo::belongsTo(a, _scanSettings, "scan_settings", Wt::Dbo::OnDeleteCascade); + } - private: - friend class Session; - ClusterType(std::string_view name); - static pointer create(Session& session, const std::string& name); + private: + friend class Session; + ClusterType(std::string_view name); + static pointer create(Session& session, const std::string& name); - static const std::size_t _maxNameLength = 128; + static const std::size_t _maxNameLength = 128; - std::string _name; - Wt::Dbo::collection< Wt::Dbo::ptr > _clusters; - Wt::Dbo::ptr _scanSettings; -}; + std::string _name; + Wt::Dbo::collection< Wt::Dbo::ptr > _clusters; + Wt::Dbo::ptr _scanSettings; + }; } // namespace Database diff --git a/src/libs/services/database/include/services/database/Release.hpp b/src/libs/services/database/include/services/database/Release.hpp index ec1ca623..810a4067 100644 --- a/src/libs/services/database/include/services/database/Release.hpp +++ b/src/libs/services/database/include/services/database/Release.hpp @@ -37,124 +37,127 @@ namespace Database { -class Artist; -class Cluster; -class ClusterType; -class Release; -class Session; -class Track; -class User; + class Artist; + class Cluster; + class ClusterType; + class Release; + class Session; + class Track; + class User; -class Release final : public Object -{ - public: - struct FindParameters - { - std::vector clusters; // if non empty, releases that belong to these clusters - std::vector keywords; // if non empty, name must match all of these keywords - ReleaseSortMethod sortMethod {ReleaseSortMethod::None}; - Range range; - Wt::WDateTime writtenAfter; - std::optional dateRange; - UserId starringUser; // only releases starred by this user - std::optional scrobbler; // and for this scrobbler - ArtistId artist; // only releases that involved this user - EnumSet trackArtistLinkTypes; // and for these link types - EnumSet excludedTrackArtistLinkTypes; // but not for these link types - std::optional primaryType; // if, set, matching this primary type - EnumSet secondaryTypes; // Matching all this (if any) + class Release final : public Object + { + public: + struct FindParameters + { + std::vector clusters; // if non empty, releases that belong to these clusters + std::vector keywords; // if non empty, name must match all of these keywords + ReleaseSortMethod sortMethod{ ReleaseSortMethod::None }; + Range range; + Wt::WDateTime writtenAfter; + std::optional dateRange; + UserId starringUser; // only releases starred by this user + std::optional scrobbler; // and for this scrobbler + ArtistId artist; // only releases that involved this user + EnumSet trackArtistLinkTypes; // and for these link types + EnumSet excludedTrackArtistLinkTypes; // but not for these link types + std::optional primaryType; // if, set, matching this primary type + EnumSet secondaryTypes; // Matching all this (if any) - FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } - FindParameters& setKeywords(const std::vector& _keywords) { keywords = _keywords; return *this; } - FindParameters& setSortMethod(ReleaseSortMethod _sortMethod) {sortMethod = _sortMethod; return *this; } - FindParameters& setRange(Range _range) {range = _range; return *this; } - FindParameters& setWrittenAfter(const Wt::WDateTime& _after) {writtenAfter = _after; return *this; } - FindParameters& setDateRange(const std::optional& _dateRange) {dateRange = _dateRange; return *this; } - FindParameters& setStarringUser(UserId _user, Scrobbler _scrobbler) { starringUser = _user; scrobbler = _scrobbler; return *this; } - FindParameters& setArtist(ArtistId _artist, EnumSet _trackArtistLinkTypes = {}, EnumSet _excludedTrackArtistLinkTypes = {}) - { - artist = _artist; - trackArtistLinkTypes = _trackArtistLinkTypes; - excludedTrackArtistLinkTypes = _excludedTrackArtistLinkTypes; - return *this; - } - }; + FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } + FindParameters& setKeywords(const std::vector& _keywords) { keywords = _keywords; return *this; } + FindParameters& setSortMethod(ReleaseSortMethod _sortMethod) { sortMethod = _sortMethod; return *this; } + FindParameters& setRange(Range _range) { range = _range; return *this; } + FindParameters& setWrittenAfter(const Wt::WDateTime& _after) { writtenAfter = _after; return *this; } + FindParameters& setDateRange(const std::optional& _dateRange) { dateRange = _dateRange; return *this; } + FindParameters& setStarringUser(UserId _user, Scrobbler _scrobbler) { starringUser = _user; scrobbler = _scrobbler; return *this; } + FindParameters& setArtist(ArtistId _artist, EnumSet _trackArtistLinkTypes = {}, EnumSet _excludedTrackArtistLinkTypes = {}) + { + artist = _artist; + trackArtistLinkTypes = _trackArtistLinkTypes; + excludedTrackArtistLinkTypes = _excludedTrackArtistLinkTypes; + return *this; + } + }; - Release() = default; + Release() = default; - // Accessors - static std::size_t getCount(Session& session); - static bool exists(Session& session, ReleaseId id); - static pointer find(Session& session, const UUID& MBID); - static std::vector find(Session& session, const std::string& name); - static pointer find(Session& session, ReleaseId id); - static RangeResults find(Session& session, const FindParameters& parameters); - static RangeResults findOrphans(Session& session, Range range); // no track related - static RangeResults findOrderedByArtist(Session& session, Range range); + // Accessors + static std::size_t getCount(Session& session); + static bool exists(Session& session, ReleaseId id); + static pointer find(Session& session, const UUID& MBID); + static std::vector find(Session& session, const std::string& name); + static pointer find(Session& session, ReleaseId id); + static RangeResults find(Session& session, const FindParameters& parameters); + static RangeResults findOrphans(Session& session, Range range); // no track related + static RangeResults findOrderedByArtist(Session& session, Range range); - std::size_t getTracksCount() const; + // Get the cluster of the tracks that belong to this release + // Each clusters are grouped by cluster type, sorted by the number of occurence (max to min) + // size is the max number of cluster per cluster type + std::vector>> getClusterGroups(const std::vector>& clusterTypes, std::size_t size) const; - // Get the cluster of the tracks that belong to this release - // Each clusters are grouped by cluster type, sorted by the number of occurence (max to min) - // size is the max number of cluster per cluster type - std::vector>> getClusterGroups(const std::vector>& clusterTypes, std::size_t size) const; + // Utility functions (if all tracks have the same values, which is legit to not be the case) + Wt::WDate getReleaseDate() const; + Wt::WDate getOriginalReleaseDate() const; + std::optional getCopyright() const; + std::optional getCopyrightURL() const; - // Utility functions (if all tracks have the same values, which is legit to not be the case) - std::optional getReleaseYear(bool originalDate = false) const; - std::optional getCopyright() const; - std::optional getCopyrightURL() const; + // Accessors + const std::string& getName() const { return _name; } + std::optional getMBID() const { return UUID::fromString(_MBID); } + std::optional getTotalDisc() const { return _totalDisc; } + std::size_t getDiscCount() const; // may not be total disc (if incomplete for example) + std::vector getDiscs() const; + std::chrono::milliseconds getDuration() const; + Wt::WDateTime getLastWritten() const; + std::optional getPrimaryType() const { return _primaryType; } + EnumSet getSecondaryTypes() const { return _secondaryTypes; } + std::size_t getTracksCount() const; - // Accessors - const std::string& getName() const { return _name; } - std::optional getMBID() const { return UUID::fromString(_MBID); } - std::optional getTotalDisc() const { return _totalDisc; } - std::size_t getDiscCount() const; // may not be total disc (if incomplete for example) - std::chrono::milliseconds getDuration() const; - Wt::WDateTime getLastWritten() const; - std::optional getPrimaryType() const { return _primaryType; } - EnumSet getSecondaryTypes() const { return _secondaryTypes; } + // Setters + void setName(std::string_view name) { _name = name; } + void setMBID(const std::optional& mbid) { _MBID = mbid ? mbid->getAsString() : ""; } + void setTotalDisc(std::optional totalDisc) { _totalDisc = totalDisc; } + void setPrimaryType(std::optional type) { _primaryType = type; } + void setSecondaryTypes(EnumSet types) { _secondaryTypes = types; } - // Setters - void setName(std::string_view name) { _name = name; } - void setMBID(const std::optional& mbid) { _MBID = mbid ? mbid->getAsString() : ""; } - void setTotalDisc(std::optional totalDisc) { _totalDisc = totalDisc; } - void setPrimaryType(std::optional type) { _primaryType = type; } - void setSecondaryTypes(EnumSet types) { _secondaryTypes = types; } - - // Get the artists of this release - std::vector> getArtists(TrackArtistLinkType type = TrackArtistLinkType::Artist) const; - std::vector> getReleaseArtists() const { return getArtists(TrackArtistLinkType::ReleaseArtist); } - bool hasVariousArtists() const; - std::vector getSimilarReleases(std::optional offset = {}, std::optional count = {}) const; + // Get the artists of this release + std::vector> getArtists(TrackArtistLinkType type = TrackArtistLinkType::Artist) const; + std::vector> getReleaseArtists() const { return getArtists(TrackArtistLinkType::ReleaseArtist); } + bool hasVariousArtists() const; + std::vector getSimilarReleases(std::optional offset = {}, std::optional count = {}) const; - template - void persist(Action& a) - { - Wt::Dbo::field(a, _name, "name"); - Wt::Dbo::field(a, _MBID, "mbid"); - Wt::Dbo::field(a, _totalDisc, "total_disc"); - Wt::Dbo::field(a, _primaryType, "primary_type"); - Wt::Dbo::field(a, _secondaryTypes, "secondary_types"); + template + void persist(Action& a) + { + Wt::Dbo::field(a, _name, "name"); + Wt::Dbo::field(a, _MBID, "mbid"); + Wt::Dbo::field(a, _totalDisc, "total_disc"); + Wt::Dbo::field(a, _primaryType, "primary_type"); + Wt::Dbo::field(a, _secondaryTypes, "secondary_types"); - Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToOne, "release"); - } + Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToOne, "release"); + } - private: - friend class Session; - Release(const std::string& name, const std::optional& MBID = {}); - static pointer create(Session& session, const std::string& name, const std::optional& MBID = {}); + private: + friend class Session; + Release(const std::string& name, const std::optional& MBID = {}); + static pointer create(Session& session, const std::string& name, const std::optional& MBID = {}); - static constexpr std::size_t _maxNameLength {128}; + Wt::WDate getReleaseDate(bool original) const; - std::string _name; - std::string _MBID; - std::optional _totalDisc {}; - std::optional _primaryType; - EnumSet _secondaryTypes; + static constexpr std::size_t _maxNameLength{ 128 }; - Wt::Dbo::collection> _tracks; // Tracks in the release -}; + std::string _name; + std::string _MBID; + std::optional _totalDisc{}; + std::optional _primaryType; + EnumSet _secondaryTypes; + + Wt::Dbo::collection> _tracks; // Tracks in the release + }; } // namespace Database diff --git a/src/libs/services/database/include/services/database/TrackArtistLink.hpp b/src/libs/services/database/include/services/database/TrackArtistLink.hpp index c342d5b9..a1dec682 100644 --- a/src/libs/services/database/include/services/database/TrackArtistLink.hpp +++ b/src/libs/services/database/include/services/database/TrackArtistLink.hpp @@ -24,6 +24,7 @@ #include +#include "services/database/ArtistId.hpp" #include "services/database/IdType.hpp" #include "services/database/Object.hpp" #include "services/database/ReleaseId.hpp" @@ -35,55 +36,58 @@ LMS_DECLARE_IDTYPE(TrackArtistLinkId) namespace Database { - class Artist; - class Session; - class Track; + class Artist; + class Session; + class Track; - class TrackArtistLink final : public Object - { - public: - struct FindParameters - { - Range range; - std::optional linkType; // if set, only artists that have produced at least one track with this link type - TrackId track; // artists involved in this track - ReleaseId release; // artists involved in this release + class TrackArtistLink final : public Object + { + public: + struct FindParameters + { + Range range; + std::optional linkType; // if set, only artists that have produced at least one track with this link type + ArtistId artist; // if set, links involved with this artist + ReleaseId release; // if set, artists involved in this release + TrackId track; // if set, artists involved in this track - FindParameters& setRange(Range _range) {range = _range; return *this; } - FindParameters& setLinkType(std::optional _linkType) { linkType = _linkType; return *this; } - FindParameters& setTrack(TrackId _track) { track = _track; return *this; } - FindParameters& setRelease(ReleaseId _release) { release = _release; return *this; } - }; + FindParameters& setRange(Range _range) { range = _range; return *this; } + FindParameters& setLinkType(std::optional _linkType) { linkType = _linkType; return *this; } + FindParameters& setArtist(ArtistId _artist) { artist = _artist; return *this; } + FindParameters& setRelease(ReleaseId _release) { release = _release; return *this; } + FindParameters& setTrack(TrackId _track) { track = _track; return *this; } + }; - TrackArtistLink() = default; - TrackArtistLink(ObjectPtr track, ObjectPtr artist, TrackArtistLinkType type, std::string_view subType); + TrackArtistLink() = default; + TrackArtistLink(ObjectPtr track, ObjectPtr artist, TrackArtistLinkType type, std::string_view subType); - static RangeResults find(Session& session, const FindParameters& parameters); - static pointer find(Session& session, TrackArtistLinkId linkId); - static pointer create(Session& session, ObjectPtr track, ObjectPtr artist, TrackArtistLinkType type, std::string_view subType = {}); - static EnumSet findUsedTypes(Session& session); + static RangeResults find(Session& session, const FindParameters& parameters); + static pointer find(Session& session, TrackArtistLinkId linkId); + static pointer create(Session& session, ObjectPtr track, ObjectPtr artist, TrackArtistLinkType type, std::string_view subType = {}); + static EnumSet findUsedTypes(Session& session); + static EnumSet findUsedTypes(Session& session, ArtistId _artist); - ObjectPtr getTrack() const { return _track; } - ObjectPtr getArtist() const { return _artist; } - TrackArtistLinkType getType() const { return _type; } - std::string_view getSubType() const { return _subType; } + ObjectPtr getTrack() const { return _track; } + ObjectPtr getArtist() const { return _artist; } + TrackArtistLinkType getType() const { return _type; } + std::string_view getSubType() const { return _subType; } - template - void persist(Action& a) - { - Wt::Dbo::field(a, _type, "type"); - Wt::Dbo::field(a, _subType, "subtype"); + template + void persist(Action& a) + { + Wt::Dbo::field(a, _type, "type"); + Wt::Dbo::field(a, _subType, "subtype"); - Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade); - Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade); - } + Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade); + } - private: - TrackArtistLinkType _type; - std::string _subType; + private: + TrackArtistLinkType _type; + std::string _subType; - Wt::Dbo::ptr _track; - Wt::Dbo::ptr _artist; - }; + Wt::Dbo::ptr _track; + Wt::Dbo::ptr _artist; + }; } diff --git a/src/libs/services/database/include/services/database/Types.hpp b/src/libs/services/database/include/services/database/Types.hpp index 0e50cb39..3266c582 100644 --- a/src/libs/services/database/include/services/database/Types.hpp +++ b/src/libs/services/database/include/services/database/Types.hpp @@ -83,6 +83,12 @@ namespace Database static DateRange fromYearRange(int from, int to); }; + struct DiscInfo + { + std::size_t position; + std::string name; + }; + enum class ArtistSortMethod { None, diff --git a/src/libs/services/database/test/Artist.cpp b/src/libs/services/database/test/Artist.cpp index 6b7868b0..fe849cf9 100644 --- a/src/libs/services/database/test/Artist.cpp +++ b/src/libs/services/database/test/Artist.cpp @@ -179,6 +179,15 @@ TEST_F(DatabaseFixture, Artist_singleTracktMultiRoles) tracks = Track::find(session, Track::FindParameters {}.setArtist(artist.getId(), {TrackArtistLinkType::Composer})); EXPECT_EQ(tracks.results.size(), 0); } + + { + auto transaction {session.createSharedTransaction()}; + EnumSet types{ TrackArtistLink::findUsedTypes(session, artist.getId()) }; + EXPECT_TRUE(types.contains(TrackArtistLinkType::ReleaseArtist)); + EXPECT_TRUE(types.contains(TrackArtistLinkType::Artist)); + EXPECT_TRUE(types.contains(TrackArtistLinkType::Writer)); + EXPECT_FALSE(types.contains(TrackArtistLinkType::Composer)); + } } TEST_F(DatabaseFixture, Artist_singleTrackMultiArtists) diff --git a/src/libs/services/database/test/Cluster.cpp b/src/libs/services/database/test/Cluster.cpp index f3d91845..d9caa140 100644 --- a/src/libs/services/database/test/Cluster.cpp +++ b/src/libs/services/database/test/Cluster.cpp @@ -26,1070 +26,1085 @@ using namespace Database; TEST_F(DatabaseFixture, Cluster) { - { - auto transaction {session.createUniqueTransaction()}; - EXPECT_EQ(Cluster::getCount(session), 0); - EXPECT_EQ(ClusterType::getCount(session), 0); - } + { + auto transaction{ session.createUniqueTransaction() }; + EXPECT_EQ(Cluster::getCount(session), 0); + EXPECT_EQ(ClusterType::getCount(session), 0); + } - ScopedClusterType clusterType {session, "MyType"}; + ScopedClusterType clusterType{ session, "MyType" }; - { - auto transaction {session.createUniqueTransaction()}; - EXPECT_EQ(ClusterType::getCount(session), 1); - } + { + auto transaction{ session.createUniqueTransaction() }; + EXPECT_EQ(ClusterType::getCount(session), 1); + } - { - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + { + ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyCluster" }; - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - EXPECT_EQ(Cluster::getCount(session), 1); - EXPECT_EQ(cluster->getType()->getId(), clusterType.getId()); + EXPECT_EQ(Cluster::getCount(session), 1); + EXPECT_EQ(cluster->getType()->getId(), clusterType.getId()); - auto clusters {Cluster::find(session, Range {})}; - ASSERT_EQ(clusters.results.size(), 1); - EXPECT_EQ(clusters.results.front(), cluster.getId()); + auto clusters{ Cluster::find(session, Cluster::FindParameters {}) }; + ASSERT_EQ(clusters.results.size(), 1); + EXPECT_EQ(clusters.results.front(), cluster.getId()); - clusters = Cluster::findOrphans(session, Range {}); - ASSERT_EQ(clusters.results.size(), 1); - EXPECT_EQ(clusters.results.front(), cluster.getId()); + clusters = Cluster::findOrphans(session, Range{}); + ASSERT_EQ(clusters.results.size(), 1); + EXPECT_EQ(clusters.results.front(), cluster.getId()); - auto clusterTypes {ClusterType::find(session, Range {})}; - ASSERT_EQ(clusterTypes.results.size(), 1); - EXPECT_EQ(clusterTypes.results.front(), clusterType.getId()); + auto clusterTypes{ ClusterType::find(session, Range {}) }; + ASSERT_EQ(clusterTypes.results.size(), 1); + EXPECT_EQ(clusterTypes.results.front(), clusterType.getId()); - clusterTypes = ClusterType::findUsed(session, Range {}); - ASSERT_EQ(clusterTypes.results.size(), 1); - EXPECT_EQ(clusterTypes.results.front(), clusterType.getId()); + clusterTypes = ClusterType::findUsed(session, Range{}); + ASSERT_EQ(clusterTypes.results.size(), 1); + EXPECT_EQ(clusterTypes.results.front(), clusterType.getId()); - clusterTypes = ClusterType::findOrphans(session, Range {}); - EXPECT_TRUE(clusterTypes.results.empty()); - } - } + clusterTypes = ClusterType::findOrphans(session, Range{}); + EXPECT_TRUE(clusterTypes.results.empty()); + } + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - auto clusterTypes {ClusterType::findOrphans(session, Range {})}; - ASSERT_EQ(clusterTypes.results.size(), 1); - EXPECT_EQ(clusterTypes.results.front(), clusterType.getId()); + auto clusterTypes{ ClusterType::findOrphans(session, Range {}) }; + ASSERT_EQ(clusterTypes.results.size(), 1); + EXPECT_EQ(clusterTypes.results.front(), clusterType.getId()); - ASSERT_TRUE(ClusterType::findUsed(session, Range {}).results.empty()); - } + ASSERT_TRUE(ClusterType::findUsed(session, Range{}).results.empty()); + } } TEST_F(DatabaseFixture, Cluster_singleTrack) { - ScopedTrack track {session, "MyTrack"}; - ScopedClusterType clusterType {session, "MyClusterType"}; + ScopedTrack track{ session, "MyTrack" }; + ScopedClusterType clusterType{ session, "MyClusterType" }; - { - auto transaction {session.createSharedTransaction()}; - EXPECT_TRUE(Cluster::findOrphans(session, Range {}).results.empty()); - auto clusterTypes {ClusterType::findOrphans(session, Range {})}; - ASSERT_EQ(clusterTypes.results.size(), 1); - EXPECT_EQ(clusterTypes.results.front(), clusterType.getId()); - } + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_TRUE(Cluster::findOrphans(session, Range{}).results.empty()); + auto clusterTypes{ ClusterType::findOrphans(session, Range {}) }; + ASSERT_EQ(clusterTypes.results.size(), 1); + EXPECT_EQ(clusterTypes.results.front(), clusterType.getId()); + } - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; - { - auto transaction {session.createSharedTransaction()}; - auto clusters {Cluster::findOrphans(session, Range {})}; - EXPECT_EQ(clusters.results.size(), 2); - EXPECT_TRUE(track->getClusters().empty()); - EXPECT_TRUE(track->getClusterIds().empty()); - } + { + auto transaction{ session.createSharedTransaction() }; + auto clusters{ Cluster::findOrphans(session, Range {}) }; + EXPECT_EQ(clusters.results.size(), 2); + EXPECT_TRUE(track->getClusters().empty()); + EXPECT_TRUE(track->getClusterIds().empty()); + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - cluster1.get().modify()->addTrack(track.get()); - } + cluster1.get().modify()->addTrack(track.get()); + } - { - auto transaction {session.createSharedTransaction()}; - auto clusters {Cluster::findOrphans(session, Range {})}; - ASSERT_EQ(clusters.results.size(), 1); - EXPECT_EQ(clusters.results.front(), cluster2.getId()); + { + auto transaction{ session.createSharedTransaction() }; + auto clusters{ Cluster::find(session, Cluster::FindParameters {}.setTrack(track.getId())) }; + ASSERT_EQ(clusters.results.size(), 1); + EXPECT_EQ(clusters.results.front(), cluster1.getId()); + } - EXPECT_TRUE(ClusterType::findOrphans(session, Range {}).results.empty()); - } + { + auto transaction{ session.createSharedTransaction() }; + auto clusters{ Cluster::findOrphans(session, Range {}) }; + ASSERT_EQ(clusters.results.size(), 1); + EXPECT_EQ(clusters.results.front(), cluster2.getId()); - { - auto transaction {session.createSharedTransaction()}; + EXPECT_TRUE(ClusterType::findOrphans(session, Range{}).results.empty()); + } - auto tracks {Track::find(session, Track::FindParameters {}.setClusters({cluster1.getId()}))}; - ASSERT_EQ(tracks.results.size(), 1); - EXPECT_EQ(tracks.results.front(), track.getId()); + { + auto transaction{ session.createSharedTransaction() }; - tracks = Track::find(session, Track::FindParameters {}.setClusters({cluster2.getId()})); - EXPECT_TRUE(tracks.results.empty()); - } + auto tracks{ Track::find(session, Track::FindParameters {}.setClusters({cluster1.getId()})) }; + ASSERT_EQ(tracks.results.size(), 1); + EXPECT_EQ(tracks.results.front(), track.getId()); - { - auto transaction {session.createSharedTransaction()}; + tracks = Track::find(session, Track::FindParameters{}.setClusters({ cluster2.getId() })); + EXPECT_TRUE(tracks.results.empty()); + } - auto clusters {track->getClusters()}; - ASSERT_EQ(clusters.size(), 1); - EXPECT_EQ(clusters.front()->getId(), cluster1.getId()); + { + auto transaction{ session.createSharedTransaction() }; - auto clusterIds {track->getClusterIds()}; - ASSERT_EQ(clusterIds.size(), 1); - EXPECT_EQ(clusterIds.front(), cluster1.getId()); - } + auto clusters{ track->getClusters() }; + ASSERT_EQ(clusters.size(), 1); + EXPECT_EQ(clusters.front()->getId(), cluster1.getId()); + + auto clusterIds{ track->getClusterIds() }; + ASSERT_EQ(clusterIds.size(), 1); + EXPECT_EQ(clusterIds.front(), cluster1.getId()); + } } TEST_F(DatabaseFixture, Cluster_multiTracks) { - std::list tracks; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + std::list tracks; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyCluster" }; - for (std::size_t i {}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + for (std::size_t i{}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - { - auto transaction {session.createUniqueTransaction()}; - cluster.get().modify()->addTrack(tracks.back().get()); - } - } + { + auto transaction{ session.createUniqueTransaction() }; + cluster.get().modify()->addTrack(tracks.back().get()); + } + } - { - auto transaction {session.createSharedTransaction()}; - EXPECT_TRUE(Cluster::findOrphans(session, Range {}).results.empty()); + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_TRUE(Cluster::findOrphans(session, Range{}).results.empty()); - EXPECT_EQ(cluster->getTracksCount(), tracks.size()); + EXPECT_EQ(cluster->getTracksCount(), tracks.size()); - for (TrackId trackId : cluster->getTracks(Range {}).results) - { - auto it {std::find_if(std::cbegin(tracks), std::cend(tracks), [&](const ScopedTrack& track) { return trackId == track.getId(); })}; - EXPECT_TRUE(it != std::cend(tracks)); - } - } + for (TrackId trackId : cluster->getTracks(Range{}).results) + { + auto it{ std::find_if(std::cbegin(tracks), std::cend(tracks), [&](const ScopedTrack& track) { return trackId == track.getId(); }) }; + EXPECT_TRUE(it != std::cend(tracks)); + } + } } TEST_F(DatabaseFixture, Cluster_multiTracksMultipleClustersTopRelease) { - ScopedClusterType clusterType {session, "ClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "Cluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "Cluster2"}; - ScopedCluster cluster3 {session, clusterType.lockAndGet(), "Cluster3"}; - ScopedTrack trackA {session, "TrackA"}; - ScopedTrack trackB {session, "TrackB"}; - ScopedTrack trackC {session, "TrackC"}; - ScopedRelease releaseA {session, "ReleaseA"}; - ScopedRelease releaseB {session, "ReleaseB"}; - ScopedRelease releaseC {session, "ReleaseC"}; + ScopedClusterType clusterType{ session, "ClusterType" }; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "Cluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "Cluster2" }; + ScopedCluster cluster3{ session, clusterType.lockAndGet(), "Cluster3" }; + ScopedTrack trackA{ session, "TrackA" }; + ScopedTrack trackB{ session, "TrackB" }; + ScopedTrack trackC{ session, "TrackC" }; + ScopedRelease releaseA{ session, "ReleaseA" }; + ScopedRelease releaseB{ session, "ReleaseB" }; + ScopedRelease releaseC{ session, "ReleaseC" }; - ScopedUser user {session, "MyUser"}; - ScopedTrackList trackList {session, "TrackList", TrackListType::Playlist, false, user.lockAndGet()}; + ScopedUser user{ session, "MyUser" }; + ScopedTrackList trackList{ session, "TrackList", TrackListType::Playlist, false, user.lockAndGet() }; - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - EXPECT_EQ(trackList->getDuration(), std::chrono::seconds {0}); - } + EXPECT_EQ(trackList->getDuration(), std::chrono::seconds{ 0 }); + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - cluster1.get().modify()->addTrack(trackA.get()); - cluster2.get().modify()->addTrack(trackB.get()); - cluster2.get().modify()->addTrack(trackC.get()); - cluster3.get().modify()->addTrack(trackC.get()); + cluster1.get().modify()->addTrack(trackA.get()); + cluster2.get().modify()->addTrack(trackB.get()); + cluster2.get().modify()->addTrack(trackC.get()); + cluster3.get().modify()->addTrack(trackC.get()); - trackA.get().modify()->setRelease(releaseA.get()); - trackB.get().modify()->setRelease(releaseB.get()); - trackC.get().modify()->setRelease(releaseC.get()); - } + trackA.get().modify()->setRelease(releaseA.get()); + trackB.get().modify()->setRelease(releaseB.get()); + trackC.get().modify()->setRelease(releaseC.get()); + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - session.create(trackA.get(), trackList.get()); - session.create(trackB.get(), trackList.get()); - session.create(trackB.get(), trackList.get()); - } + session.create(trackA.get(), trackList.get()); + session.create(trackB.get(), trackList.get()); + session.create(trackB.get(), trackList.get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool hasMore; - const auto releases{trackList->getTopReleases({}, std::nullopt, hasMore)}; - ASSERT_EQ(releases.size(), 2); - EXPECT_EQ(releases[0]->getId(), releaseB.getId()); - EXPECT_EQ(releases[1]->getId(), releaseA.getId()); - } + bool hasMore; + const auto releases{ trackList->getTopReleases({}, std::nullopt, hasMore) }; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0]->getId(), releaseB.getId()); + EXPECT_EQ(releases[1]->getId(), releaseA.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool hasMore; - auto releases{trackList->getTopReleases({cluster1.getId()}, std::nullopt, hasMore)}; - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases[0]->getId(), releaseA.getId()); + bool hasMore; + auto releases{ trackList->getTopReleases({cluster1.getId()}, std::nullopt, hasMore) }; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases[0]->getId(), releaseA.getId()); - releases = trackList->getTopReleases({cluster2.getId()}, std::nullopt, hasMore); - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases[0]->getId(), releaseB.getId()); + releases = trackList->getTopReleases({ cluster2.getId() }, std::nullopt, hasMore); + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases[0]->getId(), releaseB.getId()); - releases = trackList->getTopReleases({cluster2.getId(), cluster1.getId()}, std::nullopt, hasMore); - EXPECT_TRUE(releases.empty()); + releases = trackList->getTopReleases({ cluster2.getId(), cluster1.getId() }, std::nullopt, hasMore); + EXPECT_TRUE(releases.empty()); - releases = trackList->getTopReleases({cluster2.getId(), cluster3.getId()}, std::nullopt, hasMore); - EXPECT_TRUE(releases.empty()); - } + releases = trackList->getTopReleases({ cluster2.getId(), cluster3.getId() }, std::nullopt, hasMore); + EXPECT_TRUE(releases.empty()); + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - session.create(trackC.get(), trackList.get()); - session.create(trackC.get(), trackList.get()); - session.create(trackC.get(), trackList.get()); - } + session.create(trackC.get(), trackList.get()); + session.create(trackC.get(), trackList.get()); + session.create(trackC.get(), trackList.get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool hasMore; - auto releases {trackList->getTopReleases({cluster2.getId(), cluster3.getId()}, std::nullopt, hasMore)}; - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases[0]->getId(), releaseC.getId()); + bool hasMore; + auto releases{ trackList->getTopReleases({cluster2.getId(), cluster3.getId()}, std::nullopt, hasMore) }; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases[0]->getId(), releaseC.getId()); - releases = trackList->getTopReleases({cluster2.getId()}, std::nullopt, hasMore); - ASSERT_EQ(releases.size(), 2); - EXPECT_EQ(releases[0]->getId(), releaseC.getId()); - EXPECT_EQ(releases[1]->getId(), releaseB.getId()); - } + releases = trackList->getTopReleases({ cluster2.getId() }, std::nullopt, hasMore); + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0]->getId(), releaseC.getId()); + EXPECT_EQ(releases[1]->getId(), releaseB.getId()); + } } TEST_F(DatabaseFixture, Cluster_singleTrackSingleReleaseSingleCluster) { - ScopedTrack track {session, "MyTrackFile"}; - ScopedRelease release {session, "MyRelease"}; + ScopedTrack track{ session, "MyTrackFile" }; + ScopedRelease release{ session, "MyRelease" }; - { - auto transaction {session.createSharedTransaction()}; - EXPECT_TRUE(Cluster::findOrphans(session, Range {}).results.empty()); - } + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_TRUE(Cluster::findOrphans(session, Range{}).results.empty()); + } - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; - ScopedCluster unusedCluster {session, clusterType.lockAndGet(), "MyClusterUnused"}; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyCluster" }; + ScopedCluster unusedCluster{ session, clusterType.lockAndGet(), "MyClusterUnused" }; - { - auto transaction {session.createSharedTransaction()}; - ASSERT_EQ(Cluster::findOrphans(session, Range {}).results.size(), 2); - EXPECT_TRUE(Release::find(session, Release::FindParameters {}.setClusters({unusedCluster.getId()})).results.empty()); - EXPECT_EQ(Release::find(session, Release::FindParameters {}).results.size(), 1); - } + { + auto transaction{ session.createSharedTransaction() }; + ASSERT_EQ(Cluster::findOrphans(session, Range{}).results.size(), 2); + EXPECT_TRUE(Release::find(session, Release::FindParameters{}.setClusters({ unusedCluster.getId() })).results.empty()); + EXPECT_EQ(Release::find(session, Release::FindParameters{}).results.size(), 1); + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - track.get().modify()->setRelease(release.get()); - cluster.get().modify()->addTrack(track.get()); - } + track.get().modify()->setRelease(release.get()); + cluster.get().modify()->addTrack(track.get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - { - auto clusters {Cluster::findOrphans(session, Range {})}; - ASSERT_EQ(clusters.results.size(), 1); - EXPECT_EQ(clusters.results.front(), unusedCluster.getId()); - } - } + { + auto clusters{ Cluster::findOrphans(session, Range {}) }; + ASSERT_EQ(clusters.results.size(), 1); + EXPECT_EQ(clusters.results.front(), unusedCluster.getId()); + } + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - auto releases {Release::find(session, Release::FindParameters {}.setClusters({cluster.getId()}))}; - ASSERT_EQ(releases.results.size(), 1); - EXPECT_EQ(releases.results.front(), release.getId()); - } + auto clusters{ Cluster::find(session, Cluster::FindParameters{}.setRelease(release.getId())) }; + ASSERT_EQ(clusters.results.size(), 1); + EXPECT_EQ(clusters.results.front(), cluster.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - auto releases {Release::find(session, Release::FindParameters {}.setClusters({unusedCluster.getId()}))}; - EXPECT_EQ(releases.results.size(), 0); - } + auto releases{ Release::find(session, Release::FindParameters {}.setClusters({cluster.getId()})) }; + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - EXPECT_EQ(cluster->getReleasesCount(), 1); - EXPECT_EQ(cluster->getTracksCount(), 1); - EXPECT_EQ(unusedCluster->getReleasesCount(), 0); - EXPECT_EQ(unusedCluster->getTracksCount(), 0); - } + auto releases{ Release::find(session, Release::FindParameters {}.setClusters({unusedCluster.getId()})) }; + EXPECT_EQ(releases.results.size(), 0); + } + + { + auto transaction{ session.createSharedTransaction() }; + + EXPECT_EQ(cluster->getReleasesCount(), 1); + EXPECT_EQ(cluster->getTracksCount(), 1); + EXPECT_EQ(unusedCluster->getReleasesCount(), 0); + EXPECT_EQ(unusedCluster->getTracksCount(), 0); + } } TEST_F(DatabaseFixture, SingleTrackSingleArtistMultiClusters) { - ScopedTrack track {session, "MyTrackFile"}; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "Cluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "Cluster2"}; - ScopedCluster cluster3 {session, clusterType.lockAndGet(), "Cluster3"}; - { - auto transaction {session.createUniqueTransaction()}; + ScopedTrack track{ session, "MyTrackFile" }; + ScopedArtist artist{ session, "MyArtist" }; + ScopedClusterType clusterType{ session, "MyType" }; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "Cluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "Cluster2" }; + ScopedCluster cluster3{ session, clusterType.lockAndGet(), "Cluster3" }; + { + auto transaction{ session.createUniqueTransaction() }; - auto trackArtistLink {TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist)}; - cluster1.get().modify()->addTrack(track.get()); - } + auto trackArtistLink{ TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist) }; + cluster1.get().modify()->addTrack(track.get()); + } - { - auto transaction {session.createSharedTransaction()}; - EXPECT_TRUE(ClusterType::findOrphans(session, Range {}).results.empty()); - EXPECT_EQ(Cluster::findOrphans(session, Range {}).results.size(), 2); - EXPECT_TRUE(Release::findOrphans(session, Range {}).results.empty()); - EXPECT_TRUE(Artist::findAllOrphans(session, Range {}).results.empty()); - } + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_TRUE(ClusterType::findOrphans(session, Range{}).results.empty()); + EXPECT_EQ(Cluster::findOrphans(session, Range{}).results.size(), 2); + EXPECT_TRUE(Release::findOrphans(session, Range{}).results.empty()); + EXPECT_TRUE(Artist::findAllOrphans(session, Range{}).results.empty()); + } - { - auto transaction {session.createSharedTransaction()}; - EXPECT_EQ(track->getClusters().size(), 1); - EXPECT_EQ(track->getClusterIds().size(), 1); - } + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_EQ(track->getClusters().size(), 1); + EXPECT_EQ(track->getClusterIds().size(), 1); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - auto artists {Artist::find(session, Artist::FindParameters {}.setClusters({cluster1.getId()}))}; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist.getId()); + auto artists{ Artist::find(session, Artist::FindParameters {}.setClusters({cluster1.getId()})) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); - EXPECT_TRUE(Artist::find(session, Artist::FindParameters {}.setClusters({cluster2.getId()})).results.empty()); - EXPECT_TRUE(Artist::find(session, Artist::FindParameters {}.setClusters({cluster3.getId()})).results.empty()); + EXPECT_TRUE(Artist::find(session, Artist::FindParameters{}.setClusters({ cluster2.getId() })).results.empty()); + EXPECT_TRUE(Artist::find(session, Artist::FindParameters{}.setClusters({ cluster3.getId() })).results.empty()); - cluster2.get().modify()->addTrack(track.get()); - } + cluster2.get().modify()->addTrack(track.get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - auto artists {Artist::find(session, Artist::FindParameters {}.setClusters({cluster1.getId()}))}; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist.getId()); + auto artists{ Artist::find(session, Artist::FindParameters {}.setClusters({cluster1.getId()})) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); - artists = Artist::find(session, Artist::FindParameters {}.setClusters({cluster2.getId()})); - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist.getId()); + artists = Artist::find(session, Artist::FindParameters{}.setClusters({ cluster2.getId() })); + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); - artists = Artist::find(session, Artist::FindParameters {}.setClusters({cluster1.getId()})); - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist.getId()); + artists = Artist::find(session, Artist::FindParameters{}.setClusters({ cluster1.getId() })); + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); - EXPECT_TRUE(Artist::find(session, Artist::FindParameters {}.setClusters({cluster3.getId()})).results.empty()); - } + EXPECT_TRUE(Artist::find(session, Artist::FindParameters{}.setClusters({ cluster3.getId() })).results.empty()); + } } TEST_F(DatabaseFixture, SingleTrackSingleArtistMultiRolesMultiClusters) { - ScopedTrack track {session, "MyTrackFile"}; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + ScopedTrack track{ session, "MyTrackFile" }; + ScopedArtist artist{ session, "MyArtist" }; + ScopedClusterType clusterType{ session, "MyType" }; + ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyCluster" }; - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::ReleaseArtist); - cluster.get().modify()->addTrack(track.get()); - } + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::ReleaseArtist); + cluster.get().modify()->addTrack(track.get()); + } - { - auto transaction {session.createSharedTransaction()}; - EXPECT_TRUE(Cluster::findOrphans(session, Range {}).results.empty()); - EXPECT_TRUE(Release::findOrphans(session, Range {}).results.empty()); - EXPECT_TRUE(Artist::findAllOrphans(session, Range {}).results.empty()); - } + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_TRUE(Cluster::findOrphans(session, Range{}).results.empty()); + EXPECT_TRUE(Release::findOrphans(session, Range{}).results.empty()); + EXPECT_TRUE(Artist::findAllOrphans(session, Range{}).results.empty()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - auto artists {Artist::find(session, Artist::FindParameters {}.setClusters({cluster.getId()}))}; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist.getId()); - } + auto artists{ Artist::find(session, Artist::FindParameters {}.setClusters({cluster.getId()})) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } } TEST_F(DatabaseFixture, MultiTracksSingleArtistMultiClusters) { - constexpr std::size_t nbTracks {10}; - constexpr std::size_t nbClusters {5}; + constexpr std::size_t nbTracks{ 10 }; + constexpr std::size_t nbClusters{ 5 }; - std::list tracks; - std::list clusters; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyType"}; + std::list tracks; + std::list clusters; + ScopedArtist artist{ session, "MyArtist" }; + ScopedClusterType clusterType{ session, "MyType" }; - for (std::size_t i {}; i < nbClusters; ++i) - clusters.emplace_back(session, clusterType.lockAndGet(), "MyCluster" + std::to_string(i)); + for (std::size_t i{}; i < nbClusters; ++i) + clusters.emplace_back(session, clusterType.lockAndGet(), "MyCluster" + std::to_string(i)); - for (std::size_t i {}; i < nbTracks ; ++i) - { - tracks.emplace_back(session, "MyTrackFile" + std::to_string(i)); + for (std::size_t i{}; i < nbTracks; ++i) + { + tracks.emplace_back(session, "MyTrackFile" + std::to_string(i)); - auto transaction {session.createUniqueTransaction()}; - TrackArtistLink::create(session, tracks.back().get(), artist.get(), TrackArtistLinkType::Artist); + auto transaction{ session.createUniqueTransaction() }; + TrackArtistLink::create(session, tracks.back().get(), artist.get(), TrackArtistLinkType::Artist); - for (auto& cluster : clusters) - cluster.get().modify()->addTrack(tracks.back().get()); - } + for (auto& cluster : clusters) + cluster.get().modify()->addTrack(tracks.back().get()); + } - { - auto transaction {session.createSharedTransaction()}; - EXPECT_TRUE(Cluster::findOrphans(session, Range {}).results.empty()); - EXPECT_TRUE(Artist::findAllOrphans(session, Range {}).results.empty()); - } + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_TRUE(Cluster::findOrphans(session, Range{}).results.empty()); + EXPECT_TRUE(Artist::findAllOrphans(session, Range{}).results.empty()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - std::vector clusterIds; - std::transform(std::cbegin(clusters), std::cend(clusters), std::back_inserter(clusterIds), [](const ScopedCluster& cluster) { return cluster.getId(); }); + std::vector clusterIds; + std::transform(std::cbegin(clusters), std::cend(clusters), std::back_inserter(clusterIds), [](const ScopedCluster& cluster) { return cluster.getId(); }); - auto artists {Artist::find(session, Artist::FindParameters {}.setClusters(clusterIds))}; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist.getId()); - } + auto artists{ Artist::find(session, Artist::FindParameters {}.setClusters(clusterIds)) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } } TEST_F(DatabaseFixture, MultipleTracksSingleClusterSimilarity) { - std::list tracks; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyClusterType"}; + std::list tracks; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyClusterType" }; - for (std::size_t i {}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + for (std::size_t i{}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - { - auto transaction {session.createUniqueTransaction()}; - cluster.get().modify()->addTrack(tracks.back().get()); - } - } + { + auto transaction{ session.createUniqueTransaction() }; + cluster.get().modify()->addTrack(tracks.back().get()); + } + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - const auto similarTracks {Track::findSimilarTracks(session, {tracks.front().getId()}, Range {})}; - EXPECT_EQ(similarTracks.results.size(), tracks.size() - 1); - for (const TrackId similarTrackId : similarTracks.results) - { - EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); - } - } + const auto similarTracks{ Track::findSimilarTracks(session, {tracks.front().getId()}, Range {}) }; + EXPECT_EQ(similarTracks.results.size(), tracks.size() - 1); + for (const TrackId similarTrackId : similarTracks.results) + { + EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); + } + } } TEST_F(DatabaseFixture, MultipleTracksMultipleClustersSimilarity) { - std::list tracks; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + std::list tracks; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; - for (std::size_t i {}; i < 5; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + for (std::size_t i{}; i < 5; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - { - auto transaction {session.createUniqueTransaction()}; - cluster1.get().modify()->addTrack(tracks.back().get()); - } - } + { + auto transaction{ session.createUniqueTransaction() }; + cluster1.get().modify()->addTrack(tracks.back().get()); + } + } - for (std::size_t i {5}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + for (std::size_t i{ 5 }; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - { - auto transaction {session.createUniqueTransaction()}; - cluster1.get().modify()->addTrack(tracks.back().get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - } + { + auto transaction{ session.createUniqueTransaction() }; + cluster1.get().modify()->addTrack(tracks.back().get()); + cluster2.get().modify()->addTrack(tracks.back().get()); + } + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - { - auto similarTracks {Track::findSimilarTracks(session, {tracks.back().getId()}, Range {0, 4})}; - EXPECT_EQ(similarTracks.results.size(), 4); - for (const TrackId similarTrackId : similarTracks.results) - EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 5), std::next(std::cend(tracks), -1), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); - } + { + auto similarTracks{ Track::findSimilarTracks(session, {tracks.back().getId()}, Range {0, 4}) }; + EXPECT_EQ(similarTracks.results.size(), 4); + for (const TrackId similarTrackId : similarTracks.results) + EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 5), std::next(std::cend(tracks), -1), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); + } - { - auto similarTracks {Track::findSimilarTracks(session, {tracks.front().getId()}, Range {})}; - EXPECT_EQ(similarTracks.results.size(), tracks.size() - 1); - for (const TrackId similarTrackId : similarTracks.results) - EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); - } - } + { + auto similarTracks{ Track::findSimilarTracks(session, {tracks.front().getId()}, Range {}) }; + EXPECT_EQ(similarTracks.results.size(), tracks.size() - 1); + for (const TrackId similarTrackId : similarTracks.results) + EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); + } + } } TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtistSingleCluster) { - ScopedTrack track {session, "MyTrack"}; - ScopedRelease release {session, "MyRelease"}; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; + ScopedTrack track{ session, "MyTrack" }; + ScopedRelease release{ session, "MyRelease" }; + ScopedArtist artist{ session, "MyArtist" }; + ScopedClusterType clusterType{ session, "MyType" }; + ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyCluster" }; - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); - track.get().modify()->setRelease(release.get()); - cluster.get().modify()->addTrack(track.get()); - } + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + track.get().modify()->setRelease(release.get()); + cluster.get().modify()->addTrack(track.get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - EXPECT_TRUE(Cluster::findOrphans(session, Range {}).results.empty()); - EXPECT_TRUE(ClusterType::findOrphans(session, Range {}).results.empty()); - EXPECT_TRUE(Artist::findAllOrphans(session, Range {}).results.empty()); - EXPECT_TRUE(Release::findOrphans(session, Range {}).results.empty()); - } + EXPECT_TRUE(Cluster::findOrphans(session, Range{}).results.empty()); + EXPECT_TRUE(ClusterType::findOrphans(session, Range{}).results.empty()); + EXPECT_TRUE(Artist::findAllOrphans(session, Range{}).results.empty()); + EXPECT_TRUE(Release::findOrphans(session, Range{}).results.empty()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - auto artists {Artist::find(session, Artist::FindParameters {}.setClusters({cluster.getId()}))}; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist.getId()); + auto artists{ Artist::find(session, Artist::FindParameters {}.setClusters({cluster.getId()})) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); - auto releases {Release::find(session, Release::FindParameters {}.setArtist(artist.getId()))}; - ASSERT_EQ(releases.results.size(), 1); - EXPECT_EQ(releases.results.front(), release.getId()); + auto releases{ Release::find(session, Release::FindParameters {}.setArtist(artist.getId())) }; + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release.getId()); - releases = Release::find(session, Release::FindParameters {}.setArtist(artist.getId()).setClusters({cluster.getId()})); - ASSERT_EQ(releases.results.size(), 1); - EXPECT_EQ(releases.results.front(), release.getId()); - } + releases = Release::find(session, Release::FindParameters{}.setArtist(artist.getId()).setClusters({ cluster.getId() })); + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release.getId()); + } } TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtistMultiClusters) { - ScopedTrack track {session, "MyTrack"}; - ScopedRelease release {session, "MyRelease"}; - ScopedArtist artist {session, "MyArtist"}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + ScopedTrack track{ session, "MyTrack" }; + ScopedRelease release{ session, "MyRelease" }; + ScopedArtist artist{ session, "MyArtist" }; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - auto trackArtistLink {TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist)}; - track.get().modify()->setRelease(release.get()); - cluster1.get().modify()->addTrack(track.get()); - cluster2.get().modify()->addTrack(track.get()); - } + auto trackArtistLink{ TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist) }; + track.get().modify()->setRelease(release.get()); + cluster1.get().modify()->addTrack(track.get()); + cluster2.get().modify()->addTrack(track.get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - auto releases {Release::find(session, Release::FindParameters {}.setArtist(artist.getId()))}; - ASSERT_EQ(releases.results.size(), 1); - EXPECT_EQ(releases.results.front(), release.getId()); + auto releases{ Release::find(session, Release::FindParameters {}.setArtist(artist.getId())) }; + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release.getId()); - releases = Release::find(session, Release::FindParameters {}.setArtist(artist.getId()).setClusters({cluster1.getId(), cluster2.getId()})); - ASSERT_EQ(releases.results.size(), 1); - EXPECT_EQ(releases.results.front(), release.getId()); - } + releases = Release::find(session, Release::FindParameters{}.setArtist(artist.getId()).setClusters({ cluster1.getId(), cluster2.getId() })); + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release.getId()); + } } TEST_F(DatabaseFixture, SingleTrackListMultipleTrackSingleCluster) { - ScopedUser user {session, "MyUser"}; - ScopedTrackList trackList {session, "MyTrackList", TrackListType::Playlist, false, user.lockAndGet()}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster {session, clusterType.lockAndGet(), "MyCluster"}; - std::list tracks; + ScopedUser user{ session, "MyUser" }; + ScopedTrackList trackList{ session, "MyTrackList", TrackListType::Playlist, false, user.lockAndGet() }; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyCluster" }; + std::list tracks; - for (std::size_t i {}; i < 20; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + for (std::size_t i{}; i < 20; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - auto transaction {session.createUniqueTransaction()}; + auto transaction{ session.createUniqueTransaction() }; - if (i < 5) - session.create(tracks.back().get(), trackList.get()); + if (i < 5) + session.create(tracks.back().get(), trackList.get()); - if (i < 10) - cluster.get().modify()->addTrack(tracks.back().get()); - } + if (i < 10) + cluster.get().modify()->addTrack(tracks.back().get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - const auto similarTracks {trackList->getSimilarTracks()}; - EXPECT_EQ(similarTracks.size(), 5); + const auto similarTracks{ trackList->getSimilarTracks() }; + EXPECT_EQ(similarTracks.size(), 5); - for (auto similarTrack : similarTracks) - EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 5), std::cend(tracks), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack->getId(); })); - } + for (auto similarTrack : similarTracks) + EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 5), std::cend(tracks), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack->getId(); })); + } } TEST_F(DatabaseFixture, SingleTrackListMultipleTrackMultiClusters) { - ScopedUser user {session, "MyUser"}; - ScopedTrackList trackList {session, "MyTrackList", TrackListType::Playlist, false, user.lockAndGet()}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - std::list tracks; + ScopedUser user{ session, "MyUser" }; + ScopedTrackList trackList{ session, "MyTrackList", TrackListType::Playlist, false, user.lockAndGet() }; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; + std::list tracks; - for (std::size_t i {}; i < 20; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + for (std::size_t i{}; i < 20; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - auto transaction {session.createUniqueTransaction()}; + auto transaction{ session.createUniqueTransaction() }; - if (i < 5) - session.create(tracks.back().get(), trackList.get()); + if (i < 5) + session.create(tracks.back().get(), trackList.get()); - if (i < 10) - { - cluster1.get().modify()->addTrack(tracks.back().get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - else if (i < 15) - { - cluster1.get().modify()->addTrack(tracks.back().get()); - } - } + if (i < 10) + { + cluster1.get().modify()->addTrack(tracks.back().get()); + cluster2.get().modify()->addTrack(tracks.back().get()); + } + else if (i < 15) + { + cluster1.get().modify()->addTrack(tracks.back().get()); + } + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - { - const auto similarTracks {trackList->getSimilarTracks(0, 5)}; - ASSERT_EQ(similarTracks.size(), 5); + { + const auto similarTracks{ trackList->getSimilarTracks(0, 5) }; + ASSERT_EQ(similarTracks.size(), 5); - for (auto similarTrack : similarTracks) - EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 5), std::next(std::cbegin(tracks), 10), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack->getId(); })); - } + for (auto similarTrack : similarTracks) + EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 5), std::next(std::cbegin(tracks), 10), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack->getId(); })); + } - { - const auto similarTracks {trackList->getSimilarTracks(5, 10)}; - ASSERT_EQ(similarTracks.size(), 5); + { + const auto similarTracks{ trackList->getSimilarTracks(5, 10) }; + ASSERT_EQ(similarTracks.size(), 5); - for (auto similarTrack : similarTracks) - EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 10), std::next(std::cbegin(tracks), 15), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack->getId(); })); - } + for (auto similarTrack : similarTracks) + EXPECT_TRUE(std::any_of(std::next(std::cbegin(tracks), 10), std::next(std::cbegin(tracks), 15), [similarTrack](const ScopedTrack& track) { return track.getId() == similarTrack->getId(); })); + } - EXPECT_TRUE(trackList->getSimilarTracks(10, 10).empty()); - } + EXPECT_TRUE(trackList->getSimilarTracks(10, 10).empty()); + } } TEST_F(DatabaseFixture, SingleTrackListMultipleTrackMultiClustersRecentlyPlayed) { - ScopedUser user {session, "MyUser"}; - ScopedTrackList trackList {session, "MyTrackList", TrackListType::Playlist, false, user.lockAndGet()}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; - ScopedCluster cluster3 {session, clusterType.lockAndGet(), "MyCluster3"}; - ScopedTrack track1 {session, "MyTrack1"}; - ScopedTrack track2 {session, "MyTrack1"}; - ScopedArtist artist1 {session, "MyArtist1"}; - ScopedArtist artist2 {session, "MyArtist2"}; - ScopedRelease release1 {session, "MyRelease1"}; - ScopedRelease release2 {session, "MyRelease2"}; + ScopedUser user{ session, "MyUser" }; + ScopedTrackList trackList{ session, "MyTrackList", TrackListType::Playlist, false, user.lockAndGet() }; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; + ScopedCluster cluster3{ session, clusterType.lockAndGet(), "MyCluster3" }; + ScopedTrack track1{ session, "MyTrack1" }; + ScopedTrack track2{ session, "MyTrack1" }; + ScopedArtist artist1{ session, "MyArtist1" }; + ScopedArtist artist2{ session, "MyArtist2" }; + ScopedRelease release1{ session, "MyRelease1" }; + ScopedRelease release2{ session, "MyRelease2" }; - const Wt::WDateTime now {Wt::WDateTime::currentDateTime()}; + const Wt::WDateTime now{ Wt::WDateTime::currentDateTime() }; - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - track1.get().modify()->setRelease(release1.get()); - track2.get().modify()->setRelease(release2.get()); - TrackArtistLink::create(session, track1.get(), artist1.get(), TrackArtistLinkType::Artist); - TrackArtistLink::create(session, track2.get(), artist2.get(), TrackArtistLinkType::Artist); + track1.get().modify()->setRelease(release1.get()); + track2.get().modify()->setRelease(release2.get()); + TrackArtistLink::create(session, track1.get(), artist1.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track2.get(), artist2.get(), TrackArtistLinkType::Artist); - cluster1.get().modify()->addTrack(track1.get()); - cluster2.get().modify()->addTrack(track2.get()); - cluster3.get().modify()->addTrack(track1.get()); - cluster3.get().modify()->addTrack(track2.get()); - } + cluster1.get().modify()->addTrack(track1.get()); + cluster2.get().modify()->addTrack(track2.get()); + cluster3.get().modify()->addTrack(track1.get()); + cluster3.get().modify()->addTrack(track2.get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - EXPECT_TRUE(trackList->getArtistsOrderedByRecentFirst({}, std::nullopt, std::nullopt, moreResults).empty()); - EXPECT_TRUE(trackList->getReleasesOrderedByRecentFirst({}, std::nullopt, moreResults).empty()); - EXPECT_TRUE(trackList->getTracksOrderedByRecentFirst({}, std::nullopt, moreResults).empty()); - } + bool moreResults{}; + EXPECT_TRUE(trackList->getArtistsOrderedByRecentFirst({}, std::nullopt, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getReleasesOrderedByRecentFirst({}, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getTracksOrderedByRecentFirst({}, std::nullopt, moreResults).empty()); + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - session.create(track1.get(), trackList.get(), now); - } + session.create(track1.get(), trackList.get(), now); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 1); - EXPECT_EQ(artists.front()->getId(), artist1.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front()->getId(), artist1.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size() , 1); - EXPECT_EQ(releases.front()->getId(), release1.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front()->getId(), release1.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 1); - EXPECT_EQ(tracks.front()->getId(), track1.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front()->getId(), track1.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({cluster1.getId()}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 1); - EXPECT_EQ(artists.front()->getId(), artist1.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({cluster1.getId()}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front()->getId(), artist1.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({cluster1.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases.front()->getId(), release1.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({cluster1.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front()->getId(), release1.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({cluster1.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 1); - EXPECT_EQ(tracks.front()->getId(), track1.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({cluster1.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front()->getId(), track1.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 1); - EXPECT_EQ(artists.front()->getId(), artist1.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({cluster3.getId()}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front()->getId(), artist1.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases.front()->getId(), release1.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front()->getId(), release1.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 1); - EXPECT_EQ(tracks.front()->getId(), track1.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front()->getId(), track1.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({cluster1.getId()}, TrackArtistLinkType::Artist, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 1); - EXPECT_EQ(artists.front()->getId(), artist1.getId()); - } + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({cluster1.getId()}, TrackArtistLinkType::Artist, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front()->getId(), artist1.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({}, TrackArtistLinkType::Artist, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 1); - EXPECT_EQ(artists.front()->getId(), artist1.getId()); - } + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({}, TrackArtistLinkType::Artist, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front()->getId(), artist1.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - EXPECT_TRUE(trackList->getArtistsOrderedByRecentFirst({cluster2.getId()}, std::nullopt, std::nullopt, moreResults).empty()); - EXPECT_TRUE(trackList->getReleasesOrderedByRecentFirst({cluster2.getId()}, std::nullopt, moreResults).empty()); - EXPECT_TRUE(trackList->getTracksOrderedByRecentFirst({cluster2.getId()}, std::nullopt, moreResults).empty()); + bool moreResults{}; + EXPECT_TRUE(trackList->getArtistsOrderedByRecentFirst({ cluster2.getId() }, std::nullopt, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getReleasesOrderedByRecentFirst({ cluster2.getId() }, std::nullopt, moreResults).empty()); + EXPECT_TRUE(trackList->getTracksOrderedByRecentFirst({ cluster2.getId() }, std::nullopt, moreResults).empty()); - EXPECT_TRUE(trackList->getArtistsOrderedByRecentFirst({}, TrackArtistLinkType::ReleaseArtist, std::nullopt, moreResults).empty()); - } + EXPECT_TRUE(trackList->getArtistsOrderedByRecentFirst({}, TrackArtistLinkType::ReleaseArtist, std::nullopt, moreResults).empty()); + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - session.create(track2.get(), trackList.get(), now.addSecs(1)); - } + session.create(track2.get(), trackList.get(), now.addSecs(1)); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 2); - EXPECT_EQ(artists[0]->getId(), artist2.getId()); - EXPECT_EQ(artists[1]->getId(), artist1.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0]->getId(), artist2.getId()); + EXPECT_EQ(artists[1]->getId(), artist1.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size(), 2); - EXPECT_EQ(releases[0]->getId(), release2.getId()); - EXPECT_EQ(releases[1]->getId(), release1.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0]->getId(), release2.getId()); + EXPECT_EQ(releases[1]->getId(), release1.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 2); - EXPECT_EQ(tracks[0]->getId(), track2.getId()); - EXPECT_EQ(tracks[1]->getId(),track1.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0]->getId(), track2.getId()); + EXPECT_EQ(tracks[1]->getId(), track1.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 2); - EXPECT_EQ(artists[0]->getId(), artist2.getId()); - EXPECT_EQ(artists[1]->getId(), artist1.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({cluster3.getId()}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0]->getId(), artist2.getId()); + EXPECT_EQ(artists[1]->getId(), artist1.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size(), 2); - EXPECT_EQ(releases[0]->getId(), release2.getId()); - EXPECT_EQ(releases[1]->getId(), release1.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0]->getId(), release2.getId()); + EXPECT_EQ(releases[1]->getId(), release1.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 2); - EXPECT_EQ(tracks[0]->getId(), track2.getId()); - EXPECT_EQ(tracks[1]->getId(), track1.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0]->getId(), track2.getId()); + EXPECT_EQ(tracks[1]->getId(), track1.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({cluster1.getId()}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 1); - EXPECT_EQ(artists.front()->getId(), artist1.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({cluster1.getId()}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front()->getId(), artist1.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({cluster1.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases.front()->getId(), release1.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({cluster1.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front()->getId(), release1.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({cluster1.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 1); - EXPECT_EQ(tracks.front()->getId(), track1.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({cluster1.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front()->getId(), track1.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({cluster2.getId()}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 1); - EXPECT_EQ(artists.front()->getId(), artist2.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({cluster2.getId()}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front()->getId(), artist2.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({cluster2.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases.front()->getId(), release2.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({cluster2.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front()->getId(), release2.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({cluster2.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 1); - EXPECT_EQ(tracks.front()->getId(), track2.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({cluster2.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 1); + EXPECT_EQ(tracks.front()->getId(), track2.getId()); + } - { - auto transaction {session.createUniqueTransaction()}; + { + auto transaction{ session.createUniqueTransaction() }; - session.create(track1.get(), trackList.get(), now.addSecs(2)); - } + session.create(track1.get(), trackList.get(), now.addSecs(2)); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 2); - EXPECT_EQ(artists[0]->getId(), artist1.getId()); - EXPECT_EQ(artists[1]->getId(), artist2.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0]->getId(), artist1.getId()); + EXPECT_EQ(artists[1]->getId(), artist2.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size(), 2); - EXPECT_EQ(releases[0]->getId(), release1.getId()); - EXPECT_EQ(releases[1]->getId(), release2.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0]->getId(), release1.getId()); + EXPECT_EQ(releases[1]->getId(), release2.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 2); - EXPECT_EQ(tracks[0]->getId(), track1.getId()); - EXPECT_EQ(tracks[1]->getId(), track2.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0]->getId(), track1.getId()); + EXPECT_EQ(tracks[1]->getId(), track2.getId()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - bool moreResults {}; - const auto artists {trackList->getArtistsOrderedByRecentFirst({cluster3.getId()}, std::nullopt, std::nullopt, moreResults)}; - ASSERT_EQ(artists.size(), 2); - EXPECT_EQ(artists[0]->getId(), artist1.getId()); - EXPECT_EQ(artists[1]->getId(), artist2.getId()); + bool moreResults{}; + const auto artists{ trackList->getArtistsOrderedByRecentFirst({cluster3.getId()}, std::nullopt, std::nullopt, moreResults) }; + ASSERT_EQ(artists.size(), 2); + EXPECT_EQ(artists[0]->getId(), artist1.getId()); + EXPECT_EQ(artists[1]->getId(), artist2.getId()); - const auto releases {trackList->getReleasesOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(releases.size(), 2); - EXPECT_EQ(releases[0]->getId(), release1.getId()); - EXPECT_EQ(releases[1]->getId(), release2.getId()); + const auto releases{ trackList->getReleasesOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0]->getId(), release1.getId()); + EXPECT_EQ(releases[1]->getId(), release2.getId()); - const auto tracks {trackList->getTracksOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults)}; - ASSERT_EQ(tracks.size(), 2); - EXPECT_EQ(tracks[0]->getId(), track1.getId()); - EXPECT_EQ(tracks[1]->getId(), track2.getId()); - } + const auto tracks{ trackList->getTracksOrderedByRecentFirst({cluster3.getId()}, std::nullopt, moreResults) }; + ASSERT_EQ(tracks.size(), 2); + EXPECT_EQ(tracks[0]->getId(), track1.getId()); + EXPECT_EQ(tracks[1]->getId(), track2.getId()); + } } TEST_F(DatabaseFixture, MultipleTracksMultipleArtistsMultiClusters) { - ScopedArtist artist1 {session, "MyArtist1"}; - ScopedArtist artist2 {session, "MyArtist2"}; - ScopedArtist artist3 {session, "MyArtist3"}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + ScopedArtist artist1{ session, "MyArtist1" }; + ScopedArtist artist2{ session, "MyArtist2" }; + ScopedArtist artist3{ session, "MyArtist3" }; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; - { - auto transaction {session.createSharedTransaction()}; - EXPECT_TRUE(artist1->findSimilarArtists().results.empty()); - EXPECT_TRUE(artist2->findSimilarArtists().results.empty()); - EXPECT_TRUE(artist3->findSimilarArtists().results.empty()); - } + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_TRUE(artist1->findSimilarArtists().results.empty()); + EXPECT_TRUE(artist2->findSimilarArtists().results.empty()); + EXPECT_TRUE(artist3->findSimilarArtists().results.empty()); + } - std::list tracks; - for (std::size_t i {}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + std::list tracks; + for (std::size_t i{}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - auto transaction {session.createUniqueTransaction()}; + auto transaction{ session.createUniqueTransaction() }; - if (i < 5) - TrackArtistLink::create(session, tracks.back().get(), artist1.get(), TrackArtistLinkType::Artist); - else - { - TrackArtistLink::create(session, tracks.back().get(), artist2.get(), TrackArtistLinkType::Artist); - cluster2.get().modify()->addTrack(tracks.back().get()); - } + if (i < 5) + TrackArtistLink::create(session, tracks.back().get(), artist1.get(), TrackArtistLinkType::Artist); + else + { + TrackArtistLink::create(session, tracks.back().get(), artist2.get(), TrackArtistLinkType::Artist); + cluster2.get().modify()->addTrack(tracks.back().get()); + } - cluster1.get().modify()->addTrack(tracks.back().get()); - } + cluster1.get().modify()->addTrack(tracks.back().get()); + } - tracks.emplace_back(session, "MyTrack" + std::to_string(tracks.size())); - { - auto transaction {session.createUniqueTransaction()}; - TrackArtistLink::create(session, tracks.back().get(), artist3.get(), TrackArtistLinkType::Artist); - cluster2.get().modify()->addTrack(tracks.back().get()); - } + tracks.emplace_back(session, "MyTrack" + std::to_string(tracks.size())); + { + auto transaction{ session.createUniqueTransaction() }; + TrackArtistLink::create(session, tracks.back().get(), artist3.get(), TrackArtistLinkType::Artist); + cluster2.get().modify()->addTrack(tracks.back().get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - { - auto artists {artist1->findSimilarArtists()}; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist2.getId()); - } + { + auto artists{ artist1->findSimilarArtists() }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist2.getId()); + } - { - auto artists {artist1->findSimilarArtists({TrackArtistLinkType::Artist})}; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist2.getId()); - } + { + auto artists{ artist1->findSimilarArtists({TrackArtistLinkType::Artist}) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist2.getId()); + } - { - auto artists {artist1->findSimilarArtists({TrackArtistLinkType::ReleaseArtist})}; - EXPECT_EQ(artists.results.empty(), 1); - } + { + auto artists{ artist1->findSimilarArtists({TrackArtistLinkType::ReleaseArtist}) }; + EXPECT_EQ(artists.results.empty(), 1); + } - { - auto artists {artist1->findSimilarArtists({TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist})}; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist2.getId()); - } + { + auto artists{ artist1->findSimilarArtists({TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist2.getId()); + } - { - auto artists {artist1->findSimilarArtists({TrackArtistLinkType::Composer})}; - EXPECT_TRUE(artists.results.empty()); - } + { + auto artists{ artist1->findSimilarArtists({TrackArtistLinkType::Composer}) }; + EXPECT_TRUE(artists.results.empty()); + } - { - auto artists {artist2->findSimilarArtists()}; - ASSERT_EQ(artists.results.size(), 2); - EXPECT_EQ(artists.results[0], artist1.getId()); - EXPECT_EQ(artists.results[1], artist3.getId()); - } - } + { + auto artists{ artist2->findSimilarArtists() }; + ASSERT_EQ(artists.results.size(), 2); + EXPECT_EQ(artists.results[0], artist1.getId()); + EXPECT_EQ(artists.results[1], artist3.getId()); + } + } } TEST_F(DatabaseFixture, MultipleTracksMultipleReleasesMultiClusters) { - ScopedRelease release1 {session, "MyRelease1"}; - ScopedRelease release2 {session, "MyRelease2"}; - ScopedRelease release3 {session, "MyRelease3"}; - ScopedClusterType clusterType {session, "MyClusterType"}; - ScopedCluster cluster1 {session, clusterType.lockAndGet(), "MyCluster1"}; - ScopedCluster cluster2 {session, clusterType.lockAndGet(), "MyCluster2"}; + ScopedRelease release1{ session, "MyRelease1" }; + ScopedRelease release2{ session, "MyRelease2" }; + ScopedRelease release3{ session, "MyRelease3" }; + ScopedClusterType clusterType{ session, "MyClusterType" }; + ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; + ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; - { - auto transaction {session.createSharedTransaction()}; - EXPECT_TRUE(release1->getSimilarReleases().empty()); - EXPECT_TRUE(release2->getSimilarReleases().empty()); - EXPECT_TRUE(release3->getSimilarReleases().empty()); - } + { + auto transaction{ session.createSharedTransaction() }; + EXPECT_TRUE(release1->getSimilarReleases().empty()); + EXPECT_TRUE(release2->getSimilarReleases().empty()); + EXPECT_TRUE(release3->getSimilarReleases().empty()); + } - std::list tracks; - for (std::size_t i {}; i < 10; ++i) - { - tracks.emplace_back(session, "MyTrack" + std::to_string(i)); + std::list tracks; + for (std::size_t i{}; i < 10; ++i) + { + tracks.emplace_back(session, "MyTrack" + std::to_string(i)); - auto transaction {session.createUniqueTransaction()}; + auto transaction{ session.createUniqueTransaction() }; - if (i < 5) - tracks.back().get().modify()->setRelease(release1.get()); - else - { - tracks.back().get().modify()->setRelease(release2.get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } + if (i < 5) + tracks.back().get().modify()->setRelease(release1.get()); + else + { + tracks.back().get().modify()->setRelease(release2.get()); + cluster2.get().modify()->addTrack(tracks.back().get()); + } - cluster1.get().modify()->addTrack(tracks.back().get()); - } + cluster1.get().modify()->addTrack(tracks.back().get()); + } - tracks.emplace_back(session, "MyTrack" + std::to_string(tracks.size())); - { - auto transaction {session.createUniqueTransaction()}; - tracks.back().get().modify()->setRelease(release3.get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } + tracks.emplace_back(session, "MyTrack" + std::to_string(tracks.size())); + { + auto transaction{ session.createUniqueTransaction() }; + tracks.back().get().modify()->setRelease(release3.get()); + cluster2.get().modify()->addTrack(tracks.back().get()); + } - { - auto transaction {session.createSharedTransaction()}; + { + auto transaction{ session.createSharedTransaction() }; - { - auto releases {release1->getSimilarReleases()}; - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases.front()->getId(), release2.getId()); - } + { + auto releases{ release1->getSimilarReleases() }; + ASSERT_EQ(releases.size(), 1); + EXPECT_EQ(releases.front()->getId(), release2.getId()); + } - { - auto releases {release2->getSimilarReleases()}; - ASSERT_EQ(releases.size(), 2); - EXPECT_EQ(releases[0]->getId(), release1.getId()); - EXPECT_EQ(releases[1]->getId(), release3.getId()); - } - } + { + auto releases{ release2->getSimilarReleases() }; + ASSERT_EQ(releases.size(), 2); + EXPECT_EQ(releases[0]->getId(), release1.getId()); + EXPECT_EQ(releases[1]->getId(), release3.getId()); + } + } } diff --git a/src/libs/services/database/test/Release.cpp b/src/libs/services/database/test/Release.cpp index 573ed9c7..9c153fc4 100644 --- a/src/libs/services/database/test/Release.cpp +++ b/src/libs/services/database/test/Release.cpp @@ -350,8 +350,8 @@ TEST_F(DatabaseFixture, MultiTracksSingleReleaseDate) track1A.get().modify()->setOriginalDate(release1OriginalDate); track1B.get().modify()->setOriginalDate(release1OriginalDate); - EXPECT_EQ(release1.get()->getReleaseYear(), release1Date.year()); - EXPECT_EQ(release1.get()->getReleaseYear(true), release1OriginalDate.year()); + EXPECT_EQ(release1.get()->getReleaseDate(), release1Date); + EXPECT_EQ(release1.get()->getOriginalReleaseDate(), release1OriginalDate); } { diff --git a/src/libs/services/scrobbling/impl/IScrobbler.hpp b/src/libs/services/scrobbling/impl/IScrobbler.hpp index 7fd1f71f..e98ea1b7 100644 --- a/src/libs/services/scrobbling/impl/IScrobbler.hpp +++ b/src/libs/services/scrobbling/impl/IScrobbler.hpp @@ -30,33 +30,33 @@ namespace Database { - class Session; - class TrackList; - class User; + class Session; + class TrackList; + class User; } namespace Scrobbling { - class IScrobbler - { - public: - virtual ~IScrobbler() = default; + class IScrobbler + { + public: + virtual ~IScrobbler() = default; - // Listens - virtual void listenStarted(const Listen& listen) = 0; - virtual void listenFinished(const Listen& listen, std::optional duration) = 0; - virtual void addTimedListen(const TimedListen& listen) = 0; + // Listens + virtual void listenStarted(const Listen& listen) = 0; + virtual void listenFinished(const Listen& listen, std::optional duration) = 0; + virtual void addTimedListen(const TimedListen& listen) = 0; - // Feedbacks - virtual void onStarred(Database::StarredArtistId) = 0; - virtual void onUnstarred(Database::StarredArtistId) = 0; - virtual void onStarred(Database::StarredReleaseId) = 0; - virtual void onUnstarred(Database::StarredReleaseId) = 0; - virtual void onStarred(Database::StarredTrackId) = 0; - virtual void onUnstarred(Database::StarredTrackId) = 0; - }; + // Feedbacks + virtual void onStarred(Database::StarredArtistId) = 0; + virtual void onUnstarred(Database::StarredArtistId) = 0; + virtual void onStarred(Database::StarredReleaseId) = 0; + virtual void onUnstarred(Database::StarredReleaseId) = 0; + virtual void onStarred(Database::StarredTrackId) = 0; + virtual void onUnstarred(Database::StarredTrackId) = 0; + }; - std::unique_ptr createScrobbler(std::string_view backendName); + std::unique_ptr createScrobbler(std::string_view backendName); } // ns Scrobbling diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.cpp b/src/libs/services/scrobbling/impl/ScrobblingService.cpp index ac24670d..cecb7c00 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.cpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.cpp @@ -37,272 +37,264 @@ namespace Scrobbling { - using namespace Database; + using namespace Database; - std::unique_ptr - createScrobblingService(boost::asio::io_context& ioContext, Db& db) - { - return std::make_unique(ioContext, db); - } + std::unique_ptr createScrobblingService(boost::asio::io_context& ioContext, Db& db) + { + return std::make_unique(ioContext, db); + } - ScrobblingService::ScrobblingService(boost::asio::io_context& ioContext, Db& db) - : _db {db} - { - LMS_LOG(SCROBBLING, INFO) << "Starting service..."; - _scrobblers.emplace(Scrobbler::Internal, std::make_unique(_db)); - _scrobblers.emplace(Scrobbler::ListenBrainz, std::make_unique(ioContext, _db)); - LMS_LOG(SCROBBLING, INFO) << "Service started!"; - } + ScrobblingService::ScrobblingService(boost::asio::io_context& ioContext, Db& db) + : _db{ db } + { + LMS_LOG(SCROBBLING, INFO) << "Starting service..."; + _scrobblers.emplace(Scrobbler::Internal, std::make_unique(_db)); + _scrobblers.emplace(Scrobbler::ListenBrainz, std::make_unique(ioContext, _db)); + LMS_LOG(SCROBBLING, INFO) << "Service started!"; + } - ScrobblingService::~ScrobblingService() - { - LMS_LOG(SCROBBLING, INFO) << "Service stopped!"; - } + ScrobblingService::~ScrobblingService() + { + LMS_LOG(SCROBBLING, INFO) << "Service stopped!"; + } - void - ScrobblingService::listenStarted(const Listen& listen) - { - if (std::optional scrobbler {getUserScrobbler(listen.userId)}) - _scrobblers[*scrobbler]->listenStarted(listen); - } + void ScrobblingService::listenStarted(const Listen& listen) + { + if (std::optional scrobbler{ getUserScrobbler(listen.userId) }) + _scrobblers[*scrobbler]->listenStarted(listen); + } - void - ScrobblingService::listenFinished(const Listen& listen, std::optional duration) - { - if (std::optional scrobbler {getUserScrobbler(listen.userId)}) - _scrobblers[*scrobbler]->listenFinished(listen, duration); - } + void ScrobblingService::listenFinished(const Listen& listen, std::optional duration) + { + if (std::optional scrobbler{ getUserScrobbler(listen.userId) }) + _scrobblers[*scrobbler]->listenFinished(listen, duration); + } - void - ScrobblingService::addTimedListen(const TimedListen& listen) - { - if (std::optional scrobbler {getUserScrobbler(listen.userId)}) - _scrobblers[*scrobbler]->addTimedListen(listen); - } + void ScrobblingService::addTimedListen(const TimedListen& listen) + { + if (std::optional scrobbler{ getUserScrobbler(listen.userId) }) + _scrobblers[*scrobbler]->addTimedListen(listen); + } - std::optional - ScrobblingService::getUserScrobbler(UserId userId) - { - std::optional scrobbler; + std::optional ScrobblingService::getUserScrobbler(UserId userId) + { + std::optional scrobbler; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; - if (const User::pointer user {User::find(session, userId)}) - scrobbler = user->getScrobbler(); + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; + if (const User::pointer user{ User::find(session, userId) }) + scrobbler = user->getScrobbler(); - return scrobbler; - } + return scrobbler; + } - ScrobblingService::ArtistContainer - ScrobblingService::getRecentArtists(UserId userId, const std::vector& clusterIds, std::optional linkType, Range range) - { - ArtistContainer res; + ScrobblingService::ArtistContainer ScrobblingService::getRecentArtists(UserId userId, const std::vector& clusterIds, std::optional linkType, Range range) + { + ArtistContainer res; - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return res; + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return res; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; - res = Database::Listen::getRecentArtists(session, userId, *scrobbler, clusterIds, linkType, range); - return res; - } + res = Database::Listen::getRecentArtists(session, userId, *scrobbler, clusterIds, linkType, range); + return res; + } - ScrobblingService::ReleaseContainer - ScrobblingService::getRecentReleases(UserId userId, const std::vector& clusterIds, Range range) - { - ReleaseContainer res; + ScrobblingService::ReleaseContainer ScrobblingService::getRecentReleases(UserId userId, const std::vector& clusterIds, Range range) + { + ReleaseContainer res; - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return res; + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return res; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; - res = Database::Listen::getRecentReleases(session, userId, *scrobbler, clusterIds, range); - return res; - } + res = Database::Listen::getRecentReleases(session, userId, *scrobbler, clusterIds, range); + return res; + } - ScrobblingService::TrackContainer - ScrobblingService::getRecentTracks(UserId userId, const std::vector& clusterIds, Range range) - { - TrackContainer res; + ScrobblingService::TrackContainer ScrobblingService::getRecentTracks(UserId userId, const std::vector& clusterIds, Range range) + { + TrackContainer res; - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return res; + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return res; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; - res = Database::Listen::getRecentTracks(session, userId, *scrobbler, clusterIds, range); - return res; - } + res = Database::Listen::getRecentTracks(session, userId, *scrobbler, clusterIds, range); + return res; + } - // Top - ScrobblingService::ArtistContainer - ScrobblingService::getTopArtists(UserId userId, const std::vector& clusterIds, std::optional linkType, Range range) - { - ArtistContainer res; + // Top + ScrobblingService::ArtistContainer ScrobblingService::getTopArtists(UserId userId, const std::vector& clusterIds, std::optional linkType, Range range) + { + ArtistContainer res; - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return res; + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return res; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; - res = Database::Listen::getTopArtists(session, userId, *scrobbler, clusterIds, linkType, range); - return res; - } + res = Database::Listen::getTopArtists(session, userId, *scrobbler, clusterIds, linkType, range); + return res; + } - ScrobblingService::ReleaseContainer - ScrobblingService::getTopReleases(UserId userId, const std::vector& clusterIds, Range range) - { - ReleaseContainer res; + ScrobblingService::ReleaseContainer ScrobblingService::getTopReleases(UserId userId, const std::vector& clusterIds, Range range) + { + ReleaseContainer res; - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return res; + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return res; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; - res = Database::Listen::getTopReleases(session, userId, *scrobbler, clusterIds, range); - return res; - } + res = Database::Listen::getTopReleases(session, userId, *scrobbler, clusterIds, range); + return res; + } - ScrobblingService::TrackContainer - ScrobblingService::getTopTracks(UserId userId, const std::vector& clusterIds, Range range) - { - TrackContainer res; + ScrobblingService::TrackContainer ScrobblingService::getTopTracks(UserId userId, const std::vector& clusterIds, Range range) + { + TrackContainer res; - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return res; + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return res; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; - res = Database::Listen::getTopTracks(session, userId, *scrobbler, clusterIds, range); - return res; - } + res = Database::Listen::getTopTracks(session, userId, *scrobbler, clusterIds, range); + return res; + } - void - ScrobblingService::star(UserId userId, ArtistId artistId) - { - star(userId, artistId); - } + void ScrobblingService::star(UserId userId, ArtistId artistId) + { + star(userId, artistId); + } - void - ScrobblingService::unstar(UserId userId, ArtistId artistId) - { - unstar(userId, artistId); - } + void ScrobblingService::unstar(UserId userId, ArtistId artistId) + { + unstar(userId, artistId); + } - bool - ScrobblingService::isStarred(UserId userId, ArtistId artistId) - { - return isStarred(userId, artistId); - } + bool ScrobblingService::isStarred(UserId userId, ArtistId artistId) + { + return isStarred(userId, artistId); + } - ScrobblingService::ArtistContainer - ScrobblingService::getStarredArtists(UserId userId, const std::vector& clusterIds, - std::optional linkType, - ArtistSortMethod sortMethod, - Range range) - { - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return {}; + Wt::WDateTime ScrobblingService::getStarredDateTime(UserId userId, ArtistId artistId) + { + return getStarredDateTime(userId, artistId); + } - Artist::FindParameters params; - params.setStarringUser(userId, *scrobbler); - params.setClusters(clusterIds); - params.setLinkType(linkType); - params.setSortMethod(sortMethod); - params.setRange(range); + ScrobblingService::ArtistContainer ScrobblingService::getStarredArtists(UserId userId, const std::vector& clusterIds, + std::optional linkType, + ArtistSortMethod sortMethod, + Range range) + { + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return {}; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Artist::FindParameters params; + params.setStarringUser(userId, *scrobbler); + params.setClusters(clusterIds); + params.setLinkType(linkType); + params.setSortMethod(sortMethod); + params.setRange(range); - return Artist::find(session, params); - } + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; - void - ScrobblingService::star(UserId userId, ReleaseId releaseId) - { - star(userId, releaseId); - } + return Artist::find(session, params); + } - void - ScrobblingService::unstar(UserId userId, ReleaseId releaseId) - { - unstar(userId, releaseId); - } + void ScrobblingService::star(UserId userId, ReleaseId releaseId) + { + star(userId, releaseId); + } - bool - ScrobblingService::isStarred(UserId userId, ReleaseId releaseId) - { - return isStarred(userId, releaseId); - } + void ScrobblingService::unstar(UserId userId, ReleaseId releaseId) + { + unstar(userId, releaseId); + } - ScrobblingService::ReleaseContainer - ScrobblingService::getStarredReleases(UserId userId, const std::vector& clusterIds, Range range) - { - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return {}; + bool ScrobblingService::isStarred(UserId userId, ReleaseId releaseId) + { + return isStarred(userId, releaseId); + } - Release::FindParameters params; - params.setStarringUser(userId, *scrobbler); - params.setClusters(clusterIds); - params.setSortMethod(ReleaseSortMethod::StarredDateDesc); - params.setRange(range); + Wt::WDateTime ScrobblingService::getStarredDateTime(UserId userId, ReleaseId releaseId) + { + return getStarredDateTime(userId, releaseId); + } - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + ScrobblingService::ReleaseContainer ScrobblingService::getStarredReleases(UserId userId, const std::vector& clusterIds, Range range) + { + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return {}; - return Release::find(session, params); - } + Release::FindParameters params; + params.setStarringUser(userId, *scrobbler); + params.setClusters(clusterIds); + params.setSortMethod(ReleaseSortMethod::StarredDateDesc); + params.setRange(range); - void - ScrobblingService::star(UserId userId, TrackId trackId) - { - star(userId, trackId); - } + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; - void - ScrobblingService::unstar(UserId userId, TrackId trackId) - { - unstar(userId, trackId); - } + return Release::find(session, params); + } - bool - ScrobblingService::isStarred(UserId userId, TrackId trackId) - { - return isStarred(userId, trackId); - } + void ScrobblingService::star(UserId userId, TrackId trackId) + { + star(userId, trackId); + } - ScrobblingService::TrackContainer - ScrobblingService::getStarredTracks(UserId userId, const std::vector& clusterIds, Range range) - { - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return {}; + void ScrobblingService::unstar(UserId userId, TrackId trackId) + { + unstar(userId, trackId); + } - Track::FindParameters params; - params.setStarringUser(userId, *scrobbler); - params.setClusters(clusterIds); - params.setSortMethod(TrackSortMethod::StarredDateDesc); - params.setRange(range); + bool ScrobblingService::isStarred(UserId userId, TrackId trackId) + { + return isStarred(userId, trackId); + } - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Wt::WDateTime ScrobblingService::getStarredDateTime(UserId userId, TrackId trackId) + { + return getStarredDateTime(userId, trackId); + } - return Track::find(session, params); - } + ScrobblingService::TrackContainer ScrobblingService::getStarredTracks(UserId userId, const std::vector& clusterIds, Range range) + { + auto scrobbler{ getUserScrobbler(userId) }; + if (!scrobbler) + return {}; + + Track::FindParameters params; + params.setStarringUser(userId, *scrobbler); + params.setClusters(clusterIds); + params.setSortMethod(TrackSortMethod::StarredDateDesc); + params.setRange(range); + + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createSharedTransaction() }; + + return Track::find(session, params); + } } // ns Scrobbling diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.hpp b/src/libs/services/scrobbling/impl/ScrobblingService.hpp index ce9efaa5..225338a1 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.hpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.hpp @@ -28,74 +28,79 @@ namespace Scrobbling { - class ScrobblingService : public IScrobblingService - { - public: - ScrobblingService(boost::asio::io_context& ioContext, Database::Db& db); - ~ScrobblingService(); + class ScrobblingService : public IScrobblingService + { + public: + ScrobblingService(boost::asio::io_context& ioContext, Database::Db& db); + ~ScrobblingService(); - private: - void listenStarted(const Listen& listen) override; - void listenFinished(const Listen& listen, std::optional duration) override; - void addTimedListen(const TimedListen& listen) override; + private: + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::optional duration) override; + void addTimedListen(const TimedListen& listen) override; - ArtistContainer getRecentArtists(Database::UserId userId, - const std::vector& clusterIds, - std::optional linkType, - Database::Range range) override; + ArtistContainer getRecentArtists(Database::UserId userId, + const std::vector& clusterIds, + std::optional linkType, + Database::Range range) override; - ReleaseContainer getRecentReleases(Database::UserId userId, - const std::vector& clusterIds, - Database::Range range) override; + ReleaseContainer getRecentReleases(Database::UserId userId, + const std::vector& clusterIds, + Database::Range range) override; - TrackContainer getRecentTracks(Database::UserId userId, - const std::vector& clusterIds, - Database::Range range) override; + TrackContainer getRecentTracks(Database::UserId userId, + const std::vector& clusterIds, + Database::Range range) override; - ArtistContainer getTopArtists(Database::UserId userId, - const std::vector& clusterIds, - std::optional linkType, - Database::Range range) override; + ArtistContainer getTopArtists(Database::UserId userId, + const std::vector& clusterIds, + std::optional linkType, + Database::Range range) override; - ReleaseContainer getTopReleases(Database::UserId userId, - const std::vector& clusterIds, - Database::Range range) override; + ReleaseContainer getTopReleases(Database::UserId userId, + const std::vector& clusterIds, + Database::Range range) override; - TrackContainer getTopTracks(Database::UserId userId, - const std::vector& clusterIds, - Database::Range range) override; + TrackContainer getTopTracks(Database::UserId userId, + const std::vector& clusterIds, + Database::Range range) override; - void star(Database::UserId userId, Database::ArtistId artistId) override; - void unstar(Database::UserId userId, Database::ArtistId artistId) override; - bool isStarred(Database::UserId userId, Database::ArtistId artistId) override; - ArtistContainer getStarredArtists(Database::UserId userId, - const std::vector& clusterIds, - std::optional linkType, - Database::ArtistSortMethod sortMethod, - Database::Range range) override; + void star(Database::UserId userId, Database::ArtistId artistId) override; + void unstar(Database::UserId userId, Database::ArtistId artistId) override; + bool isStarred(Database::UserId userId, Database::ArtistId artistId) override; + Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::ArtistId artistId) override; + ArtistContainer getStarredArtists(Database::UserId userId, + const std::vector& clusterIds, + std::optional linkType, + Database::ArtistSortMethod sortMethod, + Database::Range range) override; - void star(Database::UserId userId, Database::ReleaseId releaseId) override; - void unstar(Database::UserId userId, Database::ReleaseId releaseId) override; - bool isStarred(Database::UserId userId, Database::ReleaseId artistId) override; - ReleaseContainer getStarredReleases(Database::UserId userId, const std::vector& clusterIds, Database::Range range) override; + void star(Database::UserId userId, Database::ReleaseId releaseId) override; + void unstar(Database::UserId userId, Database::ReleaseId releaseId) override; + bool isStarred(Database::UserId userId, Database::ReleaseId releasedId) override; + Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::ReleaseId releasedId) override; + ReleaseContainer getStarredReleases(Database::UserId userId, const std::vector& clusterIds, Database::Range range) override; - void star(Database::UserId userId, Database::TrackId trackId) override; - void unstar(Database::UserId userId, Database::TrackId trackId) override; - bool isStarred(Database::UserId userId, Database::TrackId trackId) override; - TrackContainer getStarredTracks(Database::UserId userId, const std::vector& clusterIds, Database::Range range) override; + void star(Database::UserId userId, Database::TrackId trackId) override; + void unstar(Database::UserId userId, Database::TrackId trackId) override; + bool isStarred(Database::UserId userId, Database::TrackId trackId) override; + Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::TrackId trackId) override; + TrackContainer getStarredTracks(Database::UserId userId, const std::vector& clusterIds, Database::Range range) override; - std::optional getUserScrobbler(Database::UserId userId); + std::optional getUserScrobbler(Database::UserId userId); - template - void star(Database::UserId userId, ObjIdType id); - template - void unstar(Database::UserId userId, ObjIdType id); - template - bool isStarred(Database::UserId userId, ObjIdType id); + template + void star(Database::UserId userId, ObjIdType id); + template + void unstar(Database::UserId userId, ObjIdType id); + template + bool isStarred(Database::UserId userId, ObjIdType id); + template + Wt::WDateTime getStarredDateTime(Database::UserId userId, ObjIdType id); - Database::Db& _db; - std::unordered_map> _scrobblers; - }; + Database::Db& _db; + std::unordered_map> _scrobblers; + }; } // ns Scrobbling diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.impl.hpp b/src/libs/services/scrobbling/impl/ScrobblingService.impl.hpp index 9514c1f1..bf26979b 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.impl.hpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.impl.hpp @@ -25,76 +25,90 @@ namespace Scrobbling { - using namespace Database; + using namespace Database; - template - void - ScrobblingService::star(UserId userId, ObjIdType objId) - { - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return; + template + void ScrobblingService::star(UserId userId, ObjIdType objId) + { + auto scrobbler {getUserScrobbler(userId)}; + if (!scrobbler) + return; - typename StarredObjType::IdType starredObjId; - { - Session& session {_db.getTLSSession()}; - auto transaction {session.createUniqueTransaction()}; + typename StarredObjType::IdType starredObjId; + { + Session& session {_db.getTLSSession()}; + auto transaction {session.createUniqueTransaction()}; - typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)}; - if (!starredObj) - { - const typename ObjType::pointer obj {ObjType::find(session, objId)}; - if (!obj) - return; + typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)}; + if (!starredObj) + { + const typename ObjType::pointer obj {ObjType::find(session, objId)}; + if (!obj) + return; - const User::pointer user {User::find(session, userId)}; - if (!user) - return; + const User::pointer user {User::find(session, userId)}; + if (!user) + return; - starredObj = session.create(obj, user, *scrobbler); - } - starredObj.modify()->setDateTime(Wt::WDateTime::currentDateTime()); - starredObjId = starredObj->getId(); - } - _scrobblers[*scrobbler]->onStarred(starredObjId); - } + starredObj = session.create(obj, user, *scrobbler); + } + starredObj.modify()->setDateTime(Wt::WDateTime::currentDateTime()); + starredObjId = starredObj->getId(); + } + _scrobblers[*scrobbler]->onStarred(starredObjId); + } - template - void - ScrobblingService::unstar(UserId userId, ObjIdType objId) - { - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return; + template + void ScrobblingService::unstar(UserId userId, ObjIdType objId) + { + auto scrobbler {getUserScrobbler(userId)}; + if (!scrobbler) + return; - typename StarredObjType::IdType starredObjId; - { - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + typename StarredObjType::IdType starredObjId; + { + Session& session {_db.getTLSSession()}; + auto transaction {session.createSharedTransaction()}; - typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)}; - if (!starredObj) - return; + typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)}; + if (!starredObj) + return; - starredObjId = starredObj->getId(); - } - _scrobblers[*scrobbler]->onUnstarred(starredObjId); - } + starredObjId = starredObj->getId(); + } + _scrobblers[*scrobbler]->onUnstarred(starredObjId); + } - template - bool - ScrobblingService::isStarred(UserId userId, ObjIdType objId) - { - auto scrobbler {getUserScrobbler(userId)}; - if (!scrobbler) - return false; + template + bool ScrobblingService::isStarred(UserId userId, ObjIdType objId) + { + auto scrobbler {getUserScrobbler(userId)}; + if (!scrobbler) + return false; - Session& session {_db.getTLSSession()}; - auto transaction {session.createSharedTransaction()}; + Session& session {_db.getTLSSession()}; + auto transaction {session.createSharedTransaction()}; - typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)}; - return starredObj && (starredObj->getScrobblingState() != ScrobblingState::PendingRemove); - } + typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)}; + return starredObj && (starredObj->getScrobblingState() != ScrobblingState::PendingRemove); + } + + template + Wt::WDateTime ScrobblingService::getStarredDateTime(UserId userId, ObjIdType objId) + { + auto scrobbler {getUserScrobbler(userId)}; + if (!scrobbler) + return {}; + + Session& session {_db.getTLSSession()}; + auto transaction {session.createSharedTransaction()}; + + typename StarredObjType::pointer starredObj {StarredObjType::find(session, objId, userId, *scrobbler)}; + if (starredObj && (starredObj->getScrobblingState() != ScrobblingState::PendingRemove)) + return starredObj->getDateTime(); + + return {}; + } } // ns Scrobbling diff --git a/src/libs/services/scrobbling/impl/internal/InternalScrobbler.cpp b/src/libs/services/scrobbling/impl/internal/InternalScrobbler.cpp index 775ef943..02c9ed1c 100644 --- a/src/libs/services/scrobbling/impl/internal/InternalScrobbler.cpp +++ b/src/libs/services/scrobbling/impl/internal/InternalScrobbler.cpp @@ -30,101 +30,93 @@ namespace { - template - void onStarred(Database::Session& session, typename StarredObjType::IdType id) - { - auto transaction {session.createUniqueTransaction()}; + template + void onStarred(Database::Session& session, typename StarredObjType::IdType id) + { + auto transaction{ session.createUniqueTransaction() }; - if (auto starredObj {StarredObjType::find(session, id)}) - starredObj.modify()->setScrobblingState(Database::ScrobblingState::Synchronized); - } + if (auto starredObj{ StarredObjType::find(session, id) }) + starredObj.modify()->setScrobblingState(Database::ScrobblingState::Synchronized); + } - template - void onUnstarred(Database::Session& session, typename StarredObjType::IdType id) - { - auto transaction {session.createUniqueTransaction()}; + template + void onUnstarred(Database::Session& session, typename StarredObjType::IdType id) + { + auto transaction{ session.createUniqueTransaction() }; - if (auto starredObj {StarredObjType::find(session, id)}) - starredObj.remove(); - } + if (auto starredObj{ StarredObjType::find(session, id) }) + starredObj.remove(); + } } namespace Scrobbling { - InternalScrobbler::InternalScrobbler(Database::Db& db) - : _db {db} - {} + InternalScrobbler::InternalScrobbler(Database::Db& db) + : _db{ db } + {} - void - InternalScrobbler::listenStarted(const Listen&) - { - // nothing to do - } + void InternalScrobbler::listenStarted(const Listen&) + { + // nothing to do + } - void - InternalScrobbler::listenFinished(const Listen& listen, std::optional duration) - { - // only record tracks that have been played for at least of few seconds... - if (duration && *duration < std::chrono::seconds {5}) - return; + void InternalScrobbler::listenFinished(const Listen& listen, std::optional duration) + { + // only record tracks that have been played for at least of few seconds... + if (duration && *duration < std::chrono::seconds{ 5 }) + return; - addTimedListen({listen, Wt::WDateTime::currentDateTime()}); - } + addTimedListen({ listen, Wt::WDateTime::currentDateTime() }); + } - void - InternalScrobbler::addTimedListen(const TimedListen& listen) - { - Database::Session& session {_db.getTLSSession()}; - auto transaction {session.createUniqueTransaction()}; + void InternalScrobbler::addTimedListen(const TimedListen& listen) + { + Database::Session& session{ _db.getTLSSession() }; + auto transaction{ session.createUniqueTransaction() }; - if (Database::Listen::find(session, listen.userId, listen.trackId, Database::Scrobbler::Internal, listen.listenedAt)) - return; + if (Database::Listen::find(session, listen.userId, listen.trackId, Database::Scrobbler::Internal, listen.listenedAt)) + return; - const Database::User::pointer user {Database::User::find(session, listen.userId)}; - if (!user) - return; + const Database::User::pointer user{ Database::User::find(session, listen.userId) }; + if (!user) + return; - const Database::Track::pointer track {Database::Track::find(session, listen.trackId)}; - if (!track) - return; + const Database::Track::pointer track{ Database::Track::find(session, listen.trackId) }; + if (!track) + return; - auto dbListen {session.create(user, track, Database::Scrobbler::Internal, listen.listenedAt)}; - dbListen.modify()->setScrobblingState(Database::ScrobblingState::Synchronized); - } + auto dbListen{ session.create(user, track, Database::Scrobbler::Internal, listen.listenedAt) }; + dbListen.modify()->setScrobblingState(Database::ScrobblingState::Synchronized); + } - void - InternalScrobbler::onStarred(Database::StarredArtistId starredArtistId) - { - ::onStarred(_db.getTLSSession(), starredArtistId); - } + void InternalScrobbler::onStarred(Database::StarredArtistId starredArtistId) + { + ::onStarred(_db.getTLSSession(), starredArtistId); + } - void - InternalScrobbler::onUnstarred(Database::StarredArtistId starredArtistId) - { - ::onUnstarred(_db.getTLSSession(), starredArtistId); - } + void InternalScrobbler::onUnstarred(Database::StarredArtistId starredArtistId) + { + ::onUnstarred(_db.getTLSSession(), starredArtistId); + } - void - InternalScrobbler::onStarred(Database::StarredReleaseId starredReleaseId) - { - ::onStarred(_db.getTLSSession(), starredReleaseId); - } + void InternalScrobbler::onStarred(Database::StarredReleaseId starredReleaseId) + { + ::onStarred(_db.getTLSSession(), starredReleaseId); + } - void InternalScrobbler::onUnstarred(Database::StarredReleaseId starredReleaseId) - { - ::onUnstarred(_db.getTLSSession(), starredReleaseId); - } + void InternalScrobbler::onUnstarred(Database::StarredReleaseId starredReleaseId) + { + ::onUnstarred(_db.getTLSSession(), starredReleaseId); + } - void - InternalScrobbler::onStarred(Database::StarredTrackId starredTrackId) - { - ::onStarred(_db.getTLSSession(), starredTrackId); - } + void InternalScrobbler::onStarred(Database::StarredTrackId starredTrackId) + { + ::onStarred(_db.getTLSSession(), starredTrackId); + } - void - InternalScrobbler::onUnstarred(Database::StarredTrackId starredTrackId) - { - ::onUnstarred(_db.getTLSSession(), starredTrackId); - } + void InternalScrobbler::onUnstarred(Database::StarredTrackId starredTrackId) + { + ::onUnstarred(_db.getTLSSession(), starredTrackId); + } } // Scrobbling diff --git a/src/libs/services/scrobbling/impl/internal/InternalScrobbler.hpp b/src/libs/services/scrobbling/impl/internal/InternalScrobbler.hpp index 4b6dd39c..ce3fb608 100644 --- a/src/libs/services/scrobbling/impl/internal/InternalScrobbler.hpp +++ b/src/libs/services/scrobbling/impl/internal/InternalScrobbler.hpp @@ -23,30 +23,30 @@ namespace Database { - class Db; + class Db; } namespace Scrobbling { - class InternalScrobbler final : public IScrobbler - { - public: - InternalScrobbler(Database::Db& db); + class InternalScrobbler final : public IScrobbler + { + public: + InternalScrobbler(Database::Db& db); - private: - // IScrobbler - void listenStarted(const Listen& listen) override; - void listenFinished(const Listen& listen, std::optional duration) override; - void addTimedListen(const TimedListen& listen) override; + private: + // IScrobbler + void listenStarted(const Listen& listen) override; + void listenFinished(const Listen& listen, std::optional duration) override; + void addTimedListen(const TimedListen& listen) override; - void onStarred(Database::StarredArtistId) override; - void onUnstarred(Database::StarredArtistId) override; - void onStarred(Database::StarredReleaseId) override; - void onUnstarred(Database::StarredReleaseId) override; - void onStarred(Database::StarredTrackId) override; - void onUnstarred(Database::StarredTrackId) override; + void onStarred(Database::StarredArtistId) override; + void onUnstarred(Database::StarredArtistId) override; + void onStarred(Database::StarredReleaseId) override; + void onUnstarred(Database::StarredReleaseId) override; + void onStarred(Database::StarredTrackId) override; + void onUnstarred(Database::StarredTrackId) override; - Database::Db& _db; - }; + Database::Db& _db; + }; } // Scrobbling diff --git a/src/libs/services/scrobbling/include/services/scrobbling/Exception.hpp b/src/libs/services/scrobbling/include/services/scrobbling/Exception.hpp index e6009e68..699bfa19 100644 --- a/src/libs/services/scrobbling/include/services/scrobbling/Exception.hpp +++ b/src/libs/services/scrobbling/include/services/scrobbling/Exception.hpp @@ -23,9 +23,9 @@ namespace Scrobbling { - class Exception : public LmsException - { - public: - using LmsException::LmsException; - }; + class Exception : public LmsException + { + public: + using LmsException::LmsException; + }; } diff --git a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp index 18b00129..fd27df5d 100644 --- a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp +++ b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp @@ -19,11 +19,11 @@ #pragma once -#include - #include #include #include +#include +#include #include "services/scrobbling/Listen.hpp" #include "services/database/ArtistId.hpp" @@ -34,77 +34,80 @@ namespace Database { - class Db; + class Db; } namespace Scrobbling { - class IScrobblingService - { - public: - virtual ~IScrobblingService() = default; + class IScrobblingService + { + public: + virtual ~IScrobblingService() = default; - // Scrobbling - virtual void listenStarted(const Listen& listen) = 0; - virtual void listenFinished(const Listen& listen, std::optional playedDuration = std::nullopt) = 0; + // Scrobbling + virtual void listenStarted(const Listen& listen) = 0; + virtual void listenFinished(const Listen& listen, std::optional playedDuration = std::nullopt) = 0; - virtual void addTimedListen(const TimedListen& listen) = 0; + virtual void addTimedListen(const TimedListen& listen) = 0; - // Stats - using ArtistContainer = Database::RangeResults; - using ReleaseContainer = Database::RangeResults; - using TrackContainer = Database::RangeResults; - // From most recent to oldest - virtual ArtistContainer getRecentArtists(Database::UserId userId, - const std::vector& clusterIds, - std::optional linkType, - Database::Range range) = 0; + // Stats + using ArtistContainer = Database::RangeResults; + using ReleaseContainer = Database::RangeResults; + using TrackContainer = Database::RangeResults; + // From most recent to oldest + virtual ArtistContainer getRecentArtists(Database::UserId userId, + const std::vector& clusterIds, + std::optional linkType, + Database::Range range) = 0; - virtual ReleaseContainer getRecentReleases(Database::UserId userId, - const std::vector& clusterIds, - Database::Range range) = 0; + virtual ReleaseContainer getRecentReleases(Database::UserId userId, + const std::vector& clusterIds, + Database::Range range) = 0; - virtual TrackContainer getRecentTracks(Database::UserId userId, - const std::vector& clusterIds, - Database::Range range) = 0; + virtual TrackContainer getRecentTracks(Database::UserId userId, + const std::vector& clusterIds, + Database::Range range) = 0; - // Top - virtual ArtistContainer getTopArtists(Database::UserId userId, - const std::vector& clusterIds, - std::optional linkType, - Database::Range) = 0; + // Top + virtual ArtistContainer getTopArtists(Database::UserId userId, + const std::vector& clusterIds, + std::optional linkType, + Database::Range) = 0; - virtual ReleaseContainer getTopReleases(Database::UserId userId, - const std::vector& clusterIds, - Database::Range range) = 0; + virtual ReleaseContainer getTopReleases(Database::UserId userId, + const std::vector& clusterIds, + Database::Range range) = 0; - virtual TrackContainer getTopTracks(Database::UserId userId, - const std::vector& clusterIds, - Database::Range range) = 0; + virtual TrackContainer getTopTracks(Database::UserId userId, + const std::vector& clusterIds, + Database::Range range) = 0; - // Star - virtual void star(Database::UserId userId, Database::ArtistId artistId) = 0; - virtual void unstar(Database::UserId userId, Database::ArtistId artistId) = 0; - virtual bool isStarred(Database::UserId userId, Database::ArtistId artistId) = 0; - virtual ArtistContainer getStarredArtists(Database::UserId userId, - const std::vector& clusterIds, - std::optional linkType, - Database::ArtistSortMethod sortMethod, - Database::Range range) = 0; + // Star + virtual void star(Database::UserId userId, Database::ArtistId artistId) = 0; + virtual void unstar(Database::UserId userId, Database::ArtistId artistId) = 0; + virtual bool isStarred(Database::UserId userId, Database::ArtistId artistId) = 0; + virtual Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::ArtistId artistId) = 0; + virtual ArtistContainer getStarredArtists(Database::UserId userId, + const std::vector& clusterIds, + std::optional linkType, + Database::ArtistSortMethod sortMethod, + Database::Range range) = 0; - virtual void star(Database::UserId userId, Database::ReleaseId releaseId) = 0; - virtual void unstar(Database::UserId userId, Database::ReleaseId releaseId) = 0; - virtual bool isStarred(Database::UserId userId, Database::ReleaseId artistId) = 0; - virtual ReleaseContainer getStarredReleases(Database::UserId userId, const std::vector& clusterIds, Database::Range range) = 0; + virtual void star(Database::UserId userId, Database::ReleaseId releaseId) = 0; + virtual void unstar(Database::UserId userId, Database::ReleaseId releaseId) = 0; + virtual bool isStarred(Database::UserId userId, Database::ReleaseId artistId) = 0; + virtual Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::ReleaseId artistId) = 0; + virtual ReleaseContainer getStarredReleases(Database::UserId userId, const std::vector& clusterIds, Database::Range range) = 0; - virtual void star(Database::UserId userId, Database::TrackId trackId) = 0; - virtual void unstar(Database::UserId userId, Database::TrackId trackId) = 0; - virtual bool isStarred(Database::UserId userId, Database::TrackId artistId) = 0; - virtual TrackContainer getStarredTracks(Database::UserId userId, const std::vector& clusterIds, Database::Range range) = 0; - }; + virtual void star(Database::UserId userId, Database::TrackId trackId) = 0; + virtual void unstar(Database::UserId userId, Database::TrackId trackId) = 0; + virtual bool isStarred(Database::UserId userId, Database::TrackId artistId) = 0; + virtual Wt::WDateTime getStarredDateTime(Database::UserId userId, Database::TrackId artistId) = 0; + virtual TrackContainer getStarredTracks(Database::UserId userId, const std::vector& clusterIds, Database::Range range) = 0; + }; - std::unique_ptr createScrobblingService(boost::asio::io_service& ioService, Database::Db& db); + std::unique_ptr createScrobblingService(boost::asio::io_service& ioService, Database::Db& db); } // ns Scrobbling diff --git a/src/libs/services/scrobbling/include/services/scrobbling/Listen.hpp b/src/libs/services/scrobbling/include/services/scrobbling/Listen.hpp index a42ab649..3838f1bc 100644 --- a/src/libs/services/scrobbling/include/services/scrobbling/Listen.hpp +++ b/src/libs/services/scrobbling/include/services/scrobbling/Listen.hpp @@ -26,15 +26,15 @@ namespace Scrobbling { - struct Listen - { - Database::UserId userId {}; - Database::TrackId trackId {}; - }; + struct Listen + { + Database::UserId userId{}; + Database::TrackId trackId{}; + }; - struct TimedListen : public Listen - { - Wt::WDateTime listenedAt; - }; + struct TimedListen : public Listen + { + Wt::WDateTime listenedAt; + }; } // ns Scrobbling diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index d4f4b985..8964a561 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -1,11 +1,32 @@ add_library(lmssubsonic SHARED + impl/entrypoints/AlbumSongLists.cpp + impl/entrypoints/Bookmarks.cpp + impl/entrypoints/Browsing.cpp + impl/entrypoints/MediaAnnotation.cpp + impl/entrypoints/MediaLibraryScanning.cpp + impl/entrypoints/MediaRetrieval.cpp + impl/entrypoints/Playlists.cpp + impl/entrypoints/Searching.cpp + impl/entrypoints/System.cpp + impl/entrypoints/UserManagement.cpp + impl/responses/Album.cpp + impl/responses/Artist.cpp + impl/responses/Bookmark.cpp + impl/responses/Contributor.cpp + impl/responses/DiscTitle.cpp + impl/responses/ItemGenre.cpp + impl/responses/Genre.cpp + impl/responses/Playlist.cpp + impl/responses/ReplayGain.cpp + impl/responses/Song.cpp + impl/responses/User.cpp impl/ProtocolVersion.cpp - impl/Scan.cpp - impl/Stream.cpp + impl/ParameterParsing.cpp impl/SubsonicId.cpp impl/SubsonicResource.cpp impl/SubsonicResponse.cpp + impl/Utils.cpp ) target_include_directories(lmssubsonic INTERFACE @@ -13,6 +34,7 @@ target_include_directories(lmssubsonic INTERFACE ) target_include_directories(lmssubsonic PRIVATE + impl include ) diff --git a/src/libs/subsonic/impl/ClientInfo.hpp b/src/libs/subsonic/impl/ClientInfo.hpp index 9c2dafd0..ed25a18b 100644 --- a/src/libs/subsonic/impl/ClientInfo.hpp +++ b/src/libs/subsonic/impl/ClientInfo.hpp @@ -24,11 +24,11 @@ namespace API::Subsonic { - struct ClientInfo - { - std::string name; - std::string user; - std::string password; - ProtocolVersion version; - }; + struct ClientInfo + { + std::string name; + std::string user; + std::string password; + ProtocolVersion version; + }; } diff --git a/src/libs/subsonic/impl/ParameterParsing.cpp b/src/libs/subsonic/impl/ParameterParsing.cpp new file mode 100644 index 00000000..4e474c03 --- /dev/null +++ b/src/libs/subsonic/impl/ParameterParsing.cpp @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 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 "ParameterParsing.hpp" + +namespace API::Subsonic +{ + bool hasParameter(const Wt::Http::ParameterMap& parameterMap, const std::string& param) + { + return parameterMap.find(param) != std::cend(parameterMap); + } + + std::string decodePasswordIfNeeded(const std::string& password) + { + if (password.find("enc:") == 0) + { + auto decodedPassword{ StringUtils::stringFromHex(password.substr(4)) }; + if (!decodedPassword) + return password; // fallback on plain password + + return *decodedPassword; + } + + return password; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/ParameterParsing.hpp b/src/libs/subsonic/impl/ParameterParsing.hpp index 98635c68..9197c1b1 100644 --- a/src/libs/subsonic/impl/ParameterParsing.hpp +++ b/src/libs/subsonic/impl/ParameterParsing.hpp @@ -16,10 +16,15 @@ * 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 "services/database/Types.hpp" #include "utils/String.hpp" #include "SubsonicResponse.hpp" @@ -27,65 +32,57 @@ namespace API::Subsonic { - template - std::vector - getMultiParametersAs(const Wt::Http::ParameterMap& parameterMap, const std::string& paramName) - { - std::vector res; + template + std::vector getMultiParametersAs(const Wt::Http::ParameterMap& parameterMap, const std::string& paramName) + { + std::vector res; - auto it = parameterMap.find(paramName); - if (it == parameterMap.end()) - return res; + auto it = parameterMap.find(paramName); + if (it == parameterMap.end()) + return res; - for (const std::string& param : it->second) - { - auto value {StringUtils::readAs(param)}; - if (value) - res.emplace_back(std::move(*value)); - } + for (const std::string& param : it->second) + { + auto value{ StringUtils::readAs(param) }; + if (value) + res.emplace_back(std::move(*value)); + } - return res; - } + return res; + } - template - std::vector - getMandatoryMultiParametersAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param) - { - std::vector res {getMultiParametersAs(parameterMap, param)}; - if (res.empty()) - throw RequiredParameterMissingError {param}; + template + std::vector getMandatoryMultiParametersAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param) + { + std::vector res{ getMultiParametersAs(parameterMap, param) }; + if (res.empty()) + throw RequiredParameterMissingError{ param }; - return res; - } + return res; + } - template - std::optional - getParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param) - { - std::vector params {getMultiParametersAs(parameterMap, param)}; + template + std::optional getParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param) + { + std::vector params{ getMultiParametersAs(parameterMap, param) }; - if (params.size() != 1) - return std::nullopt; + if (params.size() != 1) + return std::nullopt; - return T { std::move(params.front()) }; - } + return T{ std::move(params.front()) }; + } - template - T - getMandatoryParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param) - { - auto res {getParameterAs(parameterMap, param)}; - if (!res) - throw RequiredParameterMissingError {param}; + template + T getMandatoryParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param) + { + auto res{ getParameterAs(parameterMap, param) }; + if (!res) + throw RequiredParameterMissingError{ param }; - return *res; - } + return *res; + } - inline - bool - hasParameter(const Wt::Http::ParameterMap& parameterMap, const std::string& param) - { - return parameterMap.find(param) != std::cend(parameterMap); - } + bool hasParameter(const Wt::Http::ParameterMap& parameterMap, const std::string& param); + std::string decodePasswordIfNeeded(const std::string& password); } diff --git a/src/libs/subsonic/impl/ProtocolVersion.cpp b/src/libs/subsonic/impl/ProtocolVersion.cpp index 4a02a4e3..2dbf3a45 100644 --- a/src/libs/subsonic/impl/ProtocolVersion.cpp +++ b/src/libs/subsonic/impl/ProtocolVersion.cpp @@ -21,36 +21,35 @@ namespace StringUtils { - template<> - std::optional - readAs(std::string_view str) - { - // Expects "X.Y.Z" - const auto numbers {StringUtils::splitString(str, ".")}; - if (numbers.size() < 2 || numbers.size() > 3) - return std::nullopt; + template<> + std::optional readAs(std::string_view str) + { + // Expects "X.Y.Z" + const auto numbers{ StringUtils::splitString(str, ".") }; + if (numbers.size() < 2 || numbers.size() > 3) + return std::nullopt; - API::Subsonic::ProtocolVersion version; + API::Subsonic::ProtocolVersion version; - auto number {StringUtils::readAs(numbers[0])}; - if (!number) - return std::nullopt; - version.major = *number; + auto number{ StringUtils::readAs(numbers[0]) }; + if (!number) + return std::nullopt; + version.major = *number; - number = {StringUtils::readAs(numbers[1])}; - if (!number) - return std::nullopt; - version.minor = *number; + number = { StringUtils::readAs(numbers[1]) }; + if (!number) + return std::nullopt; + version.minor = *number; - if (numbers.size() == 3) - { - number = {StringUtils::readAs(numbers[2])}; - if (!number) - return std::nullopt; - version.patch = *number; - } + if (numbers.size() == 3) + { + number = { StringUtils::readAs(numbers[2]) }; + if (!number) + return std::nullopt; + version.patch = *number; + } - return version; - } + return version; + } } diff --git a/src/libs/subsonic/impl/ProtocolVersion.hpp b/src/libs/subsonic/impl/ProtocolVersion.hpp index fc046e22..4dcfff13 100644 --- a/src/libs/subsonic/impl/ProtocolVersion.hpp +++ b/src/libs/subsonic/impl/ProtocolVersion.hpp @@ -23,18 +23,20 @@ namespace API::Subsonic { - struct ProtocolVersion - { - unsigned major {}; - unsigned minor {}; - unsigned patch {}; - }; + struct ProtocolVersion + { + unsigned major{}; + unsigned minor{}; + unsigned patch{}; + }; - static inline constexpr ProtocolVersion defaultServerProtocolVersion {1, 16, 0}; + static inline constexpr ProtocolVersion defaultServerProtocolVersion{ 1, 16, 0 }; + static inline constexpr std::string_view serverVersion{ "1" }; } namespace StringUtils { - template<> std::optional readAs(std::string_view str); + template<> + std::optional readAs(std::string_view str); } diff --git a/src/libs/subsonic/impl/RequestContext.hpp b/src/libs/subsonic/impl/RequestContext.hpp index 01930783..4f2801bc 100644 --- a/src/libs/subsonic/impl/RequestContext.hpp +++ b/src/libs/subsonic/impl/RequestContext.hpp @@ -29,18 +29,18 @@ namespace Database { - class Session; + class Session; } namespace API::Subsonic { - struct RequestContext - { - const Wt::Http::ParameterMap& parameters; - Database::Session& dbSession; - Database::UserId userId; - ClientInfo clientInfo; - ProtocolVersion serverProtocolVersion; - }; + struct RequestContext + { + const Wt::Http::ParameterMap& parameters; + Database::Session& dbSession; + Database::UserId userId; + ClientInfo clientInfo; + ProtocolVersion serverProtocolVersion; + }; } diff --git a/src/libs/subsonic/impl/Scan.cpp b/src/libs/subsonic/impl/Scan.cpp deleted file mode 100644 index b006e6b1..00000000 --- a/src/libs/subsonic/impl/Scan.cpp +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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 "Scan.hpp" - -#include "services/scanner/IScannerService.hpp" -#include "utils/Service.hpp" - -namespace API::Subsonic::Scan -{ - using namespace Scanner; - - static - Response::Node - createStatusResponseNode() - { - Response::Node statusResponse; - - const IScannerService::Status scanStatus {Service::get()->getStatus()}; - - statusResponse.setAttribute("scanning", scanStatus.currentState == IScannerService::State::InProgress); - if (scanStatus.currentState == IScannerService::State::InProgress) - { - std::size_t count{}; - - if (scanStatus.currentScanStepStats && scanStatus.currentScanStepStats->currentStep == ScanStep::ScanningFiles) - count = scanStatus.currentScanStepStats->processedElems; - - statusResponse.setAttribute("count", count); - } - - return statusResponse; - } - - - Response - handleGetScanStatus(RequestContext& context) - { - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - response.addNode("scanStatus", createStatusResponseNode()); - - return response; - } - - Response - handleStartScan(RequestContext& context) - { - Service::get()->requestImmediateScan(false); - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - response.addNode("scanStatus", createStatusResponseNode()); - - return response; - } -} - diff --git a/src/libs/subsonic/impl/Stream.cpp b/src/libs/subsonic/impl/Stream.cpp deleted file mode 100644 index 91c13eaf..00000000 --- a/src/libs/subsonic/impl/Stream.cpp +++ /dev/null @@ -1,182 +0,0 @@ -/* - * 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 "Stream.hpp" - -#include "av/TranscodeParameters.hpp" -#include "av/TranscodeResourceHandlerCreator.hpp" -#include "av/Types.hpp" -#include "services/database/Session.hpp" -#include "services/database/Track.hpp" -#include "services/database/User.hpp" -#include "utils/IResourceHandler.hpp" -#include "utils/Logger.hpp" -#include "utils/FileResourceHandlerCreator.hpp" -#include "utils/Utils.hpp" -#include "ParameterParsing.hpp" -#include "SubsonicId.hpp" - -using namespace Database; - -namespace API::Subsonic::Stream -{ - -static -Av::Format -userTranscodeFormatToAvFormat(AudioFormat format) -{ - switch (format) - { - case AudioFormat::MP3: return Av::Format::MP3; - case AudioFormat::OGG_OPUS: return Av::Format::OGG_OPUS; - case AudioFormat::MATROSKA_OPUS: return Av::Format::MATROSKA_OPUS; - case AudioFormat::OGG_VORBIS: return Av::Format::OGG_VORBIS; - case AudioFormat::WEBM_VORBIS: return Av::Format::WEBM_VORBIS; - default: return Av::Format::OGG_OPUS; - } -} - -struct StreamParameters -{ - Av::InputFileParameters inputFileParameters; - std::optional transcodeParameters; - bool estimateContentLength {}; -}; - -static -StreamParameters -getStreamParameters(RequestContext& context) -{ - // Mandatory params - const TrackId id {getMandatoryParameterAs(context.parameters, "id")}; - - // Optional params - std::optional maxBitRate {getParameterAs(context.parameters, "maxBitRate")}; - std::optional format {getParameterAs(context.parameters, "format")}; - bool estimateContentLength {getParameterAs(context.parameters, "estimateContentLength").value_or(false)}; - - StreamParameters parameters; - - parameters.estimateContentLength = estimateContentLength; - - auto transaction {context.dbSession.createSharedTransaction()}; - - { - auto track {Track::find(context.dbSession, id)}; - if (!track) - throw RequestedDataNotFoundError {}; - - parameters.inputFileParameters.trackPath = track->getPath(); - parameters.inputFileParameters.duration = track->getDuration(); - } - - { - const User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - // format = "raw" => no transcode. Other format values will be ignored - const bool transcode {(!format || (*format != "raw")) && user->getSubsonicTranscodeEnable()}; - if (transcode) - { - std::size_t bitRate {user->getSubsonicTranscodeBitrate() / 1000}; - - // "If set to zero, no limit is imposed" - if (maxBitRate && *maxBitRate != 0) - bitRate = Utils::clamp(*maxBitRate, std::size_t {48}, bitRate); - - Av::TranscodeParameters transcodeParameters; - - transcodeParameters.bitrate = bitRate * 1000; - transcodeParameters.format = userTranscodeFormatToAvFormat(user->getSubsonicTranscodeFormat()); - transcodeParameters.stripMetadata = false; // We want clients to use metadata (offline use, replay gain, etc.) - - parameters.transcodeParameters = std::move(transcodeParameters); - } - } - - return parameters; -} - -void -handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response) -{ - std::shared_ptr resourceHandler; - - Wt::Http::ResponseContinuation* continuation {request.continuation()}; - if (!continuation) - { - // Mandatory params - Database::TrackId id {getMandatoryParameterAs(context.parameters, "id")}; - - std::filesystem::path trackPath; - { - auto transaction {context.dbSession.createSharedTransaction()}; - - auto track {Track::find(context.dbSession, id)}; - if (!track) - throw RequestedDataNotFoundError {}; - - trackPath = track->getPath(); - } - - resourceHandler = createFileResourceHandler(trackPath); - } - else - { - resourceHandler = Wt::cpp17::any_cast>(continuation->data()); - } - - continuation = resourceHandler->processRequest(request, response); - if (continuation) - continuation->setData(resourceHandler); -} - -void -handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response) -{ - std::shared_ptr resourceHandler; - - try - { - Wt::Http::ResponseContinuation* continuation = request.continuation(); - if (!continuation) - { - StreamParameters streamParameters {getStreamParameters(context)}; - if (streamParameters.transcodeParameters) - resourceHandler = Av::createTranscodeResourceHandler(streamParameters.inputFileParameters, *streamParameters.transcodeParameters, streamParameters.estimateContentLength); - else - resourceHandler = createFileResourceHandler(streamParameters.inputFileParameters.trackPath); - } - else - { - resourceHandler = Wt::cpp17::any_cast>(continuation->data()); - } - - continuation = resourceHandler->processRequest(request, response); - if (continuation) - continuation->setData(resourceHandler); - } - catch (const Av::Exception& e) - { - LMS_LOG(API_SUBSONIC, ERROR) << "Caught Av exception: " << e.what(); - } -} - -} // namespace API::Subsonic::Stream diff --git a/src/libs/subsonic/impl/SubsonicId.cpp b/src/libs/subsonic/impl/SubsonicId.cpp index 1252a7b8..ddd3090f 100644 --- a/src/libs/subsonic/impl/SubsonicId.cpp +++ b/src/libs/subsonic/impl/SubsonicId.cpp @@ -26,114 +26,104 @@ namespace API::Subsonic { - std::string - idToString(Database::ArtistId id) - { - return "ar-" + id.toString(); - } + std::string idToString(Database::ArtistId id) + { + return "ar-" + id.toString(); + } - std::string - idToString(Database::ReleaseId id) - { - return "al-" + id.toString(); - } + std::string idToString(Database::ReleaseId id) + { + return "al-" + id.toString(); + } - std::string - idToString(RootId) - { - return "root"; - } + std::string idToString(RootId) + { + return "root"; + } - std::string - idToString(Database::TrackId id) - { - return "tr-" + id.toString(); - } + std::string idToString(Database::TrackId id) + { + return "tr-" + id.toString(); + } - std::string - idToString(Database::TrackListId id) - { - return "pl-" + id.toString(); - } + std::string idToString(Database::TrackListId id) + { + return "pl-" + id.toString(); + } } // namespace API::Subsonic namespace StringUtils { - template<> - std::optional - readAs(std::string_view str) - { - std::vector values {StringUtils::splitString(str, "-")}; - if (values.size() != 2) - return std::nullopt; + template<> + std::optional readAs(std::string_view str) + { + std::vector values{ StringUtils::splitString(str, "-") }; + if (values.size() != 2) + return std::nullopt; - if (values[0] != "ar") - return std::nullopt; + if (values[0] != "ar") + return std::nullopt; - if (const auto value {StringUtils::readAs(values[1])}) - return Database::ArtistId {*value}; + if (const auto value{ StringUtils::readAs(values[1]) }) + return Database::ArtistId{ *value }; - return std::nullopt; - } + return std::nullopt; + } - template<> - std::optional - readAs(std::string_view str) - { - std::vector values {StringUtils::splitString(str, "-")}; - if (values.size() != 2) - return std::nullopt; + template<> + std::optional readAs(std::string_view str) + { + std::vector values{ StringUtils::splitString(str, "-") }; + if (values.size() != 2) + return std::nullopt; - if (values[0] != "al") - return std::nullopt; + if (values[0] != "al") + return std::nullopt; - if (const auto value {StringUtils::readAs(values[1])}) - return Database::ReleaseId {*value}; + if (const auto value{ StringUtils::readAs(values[1]) }) + return Database::ReleaseId{ *value }; - return std::nullopt; - } + return std::nullopt; + } - template<> - std::optional - readAs(std::string_view str) - { - if (str == "root") - return API::Subsonic::RootId {}; + template<> + std::optional readAs(std::string_view str) + { + if (str == "root") + return API::Subsonic::RootId{}; - return std::nullopt; - } + return std::nullopt; + } - template<> - std::optional - readAs(std::string_view str) - { - std::vector values {StringUtils::splitString(str, "-")}; - if (values.size() != 2) - return std::nullopt; + template<> + std::optional readAs(std::string_view str) + { + std::vector values{ StringUtils::splitString(str, "-") }; + if (values.size() != 2) + return std::nullopt; - if (values[0] != "tr") - return std::nullopt; + if (values[0] != "tr") + return std::nullopt; - if (const auto value {StringUtils::readAs(values[1])}) - return Database::TrackId {*value}; + if (const auto value{ StringUtils::readAs(values[1]) }) + return Database::TrackId{ *value }; - return std::nullopt; - } + return std::nullopt; + } - template<> - std::optional - readAs(std::string_view str) - { - std::vector values {StringUtils::splitString(str, "-")}; - if (values.size() != 2) - return std::nullopt; + template<> + std::optional readAs(std::string_view str) + { + std::vector values{ StringUtils::splitString(str, "-") }; + if (values.size() != 2) + return std::nullopt; - if (values[0] != "pl") - return std::nullopt; + if (values[0] != "pl") + return std::nullopt; - if (const auto value {StringUtils::readAs(values[1])}) - return Database::TrackListId {*value}; + if (const auto value{ StringUtils::readAs(values[1]) }) + return Database::TrackListId{ *value }; - return std::nullopt; - } + return std::nullopt; + } } diff --git a/src/libs/subsonic/impl/SubsonicId.hpp b/src/libs/subsonic/impl/SubsonicId.hpp index 96db3e79..40b831bf 100644 --- a/src/libs/subsonic/impl/SubsonicId.hpp +++ b/src/libs/subsonic/impl/SubsonicId.hpp @@ -27,36 +27,31 @@ namespace API::Subsonic { - struct RootId {}; + struct RootId {}; - std::string idToString(Database::ArtistId id); - std::string idToString(Database::ReleaseId id); - std::string idToString(Database::TrackId id); - std::string idToString(Database::TrackListId id); - std::string idToString(RootId); + std::string idToString(Database::ArtistId id); + std::string idToString(Database::ReleaseId id); + std::string idToString(Database::TrackId id); + std::string idToString(Database::TrackListId id); + std::string idToString(RootId); } // namespace API::Subsonic // Used to parse parameters namespace 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); - 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); - 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 79f4f7c4..fe432a81 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -20,2152 +20,382 @@ #include "SubsonicResource.hpp" #include -#include -#include -#include #include -#include - #include "services/auth/IPasswordService.hpp" #include "services/auth/IEnvService.hpp" -#include "services/database/Artist.hpp" -#include "services/database/Cluster.hpp" #include "services/database/Db.hpp" -#include "services/database/Release.hpp" #include "services/database/Session.hpp" -#include "services/database/Track.hpp" -#include "services/database/TrackBookmark.hpp" -#include "services/database/TrackList.hpp" #include "services/database/User.hpp" -#include "services/recommendation/IRecommendationService.hpp" -#include "services/scrobbling/IScrobblingService.hpp" -#include "services/cover/ICoverService.hpp" +#include "utils/EnumSet.hpp" #include "utils/IConfig.hpp" #include "utils/Logger.hpp" -#include "utils/Random.hpp" #include "utils/Service.hpp" #include "utils/String.hpp" #include "utils/Utils.hpp" + +#include "entrypoints/AlbumSongLists.hpp" +#include "entrypoints/Browsing.hpp" +#include "entrypoints/Bookmarks.hpp" +#include "entrypoints/MediaAnnotation.hpp" +#include "entrypoints/MediaLibraryScanning.hpp" +#include "entrypoints/MediaRetrieval.hpp" +#include "entrypoints/Playlists.hpp" +#include "entrypoints/Searching.hpp" +#include "entrypoints/System.hpp" +#include "entrypoints/UserManagement.hpp" #include "ParameterParsing.hpp" #include "ProtocolVersion.hpp" #include "RequestContext.hpp" -#include "Scan.hpp" -#include "Stream.hpp" #include "SubsonicId.hpp" #include "SubsonicResponse.hpp" +#include "Utils.hpp" using namespace Database; -static const std::string genreClusterName {"GENRE"}; -static const std::string reportedStarredDate {"2000-01-01T00:00:00"}; -static const std::string reportedDummyDate {"2000-01-01T00:00:00"}; -static const unsigned long long reportedDummyDateULong {946684800000ULL}; // 2000-01-01T00:00:00 UTC - - - - namespace API::Subsonic { - -std::unique_ptr -createSubsonicResource(Database::Db& db) -{ - return std::make_unique(db); -} - -static -void -checkSetPasswordImplemented() -{ - Auth::IPasswordService* passwordService {Service::get()}; - if (!passwordService || !passwordService->canSetPasswords()) - throw NotImplementedGenericError {}; -} - -static -std::string -makeNameFilesystemCompatible(const std::string& name) -{ - return StringUtils::replaceInString(name, "/", "_"); -} - -static -std::string -decodePasswordIfNeeded(const std::string& password) -{ - if (password.find("enc:") == 0) - { - auto decodedPassword {StringUtils::stringFromHex(password.substr(4))}; - if (!decodedPassword) - return password; // fallback on plain password - - return *decodedPassword; - } - - return password; -} - -static -std::unordered_map -readConfigProtocolVersions() -{ - std::unordered_map res; - - Service::get()->visitStrings("api-subsonic-report-old-server-protocol", - [&](std::string_view client) - { - res.emplace(std::string {client}, ProtocolVersion {1, 12, 0}); - }, {"DSub"}); - - return res; -} - -SubsonicResource::SubsonicResource(Db& db) -: _serverProtocolVersionsByClient {readConfigProtocolVersions()} -, _db {db} -{ -} - -static -std::string parameterMapToDebugString(const Wt::Http::ParameterMap& parameterMap) -{ - auto censorValue = [](const std::string& type, const std::string& value) -> std::string - { - if (type == "p" || type == "password") - return "*REDACTED*"; - else - return value; - }; - - std::string res; - - for (const auto& params : parameterMap) - { - res += "{" + params.first + "="; - if (params.second.size() == 1) - { - res += censorValue(params.first, params.second.front()); - } - else - { - res += "{"; - for (const std::string& param : params.second) - res += censorValue(params.first, param) + ","; - res += "}"; - } - res += "}, "; - } - - return res; -} - -static -void -checkUserIsMySelfOrAdmin(RequestContext& context, const std::string& username) -{ - User::pointer currentUser {User::find(context.dbSession, context.userId)}; - if (!currentUser) - throw RequestedDataNotFoundError {}; - - if (currentUser->getLoginName() != username && !currentUser->isAdmin()) - throw UserNotAuthorizedError {}; -} - -static -void -checkUserTypeIsAllowed(RequestContext& context, EnumSet allowedUserTypes) -{ - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer currentUser {User::find(context.dbSession, context.userId)}; - if (!currentUser) - throw RequestedDataNotFoundError {}; - - if (!allowedUserTypes.contains(currentUser->getType())) - throw UserNotAuthorizedError {}; -} - -static -std::string -getArtistNames(const std::vector& artists) -{ - if (artists.size() == 1) - return artists.front()->getName(); - - std::vector names; - names.resize(artists.size()); - - std::transform(std::cbegin(artists), std::cend(artists), std::begin(names), - [](const Artist::pointer& artist) - { - return artist->getName(); - }); - - return StringUtils::joinStrings(names, ", "); -} - -static -std::string -getTrackPath(const Track::pointer& track) -{ - std::string path; - - // The track path has to be relative from the root - - const auto release {track->getRelease()}; - if (release) - { - auto artists {release->getReleaseArtists()}; - if (artists.empty()) - artists = release->getArtists(); - - if (artists.size() > 1) - path = "Various Artists/"; - else if (artists.size() == 1) - path = makeNameFilesystemCompatible(artists.front()->getName()) + "/"; - - path += makeNameFilesystemCompatible(track->getRelease()->getName()) + "/"; - } - - if (track->getDiscNumber()) - path += std::to_string(*track->getDiscNumber()) + "-"; - if (track->getTrackNumber()) - path += std::to_string(*track->getTrackNumber()) + "-"; - - path += makeNameFilesystemCompatible(track->getName()); - - if (track->getPath().has_extension()) - path += track->getPath().extension(); - - return path; -} - -static -std::string_view -formatToSuffix(AudioFormat format) -{ - switch (format) - { - case AudioFormat::MP3: return "mp3"; - case AudioFormat::OGG_OPUS: return "opus"; - case AudioFormat::MATROSKA_OPUS: return "mka"; - case AudioFormat::OGG_VORBIS: return "ogg"; - case AudioFormat::WEBM_VORBIS: return "webm"; - } - - return ""; -} - -static -std::string -dateTimeToCreatedString(const Wt::WDateTime& dateTime) -{ - const std::time_t t {dateTime.toTime_t()}; - std::tm gmTime; - std::ostringstream oss; - oss << std::put_time(::gmtime_r(&t, &gmTime), "%FT%T"); - return oss.str(); -} - -static -Response::Node -trackToResponseNode(const Track::pointer& track, Session& dbSession, const User::pointer& user) -{ - Response::Node trackResponse; - - trackResponse.setAttribute("id", idToString(track->getId())); - trackResponse.setAttribute("isDir", false); - trackResponse.setAttribute("title", track->getName()); - if (track->getTrackNumber()) - trackResponse.setAttribute("track", *track->getTrackNumber()); - if (track->getDiscNumber()) - trackResponse.setAttribute("discNumber", *track->getDiscNumber()); - if (track->getYear()) - trackResponse.setAttribute("year", *track->getYear()); - - trackResponse.setAttribute("path", getTrackPath(track)); - { - std::error_code ec; - const auto fileSize {std::filesystem::file_size(track->getPath(), ec)}; - if (!ec) - trackResponse.setAttribute("size", fileSize); - } - - if (track->getPath().has_extension()) - { - auto extension {track->getPath().extension()}; - trackResponse.setAttribute("suffix", extension.string().substr(1)); - } - - if (user->getSubsonicTranscodeEnable()) - trackResponse.setAttribute("transcodedSuffix", formatToSuffix(user->getSubsonicTranscodeFormat())); - - trackResponse.setAttribute("coverArt", idToString(track->getId())); - - const std::vector& artists {track->getArtists({TrackArtistLinkType::Artist})}; - if (!artists.empty()) - { - trackResponse.setAttribute("artist", getArtistNames(artists)); - - if (artists.size() == 1) - trackResponse.setAttribute("artistId", idToString(artists.front()->getId())); - } - - if (track->getRelease()) - { - trackResponse.setAttribute("album", track->getRelease()->getName()); - trackResponse.setAttribute("albumId", idToString(track->getRelease()->getId())); - trackResponse.setAttribute("parent", idToString(track->getRelease()->getId())); - } - - trackResponse.setAttribute("duration", std::chrono::duration_cast(track->getDuration()).count()); - trackResponse.setAttribute("type", "music"); - trackResponse.setAttribute("created", dateTimeToCreatedString(track->getLastWritten())); - - if (Service::get()->isStarred(user->getId(), track->getId())) - trackResponse.setAttribute("starred", reportedStarredDate); - - // Report the first GENRE for this track - ClusterType::pointer clusterType {ClusterType::find(dbSession, genreClusterName)}; - if (clusterType) - { - auto clusters {track->getClusterGroups({clusterType}, 1)}; - if (!clusters.empty() && !clusters.front().empty()) - trackResponse.setAttribute("genre", clusters.front().front()->getName()); - } - - return trackResponse; -} - -static -Response::Node -trackBookmarkToResponseNode(const TrackBookmark::pointer& trackBookmark) -{ - Response::Node trackBookmarkNode; - - trackBookmarkNode.setAttribute("position", trackBookmark->getOffset().count()); - if (!trackBookmark->getComment().empty()) - trackBookmarkNode.setAttribute("comment", trackBookmark->getComment()); - trackBookmarkNode.setAttribute("created", reportedDummyDate); - trackBookmarkNode.setAttribute("changed", reportedDummyDate); - trackBookmarkNode.setAttribute("username", trackBookmark->getUser()->getLoginName()); - - return trackBookmarkNode; -} - -static -Response::Node -releaseToResponseNode(const Release::pointer& release, Session& dbSession, const User::pointer& user, bool id3) -{ - Response::Node albumNode; - - if (id3) - { - albumNode.setAttribute("name", release->getName()); - albumNode.setAttribute("songCount", release->getTracksCount()); - albumNode.setAttribute("duration", std::chrono::duration_cast(release->getDuration()).count()); - } - else - { - albumNode.setAttribute("title", release->getName()); - albumNode.setAttribute("isDir", true); - } - - albumNode.setAttribute("created", dateTimeToCreatedString(release->getLastWritten())); - albumNode.setAttribute("id", idToString(release->getId())); - albumNode.setAttribute("coverArt", idToString(release->getId())); - auto releaseYear {release->getReleaseYear()}; - if (releaseYear) - albumNode.setAttribute("year", *releaseYear); - - auto artists {release->getReleaseArtists()}; - if (artists.empty()) - artists = release->getArtists(); - - if (artists.empty() && !id3) - { - albumNode.setAttribute("parent", idToString(RootId {})); - } - else if (!artists.empty()) - { - albumNode.setAttribute("artist", getArtistNames(artists)); - - if (artists.size() == 1) - { - albumNode.setAttribute(id3 ? "artistId" : "parent", idToString(artists.front()->getId())); - } - else - { - if (!id3) - albumNode.setAttribute("parent", idToString(RootId {})); - } - } - - if (id3) - { - // Report the first GENRE for this track - ClusterType::pointer clusterType {ClusterType::find(dbSession, genreClusterName)}; - if (clusterType) - { - auto clusters {release->getClusterGroups({clusterType}, 1)}; - if (!clusters.empty() && !clusters.front().empty()) - albumNode.setAttribute("genre", clusters.front().front()->getName()); - } - } - - if (Service::get()->isStarred(user->getId(), release->getId())) - albumNode.setAttribute("starred", reportedStarredDate); - - return albumNode; -} - -static -Response::Node -artistToResponseNode(const Artist::pointer& artist, Session& session, const User::pointer& user, bool id3) -{ - Response::Node artistNode; - - artistNode.setAttribute("id", idToString(artist->getId())); - artistNode.setAttribute("name", artist->getName()); - - if (id3) - { - const auto releases {Release::find(session, Release::FindParameters {}.setArtist(artist->getId()))}; - artistNode.setAttribute("albumCount", releases.results.size()); - } - - if (Service::get()->isStarred(user->getId(), artist->getId())) - artistNode.setAttribute("starred", reportedStarredDate); - - return artistNode; -} - -static -Response::Node -clusterToResponseNode(const Cluster::pointer& cluster) -{ - Response::Node clusterNode; - - clusterNode.setValue(cluster->getName()); - clusterNode.setAttribute("songCount", cluster->getTracksCount()); - clusterNode.setAttribute("albumCount", cluster->getReleasesCount()); - - return clusterNode; -} - -static -Response::Node -userToResponseNode(const User::pointer& user) -{ - Response::Node userNode; - - 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); - userNode.setAttribute("streamRole", true); - userNode.setAttribute("jukeboxRole", false); - userNode.setAttribute("shareRole", false); - - Response::Node folder; - folder.setValue("0"); - userNode.addArrayChild("folder", std::move(folder)); - - return userNode; -} - -static -Response -handlePingRequest(RequestContext& context) -{ - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleChangePassword(RequestContext& context) -{ - std::string username {getMandatoryParameterAs(context.parameters, "username")}; - std::string password {decodePasswordIfNeeded(getMandatoryParameterAs(context.parameters, "password"))}; - - try - { - Database::UserId userId; - { - auto transaction {context.dbSession.createSharedTransaction()}; - - checkUserIsMySelfOrAdmin(context, username); - - User::pointer user {User::find(context.dbSession, username)}; - if (!user) - throw UserNotAuthorizedError {}; - - userId = user->getId(); - } - - Service::get()->setPassword(userId, password); - } - catch (const Auth::PasswordMustMatchLoginNameException&) - { - throw PasswordMustMatchLoginNameGenericError {}; - } - catch (const Auth::PasswordTooWeakException&) - { - throw PasswordTooWeakGenericError {}; - } - catch (const Auth::Exception& authException) - { - throw UserNotAuthorizedError {}; - } - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleCreatePlaylistRequest(RequestContext& context) -{ - // Optional params - const auto id {getParameterAs(context.parameters, "playlistId")}; - auto name {getParameterAs(context.parameters, "name")}; - - std::vector trackIds {getMultiParametersAs(context.parameters, "songId")}; - - if (!name && !id) - throw RequiredParameterMissingError {"name or id"}; - - auto transaction {context.dbSession.createUniqueTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - TrackList::pointer tracklist; - if (id) - { - tracklist = TrackList::find(context.dbSession, *id); - if (!tracklist - || tracklist->getUser() != user - || tracklist->getType() != TrackListType::Playlist) - { - throw RequestedDataNotFoundError {}; - } - - if (name) - tracklist.modify()->setName(*name); - } - else - { - tracklist = context.dbSession.create(*name, TrackListType::Playlist, false, user); - } - - for (const TrackId trackId : trackIds) - { - Track::pointer track {Track::find(context.dbSession, trackId)}; - if (!track) - continue; - - context.dbSession.create(track, tracklist); - } - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleCreateUserRequest(RequestContext& context) -{ - std::string username {getMandatoryParameterAs(context.parameters, "username")}; - std::string password {decodePasswordIfNeeded(getMandatoryParameterAs(context.parameters, "password"))}; - // Just ignore all the other fields as we don't handle them - - Database::UserId userId; - { - auto transaction {context.dbSession.createUniqueTransaction()}; - - User::pointer user {User::find(context.dbSession, username)}; - if (user) - throw UserAlreadyExistsGenericError {}; - - user = context.dbSession.create(username); - userId = user->getId(); - } - - auto removeCreatedUser {[&]() - { - auto transaction {context.dbSession.createUniqueTransaction()}; - User::pointer user {User::find(context.dbSession, userId)}; - if (user) - user.remove(); - }}; - - try - { - Service::get()->setPassword(userId, password); - } - catch (const Auth::PasswordMustMatchLoginNameException&) - { - removeCreatedUser(); - throw PasswordMustMatchLoginNameGenericError {}; - } - catch (const Auth::PasswordTooWeakException&) - { - removeCreatedUser(); - throw PasswordTooWeakGenericError {}; - } - catch (const Auth::Exception& exception) - { - removeCreatedUser(); - throw UserNotAuthorizedError {}; - } - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleDeletePlaylistRequest(RequestContext& context) -{ - TrackListId id {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createUniqueTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - TrackList::pointer tracklist {TrackList::find(context.dbSession, id)}; - if (!tracklist - || tracklist->getUser() != user - || tracklist->getType() != TrackListType::Playlist) - { - throw RequestedDataNotFoundError {}; - } - - tracklist.remove(); - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleDeleteUserRequest(RequestContext& context) -{ - std::string username {getMandatoryParameterAs(context.parameters, "username")}; - - auto transaction {context.dbSession.createUniqueTransaction()}; - - User::pointer user {User::find(context.dbSession, username)}; - if (!user) - throw RequestedDataNotFoundError {}; - - // cannot delete ourself - if (user->getId() == context.userId) - throw UserNotAuthorizedError {}; - - user.remove(); - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleGetLicenseRequest(RequestContext& context) -{ - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - - Response::Node& licenseNode {response.createNode("license")}; - licenseNode.setAttribute("licenseExpires", "2025-09-03T14:46:43"); - licenseNode.setAttribute("email", "foo@bar.com"); - licenseNode.setAttribute("valid", true); - - return response; -} - -static -Response -handleGetRandomSongsRequest(RequestContext& context) -{ - // Optional params - std::size_t size {getParameterAs(context.parameters, "size").value_or(50)}; - size = std::min(size, std::size_t {500}); - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - const auto trackIds {Track::find(context.dbSession, Track::FindParameters {}.setSortMethod(TrackSortMethod::Random).setRange({0, size}))}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - - Response::Node& randomSongsNode {response.createNode("randomSongs")}; - for (const TrackId trackId : trackIds.results) - { - const Track::pointer track {Track::find(context.dbSession, trackId)}; - randomSongsNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); - } - - return response; -} - -static -Response -handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) -{ - // Mandatory params - const std::string type {getMandatoryParameterAs(context.parameters, "type")}; - - // Optional params - const std::size_t size {getParameterAs(context.parameters, "size").value_or(10)}; - const std::size_t offset {getParameterAs(context.parameters, "offset").value_or(0)}; - - const Range range {offset, size}; - - RangeResults releases; - Scrobbling::IScrobblingService& scrobbling {*Service::get()}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - if (type == "alphabeticalByName") - { - Release::FindParameters params; - params.setSortMethod(ReleaseSortMethod::Name); - params.setRange(range); - - releases = Release::find(context.dbSession, params); - } - else if (type == "alphabeticalByArtist") - { - releases = Release::findOrderedByArtist(context.dbSession, range); - } - else if (type == "byGenre") - { - // Mandatory param - const std::string genre {getMandatoryParameterAs(context.parameters, "genre")}; - - if (const ClusterType::pointer clusterType {ClusterType::find(context.dbSession, genreClusterName)}) - { - if (const Cluster::pointer cluster {clusterType->getCluster(genre)}) - { - Release::FindParameters params; - params.setClusters({cluster->getId()}); - params.setSortMethod(ReleaseSortMethod::Name); - params.setRange(range); - - releases = Release::find(context.dbSession, params); - } - } - } - else if (type == "byYear") - { - const int fromYear {getMandatoryParameterAs(context.parameters, "fromYear")}; - const int toYear {getMandatoryParameterAs(context.parameters, "toYear")}; - - Release::FindParameters params; - params.setSortMethod(ReleaseSortMethod::Date); - params.setRange(range); - params.setDateRange(DateRange::fromYearRange(fromYear, toYear)); - - releases = Release::find(context.dbSession, params); - } - else if (type == "frequent") - { - releases = scrobbling.getTopReleases(context.userId, {}, range); - } - else if (type == "newest") - { - Release::FindParameters params; - params.setSortMethod(ReleaseSortMethod::LastWritten); - params.setRange(range); - - releases = Release::find(context.dbSession, params); - } - else if (type == "random") - { - // Random results are paginated, but there is no acceptable way to handle the pagination params without repeating some albums - // (no seed provided by subsonic, ot it would require to store some kind of context for each user/client when iterating over the random albums) - Release::FindParameters params; - params.setSortMethod(ReleaseSortMethod::Random); - params.setRange({0, size}); - - releases = Release::find(context.dbSession, params); - } - else if (type == "recent") - { - releases = scrobbling.getRecentReleases(context.userId, {}, range); - } - else if (type == "starred") - { - releases = scrobbling.getStarredReleases(context.userId, {}, range); - } - else - throw NotImplementedGenericError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& albumListNode {response.createNode(id3 ? "albumList2" : "albumList")}; - - for (const ReleaseId releaseId : releases.results) - { - const Release::pointer release {Release::find(context.dbSession, releaseId)}; - albumListNode.addArrayChild("album", releaseToResponseNode(release, context.dbSession, user, id3)); - } - - return response; -} - -static -Response -handleGetAlbumListRequest(RequestContext& context) -{ - return handleGetAlbumListRequestCommon(context, false /* no id3 */); -} - -static -Response -handleGetAlbumList2Request(RequestContext& context) -{ - return handleGetAlbumListRequestCommon(context, true /* id3 */); -} - -static -Response -handleGetAlbumRequest(RequestContext& context) -{ - // Mandatory params - ReleaseId id {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - Release::pointer release {Release::find(context.dbSession, id)}; - if (!release) - throw RequestedDataNotFoundError {}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node releaseNode {releaseToResponseNode(release, context.dbSession, user, true /* id3 */)}; - - const auto tracks {Track::find(context.dbSession, Track::FindParameters {}.setRelease(id).setSortMethod(TrackSortMethod::Release))}; - for (const TrackId trackId : tracks.results) - { - const Track::pointer track {Track::find(context.dbSession, trackId)}; - releaseNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); - } - - response.addNode("album", std::move(releaseNode)); - - return response; -} - -static -Response -handleGetSongRequest(RequestContext& context) -{ - // Mandatory params - TrackId id {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - const Track::pointer track {Track::find(context.dbSession, id)}; - if (!track) - throw RequestedDataNotFoundError {}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - response.addNode("song", trackToResponseNode(track, context.dbSession, user)); - - return response; -} - -static -Response -handleGetArtistRequest(RequestContext& context) -{ - // Mandatory params - ArtistId id {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - const Artist::pointer artist {Artist::find(context.dbSession, id)}; - if (!artist) - throw RequestedDataNotFoundError {}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node artistNode {artistToResponseNode(artist, context.dbSession, user, true /* id3 */)}; - - const auto releases {Release::find(context.dbSession, Release::FindParameters {}.setArtist(artist->getId()))}; - for (const ReleaseId releaseId : releases.results) - { - const Release::pointer release {Release::find(context.dbSession, releaseId)}; - artistNode.addArrayChild("album", releaseToResponseNode(release, context.dbSession, user, true /* id3 */)); - } - - response.addNode("artist", std::move(artistNode)); - - return response; -} - -static -Response -handleGetArtistInfoRequestCommon(RequestContext& context, bool id3) -{ - // Mandatory params - ArtistId id {getMandatoryParameterAs(context.parameters, "id")}; - - // Optional params - std::size_t count {getParameterAs(context.parameters, "count").value_or(20)}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& artistInfoNode {response.createNode(id3 ? "artistInfo2" : "artistInfo")}; - - { - auto transaction {context.dbSession.createSharedTransaction()}; - - const Artist::pointer artist {Artist::find(context.dbSession, id)}; - if (!artist) - throw RequestedDataNotFoundError {}; - - std::optional artistMBID {artist->getMBID()}; - if (artistMBID) - artistInfoNode.createChild("musicBrainzId").setValue(artistMBID->getAsString()); - } - - auto similarArtistsId {Service::get()->getSimilarArtists(id, {TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}, count)}; - - { - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - for (const ArtistId similarArtistId : similarArtistsId) - { - const Artist::pointer similarArtist {Artist::find(context.dbSession, similarArtistId)}; - if (similarArtist) - artistInfoNode.addArrayChild("similarArtist", artistToResponseNode(similarArtist, context.dbSession, user, id3)); - } - } - - return response; -} - -static -Response -handleGetArtistInfoRequest(RequestContext& context) -{ - return handleGetArtistInfoRequestCommon(context, false /* no id3 */); -} - -static -Response -handleGetArtistInfo2Request(RequestContext& context) -{ - return handleGetArtistInfoRequestCommon(context, true /* id3 */); -} - -static -Response -handleGetMusicDirectoryRequest(RequestContext& context) -{ - // Mandatory params - const auto artistId {getParameterAs(context.parameters, "id")}; - const auto releaseId {getParameterAs(context.parameters, "id")}; - const auto root {getParameterAs(context.parameters, "id")}; - - if (!root && !artistId && !releaseId) - throw BadParameterGenericError {"id"}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& directoryNode {response.createNode("directory")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - if (root) - { - directoryNode.setAttribute("id", idToString(RootId {})); - directoryNode.setAttribute("name", "Music"); - - auto rootArtistIds {Artist::find(context.dbSession, Artist::FindParameters {}.setSortMethod(ArtistSortMethod::BySortName))}; - for (const ArtistId rootArtistId : rootArtistIds.results) - { - const Artist::pointer artist {Artist::find(context.dbSession, rootArtistId)}; - directoryNode.addArrayChild("child", artistToResponseNode(artist, context.dbSession, user, false /* no id3 */)); - } - } - else if (artistId) - { - directoryNode.setAttribute("id", idToString(*artistId)); - - auto artist {Artist::find(context.dbSession, *artistId)}; - if (!artist) - throw RequestedDataNotFoundError {}; - - directoryNode.setAttribute("name", makeNameFilesystemCompatible(artist->getName())); - - const auto artistReleases {Release::find(context.dbSession, Release::FindParameters {}.setArtist(*artistId))}; - for (const ReleaseId artistReleaseId : artistReleases.results) - { - const Release::pointer release {Release::find(context.dbSession, artistReleaseId)}; - directoryNode.addArrayChild("child", releaseToResponseNode(release, context.dbSession, user, false /* no id3 */)); - } - } - else if (releaseId) - { - directoryNode.setAttribute("id", idToString(*releaseId)); - - auto release {Release::find(context.dbSession, *releaseId)}; - if (!release) - throw RequestedDataNotFoundError {}; - - directoryNode.setAttribute("name", makeNameFilesystemCompatible(release->getName())); - - const auto tracks {Track::find(context.dbSession, Track::FindParameters {}.setRelease(*releaseId).setSortMethod(TrackSortMethod::Release))}; - for (const TrackId trackId : tracks.results) - { - const Track::pointer track {Track::find(context.dbSession, trackId)}; - directoryNode.addArrayChild("child", trackToResponseNode(track, context.dbSession, user)); - } - } - else - throw BadParameterGenericError {"id"}; - - return response; -} - -static -Response -handleGetMusicFoldersRequest(RequestContext& context) -{ - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& musicFoldersNode {response.createNode("musicFolders")}; - - Response::Node& musicFolderNode {musicFoldersNode.createArrayChild("musicFolder")}; - musicFolderNode.setAttribute("id", "0"); - musicFolderNode.setAttribute("name", "Music"); - - return response; -} - -static -Response -handleGetArtistsRequestCommon(RequestContext& context, bool id3) -{ - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - - Response::Node& artistsNode {response.createNode(id3 ? "artists" : "indexes")}; - artistsNode.setAttribute("ignoredArticles", ""); - artistsNode.setAttribute("lastModified", reportedDummyDateULong); - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Artist::FindParameters parameters; - parameters.setSortMethod(ArtistSortMethod::BySortName); - switch (user->getSubsonicArtistListMode()) - { - case SubsonicArtistListMode::AllArtists: - break; - case SubsonicArtistListMode::ReleaseArtists: - parameters.setLinkType(TrackArtistLinkType::ReleaseArtist); - break; - case SubsonicArtistListMode::TrackArtists: - parameters.setLinkType(TrackArtistLinkType::Artist); - break; - } - - std::map> artistsSortedByFirstChar; - const RangeResults artists {Artist::find(context.dbSession, parameters)}; - for (const ArtistId artistId : artists.results) - { - const Artist::pointer artist {Artist::find(context.dbSession, artistId)}; - const std::string& sortName {artist->getSortName()}; - - char sortChar; - if (sortName.empty() || !std::isalpha(sortName[0])) - sortChar = '?'; - else - sortChar = std::toupper(sortName[0]); - - artistsSortedByFirstChar[sortChar].push_back(artist); - } - - - for (const auto& [sortChar, artists] : artistsSortedByFirstChar) - { - Response::Node& indexNode {artistsNode.createArrayChild("index")}; - indexNode.setAttribute("name", std::string {sortChar}); - - for (const Artist::pointer& artist :artists) - indexNode.addArrayChild("artist", artistToResponseNode(artist, context.dbSession, user, id3)); - } - - return response; -} - -static -Response -handleGetIndexesRequest(RequestContext& context) -{ - return handleGetArtistsRequestCommon(context, false /* no id3 */); -} - -static -Response -handleGetGenresRequest(RequestContext& context) -{ - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - - Response::Node& genresNode {response.createNode("genres")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - const ClusterType::pointer clusterType {ClusterType::find(context.dbSession, genreClusterName)}; - if (clusterType) - { - const auto clusters {clusterType->getClusters()}; - - for (const Cluster::pointer& cluster : clusters) - genresNode.addArrayChild("genre", clusterToResponseNode(cluster)); - } - - return response; -} - -static -Response -handleGetArtistsRequest(RequestContext& context) -{ - return handleGetArtistsRequestCommon(context, true /* id3 */); -} - -static -std::vector -findSimilarSongs(RequestContext& context, ArtistId artistId, std::size_t count) -{ - // API says: "Returns a random collection of songs from the given artist and similar artists" - const std::size_t similarArtistCount {count / 5}; - std::vector artistIds {Service::get()->getSimilarArtists(artistId, {TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}, similarArtistCount)}; - artistIds.push_back(artistId); - - const std::size_t meanTrackCountPerArtist {(count / artistIds.size()) + 1}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - std::vector tracks; - tracks.reserve(count); - - for (const ArtistId id : artistIds) - { - Track::FindParameters params; - params.setArtist(id); - params.setRange({0, meanTrackCountPerArtist}); - params.setSortMethod(TrackSortMethod::Random); - - const auto artistTracks {Track::find(context.dbSession, params)}; - tracks.insert(std::end(tracks), - std::begin(artistTracks.results), - std::end(artistTracks.results)); - } - - return tracks; -} - -static -std::vector -findSimilarSongs(RequestContext& context, ReleaseId releaseId, std::size_t count) -{ - // API says: "Returns a random collection of songs from the given artist and similar artists" - // so let's extend this for release - const std::size_t similarReleaseCount {count / 5}; - std::vector releaseIds {Service::get()->getSimilarReleases(releaseId, similarReleaseCount)}; - releaseIds.push_back(releaseId); - - const std::size_t meanTrackCountPerRelease {(count / releaseIds.size()) + 1}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - std::vector tracks; - tracks.reserve(count); - - for (const ReleaseId id : releaseIds) - { - Track::FindParameters params; - params.setRelease(id); - params.setRange({0, meanTrackCountPerRelease}); - params.setSortMethod(TrackSortMethod::Random); - - const auto releaseTracks {Track::find(context.dbSession, params)}; - tracks.insert(std::end(tracks), - std::begin(releaseTracks.results), - std::end(releaseTracks.results)); - } - - return tracks; -} - -static -std::vector -findSimilarSongs(RequestContext&, TrackId trackId, std::size_t count) -{ - return Service::get()->findSimilarTracks({trackId}, count); -} - -static -Response -handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) -{ - // Optional params - std::size_t count {getParameterAs(context.parameters, "count").value_or(50)}; - - std::vector tracks; - - if (const auto artistId {getParameterAs(context.parameters, "id")}) - tracks = findSimilarSongs(context, *artistId, count); - else if (const auto releaseId {getParameterAs(context.parameters, "id")}) - tracks = findSimilarSongs(context, *releaseId, count); - else if (const auto trackId {getParameterAs(context.parameters, "id")}) - tracks = findSimilarSongs(context, *trackId, count); - else - throw BadParameterGenericError {"id"}; - - Random::shuffleContainer(tracks); - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& similarSongsNode {response.createNode(id3 ? "similarSongs2" : "similarSongs")}; - for (const TrackId trackId : tracks) - { - const Track::pointer track {Track::find(context.dbSession, trackId)}; - similarSongsNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); - } - - return response; -} - -static -Response -handleGetSimilarSongsRequest(RequestContext& context) -{ - return handleGetSimilarSongsRequestCommon(context, false /* no id3 */); -} - -static -Response -handleGetSimilarSongs2Request(RequestContext& context) -{ - return handleGetSimilarSongsRequestCommon(context, true /* id3 */); -} - -static -Response -handleGetStarredRequestCommon(RequestContext& context, bool id3) -{ - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& starredNode {response.createNode(id3 ? "starred2" : "starred")}; - - Scrobbling::IScrobblingService& scrobbling {*Service::get()}; - - for (const ArtistId artistId : scrobbling.getStarredArtists(context.userId, {} /* clusters */, std::nullopt /* linkType */, ArtistSortMethod::BySortName, Range {}).results) - { - if (auto artist {Artist::find(context.dbSession, artistId)}) - starredNode.addArrayChild("artist", artistToResponseNode(artist, context.dbSession, user, id3)); - } - - for (const ReleaseId releaseId : scrobbling.getStarredReleases(context.userId, {} /* clusters */, Range {}).results) - { - if (auto release {Release::find(context.dbSession, releaseId)}) - starredNode.addArrayChild("album", releaseToResponseNode(release, context.dbSession, user, id3)); - } - - for (const TrackId trackId : scrobbling.getStarredTracks(context.userId, {} /* clusters */, Range {}).results) - { - if (auto track {Track::find(context.dbSession, trackId)}) - starredNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); - } - - return response; - -} - -static -Response -handleGetStarredRequest(RequestContext& context) -{ - return handleGetStarredRequestCommon(context, false /* no id3 */); -} - -static -Response -handleGetStarred2Request(RequestContext& context) -{ - return handleGetStarredRequestCommon(context, true /* id3 */); -} - -static -Response::Node -tracklistToResponseNode(const TrackList::pointer& tracklist, Session&) -{ - Response::Node playlistNode; - - playlistNode.setAttribute("id", idToString(tracklist->getId())); - playlistNode.setAttribute("name", tracklist->getName()); - playlistNode.setAttribute("songCount", tracklist->getCount()); - playlistNode.setAttribute("duration", std::chrono::duration_cast(tracklist->getDuration()).count()); - playlistNode.setAttribute("public", tracklist->isPublic()); - playlistNode.setAttribute("created", reportedDummyDate); - playlistNode.setAttribute("owner", tracklist->getUser()->getLoginName()); - - return playlistNode; -} - -static -Response -handleGetPlaylistRequest(RequestContext& context) -{ - // Mandatory params - TrackListId trackListId {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - TrackList::pointer tracklist {TrackList::find(context.dbSession, trackListId)}; - if (!tracklist) - throw RequestedDataNotFoundError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node playlistNode {tracklistToResponseNode(tracklist, context.dbSession)}; - - auto entries {tracklist->getEntries()}; - for (const TrackListEntry::pointer& entry : entries) - playlistNode.addArrayChild("entry", trackToResponseNode(entry->getTrack(), context.dbSession, user)); - - response.addNode("playlist", playlistNode ); - - return response; -} - -static -Response -handleGetPlaylistsRequest(RequestContext& context) -{ - auto transaction {context.dbSession.createSharedTransaction()}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& playlistsNode {response.createNode("playlists")}; - - TrackList::FindParameters params; - params.setUser(context.userId); - params.setType(TrackListType::Playlist); - - auto tracklistIds {TrackList::find(context.dbSession, params)}; - for (const TrackListId trackListId : tracklistIds.results) - { - const TrackList::pointer trackList {TrackList::find(context.dbSession, trackListId)}; - playlistsNode.addArrayChild("playlist", tracklistToResponseNode(trackList, context.dbSession)); - } - - return response; -} - -static -Response -handleGetSongsByGenreRequest(RequestContext& context) -{ - // Mandatory params - std::string genre {getMandatoryParameterAs(context.parameters, "genre")}; - - // Optional params - std::size_t size {getParameterAs(context.parameters, "count").value_or(10)}; - size = std::min(size, std::size_t {500}); - - std::size_t offset {getParameterAs(context.parameters, "offset").value_or(0)}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - auto clusterType {ClusterType::find(context.dbSession, genreClusterName)}; - if (!clusterType) - throw RequestedDataNotFoundError {}; - - auto cluster {clusterType->getCluster(genre)}; - if (!cluster) - throw RequestedDataNotFoundError {}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& songsByGenreNode {response.createNode("songsByGenre")}; - - Track::FindParameters params; - params.setClusters({cluster->getId()}); - params.setRange({offset, size}); - - auto trackIds {Track::find(context.dbSession, params)}; - for (const TrackId trackId : trackIds.results) - { - const Track::pointer track {Track::find(context.dbSession, trackId)}; - songsByGenreNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); - } - - return response; -} - -static -Response -handleGetUserRequest(RequestContext& context) -{ - std::string username {getMandatoryParameterAs(context.parameters, "username")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - checkUserIsMySelfOrAdmin(context, username); - - const User::pointer user {User::find(context.dbSession, username)}; - if (!user) - throw RequestedDataNotFoundError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - response.addNode("user", userToResponseNode(user)); - - return response; -} - -static -Response -handleGetUsersRequest(RequestContext& context) -{ - auto transaction {context.dbSession.createSharedTransaction()}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& usersNode {response.createNode("users")}; - - const auto userIds {User::find(context.dbSession, User::FindParameters {})}; - for (const UserId userId : userIds.results) - { - const User::pointer user {User::find(context.dbSession, userId)}; - usersNode.addArrayChild("user", userToResponseNode(user)); - } - - return response; -} - -static -Response -handleSearchRequestCommon(RequestContext& context, bool id3) -{ - // Mandatory params - std::string queryString {getMandatoryParameterAs(context.parameters, "query")}; - std::string_view query {queryString}; - - // Symfonium adds extra "" - if (context.clientInfo.name == "Symfonium") - query = StringUtils::stringTrim(query, "\""); - - std::vector keywords {StringUtils::splitString(query, " ")}; - - // Optional params - std::size_t artistCount {getParameterAs(context.parameters, "artistCount").value_or(20)}; - std::size_t artistOffset {getParameterAs(context.parameters, "artistOffset").value_or(0)}; - std::size_t albumCount {getParameterAs(context.parameters, "albumCount").value_or(20)}; - std::size_t albumOffset {getParameterAs(context.parameters, "albumOffset").value_or(0)}; - std::size_t songCount {getParameterAs(context.parameters, "songCount").value_or(20)}; - std::size_t songOffset {getParameterAs(context.parameters, "songOffset").value_or(0)}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& searchResult2Node {response.createNode(id3 ? "searchResult3" : "searchResult2")}; - - if (artistCount > 0) - { - Artist::FindParameters params; - params.setKeywords(keywords); - params.setSortMethod(ArtistSortMethod::BySortName); - params.setRange({artistOffset, artistCount}); - - RangeResults artistIds {Artist::find(context.dbSession, params)}; - for (const ArtistId artistId : artistIds.results) - { - const auto artist {Artist::find(context.dbSession, artistId)}; - searchResult2Node.addArrayChild("artist", artistToResponseNode(artist, context.dbSession, user, id3)); - } - } - - if (albumCount > 0) - { - Release::FindParameters params; - params.setKeywords(keywords); - params.setSortMethod(ReleaseSortMethod::Name); - params.setRange({albumOffset, albumCount}); - - RangeResults releaseIds {Release::find(context.dbSession, params)}; - for (const ReleaseId releaseId : releaseIds.results) - { - const auto release {Release::find(context.dbSession, releaseId)}; - searchResult2Node.addArrayChild("album", releaseToResponseNode(release, context.dbSession, user, id3)); - } - } - - if (songCount > 0) - { - Track::FindParameters params; - params.setKeywords(keywords); - params.setRange({songOffset, songCount}); - - RangeResults trackIds {Track::find(context.dbSession, params)}; - for (const TrackId trackId : trackIds.results) - { - const auto track {Track::find(context.dbSession, trackId)}; - searchResult2Node.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); - } - } - - return response; -} - -struct StarParameters -{ - std::vector artistIds; - std::vector releaseIds; - std::vector trackIds; -}; - -static -StarParameters -getStarParameters(const Wt::Http::ParameterMap& parameters) -{ - StarParameters res; - - // TODO handle parameters for legacy file browsing - res.trackIds = getMultiParametersAs(parameters, "id"); - res.artistIds = getMultiParametersAs(parameters, "artistId"); - res.releaseIds = getMultiParametersAs(parameters, "albumId"); - - return res; -} - -static -Response -handleStarRequest(RequestContext& context) -{ - StarParameters params {getStarParameters(context.parameters)}; - - for (const ArtistId id : params.artistIds) - Service::get()->star(context.userId, id); - - for (const ReleaseId id : params.releaseIds) - Service::get()->star(context.userId, id); - - for (const TrackId id : params.trackIds) - Service::get()->star(context.userId, id); - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleSearch2Request(RequestContext& context) -{ - return handleSearchRequestCommon(context, false /* no id3 */); -} - -static -Response -handleSearch3Request(RequestContext& context) -{ - return handleSearchRequestCommon(context, true /* id3 */); -} - -static -Response -handleUnstarRequest(RequestContext& context) -{ - StarParameters params {getStarParameters(context.parameters)}; - - for (const ArtistId id : params.artistIds) - Service::get()->unstar(context.userId, id); - - for (const ReleaseId id : params.releaseIds) - Service::get()->unstar(context.userId, id); - - for (const TrackId id : params.trackIds) - Service::get()->unstar(context.userId, id); - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleScrobble(RequestContext& context) -{ - const std::vector ids {getMandatoryMultiParametersAs(context.parameters, "id")}; - const std::vector times {getMultiParametersAs(context.parameters, "time")}; - const bool submission{getParameterAs(context.parameters, "submission").value_or(true)}; - - // playing now => no time to be provided - if (!submission && !times.empty()) - throw BadParameterGenericError {"time"}; - - // playing now => only one at a time - if (!submission && ids.size() > 1) - throw BadParameterGenericError {"id"}; - - // if multiple submissions, must have times - if (ids.size() > 1 && ids.size() != times.size()) - throw BadParameterGenericError {"time"}; - - if (!submission) - { - Service::get()->listenStarted({context.userId, ids.front()}); - } - else - { - if (times.empty()) - { - Service::get()->listenFinished({context.userId, ids.front()}); - } - else - { - for (std::size_t i {}; i < ids.size(); ++i) - { - const TrackId trackId {ids[i]}; - const unsigned long time {times[i]}; - Service::get()->addTimedListen({{context.userId, trackId}, Wt::WDateTime::fromTime_t(static_cast(time / 1000))}); - } - } - } - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleUpdateUserRequest(RequestContext& context) -{ - std::string username {getMandatoryParameterAs(context.parameters, "username")}; - std::optional password {getParameterAs(context.parameters, "password")}; - - UserId userId; - { - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, username)}; - if (!user) - throw RequestedDataNotFoundError {}; - - userId = user->getId(); - } - - if (password) - { - checkSetPasswordImplemented(); - - try - { - Service<::Auth::IPasswordService>()->setPassword(userId, decodePasswordIfNeeded(*password)); - } - catch (const Auth::PasswordMustMatchLoginNameException&) - { - throw PasswordMustMatchLoginNameGenericError {}; - } - catch (const Auth::PasswordTooWeakException&) - { - throw PasswordTooWeakGenericError {}; - } - catch (const Auth::Exception&) - { - throw UserNotAuthorizedError {}; - } - } - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleUpdatePlaylistRequest(RequestContext& context) -{ - // Mandatory params - TrackListId id {getMandatoryParameterAs(context.parameters, "playlistId")}; - - // Optional parameters - auto name {getParameterAs(context.parameters, "name")}; - auto isPublic {getParameterAs(context.parameters, "public")}; - - std::vector trackIdsToAdd {getMultiParametersAs(context.parameters, "songIdToAdd")}; - std::vector trackPositionsToRemove {getMultiParametersAs(context.parameters, "songIndexToRemove")}; - - auto transaction {context.dbSession.createUniqueTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - TrackList::pointer tracklist {TrackList::find(context.dbSession, id)}; - if (!tracklist - || tracklist->getUser() != user - || tracklist->getType() != TrackListType::Playlist) - { - throw RequestedDataNotFoundError {}; - } - - if (name) - tracklist.modify()->setName(*name); - - if (isPublic) - tracklist.modify()->setIsPublic(*isPublic); - - { - // Remove from end to make indexes stable - std::sort(std::begin(trackPositionsToRemove), std::end(trackPositionsToRemove), std::greater()); - - for (std::size_t trackPositionToRemove : trackPositionsToRemove) - { - auto entry {tracklist->getEntry(trackPositionToRemove)}; - if (entry) - entry.remove(); - } - } - - // Add tracks - for (const TrackId trackIdToAdd : trackIdsToAdd) - { - Track::pointer track {Track::find(context.dbSession, trackIdToAdd)}; - if (!track) - continue; - - context.dbSession.create(track, tracklist); - } - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleGetBookmarks(RequestContext& context) -{ - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - const auto bookmarkIds {TrackBookmark::find(context.dbSession, user->getId(), Range {})}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& bookmarksNode {response.createNode("bookmarks")}; - - for (const TrackBookmarkId bookmarkId : bookmarkIds.results) - { - const TrackBookmark::pointer bookmark {TrackBookmark::find(context.dbSession, bookmarkId)}; - Response::Node bookmarkNode {trackBookmarkToResponseNode(bookmark)}; - bookmarkNode.addArrayChild("entry", trackToResponseNode(bookmark->getTrack(), context.dbSession, user)); - - bookmarksNode.addArrayChild("bookmark", std::move(bookmarkNode)); - } - - return response ; -} - -static -Response -handleCreateBookmark(RequestContext& context) -{ - // Mandatory params - TrackId trackId {getMandatoryParameterAs(context.parameters, "id")}; - unsigned long position {getMandatoryParameterAs(context.parameters, "position")}; - const std::optional comment {getParameterAs(context.parameters, "comment")}; - - auto transaction {context.dbSession.createUniqueTransaction()}; - - const User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - const Track::pointer track {Track::find(context.dbSession, trackId)}; - if (!track) - throw RequestedDataNotFoundError {}; - - // Replace any existing bookmark - auto bookmark {TrackBookmark::find(context.dbSession, user->getId(), trackId)}; - if (!bookmark) - bookmark = context.dbSession.create(user, track); - - bookmark.modify()->setOffset(std::chrono::milliseconds {position}); - if (comment) - bookmark.modify()->setComment(*comment); - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleDeleteBookmark(RequestContext& context) -{ - // Mandatory params - TrackId trackId {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createUniqueTransaction()}; - - auto bookmark {TrackBookmark::find(context.dbSession, context.userId, trackId)}; - if (!bookmark) - throw RequestedDataNotFoundError {}; - - bookmark.remove(); - - return Response::createOkResponse(context.serverProtocolVersion); -} - -static -Response -handleNotImplemented(RequestContext&) -{ - throw NotImplementedGenericError {}; -} - -static -void -handleGetCoverArt(RequestContext& context, const Wt::Http::Request& /*request*/, Wt::Http::Response& response) -{ - // Mandatory params - const auto trackId {getParameterAs(context.parameters, "id")}; - const auto releaseId {getParameterAs(context.parameters, "id")}; - - if (!trackId && !releaseId) - throw BadParameterGenericError {"id"}; - - std::size_t size {getParameterAs(context.parameters, "size").value_or(1024)}; - size = Utils::clamp(size, std::size_t {32}, std::size_t {2048}); - - std::shared_ptr cover; - if (trackId) - cover = Service::get()->getFromTrack(*trackId, size); - else if (releaseId) - cover = Service::get()->getFromRelease(*releaseId, size); - - response.out().write(reinterpret_cast(cover->getData()), cover->getDataSize()); - response.setMimeType(std::string {cover->getMimeType()}); -} - -using RequestHandlerFunc = std::function; -using CheckImplementedFunc = std::function; -struct RequestEntryPointInfo -{ - RequestHandlerFunc func; - EnumSet allowedUserTypes {UserType::DEMO, UserType::REGULAR, UserType::ADMIN}; - CheckImplementedFunc checkFunc {}; -}; - -static const std::unordered_map requestEntryPoints -{ - // System - {"/ping", {handlePingRequest}}, - {"/getLicense", {handleGetLicenseRequest}}, - - // Browsing - {"/getMusicFolders", {handleGetMusicFoldersRequest}}, - {"/getIndexes", {handleGetIndexesRequest}}, - {"/getMusicDirectory", {handleGetMusicDirectoryRequest}}, - {"/getGenres", {handleGetGenresRequest}}, - {"/getArtists", {handleGetArtistsRequest}}, - {"/getArtist", {handleGetArtistRequest}}, - {"/getAlbum", {handleGetAlbumRequest}}, - {"/getSong", {handleGetSongRequest}}, - {"/getVideos", {handleNotImplemented}}, - {"/getArtistInfo", {handleGetArtistInfoRequest}}, - {"/getArtistInfo2", {handleGetArtistInfo2Request}}, - {"/getAlbumInfo", {handleNotImplemented}}, - {"/getAlbumInfo2", {handleNotImplemented}}, - {"/getSimilarSongs", {handleGetSimilarSongsRequest}}, - {"/getSimilarSongs2", {handleGetSimilarSongs2Request}}, - {"/getTopSongs", {handleNotImplemented}}, - - // Album/song lists - {"/getAlbumList", {handleGetAlbumListRequest}}, - {"/getAlbumList2", {handleGetAlbumList2Request}}, - {"/getRandomSongs", {handleGetRandomSongsRequest}}, - {"/getSongsByGenre", {handleGetSongsByGenreRequest}}, - {"/getNowPlaying", {handleNotImplemented}}, - {"/getStarred", {handleGetStarredRequest}}, - {"/getStarred2", {handleGetStarred2Request}}, - - // Searching - {"/search", {handleNotImplemented}}, - {"/search2", {handleSearch2Request}}, - {"/search3", {handleSearch3Request}}, - - // Playlists - {"/getPlaylists", {handleGetPlaylistsRequest}}, - {"/getPlaylist", {handleGetPlaylistRequest}}, - {"/createPlaylist", {handleCreatePlaylistRequest}}, - {"/updatePlaylist", {handleUpdatePlaylistRequest}}, - {"/deletePlaylist", {handleDeletePlaylistRequest}}, - - // Media retrieval - {"/hls", {handleNotImplemented}}, - {"/getCaptions", {handleNotImplemented}}, - {"/getLyrics", {handleNotImplemented}}, - {"/getAvatar", {handleNotImplemented}}, - - // Media annotation - {"/star", {handleStarRequest}}, - {"/unstar", {handleUnstarRequest}}, - {"/setRating", {handleNotImplemented}}, - {"/scrobble", {handleScrobble}}, - - // Sharing - {"/getShares", {handleNotImplemented}}, - {"/createShares", {handleNotImplemented}}, - {"/updateShare", {handleNotImplemented}}, - {"/deleteShare", {handleNotImplemented}}, - - // Podcast - {"/getPodcasts", {handleNotImplemented}}, - {"/getNewestPodcasts", {handleNotImplemented}}, - {"/refreshPodcasts", {handleNotImplemented}}, - {"/createPodcastChannel", {handleNotImplemented}}, - {"/deletePodcastChannel", {handleNotImplemented}}, - {"/deletePodcastEpisode", {handleNotImplemented}}, - {"/downloadPodcastEpisode", {handleNotImplemented}}, - - // Jukebox - {"/jukeboxControl", {handleNotImplemented}}, - - // Internet radio - {"/getInternetRadioStations", {handleNotImplemented}}, - {"/createInternetRadioStation", {handleNotImplemented}}, - {"/updateInternetRadioStation", {handleNotImplemented}}, - {"/deleteInternetRadioStation", {handleNotImplemented}}, - - // Chat - {"/getChatMessages", {handleNotImplemented}}, - {"/addChatMessages", {handleNotImplemented}}, - - // User management - {"/getUser", {handleGetUserRequest}}, - {"/getUsers", {handleGetUsersRequest, {UserType::ADMIN}}}, - {"/createUser", {handleCreateUserRequest, {UserType::ADMIN}, &checkSetPasswordImplemented}}, - {"/updateUser", {handleUpdateUserRequest, {UserType::ADMIN}}}, - {"/deleteUser", {handleDeleteUserRequest, {UserType::ADMIN}}}, - {"/changePassword", {handleChangePassword, {UserType::REGULAR, UserType::ADMIN}, &checkSetPasswordImplemented}}, - - // Bookmarks - {"/getBookmarks", {handleGetBookmarks}}, - {"/createBookmark", {handleCreateBookmark}}, - {"/deleteBookmark", {handleDeleteBookmark}}, - {"/getPlayQueue", {handleNotImplemented}}, - {"/savePlayQueue", {handleNotImplemented}}, - - // Media library scanning - {"/getScanStatus", {Scan::handleGetScanStatus, {UserType::ADMIN}}}, - {"/startScan", {Scan::handleStartScan, {UserType::ADMIN}}}, -}; - -using MediaRetrievalHandlerFunc = std::function; -static std::unordered_map mediaRetrievalHandlers -{ - // Media retrieval - {"/download", Stream::handleDownload}, - {"/stream", Stream::handleStream}, - {"/getCoverArt", handleGetCoverArt}, -}; - - -void -SubsonicResource::handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response) -{ - static std::atomic curRequestId {}; - - const std::size_t requestId {curRequestId++}; - - LMS_LOG(API_SUBSONIC, DEBUG) << "Handling request " << requestId << " '" << request.pathInfo() << "', continuation = " << (request.continuation() ? "true" : "false") << ", params = " << parameterMapToDebugString(request.getParameterMap()); - - std::string requestPath {request.pathInfo()}; - if (StringUtils::stringEndsWith(requestPath, ".view")) - requestPath.resize(requestPath.length() - 5); - - // Optional parameters - const ResponseFormat format {getParameterAs(request.getParameterMap(), "f").value_or("xml") == "json" ? ResponseFormat::json : ResponseFormat::xml}; - - ProtocolVersion protocolVersion {defaultServerProtocolVersion}; - - try - { - // We need to parse client a soon as possible to make sure to answer with the right protocol version - protocolVersion = getServerProtocolVersion(getMandatoryParameterAs(request.getParameterMap(), "c")); - RequestContext requestContext {buildRequestContext(request)}; - - auto itEntryPoint {requestEntryPoints.find(requestPath)}; - if (itEntryPoint != requestEntryPoints.end()) - { - if (itEntryPoint->second.checkFunc) - itEntryPoint->second.checkFunc(); - - checkUserTypeIsAllowed(requestContext, itEntryPoint->second.allowedUserTypes); - - Response resp {(itEntryPoint->second.func)(requestContext)}; - - resp.write(response.out(), format); - response.setMimeType(ResponseFormatToMimeType(format)); - - LMS_LOG(API_SUBSONIC, DEBUG) << "Request " << requestId << " '" << requestPath << "' handled!"; - return; - } - - auto itStreamHandler {mediaRetrievalHandlers.find(requestPath)}; - if (itStreamHandler != mediaRetrievalHandlers.end()) - { - itStreamHandler->second(requestContext, request, response); - LMS_LOG(API_SUBSONIC, DEBUG) << "Request " << requestId << " '" << requestPath << "' handled!"; - return; - } - - LMS_LOG(API_SUBSONIC, ERROR) << "Unhandled command '" << requestPath << "'"; - throw UnknownEntryPointGenericError {}; - } - catch (const Error& e) - { - LMS_LOG(API_SUBSONIC, ERROR) << "Error while processing request '" << requestPath << "'" - << ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]" - << ", code = " << static_cast(e.getCode()) << ", msg = '" << e.getMessage() << "'"; - Response resp {Response::createFailedResponse(protocolVersion, e)}; - resp.write(response.out(), format); - response.setMimeType(ResponseFormatToMimeType(format)); - } -} - -ProtocolVersion -SubsonicResource::getServerProtocolVersion(const std::string& clientName) const -{ - auto it {_serverProtocolVersionsByClient.find(clientName)}; - if (it == std::cend(_serverProtocolVersionsByClient)) - return defaultServerProtocolVersion; - - return it->second; -} - -void -SubsonicResource::checkProtocolVersion(ProtocolVersion client, ProtocolVersion server) -{ - if (client.major > server.major) - throw ServerMustUpgradeError {}; - if (client.major < server.major) - throw ClientMustUpgradeError {}; - if (client.minor > server.minor) - throw ServerMustUpgradeError {}; - else if (client.minor == server.minor) - { - if (client.patch > server.patch) - throw ServerMustUpgradeError {}; - } -} - -ClientInfo -SubsonicResource::getClientInfo(const Wt::Http::ParameterMap& parameters) -{ - ClientInfo res; - - if (hasParameter(parameters, "t")) - throw TokenAuthenticationNotSupportedForLDAPUsersError {}; - - // Mandatory parameters - res.name = getMandatoryParameterAs(parameters, "c"); - res.version = getMandatoryParameterAs(parameters, "v"); - res.user = getMandatoryParameterAs(parameters, "u"); - res.password = decodePasswordIfNeeded(getMandatoryParameterAs(parameters, "p")); - - return res; -} - -RequestContext -SubsonicResource::buildRequestContext(const Wt::Http::Request& request) -{ - const Wt::Http::ParameterMap& parameters {request.getParameterMap()}; - const ClientInfo clientInfo {getClientInfo(parameters)}; - const Database::UserId userId {authenticateUser(request, clientInfo)}; - - return {parameters, _db.getTLSSession(), userId, clientInfo, getServerProtocolVersion(clientInfo.name)}; -} - -Database::UserId -SubsonicResource::authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo) -{ - if (auto *authEnvService {Service<::Auth::IEnvService>::get()}) - { - const auto checkResult {authEnvService->processRequest(request)}; - if (checkResult.state != ::Auth::IEnvService::CheckResult::State::Granted) - throw UserNotAuthorizedError {}; - - return *checkResult.userId; - } - else if (auto *authPasswordService {Service<::Auth::IPasswordService>::get()}) - { - const auto checkResult {authPasswordService->checkUserPassword(boost::asio::ip::address::from_string(request.clientAddress()), - clientInfo.user, clientInfo.password)}; - - switch (checkResult.state) - { - case Auth::IPasswordService::CheckResult::State::Granted: - return *checkResult.userId; - break; - case Auth::IPasswordService::CheckResult::State::Denied: - throw WrongUsernameOrPasswordError {}; - case Auth::IPasswordService::CheckResult::State::Throttled: - throw LoginThrottledGenericError {}; - } - } - - throw InternalErrorGenericError {"No service avalaible to authenticate user"}; -} + std::unique_ptr createSubsonicResource(Database::Db& db) + { + return std::make_unique(db); + } + + namespace + { + std::unordered_map readConfigProtocolVersions() + { + std::unordered_map res; + + Service::get()->visitStrings("api-subsonic-report-old-server-protocol", + [&](std::string_view client) + { + res.emplace(std::string{ client }, ProtocolVersion{ 1, 12, 0 }); + }, { "DSub" }); + + return res; + } + + + std::string parameterMapToDebugString(const Wt::Http::ParameterMap& parameterMap) + { + auto censorValue = [](const std::string& type, const std::string& value) -> std::string + { + if (type == "p" || type == "password") + return "*REDACTED*"; + else + return value; + }; + + std::string res; + + for (const auto& params : parameterMap) + { + res += "{" + params.first + "="; + if (params.second.size() == 1) + { + res += censorValue(params.first, params.second.front()); + } + else + { + res += "{"; + for (const std::string& param : params.second) + res += censorValue(params.first, param) + ","; + res += "}"; + } + res += "}, "; + } + + return res; + } + + void checkUserTypeIsAllowed(RequestContext& context, EnumSet allowedUserTypes) + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer currentUser{ User::find(context.dbSession, context.userId) }; + if (!currentUser) + throw RequestedDataNotFoundError{}; + + if (!allowedUserTypes.contains(currentUser->getType())) + throw UserNotAuthorizedError{}; + } + + Response handleNotImplemented(RequestContext&) + { + throw NotImplementedGenericError{}; + } + + using RequestHandlerFunc = std::function; + using CheckImplementedFunc = std::function; + struct RequestEntryPointInfo + { + RequestHandlerFunc func; + EnumSet allowedUserTypes{ UserType::DEMO, UserType::REGULAR, UserType::ADMIN }; + CheckImplementedFunc checkFunc{}; + }; + + static const std::unordered_map requestEntryPoints + { + // System + {"/ping", {handlePingRequest}}, + {"/getLicense", {handleGetLicenseRequest}}, + {"/getOpenSubsonicExtensions", {handleGetOpenSubsonicExtensions}}, + + // Browsing + {"/getMusicFolders", {handleGetMusicFoldersRequest}}, + {"/getIndexes", {handleGetIndexesRequest}}, + {"/getMusicDirectory", {handleGetMusicDirectoryRequest}}, + {"/getGenres", {handleGetGenresRequest}}, + {"/getArtists", {handleGetArtistsRequest}}, + {"/getArtist", {handleGetArtistRequest}}, + {"/getAlbum", {handleGetAlbumRequest}}, + {"/getSong", {handleGetSongRequest}}, + {"/getVideos", {handleNotImplemented}}, + {"/getArtistInfo", {handleGetArtistInfoRequest}}, + {"/getArtistInfo2", {handleGetArtistInfo2Request}}, + {"/getAlbumInfo", {handleNotImplemented}}, + {"/getAlbumInfo2", {handleNotImplemented}}, + {"/getSimilarSongs", {handleGetSimilarSongsRequest}}, + {"/getSimilarSongs2", {handleGetSimilarSongs2Request}}, + {"/getTopSongs", {handleNotImplemented}}, + + // Album/song lists + {"/getAlbumList", {handleGetAlbumListRequest}}, + {"/getAlbumList2", {handleGetAlbumList2Request}}, + {"/getRandomSongs", {handleGetRandomSongsRequest}}, + {"/getSongsByGenre", {handleGetSongsByGenreRequest}}, + {"/getNowPlaying", {handleNotImplemented}}, + {"/getStarred", {handleGetStarredRequest}}, + {"/getStarred2", {handleGetStarred2Request}}, + + // Searching + {"/search", {handleNotImplemented}}, + {"/search2", {handleSearch2Request}}, + {"/search3", {handleSearch3Request}}, + + // Playlists + {"/getPlaylists", {handleGetPlaylistsRequest}}, + {"/getPlaylist", {handleGetPlaylistRequest}}, + {"/createPlaylist", {handleCreatePlaylistRequest}}, + {"/updatePlaylist", {handleUpdatePlaylistRequest}}, + {"/deletePlaylist", {handleDeletePlaylistRequest}}, + + // Media retrieval + {"/hls", {handleNotImplemented}}, + {"/getCaptions", {handleNotImplemented}}, + {"/getLyrics", {handleNotImplemented}}, + {"/getAvatar", {handleNotImplemented}}, + + // Media annotation + {"/star", {handleStarRequest}}, + {"/unstar", {handleUnstarRequest}}, + {"/setRating", {handleNotImplemented}}, + {"/scrobble", {handleScrobble}}, + + // Sharing + {"/getShares", {handleNotImplemented}}, + {"/createShares", {handleNotImplemented}}, + {"/updateShare", {handleNotImplemented}}, + {"/deleteShare", {handleNotImplemented}}, + + // Podcast + {"/getPodcasts", {handleNotImplemented}}, + {"/getNewestPodcasts", {handleNotImplemented}}, + {"/refreshPodcasts", {handleNotImplemented}}, + {"/createPodcastChannel", {handleNotImplemented}}, + {"/deletePodcastChannel", {handleNotImplemented}}, + {"/deletePodcastEpisode", {handleNotImplemented}}, + {"/downloadPodcastEpisode", {handleNotImplemented}}, + + // Jukebox + {"/jukeboxControl", {handleNotImplemented}}, + + // Internet radio + {"/getInternetRadioStations", {handleNotImplemented}}, + {"/createInternetRadioStation", {handleNotImplemented}}, + {"/updateInternetRadioStation", {handleNotImplemented}}, + {"/deleteInternetRadioStation", {handleNotImplemented}}, + + // Chat + {"/getChatMessages", {handleNotImplemented}}, + {"/addChatMessages", {handleNotImplemented}}, + + // User management + {"/getUser", {handleGetUserRequest}}, + {"/getUsers", {handleGetUsersRequest, {UserType::ADMIN}}}, + {"/createUser", {handleCreateUserRequest, {UserType::ADMIN}, &Utils::checkSetPasswordImplemented}}, + {"/updateUser", {handleUpdateUserRequest, {UserType::ADMIN}}}, + {"/deleteUser", {handleDeleteUserRequest, {UserType::ADMIN}}}, + {"/changePassword", {handleChangePassword, {UserType::REGULAR, UserType::ADMIN}, &Utils::checkSetPasswordImplemented}}, + + // Bookmarks + {"/getBookmarks", {handleGetBookmarks}}, + {"/createBookmark", {handleCreateBookmark}}, + {"/deleteBookmark", {handleDeleteBookmark}}, + {"/getPlayQueue", {handleNotImplemented}}, + {"/savePlayQueue", {handleNotImplemented}}, + + // Media library scanning + {"/getScanStatus", {Scan::handleGetScanStatus, {UserType::ADMIN}}}, + {"/startScan", {Scan::handleStartScan, {UserType::ADMIN}}}, + }; + + using MediaRetrievalHandlerFunc = std::function; + static std::unordered_map mediaRetrievalHandlers + { + // Media retrieval + {"/download", handleDownload}, + {"/stream", handleStream}, + {"/getCoverArt", handleGetCoverArt}, + }; + } + + + SubsonicResource::SubsonicResource(Db& db) + : _serverProtocolVersionsByClient{ readConfigProtocolVersions() } + , _db{ db } + { + } + + void SubsonicResource::handleRequest(const Wt::Http::Request& request, Wt::Http::Response& response) + { + static std::atomic curRequestId{}; + + const std::size_t requestId{ curRequestId++ }; + + LMS_LOG(API_SUBSONIC, DEBUG) << "Handling request " << requestId << " '" << request.pathInfo() << "', continuation = " << (request.continuation() ? "true" : "false") << ", params = " << parameterMapToDebugString(request.getParameterMap()); + + std::string requestPath{ request.pathInfo() }; + if (StringUtils::stringEndsWith(requestPath, ".view")) + requestPath.resize(requestPath.length() - 5); + + // Optional parameters + const ResponseFormat format{ getParameterAs(request.getParameterMap(), "f").value_or("xml") == "json" ? ResponseFormat::json : ResponseFormat::xml }; + + ProtocolVersion protocolVersion{ defaultServerProtocolVersion }; + + try + { + // We need to parse client a soon as possible to make sure to answer with the right protocol version + protocolVersion = getServerProtocolVersion(getMandatoryParameterAs(request.getParameterMap(), "c")); + RequestContext requestContext{ buildRequestContext(request) }; + + auto itEntryPoint{ requestEntryPoints.find(requestPath) }; + if (itEntryPoint != requestEntryPoints.end()) + { + if (itEntryPoint->second.checkFunc) + itEntryPoint->second.checkFunc(); + + checkUserTypeIsAllowed(requestContext, itEntryPoint->second.allowedUserTypes); + + Response resp{ (itEntryPoint->second.func)(requestContext) }; + + resp.write(response.out(), format); + response.setMimeType(std::string{ ResponseFormatToMimeType(format) }); + + LMS_LOG(API_SUBSONIC, DEBUG) << "Request " << requestId << " '" << requestPath << "' handled!"; + return; + } + + auto itStreamHandler{ mediaRetrievalHandlers.find(requestPath) }; + if (itStreamHandler != mediaRetrievalHandlers.end()) + { + itStreamHandler->second(requestContext, request, response); + LMS_LOG(API_SUBSONIC, DEBUG) << "Request " << requestId << " '" << requestPath << "' handled!"; + return; + } + + LMS_LOG(API_SUBSONIC, ERROR) << "Unhandled command '" << requestPath << "'"; + throw UnknownEntryPointGenericError{}; + } + catch (const Error& e) + { + LMS_LOG(API_SUBSONIC, ERROR) << "Error while processing request '" << requestPath << "'" + << ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]" + << ", code = " << static_cast(e.getCode()) << ", msg = '" << e.getMessage() << "'"; + Response resp{ Response::createFailedResponse(protocolVersion, e) }; + resp.write(response.out(), format); + response.setMimeType(std::string{ ResponseFormatToMimeType(format) }); + } + } + + ProtocolVersion SubsonicResource::getServerProtocolVersion(const std::string& clientName) const + { + auto it{ _serverProtocolVersionsByClient.find(clientName) }; + if (it == std::cend(_serverProtocolVersionsByClient)) + return defaultServerProtocolVersion; + + return it->second; + } + + void SubsonicResource::checkProtocolVersion(ProtocolVersion client, ProtocolVersion server) + { + if (client.major > server.major) + throw ServerMustUpgradeError{}; + if (client.major < server.major) + throw ClientMustUpgradeError{}; + if (client.minor > server.minor) + throw ServerMustUpgradeError{}; + else if (client.minor == server.minor) + { + if (client.patch > server.patch) + throw ServerMustUpgradeError{}; + } + } + + ClientInfo SubsonicResource::getClientInfo(const Wt::Http::ParameterMap& parameters) + { + ClientInfo res; + + if (hasParameter(parameters, "t")) + throw TokenAuthenticationNotSupportedForLDAPUsersError{}; + + // Mandatory parameters + res.name = getMandatoryParameterAs(parameters, "c"); + res.version = getMandatoryParameterAs(parameters, "v"); + res.user = getMandatoryParameterAs(parameters, "u"); + res.password = decodePasswordIfNeeded(getMandatoryParameterAs(parameters, "p")); + + return res; + } + + RequestContext SubsonicResource::buildRequestContext(const Wt::Http::Request& request) + { + const Wt::Http::ParameterMap& parameters{ request.getParameterMap() }; + const ClientInfo clientInfo{ getClientInfo(parameters) }; + const Database::UserId userId{ authenticateUser(request, clientInfo) }; + + return { parameters, _db.getTLSSession(), userId, clientInfo, getServerProtocolVersion(clientInfo.name) }; + } + + Database::UserId SubsonicResource::authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo) + { + if (auto * authEnvService{ Service<::Auth::IEnvService>::get() }) + { + const auto checkResult{ authEnvService->processRequest(request) }; + if (checkResult.state != ::Auth::IEnvService::CheckResult::State::Granted) + throw UserNotAuthorizedError{}; + + return *checkResult.userId; + } + else if (auto * authPasswordService{ Service<::Auth::IPasswordService>::get() }) + { + const auto checkResult{ authPasswordService->checkUserPassword(boost::asio::ip::address::from_string(request.clientAddress()), clientInfo.user, clientInfo.password) }; + + switch (checkResult.state) + { + case Auth::IPasswordService::CheckResult::State::Granted: + return *checkResult.userId; + break; + case Auth::IPasswordService::CheckResult::State::Denied: + throw WrongUsernameOrPasswordError{}; + case Auth::IPasswordService::CheckResult::State::Throttled: + throw LoginThrottledGenericError{}; + } + } + + throw InternalErrorGenericError{ "No service avalaible to authenticate user" }; + } } // namespace api::subsonic diff --git a/src/libs/subsonic/impl/SubsonicResource.hpp b/src/libs/subsonic/impl/SubsonicResource.hpp index 24c0754b..643ca504 100644 --- a/src/libs/subsonic/impl/SubsonicResource.hpp +++ b/src/libs/subsonic/impl/SubsonicResource.hpp @@ -30,28 +30,28 @@ namespace Database { - class Db; + class Db; } namespace API::Subsonic { - class SubsonicResource final : public Wt::WResource - { - public: - SubsonicResource(Database::Db& db); + class SubsonicResource final : public Wt::WResource + { + public: + SubsonicResource(Database::Db& db); - private: - void handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response) override; - ProtocolVersion getServerProtocolVersion(const std::string& clientName) const; + private: + void handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response) override; + ProtocolVersion getServerProtocolVersion(const std::string& clientName) const; - static void checkProtocolVersion(ProtocolVersion client, ProtocolVersion server); - ClientInfo getClientInfo(const Wt::Http::ParameterMap& parameters); - RequestContext buildRequestContext(const Wt::Http::Request& request); - Database::UserId authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo); + static void checkProtocolVersion(ProtocolVersion client, ProtocolVersion server); + ClientInfo getClientInfo(const Wt::Http::ParameterMap& parameters); + RequestContext buildRequestContext(const Wt::Http::Request& request); + Database::UserId authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo); - const std::unordered_map _serverProtocolVersionsByClient; - Database::Db& _db; - }; + const std::unordered_map _serverProtocolVersionsByClient; + Database::Db& _db; + }; } // namespace diff --git a/src/libs/subsonic/impl/SubsonicResponse.cpp b/src/libs/subsonic/impl/SubsonicResponse.cpp index 2958291c..95952e2e 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.cpp +++ b/src/libs/subsonic/impl/SubsonicResponse.cpp @@ -19,6 +19,7 @@ #include "SubsonicResponse.hpp" +#include #include #include #include @@ -33,250 +34,270 @@ namespace API::Subsonic { + std::string_view ResponseFormatToMimeType(ResponseFormat format) + { + switch (format) + { + case ResponseFormat::xml: return "text/xml"; + case ResponseFormat::json: return "application/json"; + } -std::string -ResponseFormatToMimeType(ResponseFormat format) -{ - switch (format) - { - case ResponseFormat::xml: return "text/xml"; - case ResponseFormat::json: return "application/json"; - } + return ""; + } - return ""; -} + void Response::Node::setValue(std::string_view value) + { + assert(_children.empty() && _childrenArrays.empty() && _childrenValues.empty()); + _value = std::string{ value }; + } -void -Response::Node::setValue(std::string_view value) -{ - if (!_children.empty() || !_childrenArrays.empty()) - throw LmsException {"Node already has children"}; + void Response::Node::setValue(long long value) + { + assert(_children.empty() && _childrenArrays.empty() && _childrenValues.empty()); + _value = value; + } - _value = std::string {value}; -} + void Response::Node::setAttribute(std::string_view key, std::string_view value) + { + _attributes[std::string{ key }] = std::string{ value }; + } -void -Response::Node::setValue(long long value) -{ - if (!_children.empty() || !_childrenArrays.empty()) - throw LmsException {"Node already has children"}; + void Response::Node::addChild(const std::string& key, Node node) + { + assert(!_value); + _children[key].emplace_back(std::move(node)); + } - _value = value; -} + void Response::Node::createEmptyArrayChild(std::string_view key) + { + assert(!_value); + _childrenArrays.emplace(key, std::vector{}); + } -void -Response::Node::setAttribute(std::string_view key, std::string_view value) -{ - _attributes[std::string {key}] = std::string {value}; -} + void Response::Node::addArrayChild(std::string_view key, Node node) + { + assert(!_value); + _childrenArrays[std::string{ key }].emplace_back(std::move(node)); + } -void -Response::Node::addChild(const std::string& key, Node node) -{ - if (_value) - throw LmsException {"Node already has a value"}; + void Response::Node::createEmptyArrayValue(std::string_view key) + { + assert (!_value); + _childrenValues.emplace(key, ValuesType{}); + } - _children[key].emplace_back(std::move(node)); -} + void Response::Node::addArrayValue(std::string_view key, std::string_view value) + { + assert(!_value); + auto& values{ _childrenValues[std::string{ key }] }; + values.push_back(std::string{ value }); + assert(std::all_of(std::cbegin(values) + 1, std::cend(values), [&](const ValueType& value) {return value.index() == values.front().index();})); + } -void -Response::Node::addArrayChild(const std::string& key, Node node) -{ - if (_value) - throw LmsException {"Node already has a value"}; + void Response::Node::addArrayValue(std::string_view key, long long value) + { + assert(!_value); + auto& values {_childrenValues[std::string{ key }]}; + values.push_back(value); + assert(std::all_of(std::cbegin(values) + 1, std::cend(values), [&](const ValueType& value) {return value.index() == values.front().index();})); + } - _childrenArrays[key].emplace_back(std::move(node)); -} + Response::Node& Response::Node::createChild(const std::string& key) + { + _children[key].emplace_back(); + return _children[key].back(); + } + Response::Node& Response::Node::createArrayChild(const std::string& key) + { + _childrenArrays[key].emplace_back(); + return _childrenArrays[key].back(); + } -Response::Node& -Response::Node::createChild(const std::string& key) -{ - _children[key].emplace_back(); - return _children[key].back(); -} + void Response::Node::setVersionAttribute(ProtocolVersion protocolVersion) + { + setAttribute("version", std::to_string(protocolVersion.major) + "." + std::to_string(protocolVersion.minor) + "." + std::to_string(protocolVersion.patch)); + } -Response::Node& -Response::Node::createArrayChild(const std::string& key) -{ - _childrenArrays[key].emplace_back(); - return _childrenArrays[key].back(); -} + Response Response::createOkResponse(ProtocolVersion protocolVersion) + { + Response response; + Node& responseNode{ response._root.createChild("subsonic-response") }; -void -Response::Node::setVersionAttribute(ProtocolVersion protocolVersion) -{ - setAttribute("version", std::to_string(protocolVersion.major) + "." + std::to_string(protocolVersion.minor) + "." + std::to_string(protocolVersion.patch)); -} + responseNode.setAttribute("status", "ok"); + responseNode.setVersionAttribute(protocolVersion); -Response -Response::createOkResponse(ProtocolVersion protocolVersion) -{ - Response response; - Node& responseNode {response._root.createChild("subsonic-response")}; + // OpenSubsonic mandatory fields + responseNode.setAttribute("type", "lms"); + responseNode.setAttribute("serverVersion", serverVersion); + responseNode.setAttribute("openSubsonic", true); - responseNode.setAttribute("status", "ok"); - responseNode.setVersionAttribute(protocolVersion); - responseNode.setAttribute("type", "lms"); // non standard field to ease client hacks + return response; + } - return response; -} + Response Response::createFailedResponse(ProtocolVersion protocolVersion, const Error& error) + { + Response response; + Node& responseNode{ response._root.createChild("subsonic-response") }; -Response -Response::createFailedResponse(ProtocolVersion protocolVersion, const Error& error) -{ - Response response; - Node& responseNode {response._root.createChild("subsonic-response")}; + responseNode.setAttribute("status", "failed"); + responseNode.setVersionAttribute(protocolVersion); + responseNode.setAttribute("type", "lms"); // non standard field to ease client hacks - responseNode.setAttribute("status", "failed"); - responseNode.setVersionAttribute(protocolVersion); - responseNode.setAttribute("type", "lms"); // non standard field to ease client hacks + Node& errorNode{ responseNode.createChild("error") }; + errorNode.setAttribute("code", static_cast(error.getCode())); + errorNode.setAttribute("message", error.getMessage()); - Node& errorNode {responseNode.createChild("error")}; - errorNode.setAttribute("code", static_cast(error.getCode())); - errorNode.setAttribute("message", error.getMessage()); + return response; + } - return response; -} + void Response::addNode(const std::string& key, Node node) + { + return _root._children["subsonic-response"].front().addChild(key, std::move(node)); + } -void -Response::addNode(const std::string& key, Node node) -{ - return _root._children["subsonic-response"].front().addChild(key, std::move(node)); -} + Response::Node& Response::createNode(const std::string& key) + { + return _root._children["subsonic-response"].front().createChild(key); + } -Response::Node& -Response::createNode(const std::string& key) -{ - return _root._children["subsonic-response"].front().createChild(key); -} + Response::Node& Response::createArrayNode(const std::string& key) + { + return _root._children["subsonic-response"].front().createArrayChild(key); + } -Response::Node& -Response::createArrayNode(const std::string& key) -{ - return _root._children["subsonic-response"].front().createArrayChild(key); -} + void Response::write(std::ostream& os, ResponseFormat format) + { + switch (format) + { + case ResponseFormat::xml: + writeXML(os); + break; + case ResponseFormat::json: + writeJSON(os); + break; + } + } -void -Response::write(std::ostream& os, ResponseFormat format) -{ - switch (format) - { - case ResponseFormat::xml: - writeXML(os); - break; - case ResponseFormat::json: - writeJSON(os); - break; - } -} + void Response::writeXML(std::ostream& os) + { + std::function nodeToPropertyTree = [&](const Node& node) + { + boost::property_tree::ptree res; -void -Response::writeXML(std::ostream& os) -{ - std::function nodeToPropertyTree = [&] (const Response::Node& node) - { - boost::property_tree::ptree res; + for (auto itAttribute : node._attributes) + { + if (std::holds_alternative(itAttribute.second)) + res.put("." + itAttribute.first, std::get(itAttribute.second)); + else if (std::holds_alternative(itAttribute.second)) + res.put("." + itAttribute.first, std::get(itAttribute.second)); + else if (std::holds_alternative(itAttribute.second)) + res.put("." + itAttribute.first, std::get(itAttribute.second)); + else if (std::holds_alternative(itAttribute.second)) + res.put("." + itAttribute.first, std::get(itAttribute.second)); + } - for (auto itAttribute : node._attributes) - { - if (std::holds_alternative(itAttribute.second)) - res.put("." + itAttribute.first, std::get(itAttribute.second)); - else if (std::holds_alternative(itAttribute.second)) - res.put("." + itAttribute.first, std::get(itAttribute.second)); - else if (std::holds_alternative(itAttribute.second)) - res.put("." + itAttribute.first, std::get(itAttribute.second)); - } + auto valueToPropertyTree = [](const Node::ValueType& value) + { + boost::property_tree::ptree res; + std::visit([&](const auto& rawValue) + { + res.put_value(rawValue); + }, value); - if (node._value) - { - const auto& value {*node._value}; + return res; + }; - if (std::holds_alternative(value)) - res.put_value(std::get(value)); - else if (std::holds_alternative(value)) - res.put_value(std::get(value)); - else if (std::holds_alternative(value)) - res.put_value(std::get(value)); - } - else - { - for (auto itChildNode : node._children) - { - for (const Response::Node& childNode : itChildNode.second) - res.add_child(itChildNode.first, nodeToPropertyTree(childNode)); - } + if (node._value) + { + res = valueToPropertyTree(*node._value); + } + else + { + for (const auto& [key, childNodes] : node._children) + { + for (const Node& childNode : childNodes) + res.add_child(key, nodeToPropertyTree(childNode)); + } - for (auto itChildArrayNode : node._childrenArrays) - { - const std::vector& childArrayNodes {itChildArrayNode.second}; + for (const auto& [key, childArrayNodes] : node._childrenArrays) + { + for (const Node& childNode : childArrayNodes) + res.add_child(key, nodeToPropertyTree(childNode)); + } - for (const Response::Node& childNode : childArrayNodes ) - res.add_child(itChildArrayNode.first, nodeToPropertyTree(childNode)); - } - } + for (const auto& [key, childArrayValues] : node._childrenValues) + { + for (const Response::Node::ValueType& value : childArrayValues) + res.add_child(key, valueToPropertyTree(value)); + } + } - return res; - }; + return res; + }; - boost::property_tree::ptree root {nodeToPropertyTree(_root)}; - boost::property_tree::write_xml(os, root); -} + boost::property_tree::ptree root{ nodeToPropertyTree(_root) }; + boost::property_tree::write_xml(os, root); + } -void -Response::writeJSON(std::ostream& os) -{ - namespace Json = Wt::Json; + void Response::writeJSON(std::ostream& os) + { + namespace Json = Wt::Json; - std::function nodeToJsonObject = [&] (const Response::Node& node) - { - Json::Object res; + std::function nodeToJsonObject = [&](const Response::Node& node) + { + Json::Object res; - auto valueToJsonValue {[](const Node::ValueType& value) -> Json::Value - { - if (std::holds_alternative(value)) - return Json::Value {std::get(value)}; - else if (std::holds_alternative(value)) - return Json::Value {std::get(value)}; - else if (std::holds_alternative(value)) - return Json::Value {std::get(value)}; + auto valueToJsonValue{ [](const Node::ValueType& value) -> Json::Value + { + Json::Value res; + std::visit([&](const auto& rawValue) + { + res = Json::Value{ rawValue }; + }, value); + return res; + } }; - throw LmsException("Unexpected value type"); - }}; + for (auto itAttribute : node._attributes) + res[itAttribute.first] = valueToJsonValue(itAttribute.second); - for (auto itAttribute : node._attributes) - res[itAttribute.first] = valueToJsonValue(itAttribute.second); + if (node._value) + { + res["value"] = valueToJsonValue(*node._value); + } + else + { + for (const auto& [key, childNodes] : node._children) + { + for (const Response::Node& childNode : childNodes) + res[key] = nodeToJsonObject(childNode); + } - if (node._value) - { - res["value"] = valueToJsonValue(*node._value); - } - else - { - for (auto itChildNode : node._children) - { - for (const Response::Node& childNode : itChildNode.second) - res[itChildNode.first] = nodeToJsonObject(childNode); - } + for (const auto& [key, childArrayNodes] : node._childrenArrays) + { + Json::Array array; + for (const Response::Node& childNode : childArrayNodes) + array.emplace_back(nodeToJsonObject(childNode)); - for (auto itChildArrayNode : node._childrenArrays) - { - const std::vector& childArrayNodes {itChildArrayNode .second}; + res[key] = std::move(array); + } - Json::Array array; - for (const Response::Node& childNode : childArrayNodes ) - array.emplace_back(nodeToJsonObject(childNode)); + for (const auto& [key, childValues] : node._childrenValues) + { + Json::Array array; + for (const Node::ValueType& childValue : childValues) + array.emplace_back(valueToJsonValue(childValue)); - res[itChildArrayNode.first] = std::move(array); - } - } + res[key] = std::move(array); + } + } - return res; - }; + return res; + }; - Json::Object root {nodeToJsonObject(_root)}; - os << Json::serialize(root); -} + Json::Object root{ nodeToJsonObject(_root) }; + os << Json::serialize(root); + } } // namespace diff --git a/src/libs/subsonic/impl/SubsonicResponse.hpp b/src/libs/subsonic/impl/SubsonicResponse.hpp index d2d5294f..1d04fd6e 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.hpp +++ b/src/libs/subsonic/impl/SubsonicResponse.hpp @@ -30,220 +30,231 @@ namespace API::Subsonic { -enum class ResponseFormat -{ - xml, - json, -}; + enum class ResponseFormat + { + xml, + json, + }; -std::string ResponseFormatToMimeType(ResponseFormat format); + std::string_view ResponseFormatToMimeType(ResponseFormat format); -class Error -{ - public: - enum class Code - { - Generic = 0, - RequiredParameterMissing = 10, - ClientMustUpgrade = 20, - ServerMustUpgrade = 30, - WrongUsernameOrPassword = 40, - TokenAuthenticationNotSupportedForLDAPUsers = 41, - UserNotAuthorized = 50, - RequestedDataNotFound = 70, - }; + class Error + { + public: + enum class Code + { + Generic = 0, + RequiredParameterMissing = 10, + ClientMustUpgrade = 20, + ServerMustUpgrade = 30, + WrongUsernameOrPassword = 40, + TokenAuthenticationNotSupportedForLDAPUsers = 41, + UserNotAuthorized = 50, + RequestedDataNotFound = 70, + }; - Error(Code code) : _code {code} {} + Error(Code code) : _code{ code } {} - virtual std::string getMessage() const = 0; + virtual std::string getMessage() const = 0; - Code getCode() const { return _code; } + Code getCode() const { return _code; } - private: - const Code _code; -}; + private: + const Code _code; + }; -class GenericError : public Error -{ - public: - GenericError() : Error {Code::Generic} {} -}; + class GenericError : public Error + { + public: + GenericError() : Error{ Code::Generic } {} + }; -class RequiredParameterMissingError : public Error -{ - public: - RequiredParameterMissingError(std::string_view param) - : Error {Code::RequiredParameterMissing} - , _param {param} - {} + class RequiredParameterMissingError : public Error + { + public: + RequiredParameterMissingError(std::string_view param) + : Error{ Code::RequiredParameterMissing } + , _param{ param } + {} - private: - std::string getMessage() const override { return "Required parameter '" + _param + "' is missing."; } - std::string _param; -}; + private: + std::string getMessage() const override { return "Required parameter '" + _param + "' is missing."; } + std::string _param; + }; -class ClientMustUpgradeError : public Error -{ - public: - ClientMustUpgradeError() : Error {Code::ClientMustUpgrade} {} - private: - std::string getMessage() const override { return "Incompatible Subsonic REST protocol version. Client must upgrade."; } -}; + class ClientMustUpgradeError : public Error + { + public: + ClientMustUpgradeError() : Error{ Code::ClientMustUpgrade } {} + private: + std::string getMessage() const override { return "Incompatible Subsonic REST protocol version. Client must upgrade."; } + }; -class ServerMustUpgradeError : public Error -{ - public: - ServerMustUpgradeError() : Error {Code::ServerMustUpgrade} {} - private: - std::string getMessage() const override { return "Incompatible Subsonic REST protocol version. Server must upgrade."; } -}; + class ServerMustUpgradeError : public Error + { + public: + ServerMustUpgradeError() : Error{ Code::ServerMustUpgrade } {} + private: + std::string getMessage() const override { return "Incompatible Subsonic REST protocol version. Server must upgrade."; } + }; -class WrongUsernameOrPasswordError : public Error -{ - public: - WrongUsernameOrPasswordError() : Error {Code::WrongUsernameOrPassword} {} - private: - std::string getMessage() const override { return "Wrong username or password."; } -}; + class WrongUsernameOrPasswordError : public Error + { + public: + WrongUsernameOrPasswordError() : Error{ Code::WrongUsernameOrPassword } {} + private: + std::string getMessage() const override { return "Wrong username or password."; } + }; -class TokenAuthenticationNotSupportedForLDAPUsersError : public Error -{ - public: - TokenAuthenticationNotSupportedForLDAPUsersError() : Error {Code::TokenAuthenticationNotSupportedForLDAPUsers} {} - private: - std::string getMessage() const override { return "Token authentication not supported for LDAP users."; } -}; + class TokenAuthenticationNotSupportedForLDAPUsersError : public Error + { + public: + TokenAuthenticationNotSupportedForLDAPUsersError() : Error{ Code::TokenAuthenticationNotSupportedForLDAPUsers } {} + private: + std::string getMessage() const override { return "Token authentication not supported for LDAP users."; } + }; -class UserNotAuthorizedError : public Error -{ - public: - UserNotAuthorizedError () : Error {Code::UserNotAuthorized} {} - private: - std::string getMessage() const override { return "User is not authorized for the given operation."; } -}; + class UserNotAuthorizedError : public Error + { + public: + UserNotAuthorizedError() : Error{ Code::UserNotAuthorized } {} + private: + std::string getMessage() const override { return "User is not authorized for the given operation."; } + }; -class RequestedDataNotFoundError : public Error -{ - public: - RequestedDataNotFoundError() : Error {Code::RequestedDataNotFound} {} - private: - std::string getMessage() const override { return "The requested data was not found."; } -}; + class RequestedDataNotFoundError : public Error + { + public: + RequestedDataNotFoundError() : Error{ Code::RequestedDataNotFound } {} + private: + std::string getMessage() const override { return "The requested data was not found."; } + }; -class InternalErrorGenericError : public GenericError -{ - public: - InternalErrorGenericError(const std::string& message) : _message {message} {} - private: - std::string getMessage() const override { return "Internal error: " + _message; } - const std::string _message; -}; + class InternalErrorGenericError : public GenericError + { + public: + InternalErrorGenericError(const std::string& message) : _message{ message } {} + private: + std::string getMessage() const override { return "Internal error: " + _message; } + const std::string _message; + }; -class LoginThrottledGenericError : public GenericError -{ - std::string getMessage() const override { return "Login throttled, too many attempts"; } -}; + class LoginThrottledGenericError : public GenericError + { + std::string getMessage() const override { return "Login throttled, too many attempts"; } + }; -class NotImplementedGenericError : public GenericError -{ - std::string getMessage() const override { return "Not implemented"; } -}; + class NotImplementedGenericError : public GenericError + { + std::string getMessage() const override { return "Not implemented"; } + }; -class UnknownEntryPointGenericError : public GenericError -{ - std::string getMessage() const override { return "Unknown API method"; } -}; + class UnknownEntryPointGenericError : public GenericError + { + std::string getMessage() const override { return "Unknown API method"; } + }; -class PasswordTooWeakGenericError : public GenericError -{ - std::string getMessage() const override { return "Password too weak"; } -}; + class PasswordTooWeakGenericError : public GenericError + { + std::string getMessage() const override { return "Password too weak"; } + }; -class PasswordMustMatchLoginNameGenericError : public GenericError -{ - std::string getMessage() const override { return "Password must match login name"; } -}; + class PasswordMustMatchLoginNameGenericError : public GenericError + { + std::string getMessage() const override { return "Password must match login name"; } + }; -class DemoUserCannotChangePasswordGenericError : public GenericError -{ - std::string getMessage() const override { return "Demo user cannot change its password"; } -}; + class DemoUserCannotChangePasswordGenericError : public GenericError + { + std::string getMessage() const override { return "Demo user cannot change its password"; } + }; -class UserAlreadyExistsGenericError : public GenericError -{ - std::string getMessage() const override { return "User already exists"; } -}; + class UserAlreadyExistsGenericError : public GenericError + { + std::string getMessage() const override { return "User already exists"; } + }; -class BadParameterGenericError : public GenericError -{ - public: - BadParameterGenericError(const std::string& parameterName) : _parameterName {parameterName} {} + class BadParameterGenericError : public GenericError + { + public: + BadParameterGenericError(const std::string& parameterName) : _parameterName{ parameterName } {} - private: - std::string getMessage() const override { return "Parameter '" + _parameterName + "': bad value"; } + private: + std::string getMessage() const override { return "Parameter '" + _parameterName + "': bad value"; } - const std::string _parameterName; -}; + const std::string _parameterName; + }; -class Response -{ - public: - class Node - { - public: - void setAttribute(std::string_view key, std::string_view value); + class Response + { + public: + class Node + { + public: + void setAttribute(std::string_view key, std::string_view value); - template ::value>* = nullptr> - void setAttribute(std::string_view key, T value) - { - if constexpr (std::is_same::value) - _attributes[std::string {key}] = value; - else - _attributes[std::string {key}] = static_cast(value); - } + template ::value>* = nullptr> + void setAttribute(std::string_view key, T value) + { + if constexpr (std::is_same::value) + _attributes[std::string{ key }] = value; + else if constexpr (std::is_floating_point::value) + _attributes[std::string{ key }] = static_cast(value); + else if constexpr (std::is_integral::value) + _attributes[std::string{ key }] = static_cast(value); + else + static_assert("Unhandled type"); + } - // A Node has either a value or some children - void setValue(std::string_view value); - void setValue(long long value); - Node& createChild(const std::string& key); - Node& createArrayChild(const std::string& key); + // A Node has either a single value or an array of values or some children + void setValue(std::string_view value); + void setValue(long long value); + Node& createChild(const std::string& key); + Node& createArrayChild(const std::string& key); - void addChild(const std::string& key, Node node); - void addArrayChild(const std::string& key, Node node); + void addChild(const std::string& key, Node node); + void createEmptyArrayChild(std::string_view key); + void addArrayChild(std::string_view key, Node node); + void createEmptyArrayValue(std::string_view key); + void addArrayValue(std::string_view key, std::string_view value); + void addArrayValue(std::string_view key, long long value); - private: - void setVersionAttribute(ProtocolVersion version); + private: + void setVersionAttribute(ProtocolVersion version); - friend class Response; - using ValueType = std::variant; - std::map _attributes; - std::optional _value; - std::map> _children; - std::map> _childrenArrays; - }; + friend class Response; + using ValueType = std::variant; + std::map _attributes; + std::optional _value; + std::map> _children; + std::map> _childrenArrays; - static Response createOkResponse(ProtocolVersion protocolVersion); - static Response createFailedResponse(ProtocolVersion protocolVersion, const Error& error); + using ValuesType = std::vector; + std::map _childrenValues; + }; - virtual ~Response() {} - Response(const Response&) = delete; - Response& operator=(const Response&) = delete; - Response(Response&&) = default; - Response& operator=(Response&&) = default; + static Response createOkResponse(ProtocolVersion protocolVersion); + static Response createFailedResponse(ProtocolVersion protocolVersion, const Error& error); - void addNode(const std::string& key, Node node); - Node& createNode(const std::string& key); - Node& createArrayNode(const std::string& key); + virtual ~Response() {} + Response(const Response&) = delete; + Response& operator=(const Response&) = delete; + Response(Response&&) = default; + Response& operator=(Response&&) = default; - void write(std::ostream& os, ResponseFormat format); + void addNode(const std::string& key, Node node); + Node& createNode(const std::string& key); + Node& createArrayNode(const std::string& key); - private: - void writeJSON(std::ostream& os); - void writeXML(std::ostream& os); + void write(std::ostream& os, ResponseFormat format); - Response() = default; - Node _root; -}; + private: + void writeJSON(std::ostream& os); + void writeXML(std::ostream& os); + + Response() = default; + Node _root; + }; } // namespace diff --git a/src/libs/subsonic/impl/Utils.cpp b/src/libs/subsonic/impl/Utils.cpp new file mode 100644 index 00000000..ec7a3b6f --- /dev/null +++ b/src/libs/subsonic/impl/Utils.cpp @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2023 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 "utils/Service.hpp" +#include "utils/String.hpp" +#include "services/auth/IPasswordService.hpp" +#include "SubsonicResponse.hpp" + +namespace API::Subsonic::Utils +{ + void checkSetPasswordImplemented() + { + Auth::IPasswordService* passwordService{ Service::get() }; + if (!passwordService || !passwordService->canSetPasswords()) + throw NotImplementedGenericError{}; + } + + std::string makeNameFilesystemCompatible(const std::string& name) + { + return StringUtils::replaceInString(name, "/", "_"); + } + +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/Utils.hpp b/src/libs/subsonic/impl/Utils.hpp new file mode 100644 index 00000000..d94f11b8 --- /dev/null +++ b/src/libs/subsonic/impl/Utils.hpp @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2023 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 API::Subsonic::Utils +{ + void checkSetPasswordImplemented(); + std::string makeNameFilesystemCompatible(const std::string& name); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/AlbumSongLists.cpp b/src/libs/subsonic/impl/entrypoints/AlbumSongLists.cpp new file mode 100644 index 00000000..e0b096db --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/AlbumSongLists.cpp @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2023 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 "AlbumSongLists.hpp" + +#include "services/database/Artist.hpp" +#include "services/database/Cluster.hpp" +#include "services/database/Release.hpp" +#include "services/database/Session.hpp" +#include "services/database/Track.hpp" +#include "services/database/User.hpp" +#include "services/scrobbling/IScrobblingService.hpp" +#include "responses/Album.hpp" +#include "responses/Artist.hpp" +#include "responses/Song.hpp" +#include "utils/Service.hpp" +#include "ParameterParsing.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + namespace { + Response handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) + { + // Mandatory params + const std::string type{ getMandatoryParameterAs(context.parameters, "type") }; + + // Optional params + const std::size_t size{ getParameterAs(context.parameters, "size").value_or(10) }; + const std::size_t offset{ getParameterAs(context.parameters, "offset").value_or(0) }; + + const Range range{ offset, size }; + + RangeResults releases; + Scrobbling::IScrobblingService& scrobbling{ *Service::get() }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + if (type == "alphabeticalByName") + { + Release::FindParameters params; + params.setSortMethod(ReleaseSortMethod::Name); + params.setRange(range); + + releases = Release::find(context.dbSession, params); + } + else if (type == "alphabeticalByArtist") + { + releases = Release::findOrderedByArtist(context.dbSession, range); + } + else if (type == "byGenre") + { + // Mandatory param + const std::string genre{ getMandatoryParameterAs(context.parameters, "genre") }; + + if (const ClusterType::pointer clusterType{ ClusterType::find(context.dbSession, "GENRE") }) + { + if (const Cluster::pointer cluster{ clusterType->getCluster(genre) }) + { + Release::FindParameters params; + params.setClusters({ cluster->getId() }); + params.setSortMethod(ReleaseSortMethod::Name); + params.setRange(range); + + releases = Release::find(context.dbSession, params); + } + } + } + else if (type == "byYear") + { + const int fromYear{ getMandatoryParameterAs(context.parameters, "fromYear") }; + const int toYear{ getMandatoryParameterAs(context.parameters, "toYear") }; + + Release::FindParameters params; + params.setSortMethod(ReleaseSortMethod::Date); + params.setRange(range); + params.setDateRange(DateRange::fromYearRange(fromYear, toYear)); + + releases = Release::find(context.dbSession, params); + } + else if (type == "frequent") + { + releases = scrobbling.getTopReleases(context.userId, {}, range); + } + else if (type == "newest") + { + Release::FindParameters params; + params.setSortMethod(ReleaseSortMethod::LastWritten); + params.setRange(range); + + releases = Release::find(context.dbSession, params); + } + else if (type == "random") + { + // Random results are paginated, but there is no acceptable way to handle the pagination params without repeating some albums + // (no seed provided by subsonic, ot it would require to store some kind of context for each user/client when iterating over the random albums) + Release::FindParameters params; + params.setSortMethod(ReleaseSortMethod::Random); + params.setRange({ 0, size }); + + releases = Release::find(context.dbSession, params); + } + else if (type == "recent") + { + releases = scrobbling.getRecentReleases(context.userId, {}, range); + } + else if (type == "starred") + { + releases = scrobbling.getStarredReleases(context.userId, {}, range); + } + else + throw NotImplementedGenericError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& albumListNode{ response.createNode(id3 ? "albumList2" : "albumList") }; + + for (const ReleaseId releaseId : releases.results) + { + const Release::pointer release{ Release::find(context.dbSession, releaseId) }; + albumListNode.addArrayChild("album", createAlbumNode(release, context.dbSession, user, id3)); + } + + return response; + } + + Response handleGetStarredRequestCommon(RequestContext& context, bool id3) + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& starredNode{ response.createNode(id3 ? "starred2" : "starred") }; + + Scrobbling::IScrobblingService& scrobbling{ *Service::get() }; + + for (const ArtistId artistId : scrobbling.getStarredArtists(context.userId, {} /* clusters */, std::nullopt /* linkType */, ArtistSortMethod::BySortName, Range{}).results) + { + if (auto artist{ Artist::find(context.dbSession, artistId) }) + starredNode.addArrayChild("artist", createArtistNode(artist, context.dbSession, user, id3)); + } + + for (const ReleaseId releaseId : scrobbling.getStarredReleases(context.userId, {} /* clusters */, Range{}).results) + { + if (auto release{ Release::find(context.dbSession, releaseId) }) + starredNode.addArrayChild("album", createAlbumNode(release, context.dbSession, user, id3)); + } + + for (const TrackId trackId : scrobbling.getStarredTracks(context.userId, {} /* clusters */, Range{}).results) + { + if (auto track{ Track::find(context.dbSession, trackId) }) + starredNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); + } + + return response; + } + } // namespace + + Response handleGetAlbumListRequest(RequestContext& context) + { + return handleGetAlbumListRequestCommon(context, false /* no id3 */); + } + + Response handleGetAlbumList2Request(RequestContext& context) + { + return handleGetAlbumListRequestCommon(context, true /* id3 */); + } + + Response handleGetRandomSongsRequest(RequestContext& context) + { + // Optional params + std::size_t size{ getParameterAs(context.parameters, "size").value_or(50) }; + size = std::min(size, std::size_t{ 500 }); + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + const auto trackIds{ Track::find(context.dbSession, Track::FindParameters {}.setSortMethod(TrackSortMethod::Random).setRange({0, size})) }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + Response::Node& randomSongsNode{ response.createNode("randomSongs") }; + for (const TrackId trackId : trackIds.results) + { + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + randomSongsNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); + } + + return response; + } + + Response handleGetSongsByGenreRequest(RequestContext& context) + { + // Mandatory params + std::string genre{ getMandatoryParameterAs(context.parameters, "genre") }; + + // Optional params + std::size_t size{ getParameterAs(context.parameters, "count").value_or(10) }; + size = std::min(size, std::size_t{ 500 }); + + std::size_t offset{ getParameterAs(context.parameters, "offset").value_or(0) }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + auto clusterType{ ClusterType::find(context.dbSession, "GENRE") }; + if (!clusterType) + throw RequestedDataNotFoundError{}; + + auto cluster{ clusterType->getCluster(genre) }; + if (!cluster) + throw RequestedDataNotFoundError{}; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& songsByGenreNode{ response.createNode("songsByGenre") }; + + Track::FindParameters params; + params.setClusters({ cluster->getId() }); + params.setRange({ offset, size }); + + auto trackIds{ Track::find(context.dbSession, params) }; + for (const TrackId trackId : trackIds.results) + { + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + songsByGenreNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); + } + + return response; + } + + Response handleGetStarredRequest(RequestContext& context) + { + return handleGetStarredRequestCommon(context, false /* no id3 */); + } + + Response handleGetStarred2Request(RequestContext& context) + { + return handleGetStarredRequestCommon(context, true /* id3 */); + } + +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/AlbumSongLists.hpp b/src/libs/subsonic/impl/entrypoints/AlbumSongLists.hpp new file mode 100644 index 00000000..3968a78f --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/AlbumSongLists.hpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 API::Subsonic +{ + Response handleGetAlbumListRequest(RequestContext& context); + Response handleGetAlbumList2Request(RequestContext& context); + Response handleGetRandomSongsRequest(RequestContext& context); + Response handleGetSongsByGenreRequest(RequestContext& context); + Response handleGetStarredRequest(RequestContext& context); + Response handleGetStarred2Request(RequestContext& context); +} diff --git a/src/libs/subsonic/impl/entrypoints/Bookmarks.cpp b/src/libs/subsonic/impl/entrypoints/Bookmarks.cpp new file mode 100644 index 00000000..2e007cdf --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Bookmarks.cpp @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2023 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 "Bookmarks.hpp" + +#include "services/database/Session.hpp" +#include "services/database/User.hpp" +#include "services/database/Track.hpp" +#include "services/database/TrackBookmark.hpp" +#include "responses/Bookmark.hpp" +#include "responses/Song.hpp" +#include "ParameterParsing.hpp" +#include "SubsonicId.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + Response handleGetBookmarks(RequestContext& context) + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + const auto bookmarkIds{ TrackBookmark::find(context.dbSession, user->getId(), Range {}) }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& bookmarksNode{ response.createNode("bookmarks") }; + + for (const TrackBookmarkId bookmarkId : bookmarkIds.results) + { + const TrackBookmark::pointer bookmark{ TrackBookmark::find(context.dbSession, bookmarkId) }; + Response::Node bookmarkNode{ createBookmarkNode(bookmark) }; + bookmarkNode.addArrayChild("entry", createSongNode(bookmark->getTrack(), context.dbSession, user)); + + bookmarksNode.addArrayChild("bookmark", std::move(bookmarkNode)); + } + + return response; + } + + Response handleCreateBookmark(RequestContext& context) + { + // Mandatory params + TrackId trackId{ getMandatoryParameterAs(context.parameters, "id") }; + unsigned long position{ getMandatoryParameterAs(context.parameters, "position") }; + const std::optional comment{ getParameterAs(context.parameters, "comment") }; + + auto transaction{ context.dbSession.createUniqueTransaction() }; + + const User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + if (!track) + throw RequestedDataNotFoundError{}; + + // Replace any existing bookmark + auto bookmark{ TrackBookmark::find(context.dbSession, user->getId(), trackId) }; + if (!bookmark) + bookmark = context.dbSession.create(user, track); + + bookmark.modify()->setOffset(std::chrono::milliseconds{ position }); + if (comment) + bookmark.modify()->setComment(*comment); + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleDeleteBookmark(RequestContext& context) + { + // Mandatory params + TrackId trackId{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createUniqueTransaction() }; + + auto bookmark{ TrackBookmark::find(context.dbSession, context.userId, trackId) }; + if (!bookmark) + throw RequestedDataNotFoundError{}; + + bookmark.remove(); + + return Response::createOkResponse(context.serverProtocolVersion); + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/Bookmarks.hpp b/src/libs/subsonic/impl/entrypoints/Bookmarks.hpp new file mode 100644 index 00000000..e1b6d5cd --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Bookmarks.hpp @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2023 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 API::Subsonic +{ + Response handleGetBookmarks(RequestContext& context); + Response handleCreateBookmark(RequestContext& context); + Response handleDeleteBookmark(RequestContext& context); +} diff --git a/src/libs/subsonic/impl/entrypoints/Browsing.cpp b/src/libs/subsonic/impl/entrypoints/Browsing.cpp new file mode 100644 index 00000000..44202630 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Browsing.cpp @@ -0,0 +1,465 @@ +/* + * Copyright (C) 2023 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 "Browsing.hpp" + +#include "services/database/Artist.hpp" +#include "services/database/Cluster.hpp" +#include "services/database/Session.hpp" +#include "services/database/Release.hpp" +#include "services/database/Track.hpp" +#include "services/database/User.hpp" +#include "services/recommendation/IRecommendationService.hpp" +#include "utils/Random.hpp" +#include "utils/Service.hpp" +#include "responses/Album.hpp" +#include "responses/Artist.hpp" +#include "responses/Genre.hpp" +#include "responses/Song.hpp" +#include "ParameterParsing.hpp" +#include "SubsonicId.hpp" +#include "Utils.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + static const std::string_view reportedDummyDate{ "2000-01-01T00:00:00" }; + static const unsigned long long reportedDummyDateULong{ 946684800000ULL }; // 2000-01-01T00:00:00 UTC + + namespace + { + Response handleGetArtistInfoRequestCommon(RequestContext& context, bool id3) + { + // Mandatory params + ArtistId id{ getMandatoryParameterAs(context.parameters, "id") }; + + // Optional params + std::size_t count{ getParameterAs(context.parameters, "count").value_or(20) }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& artistInfoNode{ response.createNode(id3 ? "artistInfo2" : "artistInfo") }; + + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + const Artist::pointer artist{ Artist::find(context.dbSession, id) }; + if (!artist) + throw RequestedDataNotFoundError{}; + + std::optional artistMBID{ artist->getMBID() }; + if (artistMBID) + artistInfoNode.createChild("musicBrainzId").setValue(artistMBID->getAsString()); + } + + auto similarArtistsId{ Service::get()->getSimilarArtists(id, {TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}, count) }; + + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + for (const ArtistId similarArtistId : similarArtistsId) + { + const Artist::pointer similarArtist{ Artist::find(context.dbSession, similarArtistId) }; + if (similarArtist) + artistInfoNode.addArrayChild("similarArtist", createArtistNode(similarArtist, context.dbSession, user, id3)); + } + } + + return response; + } + + Response handleGetArtistsRequestCommon(RequestContext& context, bool id3) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + Response::Node& artistsNode{ response.createNode(id3 ? "artists" : "indexes") }; + artistsNode.setAttribute("ignoredArticles", ""); + artistsNode.setAttribute("lastModified", reportedDummyDateULong); + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Artist::FindParameters parameters; + parameters.setSortMethod(ArtistSortMethod::BySortName); + switch (user->getSubsonicArtistListMode()) + { + case SubsonicArtistListMode::AllArtists: + break; + case SubsonicArtistListMode::ReleaseArtists: + parameters.setLinkType(TrackArtistLinkType::ReleaseArtist); + break; + case SubsonicArtistListMode::TrackArtists: + parameters.setLinkType(TrackArtistLinkType::Artist); + break; + } + + std::map> artistsSortedByFirstChar; + const RangeResults artists{ Artist::find(context.dbSession, parameters) }; + for (const ArtistId artistId : artists.results) + { + const Artist::pointer artist{ Artist::find(context.dbSession, artistId) }; + const std::string& sortName{ artist->getSortName() }; + + char sortChar; + if (sortName.empty() || !std::isalpha(sortName[0])) + sortChar = '?'; + else + sortChar = std::toupper(sortName[0]); + + artistsSortedByFirstChar[sortChar].push_back(artist); + } + + + for (const auto& [sortChar, artists] : artistsSortedByFirstChar) + { + Response::Node& indexNode{ artistsNode.createArrayChild("index") }; + indexNode.setAttribute("name", std::string{ sortChar }); + + for (const Artist::pointer& artist : artists) + indexNode.addArrayChild("artist", createArtistNode(artist, context.dbSession, user, id3)); + } + + return response; + } + + std::vector findSimilarSongs(RequestContext& context, ArtistId artistId, std::size_t count) + { + // API says: "Returns a random collection of songs from the given artist and similar artists" + const std::size_t similarArtistCount{ count / 5 }; + std::vector artistIds{ Service::get()->getSimilarArtists(artistId, {TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}, similarArtistCount) }; + artistIds.push_back(artistId); + + const std::size_t meanTrackCountPerArtist{ (count / artistIds.size()) + 1 }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + std::vector tracks; + tracks.reserve(count); + + for (const ArtistId id : artistIds) + { + Track::FindParameters params; + params.setArtist(id); + params.setRange({ 0, meanTrackCountPerArtist }); + params.setSortMethod(TrackSortMethod::Random); + + const auto artistTracks{ Track::find(context.dbSession, params) }; + tracks.insert(std::end(tracks), + std::begin(artistTracks.results), + std::end(artistTracks.results)); + } + + return tracks; + } + + std::vector findSimilarSongs(RequestContext& context, ReleaseId releaseId, std::size_t count) + { + // API says: "Returns a random collection of songs from the given artist and similar artists" + // so let's extend this for release + const std::size_t similarReleaseCount{ count / 5 }; + std::vector releaseIds{ Service::get()->getSimilarReleases(releaseId, similarReleaseCount) }; + releaseIds.push_back(releaseId); + + const std::size_t meanTrackCountPerRelease{ (count / releaseIds.size()) + 1 }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + std::vector tracks; + tracks.reserve(count); + + for (const ReleaseId id : releaseIds) + { + Track::FindParameters params; + params.setRelease(id); + params.setRange({ 0, meanTrackCountPerRelease }); + params.setSortMethod(TrackSortMethod::Random); + + const auto releaseTracks{ Track::find(context.dbSession, params) }; + tracks.insert(std::end(tracks), + std::begin(releaseTracks.results), + std::end(releaseTracks.results)); + } + + return tracks; + } + + std::vector findSimilarSongs(RequestContext&, TrackId trackId, std::size_t count) + { + return Service::get()->findSimilarTracks({ trackId }, count); + } + + Response handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) + { + // Optional params + std::size_t count{ getParameterAs(context.parameters, "count").value_or(50) }; + + std::vector tracks; + + if (const auto artistId{ getParameterAs(context.parameters, "id") }) + tracks = findSimilarSongs(context, *artistId, count); + else if (const auto releaseId{ getParameterAs(context.parameters, "id") }) + tracks = findSimilarSongs(context, *releaseId, count); + else if (const auto trackId{ getParameterAs(context.parameters, "id") }) + tracks = findSimilarSongs(context, *trackId, count); + else + throw BadParameterGenericError{ "id" }; + + Random::shuffleContainer(tracks); + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& similarSongsNode{ response.createNode(id3 ? "similarSongs2" : "similarSongs") }; + for (const TrackId trackId : tracks) + { + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + similarSongsNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); + } + + return response; + } + + } + + Response handleGetMusicFoldersRequest(RequestContext& context) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& musicFoldersNode{ response.createNode("musicFolders") }; + + Response::Node& musicFolderNode{ musicFoldersNode.createArrayChild("musicFolder") }; + musicFolderNode.setAttribute("id", "0"); + musicFolderNode.setAttribute("name", "Music"); + + return response; + } + + Response handleGetIndexesRequest(RequestContext& context) + { + return handleGetArtistsRequestCommon(context, false /* no id3 */); + } + + Response handleGetMusicDirectoryRequest(RequestContext& context) + { + // Mandatory params + const auto artistId{ getParameterAs(context.parameters, "id") }; + const auto releaseId{ getParameterAs(context.parameters, "id") }; + const auto root{ getParameterAs(context.parameters, "id") }; + + if (!root && !artistId && !releaseId) + throw BadParameterGenericError{ "id" }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& directoryNode{ response.createNode("directory") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + if (root) + { + directoryNode.setAttribute("id", idToString(RootId{})); + directoryNode.setAttribute("name", "Music"); + + auto rootArtistIds{ Artist::find(context.dbSession, Artist::FindParameters {}.setSortMethod(ArtistSortMethod::BySortName)) }; + for (const ArtistId rootArtistId : rootArtistIds.results) + { + const Artist::pointer artist{ Artist::find(context.dbSession, rootArtistId) }; + directoryNode.addArrayChild("child", createArtistNode(artist, context.dbSession, user, false /* no id3 */)); + } + } + else if (artistId) + { + directoryNode.setAttribute("id", idToString(*artistId)); + + auto artist{ Artist::find(context.dbSession, *artistId) }; + if (!artist) + throw RequestedDataNotFoundError{}; + + directoryNode.setAttribute("name", Utils::makeNameFilesystemCompatible(artist->getName())); + + const auto artistReleases{ Release::find(context.dbSession, Release::FindParameters {}.setArtist(*artistId)) }; + for (const ReleaseId artistReleaseId : artistReleases.results) + { + const Release::pointer release{ Release::find(context.dbSession, artistReleaseId) }; + directoryNode.addArrayChild("child", createAlbumNode(release, context.dbSession, user, false /* no id3 */)); + } + } + else if (releaseId) + { + directoryNode.setAttribute("id", idToString(*releaseId)); + + auto release{ Release::find(context.dbSession, *releaseId) }; + if (!release) + throw RequestedDataNotFoundError{}; + + directoryNode.setAttribute("name", Utils::makeNameFilesystemCompatible(release->getName())); + + const auto tracks{ Track::find(context.dbSession, Track::FindParameters {}.setRelease(*releaseId).setSortMethod(TrackSortMethod::Release)) }; + for (const TrackId trackId : tracks.results) + { + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + directoryNode.addArrayChild("child", createSongNode(track, context.dbSession, user)); + } + } + else + throw BadParameterGenericError{ "id" }; + + return response; + } + + Response handleGetGenresRequest(RequestContext& context) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + Response::Node& genresNode{ response.createNode("genres") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + const ClusterType::pointer clusterType{ ClusterType::find(context.dbSession, "GENRE") }; + if (clusterType) + { + const auto clusters{ clusterType->getClusters() }; + + for (const Cluster::pointer& cluster : clusters) + genresNode.addArrayChild("genre", createGenreNode(cluster)); + } + + return response; + } + + Response handleGetArtistsRequest(RequestContext& context) + { + return handleGetArtistsRequestCommon(context, true /* id3 */); + } + + Response handleGetArtistRequest(RequestContext& context) + { + // Mandatory params + ArtistId id{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + const Artist::pointer artist{ Artist::find(context.dbSession, id) }; + if (!artist) + throw RequestedDataNotFoundError{}; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node artistNode{ createArtistNode(artist, context.dbSession, user, true /* id3 */) }; + + const auto releases{ Release::find(context.dbSession, Release::FindParameters {}.setArtist(artist->getId())) }; + for (const ReleaseId releaseId : releases.results) + { + const Release::pointer release{ Release::find(context.dbSession, releaseId) }; + artistNode.addArrayChild("album", createAlbumNode(release, context.dbSession, user, true /* id3 */)); + } + + response.addNode("artist", std::move(artistNode)); + + return response; + } + + Response handleGetAlbumRequest(RequestContext& context) + { + // Mandatory params + ReleaseId id{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + Release::pointer release{ Release::find(context.dbSession, id) }; + if (!release) + throw RequestedDataNotFoundError{}; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node albumNode{ createAlbumNode(release, context.dbSession, user, true /* id3 */) }; + + const auto tracks{ Track::find(context.dbSession, Track::FindParameters {}.setRelease(id).setSortMethod(TrackSortMethod::Release)) }; + for (const TrackId trackId : tracks.results) + { + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + albumNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); + } + + response.addNode("album", std::move(albumNode)); + + return response; + } + + Response handleGetSongRequest(RequestContext& context) + { + // Mandatory params + TrackId id{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + const Track::pointer track{ Track::find(context.dbSession, id) }; + if (!track) + throw RequestedDataNotFoundError{}; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + response.addNode("song", createSongNode(track, context.dbSession, user)); + + return response; + } + + Response handleGetArtistInfoRequest(RequestContext& context) + { + return handleGetArtistInfoRequestCommon(context, false /* no id3 */); + } + + Response handleGetArtistInfo2Request(RequestContext& context) + { + return handleGetArtistInfoRequestCommon(context, true /* id3 */); + } + + Response handleGetSimilarSongsRequest(RequestContext& context) + { + return handleGetSimilarSongsRequestCommon(context, false /* no id3 */); + } + + Response handleGetSimilarSongs2Request(RequestContext& context) + { + return handleGetSimilarSongsRequestCommon(context, true /* id3 */); + } + +} diff --git a/src/libs/subsonic/impl/entrypoints/Browsing.hpp b/src/libs/subsonic/impl/entrypoints/Browsing.hpp new file mode 100644 index 00000000..3253db39 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Browsing.hpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 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 API::Subsonic +{ + Response handleGetMusicFoldersRequest(RequestContext& context); + Response handleGetIndexesRequest(RequestContext& context); + Response handleGetMusicDirectoryRequest(RequestContext& context); + Response handleGetGenresRequest(RequestContext& context); + Response handleGetArtistsRequest(RequestContext& context); + Response handleGetArtistRequest(RequestContext& context); + Response handleGetAlbumRequest(RequestContext& context); + Response handleGetSongRequest(RequestContext& context); + Response handleGetArtistInfoRequest(RequestContext& context); + Response handleGetArtistInfo2Request(RequestContext& context); + Response handleGetSimilarSongsRequest(RequestContext& context); + Response handleGetSimilarSongs2Request(RequestContext& context); +} diff --git a/src/libs/subsonic/impl/entrypoints/MediaAnnotation.cpp b/src/libs/subsonic/impl/entrypoints/MediaAnnotation.cpp new file mode 100644 index 00000000..96aa7c2c --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/MediaAnnotation.cpp @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2023 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 "MediaAnnotation.hpp" + +#include + +#include "services/database/ArtistId.hpp" +#include "services/database/ReleaseId.hpp" +#include "services/database/TrackId.hpp" +#include "services/scrobbling/IScrobblingService.hpp" +#include "utils/Service.hpp" +#include "ParameterParsing.hpp" +#include "SubsonicId.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + namespace + { + struct StarParameters + { + std::vector artistIds; + std::vector releaseIds; + std::vector trackIds; + }; + + StarParameters getStarParameters(const Wt::Http::ParameterMap& parameters) + { + StarParameters res; + + // TODO handle parameters for legacy file browsing + res.trackIds = getMultiParametersAs(parameters, "id"); + res.artistIds = getMultiParametersAs(parameters, "artistId"); + res.releaseIds = getMultiParametersAs(parameters, "albumId"); + + return res; + } + } + + Response handleStarRequest(RequestContext& context) + { + StarParameters params{ getStarParameters(context.parameters) }; + + for (const ArtistId id : params.artistIds) + Service::get()->star(context.userId, id); + + for (const ReleaseId id : params.releaseIds) + Service::get()->star(context.userId, id); + + for (const TrackId id : params.trackIds) + Service::get()->star(context.userId, id); + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleUnstarRequest(RequestContext& context) + { + StarParameters params{ getStarParameters(context.parameters) }; + + for (const ArtistId id : params.artistIds) + Service::get()->unstar(context.userId, id); + + for (const ReleaseId id : params.releaseIds) + Service::get()->unstar(context.userId, id); + + for (const TrackId id : params.trackIds) + Service::get()->unstar(context.userId, id); + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleScrobble(RequestContext& context) + { + const std::vector ids{ getMandatoryMultiParametersAs(context.parameters, "id") }; + const std::vector times{ getMultiParametersAs(context.parameters, "time") }; + const bool submission{ getParameterAs(context.parameters, "submission").value_or(true) }; + + // playing now => no time to be provided + if (!submission && !times.empty()) + throw BadParameterGenericError{ "time" }; + + // playing now => only one at a time + if (!submission && ids.size() > 1) + throw BadParameterGenericError{ "id" }; + + // if multiple submissions, must have times + if (ids.size() > 1 && ids.size() != times.size()) + throw BadParameterGenericError{ "time" }; + + if (!submission) + { + Service::get()->listenStarted({ context.userId, ids.front() }); + } + else + { + if (times.empty()) + { + Service::get()->listenFinished({ context.userId, ids.front() }); + } + else + { + for (std::size_t i{}; i < ids.size(); ++i) + { + const TrackId trackId{ ids[i] }; + const unsigned long time{ times[i] }; + Service::get()->addTimedListen({ {context.userId, trackId}, Wt::WDateTime::fromTime_t(static_cast(time / 1000)) }); + } + } + } + + return Response::createOkResponse(context.serverProtocolVersion); + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/MediaAnnotation.hpp b/src/libs/subsonic/impl/entrypoints/MediaAnnotation.hpp new file mode 100644 index 00000000..593d3bd3 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/MediaAnnotation.hpp @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2023 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 API::Subsonic +{ + Response handleStarRequest(RequestContext& context); + Response handleUnstarRequest(RequestContext& context); + Response handleScrobble(RequestContext& context); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/MediaLibraryScanning.cpp b/src/libs/subsonic/impl/entrypoints/MediaLibraryScanning.cpp new file mode 100644 index 00000000..b328553e --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/MediaLibraryScanning.cpp @@ -0,0 +1,71 @@ +/* + * 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 "MediaLibraryScanning.hpp" + +#include "services/scanner/IScannerService.hpp" +#include "utils/Service.hpp" + +namespace API::Subsonic::Scan +{ + using namespace Scanner; + + namespace + { + Response::Node + createStatusResponseNode() + { + Response::Node statusResponse; + + const IScannerService::Status scanStatus{ Service::get()->getStatus() }; + + statusResponse.setAttribute("scanning", scanStatus.currentState == IScannerService::State::InProgress); + if (scanStatus.currentState == IScannerService::State::InProgress) + { + std::size_t count{}; + + if (scanStatus.currentScanStepStats && scanStatus.currentScanStepStats->currentStep == ScanStep::ScanningFiles) + count = scanStatus.currentScanStepStats->processedElems; + + statusResponse.setAttribute("count", count); + } + + return statusResponse; + } + } + + Response handleGetScanStatus(RequestContext& context) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + response.addNode("scanStatus", createStatusResponseNode()); + + return response; + } + + Response handleStartScan(RequestContext& context) + { + Service::get()->requestImmediateScan(false); + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + response.addNode("scanStatus", createStatusResponseNode()); + + return response; + } +} + diff --git a/src/libs/subsonic/impl/Scan.hpp b/src/libs/subsonic/impl/entrypoints/MediaLibraryScanning.hpp similarity index 87% rename from src/libs/subsonic/impl/Scan.hpp rename to src/libs/subsonic/impl/entrypoints/MediaLibraryScanning.hpp index 3074a7b5..3fd5329c 100644 --- a/src/libs/subsonic/impl/Scan.hpp +++ b/src/libs/subsonic/impl/entrypoints/MediaLibraryScanning.hpp @@ -24,7 +24,7 @@ namespace API::Subsonic::Scan { - Response handleGetScanStatus(RequestContext& context); - Response handleStartScan(RequestContext& context); + Response handleGetScanStatus(RequestContext& context); + Response handleStartScan(RequestContext& context); } diff --git a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp new file mode 100644 index 00000000..d3343106 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp @@ -0,0 +1,202 @@ +/* + * 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 "MediaRetrieval.hpp" + +#include "av/TranscodeParameters.hpp" +#include "av/TranscodeResourceHandlerCreator.hpp" +#include "av/Types.hpp" +#include "services/cover/ICoverService.hpp" +#include "services/database/Session.hpp" +#include "services/database/Track.hpp" +#include "services/database/User.hpp" +#include "utils/IResourceHandler.hpp" +#include "utils/Logger.hpp" +#include "utils/FileResourceHandlerCreator.hpp" +#include "utils/Utils.hpp" +#include "ParameterParsing.hpp" +#include "SubsonicId.hpp" + +using namespace Database; + +namespace API::Subsonic +{ + namespace { + Av::Format userTranscodeFormatToAvFormat(AudioFormat format) + { + switch (format) + { + case AudioFormat::MP3: return Av::Format::MP3; + case AudioFormat::OGG_OPUS: return Av::Format::OGG_OPUS; + case AudioFormat::MATROSKA_OPUS: return Av::Format::MATROSKA_OPUS; + case AudioFormat::OGG_VORBIS: return Av::Format::OGG_VORBIS; + case AudioFormat::WEBM_VORBIS: return Av::Format::WEBM_VORBIS; + default: return Av::Format::OGG_OPUS; + } + } + + struct StreamParameters + { + Av::InputFileParameters inputFileParameters; + std::optional transcodeParameters; + bool estimateContentLength{}; + }; + + StreamParameters getStreamParameters(RequestContext& context) + { + // Mandatory params + const TrackId id{ getMandatoryParameterAs(context.parameters, "id") }; + + // Optional params + std::optional maxBitRate{ getParameterAs(context.parameters, "maxBitRate") }; + const std::optional format{ getParameterAs(context.parameters, "format") }; + const std::optional timeOffset{ getParameterAs(context.parameters, "timeOffset") }; + bool estimateContentLength{ getParameterAs(context.parameters, "estimateContentLength").value_or(false) }; + + StreamParameters parameters; + + parameters.estimateContentLength = estimateContentLength; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + { + const auto track{ Track::find(context.dbSession, id) }; + if (!track) + throw RequestedDataNotFoundError{}; + + parameters.inputFileParameters.trackPath = track->getPath(); + parameters.inputFileParameters.duration = track->getDuration(); + } + + { + const User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + // format = "raw" => no transcode. Other format values will be ignored + const bool transcode{ (!format || (*format != "raw")) && user->getSubsonicTranscodeEnable() }; + if (transcode) + { + std::size_t bitRate{ user->getSubsonicTranscodeBitrate() / 1000 }; + + // "If set to zero, no limit is imposed" + if (maxBitRate && *maxBitRate != 0) + bitRate = Utils::clamp(*maxBitRate, std::size_t{ 48 }, bitRate); + + Av::TranscodeParameters transcodeParameters; + + transcodeParameters.bitrate = bitRate * 1000; + transcodeParameters.format = userTranscodeFormatToAvFormat(user->getSubsonicTranscodeFormat()); + transcodeParameters.stripMetadata = false; // We want clients to use metadata (offline use, replay gain, etc.) + transcodeParameters.offset = std::chrono::seconds{ timeOffset ? *timeOffset : 0 }; + + parameters.transcodeParameters = std::move(transcodeParameters); + } + } + + return parameters; + } + } + + void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response) + { + std::shared_ptr resourceHandler; + + Wt::Http::ResponseContinuation* continuation{ request.continuation() }; + if (!continuation) + { + // Mandatory params + Database::TrackId id{ getMandatoryParameterAs(context.parameters, "id") }; + + std::filesystem::path trackPath; + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + auto track{ Track::find(context.dbSession, id) }; + if (!track) + throw RequestedDataNotFoundError{}; + + trackPath = track->getPath(); + } + + resourceHandler = createFileResourceHandler(trackPath); + } + else + { + resourceHandler = Wt::cpp17::any_cast>(continuation->data()); + } + + continuation = resourceHandler->processRequest(request, response); + if (continuation) + continuation->setData(resourceHandler); + } + + void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response) + { + std::shared_ptr resourceHandler; + + try + { + Wt::Http::ResponseContinuation* continuation = request.continuation(); + if (!continuation) + { + StreamParameters streamParameters{ getStreamParameters(context) }; + if (streamParameters.transcodeParameters) + resourceHandler = Av::createTranscodeResourceHandler(streamParameters.inputFileParameters, *streamParameters.transcodeParameters, streamParameters.estimateContentLength); + else + resourceHandler = createFileResourceHandler(streamParameters.inputFileParameters.trackPath); + } + else + { + resourceHandler = Wt::cpp17::any_cast>(continuation->data()); + } + + continuation = resourceHandler->processRequest(request, response); + if (continuation) + continuation->setData(resourceHandler); + } + catch (const Av::Exception& e) + { + LMS_LOG(API_SUBSONIC, ERROR) << "Caught Av exception: " << e.what(); + } + } + + void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& /*request*/, Wt::Http::Response& response) + { + // Mandatory params + const auto trackId{ getParameterAs(context.parameters, "id") }; + const auto releaseId{ getParameterAs(context.parameters, "id") }; + + if (!trackId && !releaseId) + throw BadParameterGenericError{ "id" }; + + std::size_t size{ getParameterAs(context.parameters, "size").value_or(1024) }; + size = ::Utils::clamp(size, std::size_t{ 32 }, std::size_t{ 2048 }); + + std::shared_ptr cover; + if (trackId) + cover = Service::get()->getFromTrack(*trackId, size); + else if (releaseId) + cover = Service::get()->getFromRelease(*releaseId, size); + + response.out().write(reinterpret_cast(cover->getData()), cover->getDataSize()); + response.setMimeType(std::string{ cover->getMimeType() }); + } + +} // namespace API::Subsonic diff --git a/src/libs/subsonic/impl/Stream.hpp b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp similarity index 68% rename from src/libs/subsonic/impl/Stream.hpp rename to src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp index c2de5709..4d2944a4 100644 --- a/src/libs/subsonic/impl/Stream.hpp +++ b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp @@ -24,9 +24,10 @@ #include "RequestContext.hpp" -namespace API::Subsonic::Stream +namespace API::Subsonic { - void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); - void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); + void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); + void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); + void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); } diff --git a/src/libs/subsonic/impl/entrypoints/Playlists.cpp b/src/libs/subsonic/impl/entrypoints/Playlists.cpp new file mode 100644 index 00000000..049a8dd7 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Playlists.cpp @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2023 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 "Playlists.hpp" + +#include "services/database/Session.hpp" +#include "services/database/Track.hpp" +#include "services/database/TrackList.hpp" +#include "services/database/User.hpp" +#include "responses/Playlist.hpp" +#include "responses/Song.hpp" +#include "ParameterParsing.hpp" +#include "SubsonicId.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + Response handleGetPlaylistsRequest(RequestContext& context) + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& playlistsNode{ response.createNode("playlists") }; + + TrackList::FindParameters params; + params.setUser(context.userId); + params.setType(TrackListType::Playlist); + + auto tracklistIds{ TrackList::find(context.dbSession, params) }; + for (const TrackListId trackListId : tracklistIds.results) + { + const TrackList::pointer trackList{ TrackList::find(context.dbSession, trackListId) }; + playlistsNode.addArrayChild("playlist", createPlaylistNode(trackList, context.dbSession)); + } + + return response; + } + + Response handleGetPlaylistRequest(RequestContext& context) + { + // Mandatory params + TrackListId trackListId{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + TrackList::pointer tracklist{ TrackList::find(context.dbSession, trackListId) }; + if (!tracklist) + throw RequestedDataNotFoundError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node playlistNode{ createPlaylistNode(tracklist, context.dbSession) }; + + auto entries{ tracklist->getEntries() }; + for (const TrackListEntry::pointer& entry : entries) + playlistNode.addArrayChild("entry", createSongNode(entry->getTrack(), context.dbSession, user)); + + response.addNode("playlist", playlistNode); + + return response; + } + + Response handleCreatePlaylistRequest(RequestContext& context) + { + // Optional params + const auto id{ getParameterAs(context.parameters, "playlistId") }; + auto name{ getParameterAs(context.parameters, "name") }; + + std::vector trackIds{ getMultiParametersAs(context.parameters, "songId") }; + + if (!name && !id) + throw RequiredParameterMissingError{ "name or id" }; + + auto transaction{ context.dbSession.createUniqueTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + TrackList::pointer tracklist; + if (id) + { + tracklist = TrackList::find(context.dbSession, *id); + if (!tracklist + || tracklist->getUser() != user + || tracklist->getType() != TrackListType::Playlist) + { + throw RequestedDataNotFoundError{}; + } + + if (name) + tracklist.modify()->setName(*name); + } + else + { + tracklist = context.dbSession.create(*name, TrackListType::Playlist, false, user); + } + + for (const TrackId trackId : trackIds) + { + Track::pointer track{ Track::find(context.dbSession, trackId) }; + if (!track) + continue; + + context.dbSession.create(track, tracklist); + } + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleUpdatePlaylistRequest(RequestContext& context) + { + // Mandatory params + TrackListId id{ getMandatoryParameterAs(context.parameters, "playlistId") }; + + // Optional parameters + auto name{ getParameterAs(context.parameters, "name") }; + auto isPublic{ getParameterAs(context.parameters, "public") }; + + std::vector trackIdsToAdd{ getMultiParametersAs(context.parameters, "songIdToAdd") }; + std::vector trackPositionsToRemove{ getMultiParametersAs(context.parameters, "songIndexToRemove") }; + + auto transaction{ context.dbSession.createUniqueTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + TrackList::pointer tracklist{ TrackList::find(context.dbSession, id) }; + if (!tracklist + || tracklist->getUser() != user + || tracklist->getType() != TrackListType::Playlist) + { + throw RequestedDataNotFoundError{}; + } + + if (name) + tracklist.modify()->setName(*name); + + if (isPublic) + tracklist.modify()->setIsPublic(*isPublic); + + { + // Remove from end to make indexes stable + std::sort(std::begin(trackPositionsToRemove), std::end(trackPositionsToRemove), std::greater()); + + for (std::size_t trackPositionToRemove : trackPositionsToRemove) + { + auto entry{ tracklist->getEntry(trackPositionToRemove) }; + if (entry) + entry.remove(); + } + } + + // Add tracks + for (const TrackId trackIdToAdd : trackIdsToAdd) + { + Track::pointer track{ Track::find(context.dbSession, trackIdToAdd) }; + if (!track) + continue; + + context.dbSession.create(track, tracklist); + } + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleDeletePlaylistRequest(RequestContext& context) + { + TrackListId id{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createUniqueTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + TrackList::pointer tracklist{ TrackList::find(context.dbSession, id) }; + if (!tracklist + || tracklist->getUser() != user + || tracklist->getType() != TrackListType::Playlist) + { + throw RequestedDataNotFoundError{}; + } + + tracklist.remove(); + + return Response::createOkResponse(context.serverProtocolVersion); + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/Playlists.hpp b/src/libs/subsonic/impl/entrypoints/Playlists.hpp new file mode 100644 index 00000000..a9d44093 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Playlists.hpp @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2023 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 API::Subsonic +{ + Response handleGetPlaylistsRequest(RequestContext& context); + Response handleGetPlaylistRequest(RequestContext& context); + Response handleCreatePlaylistRequest(RequestContext& context); + Response handleUpdatePlaylistRequest(RequestContext& context); + Response handleDeletePlaylistRequest(RequestContext& context); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/Searching.cpp b/src/libs/subsonic/impl/entrypoints/Searching.cpp new file mode 100644 index 00000000..59f3cfdd --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Searching.cpp @@ -0,0 +1,127 @@ +/* + * 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 "Searching.hpp" + +#include "services/database/Artist.hpp" +#include "services/database/Release.hpp" +#include "services/database/Session.hpp" +#include "services/database/Track.hpp" +#include "services/database/User.hpp" +#include "responses/Album.hpp" +#include "responses/Artist.hpp" +#include "responses/Song.hpp" +#include "ParameterParsing.hpp" + +#include "ParameterParsing.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + namespace + { + Response handleSearchRequestCommon(RequestContext& context, bool id3) + { + // Mandatory params + std::string queryString{ getMandatoryParameterAs(context.parameters, "query") }; + std::string_view query{ queryString }; + + // Symfonium adds extra "" + if (context.clientInfo.name == "Symfonium") + query = StringUtils::stringTrim(query, "\""); + + std::vector keywords{ StringUtils::splitString(query, " ") }; + + // Optional params + std::size_t artistCount{ getParameterAs(context.parameters, "artistCount").value_or(20) }; + std::size_t artistOffset{ getParameterAs(context.parameters, "artistOffset").value_or(0) }; + std::size_t albumCount{ getParameterAs(context.parameters, "albumCount").value_or(20) }; + std::size_t albumOffset{ getParameterAs(context.parameters, "albumOffset").value_or(0) }; + std::size_t songCount{ getParameterAs(context.parameters, "songCount").value_or(20) }; + std::size_t songOffset{ getParameterAs(context.parameters, "songOffset").value_or(0) }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& searchResult2Node{ response.createNode(id3 ? "searchResult3" : "searchResult2") }; + + if (artistCount > 0) + { + Artist::FindParameters params; + params.setKeywords(keywords); + params.setSortMethod(ArtistSortMethod::BySortName); + params.setRange({ artistOffset, artistCount }); + + RangeResults artistIds{ Artist::find(context.dbSession, params) }; + for (const ArtistId artistId : artistIds.results) + { + const auto artist{ Artist::find(context.dbSession, artistId) }; + searchResult2Node.addArrayChild("artist", createArtistNode(artist, context.dbSession, user, id3)); + } + } + + if (albumCount > 0) + { + Release::FindParameters params; + params.setKeywords(keywords); + params.setSortMethod(ReleaseSortMethod::Name); + params.setRange({ albumOffset, albumCount }); + + RangeResults releaseIds{ Release::find(context.dbSession, params) }; + for (const ReleaseId releaseId : releaseIds.results) + { + const auto release{ Release::find(context.dbSession, releaseId) }; + searchResult2Node.addArrayChild("album", createAlbumNode(release, context.dbSession, user, id3)); + } + } + + if (songCount > 0) + { + Track::FindParameters params; + params.setKeywords(keywords); + params.setRange({ songOffset, songCount }); + + RangeResults trackIds{ Track::find(context.dbSession, params) }; + for (const TrackId trackId : trackIds.results) + { + const auto track{ Track::find(context.dbSession, trackId) }; + searchResult2Node.addArrayChild("song", createSongNode(track, context.dbSession, user)); + } + } + + return response; + } + } + + Response handleSearch2Request(RequestContext& context) + { + return handleSearchRequestCommon(context, false /* no id3 */); + } + + Response handleSearch3Request(RequestContext& context) + { + return handleSearchRequestCommon(context, true /* id3 */); + } + +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/Searching.hpp b/src/libs/subsonic/impl/entrypoints/Searching.hpp new file mode 100644 index 00000000..1c8b00ac --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Searching.hpp @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023 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 API::Subsonic +{ + Response handleSearch2Request(RequestContext& context); + Response handleSearch3Request(RequestContext& context); +} diff --git a/src/libs/subsonic/impl/entrypoints/System.cpp b/src/libs/subsonic/impl/entrypoints/System.cpp new file mode 100644 index 00000000..11d7b350 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/System.cpp @@ -0,0 +1,34 @@ +#include "entrypoints/System.hpp" + +namespace API::Subsonic +{ + Response handlePingRequest(RequestContext& context) + { + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleGetLicenseRequest(RequestContext& context) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + Response::Node& licenseNode{ response.createNode("license") }; + licenseNode.setAttribute("licenseExpires", "2025-09-03T14:46:43"); + licenseNode.setAttribute("email", "foo@bar.com"); + licenseNode.setAttribute("valid", true); + + return response; + } + + Response handleGetOpenSubsonicExtensions(RequestContext& context) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + { + Response::Node& transcodeOffsetNode{ response.createArrayNode("openSubsonicExtensions") }; + transcodeOffsetNode.setAttribute("name", "transcodeOffset"); + transcodeOffsetNode.addArrayValue("versions", 1); + } + + return response; + }; +} diff --git a/src/libs/subsonic/impl/entrypoints/System.hpp b/src/libs/subsonic/impl/entrypoints/System.hpp new file mode 100644 index 00000000..81a394c8 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/System.hpp @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2023 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 API::Subsonic +{ + Response handlePingRequest(RequestContext& context); + Response handleGetLicenseRequest(RequestContext& context); + Response handleGetOpenSubsonicExtensions(RequestContext& context); +} diff --git a/src/libs/subsonic/impl/entrypoints/UserManagement.cpp b/src/libs/subsonic/impl/entrypoints/UserManagement.cpp new file mode 100644 index 00000000..d154726c --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/UserManagement.cpp @@ -0,0 +1,208 @@ +#include "UserManagement.hpp" + +#include "services/database/Session.hpp" +#include "services/database/User.hpp" +#include "services/auth/IPasswordService.hpp" +#include "utils/Service.hpp" +#include "responses/User.hpp" +#include "ParameterParsing.hpp" +#include "Utils.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + namespace { + void checkUserIsMySelfOrAdmin(RequestContext& context, const std::string& username) + { + User::pointer currentUser{ User::find(context.dbSession, context.userId) }; + if (!currentUser) + throw RequestedDataNotFoundError{}; + + if (currentUser->getLoginName() != username && !currentUser->isAdmin()) + throw UserNotAuthorizedError{}; + } + } + + Response handleGetUserRequest(RequestContext& context) + { + std::string username{ getMandatoryParameterAs(context.parameters, "username") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + checkUserIsMySelfOrAdmin(context, username); + + const User::pointer user{ User::find(context.dbSession, username) }; + if (!user) + throw RequestedDataNotFoundError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + response.addNode("user", createUserNode(user)); + + return response; + } + + Response handleGetUsersRequest(RequestContext& context) + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& usersNode{ response.createNode("users") }; + + const auto userIds{ User::find(context.dbSession, User::FindParameters {}) }; + for (const UserId userId : userIds.results) + { + const User::pointer user{ User::find(context.dbSession, userId) }; + usersNode.addArrayChild("user", createUserNode(user)); + } + + return response; + } + + Response handleCreateUserRequest(RequestContext& context) + { + std::string username{ getMandatoryParameterAs(context.parameters, "username") }; + std::string password{ decodePasswordIfNeeded(getMandatoryParameterAs(context.parameters, "password")) }; + // Just ignore all the other fields as we don't handle them + + Database::UserId userId; + { + auto transaction{ context.dbSession.createUniqueTransaction() }; + + User::pointer user{ User::find(context.dbSession, username) }; + if (user) + throw UserAlreadyExistsGenericError{}; + + user = context.dbSession.create(username); + userId = user->getId(); + } + + auto removeCreatedUser{ [&]() + { + auto transaction {context.dbSession.createUniqueTransaction()}; + User::pointer user {User::find(context.dbSession, userId)}; + if (user) + user.remove(); + } }; + + try + { + Service::get()->setPassword(userId, password); + } + catch (const Auth::PasswordMustMatchLoginNameException&) + { + removeCreatedUser(); + throw PasswordMustMatchLoginNameGenericError{}; + } + catch (const Auth::PasswordTooWeakException&) + { + removeCreatedUser(); + throw PasswordTooWeakGenericError{}; + } + catch (const Auth::Exception& exception) + { + removeCreatedUser(); + throw UserNotAuthorizedError{}; + } + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleDeleteUserRequest(RequestContext& context) + { + std::string username{ getMandatoryParameterAs(context.parameters, "username") }; + + auto transaction{ context.dbSession.createUniqueTransaction() }; + + User::pointer user{ User::find(context.dbSession, username) }; + if (!user) + throw RequestedDataNotFoundError{}; + + // cannot delete ourself + if (user->getId() == context.userId) + throw UserNotAuthorizedError{}; + + user.remove(); + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleUpdateUserRequest(RequestContext& context) + { + std::string username{ getMandatoryParameterAs(context.parameters, "username") }; + std::optional password{ getParameterAs(context.parameters, "password") }; + + UserId userId; + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, username) }; + if (!user) + throw RequestedDataNotFoundError{}; + + userId = user->getId(); + } + + if (password) + { + Utils::checkSetPasswordImplemented(); + + try + { + Service<::Auth::IPasswordService>()->setPassword(userId, decodePasswordIfNeeded(*password)); + } + catch (const Auth::PasswordMustMatchLoginNameException&) + { + throw PasswordMustMatchLoginNameGenericError{}; + } + catch (const Auth::PasswordTooWeakException&) + { + throw PasswordTooWeakGenericError{}; + } + catch (const Auth::Exception&) + { + throw UserNotAuthorizedError{}; + } + } + + return Response::createOkResponse(context.serverProtocolVersion); + } + + Response handleChangePassword(RequestContext& context) + { + std::string username{ getMandatoryParameterAs(context.parameters, "username") }; + std::string password{ decodePasswordIfNeeded(getMandatoryParameterAs(context.parameters, "password")) }; + + try + { + Database::UserId userId; + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + checkUserIsMySelfOrAdmin(context, username); + + User::pointer user{ User::find(context.dbSession, username) }; + if (!user) + throw UserNotAuthorizedError{}; + + userId = user->getId(); + } + + Service::get()->setPassword(userId, password); + } + catch (const Auth::PasswordMustMatchLoginNameException&) + { + throw PasswordMustMatchLoginNameGenericError{}; + } + catch (const Auth::PasswordTooWeakException&) + { + throw PasswordTooWeakGenericError{}; + } + catch (const Auth::Exception& authException) + { + throw UserNotAuthorizedError{}; + } + + return Response::createOkResponse(context.serverProtocolVersion); + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/UserManagement.hpp b/src/libs/subsonic/impl/entrypoints/UserManagement.hpp new file mode 100644 index 00000000..e4fac6f4 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/UserManagement.hpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 API::Subsonic +{ + Response handleGetUserRequest(RequestContext& context); + Response handleGetUsersRequest(RequestContext& context); + Response handleCreateUserRequest(RequestContext& context); + Response handleUpdateUserRequest(RequestContext& context); + Response handleDeleteUserRequest(RequestContext& context); + Response handleChangePassword(RequestContext& context); +} diff --git a/src/libs/subsonic/impl/responses/Album.cpp b/src/libs/subsonic/impl/responses/Album.cpp new file mode 100644 index 00000000..78bfb6c6 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Album.cpp @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2023 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 "responses/Album.hpp" + +#include "services/database/Cluster.hpp" +#include "services/database/Artist.hpp" +#include "services/database/Release.hpp" +#include "services/database/User.hpp" +#include "services/scrobbling/IScrobblingService.hpp" +#include "utils/Service.hpp" +#include "utils/String.hpp" + +#include "responses/Artist.hpp" +#include "responses/DiscTitle.hpp" +#include "responses/ItemGenre.hpp" +#include "SubsonicId.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + namespace + { + std::string_view toString(ReleaseTypePrimary releaseType) + { + switch (releaseType) + { + case ReleaseTypePrimary::Album: return "album"; + case ReleaseTypePrimary::Broadcast: return "broadcast"; + case ReleaseTypePrimary::EP: return "ep"; + case ReleaseTypePrimary::Single: return "single"; + case ReleaseTypePrimary::Other: return "other"; + } + + return "unknown"; + } + + std::string_view toString(ReleaseTypeSecondary releaseType) + { + switch (releaseType) + { + case ReleaseTypeSecondary::Audiobook: return "audiobook"; + case ReleaseTypeSecondary::AudioDrama: return "audiodrama"; + case ReleaseTypeSecondary::Compilation: return "compilation"; + case ReleaseTypeSecondary::Demo: return "demo"; + case ReleaseTypeSecondary::DJMix: return "djmix"; + case ReleaseTypeSecondary::Interview: return "interview"; + case ReleaseTypeSecondary::Live: return "live"; + case ReleaseTypeSecondary::Mixtape_Street: return "mixtapestreet"; + case ReleaseTypeSecondary::Remix: return "remix"; + case ReleaseTypeSecondary::Soundtrack: return "soundtrack"; + case ReleaseTypeSecondary::Spokenword: return "soundtrack"; + } + + return "unknown"; + } + } + + Response::Node createAlbumNode(const Release::pointer& release, Session& dbSession, const User::pointer& user, bool id3) + { + Response::Node albumNode; + + if (id3) { + albumNode.setAttribute("name", release->getName()); + albumNode.setAttribute("songCount", release->getTracksCount()); + albumNode.setAttribute( + "duration", std::chrono::duration_cast( + release->getDuration()) + .count()); + } + else + { + albumNode.setAttribute("title", release->getName()); + albumNode.setAttribute("isDir", true); + } + + albumNode.setAttribute("created", StringUtils::toISO8601String(release->getLastWritten())); + albumNode.setAttribute("id", idToString(release->getId())); + albumNode.setAttribute("coverArt", idToString(release->getId())); + if (const Wt::WDate releaseDate{ release->getReleaseDate() }; releaseDate.isValid()) + albumNode.setAttribute("year", releaseDate.year()); + + auto artists{ release->getReleaseArtists() }; + if (artists.empty()) + artists = release->getArtists(); + + if (artists.empty() && !id3) + { + albumNode.setAttribute("parent", idToString(RootId{})); + } + else if (!artists.empty()) + { + albumNode.setAttribute("artist", Utils::joinArtistNames(artists)); + + if (artists.size() == 1) + { + albumNode.setAttribute(id3 ? "artistId" : "parent", idToString(artists.front()->getId())); + } + else + { + if (!id3) + albumNode.setAttribute("parent", idToString(RootId{})); + } + } + + // Report the first GENRE for this track + if (ClusterType::pointer clusterType{ ClusterType::find(dbSession, "GENRE") }) + { + auto clusters{ release->getClusterGroups({clusterType}, 1) }; + if (!clusters.empty() && !clusters.front().empty()) + albumNode.setAttribute("genre", clusters.front().front()->getName()); + } + + if (const Wt::WDateTime dateTime{ Service::get()->getStarredDateTime(user->getId(), release->getId()) }; dateTime.isValid()) + albumNode.setAttribute("starred", StringUtils::toISO8601String(dateTime)); // TODO report correct date/time + + // OpenSubsonic specific fields (must always be set) + if (!id3) + albumNode.setAttribute("mediaType", "album"); + + { + std::optional mbid{ release->getMBID() }; + albumNode.setAttribute("musicBrainzId", mbid ? mbid->getAsString() : ""); + } + + auto addClusters{ [&](std::string_view field, std::string_view clusterTypeName) + { + albumNode.createEmptyArrayValue(field); + + ClusterType::pointer clusterType{ ClusterType::find(dbSession, clusterTypeName) }; + if (clusterType) + { + Cluster::FindParameters params; + params.setRelease(release->getId()); + params.setClusterType(clusterType->getId()); + + for (const ClusterId clusterId : Cluster::find(dbSession, params).results) + { + Cluster::pointer cluster{ Cluster::find(dbSession, clusterId) }; + if (cluster) + albumNode.addArrayValue(field, cluster->getName()); + } + } + } }; + + addClusters("moods", "MOOD"); + + // Genres + { + albumNode.createEmptyArrayChild("genres"); + + ClusterType::pointer clusterType{ ClusterType::find(dbSession, "GENRE") }; + if (clusterType) + { + Cluster::FindParameters params; + params.setRelease(release->getId()); + params.setClusterType(clusterType->getId()); + + for (const ClusterId clusterId : Cluster::find(dbSession, params).results) + { + Cluster::pointer cluster{ Cluster::find(dbSession, clusterId) }; + if (cluster) + albumNode.addArrayChild("genres", createItemGenreNode(cluster)); + } + } + } + + albumNode.createEmptyArrayChild("artists"); + for (const Artist::pointer& artist : release->getReleaseArtists()) + albumNode.addArrayChild("artists", createArtistNode(artist)); + + { + const Wt::WDate originalReleaseDate{ release->getOriginalReleaseDate() }; + albumNode.setAttribute("originalReleaseDate", originalReleaseDate.isValid() ? StringUtils::toISO8601String(originalReleaseDate) : ""); + } + + albumNode.setAttribute("isCompilation", release->getSecondaryTypes().contains(ReleaseTypeSecondary::Compilation)); + albumNode.createEmptyArrayValue("releaseTypes"); + if (auto releaseType{ release->getPrimaryType() }) + albumNode.addArrayValue("releaseTypes", toString(*releaseType)); + for (const ReleaseTypeSecondary releaseType : release->getSecondaryTypes()) + albumNode.addArrayValue("releaseTypes", toString(releaseType)); + + // disc titles + albumNode.createEmptyArrayChild("discTitles"); + for (const DiscInfo& discInfo : release->getDiscs()) + { + if (!discInfo.name.empty()) + albumNode.addArrayChild("discTitles", createDiscTitle(discInfo)); + } + + return albumNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Album.hpp b/src/libs/subsonic/impl/responses/Album.hpp new file mode 100644 index 00000000..c05c1f35 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Album.hpp @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class Release; + class User; + class Session; +} + +namespace API::Subsonic +{ + Response::Node createAlbumNode(const Database::ObjectPtr& release, Database::Session& dbSession, const Database::ObjectPtr& user, bool id3); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Artist.cpp b/src/libs/subsonic/impl/responses/Artist.cpp new file mode 100644 index 00000000..59505c18 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Artist.cpp @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2023 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 "responses/Artist.hpp" + +#include "services/database/Artist.hpp" +#include "services/database/Release.hpp" +#include "services/database/TrackArtistLink.hpp" +#include "services/database/User.hpp" +#include "services/scrobbling/IScrobblingService.hpp" +#include "utils/Service.hpp" +#include "utils/String.hpp" + +#include "SubsonicId.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + namespace Utils + { + std::string joinArtistNames(const std::vector& artists) + { + if (artists.size() == 1) + return artists.front()->getName(); + + std::vector names; + names.resize(artists.size()); + + std::transform(std::cbegin(artists), std::cend(artists), std::begin(names), + [](const Artist::pointer& artist) + { + return artist->getName(); + }); + + return StringUtils::joinStrings(names, ", "); + } + + std::string_view toString(TrackArtistLinkType type) + { + switch (type) + { + case TrackArtistLinkType::Arranger: return "arranger"; + case TrackArtistLinkType::Artist: return "artist"; + case TrackArtistLinkType::Composer: return "composer"; + case TrackArtistLinkType::Conductor: return "conductor"; + case TrackArtistLinkType::Lyricist: return "lyricist"; + case TrackArtistLinkType::Mixer: return "mixer"; + case TrackArtistLinkType::Performer: return "performer"; + case TrackArtistLinkType::Producer: return "producer"; + case TrackArtistLinkType::ReleaseArtist: return "albumartist"; + case TrackArtistLinkType::Remixer: return "remixer"; + case TrackArtistLinkType::Writer: return "writer"; + } + + return "unknown"; + } + } + + Response::Node createArtistNode(const Artist::pointer& artist, Session& session, const User::pointer& user, bool id3) + { + Response::Node artistNode{ createArtistNode(artist) }; + + artistNode.setAttribute("id", idToString(artist->getId())); + artistNode.setAttribute("name", artist->getName()); + + if (id3) + { + const auto releases{ Release::find(session, Release::FindParameters {}.setArtist(artist->getId())) }; + artistNode.setAttribute("albumCount", releases.results.size()); + } + + if (const Wt::WDateTime dateTime{ Service::get()->getStarredDateTime(user->getId(), artist->getId()) }; dateTime.isValid()) + artistNode.setAttribute("starred", StringUtils::toISO8601String(dateTime)); + + // OpenSubsonic specific fields (must always be set) + if (!id3) + artistNode.setAttribute("mediaType", "artist"); + + { + std::optional mbid{ artist->getMBID() }; + artistNode.setAttribute("musicBrainzId", mbid ? mbid->getAsString() : ""); + } + + artistNode.setAttribute("sortName", artist->getSortName()); + + // roles + Response::Node roles; + artistNode.createEmptyArrayValue("roles"); + for (const TrackArtistLinkType linkType : TrackArtistLink::findUsedTypes(session, artist->getId())) + artistNode.addArrayValue("roles", Utils::toString(linkType)); + + return artistNode; + } + + Response::Node createArtistNode(const Artist::pointer& artist) + { + Response::Node artistNode; + + artistNode.setAttribute("id", idToString(artist->getId())); + artistNode.setAttribute("name", artist->getName()); + + return artistNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Artist.hpp b/src/libs/subsonic/impl/responses/Artist.hpp new file mode 100644 index 00000000..4b293d5e --- /dev/null +++ b/src/libs/subsonic/impl/responses/Artist.hpp @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "services/database/Types.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class Artist; + class User; + class Session; +} + +namespace API::Subsonic +{ + namespace Utils + { + std::string joinArtistNames(const std::vector>& artists); + std::string_view toString(Database::TrackArtistLinkType type); + } + Response::Node createArtistNode(const Database::ObjectPtr& artist, Database::Session& session, const Database::ObjectPtr& user, bool id3); + Response::Node createArtistNode(const Database::ObjectPtr& artist); // only minimal info +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Bookmark.cpp b/src/libs/subsonic/impl/responses/Bookmark.cpp new file mode 100644 index 00000000..f04b1d7c --- /dev/null +++ b/src/libs/subsonic/impl/responses/Bookmark.cpp @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 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 "responses/Bookmark.hpp" + +#include "services/database/TrackBookmark.hpp" +#include "services/database/User.hpp" + +namespace API::Subsonic +{ + static const std::string_view reportedDummyDate{ "2000-01-01T00:00:00" }; + + Response::Node createBookmarkNode(const Database::ObjectPtr& trackBookmark) + { + Response::Node trackBookmarkNode; + + trackBookmarkNode.setAttribute("position", trackBookmark->getOffset().count()); + if (!trackBookmark->getComment().empty()) + trackBookmarkNode.setAttribute("comment", trackBookmark->getComment()); + trackBookmarkNode.setAttribute("created", reportedDummyDate); + trackBookmarkNode.setAttribute("changed", reportedDummyDate); + trackBookmarkNode.setAttribute("username", trackBookmark->getUser()->getLoginName()); + + return trackBookmarkNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Bookmark.hpp b/src/libs/subsonic/impl/responses/Bookmark.hpp new file mode 100644 index 00000000..ea36ee41 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Bookmark.hpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class TrackBookmark; +} + +namespace API::Subsonic +{ + Response::Node createBookmarkNode(const Database::ObjectPtr& bookmark); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Contributor.cpp b/src/libs/subsonic/impl/responses/Contributor.cpp new file mode 100644 index 00000000..755b44dd --- /dev/null +++ b/src/libs/subsonic/impl/responses/Contributor.cpp @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 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 "responses/Contributor.hpp" + +#include "services/database/Object.hpp" +#include "services/database/TrackArtistLink.hpp" +#include "SubsonicResponse.hpp" +#include "responses/Artist.hpp" + +namespace API::Subsonic +{ + Response::Node createContributorNode(const Database::ObjectPtr& trackArtistLink) + { + Response::Node contributorNode; + + contributorNode.setAttribute("role", Utils::toString(trackArtistLink->getType())); + if (!trackArtistLink->getSubType().empty()) + contributorNode.setAttribute("subRole", trackArtistLink->getSubType()); + contributorNode.addChild("artist", createArtistNode(trackArtistLink->getArtist())); + + return contributorNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Contributor.hpp b/src/libs/subsonic/impl/responses/Contributor.hpp new file mode 100644 index 00000000..80a0d0d9 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Contributor.hpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class TrackArtistLink; +} + +namespace API::Subsonic +{ + Response::Node createContributorNode(const Database::ObjectPtr& trackArtistLink); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/DiscTitle.cpp b/src/libs/subsonic/impl/responses/DiscTitle.cpp new file mode 100644 index 00000000..53ec3215 --- /dev/null +++ b/src/libs/subsonic/impl/responses/DiscTitle.cpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 "responses/DiscTitle.hpp" + +namespace API::Subsonic +{ + Response::Node createDiscTitle(const Database::DiscInfo& discInfo) + { + Response::Node discTitleNode; + + discTitleNode.setAttribute("disc", discInfo.position); + discTitleNode.setAttribute("title", discInfo.name); + + return discTitleNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/DiscTitle.hpp b/src/libs/subsonic/impl/responses/DiscTitle.hpp new file mode 100644 index 00000000..f9ff1185 --- /dev/null +++ b/src/libs/subsonic/impl/responses/DiscTitle.hpp @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2023 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 "services/database/Types.hpp" +#include "SubsonicResponse.hpp" + +namespace API::Subsonic +{ + Response::Node createDiscTitle(const Database::DiscInfo& discInfo); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Genre.cpp b/src/libs/subsonic/impl/responses/Genre.cpp new file mode 100644 index 00000000..05d5ca48 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Genre.cpp @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 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 "responses/Genre.hpp" + +#include "services/database/Cluster.hpp" + +namespace API::Subsonic +{ + Response::Node createGenreNode(const Database::Cluster::pointer& cluster) + { + Response::Node clusterNode; + + clusterNode.setValue(cluster->getName()); + clusterNode.setAttribute("songCount", cluster->getTracksCount()); + clusterNode.setAttribute("albumCount", cluster->getReleasesCount()); + + return clusterNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Genre.hpp b/src/libs/subsonic/impl/responses/Genre.hpp new file mode 100644 index 00000000..6d217304 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Genre.hpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class Cluster; +} + +namespace API::Subsonic +{ + Response::Node createGenreNode(const Database::ObjectPtr& cluster); +} diff --git a/src/libs/subsonic/impl/responses/ItemGenre.cpp b/src/libs/subsonic/impl/responses/ItemGenre.cpp new file mode 100644 index 00000000..a5b3387d --- /dev/null +++ b/src/libs/subsonic/impl/responses/ItemGenre.cpp @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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 "responses/ItemGenre.hpp" + +#include "services/database/Cluster.hpp" + +namespace API::Subsonic +{ + Response::Node createItemGenreNode(const Database::Cluster::pointer& cluster) + { + Response::Node genreNode; + + genreNode.setAttribute("name", cluster->getName()); + + return genreNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/ItemGenre.hpp b/src/libs/subsonic/impl/responses/ItemGenre.hpp new file mode 100644 index 00000000..f4c1dda9 --- /dev/null +++ b/src/libs/subsonic/impl/responses/ItemGenre.hpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class Cluster; +} + +namespace API::Subsonic +{ + Response::Node createItemGenreNode(const Database::ObjectPtr& cluster); +} diff --git a/src/libs/subsonic/impl/responses/Playlist.cpp b/src/libs/subsonic/impl/responses/Playlist.cpp new file mode 100644 index 00000000..9c11d81b --- /dev/null +++ b/src/libs/subsonic/impl/responses/Playlist.cpp @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 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 "Playlist.hpp" + +#include "services/database/TrackList.hpp" +#include "services/database/User.hpp" +#include "SubsonicId.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + static const std::string_view reportedDummyDate{ "2000-01-01T00:00:00" }; + + Response::Node createPlaylistNode(const TrackList::pointer& tracklist, Session&) + { + Response::Node playlistNode; + + playlistNode.setAttribute("id", idToString(tracklist->getId())); + playlistNode.setAttribute("name", tracklist->getName()); + playlistNode.setAttribute("songCount", tracklist->getCount()); + playlistNode.setAttribute("duration", std::chrono::duration_cast(tracklist->getDuration()).count()); + playlistNode.setAttribute("public", tracklist->isPublic()); + playlistNode.setAttribute("created", reportedDummyDate); + playlistNode.setAttribute("owner", tracklist->getUser()->getLoginName()); + + return playlistNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Playlist.hpp b/src/libs/subsonic/impl/responses/Playlist.hpp new file mode 100644 index 00000000..2085d69c --- /dev/null +++ b/src/libs/subsonic/impl/responses/Playlist.hpp @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class TrackList; + class Session; +} + +namespace API::Subsonic +{ + Response::Node createPlaylistNode(const Database::ObjectPtr& tracklist, Database::Session& session); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/ReplayGain.cpp b/src/libs/subsonic/impl/responses/ReplayGain.cpp new file mode 100644 index 00000000..e022a3d8 --- /dev/null +++ b/src/libs/subsonic/impl/responses/ReplayGain.cpp @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2023 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 "responses/ReplayGain.hpp" + +#include "services/database/Track.hpp" + +namespace API::Subsonic +{ + Response::Node createReplayGainNode(const Database::ObjectPtr& track) + { + Response::Node replayGainNode; + + if (const auto trackReplayGain{ track->getTrackReplayGain() }) + replayGainNode.setAttribute("trackGain", *trackReplayGain); + + if (const auto releaseReplayGain{ track->getReleaseReplayGain() }) + replayGainNode.setAttribute("albumGain", *releaseReplayGain); + + return replayGainNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/ReplayGain.hpp b/src/libs/subsonic/impl/responses/ReplayGain.hpp new file mode 100644 index 00000000..9d5db182 --- /dev/null +++ b/src/libs/subsonic/impl/responses/ReplayGain.hpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class Track; +} + +namespace API::Subsonic +{ + Response::Node createReplayGainNode(const Database::ObjectPtr& track); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp new file mode 100644 index 00000000..73eeb5d7 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2023 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 "responses/Song.hpp" + +#include + +#include "services/database/Artist.hpp" +#include "services/database/Cluster.hpp" +#include "services/database/Release.hpp" +#include "services/database/Track.hpp" +#include "services/database/TrackArtistLink.hpp" +#include "services/database/User.hpp" +#include "services/scrobbling/IScrobblingService.hpp" +#include "utils/Service.hpp" +#include "utils/String.hpp" +#include "responses/Artist.hpp" +#include "responses/Contributor.hpp" +#include "responses/ItemGenre.hpp" +#include "responses/ReplayGain.hpp" +#include "SubsonicId.hpp" +#include "Utils.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + namespace + { + std::string_view formatToSuffix(AudioFormat format) + { + switch (format) + { + case AudioFormat::MP3: return "mp3"; + case AudioFormat::OGG_OPUS: return "opus"; + case AudioFormat::MATROSKA_OPUS: return "mka"; + case AudioFormat::OGG_VORBIS: return "ogg"; + case AudioFormat::WEBM_VORBIS: return "webm"; + } + + return ""; + } + + std::string getTrackPath(const Track::pointer& track) + { + std::string path; + + // The track path has to be relative from the root + + const auto release{ track->getRelease() }; + if (release) + { + auto artists{ release->getReleaseArtists() }; + if (artists.empty()) + artists = release->getArtists(); + + if (artists.size() > 1) + path = "Various Artists/"; + else if (artists.size() == 1) + path = Utils::makeNameFilesystemCompatible(artists.front()->getName()) + "/"; + + path += Utils::makeNameFilesystemCompatible(track->getRelease()->getName()) + "/"; + } + + if (track->getDiscNumber()) + path += std::to_string(*track->getDiscNumber()) + "-"; + if (track->getTrackNumber()) + path += std::to_string(*track->getTrackNumber()) + "-"; + + path += Utils::makeNameFilesystemCompatible(track->getName()); + + if (track->getPath().has_extension()) + path += track->getPath().extension(); + + return path; + } + } + + Response::Node createSongNode(const Track::pointer& track, Session& dbSession, const User::pointer& user) + { + Response::Node trackResponse; + + trackResponse.setAttribute("id", idToString(track->getId())); + trackResponse.setAttribute("isDir", false); + trackResponse.setAttribute("title", track->getName()); + if (track->getTrackNumber()) + trackResponse.setAttribute("track", *track->getTrackNumber()); + if (track->getDiscNumber()) + trackResponse.setAttribute("discNumber", *track->getDiscNumber()); + if (track->getYear()) + trackResponse.setAttribute("year", *track->getYear()); + + trackResponse.setAttribute("path", getTrackPath(track)); + { + std::error_code ec; + const auto fileSize{ std::filesystem::file_size(track->getPath(), ec) }; + if (!ec) + trackResponse.setAttribute("size", fileSize); + } + + if (track->getPath().has_extension()) + { + auto extension{ track->getPath().extension() }; + trackResponse.setAttribute("suffix", extension.string().substr(1)); + } + + if (user->getSubsonicTranscodeEnable()) + trackResponse.setAttribute("transcodedSuffix", formatToSuffix(user->getSubsonicTranscodeFormat())); + + trackResponse.setAttribute("coverArt", idToString(track->getId())); + + const std::vector& artists{ track->getArtists({TrackArtistLinkType::Artist}) }; + if (!artists.empty()) + { + trackResponse.setAttribute("artist", Utils::joinArtistNames(artists)); + + if (artists.size() == 1) + trackResponse.setAttribute("artistId", idToString(artists.front()->getId())); + } + + if (track->getRelease()) + { + trackResponse.setAttribute("album", track->getRelease()->getName()); + trackResponse.setAttribute("albumId", idToString(track->getRelease()->getId())); + trackResponse.setAttribute("parent", idToString(track->getRelease()->getId())); + } + + trackResponse.setAttribute("duration", std::chrono::duration_cast(track->getDuration()).count()); + trackResponse.setAttribute("type", "music"); + trackResponse.setAttribute("created", StringUtils::toISO8601String(track->getLastWritten())); + + if (const Wt::WDateTime dateTime{ Service::get()->getStarredDateTime(user->getId(), track->getId()) }; dateTime.isValid()) + trackResponse.setAttribute("starred", StringUtils::toISO8601String(dateTime)); + + // Report the first GENRE for this track + if (ClusterType::pointer genreClusterType{ ClusterType::find(dbSession, "GENRE") }) + { + auto clusters{ track->getClusterGroups({genreClusterType}, 1) }; + if (!clusters.empty() && !clusters.front().empty()) + trackResponse.setAttribute("genre", clusters.front().front()->getName()); + } + + // OpenSubsonic specific fields (must always be set) + trackResponse.setAttribute("mediaType", "song"); + + { + std::optional mbid{ track->getRecordingMBID() }; + trackResponse.setAttribute("musicBrainzId", mbid ? mbid->getAsString() : ""); + } + + trackResponse.createEmptyArrayChild("contributors"); + { + TrackArtistLink::FindParameters params; + params.setTrack(track->getId()); + + for (const TrackArtistLinkId linkId : TrackArtistLink::find(dbSession, params).results) + { + TrackArtistLink::pointer link{ TrackArtistLink::find(dbSession, linkId) }; + // Don't report artists nor release artists as they are set in dedicated fields + if (link && link->getType() != TrackArtistLinkType::Artist && link->getType() != TrackArtistLinkType::ReleaseArtist) + trackResponse.addArrayChild("contributors", createContributorNode(link)); + } + } + + auto addArtistLinks{ [&](std::string_view nodeName, TrackArtistLinkType type) + { + trackResponse.createEmptyArrayChild(nodeName); + + TrackArtistLink::FindParameters params; + params.setTrack(track->getId()); + params.setLinkType(type); + + for (const TrackArtistLinkId linkId : TrackArtistLink::find(dbSession, params).results) + { + TrackArtistLink::pointer link{ TrackArtistLink::find(dbSession, linkId) }; + if (link) + trackResponse.addArrayChild(nodeName, createArtistNode(link->getArtist())); + } + } }; + + addArtistLinks("artists", TrackArtistLinkType::Artist); + addArtistLinks("albumartists", TrackArtistLinkType::ReleaseArtist); + + auto addClusters{ [&](std::string_view field, std::string_view clusterTypeName) + { + trackResponse.createEmptyArrayValue(field); + + ClusterType::pointer clusterType{ ClusterType::find(dbSession, clusterTypeName) }; + if (clusterType) + { + Cluster::FindParameters params; + params.setTrack(track->getId()); + params.setClusterType(clusterType->getId()); + + for (const ClusterId clusterId : Cluster::find(dbSession, params).results) + { + Cluster::pointer cluster {Cluster::find(dbSession, clusterId)}; + if (cluster) + trackResponse.addArrayValue(field, cluster->getName()); + } + } + } }; + + addClusters("moods", "MOOD"); + + // Genres + { + trackResponse.createEmptyArrayChild("genres"); + + ClusterType::pointer clusterType{ ClusterType::find(dbSession, "GENRE") }; + if (clusterType) + { + Cluster::FindParameters params; + params.setTrack(track->getId()); + params.setClusterType(clusterType->getId()); + + for (const ClusterId clusterId : Cluster::find(dbSession, params).results) + { + Cluster::pointer cluster{ Cluster::find(dbSession, clusterId) }; + if (cluster) + trackResponse.addArrayChild("genres", createItemGenreNode(cluster)); + } + } + } + + trackResponse.addChild("replayGain", createReplayGainNode(track)); + + return trackResponse; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Song.hpp b/src/libs/subsonic/impl/responses/Song.hpp new file mode 100644 index 00000000..9fcba25e --- /dev/null +++ b/src/libs/subsonic/impl/responses/Song.hpp @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class Track; + class User; + class Session; +} + +namespace API::Subsonic +{ + Response::Node createSongNode(const Database::ObjectPtr& track, Database::Session& session, const Database::ObjectPtr& user); +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/User.cpp b/src/libs/subsonic/impl/responses/User.cpp new file mode 100644 index 00000000..16f3c532 --- /dev/null +++ b/src/libs/subsonic/impl/responses/User.cpp @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 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 "responses/User.hpp" + +#include "services/database/User.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + Response::Node createUserNode(const User::pointer& user) + { + Response::Node userNode; + + 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); + userNode.setAttribute("streamRole", true); + userNode.setAttribute("jukeboxRole", false); + userNode.setAttribute("shareRole", false); + + Response::Node folder; + folder.setValue("0"); + userNode.addArrayChild("folder", std::move(folder)); + + return userNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/User.hpp b/src/libs/subsonic/impl/responses/User.hpp new file mode 100644 index 00000000..8ce1755f --- /dev/null +++ b/src/libs/subsonic/impl/responses/User.hpp @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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 "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class User; +} + +namespace API::Subsonic +{ + Response::Node createUserNode(const Database::ObjectPtr& user); +} \ No newline at end of file diff --git a/src/libs/utils/impl/String.cpp b/src/libs/utils/impl/String.cpp index 89c58edf..ff7f64ff 100644 --- a/src/libs/utils/impl/String.cpp +++ b/src/libs/utils/impl/String.cpp @@ -27,279 +27,275 @@ #include #include -namespace StringUtils { +#include +#include -bool -readList(const std::string& str, const std::string& separators, std::list& results) +namespace StringUtils { - std::string curStr; - for (char c : str) - { - if (separators.find(c) != std::string::npos) { - if (!curStr.empty()) { - results.push_back(curStr); - curStr.clear(); - } - } - else { - if (curStr.empty() && std::isspace(c)) - continue; + bool readList(const std::string& str, const std::string& separators, std::list& results) + { + std::string curStr; - curStr.push_back(c); - } - } + for (char c : str) + { + if (separators.find(c) != std::string::npos) { + if (!curStr.empty()) { + results.push_back(curStr); + curStr.clear(); + } + } + else { + if (curStr.empty() && std::isspace(c)) + continue; - if (!curStr.empty()) - results.push_back(curStr); + curStr.push_back(c); + } + } - return !str.empty(); -} + if (!curStr.empty()) + results.push_back(curStr); -template<> -std::optional -readAs(std::string_view str) -{ - return std::string {str}; -} + return !str.empty(); + } -template<> -std::optional -readAs(std::string_view str) -{ - return str; -} + template<> + std::optional readAs(std::string_view str) + { + return std::string{ str }; + } -template<> -std::optional -readAs(std::string_view str) -{ - if (str == "1" || str == "true") - return true; - else if (str == "0" || str == "false") - return false; + template<> + std::optional readAs(std::string_view str) + { + return str; + } - return std::nullopt; -} + template<> + std::optional readAs(std::string_view str) + { + if (str == "1" || str == "true") + return true; + else if (str == "0" || str == "false") + return false; -std::vector -splitStringCopy(std::string_view string, std::string_view separators) -{ - std::string str {stringTrim(string, separators)}; + return std::nullopt; + } - std::vector res; - boost::algorithm::split(res, str, boost::is_any_of(separators), boost::token_compress_on); + std::vector splitStringCopy(std::string_view string, std::string_view separators) + { + std::string str{ stringTrim(string, separators) }; - return res; -} + std::vector res; + boost::algorithm::split(res, str, boost::is_any_of(separators), boost::token_compress_on); -std::vector -splitString(std::string_view str, std::string_view separators) -{ - std::vector res; + return res; + } - std::string_view::size_type strBegin {}; + std::vector splitString(std::string_view str, std::string_view separators) + { + std::vector res; - while ((strBegin = str.find_first_not_of(separators, strBegin)) != std::string_view::npos) - { - auto strEnd {str.find_first_of(separators, strBegin + 1)}; - if (strEnd == std::string_view::npos) - { - res.push_back(str.substr(strBegin, str.size() - strBegin)); - break; - } + std::string_view::size_type strBegin{}; - res.push_back(str.substr(strBegin, strEnd - strBegin)); - strBegin = strEnd + 1; - } + while ((strBegin = str.find_first_not_of(separators, strBegin)) != std::string_view::npos) + { + auto strEnd{ str.find_first_of(separators, strBegin + 1) }; + if (strEnd == std::string_view::npos) + { + res.push_back(str.substr(strBegin, str.size() - strBegin)); + break; + } - return res; -} + res.push_back(str.substr(strBegin, strEnd - strBegin)); + strBegin = strEnd + 1; + } -std::string -joinStrings(const std::vector& strings, const std::string& delimiter) -{ - return boost::algorithm::join(strings, delimiter); -} + return res; + } -std::string_view -stringTrim(std::string_view str, std::string_view whitespaces) -{ - std::string_view res; + std::string joinStrings(const std::vector& strings, const std::string& delimiter) + { + return boost::algorithm::join(strings, delimiter); + } - const auto strBegin = str.find_first_not_of(whitespaces); - if (strBegin != std::string_view::npos) - { - const auto strEnd {str.find_last_not_of(whitespaces)}; - const auto strRange {strEnd - strBegin + 1}; + std::string_view stringTrim(std::string_view str, std::string_view whitespaces) + { + std::string_view res; - res = str.substr(strBegin, strRange); - } + const auto strBegin = str.find_first_not_of(whitespaces); + if (strBegin != std::string_view::npos) + { + const auto strEnd{ str.find_last_not_of(whitespaces) }; + const auto strRange{ strEnd - strBegin + 1 }; - return res; -} + res = str.substr(strBegin, strRange); + } -std::string_view -stringTrimEnd(std::string_view str, std::string_view whitespaces) -{ - return str.substr(0, str.find_last_not_of(whitespaces) + 1); -} + return res; + } -std::string -stringToLower(std::string_view str) -{ - std::string res; - res.reserve(str.size()); + std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces) + { + return str.substr(0, str.find_last_not_of(whitespaces) + 1); + } - std::transform(std::cbegin(str), std::cend(str), std::back_inserter(res), [](unsigned char c) { return std::tolower(c);}); + std::string stringToLower(std::string_view str) + { + std::string res; + res.reserve(str.size()); - return res; -} + std::transform(std::cbegin(str), std::cend(str), std::back_inserter(res), [](unsigned char c) { return std::tolower(c);}); -void -stringToLower(std::string& str) -{ - std::transform(std::cbegin(str), std::cend(str), std::begin(str), [](unsigned char c) { return std::tolower(c);}); -} + return res; + } -std::string -stringToUpper(const std::string& str) -{ - std::string res; - res.reserve(str.size()); + void stringToLower(std::string& str) + { + std::transform(std::cbegin(str), std::cend(str), std::begin(str), [](unsigned char c) { return std::tolower(c);}); + } - std::transform(std::cbegin(str), std::cend(str), std::back_inserter(res), [](char c) { return std::toupper(c);}); + std::string stringToUpper(const std::string& str) + { + std::string res; + res.reserve(str.size()); - return res; -} + std::transform(std::cbegin(str), std::cend(str), std::back_inserter(res), [](char c) { return std::toupper(c);}); -std::string -bufferToString(const std::vector& data) -{ - std::ostringstream oss; + return res; + } - for (unsigned char c : data) - { - oss << std::setw(2) << std::setfill('0') << std::hex << (int)c; - } + std::string bufferToString(const std::vector& data) + { + std::ostringstream oss; - return oss.str(); -} + for (unsigned char c : data) + { + oss << std::setw(2) << std::setfill('0') << std::hex << (int)c; + } -void -capitalize(std::string& str) -{ - for (auto it {std::begin(str)}; it != std::end(str); ++it) - { - if (std::isspace(*it)) - continue; + return oss.str(); + } - if (std::isalpha(*it)) - *it = std::toupper(*it); + void capitalize(std::string& str) + { + for (auto it{ std::begin(str) }; it != std::end(str); ++it) + { + if (std::isspace(*it)) + continue; - break; - } -} + if (std::isalpha(*it)) + *it = std::toupper(*it); -std::string -replaceInString(std::string_view str, const std::string& from, const std::string& to) -{ - std::string res {str}; - size_t pos = 0; + break; + } + } - while ((pos = res.find(from, pos)) != std::string::npos) - { - res.replace(pos, from.length(), to); - pos += to.length(); - } + std::string replaceInString(std::string_view str, const std::string& from, const std::string& to) + { + std::string res{ str }; + size_t pos = 0; - return res; -} + while ((pos = res.find(from, pos)) != std::string::npos) + { + res.replace(pos, from.length(), to); + pos += to.length(); + } -std::string -jsEscape(const std::string& str) -{ - static const std::unordered_map escapeMap - { - { '\\', "\\\\" }, - { '\n', "\\n" }, - { '\r', "\\r" }, - { '\t', "\\t" }, - { '"', "\\\"" }, - { '\'', "\\\'" }, - }; + return res; + } - std::string escaped; - escaped.reserve(str.length()); + std::string jsEscape(const std::string& str) + { + static const std::unordered_map escapeMap + { + { '\\', "\\\\" }, + { '\n', "\\n" }, + { '\r', "\\r" }, + { '\t', "\\t" }, + { '"', "\\\"" }, + { '\'', "\\\'" }, + }; - for (const char c : str) - { - auto it {escapeMap.find(c)}; - if (it == std::cend(escapeMap)) - { - escaped += c; - continue; - } + std::string escaped; + escaped.reserve(str.length()); - escaped += it->second; - } + for (const char c : str) + { + auto it{ escapeMap.find(c) }; + if (it == std::cend(escapeMap)) + { + escaped += c; + continue; + } - return escaped; -} + escaped += it->second; + } -std::string -escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar) -{ - std::string res; - res.reserve(str.size()); + return escaped; + } - for (const char c : str) - { - if (std::any_of(std::cbegin(charsToEscape), std::cend(charsToEscape), [c](char charToEscape) { return c == charToEscape; })) - res += escapeChar; + std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar) + { + std::string res; + res.reserve(str.size()); - res += c; - } + for (const char c : str) + { + if (std::any_of(std::cbegin(charsToEscape), std::cend(charsToEscape), [c](char charToEscape) { return c == charToEscape; })) + res += escapeChar; - return res; -} + res += c; + } -bool -stringEndsWith(const std::string& str, const std::string& ending) -{ - return boost::algorithm::ends_with(str, ending); -} + return res; + } -std::optional -stringFromHex(const std::string& str) -{ - static const char lut[] {"0123456789ABCDEF"}; + bool stringEndsWith(const std::string& str, const std::string& ending) + { + return boost::algorithm::ends_with(str, ending); + } - if (str.length() % 2 != 0) - return std::nullopt; + std::optional stringFromHex(const std::string& str) + { + static const char lut[]{ "0123456789ABCDEF" }; - std::string res; - res.reserve(str.length() / 2); + if (str.length() % 2 != 0) + return std::nullopt; - auto it {std::cbegin(str)}; - while (it != std::cend(str)) - { - unsigned val {}; + std::string res; + res.reserve(str.length() / 2); - 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++)))}; + auto it{ std::cbegin(str) }; + while (it != std::cend(str)) + { + unsigned val{}; - if (itHigh == std::cend(lut) || itLow == std::cend(lut)) - return {}; + 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++))) }; - val = std::distance(std::cbegin(lut), itHigh) << 4; - val += std::distance(std::cbegin(lut), itLow ); + if (itHigh == std::cend(lut) || itLow == std::cend(lut)) + return {}; - res.push_back(static_cast(val)); - } + val = std::distance(std::cbegin(lut), itHigh) << 4; + val += std::distance(std::cbegin(lut), itLow); - return res; -} + res.push_back(static_cast(val)); + } + return res; + } + + std::string toISO8601String(const Wt::WDateTime& dateTime) + { + // assume UTC + return dateTime.toString("yyyy-MM-ddThh:mm:ss.zzz", false).toUTF8(); + } + + std::string toISO8601String(const Wt::WDate& date) + { + // assume UTC + return date.toString("yyyy-MM-dd").toUTF8(); + } } // StringUtils diff --git a/src/libs/utils/include/utils/String.hpp b/src/libs/utils/include/utils/String.hpp index dff67ca0..670ea89a 100644 --- a/src/libs/utils/include/utils/String.hpp +++ b/src/libs/utils/include/utils/String.hpp @@ -29,94 +29,67 @@ #define QUOTEME(x) QUOTEME_1(x) #define QUOTEME_1(x) #x -namespace StringUtils { - -[[nodiscard]] -std::vector -splitStringCopy(std::string_view string, std::string_view separators); - -[[nodiscard]] -std::vector -splitString(std::string_view string, std::string_view separators); - -[[nodiscard]] -std::string -joinStrings(const std::vector& strings, const std::string& delimiter); - -[[nodiscard]] -std::string_view -stringTrim(std::string_view str, std::string_view whitespaces = " \t"); - -[[nodiscard]] -std::string_view -stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t"); - -[[nodiscard]] -std::string -stringToLower(std::string_view str); - -void -stringToLower(std::string& str); - -[[nodiscard]] -std::string -stringToUpper(const std::string& str); - -[[nodiscard]] -std::string -bufferToString(const std::vector& data); - -void -capitalize(std::string& str); - -template -[[nodiscard]] -std::optional readAs(std::string_view str) +namespace Wt { - T res; - - std::istringstream iss {std::string {str}}; - iss >> res; - if (iss.fail()) - return std::nullopt; - - return res; + class WDate; + class WDateTime; } -template<> -[[nodiscard]] -std::optional -readAs(std::string_view str); +namespace StringUtils { -template<> -[[nodiscard]] -std::optional -readAs(std::string_view str); + [[nodiscard]] std::vector splitStringCopy(std::string_view string, std::string_view separators); -template<> -[[nodiscard]] -std::optional -readAs(std::string_view str); + [[nodiscard]] std::vector splitString(std::string_view string, std::string_view separators); -[[nodiscard]] -std::string -replaceInString(std::string_view str, const std::string& from, const std::string& to); + [[nodiscard]] std::string joinStrings(const std::vector& strings, const std::string& delimiter); -[[nodiscard]] -std::string -jsEscape(const std::string& str); + [[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t"); -[[nodiscard]] -std::string -escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar); + [[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t"); -[[nodiscard]] -bool -stringEndsWith(const std::string& str, const std::string& ending); + [[nodiscard]] std::string stringToLower(std::string_view str); -[[nodiscard]] -std::optional -stringFromHex(const std::string& str); + void stringToLower(std::string& str); -} // StringUtils + [[nodiscard]] std::string stringToUpper(const std::string& str); + [[nodiscard]] std::string bufferToString(const std::vector& data); + + void capitalize(std::string& str); + + template + [[nodiscard]] std::optional readAs(std::string_view str) + { + T res; + + std::istringstream iss{ std::string {str} }; + iss >> res; + if (iss.fail()) + return std::nullopt; + + return res; + } + + template<> + [[nodiscard]] std::optional readAs(std::string_view str); + + template<> + [[nodiscard]] std::optional readAs(std::string_view str); + + template<> + [[nodiscard]] std::optional readAs(std::string_view str); + + [[nodiscard]] std::string replaceInString(std::string_view str, const std::string& from, const std::string& to); + + [[nodiscard]] std::string jsEscape(const std::string& str); + + [[nodiscard]] std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar); + + [[nodiscard]] bool stringEndsWith(const std::string& str, const std::string& ending); + + [[nodiscard]] std::optional stringFromHex(const std::string& str); + + [[nodiscard]] std::string toISO8601String(const Wt::WDateTime& dateTime); + [[nodiscard]] std::string toISO8601String(const Wt::WDate& date); + +} // StringUtils \ No newline at end of file diff --git a/src/libs/utils/test/String.cpp b/src/libs/utils/test/String.cpp index ccf02829..eb5281f6 100644 --- a/src/libs/utils/test/String.cpp +++ b/src/libs/utils/test/String.cpp @@ -19,6 +19,9 @@ #include +#include +#include +#include #include "utils/String.hpp" TEST(StringUtils, splitString) @@ -147,3 +150,15 @@ TEST(StringUtils, capitalize) EXPECT_EQ(str, test.expectedOutput) << " str was '" << test.input << "'"; } } + +TEST(Stringutils, date) +{ + const Wt::WDate date{ 2020, 01, 03 }; + EXPECT_EQ(StringUtils::toISO8601String(date), "2020-01-03"); +} + +TEST(Stringutils, dateTime) +{ + const Wt::WDateTime dateTime{ Wt::WDate {2020, 01, 03 }, Wt::WTime{9, 8, 11, 75} }; + EXPECT_EQ(StringUtils::toISO8601String(dateTime), "2020-01-03T09:08:11.075"); +} \ No newline at end of file diff --git a/src/lms/ui/explore/ArtistCollector.cpp b/src/lms/ui/explore/ArtistCollector.cpp index 0fddcfd7..16bfd9c7 100644 --- a/src/lms/ui/explore/ArtistCollector.cpp +++ b/src/lms/ui/explore/ArtistCollector.cpp @@ -78,6 +78,7 @@ namespace UserInterface Artist::FindParameters params; params.setClusters(getFilters().getClusterIds()); params.setKeywords(getSearchKeywords()); + params.setLinkType(_linkType); params.setSortMethod(ArtistSortMethod::BySortName); params.setRange(range); diff --git a/src/lms/ui/explore/ArtistListHelpers.cpp b/src/lms/ui/explore/ArtistListHelpers.cpp index f8bd2c29..2270307f 100644 --- a/src/lms/ui/explore/ArtistListHelpers.cpp +++ b/src/lms/ui/explore/ArtistListHelpers.cpp @@ -21,6 +21,10 @@ #include #include "services/database/Artist.hpp" +#include "services/database/Session.hpp" +#include "services/database/TrackArtistLink.hpp" +#include "utils/EnumSet.hpp" +#include "LmsApplication.hpp" #include "Utils.hpp" namespace UserInterface::ArtistListHelpers @@ -33,5 +37,43 @@ namespace UserInterface::ArtistListHelpers return res; } + + std::unique_ptr + createArtistLinkTypesModel() + { + using namespace Database; + + std::unique_ptr linkTypesModel {std::make_unique()}; + + EnumSet usedLinkTypes; + { + auto transaction {LmsApp->getDbSession().createSharedTransaction()}; + usedLinkTypes = TrackArtistLink::findUsedTypes(LmsApp->getDbSession()); + } + + auto addTypeIfUsed {[&](TrackArtistLinkType linkType, std::string_view stringKey) + { + if (!usedLinkTypes.contains(linkType)) + return; + + linkTypesModel->add(Wt::WString::trn(std::string {stringKey}, 2), linkType); + }}; + + // add default one first (none) + linkTypesModel->add(Wt::WString::tr("Lms.Explore.Artists.linktype-all"), std::nullopt); + + // TODO: sort by translated strings + addTypeIfUsed(TrackArtistLinkType::Artist, "Lms.Explore.Artists.linktype-artist"); + addTypeIfUsed(TrackArtistLinkType::ReleaseArtist, "Lms.Explore.Artists.linktype-releaseartist"); + addTypeIfUsed(TrackArtistLinkType::Composer, "Lms.Explore.Artists.linktype-composer"); + addTypeIfUsed(TrackArtistLinkType::Conductor, "Lms.Explore.Artists.linktype-conductor"); + addTypeIfUsed(TrackArtistLinkType::Lyricist, "Lms.Explore.Artists.linktype-lyricist"); + addTypeIfUsed(TrackArtistLinkType::Mixer, "Lms.Explore.Artists.linktype-mixer"); + addTypeIfUsed(TrackArtistLinkType::Performer, "Lms.Explore.Artists.linktype-performer"); + addTypeIfUsed(TrackArtistLinkType::Producer, "Lms.Explore.Artists.linktype-producer"); + addTypeIfUsed(TrackArtistLinkType::Remixer, "Lms.Explore.Artists.linktype-remixer"); + + return linkTypesModel; + } } diff --git a/src/lms/ui/explore/ArtistListHelpers.hpp b/src/lms/ui/explore/ArtistListHelpers.hpp index 79bbf700..40d3bf6b 100644 --- a/src/lms/ui/explore/ArtistListHelpers.hpp +++ b/src/lms/ui/explore/ArtistListHelpers.hpp @@ -23,15 +23,23 @@ #include +#include "common/ValueStringModel.hpp" #include "services/database/Object.hpp" +#include "services/database/Types.hpp" namespace Database { class Artist; } -namespace UserInterface::ArtistListHelpers +namespace UserInterface { - std::unique_ptr createEntry(const Database::ObjectPtr& artist); + using ArtistLinkTypesModel = ValueStringModel>; + + namespace ArtistListHelpers + { + std::unique_ptr createEntry(const Database::ObjectPtr& artist); + std::unique_ptr createArtistLinkTypesModel(); + } } diff --git a/src/lms/ui/explore/ArtistsView.cpp b/src/lms/ui/explore/ArtistsView.cpp index 990d4dd5..1db66e42 100644 --- a/src/lms/ui/explore/ArtistsView.cpp +++ b/src/lms/ui/explore/ArtistsView.cpp @@ -24,10 +24,8 @@ #include "services/database/Artist.hpp" #include "services/database/Session.hpp" #include "services/database/TrackArtistLink.hpp" -#include "utils/EnumSet.hpp" #include "utils/Logger.hpp" -#include "common/ValueStringModel.hpp" #include "common/InfiniteScrollingContainer.hpp" #include "ArtistListHelpers.hpp" #include "Filters.hpp" @@ -37,8 +35,6 @@ using namespace Database; namespace UserInterface { -using ArtistLinkModel = ValueStringModel>; - Artists::Artists(Filters& filters) : Wt::WTemplate {Wt::WString::tr("Lms.Explore.Artists.template")} , _artistCollector {filters, _defaultMode, _maxCount} @@ -71,18 +67,17 @@ Artists::Artists(Filters& filters) bindMenuItem("all", Wt::WString::tr("Lms.Explore.all"), ArtistCollector::Mode::All); _linkType = bindNew("link-type"); - _linkType->setModel(std::make_shared()); + _linkType->setModel(ArtistListHelpers::createArtistLinkTypesModel()); _linkType->changed().connect([this] { - const std::optional linkType {static_cast(_linkType->model().get())->getValue(_linkType->currentIndex())}; + const std::optional linkType {static_cast(_linkType->model().get())->getValue(_linkType->currentIndex())}; refreshView(linkType); }); - refreshArtistLinkTypes(); LmsApp->getScannerEvents().scanComplete.connect(this, [this](const Scanner::ScanStats& stats) { if (stats.nbChanges()) - refreshArtistLinkTypes(); + _linkType->setModel(ArtistListHelpers::createArtistLinkTypesModel()); }); _container = bindNew("artists", Wt::WString::tr("Lms.Explore.Artists.template.container")); @@ -120,42 +115,6 @@ Artists::refreshView(std::optional linkType) refreshView(); } -void -Artists::refreshArtistLinkTypes() -{ - std::shared_ptr linkTypeModel {std::static_pointer_cast(_linkType->model())}; - - EnumSet usedLinkTypes; - { - auto transaction {LmsApp->getDbSession().createSharedTransaction()}; - usedLinkTypes = TrackArtistLink::findUsedTypes(LmsApp->getDbSession()); - } - - auto addTypeIfUsed {[&](TrackArtistLinkType linkType, std::string_view stringKey) - { - if (!usedLinkTypes.contains(linkType)) - return; - - linkTypeModel->add(Wt::WString::trn(std::string {stringKey}, 2), linkType); - }}; - - linkTypeModel->clear(); - - // add default one first (none) - linkTypeModel->add(Wt::WString::tr("Lms.Explore.Artists.linktype-all"), std::nullopt); - - // TODO: sort by translated strings - addTypeIfUsed(TrackArtistLinkType::Artist, "Lms.Explore.Artists.linktype-artist"); - addTypeIfUsed(TrackArtistLinkType::ReleaseArtist, "Lms.Explore.Artists.linktype-releaseartist"); - addTypeIfUsed(TrackArtistLinkType::Composer, "Lms.Explore.Artists.linktype-composer"); - addTypeIfUsed(TrackArtistLinkType::Conductor, "Lms.Explore.Artists.linktype-conductor"); - addTypeIfUsed(TrackArtistLinkType::Lyricist, "Lms.Explore.Artists.linktype-lyricist"); - addTypeIfUsed(TrackArtistLinkType::Mixer, "Lms.Explore.Artists.linktype-mixer"); - addTypeIfUsed(TrackArtistLinkType::Performer, "Lms.Explore.Artists.linktype-performer"); - addTypeIfUsed(TrackArtistLinkType::Producer, "Lms.Explore.Artists.linktype-producer"); - addTypeIfUsed(TrackArtistLinkType::Remixer, "Lms.Explore.Artists.linktype-remixer"); -} - void Artists::addSome() { diff --git a/src/lms/ui/explore/ArtistsView.hpp b/src/lms/ui/explore/ArtistsView.hpp index 53a200e2..26e34e16 100644 --- a/src/lms/ui/explore/ArtistsView.hpp +++ b/src/lms/ui/explore/ArtistsView.hpp @@ -42,7 +42,6 @@ namespace UserInterface void refreshView(); void refreshView(ArtistCollector::Mode mode); void refreshView(std::optional linkType); - void refreshArtistLinkTypes(); void addSome(); static constexpr std::size_t _batchSize {30}; diff --git a/src/lms/ui/explore/ReleaseHelpers.cpp b/src/lms/ui/explore/ReleaseHelpers.cpp index c4b9fa32..57a91efe 100644 --- a/src/lms/ui/explore/ReleaseHelpers.cpp +++ b/src/lms/ui/explore/ReleaseHelpers.cpp @@ -66,7 +66,7 @@ namespace UserInterface::ReleaseListHelpers if (showYear) { - Wt::WString year {ReleaseHelpers::buildReleaseYearString(release->getReleaseYear(), release->getReleaseYear(true))}; + Wt::WString year {ReleaseHelpers::buildReleaseYearString(release->getReleaseDate(), release->getOriginalReleaseDate())}; if (!year.empty()) { entry->setCondition("if-has-year", true); @@ -135,18 +135,18 @@ namespace UserInterface::ReleaseHelpers return res; } - Wt::WString buildReleaseYearString(std::optional year, std::optional originalYear) + Wt::WString buildReleaseYearString(const Wt::WDate& releaseDate, const Wt::WDate& originalReleaseDate) { Wt::WString res; // Year can be here, but originalYear can't be here without year (enforced by scanner) - if (!year) + if (!releaseDate.isValid()) return res; - if (originalYear && *originalYear != *year) - res = std::to_string(*originalYear) + " (" + std::to_string(*year) + ")"; + if (originalReleaseDate.isValid() && originalReleaseDate != releaseDate) + res = std::to_string(originalReleaseDate.year()) + " (" + std::to_string(releaseDate.year()) + ")"; else - res = std::to_string(*year); + res = std::to_string(releaseDate.year()); return res; } diff --git a/src/lms/ui/explore/ReleaseHelpers.hpp b/src/lms/ui/explore/ReleaseHelpers.hpp index ebc424aa..3bdef46b 100644 --- a/src/lms/ui/explore/ReleaseHelpers.hpp +++ b/src/lms/ui/explore/ReleaseHelpers.hpp @@ -24,6 +24,7 @@ #include #include +#include #include "services/database/Object.hpp" #include "services/database/Types.hpp" #include "utils/EnumSet.hpp" @@ -43,5 +44,5 @@ namespace UserInterface::ReleaseListHelpers namespace UserInterface::ReleaseHelpers { Wt::WString buildReleaseTypeString(Database::ReleaseTypePrimary primaryType, EnumSet secondaryTypes); - Wt::WString buildReleaseYearString(std::optional year, std::optional originalYear); + Wt::WString buildReleaseYearString(const Wt::WDate& releaseDate, const Wt::WDate& originalReleaseDate); } diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index 7e538365..de03fe76 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -248,7 +248,7 @@ Release::refreshView() bindString("name", Wt::WString::fromUTF8(release->getName()), Wt::TextFormat::Plain); - Wt::WString year {ReleaseHelpers::buildReleaseYearString(release->getReleaseYear(), release->getReleaseYear(true))}; + Wt::WString year {ReleaseHelpers::buildReleaseYearString(release->getReleaseDate(), release->getOriginalReleaseDate())}; if (!year.empty()) { setCondition("if-has-year", true); diff --git a/src/lms/ui/explore/SearchView.cpp b/src/lms/ui/explore/SearchView.cpp index c8fca22b..471f431d 100644 --- a/src/lms/ui/explore/SearchView.cpp +++ b/src/lms/ui/explore/SearchView.cpp @@ -48,6 +48,14 @@ namespace UserInterface _artists = bindNew("artists", Wt::WString::tr("Lms.Explore.Artists.template.container")); _artists->onRequestElements.connect([this] { addSomeArtists(); }); + _artistLinkType = bindNew("link-type"); + _artistLinkType->setModel(ArtistListHelpers::createArtistLinkTypesModel()); + _artistLinkType->changed().connect([this] + { + const std::optional linkType {static_cast(_artistLinkType->model().get())->getValue(_artistLinkType->currentIndex())}; + refreshView(linkType); + }); + _releases = bindNew("releases", Wt::WString::tr("Lms.Explore.Releases.template.container")); _releases->onRequestElements.connect([this] { addSomeReleases(); }); @@ -58,6 +66,12 @@ namespace UserInterface { refreshView(); }); + + LmsApp->getScannerEvents().scanComplete.connect(this, [this](const Scanner::ScanStats& stats) + { + if (stats.nbChanges()) + _artistLinkType->setModel(ArtistListHelpers::createArtistLinkTypesModel()); + }); } std::size_t @@ -76,6 +90,13 @@ namespace UserInterface return it->second; } + void + SearchView::refreshView(std::optional linkType) + { + _artistCollector.setArtistLinkType(linkType); + refreshView(); + } + void SearchView::refreshView(const Wt::WString& searchText) { diff --git a/src/lms/ui/explore/SearchView.hpp b/src/lms/ui/explore/SearchView.hpp index 337a3a0c..bc5c33df 100644 --- a/src/lms/ui/explore/SearchView.hpp +++ b/src/lms/ui/explore/SearchView.hpp @@ -19,11 +19,14 @@ #pragma once +#include #include +#include #include #include +#include "services/database/Types.hpp" #include "ArtistCollector.hpp" #include "ReleaseCollector.hpp" #include "TrackCollector.hpp" @@ -67,6 +70,7 @@ namespace UserInterface std::size_t getMaxCount(Mode mode) const; void refreshView(); + void refreshView(std::optional linkType); void addSomeArtists(); void addSomeReleases(); void addSomeTracks(); @@ -77,9 +81,11 @@ namespace UserInterface ReleaseCollector _releaseCollector; TrackCollector _trackCollector; - InfiniteScrollingContainer* _artists; - InfiniteScrollingContainer* _releases; - InfiniteScrollingContainer* _tracks; + InfiniteScrollingContainer* _artists {}; + InfiniteScrollingContainer* _releases {}; + InfiniteScrollingContainer* _tracks {}; + + Wt::WComboBox* _artistLinkType {}; std::vector _results; }; diff --git a/src/lms/ui/resource/DownloadResource.cpp b/src/lms/ui/resource/DownloadResource.cpp index 4f6f4b77..594c8182 100644 --- a/src/lms/ui/resource/DownloadResource.cpp +++ b/src/lms/ui/resource/DownloadResource.cpp @@ -109,8 +109,8 @@ getReleasePathName(Database::Release::pointer release) { std::string releaseName; - if (auto releaseYear {release->getReleaseYear()}) - releaseName += std::to_string(*releaseYear) + " - "; + if (const Wt::WDate releaseDate {release->getReleaseDate()}; releaseDate.isValid()) + releaseName += std::to_string(releaseDate.year()) + " - "; releaseName += StringUtils::replaceInString(release->getName(), "/", "_"); return releaseName;