Compare commits

...

15 Commits

Author SHA1 Message Date
Jonas Kvinge
ce06115557 Release 1.2.17 2026-01-18 02:10:44 +01:00
Jonas Kvinge
89d1ac8f20 Update Changelog 2026-01-18 02:09:12 +01:00
Jonas Kvinge
891b635c64 Update Changelog 2026-01-18 00:37:02 +01:00
Jonas Kvinge
f37b1099f3 MainWindow: Remove parent object from MetadataRequest 2026-01-18 00:36:57 +01:00
Jonas Kvinge
626dd48730 FilterTreeTerm: Add sort tags 2026-01-18 00:10:09 +01:00
Jonas Kvinge
6f7b8ab162 Add sort columns to filter parser
Also pass the filter column enum through to filter tree instead of string.
2026-01-17 23:48:54 +01:00
Jonas Kvinge
3416ede211 Update Changelog 2026-01-17 17:32:06 +01:00
Jonas Kvinge
f8bb69ec65 Update Changelog 2026-01-17 17:30:18 +01:00
Jonas Kvinge
64540ef6f9 MergedProxyModel: Ignore -Wstringop-overflow 2026-01-17 16:23:08 +01:00
Jonas Kvinge
cd013db33b CI: Update distro versions 2026-01-17 16:23:08 +01:00
Jonas Kvinge
4f554f5d5f FilterParser: Optimize code 2026-01-17 15:24:31 +01:00
Jonas Kvinge
326fe84e8a CollectionWatcher: Update directories with missing mtime
mtime is missing on FAT mountpoints, so continue scan if mtime is zero, and remove directory based on existence instead of mtime.
2026-01-17 04:11:17 +01:00
Jonas Kvinge
1bded170a2 CollectionWatcher: Add const 2026-01-17 03:32:53 +01:00
Rob Stanfield
a71e5b170b Fetch metadata and allow editing for stream songs 2026-01-13 01:31:05 +01:00
Rob Stanfield
ea629aedd1 Get genre metadata for Tidal, Qobuz and Spotify
Extract genre information when fetching favorites and search results.
Genre is now populated in the collection and playlists for
tracks from these streaming services.
2026-01-13 01:31:05 +01:00
31 changed files with 1385 additions and 208 deletions

View File

@@ -156,7 +156,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
fedora_version: [ '41', '42', '43' ] fedora_version: [ '42', '43', '44' ]
container: container:
image: fedora:${{matrix.fedora_version}} image: fedora:${{matrix.fedora_version}}
steps: steps:
@@ -542,7 +542,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
ubuntu_version: [ 'noble', 'plucky', 'questing' ] ubuntu_version: [ 'noble', 'questing', 'resolute' ]
container: container:
image: ubuntu:${{matrix.ubuntu_version}} image: ubuntu:${{matrix.ubuntu_version}}
steps: steps:
@@ -596,10 +596,10 @@ jobs:
qt6-l10n-tools qt6-l10n-tools
rapidjson-dev rapidjson-dev
- name: Install KDSingleApplication - name: Install KDSingleApplication
if: matrix.ubuntu_version != 'noble' && matrix.ubuntu_version != 'plucky' if: matrix.ubuntu_version != 'noble'
run: apt install -y libkdsingleapplication-qt6-dev run: apt install -y libkdsingleapplication-qt6-dev
- name: Build and install KDSingleApplication - name: Build and install KDSingleApplication
if: matrix.ubuntu_version == 'noble' || matrix.ubuntu_version == 'plucky' if: matrix.ubuntu_version == 'noble'
run: | run: |
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
cd KDSingleApplication cd KDSingleApplication
@@ -639,7 +639,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
ubuntu_version: [ 'noble', 'plucky', 'questing' ] ubuntu_version: [ 'noble', 'questing', 'resolute' ]
container: container:
image: ubuntu:${{matrix.ubuntu_version}} image: ubuntu:${{matrix.ubuntu_version}}
steps: steps:

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

