Fetch metadata and allow editing for stream songs

This commit is contained in:
Rob Stanfield
2025-12-20 09:46:23 -08:00
committed by Jonas Kvinge
parent ea629aedd1
commit a71e5b170b
12 changed files with 826 additions and 10 deletions

View File

@@ -1447,6 +1447,7 @@ optional_source(HAVE_SPOTIFY
src/spotify/spotifybaserequest.cpp src/spotify/spotifybaserequest.cpp
src/spotify/spotifyrequest.cpp src/spotify/spotifyrequest.cpp
src/spotify/spotifyfavoriterequest.cpp src/spotify/spotifyfavoriterequest.cpp
src/spotify/spotifymetadatarequest.cpp
src/settings/spotifysettingspage.cpp src/settings/spotifysettingspage.cpp
src/covermanager/spotifycoverprovider.cpp src/covermanager/spotifycoverprovider.cpp
HEADERS HEADERS
@@ -1454,6 +1455,7 @@ optional_source(HAVE_SPOTIFY
src/spotify/spotifybaserequest.h src/spotify/spotifybaserequest.h
src/spotify/spotifyrequest.h src/spotify/spotifyrequest.h
src/spotify/spotifyfavoriterequest.h src/spotify/spotifyfavoriterequest.h
src/spotify/spotifymetadatarequest.h
src/settings/spotifysettingspage.h src/settings/spotifysettingspage.h
src/covermanager/spotifycoverprovider.h src/covermanager/spotifycoverprovider.h
UI UI
@@ -1468,6 +1470,7 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.cpp src/qobuz/qobuzrequest.cpp
src/qobuz/qobuzstreamurlrequest.cpp src/qobuz/qobuzstreamurlrequest.cpp
src/qobuz/qobuzfavoriterequest.cpp src/qobuz/qobuzfavoriterequest.cpp
src/qobuz/qobuzmetadatarequest.cpp
src/qobuz/qobuzcredentialfetcher.cpp src/qobuz/qobuzcredentialfetcher.cpp
src/settings/qobuzsettingspage.cpp src/settings/qobuzsettingspage.cpp
src/covermanager/qobuzcoverprovider.cpp src/covermanager/qobuzcoverprovider.cpp
@@ -1478,6 +1481,7 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.h src/qobuz/qobuzrequest.h
src/qobuz/qobuzstreamurlrequest.h src/qobuz/qobuzstreamurlrequest.h
src/qobuz/qobuzfavoriterequest.h src/qobuz/qobuzfavoriterequest.h
src/qobuz/qobuzmetadatarequest.h
src/qobuz/qobuzcredentialfetcher.h src/qobuz/qobuzcredentialfetcher.h
src/settings/qobuzsettingspage.h src/settings/qobuzsettingspage.h
src/covermanager/qobuzcoverprovider.h src/covermanager/qobuzcoverprovider.h

View File

@@ -173,9 +173,12 @@
#endif #endif
#ifdef HAVE_SPOTIFY #ifdef HAVE_SPOTIFY
# include "spotify/spotifyservice.h" # include "spotify/spotifyservice.h"
# include "spotify/spotifymetadatarequest.h"
# include "constants/spotifysettings.h" # include "constants/spotifysettings.h"
#endif #endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
# include "qobuz/qobuzservice.h"
# include "qobuz/qobuzmetadatarequest.h"
# include "constants/qobuzsettings.h" # include "constants/qobuzsettings.h"
#endif #endif
@@ -379,8 +382,10 @@ MainWindow::MainWindow(Application *app,
playlist_add_to_another_(nullptr), playlist_add_to_another_(nullptr),
playlistitem_actions_separator_(nullptr), playlistitem_actions_separator_(nullptr),
playlist_rescan_songs_(nullptr), playlist_rescan_songs_(nullptr),
playlist_fetch_metadata_(nullptr),
track_position_timer_(new QTimer(this)), track_position_timer_(new QTimer(this)),
track_slider_timer_(new QTimer(this)), track_slider_timer_(new QTimer(this)),
metadata_queue_timer_(new QTimer(this)),
keep_running_(false), keep_running_(false),
playing_widget_(true), playing_widget_(true),
#ifdef HAVE_DBUS #ifdef HAVE_DBUS
@@ -452,6 +457,10 @@ MainWindow::MainWindow(Application *app,
track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs); track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs);
QObject::connect(track_slider_timer_, &QTimer::timeout, this, &MainWindow::UpdateTrackSliderPosition); 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 // Start initializing the player
qLog(Debug) << "Initializing player"; qLog(Debug) << "Initializing player";
app_->player()->SetAnalyzer(ui_->analyzer); app_->player()->SetAnalyzer(ui_->analyzer);
@@ -812,6 +821,8 @@ MainWindow::MainWindow(Application *app,
#endif #endif
playlist_rescan_songs_ = playlist_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Rescan song(s)..."), this, &MainWindow::RescanSongs); 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_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_->addAction(ui_->action_add_files_to_transcoder);
playlist_menu_->addSeparator(); playlist_menu_->addSeparator();
playlist_copy_url_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy URL(s)..."), this, &MainWindow::PlaylistCopyUrl); 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 in_skipped = 0;
int not_in_skipped = 0; int not_in_skipped = 0;
int local_songs = 0; int local_songs = 0;
int streaming_songs = 0;
for (const QModelIndex &idx : selection) { 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()); PlaylistItemPtr item = app_->playlist_manager()->current()->item_at(src_idx.row());
if (!item) continue; 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()) { if (item->EffectiveMetadata().has_cue()) {
cue_selected = true; 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_->setEnabled(local_songs > 0 && editable > 0);
playlist_rescan_songs_->setVisible(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->setEnabled(local_songs > 0 && editable > 0);
ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0); ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0);
@@ -2243,9 +2264,23 @@ void MainWindow::EditTracks() {
void MainWindow::EditTagDialogAccepted() { void MainWindow::EditTagDialogAccepted() {
const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items(); const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items();
for (PlaylistItemPtr item : items) { 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(); item->Reload();
} }
}
// FIXME: This is really lame but we don't know what rows have changed. // FIXME: This is really lame but we don't know what rows have changed.
ui_->playlist->view()->update(); ui_->playlist->view()->update();
@@ -2319,8 +2354,8 @@ void MainWindow::SelectionSetValue() {
QObject::disconnect(*connection); QObject::disconnect(*connection);
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
} }
else if (song.source() == Song::Source::Stream) { else if (song.is_stream()) {
app_->playlist_manager()->current()->setData(source_index, column_value, 0); app_->playlist_manager()->current()->setData(source_index.sibling(source_index.row(), static_cast<int>(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<QobuzService>()) {
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<SpotifyService>()) {
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();
}
}

View File

@@ -276,6 +276,9 @@ class MainWindow : public QMainWindow, public PlatformInterface {
void DeleteFilesFinished(const SongList &songs_with_errors); void DeleteFilesFinished(const SongList &songs_with_errors);
void FetchStreamingMetadata();
void ProcessMetadataQueue();
public Q_SLOTS: public Q_SLOTS:
void CommandlineOptionsReceived(const QByteArray &string_options); void CommandlineOptionsReceived(const QByteArray &string_options);
void Raise(); void Raise();
@@ -379,11 +382,13 @@ class MainWindow : public QMainWindow, public PlatformInterface {
QList<QAction*> playlistitem_actions_; QList<QAction*> playlistitem_actions_;
QAction *playlistitem_actions_separator_; QAction *playlistitem_actions_separator_;
QAction *playlist_rescan_songs_; QAction *playlist_rescan_songs_;
QAction *playlist_fetch_metadata_;
QModelIndex playlist_menu_index_; QModelIndex playlist_menu_index_;
QTimer *track_position_timer_; QTimer *track_position_timer_;
QTimer *track_slider_timer_; QTimer *track_slider_timer_;
QTimer *metadata_queue_timer_;
bool keep_running_; bool keep_running_;
bool playing_widget_; bool playing_widget_;
@@ -407,6 +412,14 @@ class MainWindow : public QMainWindow, public PlatformInterface {
bool playlists_loaded_; bool playlists_loaded_;
bool delete_files_; bool delete_files_;
std::optional<CommandlineOptions> options_; std::optional<CommandlineOptions> options_;
class MetadataQueueEntry {
public:
Song::Source source;
QString track_id;
QPersistentModelIndex persistent_index;
};
QList<MetadataQueueEntry> metadata_queue_;
}; };
#endif // MAINWINDOW_H #endif // MAINWINDOW_H

View File

@@ -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_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_local_collection_song() const { return d->source_ == Source::Collection; }
bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); } 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_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_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::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; } 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 { 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 { bool Song::IsFileInfoEqual(const Song &other) const {

View File

@@ -407,8 +407,9 @@ class Song {
bool is_metadata_good() const; bool is_metadata_good() const;
bool is_local_collection_song() const; bool is_local_collection_song() const;
bool is_linked_collection_song() const; bool is_linked_collection_song() const;
bool is_stream() const;
bool is_radio() const; bool is_radio() const;
bool is_stream_service() const;
bool is_stream() const;
bool is_cdda() const; bool is_cdda() const;
bool is_compilation() const; bool is_compilation() const;
bool stream_url_can_expire() const; bool stream_url_can_expire() const;

View File

@@ -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) { bool EditTagDialog::SetLoading(const QString &message) {
const bool loading = !message.isEmpty(); const bool loading = !message.isEmpty();
@@ -1399,6 +1410,12 @@ void EditTagDialog::SaveData() {
} }
if (save_tags || save_playcount || save_rating || save_embedded_cover) { 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. // Not to confuse the collection model.
if (ref.current_.track() <= 0) { ref.current_.set_track(-1); } if (ref.current_.track() <= 0) { ref.current_.set_track(-1); }
if (ref.current_.disc() <= 0) { ref.current_.set_disc(-1); } if (ref.current_.disc() <= 0) { ref.current_.set_disc(-1); }

View File

@@ -85,7 +85,7 @@ class EditTagDialog : public QDialog {
void SetSongs(const SongList &songs, const PlaylistItemPtrList &items = PlaylistItemPtrList()); void SetSongs(const SongList &songs, const PlaylistItemPtrList &items = PlaylistItemPtrList());
PlaylistItemPtrList playlist_items() const { return playlist_items_; } PlaylistItemPtrList playlist_items() const { return playlist_items_; }
SongList songs() const;
void accept() override; void accept() override;
Q_SIGNALS: Q_SIGNALS:

View File

@@ -474,8 +474,10 @@ bool Playlist::setData(const QModelIndex &idx, const QVariant &value, const int
QObject::disconnect(*connection); QObject::disconnect(*connection);
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
} }
else if (song.is_radio()) { else if (song.is_stream()) {
item->SetOriginalMetadata(song); item->SetOriginalMetadata(song);
Q_EMIT dataChanged(index(row, 0), index(row, ColumnCount - 1));
Q_EMIT EditingFinished(id_, idx);
ScheduleSave(); ScheduleSave();
} }

View File

@@ -0,0 +1,230 @@
/*
* Strawberry Music Player
* Copyright 2025-2026, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QObject>
#include <QString>
#include <QUrl>
#include <QDateTime>
#include <QNetworkReply>
#include <QJsonObject>
#include <QJsonValue>
#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<NetworkAccessManager> 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;
}

View File

@@ -0,0 +1,55 @@
/*
* Strawberry Music Player
* Copyright 2025-2026, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#ifndef QOBUZMETADATAREQUEST_H
#define QOBUZMETADATAREQUEST_H
#include "config.h"
#include <QObject>
#include <QString>
#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<NetworkAccessManager> 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

View File

@@ -0,0 +1,231 @@
/*
* Strawberry Music Player
* Copyright 2025-2026, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QObject>
#include <QString>
#include <QUrl>
#include <QNetworkReply>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#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<NetworkAccessManager> 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;
}

View File

@@ -0,0 +1,58 @@
/*
* Strawberry Music Player
* Copyright 2025-2026, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#ifndef SPOTIFYMETADATAREQUEST_H
#define SPOTIFYMETADATAREQUEST_H
#include "config.h"
#include <QObject>
#include <QString>
#include <QMap>
#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<NetworkAccessManager> 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<QString, Song> pending_songs_; // track_id -> partial Song (waiting for artist genre)
};
#endif // SPOTIFYMETADATAREQUEST_H