Compare commits
15 Commits
l10n_maste
...
1.2.17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce06115557 | ||
|
|
89d1ac8f20 | ||
|
|
891b635c64 | ||
|
|
f37b1099f3 | ||
|
|
626dd48730 | ||
|
|
6f7b8ab162 | ||
|
|
3416ede211 | ||
|
|
f8bb69ec65 | ||
|
|
64540ef6f9 | ||
|
|
cd013db33b | ||
|
|
4f554f5d5f | ||
|
|
326fe84e8a | ||
|
|
1bded170a2 | ||
|
|
a71e5b170b | ||
|
|
ea629aedd1 |
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
34
Changelog
34
Changelog
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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:
|
||||
|
||||
53
src/filterparser/filtercolumn.h
Normal file
53
src/filterparser/filtercolumn.h
Normal 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
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
230
src/qobuz/qobuzmetadatarequest.cpp
Normal file
230
src/qobuz/qobuzmetadatarequest.cpp
Normal 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;
|
||||
|
||||
}
|
||||
55
src/qobuz/qobuzmetadatarequest.h
Normal file
55
src/qobuz/qobuzmetadatarequest.h
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
231
src/spotify/spotifymetadatarequest.cpp
Normal file
231
src/spotify/spotifymetadatarequest.cpp
Normal 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;
|
||||
|
||||
}
|
||||
58
src/spotify/spotifymetadatarequest.h
Normal file
58
src/spotify/spotifymetadatarequest.h
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user