@@ -2,6 +2,40 @@ Strawberry Music Player
======================= =======================
ChangeLog ChangeLog
Version 1.2.17 (2026.01.18):
* Avoid re-scan of restored songs unless mtime is changed (#1819)
* Skip existing files when organizing if not overwriting (#1484)
* Fixed cursor highlight disappearing off-screen when using down cursor (#1489)
* Fixed CD playback only working for the first optical drive (#1852)
* Fixed possible race-condition when switching tracks (#1863)
* Fixed possible file descriptor exhaustion by using shared thread pool (#1687)
* Don't automatically sort playlist with the auto sort option before it's fully loaded (#1690)
* Fixed network features stop working after computer suspends and resumes (#1521)
* Fixed crash on exit after Qobuz login
* Added tag editor option to select ID3v2 version (#1861)
* Fixed Qobuz authentication and added automatic credential fetching (#1898)
* Fixed playback stopping after deleting a song from disk via context menu (#1783)
* Added option to restore smart playlists to the defaults (#1848)
* Fixed possible race condition in pipeline destructor (#1875)
* Fixed buffering issue near track end during gapless playback (#1725)
* Fixed duplicate collection entries for the same artist if they have different sort tags (#1899)
* Defer playcount and rating tag writes for currently playing Ogg songs to prevent playback shutter (#1816)
* Fixed tag editing not working for Opus sort tags (#1929)
* Show playlist load errors (#1470)
* Fallback to delete if moving to trash fails (#1679)
* Prefer filenames with "front" or "cover" in the filename for album cover art for songs outside of the collection (#1745)
* Fixed collection enter/return behavior to respect double-click settings (#1691)
* Added tree view mode to files tab (#1922)
* Include .webp in allowed extensions for album covers (#1941)
* Exit gracefully on SIGTERM signal for Unix systems (#1942)
* Optimize the collection scanning process by deferring media file validation from the initial directory scan (#1954)
* Fixed collection scan not finding new directories in the top level collection directory when the mountpoint is restored (#1914)
* Added genre metadata parsing for Tidal, Qobuz and Spotify (#1913)
* Allow editing metadata for stream songs (#1913)
* Optimized collection/playlist filtering
* Added sort tags to collection/playlist filtering (#1966)
Version 1.2.16 (2025.12.16): Version 1.2.16 (2025.12.16):
* Make Discord Rich presence use filename if song title is missing * Make Discord Rich presence use filename if song title is missing

View File

@@ -1,9 +1,9 @@
set(STRAWBERRY_VERSION_MAJOR 1) set(STRAWBERRY_VERSION_MAJOR 1)
set(STRAWBERRY_VERSION_MINOR 2) set(STRAWBERRY_VERSION_MINOR 2)
set(STRAWBERRY_VERSION_PATCH 16) set(STRAWBERRY_VERSION_PATCH 17)
#set(STRAWBERRY_VERSION_PRERELEASE rc1) #set(STRAWBERRY_VERSION_PRERELEASE rc1)
set(INCLUDE_GIT_REVISION ON) set(INCLUDE_GIT_REVISION OFF)
set(majorminorpatch "${STRAWBERRY_VERSION_MAJOR}.${STRAWBERRY_VERSION_MINOR}.${STRAWBERRY_VERSION_PATCH}") set(majorminorpatch "${STRAWBERRY_VERSION_MAJOR}.${STRAWBERRY_VERSION_MINOR}.${STRAWBERRY_VERSION_PATCH}")

View File

@@ -51,6 +51,7 @@
</screenshots> </screenshots>
<update_contact>eclipseo@fedoraproject.org</update_contact> <update_contact>eclipseo@fedoraproject.org</update_contact>
<releases> <releases>
<release version="1.2.17" date="2026-01-18"/>
<release version="1.2.16" date="2025-12-16"/> <release version="1.2.16" date="2025-12-16"/>
<release version="1.2.15" date="2025-11-25"/> <release version="1.2.15" date="2025-11-25"/>
<release version="1.2.14" date="2025-10-25"/> <release version="1.2.14" date="2025-10-25"/>

View File

@@ -530,6 +530,7 @@ void CollectionWatcher::AddDirectory(const CollectionDirectory &dir, const Colle
void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, ScanTransaction *t, const bool force_noincremental) { void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, ScanTransaction *t, const bool force_noincremental) {
const QFileInfo path_info(path); const QFileInfo path_info(path);
const qint64 path_mtime = path_info.exists() && path_info.lastModified().isValid() ? path_info.lastModified().toSecsSinceEpoch() : 0;
if (path_info.isSymLink()) { if (path_info.isSymLink()) {
const QString real_path = path_info.symLinkTarget(); const QString real_path = path_info.symLinkTarget();
@@ -566,7 +567,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
} }
#endif #endif
if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && subdir.mtime == path_info.lastModified().toSecsSinceEpoch() && !songs_missing_fingerprint && !songs_missing_loudness_characteristics) { if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && path_mtime != 0 && subdir.mtime == path_mtime && !songs_missing_fingerprint && !songs_missing_loudness_characteristics) {
// The directory hasn't changed since last time // The directory hasn't changed since last time
t->AddToProgress(files_count); t->AddToProgress(files_count);
return; return;
@@ -586,6 +587,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
} }
// First we "quickly" get a list of the files in the directory that we think might be music. While we're here, we also look for new subdirectories and possible album artwork. // First we "quickly" get a list of the files in the directory that we think might be music. While we're here, we also look for new subdirectories and possible album artwork.
if (path_info.exists()) {
QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
while (it.hasNext()) { while (it.hasNext()) {
@@ -595,7 +597,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const QFileInfo child_fileinfo(child_filepath); const QFileInfo child_fileinfo(child_filepath);
if (child_fileinfo.isSymLink()) { if (child_fileinfo.isSymLink()) {
QStorageInfo storage_info(child_fileinfo.symLinkTarget()); const QStorageInfo storage_info(child_fileinfo.symLinkTarget());
if (kRejectedFileSystems.contains(storage_info.fileSystemType())) { if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
qLog(Warning) << "Ignoring symbolic link" << child_filepath << "which links to" << child_fileinfo.symLinkTarget() << "with rejected filesystem type" << storage_info.fileSystemType(); qLog(Warning) << "Ignoring symbolic link" << child_filepath << "which links to" << child_fileinfo.symLinkTarget() << "with rejected filesystem type" << storage_info.fileSystemType();
continue; continue;
@@ -608,14 +610,14 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
CollectionSubdirectory new_subdir; CollectionSubdirectory new_subdir;
new_subdir.directory_id = -1; new_subdir.directory_id = -1;
new_subdir.path = child_filepath; new_subdir.path = child_filepath;
new_subdir.mtime = child_fileinfo.lastModified().toSecsSinceEpoch(); new_subdir.mtime = child_fileinfo.exists() && child_fileinfo.lastModified().isValid() ? child_fileinfo.lastModified().toSecsSinceEpoch() : 0;
my_new_subdirs << new_subdir; my_new_subdirs << new_subdir;
} }
t->AddToProgress(1); t->AddToProgress(1);
} }
else { else {
QString ext_part(ExtensionPart(child_filepath)); const QString ext_part = ExtensionPart(child_filepath);
QString dir_part(DirectoryPart(child_filepath)); const QString dir_part = DirectoryPart(child_filepath);
if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) { if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) {
t->AddToProgress(1); t->AddToProgress(1);
} }
@@ -628,31 +630,32 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
} }
} }
} }
}
if (stop_or_abort_requested()) return; if (stop_or_abort_requested()) return;
// Ask the database for a list of files in this directory // Ask the database for a list of files in this directory
SongList songs_in_db = t->FindSongsInSubdirectory(path); const SongList songs_in_db = t->FindSongsInSubdirectory(path);
QSet<QString> cues_processed; QSet<QString> cues_processed;
// Now compare the list from the database with the list of files on disk // Now compare the list from the database with the list of files on disk
QStringList files_on_disk_copy = files_on_disk; const QStringList files_on_disk_copy = files_on_disk;
for (const QString &file : files_on_disk_copy) { for (const QString &file : files_on_disk_copy) {
if (stop_or_abort_requested()) return; if (stop_or_abort_requested()) return;
// Associated CUE // Associated CUE
QString new_cue = CueParser::FindCueFilename(file); const QString new_cue = CueParser::FindCueFilename(file);
SongList matching_songs; SongList matching_songs;
if (FindSongsByPath(songs_in_db, file, &matching_songs)) { // Found matching song in DB by path. if (FindSongsByPath(songs_in_db, file, &matching_songs)) { // Found matching song in DB by path.
Song matching_song = matching_songs.first(); const Song matching_song = matching_songs.first();
// The song is in the database and still on disk. // The song is in the database and still on disk.
// Check the mtime to see if it's been changed since it was added. // Check the mtime to see if it's been changed since it was added.
QFileInfo fileinfo(file); const QFileInfo fileinfo(file);
if (!fileinfo.exists()) { if (!fileinfo.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now. // Partially fixes race condition - if file was removed between being added to the list and now.
@@ -752,7 +755,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
// The song is in the database and still on disk. // The song is in the database and still on disk.
// Check the mtime to see if it's been changed since it was added. // Check the mtime to see if it's been changed since it was added.
QFileInfo fileinfo(file); const QFileInfo fileinfo(file);
if (!fileinfo.exists()) { if (!fileinfo.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now. // Partially fixes race condition - if file was removed between being added to the list and now.
files_on_disk.removeAll(file); files_on_disk.removeAll(file);
@@ -763,7 +766,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
// Make sure the songs aren't deleted, as they still exist elsewhere with a different file path. // Make sure the songs aren't deleted, as they still exist elsewhere with a different file path.
bool matching_songs_has_cue = false; bool matching_songs_has_cue = false;
for (const Song &matching_song : std::as_const(matching_songs)) { for (const Song &matching_song : std::as_const(matching_songs)) {
QString matching_filename = matching_song.url().toLocalFile(); const QString matching_filename = matching_song.url().toLocalFile();
if (!t->files_changed_path_.contains(matching_filename)) { if (!t->files_changed_path_.contains(matching_filename)) {
t->files_changed_path_ << matching_filename; t->files_changed_path_ << matching_filename;
qLog(Debug) << matching_filename << "has changed path to" << file; qLog(Debug) << matching_filename << "has changed path to" << file;
@@ -831,10 +834,10 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
// Add, update or delete subdir // Add, update or delete subdir
CollectionSubdirectory updated_subdir; CollectionSubdirectory updated_subdir;
updated_subdir.directory_id = t->dir_id(); updated_subdir.directory_id = t->dir_id();
updated_subdir.mtime = path_info.exists() ? path_info.lastModified().toSecsSinceEpoch() : 0; updated_subdir.mtime = path_mtime;
updated_subdir.path = path; updated_subdir.path = path;
if (updated_subdir.mtime == 0 && updated_subdir.path != dir.path) { if (!path_info.exists() && updated_subdir.path != dir.path) {
t->deleted_subdirs << updated_subdir; t->deleted_subdirs << updated_subdir;
} }
else if (subdir.directory_id == -1) { else if (subdir.directory_id == -1) {

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, qobuz_service->network());
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, app_->network());
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

@@ -34,6 +34,13 @@
#include "mergedproxymodel.h" #include "mergedproxymodel.h"
#ifdef __GNUC__
#pragma GCC diagnostic push
#if __GNUC__ >= 16
#pragma GCC diagnostic ignored "-Wstringop-overflow"
#endif
#endif
#include <boost/multi_index/detail/bidir_node_iterator.hpp> #include <boost/multi_index/detail/bidir_node_iterator.hpp>
#include <boost/multi_index/detail/hash_index_iterator.hpp> #include <boost/multi_index/detail/hash_index_iterator.hpp>
#include <boost/multi_index/hashed_index.hpp> #include <boost/multi_index/hashed_index.hpp>
@@ -45,6 +52,10 @@
#include <boost/multi_index_container.hpp> #include <boost/multi_index_container.hpp>
#include <boost/operators.hpp> #include <boost/operators.hpp>
#ifdef __GNUC__
#pragma GCC diagnostic pop
#endif
using boost::multi_index::hashed_unique; using boost::multi_index::hashed_unique;
using boost::multi_index::identity; using boost::multi_index::identity;
using boost::multi_index::indexed_by; using boost::multi_index::indexed_by;

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

@@ -0,0 +1,53 @@
/*
* Strawberry Music Player
* Copyright 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 FILTERCOLUMN_H
#define FILTERCOLUMN_H
enum class FilterColumn {
Unknown,
Title,
TitleSort,
Album,
AlbumSort,
Artist,
ArtistSort,
AlbumArtist,
AlbumArtistSort,
Composer,
ComposerSort,
Performer,
PerformerSort,
Grouping,
Genre,
Comment,
Filename,
URL,
Track,
Year,
Samplerate,
Bitdepth,
Bitrate,
Playcount,
Skipcount,
Length,
Rating,
};
#endif // FILTERCOLUMN_H

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player * Strawberry Music Player
* This file was part of Clementine. * This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com> * Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net> * Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de> * Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
* *
* Strawberry is free software: you can redistribute it and/or modify * Strawberry is free software: you can redistribute it and/or modify
@@ -21,8 +21,10 @@
*/ */
#include <QString> #include <QString>
#include <QMap>
#include "constants/timeconstants.h" #include "constants/timeconstants.h"
#include "core/song.h"
#include "filterparser.h" #include "filterparser.h"
#include "filtertreenop.h" #include "filtertreenop.h"
#include "filtertreeand.h" #include "filtertreeand.h"
@@ -31,9 +33,126 @@
#include "filtertreeterm.h" #include "filtertreeterm.h"
#include "filtertreecolumnterm.h" #include "filtertreecolumnterm.h"
#include "filterparsersearchcomparators.h" #include "filterparsersearchcomparators.h"
#include "filtercolumn.h"
using namespace Qt::Literals::StringLiterals; using namespace Qt::Literals::StringLiterals;
namespace {
enum class FilterOperator {
None,
Eq,
Ne,
Gt,
Ge,
Lt,
Le
};
const QMap<QString, FilterOperator> &GetFilterOperatorsMap() {
static const QMap<QString, FilterOperator> filter_operators_map_ = []() {
QMap<QString, FilterOperator> filter_operators_map;
filter_operators_map.insert(u"="_s, FilterOperator::Eq);
filter_operators_map.insert(u"=="_s, FilterOperator::Eq);
filter_operators_map.insert(u"!="_s, FilterOperator::Ne);
filter_operators_map.insert(u"<>"_s, FilterOperator::Ne);
filter_operators_map.insert(u">"_s, FilterOperator::Gt);
filter_operators_map.insert(u">="_s, FilterOperator::Ge);
filter_operators_map.insert(u"<"_s, FilterOperator::Lt);
filter_operators_map.insert(u"<="_s, FilterOperator::Le);
return filter_operators_map;
}();
return filter_operators_map_;
}
enum class ColumnType {
Unknown,
Text,
Int,
UInt,
Int64,
Float
};
const QMap<QString, FilterColumn> &GetFilterColumnsMap() {
static const QMap<QString, FilterColumn> filter_columns_map_ = []() {
QMap<QString, FilterColumn> filter_columns_map;
filter_columns_map.insert(u"albumartist"_s, FilterColumn::AlbumArtist);
filter_columns_map.insert(u"albumartistsort"_s, FilterColumn::AlbumArtistSort);
filter_columns_map.insert(u"artist"_s, FilterColumn::Artist);
filter_columns_map.insert(u"artistsort"_s, FilterColumn::ArtistSort);
filter_columns_map.insert(u"album"_s, FilterColumn::Album);
filter_columns_map.insert(u"albumsort"_s, FilterColumn::AlbumSort);
filter_columns_map.insert(u"title"_s, FilterColumn::Title);
filter_columns_map.insert(u"titlesort"_s, FilterColumn::TitleSort);
filter_columns_map.insert(u"composer"_s, FilterColumn::Composer);
filter_columns_map.insert(u"composersort"_s, FilterColumn::ComposerSort);
filter_columns_map.insert(u"performer"_s, FilterColumn::Performer);
filter_columns_map.insert(u"performersort"_s, FilterColumn::PerformerSort);
filter_columns_map.insert(u"grouping"_s, FilterColumn::Grouping);
filter_columns_map.insert(u"genre"_s, FilterColumn::Genre);
filter_columns_map.insert(u"comment"_s, FilterColumn::Comment);
filter_columns_map.insert(u"filename"_s, FilterColumn::Filename);
filter_columns_map.insert(u"url"_s, FilterColumn::URL);
filter_columns_map.insert(u"track"_s, FilterColumn::Track);
filter_columns_map.insert(u"year"_s, FilterColumn::Year);
filter_columns_map.insert(u"samplerate"_s, FilterColumn::Samplerate);
filter_columns_map.insert(u"bitdepth"_s, FilterColumn::Bitdepth);
filter_columns_map.insert(u"bitrate"_s, FilterColumn::Bitrate);
filter_columns_map.insert(u"playcount"_s, FilterColumn::Playcount);
filter_columns_map.insert(u"skipcount"_s, FilterColumn::Skipcount);
filter_columns_map.insert(u"length"_s, FilterColumn::Length);
filter_columns_map.insert(u"rating"_s, FilterColumn::Rating);
return filter_columns_map;
}();
return filter_columns_map_;
}
const QMap<FilterColumn, ColumnType> &GetColumnTypesMap() {
static const QMap<FilterColumn, ColumnType> column_types_map_ = []() {
QMap<FilterColumn, ColumnType> column_types_map;
column_types_map.insert(FilterColumn::AlbumArtist, ColumnType::Text);
column_types_map.insert(FilterColumn::AlbumArtistSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Artist, ColumnType::Text);
column_types_map.insert(FilterColumn::ArtistSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Album, ColumnType::Text);
column_types_map.insert(FilterColumn::AlbumSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Title, ColumnType::Text);
column_types_map.insert(FilterColumn::TitleSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Composer, ColumnType::Text);
column_types_map.insert(FilterColumn::ComposerSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Performer, ColumnType::Text);
column_types_map.insert(FilterColumn::PerformerSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Grouping, ColumnType::Text);
column_types_map.insert(FilterColumn::Genre, ColumnType::Text);
column_types_map.insert(FilterColumn::Comment, ColumnType::Text);
column_types_map.insert(FilterColumn::Filename, ColumnType::Text);
column_types_map.insert(FilterColumn::URL, ColumnType::Text);
column_types_map.insert(FilterColumn::Track, ColumnType::Int);
column_types_map.insert(FilterColumn::Year, ColumnType::Int);
column_types_map.insert(FilterColumn::Samplerate, ColumnType::Int);
column_types_map.insert(FilterColumn::Bitdepth, ColumnType::Int);
column_types_map.insert(FilterColumn::Bitrate, ColumnType::Int);
column_types_map.insert(FilterColumn::Playcount, ColumnType::UInt);
column_types_map.insert(FilterColumn::Skipcount, ColumnType::UInt);
column_types_map.insert(FilterColumn::Length, ColumnType::Int64);
column_types_map.insert(FilterColumn::Rating, ColumnType::Float);
return column_types_map;
}();
return column_types_map_;
}
} // namespace
FilterParser::FilterParser(const QString &filter_string) : filter_string_(filter_string), iter_{}, end_{} {} FilterParser::FilterParser(const QString &filter_string) : filter_string_(filter_string), iter_{}, end_{} {}
FilterTree *FilterParser::parse() { FilterTree *FilterParser::parse() {
@@ -119,7 +238,7 @@ bool FilterParser::checkAnd() {
bool FilterParser::checkOr(const bool step_over) { bool FilterParser::checkOr(const bool step_over) {
if (!buf_.isEmpty()) { if (!buf_.isEmpty()) {
if (buf_ == "OR"_L1) { if (buf_.size() == 2 && buf_[0] == u'O' && buf_[1] == u'R') {
if (step_over) { if (step_over) {
buf_.clear(); buf_.clear();
advance(); advance();
@@ -141,7 +260,8 @@ bool FilterParser::checkOr(const bool step_over) {
advance(); advance();
} }
else { else {
buf_ += "OR"_L1; buf_ += u'O';
buf_ += u'R';
} }
return true; return true;
} }
@@ -191,6 +311,8 @@ FilterTree *FilterParser::parseSearchTerm() {
bool in_quotes = false; bool in_quotes = false;
bool previous_char_operator = false; bool previous_char_operator = false;
buf_.reserve(32);
for (; iter_ != end_; ++iter_) { for (; iter_ != end_; ++iter_) {
if (previous_char_operator) { if (previous_char_operator) {
if (iter_->isSpace()) { if (iter_->isSpace()) {
@@ -225,7 +347,7 @@ FilterTree *FilterParser::parseSearchTerm() {
prefix += *iter_; prefix += *iter_;
previous_char_operator = true; previous_char_operator = true;
} }
else if (prefix != u'=' && *iter_ == u'=') { else if (prefix.size() == 1 && prefix[0] != u'=' && *iter_ == u'=') {
prefix += *iter_; prefix += *iter_;
previous_char_operator = true; previous_char_operator = true;
} }
@@ -252,132 +374,145 @@ FilterTree *FilterParser::createSearchTermTreeNode(const QString &column, const
return new FilterTreeNop; return new FilterTreeNop;
} }
FilterColumn filter_column = FilterColumn::Unknown;
FilterParserSearchTermComparator *cmp = nullptr; FilterParserSearchTermComparator *cmp = nullptr;
if (!column.isEmpty()) { if (!column.isEmpty()) {
if (Song::kTextSearchColumns.contains(column, Qt::CaseInsensitive)) { filter_column = GetFilterColumnsMap().value(column, FilterColumn::Unknown);
if (prefix == u'=' || prefix == "=="_L1) { const ColumnType column_type = GetColumnTypesMap().value(filter_column, ColumnType::Unknown);
const FilterOperator filter_operator = GetFilterOperatorsMap().value(prefix, FilterOperator::None);
switch (column_type) {
case ColumnType::Text:{
switch (filter_operator) {
case FilterOperator::Eq:
cmp = new FilterParserTextEqComparator(value); cmp = new FilterParserTextEqComparator(value);
} break;
else if (prefix == "!="_L1 || prefix == "<>"_L1) { case FilterOperator::Ne:
cmp = new FilterParserTextNeComparator(value); cmp = new FilterParserTextNeComparator(value);
} break;
else { default:
cmp = new FilterParserTextContainsComparator(value); cmp = new FilterParserTextContainsComparator(value);
break;
} }
break;
} }
else if (Song::kIntSearchColumns.contains(column, Qt::CaseInsensitive)) { case ColumnType::Int:{
bool ok = false; bool ok = false;
int number = value.toInt(&ok); const int number = value.toInt(&ok);
if (ok) { if (!ok) break;
if (prefix == u'=' || prefix == "=="_L1) { switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
cmp = new FilterParserIntEqComparator(number); cmp = new FilterParserIntEqComparator(number);
} break;
else if (prefix == "!="_L1 || prefix == "<>"_L1) { case FilterOperator::Ne:
cmp = new FilterParserIntNeComparator(number); cmp = new FilterParserIntNeComparator(number);
} break;
else if (prefix == u'>') { case FilterOperator::Gt:
cmp = new FilterParserIntGtComparator(number); cmp = new FilterParserIntGtComparator(number);
} break;
else if (prefix == ">="_L1) { case FilterOperator::Ge:
cmp = new FilterParserIntGeComparator(number); cmp = new FilterParserIntGeComparator(number);
} break;
else if (prefix == u'<') { case FilterOperator::Lt:
cmp = new FilterParserIntLtComparator(number); cmp = new FilterParserIntLtComparator(number);
} break;
else if (prefix == "<="_L1) { case FilterOperator::Le:
cmp = new FilterParserIntLeComparator(number); cmp = new FilterParserIntLeComparator(number);
break;
} }
else { break;
cmp = new FilterParserIntEqComparator(number);
} }
} case ColumnType::UInt:{
}
else if (Song::kUIntSearchColumns.contains(column, Qt::CaseInsensitive)) {
bool ok = false; bool ok = false;
uint number = value.toUInt(&ok); const uint number = value.toUInt(&ok);
if (ok) { if (!ok) break;
if (prefix == u'=' || prefix == "=="_L1) { switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
cmp = new FilterParserUIntEqComparator(number); cmp = new FilterParserUIntEqComparator(number);
} break;
else if (prefix == "!="_L1 || prefix == "<>"_L1) { case FilterOperator::Ne:
cmp = new FilterParserUIntNeComparator(number); cmp = new FilterParserUIntNeComparator(number);
} break;
else if (prefix == u'>') { case FilterOperator::Gt:
cmp = new FilterParserUIntGtComparator(number); cmp = new FilterParserUIntGtComparator(number);
} break;
else if (prefix == ">="_L1) { case FilterOperator::Ge:
cmp = new FilterParserUIntGeComparator(number); cmp = new FilterParserUIntGeComparator(number);
} break;
else if (prefix == u'<') { case FilterOperator::Lt:
cmp = new FilterParserUIntLtComparator(number); cmp = new FilterParserUIntLtComparator(number);
} break;
else if (prefix == "<="_L1) { case FilterOperator::Le:
cmp = new FilterParserUIntLeComparator(number); cmp = new FilterParserUIntLeComparator(number);
break;
} }
else { break;
cmp = new FilterParserUIntEqComparator(number);
} }
} case ColumnType::Int64:{
}
else if (Song::kInt64SearchColumns.contains(column, Qt::CaseInsensitive)) {
qint64 number = 0; qint64 number = 0;
if (column == "length"_L1) { if (filter_column == FilterColumn::Length) {
number = ParseTime(value) * kNsecPerSec; number = ParseTime(value) * kNsecPerSec;
} }
else { else {
number = value.toLongLong(); number = value.toLongLong();
} }
if (prefix == u'=' || prefix == "=="_L1) { switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
cmp = new FilterParserInt64EqComparator(number); cmp = new FilterParserInt64EqComparator(number);
} break;
else if (prefix == "!="_L1 || prefix == "<>"_L1) { case FilterOperator::Ne:
cmp = new FilterParserInt64NeComparator(number); cmp = new FilterParserInt64NeComparator(number);
} break;
else if (prefix == u'>') { case FilterOperator::Gt:
cmp = new FilterParserInt64GtComparator(number); cmp = new FilterParserInt64GtComparator(number);
} break;
else if (prefix == ">="_L1) { case FilterOperator::Ge:
cmp = new FilterParserInt64GeComparator(number); cmp = new FilterParserInt64GeComparator(number);
} break;
else if (prefix == u'<') { case FilterOperator::Lt:
cmp = new FilterParserInt64LtComparator(number); cmp = new FilterParserInt64LtComparator(number);
} break;
else if (prefix == "<="_L1) { case FilterOperator::Le:
cmp = new FilterParserInt64LeComparator(number); cmp = new FilterParserInt64LeComparator(number);
break;
} }
else { break;
cmp = new FilterParserInt64EqComparator(number);
} }
} case ColumnType::Float:{
else if (Song::kFloatSearchColumns.contains(column, Qt::CaseInsensitive)) {
const float rating = ParseRating(value); const float rating = ParseRating(value);
if (prefix == u'=' || prefix == "=="_L1) { switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
cmp = new FilterParserFloatEqComparator(rating); cmp = new FilterParserFloatEqComparator(rating);
} break;
else if (prefix == "!="_L1 || prefix == "<>"_L1) { case FilterOperator::Ne:
cmp = new FilterParserFloatNeComparator(rating); cmp = new FilterParserFloatNeComparator(rating);
} break;
else if (prefix == u'>') { case FilterOperator::Gt:
cmp = new FilterParserFloatGtComparator(rating); cmp = new FilterParserFloatGtComparator(rating);
} break;
else if (prefix == ">="_L1) { case FilterOperator::Ge:
cmp = new FilterParserFloatGeComparator(rating); cmp = new FilterParserFloatGeComparator(rating);
} break;
else if (prefix == u'<') { case FilterOperator::Lt:
cmp = new FilterParserFloatLtComparator(rating); cmp = new FilterParserFloatLtComparator(rating);
} break;
else if (prefix == "<="_L1) { case FilterOperator::Le:
cmp = new FilterParserFloatLeComparator(rating); cmp = new FilterParserFloatLeComparator(rating);
break;
} }
else { break;
cmp = new FilterParserFloatEqComparator(rating);
} }
case ColumnType::Unknown:
break;
} }
} }
if (cmp) { if (filter_column != FilterColumn::Unknown && cmp != nullptr) {
return new FilterTreeColumnTerm(column, cmp); return new FilterTreeColumnTerm(filter_column, cmp);
} }
return new FilterTreeTerm(new FilterParserTextContainsComparator(value)); return new FilterTreeTerm(new FilterParserTextContainsComparator(value));

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player * Strawberry Music Player
* This file was part of Clementine. * This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com> * Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net> * Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de> * Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
* *
* Strawberry is free software: you can redistribute it and/or modify * Strawberry is free software: you can redistribute it and/or modify

View File

@@ -1,8 +1,6 @@
/* /*
* Strawberry Music Player * Strawberry Music Player
* This file was part of Clementine. * Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
* *
* Strawberry is free software: you can redistribute it and/or modify * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -22,7 +20,7 @@
#include <QString> #include <QString>
#include "filtertree.h" #include "filtertree.h"
#include "filtercolumn.h"
#include "core/song.h" #include "core/song.h"
using namespace Qt::Literals::StringLiterals; using namespace Qt::Literals::StringLiterals;
@@ -30,28 +28,64 @@ using namespace Qt::Literals::StringLiterals;
FilterTree::FilterTree() = default; FilterTree::FilterTree() = default;
FilterTree::~FilterTree() = default; FilterTree::~FilterTree() = default;
QVariant FilterTree::DataFromColumn(const QString &column, const Song &metadata) { QVariant FilterTree::DataFromColumn(const FilterColumn filter_column, const Song &song) {
if (column == "albumartist"_L1) return metadata.effective_albumartist(); switch (filter_column) {
if (column == "artist"_L1) return metadata.artist(); case FilterColumn::AlbumArtist:
if (column == "album"_L1) return metadata.album(); return song.effective_albumartist();
if (column == "title"_L1) return metadata.PrettyTitle(); case FilterColumn::AlbumArtistSort:
if (column == "composer"_L1) return metadata.composer(); return song.effective_albumartistsort();
if (column == "performer"_L1) return metadata.performer(); case FilterColumn::Artist:
if (column == "grouping"_L1) return metadata.grouping(); return song.artist();
if (column == "genre"_L1) return metadata.genre(); case FilterColumn::ArtistSort:
if (column == "comment"_L1) return metadata.comment(); return song.effective_artistsort();
if (column == "track"_L1) return metadata.track(); case FilterColumn::Album:
if (column == "year"_L1) return metadata.year(); return song.album();
if (column == "length"_L1) return metadata.length_nanosec(); case FilterColumn::AlbumSort:
if (column == "samplerate"_L1) return metadata.samplerate(); return song.effective_albumsort();
if (column == "bitdepth"_L1) return metadata.bitdepth(); case FilterColumn::Title:
if (column == "bitrate"_L1) return metadata.bitrate(); return song.PrettyTitle();
if (column == "rating"_L1) return metadata.rating(); case FilterColumn::TitleSort:
if (column == "playcount"_L1) return metadata.playcount(); return song.effective_titlesort();
if (column == "skipcount"_L1) return metadata.skipcount(); case FilterColumn::Composer:
if (column == "filename"_L1) return metadata.basefilename(); return song.composer();
if (column == "url"_L1) return metadata.effective_url().toString(); case FilterColumn::ComposerSort:
return song.effective_composersort();
case FilterColumn::Performer:
return song.performer();
case FilterColumn::PerformerSort:
return song.effective_performersort();
case FilterColumn::Grouping:
return song.grouping();
case FilterColumn::Genre:
return song.genre();
case FilterColumn::Comment:
return song.comment();
case FilterColumn::Track:
return song.track();
case FilterColumn::Year:
return song.year();
case FilterColumn::Length:
return song.length_nanosec();
case FilterColumn::Samplerate:
return song.samplerate();
case FilterColumn::Bitdepth:
return song.bitdepth();
case FilterColumn::Bitrate:
return song.bitrate();
case FilterColumn::Rating:
return song.rating();
case FilterColumn::Playcount:
return song.playcount();
case FilterColumn::Skipcount:
return song.skipcount();
case FilterColumn::Filename:
return song.basefilename();
case FilterColumn::URL:
return song.effective_url().toString();
case FilterColumn::Unknown:
break;
}
return QVariant(); return QVariant();

View File

@@ -1,8 +1,6 @@
/* /*
* Strawberry Music Player * Strawberry Music Player
* This file was part of Clementine. * Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
* *
* Strawberry is free software: you can redistribute it and/or modify * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -25,6 +23,7 @@
#include <QString> #include <QString>
#include "core/song.h" #include "core/song.h"
#include "filtercolumn.h"
class FilterTree { class FilterTree {
public: public:
@@ -45,7 +44,7 @@ class FilterTree {
virtual bool accept(const Song &song) const = 0; virtual bool accept(const Song &song) const = 0;
protected: protected:
static QVariant DataFromColumn(const QString &column, const Song &metadata); static QVariant DataFromColumn(const FilterColumn filter_column, const Song &metadata);
private: private:
Q_DISABLE_COPY(FilterTree) Q_DISABLE_COPY(FilterTree)

View File

@@ -24,8 +24,8 @@
#include "filtertreecolumnterm.h" #include "filtertreecolumnterm.h"
#include "filterparsersearchtermcomparator.h" #include "filterparsersearchtermcomparator.h"
FilterTreeColumnTerm::FilterTreeColumnTerm(const QString &column, FilterParserSearchTermComparator *comparator) : column_(column), cmp_(comparator) {} FilterTreeColumnTerm::FilterTreeColumnTerm(const FilterColumn filter_column, FilterParserSearchTermComparator *comparator) : filter_column_(filter_column), cmp_(comparator) {}
bool FilterTreeColumnTerm::accept(const Song &song) const { bool FilterTreeColumnTerm::accept(const Song &song) const {
return cmp_->Matches(DataFromColumn(column_, song)); return cmp_->Matches(DataFromColumn(filter_column_, song));
} }

View File

@@ -26,20 +26,20 @@
#include <QScopedPointer> #include <QScopedPointer>
#include "filtertree.h" #include "filtertree.h"
#include "filtercolumn.h"
#include "core/song.h" #include "core/song.h"
class FilterParserSearchTermComparator; class FilterParserSearchTermComparator;
class FilterTreeColumnTerm : public FilterTree { class FilterTreeColumnTerm : public FilterTree {
public: public:
explicit FilterTreeColumnTerm(const QString &column, FilterParserSearchTermComparator *comparator); explicit FilterTreeColumnTerm(const FilterColumn filter_column, FilterParserSearchTermComparator *comparator);
FilterType type() const override { return FilterType::Column; } FilterType type() const override { return FilterType::Column; }
bool accept(const Song &song) const override; bool accept(const Song &song) const override;
private: private:
const QString column_; const FilterColumn filter_column_;
QScopedPointer<FilterParserSearchTermComparator> cmp_; QScopedPointer<FilterParserSearchTermComparator> cmp_;
Q_DISABLE_COPY(FilterTreeColumnTerm) Q_DISABLE_COPY(FilterTreeColumnTerm)

View File

@@ -27,11 +27,17 @@ FilterTreeTerm::FilterTreeTerm(FilterParserSearchTermComparator *comparator) : c
bool FilterTreeTerm::accept(const Song &song) const { bool FilterTreeTerm::accept(const Song &song) const {
if (cmp_->Matches(song.PrettyTitle())) return true; if (cmp_->Matches(song.PrettyTitle())) return true;
if (cmp_->Matches(song.titlesort())) return true;
if (cmp_->Matches(song.album())) return true; if (cmp_->Matches(song.album())) return true;
if (cmp_->Matches(song.albumsort())) return true;
if (cmp_->Matches(song.artist())) return true; if (cmp_->Matches(song.artist())) return true;
if (cmp_->Matches(song.artistsort())) return true;
if (cmp_->Matches(song.albumartist())) return true; if (cmp_->Matches(song.albumartist())) return true;
if (cmp_->Matches(song.albumartistsort())) return true;
if (cmp_->Matches(song.composer())) return true; if (cmp_->Matches(song.composer())) return true;
if (cmp_->Matches(song.composersort())) return true;
if (cmp_->Matches(song.performer())) return true; if (cmp_->Matches(song.performer())) return true;
if (cmp_->Matches(song.performersort())) return true;
if (cmp_->Matches(song.grouping())) return true; if (cmp_->Matches(song.grouping())) return true;
if (cmp_->Matches(song.genre())) return true; if (cmp_->Matches(song.genre())) return true;
if (cmp_->Matches(song.comment())) return true; if (cmp_->Matches(song.comment())) return true;

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

@@ -695,6 +695,16 @@ void QobuzRequest::AlbumsReceived(QNetworkReply *reply, const Artist &artist_req
} }
album.album = obj_item["title"_L1].toString(); album.album = obj_item["title"_L1].toString();
if (obj_item.contains("genre"_L1)) {
QJsonValue value_genre = obj_item["genre"_L1];
if (value_genre.isObject()) {
QJsonObject obj_genre = value_genre.toObject();
if (obj_genre.contains("name"_L1)) {
album.genre = obj_genre["name"_L1].toString();
}
}
}
if (album_songs_requests_pending_.contains(album.album_id)) continue; if (album_songs_requests_pending_.contains(album.album_id)) continue;
QJsonValue value_artist = obj_item["artist"_L1]; QJsonValue value_artist = obj_item["artist"_L1];
@@ -921,6 +931,17 @@ void QobuzRequest::SongsReceived(QNetworkReply *reply, const Artist &artist_requ
} }
} }
// Extract genre from album/get response if not already set
if (album.genre.isEmpty() && json_object.contains("genre"_L1)) {
QJsonValue value_genre = json_object["genre"_L1];
if (value_genre.isObject()) {
QJsonObject obj_genre = value_genre.toObject();
if (obj_genre.contains("name"_L1)) {
album.genre = obj_genre["name"_L1].toString();
}
}
}
QJsonValue value_tracks = json_object["tracks"_L1]; QJsonValue value_tracks = json_object["tracks"_L1];
if (!value_tracks.isObject()) { if (!value_tracks.isObject()) {
Error(u"Json tracks is not an object."_s, json_object); Error(u"Json tracks is not an object."_s, json_object);
@@ -1053,6 +1074,7 @@ void QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
// bool streamable = json_obj["streamable"].toBool(); // bool streamable = json_obj["streamable"].toBool();
QString composer; QString composer;
QString performer; QString performer;
QString genre;
if (json_obj.contains("media_number"_L1)) { if (json_obj.contains("media_number"_L1)) {
disc = json_obj["media_number"_L1].toInt(); disc = json_obj["media_number"_L1].toInt();
@@ -1118,6 +1140,21 @@ void QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
song_album.cover_url.setUrl(album_image); song_album.cover_url.setUrl(album_image);
} }
} }
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)) {
genre = obj_genre["name"_L1].toString();
}
}
}
}
// Fall back to genre from the Album struct if not found in the track's album object
if (genre.isEmpty() && !album.genre.isEmpty()) {
genre = album.genre;
} }
if (json_obj.contains("composer"_L1)) { if (json_obj.contains("composer"_L1)) {
@@ -1180,6 +1217,7 @@ void QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
song.set_performer(performer); song.set_performer(performer);
song.set_composer(composer); song.set_composer(composer);
song.set_comment(copyright); song.set_comment(copyright);
song.set_genre(genre);
song.set_directory_id(0); song.set_directory_id(0);
song.set_filetype(Song::FileType::Stream); song.set_filetype(Song::FileType::Stream);
song.set_filesize(0); song.set_filesize(0);

View File

@@ -65,6 +65,7 @@ class QobuzRequest : public QobuzBaseRequest {
QString album; QString album;
QUrl cover_url; QUrl cover_url;
bool album_explicit; bool album_explicit;
QString genre;
}; };
struct Request { struct Request {
Request() : offset(0), limit(0) {} Request() : offset(0), limit(0) {}

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

View File

@@ -496,11 +496,20 @@ void SpotifyRequest::ArtistsReplyReceived(QNetworkReply *reply, const int limit_
const QString artist_id = object_item["id"_L1].toString(); const QString artist_id = object_item["id"_L1].toString();
const QString artist = object_item["name"_L1].toString(); const QString artist = object_item["name"_L1].toString();
QString genre;
if (object_item.contains("genres"_L1) && object_item["genres"_L1].isArray()) {
const QJsonArray array_genres = object_item["genres"_L1].toArray();
if (!array_genres.isEmpty()) {
genre = array_genres.first().toString();
}
}
if (artist_albums_requests_pending_.contains(artist_id)) continue; if (artist_albums_requests_pending_.contains(artist_id)) continue;
ArtistAlbumsRequest request; ArtistAlbumsRequest request;
request.artist.artist_id = artist_id; request.artist.artist_id = artist_id;
request.artist.artist = artist; request.artist.artist = artist;
request.artist.genre = genre;
artist_albums_requests_pending_.insert(artist_id, request); artist_albums_requests_pending_.insert(artist_id, request);
} }
@@ -715,6 +724,12 @@ void SpotifyRequest::AlbumsReceived(QNetworkReply *reply, const Artist &artist_a
if (artist.artist_id.isEmpty() || artist.artist_id == artist_artist.artist_id) { if (artist.artist_id.isEmpty() || artist.artist_id == artist_artist.artist_id) {
artist.artist_id = obj_artist["id"_L1].toString(); artist.artist_id = obj_artist["id"_L1].toString();
artist.artist = obj_artist["name"_L1].toString(); artist.artist = obj_artist["name"_L1].toString();
if (obj_artist.contains("genres"_L1) && obj_artist["genres"_L1].isArray()) {
const QJsonArray array_genres = obj_artist["genres"_L1].toArray();
if (!array_genres.isEmpty()) {
album.genre = array_genres.first().toString();
}
}
if (artist.artist_id == artist_artist.artist_id) { if (artist.artist_id == artist_artist.artist_id) {
artist_matches = true; artist_matches = true;
break; break;
@@ -730,6 +745,11 @@ void SpotifyRequest::AlbumsReceived(QNetworkReply *reply, const Artist &artist_a
artist = artist_artist; artist = artist_artist;
} }
// Fall back to artist's genre if no genre found in album's artist data
if (album.genre.isEmpty() && !artist_artist.genre.isEmpty()) {
album.genre = artist_artist.genre;
}
if (object_item.contains("images"_L1) && object_item["images"_L1].isArray()) { if (object_item.contains("images"_L1) && object_item["images"_L1].isArray()) {
const QJsonArray array_images = object_item["images"_L1].toArray(); const QJsonArray array_images = object_item["images"_L1].toArray();
for (const QJsonValue &value : array_images) { for (const QJsonValue &value : array_images) {
@@ -1050,6 +1070,7 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
QString artist_id; QString artist_id;
QString artist_title; QString artist_title;
QString genre;
if (json_obj.contains("artists"_L1) && json_obj["artists"_L1].isArray()) { if (json_obj.contains("artists"_L1) && json_obj["artists"_L1].isArray()) {
const QJsonArray array_artists = json_obj["artists"_L1].toArray(); const QJsonArray array_artists = json_obj["artists"_L1].toArray();
for (const QJsonValue &value_artist : array_artists) { for (const QJsonValue &value_artist : array_artists) {
@@ -1060,6 +1081,12 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
} }
artist_id = obj_artist["id"_L1].toString(); artist_id = obj_artist["id"_L1].toString();
artist_title = obj_artist["name"_L1].toString(); artist_title = obj_artist["name"_L1].toString();
if (obj_artist.contains("genres"_L1) && obj_artist["genres"_L1].isArray()) {
const QJsonArray array_genres = obj_artist["genres"_L1].toArray();
if (!array_genres.isEmpty()) {
genre = array_genres.first().toString();
}
}
break; break;
} }
} }
@@ -1102,6 +1129,16 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
cover_url = album.cover_url; cover_url = album.cover_url;
} }
// Fall back to genre from the Album struct if not found in the track's artist
if (genre.isEmpty() && !album.genre.isEmpty()) {
genre = album.genre;
}
// Fall back to genre from the Artist struct if still not found
if (genre.isEmpty() && !album_artist.genre.isEmpty()) {
genre = album_artist.genre;
}
QString song_id = json_obj["id"_L1].toString(); QString song_id = json_obj["id"_L1].toString();
QString title = json_obj["name"_L1].toString(); QString title = json_obj["name"_L1].toString();
QString uri = json_obj["uri"_L1].toString(); QString uri = json_obj["uri"_L1].toString();
@@ -1130,6 +1167,7 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
song.set_url(url); song.set_url(url);
song.set_length_nanosec(duration); song.set_length_nanosec(duration);
song.set_art_automatic(cover_url); song.set_art_automatic(cover_url);
song.set_genre(genre);
song.set_directory_id(0); song.set_directory_id(0);
song.set_filetype(Song::FileType::Stream); song.set_filetype(Song::FileType::Stream);
song.set_filesize(0); song.set_filesize(0);

View File

@@ -57,11 +57,13 @@ class SpotifyRequest : public SpotifyBaseRequest {
struct Artist { struct Artist {
QString artist_id; QString artist_id;
QString artist; QString artist;
QString genre;
}; };
struct Album { struct Album {
QString album_id; QString album_id;
QString album; QString album;
QUrl cover_url; QUrl cover_url;
QString genre;
}; };
struct Request { struct Request {
Request() : offset(0), limit(0) {} Request() : offset(0), limit(0) {}

View File

@@ -1000,6 +1000,11 @@ void TidalRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
const bool stream_ready = json_obj["streamReady"_L1].toBool(); const bool stream_ready = json_obj["streamReady"_L1].toBool();
const QString copyright = json_obj["copyright"_L1].toString(); const QString copyright = json_obj["copyright"_L1].toString();
QString genre;
if (json_obj.contains("genre"_L1)) {
genre = json_obj["genre"_L1].toString();
}
if (!value_artist.isObject()) { if (!value_artist.isObject()) {
Error(u"Invalid Json reply, track artist is not a object."_s, value_artist); Error(u"Invalid Json reply, track artist is not a object."_s, value_artist);
return; return;
@@ -1095,6 +1100,7 @@ void TidalRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
song.set_art_automatic(cover_url); song.set_art_automatic(cover_url);
} }
song.set_comment(copyright); song.set_comment(copyright);
song.set_genre(genre);
song.set_directory_id(0); song.set_directory_id(0);
song.set_filetype(Song::FileType::Stream); song.set_filetype(Song::FileType::Stream);
song.set_filesize(0); song.set_filesize(0);