From a71e5b170bd49584e2a3fe99aef32012575c5882 Mon Sep 17 00:00:00 2001 From: Rob Stanfield Date: Sat, 20 Dec 2025 09:46:23 -0800 Subject: [PATCH] Fetch metadata and allow editing for stream songs --- CMakeLists.txt | 4 + src/core/mainwindow.cpp | 214 ++++++++++++++++++++++- src/core/mainwindow.h | 13 ++ src/core/song.cpp | 5 +- src/core/song.h | 3 +- src/dialogs/edittagdialog.cpp | 17 ++ src/dialogs/edittagdialog.h | 2 +- src/playlist/playlist.cpp | 4 +- src/qobuz/qobuzmetadatarequest.cpp | 230 ++++++++++++++++++++++++ src/qobuz/qobuzmetadatarequest.h | 55 ++++++ src/spotify/spotifymetadatarequest.cpp | 231 +++++++++++++++++++++++++ src/spotify/spotifymetadatarequest.h | 58 +++++++ 12 files changed, 826 insertions(+), 10 deletions(-) create mode 100644 src/qobuz/qobuzmetadatarequest.cpp create mode 100644 src/qobuz/qobuzmetadatarequest.h create mode 100644 src/spotify/spotifymetadatarequest.cpp create mode 100644 src/spotify/spotifymetadatarequest.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 432c540f4..db3dadaad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1447,6 +1447,7 @@ optional_source(HAVE_SPOTIFY src/spotify/spotifybaserequest.cpp src/spotify/spotifyrequest.cpp src/spotify/spotifyfavoriterequest.cpp + src/spotify/spotifymetadatarequest.cpp src/settings/spotifysettingspage.cpp src/covermanager/spotifycoverprovider.cpp HEADERS @@ -1454,6 +1455,7 @@ optional_source(HAVE_SPOTIFY src/spotify/spotifybaserequest.h src/spotify/spotifyrequest.h src/spotify/spotifyfavoriterequest.h + src/spotify/spotifymetadatarequest.h src/settings/spotifysettingspage.h src/covermanager/spotifycoverprovider.h UI @@ -1468,6 +1470,7 @@ optional_source(HAVE_QOBUZ src/qobuz/qobuzrequest.cpp src/qobuz/qobuzstreamurlrequest.cpp src/qobuz/qobuzfavoriterequest.cpp + src/qobuz/qobuzmetadatarequest.cpp src/qobuz/qobuzcredentialfetcher.cpp src/settings/qobuzsettingspage.cpp src/covermanager/qobuzcoverprovider.cpp @@ -1478,6 +1481,7 @@ optional_source(HAVE_QOBUZ src/qobuz/qobuzrequest.h src/qobuz/qobuzstreamurlrequest.h src/qobuz/qobuzfavoriterequest.h + src/qobuz/qobuzmetadatarequest.h src/qobuz/qobuzcredentialfetcher.h src/settings/qobuzsettingspage.h src/covermanager/qobuzcoverprovider.h diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index ea66e1b85..e52488739 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -173,9 +173,12 @@ #endif #ifdef HAVE_SPOTIFY # include "spotify/spotifyservice.h" +# include "spotify/spotifymetadatarequest.h" # include "constants/spotifysettings.h" #endif #ifdef HAVE_QOBUZ +# include "qobuz/qobuzservice.h" +# include "qobuz/qobuzmetadatarequest.h" # include "constants/qobuzsettings.h" #endif @@ -379,8 +382,10 @@ MainWindow::MainWindow(Application *app, playlist_add_to_another_(nullptr), playlistitem_actions_separator_(nullptr), playlist_rescan_songs_(nullptr), + playlist_fetch_metadata_(nullptr), track_position_timer_(new QTimer(this)), track_slider_timer_(new QTimer(this)), + metadata_queue_timer_(new QTimer(this)), keep_running_(false), playing_widget_(true), #ifdef HAVE_DBUS @@ -452,6 +457,10 @@ MainWindow::MainWindow(Application *app, track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs); QObject::connect(track_slider_timer_, &QTimer::timeout, this, &MainWindow::UpdateTrackSliderPosition); + metadata_queue_timer_->setInterval(200ms); // 200ms between requests to avoid rate limiting + metadata_queue_timer_->setSingleShot(true); + QObject::connect(metadata_queue_timer_, &QTimer::timeout, this, &MainWindow::ProcessMetadataQueue); + // Start initializing the player qLog(Debug) << "Initializing player"; app_->player()->SetAnalyzer(ui_->analyzer); @@ -812,6 +821,8 @@ MainWindow::MainWindow(Application *app, #endif playlist_rescan_songs_ = playlist_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Rescan song(s)..."), this, &MainWindow::RescanSongs); playlist_menu_->addAction(playlist_rescan_songs_); + playlist_fetch_metadata_ = playlist_menu_->addAction(IconLoader::Load(u"download"_s), tr("Fetch metadata from service"), this, &MainWindow::FetchStreamingMetadata); + playlist_menu_->addAction(playlist_fetch_metadata_); playlist_menu_->addAction(ui_->action_add_files_to_transcoder); playlist_menu_->addSeparator(); playlist_copy_url_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy URL(s)..."), this, &MainWindow::PlaylistCopyUrl); @@ -1995,6 +2006,7 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex & int in_skipped = 0; int not_in_skipped = 0; int local_songs = 0; + int streaming_songs = 0; for (const QModelIndex &idx : selection) { @@ -2004,7 +2016,13 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex & PlaylistItemPtr item = app_->playlist_manager()->current()->item_at(src_idx.row()); if (!item) continue; - if (item->EffectiveMetadata().url().isLocalFile()) ++local_songs; + if (item->EffectiveMetadata().url().isLocalFile()) { + ++local_songs; + } + + if (item->EffectiveMetadata().is_stream_service()) { + ++streaming_songs; + } if (item->EffectiveMetadata().has_cue()) { cue_selected = true; @@ -2032,6 +2050,9 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex & playlist_rescan_songs_->setEnabled(local_songs > 0 && editable > 0); playlist_rescan_songs_->setVisible(local_songs > 0 && editable > 0); + playlist_fetch_metadata_->setEnabled(streaming_songs > 0); + playlist_fetch_metadata_->setVisible(streaming_songs > 0); + ui_->action_add_files_to_transcoder->setEnabled(local_songs > 0 && editable > 0); ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0); @@ -2243,8 +2264,22 @@ void MainWindow::EditTracks() { void MainWindow::EditTagDialogAccepted() { const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items(); - for (PlaylistItemPtr item : items) { - item->Reload(); + const SongList songs = edit_tag_dialog_->songs(); + + if (items.count() != songs.count()) { + return; + } + + for (int i = 0; i < items.count(); ++i) { + PlaylistItemPtr item = items[i]; + const Song &updated_song = songs[i]; + // For stream tracks, apply the metadata directly since there's no file to reload from + if (updated_song.is_stream_service()) { + item->SetOriginalMetadata(updated_song); + } + else { + item->Reload(); + } } // FIXME: This is really lame but we don't know what rows have changed. @@ -2319,8 +2354,8 @@ void MainWindow::SelectionSetValue() { QObject::disconnect(*connection); }, Qt::QueuedConnection); } - else if (song.source() == Song::Source::Stream) { - app_->playlist_manager()->current()->setData(source_index, column_value, 0); + else if (song.is_stream()) { + app_->playlist_manager()->current()->setData(source_index.sibling(source_index.row(), static_cast(column)), column_value, 0); } } @@ -3404,3 +3439,172 @@ void MainWindow::FocusSearchField() { } } + +void MainWindow::FetchStreamingMetadata() { + + const QModelIndexList proxy_indexes = ui_->playlist->view()->selectionModel()->selectedRows(); + for (const QModelIndex &proxy_index : proxy_indexes) { + const QModelIndex source_index = app_->playlist_manager()->current()->filter()->mapToSource(proxy_index); + if (!source_index.isValid()) continue; + PlaylistItemPtr item(app_->playlist_manager()->current()->item_at(source_index.row())); + if (!item) continue; + + const Song &song = item->EffectiveMetadata(); + const QPersistentModelIndex persistent_index = QPersistentModelIndex(source_index); + QString track_id; + +#ifdef HAVE_QOBUZ + if (song.source() == Song::Source::Qobuz) { + track_id = song.song_id(); + // song_id() may be empty if not persisted, fall back to URL path + if (track_id.isEmpty()) { + track_id = song.url().path(); + } + if (track_id.isEmpty()) { + qLog(Error) << "Failed to fetch Qobuz metadata: No track ID"; + continue; + } + } +#endif + +#ifdef HAVE_SPOTIFY + if (song.source() == Song::Source::Spotify) { + track_id = song.song_id(); + // song_id() may be empty if not persisted, fall back to parsing URL + if (track_id.isEmpty() && song.url().scheme() == "spotify"_L1 && song.url().path().startsWith(u"track:"_s)) { + track_id = song.url().path().mid(6); + } + if (track_id.isEmpty()) { + qLog(Error) << "Failed to fetch Spotify metadata: No track ID"; + continue; + } + } +#endif + + if (!track_id.isEmpty()) { + metadata_queue_.append({song.source(), track_id, persistent_index}); + } + + } + + // Start processing the queue if it's not already running + if (!metadata_queue_.isEmpty() && !metadata_queue_timer_->isActive()) { + ProcessMetadataQueue(); + } + +} + +void MainWindow::ProcessMetadataQueue() { + + if (metadata_queue_.isEmpty()) { + return; + } + + const MetadataQueueEntry metadata_queue_entry = metadata_queue_.takeFirst(); + +#ifdef HAVE_QOBUZ + if (metadata_queue_entry.source == Song::Source::Qobuz) { + if (QobuzServicePtr qobuz_service = app_->streaming_services()->Service()) { + QobuzMetadataRequest *request = new QobuzMetadataRequest(qobuz_service.get(), qobuz_service->network(), this); + QObject::connect(request, &QobuzMetadataRequest::MetadataReceived, this, [this, metadata_queue_entry, request](const QString &received_track_id, const Song &fetched_song) { + Q_UNUSED(received_track_id); + if (metadata_queue_entry.persistent_index.isValid() && fetched_song.is_valid()) { + PlaylistItemPtr playlist_item = app_->playlist_manager()->current()->item_at(metadata_queue_entry.persistent_index.row()); + if (playlist_item) { + const Song old_song = playlist_item->OriginalMetadata(); + Song updated_song = old_song; + // Update all metadata fields from the fetched song + if (!fetched_song.title().isEmpty()) updated_song.set_title(fetched_song.title()); + if (!fetched_song.artist().isEmpty()) updated_song.set_artist(fetched_song.artist()); + if (!fetched_song.album().isEmpty()) updated_song.set_album(fetched_song.album()); + if (!fetched_song.albumartist().isEmpty()) updated_song.set_albumartist(fetched_song.albumartist()); + if (!fetched_song.genre().isEmpty()) updated_song.set_genre(fetched_song.genre()); + if (!fetched_song.composer().isEmpty()) updated_song.set_composer(fetched_song.composer()); + if (!fetched_song.performer().isEmpty()) updated_song.set_performer(fetched_song.performer()); + if (!fetched_song.comment().isEmpty()) updated_song.set_comment(fetched_song.comment()); + if (fetched_song.track() > 0) updated_song.set_track(fetched_song.track()); + if (fetched_song.disc() > 0) updated_song.set_disc(fetched_song.disc()); + if (fetched_song.year() > 0) updated_song.set_year(fetched_song.year()); + if (fetched_song.length_nanosec() > 0) updated_song.set_length_nanosec(fetched_song.length_nanosec()); + if (fetched_song.art_automatic().isValid()) updated_song.set_art_automatic(fetched_song.art_automatic()); + playlist_item->SetOriginalMetadata(updated_song); + app_->playlist_manager()->current()->ItemReload(metadata_queue_entry.persistent_index, old_song, false); + } + } + request->deleteLater(); + // Process next item in queue + if (!metadata_queue_.isEmpty()) { + metadata_queue_timer_->start(); + } + }); + QObject::connect(request, &QobuzMetadataRequest::MetadataFailure, this, [this, request](const QString &failed_track_id, const QString &error) { + Q_UNUSED(failed_track_id); + qLog(Error) << "Failed to fetch Qobuz metadata:" << error; + request->deleteLater(); + // Process next item in queue + if (!metadata_queue_.isEmpty()) { + metadata_queue_timer_->start(); + } + }); + request->FetchTrackMetadata(metadata_queue_entry.track_id); + return; + } + } +#endif + +#ifdef HAVE_SPOTIFY + if (metadata_queue_entry.source == Song::Source::Spotify) { + if (SpotifyServicePtr spotify_service = app_->streaming_services()->Service()) { + SpotifyMetadataRequest *request = new SpotifyMetadataRequest(spotify_service.get(), app_->network(), this); + QObject::connect(request, &SpotifyMetadataRequest::MetadataReceived, this, [this, metadata_queue_entry, request](const QString &received_track_id, const Song &fetched_song) { + Q_UNUSED(received_track_id); + if (metadata_queue_entry.persistent_index.isValid() && fetched_song.is_valid()) { + PlaylistItemPtr playlist_item = app_->playlist_manager()->current()->item_at(metadata_queue_entry.persistent_index.row()); + if (playlist_item) { + const Song old_song = playlist_item->OriginalMetadata(); + Song updated_song = old_song; + // Update all metadata fields from the fetched song + if (!fetched_song.title().isEmpty()) updated_song.set_title(fetched_song.title()); + if (!fetched_song.artist().isEmpty()) updated_song.set_artist(fetched_song.artist()); + if (!fetched_song.album().isEmpty()) updated_song.set_album(fetched_song.album()); + if (!fetched_song.albumartist().isEmpty()) updated_song.set_albumartist(fetched_song.albumartist()); + if (!fetched_song.genre().isEmpty()) updated_song.set_genre(fetched_song.genre()); + if (!fetched_song.composer().isEmpty()) updated_song.set_composer(fetched_song.composer()); + if (!fetched_song.performer().isEmpty()) updated_song.set_performer(fetched_song.performer()); + if (!fetched_song.comment().isEmpty()) updated_song.set_comment(fetched_song.comment()); + if (fetched_song.track() > 0) updated_song.set_track(fetched_song.track()); + if (fetched_song.disc() > 0) updated_song.set_disc(fetched_song.disc()); + if (fetched_song.year() > 0) updated_song.set_year(fetched_song.year()); + if (fetched_song.length_nanosec() > 0) updated_song.set_length_nanosec(fetched_song.length_nanosec()); + if (fetched_song.art_automatic().isValid()) updated_song.set_art_automatic(fetched_song.art_automatic()); + playlist_item->SetOriginalMetadata(updated_song); + app_->playlist_manager()->current()->ItemReload(metadata_queue_entry.persistent_index, old_song, false); + } + } + request->deleteLater(); + // Process next item in queue + if (!metadata_queue_.isEmpty()) { + metadata_queue_timer_->start(); + } + }); + QObject::connect(request, &SpotifyMetadataRequest::MetadataFailure, this, [this, request](const QString &failed_track_id, const QString &error) { + Q_UNUSED(failed_track_id); + qLog(Error) << "Failed to fetch Spotify metadata:" << error; + request->deleteLater(); + // Process next item in queue + if (!metadata_queue_.isEmpty()) { + metadata_queue_timer_->start(); + } + }); + request->FetchTrackMetadata(metadata_queue_entry.track_id); + return; + } + } +#endif + + // If we get here, the source wasn't handled - try the next item + if (!metadata_queue_.isEmpty()) { + metadata_queue_timer_->start(); + } + +} diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index e4a0791d8..a517a31cb 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -276,6 +276,9 @@ class MainWindow : public QMainWindow, public PlatformInterface { void DeleteFilesFinished(const SongList &songs_with_errors); + void FetchStreamingMetadata(); + void ProcessMetadataQueue(); + public Q_SLOTS: void CommandlineOptionsReceived(const QByteArray &string_options); void Raise(); @@ -379,11 +382,13 @@ class MainWindow : public QMainWindow, public PlatformInterface { QList playlistitem_actions_; QAction *playlistitem_actions_separator_; QAction *playlist_rescan_songs_; + QAction *playlist_fetch_metadata_; QModelIndex playlist_menu_index_; QTimer *track_position_timer_; QTimer *track_slider_timer_; + QTimer *metadata_queue_timer_; bool keep_running_; bool playing_widget_; @@ -407,6 +412,14 @@ class MainWindow : public QMainWindow, public PlatformInterface { bool playlists_loaded_; bool delete_files_; std::optional options_; + + class MetadataQueueEntry { + public: + Song::Source source; + QString track_id; + QPersistentModelIndex persistent_index; + }; + QList metadata_queue_; }; #endif // MAINWINDOW_H diff --git a/src/core/song.cpp b/src/core/song.cpp index af4fcbe42..78b1a0370 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -686,8 +686,9 @@ const QString &Song::playlist_effective_albumartistsort() const { return is_comp bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); } bool Song::is_local_collection_song() const { return d->source_ == Source::Collection; } bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); } -bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; } bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; } +bool Song::is_stream_service() const { return d->source_ == Source::Subsonic || d->source_ == Source::Tidal || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; } +bool Song::is_stream() const { return is_radio() || is_stream_service(); } bool Song::is_cdda() const { return d->source_ == Source::CDDA; } bool Song::is_compilation() const { return (d->compilation_ || d->compilation_detected_ || d->compilation_on_) && !d->compilation_off_; } bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz; } @@ -956,7 +957,7 @@ QString Song::PrettyRating() const { } bool Song::IsEditable() const { - return d->valid_ && d->url_.isValid() && ((d->url_.isLocalFile() && write_tags_supported() && !has_cue()) || d->source_ == Source::Stream); + return d->valid_ && d->url_.isValid() && ((d->url_.isLocalFile() && write_tags_supported() && !has_cue()) || is_stream()); } bool Song::IsFileInfoEqual(const Song &other) const { diff --git a/src/core/song.h b/src/core/song.h index 9c06e54c7..cf0802da1 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -407,8 +407,9 @@ class Song { bool is_metadata_good() const; bool is_local_collection_song() const; bool is_linked_collection_song() const; - bool is_stream() const; bool is_radio() const; + bool is_stream_service() const; + bool is_stream() const; bool is_cdda() const; bool is_compilation() const; bool stream_url_can_expire() const; diff --git a/src/dialogs/edittagdialog.cpp b/src/dialogs/edittagdialog.cpp index e4b1774c4..26b281eb1 100644 --- a/src/dialogs/edittagdialog.cpp +++ b/src/dialogs/edittagdialog.cpp @@ -411,6 +411,17 @@ bool EditTagDialog::eventFilter(QObject *o, QEvent *e) { } +SongList EditTagDialog::songs() const { + + SongList result; + for (const Data &d : data_) { + result << d.current_; + } + + return result; + +} + bool EditTagDialog::SetLoading(const QString &message) { const bool loading = !message.isEmpty(); @@ -1399,6 +1410,12 @@ void EditTagDialog::SaveData() { } if (save_tags || save_playcount || save_rating || save_embedded_cover) { + // For streaming tracks, skip tag writing since there's no local file. + // The metadata will be applied directly to the playlist item in MainWindow::EditTagDialogAccepted. + if (ref.current_.is_stream()) { + continue; + } + // Not to confuse the collection model. if (ref.current_.track() <= 0) { ref.current_.set_track(-1); } if (ref.current_.disc() <= 0) { ref.current_.set_disc(-1); } diff --git a/src/dialogs/edittagdialog.h b/src/dialogs/edittagdialog.h index 38e6832ed..a2a294fd0 100644 --- a/src/dialogs/edittagdialog.h +++ b/src/dialogs/edittagdialog.h @@ -85,7 +85,7 @@ class EditTagDialog : public QDialog { void SetSongs(const SongList &songs, const PlaylistItemPtrList &items = PlaylistItemPtrList()); PlaylistItemPtrList playlist_items() const { return playlist_items_; } - + SongList songs() const; void accept() override; Q_SIGNALS: diff --git a/src/playlist/playlist.cpp b/src/playlist/playlist.cpp index dd1fe0b1e..a320d92d5 100644 --- a/src/playlist/playlist.cpp +++ b/src/playlist/playlist.cpp @@ -474,8 +474,10 @@ bool Playlist::setData(const QModelIndex &idx, const QVariant &value, const int QObject::disconnect(*connection); }, Qt::QueuedConnection); } - else if (song.is_radio()) { + else if (song.is_stream()) { item->SetOriginalMetadata(song); + Q_EMIT dataChanged(index(row, 0), index(row, ColumnCount - 1)); + Q_EMIT EditingFinished(id_, idx); ScheduleSave(); } diff --git a/src/qobuz/qobuzmetadatarequest.cpp b/src/qobuz/qobuzmetadatarequest.cpp new file mode 100644 index 000000000..3c792f4a9 --- /dev/null +++ b/src/qobuz/qobuzmetadatarequest.cpp @@ -0,0 +1,230 @@ +/* + * Strawberry Music Player + * Copyright 2025-2026, Jonas Kvinge + * + * Strawberry 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. + * + * Strawberry 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 Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "includes/shared_ptr.h" +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "core/song.h" +#include "qobuzservice.h" +#include "qobuzmetadatarequest.h" + +namespace { +constexpr qint64 kNsecPerSec = 1000000000LL; +} + +using namespace Qt::Literals::StringLiterals; + +QobuzMetadataRequest::QobuzMetadataRequest(QobuzService *service, const SharedPtr network, QObject *parent) + : QobuzBaseRequest(service, network, parent) {} + +void QobuzMetadataRequest::FetchTrackMetadata(const QString &track_id) { + + if (!authenticated()) { + Q_EMIT MetadataFailure(track_id, tr("Not authenticated")); + return; + } + + if (track_id.isEmpty()) { + Q_EMIT MetadataFailure(track_id, tr("No track ID")); + return; + } + + ParamList params = ParamList() << Param(u"track_id"_s, track_id); + + QNetworkReply *reply = CreateRequest(u"track/get"_s, params); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, track_id]() { + TrackMetadataReceived(reply, track_id); + }); + +} + +void QobuzMetadataRequest::TrackMetadataReceived(QNetworkReply *reply, const QString &track_id) { + + if (!replies_.contains(reply)) { + qLog(Debug) << "Qobuz: Reply not in replies_ list for track" << track_id; + return; + } + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + JsonObjectResult result = ParseJsonObject(reply); + if (result.error_code != JsonBaseRequest::ErrorCode::Success) { + Error(result.error_message); + Q_EMIT MetadataFailure(track_id, result.error_message); + return; + } + + const QJsonObject &json_obj = result.json_object; + + Song song; + song.set_source(Song::Source::Qobuz); + + // Parse song ID + QString song_id; + if (json_obj["id"_L1].isString()) { + song_id = json_obj["id"_L1].toString(); + } + else { + song_id = QString::number(json_obj["id"_L1].toInt()); + } + song.set_song_id(song_id); + + // Parse basic track info + if (json_obj.contains("title"_L1)) { + song.set_title(json_obj["title"_L1].toString()); + } + if (json_obj.contains("track_number"_L1)) { + song.set_track(json_obj["track_number"_L1].toInt()); + } + if (json_obj.contains("media_number"_L1)) { + song.set_disc(json_obj["media_number"_L1].toInt()); + } + if (json_obj.contains("duration"_L1)) { + song.set_length_nanosec(json_obj["duration"_L1].toInt() * kNsecPerSec); + } + if (json_obj.contains("copyright"_L1)) { + song.set_comment(json_obj["copyright"_L1].toString()); + } + if (json_obj.contains("composer"_L1)) { + QJsonValue value_composer = json_obj["composer"_L1]; + if (value_composer.isObject()) { + QJsonObject obj_composer = value_composer.toObject(); + if (obj_composer.contains("name"_L1)) { + song.set_composer(obj_composer["name"_L1].toString()); + } + } + } + if (json_obj.contains("performer"_L1)) { + QJsonValue value_performer = json_obj["performer"_L1]; + if (value_performer.isObject()) { + QJsonObject obj_performer = value_performer.toObject(); + if (obj_performer.contains("name"_L1)) { + song.set_performer(obj_performer["name"_L1].toString()); + } + } + } + + // Parse album info (includes artist, cover, genre) + if (json_obj.contains("album"_L1)) { + QJsonValue value_album = json_obj["album"_L1]; + if (value_album.isObject()) { + QJsonObject obj_album = value_album.toObject(); + + if (obj_album.contains("id"_L1)) { + QString album_id; + if (obj_album["id"_L1].isString()) { + album_id = obj_album["id"_L1].toString(); + } + else { + album_id = QString::number(obj_album["id"_L1].toInt()); + } + song.set_album_id(album_id); + } + + if (obj_album.contains("title"_L1)) { + song.set_album(obj_album["title"_L1].toString()); + } + + // Artist from album + if (obj_album.contains("artist"_L1)) { + QJsonValue value_artist = obj_album["artist"_L1]; + if (value_artist.isObject()) { + QJsonObject obj_artist = value_artist.toObject(); + if (obj_artist.contains("id"_L1)) { + QString artist_id; + if (obj_artist["id"_L1].isString()) { + artist_id = obj_artist["id"_L1].toString(); + } + else { + artist_id = QString::number(obj_artist["id"_L1].toInt()); + } + song.set_artist_id(artist_id); + } + if (obj_artist.contains("name"_L1)) { + song.set_artist(obj_artist["name"_L1].toString()); + song.set_albumartist(obj_artist["name"_L1].toString()); + } + } + } + + // Cover image + if (obj_album.contains("image"_L1)) { + QJsonValue value_image = obj_album["image"_L1]; + if (value_image.isObject()) { + QJsonObject obj_image = value_image.toObject(); + if (obj_image.contains("large"_L1)) { + QString cover_url = obj_image["large"_L1].toString(); + if (!cover_url.isEmpty()) { + song.set_art_automatic(QUrl(cover_url)); + } + } + } + } + + // Genre + if (obj_album.contains("genre"_L1)) { + QJsonValue value_genre = obj_album["genre"_L1]; + if (value_genre.isObject()) { + QJsonObject obj_genre = value_genre.toObject(); + if (obj_genre.contains("name"_L1)) { + song.set_genre(obj_genre["name"_L1].toString()); + } + } + } + + // Release date / year + if (obj_album.contains("released_at"_L1)) { + qint64 released_at = obj_album["released_at"_L1].toVariant().toLongLong(); + if (released_at > 0) { + QDateTime datetime = QDateTime::fromSecsSinceEpoch(released_at); + song.set_year(datetime.date().year()); + } + } + } + } + + song.set_valid(true); + + qLog(Debug) << "Qobuz: Track metadata received for" << track_id + << "- title:" << song.title() + << "- artist:" << song.artist() + << "- album:" << song.album() + << "- genre:" << song.genre(); + + Q_EMIT MetadataReceived(track_id, song); + +} + +void QobuzMetadataRequest::Error(const QString &error_message, const QVariant &debug_output) { + + qLog(Error) << "Qobuz:" << error_message; + if (debug_output.isValid()) qLog(Debug) << debug_output; + +} diff --git a/src/qobuz/qobuzmetadatarequest.h b/src/qobuz/qobuzmetadatarequest.h new file mode 100644 index 000000000..5dc7fe78f --- /dev/null +++ b/src/qobuz/qobuzmetadatarequest.h @@ -0,0 +1,55 @@ +/* + * Strawberry Music Player + * Copyright 2025-2026, Jonas Kvinge + * + * Strawberry 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. + * + * Strawberry 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 Strawberry. If not, see . + * + */ + +#ifndef QOBUZMETADATAREQUEST_H +#define QOBUZMETADATAREQUEST_H + +#include "config.h" + +#include +#include + +#include "includes/shared_ptr.h" +#include "core/song.h" +#include "qobuzbaserequest.h" + +class QNetworkReply; +class NetworkAccessManager; +class QobuzService; + +class QobuzMetadataRequest : public QobuzBaseRequest { + Q_OBJECT + + public: + explicit QobuzMetadataRequest(QobuzService *service, const SharedPtr network, QObject *parent = nullptr); + + void FetchTrackMetadata(const QString &track_id); + + Q_SIGNALS: + void MetadataReceived(QString track_id, Song song); + void MetadataFailure(QString track_id, QString error); + + private Q_SLOTS: + void TrackMetadataReceived(QNetworkReply *reply, const QString &track_id); + + private: + void Error(const QString &error_message, const QVariant &debug_output = QVariant()) override; +}; + +#endif // QOBUZMETADATAREQUEST_H diff --git a/src/spotify/spotifymetadatarequest.cpp b/src/spotify/spotifymetadatarequest.cpp new file mode 100644 index 000000000..13b397ee8 --- /dev/null +++ b/src/spotify/spotifymetadatarequest.cpp @@ -0,0 +1,231 @@ +/* + * Strawberry Music Player + * Copyright 2025-2026, Jonas Kvinge + * + * Strawberry 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. + * + * Strawberry 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 Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "includes/shared_ptr.h" +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "core/song.h" +#include "spotifyservice.h" +#include "spotifymetadatarequest.h" + +namespace { +constexpr qint64 kNsecPerMsec = 1000000LL; +} + +using namespace Qt::Literals::StringLiterals; + +SpotifyMetadataRequest::SpotifyMetadataRequest(SpotifyService *service, const SharedPtr network, QObject *parent) + : SpotifyBaseRequest(service, network, parent) {} + +void SpotifyMetadataRequest::FetchTrackMetadata(const QString &track_id) { + + if (!authenticated()) { + Q_EMIT MetadataFailure(track_id, tr("Not authenticated")); + return; + } + + if (track_id.isEmpty()) { + Q_EMIT MetadataFailure(track_id, tr("No track ID")); + return; + } + + QNetworkReply *reply = CreateRequest(u"tracks/"_s + track_id, ParamList()); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, track_id]() { + TrackMetadataReceived(reply, track_id); + }); + +} + +void SpotifyMetadataRequest::TrackMetadataReceived(QNetworkReply *reply, const QString &track_id) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + JsonObjectResult result = ParseJsonObject(reply); + if (result.error_code != JsonBaseRequest::ErrorCode::Success) { + Error(result.error_message); + Q_EMIT MetadataFailure(track_id, result.error_message); + return; + } + + const QJsonObject &json_obj = result.json_object; + + Song song; + song.set_source(Song::Source::Spotify); + + // Parse song ID and URI + if (json_obj.contains("id"_L1)) { + song.set_song_id(json_obj["id"_L1].toString()); + } + if (json_obj.contains("uri"_L1)) { + song.set_url(QUrl(json_obj["uri"_L1].toString())); + } + + // Parse basic track info + if (json_obj.contains("name"_L1)) { + song.set_title(json_obj["name"_L1].toString()); + } + if (json_obj.contains("track_number"_L1)) { + song.set_track(json_obj["track_number"_L1].toInt()); + } + if (json_obj.contains("disc_number"_L1)) { + song.set_disc(json_obj["disc_number"_L1].toInt()); + } + if (json_obj.contains("duration_ms"_L1)) { + song.set_length_nanosec(json_obj["duration_ms"_L1].toVariant().toLongLong() * kNsecPerMsec); + } + + // Extract artist info + QString artist_id; + if (json_obj.contains("artists"_L1) && json_obj["artists"_L1].isArray()) { + const QJsonArray array_artists = json_obj["artists"_L1].toArray(); + if (!array_artists.isEmpty()) { + const QJsonObject obj_artist = array_artists.first().toObject(); + if (obj_artist.contains("id"_L1)) { + artist_id = obj_artist["id"_L1].toString(); + song.set_artist_id(artist_id); + } + if (obj_artist.contains("name"_L1)) { + song.set_artist(obj_artist["name"_L1].toString()); + } + } + } + + // Extract album info + if (json_obj.contains("album"_L1) && json_obj["album"_L1].isObject()) { + QJsonObject obj_album = json_obj["album"_L1].toObject(); + if (obj_album.contains("id"_L1)) { + song.set_album_id(obj_album["id"_L1].toString()); + } + if (obj_album.contains("name"_L1)) { + song.set_album(obj_album["name"_L1].toString()); + } + // Cover image - prefer larger images + if (obj_album.contains("images"_L1) && obj_album["images"_L1].isArray()) { + const QJsonArray array_images = obj_album["images"_L1].toArray(); + for (const QJsonValue &value : array_images) { + if (!value.isObject()) continue; + QJsonObject obj_image = value.toObject(); + if (!obj_image.contains("url"_L1) || !obj_image.contains("width"_L1) || !obj_image.contains("height"_L1)) continue; + int width = obj_image["width"_L1].toInt(); + int height = obj_image["height"_L1].toInt(); + if (width >= 300 && height >= 300) { + song.set_art_automatic(QUrl(obj_image["url"_L1].toString())); + break; + } + } + } + // Album artist + if (obj_album.contains("artists"_L1) && obj_album["artists"_L1].isArray()) { + const QJsonArray array_album_artists = obj_album["artists"_L1].toArray(); + if (!array_album_artists.isEmpty()) { + const QJsonObject obj_album_artist = array_album_artists.first().toObject(); + if (obj_album_artist.contains("name"_L1)) { + song.set_albumartist(obj_album_artist["name"_L1].toString()); + } + } + } + // Release date + if (obj_album.contains("release_date"_L1)) { + QString release_date = obj_album["release_date"_L1].toString(); + if (release_date.length() >= 4) { + song.set_year(release_date.left(4).toInt()); + } + } + } + + song.set_valid(true); + + if (artist_id.isEmpty()) { + // No artist ID - emit what we have without genre + qLog(Debug) << "Spotify: Track metadata received for" << track_id << "(no artist ID for genre lookup)"; + Q_EMIT MetadataReceived(track_id, song); + return; + } + + // Store partial song and fetch artist metadata for genre + pending_songs_[track_id] = song; + + QNetworkReply *artist_reply = CreateRequest(u"artists/"_s + artist_id, ParamList()); + QObject::connect(artist_reply, &QNetworkReply::finished, this, [this, artist_reply, track_id]() { + ArtistMetadataReceived(artist_reply, track_id); + }); + +} + +void SpotifyMetadataRequest::ArtistMetadataReceived(QNetworkReply *reply, const QString &track_id) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + // Retrieve the stored partial song + if (!pending_songs_.contains(track_id)) { + Q_EMIT MetadataFailure(track_id, tr("No pending song for track ID")); + return; + } + Song song = pending_songs_.take(track_id); + + JsonObjectResult result = ParseJsonObject(reply); + if (result.error_code != JsonBaseRequest::ErrorCode::Success) { + // Still emit the song even without genre + qLog(Warning) << "Spotify: Failed to get artist metadata for genre:" << result.error_message; + Q_EMIT MetadataReceived(track_id, song); + return; + } + + const QJsonObject &json_object = result.json_object; + + // Add genre from artist + if (json_object.contains("genres"_L1) && json_object["genres"_L1].isArray()) { + const QJsonArray array_genres = json_object["genres"_L1].toArray(); + if (!array_genres.isEmpty()) { + song.set_genre(array_genres.first().toString()); + } + } + + qLog(Debug) << "Spotify: Track metadata received for" << track_id + << "- title:" << song.title() + << "- artist:" << song.artist() + << "- album:" << song.album() + << "- genre:" << song.genre(); + + Q_EMIT MetadataReceived(track_id, song); + +} + +void SpotifyMetadataRequest::Error(const QString &error_message, const QVariant &debug_output) { + + qLog(Error) << "Spotify:" << error_message; + if (debug_output.isValid()) qLog(Debug) << debug_output; + +} diff --git a/src/spotify/spotifymetadatarequest.h b/src/spotify/spotifymetadatarequest.h new file mode 100644 index 000000000..253041f9e --- /dev/null +++ b/src/spotify/spotifymetadatarequest.h @@ -0,0 +1,58 @@ +/* + * Strawberry Music Player + * Copyright 2025-2026, Jonas Kvinge + * + * Strawberry 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. + * + * Strawberry 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 Strawberry. If not, see . + * + */ + +#ifndef SPOTIFYMETADATAREQUEST_H +#define SPOTIFYMETADATAREQUEST_H + +#include "config.h" + +#include +#include +#include + +#include "includes/shared_ptr.h" +#include "core/song.h" +#include "spotifybaserequest.h" + +class QNetworkReply; +class NetworkAccessManager; +class SpotifyService; + +class SpotifyMetadataRequest : public SpotifyBaseRequest { + Q_OBJECT + + public: + explicit SpotifyMetadataRequest(SpotifyService *service, const SharedPtr network, QObject *parent = nullptr); + + void FetchTrackMetadata(const QString &track_id); + + Q_SIGNALS: + void MetadataReceived(QString track_id, Song song); + void MetadataFailure(QString track_id, QString error); + + private Q_SLOTS: + void TrackMetadataReceived(QNetworkReply *reply, const QString &track_id); + void ArtistMetadataReceived(QNetworkReply *reply, const QString &track_id); + + private: + void Error(const QString &error_message, const QVariant &debug_output = QVariant()) override; + QMap pending_songs_; // track_id -> partial Song (waiting for artist genre) +}; + +#endif // SPOTIFYMETADATAREQUEST_H