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

View File

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

View File

@@ -2,6 +2,40 @@ Strawberry Music Player
=======================
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):
* 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_MINOR 2)
set(STRAWBERRY_VERSION_PATCH 16)
set(STRAWBERRY_VERSION_PATCH 17)
#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}")

View File

@@ -51,6 +51,7 @@
</screenshots>
<update_contact>eclipseo@fedoraproject.org</update_contact>
<releases>
<release version="1.2.17" date="2026-01-18"/>
<release version="1.2.16" date="2025-12-16"/>
<release version="1.2.15" date="2025-11-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) {
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()) {
const QString real_path = path_info.symLinkTarget();
@@ -566,7 +567,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
}
#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
t->AddToProgress(files_count);
return;
@@ -586,45 +587,47 @@ 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.
QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
while (it.hasNext()) {
if (path_info.exists()) {
QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
while (it.hasNext()) {
if (stop_or_abort_requested()) return;
if (stop_or_abort_requested()) return;
const QString child_filepath = it.next();
const QFileInfo child_fileinfo(child_filepath);
const QString child_filepath = it.next();
const QFileInfo child_fileinfo(child_filepath);
if (child_fileinfo.isSymLink()) {
QStorageInfo storage_info(child_fileinfo.symLinkTarget());
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();
continue;
if (child_fileinfo.isSymLink()) {
const QStorageInfo storage_info(child_fileinfo.symLinkTarget());
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();
continue;
}
}
}
if (child_fileinfo.isDir()) {
if (!t->HasSeenSubdir(child_filepath)) {
// We haven't seen this subdirectory before - add it to a list, and later we'll tell the backend about it and scan it.
CollectionSubdirectory new_subdir;
new_subdir.directory_id = -1;
new_subdir.path = child_filepath;
new_subdir.mtime = child_fileinfo.lastModified().toSecsSinceEpoch();
my_new_subdirs << new_subdir;
}
t->AddToProgress(1);
}
else {
QString ext_part(ExtensionPart(child_filepath));
QString dir_part(DirectoryPart(child_filepath));
if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) {
t->AddToProgress(1);
}
else if (sValidImages.contains(ext_part)) {
album_art[dir_part] << child_filepath;
if (child_fileinfo.isDir()) {
if (!t->HasSeenSubdir(child_filepath)) {
// We haven't seen this subdirectory before - add it to a list, and later we'll tell the backend about it and scan it.
CollectionSubdirectory new_subdir;
new_subdir.directory_id = -1;
new_subdir.path = child_filepath;
new_subdir.mtime = child_fileinfo.exists() && child_fileinfo.lastModified().isValid() ? child_fileinfo.lastModified().toSecsSinceEpoch() : 0;
my_new_subdirs << new_subdir;
}
t->AddToProgress(1);
}
else {
files_on_disk << child_filepath;
const QString ext_part = ExtensionPart(child_filepath);
const QString dir_part = DirectoryPart(child_filepath);
if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) {
t->AddToProgress(1);
}
else if (sValidImages.contains(ext_part)) {
album_art[dir_part] << child_filepath;
t->AddToProgress(1);
}
else {
files_on_disk << child_filepath;
}
}
}
}
@@ -632,27 +635,27 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
if (stop_or_abort_requested()) return;
// 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;
// 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) {
if (stop_or_abort_requested()) return;
// Associated CUE
QString new_cue = CueParser::FindCueFilename(file);
const QString new_cue = CueParser::FindCueFilename(file);
SongList matching_songs;
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.
// Check the mtime to see if it's been changed since it was added.
QFileInfo fileinfo(file);
const QFileInfo fileinfo(file);
if (!fileinfo.exists()) {
// 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.
// Check the mtime to see if it's been changed since it was added.
QFileInfo fileinfo(file);
const QFileInfo fileinfo(file);
if (!fileinfo.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now.
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.
bool matching_songs_has_cue = false;
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)) {
t->files_changed_path_ << matching_filename;
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
CollectionSubdirectory updated_subdir;
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;
if (updated_subdir.mtime == 0 && updated_subdir.path != dir.path) {
if (!path_info.exists() && updated_subdir.path != dir.path) {
t->deleted_subdirs << updated_subdir;
}
else if (subdir.directory_id == -1) {

View File

@@ -173,9 +173,12 @@
#endif
#ifdef HAVE_SPOTIFY
# include "spotify/spotifyservice.h"
# include "spotify/spotifymetadatarequest.h"
# include "constants/spotifysettings.h"
#endif
#ifdef HAVE_QOBUZ
# include "qobuz/qobuzservice.h"
# include "qobuz/qobuzmetadatarequest.h"
# include "constants/qobuzsettings.h"
#endif
@@ -379,8 +382,10 @@ MainWindow::MainWindow(Application *app,
playlist_add_to_another_(nullptr),
playlistitem_actions_separator_(nullptr),
playlist_rescan_songs_(nullptr),
playlist_fetch_metadata_(nullptr),
track_position_timer_(new QTimer(this)),
track_slider_timer_(new QTimer(this)),
metadata_queue_timer_(new QTimer(this)),
keep_running_(false),
playing_widget_(true),
#ifdef HAVE_DBUS
@@ -452,6 +457,10 @@ MainWindow::MainWindow(Application *app,
track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs);
QObject::connect(track_slider_timer_, &QTimer::timeout, this, &MainWindow::UpdateTrackSliderPosition);
metadata_queue_timer_->setInterval(200ms); // 200ms between requests to avoid rate limiting
metadata_queue_timer_->setSingleShot(true);
QObject::connect(metadata_queue_timer_, &QTimer::timeout, this, &MainWindow::ProcessMetadataQueue);
// Start initializing the player
qLog(Debug) << "Initializing player";
app_->player()->SetAnalyzer(ui_->analyzer);
@@ -812,6 +821,8 @@ MainWindow::MainWindow(Application *app,
#endif
playlist_rescan_songs_ = playlist_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Rescan song(s)..."), this, &MainWindow::RescanSongs);
playlist_menu_->addAction(playlist_rescan_songs_);
playlist_fetch_metadata_ = playlist_menu_->addAction(IconLoader::Load(u"download"_s), tr("Fetch metadata from service"), this, &MainWindow::FetchStreamingMetadata);
playlist_menu_->addAction(playlist_fetch_metadata_);
playlist_menu_->addAction(ui_->action_add_files_to_transcoder);
playlist_menu_->addSeparator();
playlist_copy_url_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy URL(s)..."), this, &MainWindow::PlaylistCopyUrl);
@@ -1995,6 +2006,7 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
int in_skipped = 0;
int not_in_skipped = 0;
int local_songs = 0;
int streaming_songs = 0;
for (const QModelIndex &idx : selection) {
@@ -2004,7 +2016,13 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
PlaylistItemPtr item = app_->playlist_manager()->current()->item_at(src_idx.row());
if (!item) continue;
if (item->EffectiveMetadata().url().isLocalFile()) ++local_songs;
if (item->EffectiveMetadata().url().isLocalFile()) {
++local_songs;
}
if (item->EffectiveMetadata().is_stream_service()) {
++streaming_songs;
}
if (item->EffectiveMetadata().has_cue()) {
cue_selected = true;
@@ -2032,6 +2050,9 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
playlist_rescan_songs_->setEnabled(local_songs > 0 && editable > 0);
playlist_rescan_songs_->setVisible(local_songs > 0 && editable > 0);
playlist_fetch_metadata_->setEnabled(streaming_songs > 0);
playlist_fetch_metadata_->setVisible(streaming_songs > 0);
ui_->action_add_files_to_transcoder->setEnabled(local_songs > 0 && editable > 0);
ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0);
@@ -2243,8 +2264,22 @@ void MainWindow::EditTracks() {
void MainWindow::EditTagDialogAccepted() {
const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items();
for (PlaylistItemPtr item : items) {
item->Reload();
const SongList songs = edit_tag_dialog_->songs();
if (items.count() != songs.count()) {
return;
}
for (int i = 0; i < items.count(); ++i) {
PlaylistItemPtr item = items[i];
const Song &updated_song = songs[i];
// For stream tracks, apply the metadata directly since there's no file to reload from
if (updated_song.is_stream_service()) {
item->SetOriginalMetadata(updated_song);
}
else {
item->Reload();
}
}
// FIXME: This is really lame but we don't know what rows have changed.
@@ -2319,8 +2354,8 @@ void MainWindow::SelectionSetValue() {
QObject::disconnect(*connection);
}, Qt::QueuedConnection);
}
else if (song.source() == Song::Source::Stream) {
app_->playlist_manager()->current()->setData(source_index, column_value, 0);
else if (song.is_stream()) {
app_->playlist_manager()->current()->setData(source_index.sibling(source_index.row(), static_cast<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 FetchStreamingMetadata();
void ProcessMetadataQueue();
public Q_SLOTS:
void CommandlineOptionsReceived(const QByteArray &string_options);
void Raise();
@@ -379,11 +382,13 @@ class MainWindow : public QMainWindow, public PlatformInterface {
QList<QAction*> playlistitem_actions_;
QAction *playlistitem_actions_separator_;
QAction *playlist_rescan_songs_;
QAction *playlist_fetch_metadata_;
QModelIndex playlist_menu_index_;
QTimer *track_position_timer_;
QTimer *track_slider_timer_;
QTimer *metadata_queue_timer_;
bool keep_running_;
bool playing_widget_;
@@ -407,6 +412,14 @@ class MainWindow : public QMainWindow, public PlatformInterface {
bool playlists_loaded_;
bool delete_files_;
std::optional<CommandlineOptions> options_;
class MetadataQueueEntry {
public:
Song::Source source;
QString track_id;
QPersistentModelIndex persistent_index;
};
QList<MetadataQueueEntry> metadata_queue_;
};
#endif // MAINWINDOW_H

View File

@@ -34,6 +34,13 @@
#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/hash_index_iterator.hpp>
#include <boost/multi_index/hashed_index.hpp>
@@ -45,6 +52,10 @@
#include <boost/multi_index_container.hpp>
#include <boost/operators.hpp>
#ifdef __GNUC__
#pragma GCC diagnostic pop
#endif
using boost::multi_index::hashed_unique;
using boost::multi_index::identity;
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_local_collection_song() const { return d->source_ == Source::Collection; }
bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); }
bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; }
bool Song::is_stream_service() const { return d->source_ == Source::Subsonic || d->source_ == Source::Tidal || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_stream() const { return is_radio() || is_stream_service(); }
bool Song::is_cdda() const { return d->source_ == Source::CDDA; }
bool Song::is_compilation() const { return (d->compilation_ || d->compilation_detected_ || d->compilation_on_) && !d->compilation_off_; }
bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz; }
@@ -956,7 +957,7 @@ QString Song::PrettyRating() const {
}
bool Song::IsEditable() const {
return d->valid_ && d->url_.isValid() && ((d->url_.isLocalFile() && write_tags_supported() && !has_cue()) || d->source_ == Source::Stream);
return d->valid_ && d->url_.isValid() && ((d->url_.isLocalFile() && write_tags_supported() && !has_cue()) || is_stream());
}
bool Song::IsFileInfoEqual(const Song &other) const {

View File

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

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) {
const bool loading = !message.isEmpty();
@@ -1399,6 +1410,12 @@ void EditTagDialog::SaveData() {
}
if (save_tags || save_playcount || save_rating || save_embedded_cover) {
// For streaming tracks, skip tag writing since there's no local file.
// The metadata will be applied directly to the playlist item in MainWindow::EditTagDialogAccepted.
if (ref.current_.is_stream()) {
continue;
}
// Not to confuse the collection model.
if (ref.current_.track() <= 0) { ref.current_.set_track(-1); }
if (ref.current_.disc() <= 0) { ref.current_.set_disc(-1); }

View File

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

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
* This file was part of Clementine.
* 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>
*
* Strawberry is free software: you can redistribute it and/or modify
@@ -21,8 +21,10 @@
*/
#include <QString>
#include <QMap>
#include "constants/timeconstants.h"
#include "core/song.h"
#include "filterparser.h"
#include "filtertreenop.h"
#include "filtertreeand.h"
@@ -31,9 +33,126 @@
#include "filtertreeterm.h"
#include "filtertreecolumnterm.h"
#include "filterparsersearchcomparators.h"
#include "filtercolumn.h"
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_{} {}
FilterTree *FilterParser::parse() {
@@ -119,7 +238,7 @@ bool FilterParser::checkAnd() {
bool FilterParser::checkOr(const bool step_over) {
if (!buf_.isEmpty()) {
if (buf_ == "OR"_L1) {
if (buf_.size() == 2 && buf_[0] == u'O' && buf_[1] == u'R') {
if (step_over) {
buf_.clear();
advance();
@@ -141,7 +260,8 @@ bool FilterParser::checkOr(const bool step_over) {
advance();
}
else {
buf_ += "OR"_L1;
buf_ += u'O';
buf_ += u'R';
}
return true;
}
@@ -191,6 +311,8 @@ FilterTree *FilterParser::parseSearchTerm() {
bool in_quotes = false;
bool previous_char_operator = false;
buf_.reserve(32);
for (; iter_ != end_; ++iter_) {
if (previous_char_operator) {
if (iter_->isSpace()) {
@@ -225,7 +347,7 @@ FilterTree *FilterParser::parseSearchTerm() {
prefix += *iter_;
previous_char_operator = true;
}
else if (prefix != u'=' && *iter_ == u'=') {
else if (prefix.size() == 1 && prefix[0] != u'=' && *iter_ == u'=') {
prefix += *iter_;
previous_char_operator = true;
}
@@ -252,132 +374,145 @@ FilterTree *FilterParser::createSearchTermTreeNode(const QString &column, const
return new FilterTreeNop;
}
FilterColumn filter_column = FilterColumn::Unknown;
FilterParserSearchTermComparator *cmp = nullptr;
if (!column.isEmpty()) {
if (Song::kTextSearchColumns.contains(column, Qt::CaseInsensitive)) {
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserTextEqComparator(value);
filter_column = GetFilterColumnsMap().value(column, FilterColumn::Unknown);
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);
break;
case FilterOperator::Ne:
cmp = new FilterParserTextNeComparator(value);
break;
default:
cmp = new FilterParserTextContainsComparator(value);
break;
}
break;
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserTextNeComparator(value);
case ColumnType::Int:{
bool ok = false;
const int number = value.toInt(&ok);
if (!ok) break;
switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
cmp = new FilterParserIntEqComparator(number);
break;
case FilterOperator::Ne:
cmp = new FilterParserIntNeComparator(number);
break;
case FilterOperator::Gt:
cmp = new FilterParserIntGtComparator(number);
break;
case FilterOperator::Ge:
cmp = new FilterParserIntGeComparator(number);
break;
case FilterOperator::Lt:
cmp = new FilterParserIntLtComparator(number);
break;
case FilterOperator::Le:
cmp = new FilterParserIntLeComparator(number);
break;
}
break;
}
else {
cmp = new FilterParserTextContainsComparator(value);
case ColumnType::UInt:{
bool ok = false;
const uint number = value.toUInt(&ok);
if (!ok) break;
switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
cmp = new FilterParserUIntEqComparator(number);
break;
case FilterOperator::Ne:
cmp = new FilterParserUIntNeComparator(number);
break;
case FilterOperator::Gt:
cmp = new FilterParserUIntGtComparator(number);
break;
case FilterOperator::Ge:
cmp = new FilterParserUIntGeComparator(number);
break;
case FilterOperator::Lt:
cmp = new FilterParserUIntLtComparator(number);
break;
case FilterOperator::Le:
cmp = new FilterParserUIntLeComparator(number);
break;
}
break;
}
}
else if (Song::kIntSearchColumns.contains(column, Qt::CaseInsensitive)) {
bool ok = false;
int number = value.toInt(&ok);
if (ok) {
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserIntEqComparator(number);
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserIntNeComparator(number);
}
else if (prefix == u'>') {
cmp = new FilterParserIntGtComparator(number);
}
else if (prefix == ">="_L1) {
cmp = new FilterParserIntGeComparator(number);
}
else if (prefix == u'<') {
cmp = new FilterParserIntLtComparator(number);
}
else if (prefix == "<="_L1) {
cmp = new FilterParserIntLeComparator(number);
case ColumnType::Int64:{
qint64 number = 0;
if (filter_column == FilterColumn::Length) {
number = ParseTime(value) * kNsecPerSec;
}
else {
cmp = new FilterParserIntEqComparator(number);
number = value.toLongLong();
}
}
}
else if (Song::kUIntSearchColumns.contains(column, Qt::CaseInsensitive)) {
bool ok = false;
uint number = value.toUInt(&ok);
if (ok) {
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserUIntEqComparator(number);
switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
cmp = new FilterParserInt64EqComparator(number);
break;
case FilterOperator::Ne:
cmp = new FilterParserInt64NeComparator(number);
break;
case FilterOperator::Gt:
cmp = new FilterParserInt64GtComparator(number);
break;
case FilterOperator::Ge:
cmp = new FilterParserInt64GeComparator(number);
break;
case FilterOperator::Lt:
cmp = new FilterParserInt64LtComparator(number);
break;
case FilterOperator::Le:
cmp = new FilterParserInt64LeComparator(number);
break;
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserUIntNeComparator(number);
}
else if (prefix == u'>') {
cmp = new FilterParserUIntGtComparator(number);
}
else if (prefix == ">="_L1) {
cmp = new FilterParserUIntGeComparator(number);
}
else if (prefix == u'<') {
cmp = new FilterParserUIntLtComparator(number);
}
else if (prefix == "<="_L1) {
cmp = new FilterParserUIntLeComparator(number);
}
else {
cmp = new FilterParserUIntEqComparator(number);
break;
}
case ColumnType::Float:{
const float rating = ParseRating(value);
switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
cmp = new FilterParserFloatEqComparator(rating);
break;
case FilterOperator::Ne:
cmp = new FilterParserFloatNeComparator(rating);
break;
case FilterOperator::Gt:
cmp = new FilterParserFloatGtComparator(rating);
break;
case FilterOperator::Ge:
cmp = new FilterParserFloatGeComparator(rating);
break;
case FilterOperator::Lt:
cmp = new FilterParserFloatLtComparator(rating);
break;
case FilterOperator::Le:
cmp = new FilterParserFloatLeComparator(rating);
break;
}
break;
}
}
else if (Song::kInt64SearchColumns.contains(column, Qt::CaseInsensitive)) {
qint64 number = 0;
if (column == "length"_L1) {
number = ParseTime(value) * kNsecPerSec;
}
else {
number = value.toLongLong();
}
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserInt64EqComparator(number);
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserInt64NeComparator(number);
}
else if (prefix == u'>') {
cmp = new FilterParserInt64GtComparator(number);
}
else if (prefix == ">="_L1) {
cmp = new FilterParserInt64GeComparator(number);
}
else if (prefix == u'<') {
cmp = new FilterParserInt64LtComparator(number);
}
else if (prefix == "<="_L1) {
cmp = new FilterParserInt64LeComparator(number);
}
else {
cmp = new FilterParserInt64EqComparator(number);
}
}
else if (Song::kFloatSearchColumns.contains(column, Qt::CaseInsensitive)) {
const float rating = ParseRating(value);
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserFloatEqComparator(rating);
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserFloatNeComparator(rating);
}
else if (prefix == u'>') {
cmp = new FilterParserFloatGtComparator(rating);
}
else if (prefix == ">="_L1) {
cmp = new FilterParserFloatGeComparator(rating);
}
else if (prefix == u'<') {
cmp = new FilterParserFloatLtComparator(rating);
}
else if (prefix == "<="_L1) {
cmp = new FilterParserFloatLeComparator(rating);
}
else {
cmp = new FilterParserFloatEqComparator(rating);
}
case ColumnType::Unknown:
break;
}
}
if (cmp) {
return new FilterTreeColumnTerm(column, cmp);
if (filter_column != FilterColumn::Unknown && cmp != nullptr) {
return new FilterTreeColumnTerm(filter_column, cmp);
}
return new FilterTreeTerm(new FilterParserTextContainsComparator(value));

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* 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>
*
* Strawberry is free software: you can redistribute it and/or modify

View File

@@ -1,8 +1,6 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-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
@@ -22,7 +20,7 @@
#include <QString>
#include "filtertree.h"
#include "filtercolumn.h"
#include "core/song.h"
using namespace Qt::Literals::StringLiterals;
@@ -30,28 +28,64 @@ using namespace Qt::Literals::StringLiterals;
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();
if (column == "artist"_L1) return metadata.artist();
if (column == "album"_L1) return metadata.album();
if (column == "title"_L1) return metadata.PrettyTitle();
if (column == "composer"_L1) return metadata.composer();
if (column == "performer"_L1) return metadata.performer();
if (column == "grouping"_L1) return metadata.grouping();
if (column == "genre"_L1) return metadata.genre();
if (column == "comment"_L1) return metadata.comment();
if (column == "track"_L1) return metadata.track();
if (column == "year"_L1) return metadata.year();
if (column == "length"_L1) return metadata.length_nanosec();
if (column == "samplerate"_L1) return metadata.samplerate();
if (column == "bitdepth"_L1) return metadata.bitdepth();
if (column == "bitrate"_L1) return metadata.bitrate();
if (column == "rating"_L1) return metadata.rating();
if (column == "playcount"_L1) return metadata.playcount();
if (column == "skipcount"_L1) return metadata.skipcount();
if (column == "filename"_L1) return metadata.basefilename();
if (column == "url"_L1) return metadata.effective_url().toString();
switch (filter_column) {
case FilterColumn::AlbumArtist:
return song.effective_albumartist();
case FilterColumn::AlbumArtistSort:
return song.effective_albumartistsort();
case FilterColumn::Artist:
return song.artist();
case FilterColumn::ArtistSort:
return song.effective_artistsort();
case FilterColumn::Album:
return song.album();
case FilterColumn::AlbumSort:
return song.effective_albumsort();
case FilterColumn::Title:
return song.PrettyTitle();
case FilterColumn::TitleSort:
return song.effective_titlesort();
case FilterColumn::Composer:
return song.composer();
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();

View File

@@ -1,8 +1,6 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-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
@@ -25,6 +23,7 @@
#include <QString>
#include "core/song.h"
#include "filtercolumn.h"
class FilterTree {
public:
@@ -45,7 +44,7 @@ class FilterTree {
virtual bool accept(const Song &song) const = 0;
protected:
static QVariant DataFromColumn(const QString &column, const Song &metadata);
static QVariant DataFromColumn(const FilterColumn filter_column, const Song &metadata);
private:
Q_DISABLE_COPY(FilterTree)

View File

@@ -24,8 +24,8 @@
#include "filtertreecolumnterm.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 {
return cmp_->Matches(DataFromColumn(column_, song));
return cmp_->Matches(DataFromColumn(filter_column_, song));
}

View File

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

View File

@@ -27,11 +27,17 @@ FilterTreeTerm::FilterTreeTerm(FilterParserSearchTermComparator *comparator) : c
bool FilterTreeTerm::accept(const Song &song) const {
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.albumsort())) 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.albumartistsort())) 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.performersort())) return true;
if (cmp_->Matches(song.grouping())) return true;
if (cmp_->Matches(song.genre())) 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);
}, Qt::QueuedConnection);
}
else if (song.is_radio()) {
else if (song.is_stream()) {
item->SetOriginalMetadata(song);
Q_EMIT dataChanged(index(row, 0), index(row, ColumnCount - 1));
Q_EMIT EditingFinished(id_, idx);
ScheduleSave();
}

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();
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;
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];
if (!value_tracks.isObject()) {
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();
QString composer;
QString performer;
QString genre;
if (json_obj.contains("media_number"_L1)) {
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);
}
}
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)) {
@@ -1180,6 +1217,7 @@ void QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
song.set_performer(performer);
song.set_composer(composer);
song.set_comment(copyright);
song.set_genre(genre);
song.set_directory_id(0);
song.set_filetype(Song::FileType::Stream);
song.set_filesize(0);

View File

@@ -65,6 +65,7 @@ class QobuzRequest : public QobuzBaseRequest {
QString album;
QUrl cover_url;
bool album_explicit;
QString genre;
};
struct Request {
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 = 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;
ArtistAlbumsRequest request;
request.artist.artist_id = artist_id;
request.artist.artist = artist;
request.artist.genre = genre;
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) {
artist.artist_id = obj_artist["id"_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) {
artist_matches = true;
break;
@@ -730,6 +745,11 @@ void SpotifyRequest::AlbumsReceived(QNetworkReply *reply, const Artist &artist_a
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()) {
const QJsonArray array_images = object_item["images"_L1].toArray();
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_title;
QString genre;
if (json_obj.contains("artists"_L1) && json_obj["artists"_L1].isArray()) {
const QJsonArray array_artists = json_obj["artists"_L1].toArray();
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_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;
}
}
@@ -1102,6 +1129,16 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
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 title = json_obj["name"_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_length_nanosec(duration);
song.set_art_automatic(cover_url);
song.set_genre(genre);
song.set_directory_id(0);
song.set_filetype(Song::FileType::Stream);
song.set_filesize(0);

View File

@@ -57,11 +57,13 @@ class SpotifyRequest : public SpotifyBaseRequest {
struct Artist {
QString artist_id;
QString artist;
QString genre;
};
struct Album {
QString album_id;
QString album;
QUrl cover_url;
QString genre;
};
struct Request {
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 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()) {
Error(u"Invalid Json reply, track artist is not a object."_s, value_artist);
return;
@@ -1095,6 +1100,7 @@ void TidalRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
song.set_art_automatic(cover_url);
}
song.set_comment(copyright);
song.set_genre(genre);
song.set_directory_id(0);
song.set_filetype(Song::FileType::Stream);
song.set_filesize(0);