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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
fedora_version: [ '41', '42', '43' ]
|
fedora_version: [ '42', '43', '44' ]
|
||||||
container:
|
container:
|
||||||
image: fedora:${{matrix.fedora_version}}
|
image: fedora:${{matrix.fedora_version}}
|
||||||
steps:
|
steps:
|
||||||
@@ -542,7 +542,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
ubuntu_version: [ 'noble', 'plucky', 'questing' ]
|
ubuntu_version: [ 'noble', 'questing', 'resolute' ]
|
||||||
container:
|
container:
|
||||||
image: ubuntu:${{matrix.ubuntu_version}}
|
image: ubuntu:${{matrix.ubuntu_version}}
|
||||||
steps:
|
steps:
|
||||||
@@ -596,10 +596,10 @@ jobs:
|
|||||||
qt6-l10n-tools
|
qt6-l10n-tools
|
||||||
rapidjson-dev
|
rapidjson-dev
|
||||||
- name: Install KDSingleApplication
|
- name: Install KDSingleApplication
|
||||||
if: matrix.ubuntu_version != 'noble' && matrix.ubuntu_version != 'plucky'
|
if: matrix.ubuntu_version != 'noble'
|
||||||
run: apt install -y libkdsingleapplication-qt6-dev
|
run: apt install -y libkdsingleapplication-qt6-dev
|
||||||
- name: Build and install KDSingleApplication
|
- name: Build and install KDSingleApplication
|
||||||
if: matrix.ubuntu_version == 'noble' || matrix.ubuntu_version == 'plucky'
|
if: matrix.ubuntu_version == 'noble'
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
|
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
|
||||||
cd KDSingleApplication
|
cd KDSingleApplication
|
||||||
@@ -639,7 +639,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
ubuntu_version: [ 'noble', 'plucky', 'questing' ]
|
ubuntu_version: [ 'noble', 'questing', 'resolute' ]
|
||||||
container:
|
container:
|
||||||
image: ubuntu:${{matrix.ubuntu_version}}
|
image: ubuntu:${{matrix.ubuntu_version}}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1447,6 +1447,7 @@ optional_source(HAVE_SPOTIFY
|
|||||||
src/spotify/spotifybaserequest.cpp
|
src/spotify/spotifybaserequest.cpp
|
||||||
src/spotify/spotifyrequest.cpp
|
src/spotify/spotifyrequest.cpp
|
||||||
src/spotify/spotifyfavoriterequest.cpp
|
src/spotify/spotifyfavoriterequest.cpp
|
||||||
|
src/spotify/spotifymetadatarequest.cpp
|
||||||
src/settings/spotifysettingspage.cpp
|
src/settings/spotifysettingspage.cpp
|
||||||
src/covermanager/spotifycoverprovider.cpp
|
src/covermanager/spotifycoverprovider.cpp
|
||||||
HEADERS
|
HEADERS
|
||||||
@@ -1454,6 +1455,7 @@ optional_source(HAVE_SPOTIFY
|
|||||||
src/spotify/spotifybaserequest.h
|
src/spotify/spotifybaserequest.h
|
||||||
src/spotify/spotifyrequest.h
|
src/spotify/spotifyrequest.h
|
||||||
src/spotify/spotifyfavoriterequest.h
|
src/spotify/spotifyfavoriterequest.h
|
||||||
|
src/spotify/spotifymetadatarequest.h
|
||||||
src/settings/spotifysettingspage.h
|
src/settings/spotifysettingspage.h
|
||||||
src/covermanager/spotifycoverprovider.h
|
src/covermanager/spotifycoverprovider.h
|
||||||
UI
|
UI
|
||||||
@@ -1468,6 +1470,7 @@ optional_source(HAVE_QOBUZ
|
|||||||
src/qobuz/qobuzrequest.cpp
|
src/qobuz/qobuzrequest.cpp
|
||||||
src/qobuz/qobuzstreamurlrequest.cpp
|
src/qobuz/qobuzstreamurlrequest.cpp
|
||||||
src/qobuz/qobuzfavoriterequest.cpp
|
src/qobuz/qobuzfavoriterequest.cpp
|
||||||
|
src/qobuz/qobuzmetadatarequest.cpp
|
||||||
src/qobuz/qobuzcredentialfetcher.cpp
|
src/qobuz/qobuzcredentialfetcher.cpp
|
||||||
src/settings/qobuzsettingspage.cpp
|
src/settings/qobuzsettingspage.cpp
|
||||||
src/covermanager/qobuzcoverprovider.cpp
|
src/covermanager/qobuzcoverprovider.cpp
|
||||||
@@ -1478,6 +1481,7 @@ optional_source(HAVE_QOBUZ
|
|||||||
src/qobuz/qobuzrequest.h
|
src/qobuz/qobuzrequest.h
|
||||||
src/qobuz/qobuzstreamurlrequest.h
|
src/qobuz/qobuzstreamurlrequest.h
|
||||||
src/qobuz/qobuzfavoriterequest.h
|
src/qobuz/qobuzfavoriterequest.h
|
||||||
|
src/qobuz/qobuzmetadatarequest.h
|
||||||
src/qobuz/qobuzcredentialfetcher.h
|
src/qobuz/qobuzcredentialfetcher.h
|
||||||
src/settings/qobuzsettingspage.h
|
src/settings/qobuzsettingspage.h
|
||||||
src/covermanager/qobuzcoverprovider.h
|
src/covermanager/qobuzcoverprovider.h
|
||||||
|
|||||||
34
Changelog
34
Changelog
@@ -2,6 +2,40 @@ Strawberry Music Player
|
|||||||
=======================
|
=======================
|
||||||
ChangeLog
|
ChangeLog
|
||||||
|
|
||||||
|
Version 1.2.17 (2026.01.18):
|
||||||
|
|
||||||
|
* Avoid re-scan of restored songs unless mtime is changed (#1819)
|
||||||
|
* Skip existing files when organizing if not overwriting (#1484)
|
||||||
|
* Fixed cursor highlight disappearing off-screen when using down cursor (#1489)
|
||||||
|
* Fixed CD playback only working for the first optical drive (#1852)
|
||||||
|
* Fixed possible race-condition when switching tracks (#1863)
|
||||||
|
* Fixed possible file descriptor exhaustion by using shared thread pool (#1687)
|
||||||
|
* Don't automatically sort playlist with the auto sort option before it's fully loaded (#1690)
|
||||||
|
* Fixed network features stop working after computer suspends and resumes (#1521)
|
||||||
|
* Fixed crash on exit after Qobuz login
|
||||||
|
* Added tag editor option to select ID3v2 version (#1861)
|
||||||
|
* Fixed Qobuz authentication and added automatic credential fetching (#1898)
|
||||||
|
* Fixed playback stopping after deleting a song from disk via context menu (#1783)
|
||||||
|
* Added option to restore smart playlists to the defaults (#1848)
|
||||||
|
* Fixed possible race condition in pipeline destructor (#1875)
|
||||||
|
* Fixed buffering issue near track end during gapless playback (#1725)
|
||||||
|
* Fixed duplicate collection entries for the same artist if they have different sort tags (#1899)
|
||||||
|
* Defer playcount and rating tag writes for currently playing Ogg songs to prevent playback shutter (#1816)
|
||||||
|
* Fixed tag editing not working for Opus sort tags (#1929)
|
||||||
|
* Show playlist load errors (#1470)
|
||||||
|
* Fallback to delete if moving to trash fails (#1679)
|
||||||
|
* Prefer filenames with "front" or "cover" in the filename for album cover art for songs outside of the collection (#1745)
|
||||||
|
* Fixed collection enter/return behavior to respect double-click settings (#1691)
|
||||||
|
* Added tree view mode to files tab (#1922)
|
||||||
|
* Include .webp in allowed extensions for album covers (#1941)
|
||||||
|
* Exit gracefully on SIGTERM signal for Unix systems (#1942)
|
||||||
|
* Optimize the collection scanning process by deferring media file validation from the initial directory scan (#1954)
|
||||||
|
* Fixed collection scan not finding new directories in the top level collection directory when the mountpoint is restored (#1914)
|
||||||
|
* Added genre metadata parsing for Tidal, Qobuz and Spotify (#1913)
|
||||||
|
* Allow editing metadata for stream songs (#1913)
|
||||||
|
* Optimized collection/playlist filtering
|
||||||
|
* Added sort tags to collection/playlist filtering (#1966)
|
||||||
|
|
||||||
Version 1.2.16 (2025.12.16):
|
Version 1.2.16 (2025.12.16):
|
||||||
|
|
||||||
* Make Discord Rich presence use filename if song title is missing
|
* Make Discord Rich presence use filename if song title is missing
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
set(STRAWBERRY_VERSION_MAJOR 1)
|
set(STRAWBERRY_VERSION_MAJOR 1)
|
||||||
set(STRAWBERRY_VERSION_MINOR 2)
|
set(STRAWBERRY_VERSION_MINOR 2)
|
||||||
set(STRAWBERRY_VERSION_PATCH 16)
|
set(STRAWBERRY_VERSION_PATCH 17)
|
||||||
#set(STRAWBERRY_VERSION_PRERELEASE rc1)
|
#set(STRAWBERRY_VERSION_PRERELEASE rc1)
|
||||||
|
|
||||||
set(INCLUDE_GIT_REVISION ON)
|
set(INCLUDE_GIT_REVISION OFF)
|
||||||
|
|
||||||
set(majorminorpatch "${STRAWBERRY_VERSION_MAJOR}.${STRAWBERRY_VERSION_MINOR}.${STRAWBERRY_VERSION_PATCH}")
|
set(majorminorpatch "${STRAWBERRY_VERSION_MAJOR}.${STRAWBERRY_VERSION_MINOR}.${STRAWBERRY_VERSION_PATCH}")
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
</screenshots>
|
</screenshots>
|
||||||
<update_contact>eclipseo@fedoraproject.org</update_contact>
|
<update_contact>eclipseo@fedoraproject.org</update_contact>
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="1.2.17" date="2026-01-18"/>
|
||||||
<release version="1.2.16" date="2025-12-16"/>
|
<release version="1.2.16" date="2025-12-16"/>
|
||||||
<release version="1.2.15" date="2025-11-25"/>
|
<release version="1.2.15" date="2025-11-25"/>
|
||||||
<release version="1.2.14" date="2025-10-25"/>
|
<release version="1.2.14" date="2025-10-25"/>
|
||||||
|
|||||||
@@ -530,6 +530,7 @@ void CollectionWatcher::AddDirectory(const CollectionDirectory &dir, const Colle
|
|||||||
void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, ScanTransaction *t, const bool force_noincremental) {
|
void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, ScanTransaction *t, const bool force_noincremental) {
|
||||||
|
|
||||||
const QFileInfo path_info(path);
|
const QFileInfo path_info(path);
|
||||||
|
const qint64 path_mtime = path_info.exists() && path_info.lastModified().isValid() ? path_info.lastModified().toSecsSinceEpoch() : 0;
|
||||||
|
|
||||||
if (path_info.isSymLink()) {
|
if (path_info.isSymLink()) {
|
||||||
const QString real_path = path_info.symLinkTarget();
|
const QString real_path = path_info.symLinkTarget();
|
||||||
@@ -566,7 +567,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && subdir.mtime == path_info.lastModified().toSecsSinceEpoch() && !songs_missing_fingerprint && !songs_missing_loudness_characteristics) {
|
if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && path_mtime != 0 && subdir.mtime == path_mtime && !songs_missing_fingerprint && !songs_missing_loudness_characteristics) {
|
||||||
// The directory hasn't changed since last time
|
// The directory hasn't changed since last time
|
||||||
t->AddToProgress(files_count);
|
t->AddToProgress(files_count);
|
||||||
return;
|
return;
|
||||||
@@ -586,6 +587,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First we "quickly" get a list of the files in the directory that we think might be music. While we're here, we also look for new subdirectories and possible album artwork.
|
// First we "quickly" get a list of the files in the directory that we think might be music. While we're here, we also look for new subdirectories and possible album artwork.
|
||||||
|
if (path_info.exists()) {
|
||||||
QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
|
QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
|
||||||
while (it.hasNext()) {
|
while (it.hasNext()) {
|
||||||
|
|
||||||
@@ -595,7 +597,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
|
|||||||
const QFileInfo child_fileinfo(child_filepath);
|
const QFileInfo child_fileinfo(child_filepath);
|
||||||
|
|
||||||
if (child_fileinfo.isSymLink()) {
|
if (child_fileinfo.isSymLink()) {
|
||||||
QStorageInfo storage_info(child_fileinfo.symLinkTarget());
|
const QStorageInfo storage_info(child_fileinfo.symLinkTarget());
|
||||||
if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
|
if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
|
||||||
qLog(Warning) << "Ignoring symbolic link" << child_filepath << "which links to" << child_fileinfo.symLinkTarget() << "with rejected filesystem type" << storage_info.fileSystemType();
|
qLog(Warning) << "Ignoring symbolic link" << child_filepath << "which links to" << child_fileinfo.symLinkTarget() << "with rejected filesystem type" << storage_info.fileSystemType();
|
||||||
continue;
|
continue;
|
||||||
@@ -608,14 +610,14 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
|
|||||||
CollectionSubdirectory new_subdir;
|
CollectionSubdirectory new_subdir;
|
||||||
new_subdir.directory_id = -1;
|
new_subdir.directory_id = -1;
|
||||||
new_subdir.path = child_filepath;
|
new_subdir.path = child_filepath;
|
||||||
new_subdir.mtime = child_fileinfo.lastModified().toSecsSinceEpoch();
|
new_subdir.mtime = child_fileinfo.exists() && child_fileinfo.lastModified().isValid() ? child_fileinfo.lastModified().toSecsSinceEpoch() : 0;
|
||||||
my_new_subdirs << new_subdir;
|
my_new_subdirs << new_subdir;
|
||||||
}
|
}
|
||||||
t->AddToProgress(1);
|
t->AddToProgress(1);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
QString ext_part(ExtensionPart(child_filepath));
|
const QString ext_part = ExtensionPart(child_filepath);
|
||||||
QString dir_part(DirectoryPart(child_filepath));
|
const QString dir_part = DirectoryPart(child_filepath);
|
||||||
if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) {
|
if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) {
|
||||||
t->AddToProgress(1);
|
t->AddToProgress(1);
|
||||||
}
|
}
|
||||||
@@ -628,31 +630,32 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (stop_or_abort_requested()) return;
|
if (stop_or_abort_requested()) return;
|
||||||
|
|
||||||
// Ask the database for a list of files in this directory
|
// Ask the database for a list of files in this directory
|
||||||
SongList songs_in_db = t->FindSongsInSubdirectory(path);
|
const SongList songs_in_db = t->FindSongsInSubdirectory(path);
|
||||||
|
|
||||||
QSet<QString> cues_processed;
|
QSet<QString> cues_processed;
|
||||||
|
|
||||||
// Now compare the list from the database with the list of files on disk
|
// Now compare the list from the database with the list of files on disk
|
||||||
QStringList files_on_disk_copy = files_on_disk;
|
const QStringList files_on_disk_copy = files_on_disk;
|
||||||
for (const QString &file : files_on_disk_copy) {
|
for (const QString &file : files_on_disk_copy) {
|
||||||
|
|
||||||
if (stop_or_abort_requested()) return;
|
if (stop_or_abort_requested()) return;
|
||||||
|
|
||||||
// Associated CUE
|
// Associated CUE
|
||||||
QString new_cue = CueParser::FindCueFilename(file);
|
const QString new_cue = CueParser::FindCueFilename(file);
|
||||||
|
|
||||||
SongList matching_songs;
|
SongList matching_songs;
|
||||||
if (FindSongsByPath(songs_in_db, file, &matching_songs)) { // Found matching song in DB by path.
|
if (FindSongsByPath(songs_in_db, file, &matching_songs)) { // Found matching song in DB by path.
|
||||||
|
|
||||||
Song matching_song = matching_songs.first();
|
const Song matching_song = matching_songs.first();
|
||||||
|
|
||||||
// The song is in the database and still on disk.
|
// The song is in the database and still on disk.
|
||||||
// Check the mtime to see if it's been changed since it was added.
|
// Check the mtime to see if it's been changed since it was added.
|
||||||
QFileInfo fileinfo(file);
|
const QFileInfo fileinfo(file);
|
||||||
|
|
||||||
if (!fileinfo.exists()) {
|
if (!fileinfo.exists()) {
|
||||||
// Partially fixes race condition - if file was removed between being added to the list and now.
|
// Partially fixes race condition - if file was removed between being added to the list and now.
|
||||||
@@ -752,7 +755,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
|
|||||||
|
|
||||||
// The song is in the database and still on disk.
|
// The song is in the database and still on disk.
|
||||||
// Check the mtime to see if it's been changed since it was added.
|
// Check the mtime to see if it's been changed since it was added.
|
||||||
QFileInfo fileinfo(file);
|
const QFileInfo fileinfo(file);
|
||||||
if (!fileinfo.exists()) {
|
if (!fileinfo.exists()) {
|
||||||
// Partially fixes race condition - if file was removed between being added to the list and now.
|
// Partially fixes race condition - if file was removed between being added to the list and now.
|
||||||
files_on_disk.removeAll(file);
|
files_on_disk.removeAll(file);
|
||||||
@@ -763,7 +766,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
|
|||||||
// Make sure the songs aren't deleted, as they still exist elsewhere with a different file path.
|
// Make sure the songs aren't deleted, as they still exist elsewhere with a different file path.
|
||||||
bool matching_songs_has_cue = false;
|
bool matching_songs_has_cue = false;
|
||||||
for (const Song &matching_song : std::as_const(matching_songs)) {
|
for (const Song &matching_song : std::as_const(matching_songs)) {
|
||||||
QString matching_filename = matching_song.url().toLocalFile();
|
const QString matching_filename = matching_song.url().toLocalFile();
|
||||||
if (!t->files_changed_path_.contains(matching_filename)) {
|
if (!t->files_changed_path_.contains(matching_filename)) {
|
||||||
t->files_changed_path_ << matching_filename;
|
t->files_changed_path_ << matching_filename;
|
||||||
qLog(Debug) << matching_filename << "has changed path to" << file;
|
qLog(Debug) << matching_filename << "has changed path to" << file;
|
||||||
@@ -831,10 +834,10 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
|
|||||||
// Add, update or delete subdir
|
// Add, update or delete subdir
|
||||||
CollectionSubdirectory updated_subdir;
|
CollectionSubdirectory updated_subdir;
|
||||||
updated_subdir.directory_id = t->dir_id();
|
updated_subdir.directory_id = t->dir_id();
|
||||||
updated_subdir.mtime = path_info.exists() ? path_info.lastModified().toSecsSinceEpoch() : 0;
|
updated_subdir.mtime = path_mtime;
|
||||||
updated_subdir.path = path;
|
updated_subdir.path = path;
|
||||||
|
|
||||||
if (updated_subdir.mtime == 0 && updated_subdir.path != dir.path) {
|
if (!path_info.exists() && updated_subdir.path != dir.path) {
|
||||||
t->deleted_subdirs << updated_subdir;
|
t->deleted_subdirs << updated_subdir;
|
||||||
}
|
}
|
||||||
else if (subdir.directory_id == -1) {
|
else if (subdir.directory_id == -1) {
|
||||||
|
|||||||
@@ -173,9 +173,12 @@
|
|||||||
#endif
|
#endif
|
||||||
#ifdef HAVE_SPOTIFY
|
#ifdef HAVE_SPOTIFY
|
||||||
# include "spotify/spotifyservice.h"
|
# include "spotify/spotifyservice.h"
|
||||||
|
# include "spotify/spotifymetadatarequest.h"
|
||||||
# include "constants/spotifysettings.h"
|
# include "constants/spotifysettings.h"
|
||||||
#endif
|
#endif
|
||||||
#ifdef HAVE_QOBUZ
|
#ifdef HAVE_QOBUZ
|
||||||
|
# include "qobuz/qobuzservice.h"
|
||||||
|
# include "qobuz/qobuzmetadatarequest.h"
|
||||||
# include "constants/qobuzsettings.h"
|
# include "constants/qobuzsettings.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -379,8 +382,10 @@ MainWindow::MainWindow(Application *app,
|
|||||||
playlist_add_to_another_(nullptr),
|
playlist_add_to_another_(nullptr),
|
||||||
playlistitem_actions_separator_(nullptr),
|
playlistitem_actions_separator_(nullptr),
|
||||||
playlist_rescan_songs_(nullptr),
|
playlist_rescan_songs_(nullptr),
|
||||||
|
playlist_fetch_metadata_(nullptr),
|
||||||
track_position_timer_(new QTimer(this)),
|
track_position_timer_(new QTimer(this)),
|
||||||
track_slider_timer_(new QTimer(this)),
|
track_slider_timer_(new QTimer(this)),
|
||||||
|
metadata_queue_timer_(new QTimer(this)),
|
||||||
keep_running_(false),
|
keep_running_(false),
|
||||||
playing_widget_(true),
|
playing_widget_(true),
|
||||||
#ifdef HAVE_DBUS
|
#ifdef HAVE_DBUS
|
||||||
@@ -452,6 +457,10 @@ MainWindow::MainWindow(Application *app,
|
|||||||
track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs);
|
track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs);
|
||||||
QObject::connect(track_slider_timer_, &QTimer::timeout, this, &MainWindow::UpdateTrackSliderPosition);
|
QObject::connect(track_slider_timer_, &QTimer::timeout, this, &MainWindow::UpdateTrackSliderPosition);
|
||||||
|
|
||||||
|
metadata_queue_timer_->setInterval(200ms); // 200ms between requests to avoid rate limiting
|
||||||
|
metadata_queue_timer_->setSingleShot(true);
|
||||||
|
QObject::connect(metadata_queue_timer_, &QTimer::timeout, this, &MainWindow::ProcessMetadataQueue);
|
||||||
|
|
||||||
// Start initializing the player
|
// Start initializing the player
|
||||||
qLog(Debug) << "Initializing player";
|
qLog(Debug) << "Initializing player";
|
||||||
app_->player()->SetAnalyzer(ui_->analyzer);
|
app_->player()->SetAnalyzer(ui_->analyzer);
|
||||||
@@ -812,6 +821,8 @@ MainWindow::MainWindow(Application *app,
|
|||||||
#endif
|
#endif
|
||||||
playlist_rescan_songs_ = playlist_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Rescan song(s)..."), this, &MainWindow::RescanSongs);
|
playlist_rescan_songs_ = playlist_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Rescan song(s)..."), this, &MainWindow::RescanSongs);
|
||||||
playlist_menu_->addAction(playlist_rescan_songs_);
|
playlist_menu_->addAction(playlist_rescan_songs_);
|
||||||
|
playlist_fetch_metadata_ = playlist_menu_->addAction(IconLoader::Load(u"download"_s), tr("Fetch metadata from service"), this, &MainWindow::FetchStreamingMetadata);
|
||||||
|
playlist_menu_->addAction(playlist_fetch_metadata_);
|
||||||
playlist_menu_->addAction(ui_->action_add_files_to_transcoder);
|
playlist_menu_->addAction(ui_->action_add_files_to_transcoder);
|
||||||
playlist_menu_->addSeparator();
|
playlist_menu_->addSeparator();
|
||||||
playlist_copy_url_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy URL(s)..."), this, &MainWindow::PlaylistCopyUrl);
|
playlist_copy_url_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy URL(s)..."), this, &MainWindow::PlaylistCopyUrl);
|
||||||
@@ -1995,6 +2006,7 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
|
|||||||
int in_skipped = 0;
|
int in_skipped = 0;
|
||||||
int not_in_skipped = 0;
|
int not_in_skipped = 0;
|
||||||
int local_songs = 0;
|
int local_songs = 0;
|
||||||
|
int streaming_songs = 0;
|
||||||
|
|
||||||
for (const QModelIndex &idx : selection) {
|
for (const QModelIndex &idx : selection) {
|
||||||
|
|
||||||
@@ -2004,7 +2016,13 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
|
|||||||
PlaylistItemPtr item = app_->playlist_manager()->current()->item_at(src_idx.row());
|
PlaylistItemPtr item = app_->playlist_manager()->current()->item_at(src_idx.row());
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
|
|
||||||
if (item->EffectiveMetadata().url().isLocalFile()) ++local_songs;
|
if (item->EffectiveMetadata().url().isLocalFile()) {
|
||||||
|
++local_songs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item->EffectiveMetadata().is_stream_service()) {
|
||||||
|
++streaming_songs;
|
||||||
|
}
|
||||||
|
|
||||||
if (item->EffectiveMetadata().has_cue()) {
|
if (item->EffectiveMetadata().has_cue()) {
|
||||||
cue_selected = true;
|
cue_selected = true;
|
||||||
@@ -2032,6 +2050,9 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
|
|||||||
playlist_rescan_songs_->setEnabled(local_songs > 0 && editable > 0);
|
playlist_rescan_songs_->setEnabled(local_songs > 0 && editable > 0);
|
||||||
playlist_rescan_songs_->setVisible(local_songs > 0 && editable > 0);
|
playlist_rescan_songs_->setVisible(local_songs > 0 && editable > 0);
|
||||||
|
|
||||||
|
playlist_fetch_metadata_->setEnabled(streaming_songs > 0);
|
||||||
|
playlist_fetch_metadata_->setVisible(streaming_songs > 0);
|
||||||
|
|
||||||
ui_->action_add_files_to_transcoder->setEnabled(local_songs > 0 && editable > 0);
|
ui_->action_add_files_to_transcoder->setEnabled(local_songs > 0 && editable > 0);
|
||||||
ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0);
|
ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0);
|
||||||
|
|
||||||
@@ -2243,9 +2264,23 @@ void MainWindow::EditTracks() {
|
|||||||
void MainWindow::EditTagDialogAccepted() {
|
void MainWindow::EditTagDialogAccepted() {
|
||||||
|
|
||||||
const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items();
|
const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items();
|
||||||
for (PlaylistItemPtr item : items) {
|
const SongList songs = edit_tag_dialog_->songs();
|
||||||
|
|
||||||
|
if (items.count() != songs.count()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < items.count(); ++i) {
|
||||||
|
PlaylistItemPtr item = items[i];
|
||||||
|
const Song &updated_song = songs[i];
|
||||||
|
// For stream tracks, apply the metadata directly since there's no file to reload from
|
||||||
|
if (updated_song.is_stream_service()) {
|
||||||
|
item->SetOriginalMetadata(updated_song);
|
||||||
|
}
|
||||||
|
else {
|
||||||
item->Reload();
|
item->Reload();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME: This is really lame but we don't know what rows have changed.
|
// FIXME: This is really lame but we don't know what rows have changed.
|
||||||
ui_->playlist->view()->update();
|
ui_->playlist->view()->update();
|
||||||
@@ -2319,8 +2354,8 @@ void MainWindow::SelectionSetValue() {
|
|||||||
QObject::disconnect(*connection);
|
QObject::disconnect(*connection);
|
||||||
}, Qt::QueuedConnection);
|
}, Qt::QueuedConnection);
|
||||||
}
|
}
|
||||||
else if (song.source() == Song::Source::Stream) {
|
else if (song.is_stream()) {
|
||||||
app_->playlist_manager()->current()->setData(source_index, column_value, 0);
|
app_->playlist_manager()->current()->setData(source_index.sibling(source_index.row(), static_cast<int>(column)), column_value, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3404,3 +3439,172 @@ void MainWindow::FocusSearchField() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::FetchStreamingMetadata() {
|
||||||
|
|
||||||
|
const QModelIndexList proxy_indexes = ui_->playlist->view()->selectionModel()->selectedRows();
|
||||||
|
for (const QModelIndex &proxy_index : proxy_indexes) {
|
||||||
|
const QModelIndex source_index = app_->playlist_manager()->current()->filter()->mapToSource(proxy_index);
|
||||||
|
if (!source_index.isValid()) continue;
|
||||||
|
PlaylistItemPtr item(app_->playlist_manager()->current()->item_at(source_index.row()));
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
const Song &song = item->EffectiveMetadata();
|
||||||
|
const QPersistentModelIndex persistent_index = QPersistentModelIndex(source_index);
|
||||||
|
QString track_id;
|
||||||
|
|
||||||
|
#ifdef HAVE_QOBUZ
|
||||||
|
if (song.source() == Song::Source::Qobuz) {
|
||||||
|
track_id = song.song_id();
|
||||||
|
// song_id() may be empty if not persisted, fall back to URL path
|
||||||
|
if (track_id.isEmpty()) {
|
||||||
|
track_id = song.url().path();
|
||||||
|
}
|
||||||
|
if (track_id.isEmpty()) {
|
||||||
|
qLog(Error) << "Failed to fetch Qobuz metadata: No track ID";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef HAVE_SPOTIFY
|
||||||
|
if (song.source() == Song::Source::Spotify) {
|
||||||
|
track_id = song.song_id();
|
||||||
|
// song_id() may be empty if not persisted, fall back to parsing URL
|
||||||
|
if (track_id.isEmpty() && song.url().scheme() == "spotify"_L1 && song.url().path().startsWith(u"track:"_s)) {
|
||||||
|
track_id = song.url().path().mid(6);
|
||||||
|
}
|
||||||
|
if (track_id.isEmpty()) {
|
||||||
|
qLog(Error) << "Failed to fetch Spotify metadata: No track ID";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (!track_id.isEmpty()) {
|
||||||
|
metadata_queue_.append({song.source(), track_id, persistent_index});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start processing the queue if it's not already running
|
||||||
|
if (!metadata_queue_.isEmpty() && !metadata_queue_timer_->isActive()) {
|
||||||
|
ProcessMetadataQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::ProcessMetadataQueue() {
|
||||||
|
|
||||||
|
if (metadata_queue_.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetadataQueueEntry metadata_queue_entry = metadata_queue_.takeFirst();
|
||||||
|
|
||||||
|
#ifdef HAVE_QOBUZ
|
||||||
|
if (metadata_queue_entry.source == Song::Source::Qobuz) {
|
||||||
|
if (QobuzServicePtr qobuz_service = app_->streaming_services()->Service<QobuzService>()) {
|
||||||
|
QobuzMetadataRequest *request = new QobuzMetadataRequest(&*qobuz_service, qobuz_service->network());
|
||||||
|
QObject::connect(request, &QobuzMetadataRequest::MetadataReceived, this, [this, metadata_queue_entry, request](const QString &received_track_id, const Song &fetched_song) {
|
||||||
|
Q_UNUSED(received_track_id);
|
||||||
|
if (metadata_queue_entry.persistent_index.isValid() && fetched_song.is_valid()) {
|
||||||
|
PlaylistItemPtr playlist_item = app_->playlist_manager()->current()->item_at(metadata_queue_entry.persistent_index.row());
|
||||||
|
if (playlist_item) {
|
||||||
|
const Song old_song = playlist_item->OriginalMetadata();
|
||||||
|
Song updated_song = old_song;
|
||||||
|
// Update all metadata fields from the fetched song
|
||||||
|
if (!fetched_song.title().isEmpty()) updated_song.set_title(fetched_song.title());
|
||||||
|
if (!fetched_song.artist().isEmpty()) updated_song.set_artist(fetched_song.artist());
|
||||||
|
if (!fetched_song.album().isEmpty()) updated_song.set_album(fetched_song.album());
|
||||||
|
if (!fetched_song.albumartist().isEmpty()) updated_song.set_albumartist(fetched_song.albumartist());
|
||||||
|
if (!fetched_song.genre().isEmpty()) updated_song.set_genre(fetched_song.genre());
|
||||||
|
if (!fetched_song.composer().isEmpty()) updated_song.set_composer(fetched_song.composer());
|
||||||
|
if (!fetched_song.performer().isEmpty()) updated_song.set_performer(fetched_song.performer());
|
||||||
|
if (!fetched_song.comment().isEmpty()) updated_song.set_comment(fetched_song.comment());
|
||||||
|
if (fetched_song.track() > 0) updated_song.set_track(fetched_song.track());
|
||||||
|
if (fetched_song.disc() > 0) updated_song.set_disc(fetched_song.disc());
|
||||||
|
if (fetched_song.year() > 0) updated_song.set_year(fetched_song.year());
|
||||||
|
if (fetched_song.length_nanosec() > 0) updated_song.set_length_nanosec(fetched_song.length_nanosec());
|
||||||
|
if (fetched_song.art_automatic().isValid()) updated_song.set_art_automatic(fetched_song.art_automatic());
|
||||||
|
playlist_item->SetOriginalMetadata(updated_song);
|
||||||
|
app_->playlist_manager()->current()->ItemReload(metadata_queue_entry.persistent_index, old_song, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request->deleteLater();
|
||||||
|
// Process next item in queue
|
||||||
|
if (!metadata_queue_.isEmpty()) {
|
||||||
|
metadata_queue_timer_->start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
QObject::connect(request, &QobuzMetadataRequest::MetadataFailure, this, [this, request](const QString &failed_track_id, const QString &error) {
|
||||||
|
Q_UNUSED(failed_track_id);
|
||||||
|
qLog(Error) << "Failed to fetch Qobuz metadata:" << error;
|
||||||
|
request->deleteLater();
|
||||||
|
// Process next item in queue
|
||||||
|
if (!metadata_queue_.isEmpty()) {
|
||||||
|
metadata_queue_timer_->start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
request->FetchTrackMetadata(metadata_queue_entry.track_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef HAVE_SPOTIFY
|
||||||
|
if (metadata_queue_entry.source == Song::Source::Spotify) {
|
||||||
|
if (SpotifyServicePtr spotify_service = app_->streaming_services()->Service<SpotifyService>()) {
|
||||||
|
SpotifyMetadataRequest *request = new SpotifyMetadataRequest(&*spotify_service, app_->network());
|
||||||
|
QObject::connect(request, &SpotifyMetadataRequest::MetadataReceived, this, [this, metadata_queue_entry, request](const QString &received_track_id, const Song &fetched_song) {
|
||||||
|
Q_UNUSED(received_track_id);
|
||||||
|
if (metadata_queue_entry.persistent_index.isValid() && fetched_song.is_valid()) {
|
||||||
|
PlaylistItemPtr playlist_item = app_->playlist_manager()->current()->item_at(metadata_queue_entry.persistent_index.row());
|
||||||
|
if (playlist_item) {
|
||||||
|
const Song old_song = playlist_item->OriginalMetadata();
|
||||||
|
Song updated_song = old_song;
|
||||||
|
// Update all metadata fields from the fetched song
|
||||||
|
if (!fetched_song.title().isEmpty()) updated_song.set_title(fetched_song.title());
|
||||||
|
if (!fetched_song.artist().isEmpty()) updated_song.set_artist(fetched_song.artist());
|
||||||
|
if (!fetched_song.album().isEmpty()) updated_song.set_album(fetched_song.album());
|
||||||
|
if (!fetched_song.albumartist().isEmpty()) updated_song.set_albumartist(fetched_song.albumartist());
|
||||||
|
if (!fetched_song.genre().isEmpty()) updated_song.set_genre(fetched_song.genre());
|
||||||
|
if (!fetched_song.composer().isEmpty()) updated_song.set_composer(fetched_song.composer());
|
||||||
|
if (!fetched_song.performer().isEmpty()) updated_song.set_performer(fetched_song.performer());
|
||||||
|
if (!fetched_song.comment().isEmpty()) updated_song.set_comment(fetched_song.comment());
|
||||||
|
if (fetched_song.track() > 0) updated_song.set_track(fetched_song.track());
|
||||||
|
if (fetched_song.disc() > 0) updated_song.set_disc(fetched_song.disc());
|
||||||
|
if (fetched_song.year() > 0) updated_song.set_year(fetched_song.year());
|
||||||
|
if (fetched_song.length_nanosec() > 0) updated_song.set_length_nanosec(fetched_song.length_nanosec());
|
||||||
|
if (fetched_song.art_automatic().isValid()) updated_song.set_art_automatic(fetched_song.art_automatic());
|
||||||
|
playlist_item->SetOriginalMetadata(updated_song);
|
||||||
|
app_->playlist_manager()->current()->ItemReload(metadata_queue_entry.persistent_index, old_song, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request->deleteLater();
|
||||||
|
// Process next item in queue
|
||||||
|
if (!metadata_queue_.isEmpty()) {
|
||||||
|
metadata_queue_timer_->start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
QObject::connect(request, &SpotifyMetadataRequest::MetadataFailure, this, [this, request](const QString &failed_track_id, const QString &error) {
|
||||||
|
Q_UNUSED(failed_track_id);
|
||||||
|
qLog(Error) << "Failed to fetch Spotify metadata:" << error;
|
||||||
|
request->deleteLater();
|
||||||
|
// Process next item in queue
|
||||||
|
if (!metadata_queue_.isEmpty()) {
|
||||||
|
metadata_queue_timer_->start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
request->FetchTrackMetadata(metadata_queue_entry.track_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// If we get here, the source wasn't handled - try the next item
|
||||||
|
if (!metadata_queue_.isEmpty()) {
|
||||||
|
metadata_queue_timer_->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,6 +276,9 @@ class MainWindow : public QMainWindow, public PlatformInterface {
|
|||||||
|
|
||||||
void DeleteFilesFinished(const SongList &songs_with_errors);
|
void DeleteFilesFinished(const SongList &songs_with_errors);
|
||||||
|
|
||||||
|
void FetchStreamingMetadata();
|
||||||
|
void ProcessMetadataQueue();
|
||||||
|
|
||||||
public Q_SLOTS:
|
public Q_SLOTS:
|
||||||
void CommandlineOptionsReceived(const QByteArray &string_options);
|
void CommandlineOptionsReceived(const QByteArray &string_options);
|
||||||
void Raise();
|
void Raise();
|
||||||
@@ -379,11 +382,13 @@ class MainWindow : public QMainWindow, public PlatformInterface {
|
|||||||
QList<QAction*> playlistitem_actions_;
|
QList<QAction*> playlistitem_actions_;
|
||||||
QAction *playlistitem_actions_separator_;
|
QAction *playlistitem_actions_separator_;
|
||||||
QAction *playlist_rescan_songs_;
|
QAction *playlist_rescan_songs_;
|
||||||
|
QAction *playlist_fetch_metadata_;
|
||||||
|
|
||||||
QModelIndex playlist_menu_index_;
|
QModelIndex playlist_menu_index_;
|
||||||
|
|
||||||
QTimer *track_position_timer_;
|
QTimer *track_position_timer_;
|
||||||
QTimer *track_slider_timer_;
|
QTimer *track_slider_timer_;
|
||||||
|
QTimer *metadata_queue_timer_;
|
||||||
|
|
||||||
bool keep_running_;
|
bool keep_running_;
|
||||||
bool playing_widget_;
|
bool playing_widget_;
|
||||||
@@ -407,6 +412,14 @@ class MainWindow : public QMainWindow, public PlatformInterface {
|
|||||||
bool playlists_loaded_;
|
bool playlists_loaded_;
|
||||||
bool delete_files_;
|
bool delete_files_;
|
||||||
std::optional<CommandlineOptions> options_;
|
std::optional<CommandlineOptions> options_;
|
||||||
|
|
||||||
|
class MetadataQueueEntry {
|
||||||
|
public:
|
||||||
|
Song::Source source;
|
||||||
|
QString track_id;
|
||||||
|
QPersistentModelIndex persistent_index;
|
||||||
|
};
|
||||||
|
QList<MetadataQueueEntry> metadata_queue_;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // MAINWINDOW_H
|
#endif // MAINWINDOW_H
|
||||||
|
|||||||
@@ -34,6 +34,13 @@
|
|||||||
|
|
||||||
#include "mergedproxymodel.h"
|
#include "mergedproxymodel.h"
|
||||||
|
|
||||||
|
#ifdef __GNUC__
|
||||||
|
#pragma GCC diagnostic push
|
||||||
|
#if __GNUC__ >= 16
|
||||||
|
#pragma GCC diagnostic ignored "-Wstringop-overflow"
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
#include <boost/multi_index/detail/bidir_node_iterator.hpp>
|
#include <boost/multi_index/detail/bidir_node_iterator.hpp>
|
||||||
#include <boost/multi_index/detail/hash_index_iterator.hpp>
|
#include <boost/multi_index/detail/hash_index_iterator.hpp>
|
||||||
#include <boost/multi_index/hashed_index.hpp>
|
#include <boost/multi_index/hashed_index.hpp>
|
||||||
@@ -45,6 +52,10 @@
|
|||||||
#include <boost/multi_index_container.hpp>
|
#include <boost/multi_index_container.hpp>
|
||||||
#include <boost/operators.hpp>
|
#include <boost/operators.hpp>
|
||||||
|
|
||||||
|
#ifdef __GNUC__
|
||||||
|
#pragma GCC diagnostic pop
|
||||||
|
#endif
|
||||||
|
|
||||||
using boost::multi_index::hashed_unique;
|
using boost::multi_index::hashed_unique;
|
||||||
using boost::multi_index::identity;
|
using boost::multi_index::identity;
|
||||||
using boost::multi_index::indexed_by;
|
using boost::multi_index::indexed_by;
|
||||||
|
|||||||
@@ -686,8 +686,9 @@ const QString &Song::playlist_effective_albumartistsort() const { return is_comp
|
|||||||
bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); }
|
bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); }
|
||||||
bool Song::is_local_collection_song() const { return d->source_ == Source::Collection; }
|
bool Song::is_local_collection_song() const { return d->source_ == Source::Collection; }
|
||||||
bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); }
|
bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); }
|
||||||
bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
|
|
||||||
bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; }
|
bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; }
|
||||||
|
bool Song::is_stream_service() const { return d->source_ == Source::Subsonic || d->source_ == Source::Tidal || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
|
||||||
|
bool Song::is_stream() const { return is_radio() || is_stream_service(); }
|
||||||
bool Song::is_cdda() const { return d->source_ == Source::CDDA; }
|
bool Song::is_cdda() const { return d->source_ == Source::CDDA; }
|
||||||
bool Song::is_compilation() const { return (d->compilation_ || d->compilation_detected_ || d->compilation_on_) && !d->compilation_off_; }
|
bool Song::is_compilation() const { return (d->compilation_ || d->compilation_detected_ || d->compilation_on_) && !d->compilation_off_; }
|
||||||
bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz; }
|
bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz; }
|
||||||
@@ -956,7 +957,7 @@ QString Song::PrettyRating() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Song::IsEditable() const {
|
bool Song::IsEditable() const {
|
||||||
return d->valid_ && d->url_.isValid() && ((d->url_.isLocalFile() && write_tags_supported() && !has_cue()) || d->source_ == Source::Stream);
|
return d->valid_ && d->url_.isValid() && ((d->url_.isLocalFile() && write_tags_supported() && !has_cue()) || is_stream());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Song::IsFileInfoEqual(const Song &other) const {
|
bool Song::IsFileInfoEqual(const Song &other) const {
|
||||||
|
|||||||
@@ -407,8 +407,9 @@ class Song {
|
|||||||
bool is_metadata_good() const;
|
bool is_metadata_good() const;
|
||||||
bool is_local_collection_song() const;
|
bool is_local_collection_song() const;
|
||||||
bool is_linked_collection_song() const;
|
bool is_linked_collection_song() const;
|
||||||
bool is_stream() const;
|
|
||||||
bool is_radio() const;
|
bool is_radio() const;
|
||||||
|
bool is_stream_service() const;
|
||||||
|
bool is_stream() const;
|
||||||
bool is_cdda() const;
|
bool is_cdda() const;
|
||||||
bool is_compilation() const;
|
bool is_compilation() const;
|
||||||
bool stream_url_can_expire() const;
|
bool stream_url_can_expire() const;
|
||||||
|
|||||||
@@ -411,6 +411,17 @@ bool EditTagDialog::eventFilter(QObject *o, QEvent *e) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SongList EditTagDialog::songs() const {
|
||||||
|
|
||||||
|
SongList result;
|
||||||
|
for (const Data &d : data_) {
|
||||||
|
result << d.current_;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
bool EditTagDialog::SetLoading(const QString &message) {
|
bool EditTagDialog::SetLoading(const QString &message) {
|
||||||
|
|
||||||
const bool loading = !message.isEmpty();
|
const bool loading = !message.isEmpty();
|
||||||
@@ -1399,6 +1410,12 @@ void EditTagDialog::SaveData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (save_tags || save_playcount || save_rating || save_embedded_cover) {
|
if (save_tags || save_playcount || save_rating || save_embedded_cover) {
|
||||||
|
// For streaming tracks, skip tag writing since there's no local file.
|
||||||
|
// The metadata will be applied directly to the playlist item in MainWindow::EditTagDialogAccepted.
|
||||||
|
if (ref.current_.is_stream()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Not to confuse the collection model.
|
// Not to confuse the collection model.
|
||||||
if (ref.current_.track() <= 0) { ref.current_.set_track(-1); }
|
if (ref.current_.track() <= 0) { ref.current_.set_track(-1); }
|
||||||
if (ref.current_.disc() <= 0) { ref.current_.set_disc(-1); }
|
if (ref.current_.disc() <= 0) { ref.current_.set_disc(-1); }
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class EditTagDialog : public QDialog {
|
|||||||
void SetSongs(const SongList &songs, const PlaylistItemPtrList &items = PlaylistItemPtrList());
|
void SetSongs(const SongList &songs, const PlaylistItemPtrList &items = PlaylistItemPtrList());
|
||||||
|
|
||||||
PlaylistItemPtrList playlist_items() const { return playlist_items_; }
|
PlaylistItemPtrList playlist_items() const { return playlist_items_; }
|
||||||
|
SongList songs() const;
|
||||||
void accept() override;
|
void accept() override;
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
|
|||||||
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
|
* Strawberry Music Player
|
||||||
* This file was part of Clementine.
|
* This file was part of Clementine.
|
||||||
* Copyright 2012, David Sansome <me@davidsansome.com>
|
* Copyright 2012, David Sansome <me@davidsansome.com>
|
||||||
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
|
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
|
||||||
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
|
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
|
||||||
*
|
*
|
||||||
* Strawberry is free software: you can redistribute it and/or modify
|
* Strawberry is free software: you can redistribute it and/or modify
|
||||||
@@ -21,8 +21,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QMap>
|
||||||
|
|
||||||
#include "constants/timeconstants.h"
|
#include "constants/timeconstants.h"
|
||||||
|
#include "core/song.h"
|
||||||
#include "filterparser.h"
|
#include "filterparser.h"
|
||||||
#include "filtertreenop.h"
|
#include "filtertreenop.h"
|
||||||
#include "filtertreeand.h"
|
#include "filtertreeand.h"
|
||||||
@@ -31,9 +33,126 @@
|
|||||||
#include "filtertreeterm.h"
|
#include "filtertreeterm.h"
|
||||||
#include "filtertreecolumnterm.h"
|
#include "filtertreecolumnterm.h"
|
||||||
#include "filterparsersearchcomparators.h"
|
#include "filterparsersearchcomparators.h"
|
||||||
|
#include "filtercolumn.h"
|
||||||
|
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
enum class FilterOperator {
|
||||||
|
None,
|
||||||
|
Eq,
|
||||||
|
Ne,
|
||||||
|
Gt,
|
||||||
|
Ge,
|
||||||
|
Lt,
|
||||||
|
Le
|
||||||
|
};
|
||||||
|
|
||||||
|
const QMap<QString, FilterOperator> &GetFilterOperatorsMap() {
|
||||||
|
|
||||||
|
static const QMap<QString, FilterOperator> filter_operators_map_ = []() {
|
||||||
|
QMap<QString, FilterOperator> filter_operators_map;
|
||||||
|
filter_operators_map.insert(u"="_s, FilterOperator::Eq);
|
||||||
|
filter_operators_map.insert(u"=="_s, FilterOperator::Eq);
|
||||||
|
filter_operators_map.insert(u"!="_s, FilterOperator::Ne);
|
||||||
|
filter_operators_map.insert(u"<>"_s, FilterOperator::Ne);
|
||||||
|
filter_operators_map.insert(u">"_s, FilterOperator::Gt);
|
||||||
|
filter_operators_map.insert(u">="_s, FilterOperator::Ge);
|
||||||
|
filter_operators_map.insert(u"<"_s, FilterOperator::Lt);
|
||||||
|
filter_operators_map.insert(u"<="_s, FilterOperator::Le);
|
||||||
|
return filter_operators_map;
|
||||||
|
}();
|
||||||
|
|
||||||
|
return filter_operators_map_;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ColumnType {
|
||||||
|
Unknown,
|
||||||
|
Text,
|
||||||
|
Int,
|
||||||
|
UInt,
|
||||||
|
Int64,
|
||||||
|
Float
|
||||||
|
};
|
||||||
|
|
||||||
|
const QMap<QString, FilterColumn> &GetFilterColumnsMap() {
|
||||||
|
|
||||||
|
static const QMap<QString, FilterColumn> filter_columns_map_ = []() {
|
||||||
|
QMap<QString, FilterColumn> filter_columns_map;
|
||||||
|
filter_columns_map.insert(u"albumartist"_s, FilterColumn::AlbumArtist);
|
||||||
|
filter_columns_map.insert(u"albumartistsort"_s, FilterColumn::AlbumArtistSort);
|
||||||
|
filter_columns_map.insert(u"artist"_s, FilterColumn::Artist);
|
||||||
|
filter_columns_map.insert(u"artistsort"_s, FilterColumn::ArtistSort);
|
||||||
|
filter_columns_map.insert(u"album"_s, FilterColumn::Album);
|
||||||
|
filter_columns_map.insert(u"albumsort"_s, FilterColumn::AlbumSort);
|
||||||
|
filter_columns_map.insert(u"title"_s, FilterColumn::Title);
|
||||||
|
filter_columns_map.insert(u"titlesort"_s, FilterColumn::TitleSort);
|
||||||
|
filter_columns_map.insert(u"composer"_s, FilterColumn::Composer);
|
||||||
|
filter_columns_map.insert(u"composersort"_s, FilterColumn::ComposerSort);
|
||||||
|
filter_columns_map.insert(u"performer"_s, FilterColumn::Performer);
|
||||||
|
filter_columns_map.insert(u"performersort"_s, FilterColumn::PerformerSort);
|
||||||
|
filter_columns_map.insert(u"grouping"_s, FilterColumn::Grouping);
|
||||||
|
filter_columns_map.insert(u"genre"_s, FilterColumn::Genre);
|
||||||
|
filter_columns_map.insert(u"comment"_s, FilterColumn::Comment);
|
||||||
|
filter_columns_map.insert(u"filename"_s, FilterColumn::Filename);
|
||||||
|
filter_columns_map.insert(u"url"_s, FilterColumn::URL);
|
||||||
|
filter_columns_map.insert(u"track"_s, FilterColumn::Track);
|
||||||
|
filter_columns_map.insert(u"year"_s, FilterColumn::Year);
|
||||||
|
filter_columns_map.insert(u"samplerate"_s, FilterColumn::Samplerate);
|
||||||
|
filter_columns_map.insert(u"bitdepth"_s, FilterColumn::Bitdepth);
|
||||||
|
filter_columns_map.insert(u"bitrate"_s, FilterColumn::Bitrate);
|
||||||
|
filter_columns_map.insert(u"playcount"_s, FilterColumn::Playcount);
|
||||||
|
filter_columns_map.insert(u"skipcount"_s, FilterColumn::Skipcount);
|
||||||
|
filter_columns_map.insert(u"length"_s, FilterColumn::Length);
|
||||||
|
filter_columns_map.insert(u"rating"_s, FilterColumn::Rating);
|
||||||
|
return filter_columns_map;
|
||||||
|
}();
|
||||||
|
|
||||||
|
return filter_columns_map_;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const QMap<FilterColumn, ColumnType> &GetColumnTypesMap() {
|
||||||
|
|
||||||
|
static const QMap<FilterColumn, ColumnType> column_types_map_ = []() {
|
||||||
|
QMap<FilterColumn, ColumnType> column_types_map;
|
||||||
|
column_types_map.insert(FilterColumn::AlbumArtist, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::AlbumArtistSort, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Artist, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::ArtistSort, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Album, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::AlbumSort, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Title, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::TitleSort, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Composer, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::ComposerSort, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Performer, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::PerformerSort, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Grouping, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Genre, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Comment, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Filename, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::URL, ColumnType::Text);
|
||||||
|
column_types_map.insert(FilterColumn::Track, ColumnType::Int);
|
||||||
|
column_types_map.insert(FilterColumn::Year, ColumnType::Int);
|
||||||
|
column_types_map.insert(FilterColumn::Samplerate, ColumnType::Int);
|
||||||
|
column_types_map.insert(FilterColumn::Bitdepth, ColumnType::Int);
|
||||||
|
column_types_map.insert(FilterColumn::Bitrate, ColumnType::Int);
|
||||||
|
column_types_map.insert(FilterColumn::Playcount, ColumnType::UInt);
|
||||||
|
column_types_map.insert(FilterColumn::Skipcount, ColumnType::UInt);
|
||||||
|
column_types_map.insert(FilterColumn::Length, ColumnType::Int64);
|
||||||
|
column_types_map.insert(FilterColumn::Rating, ColumnType::Float);
|
||||||
|
return column_types_map;
|
||||||
|
}();
|
||||||
|
|
||||||
|
return column_types_map_;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
FilterParser::FilterParser(const QString &filter_string) : filter_string_(filter_string), iter_{}, end_{} {}
|
FilterParser::FilterParser(const QString &filter_string) : filter_string_(filter_string), iter_{}, end_{} {}
|
||||||
|
|
||||||
FilterTree *FilterParser::parse() {
|
FilterTree *FilterParser::parse() {
|
||||||
@@ -119,7 +238,7 @@ bool FilterParser::checkAnd() {
|
|||||||
bool FilterParser::checkOr(const bool step_over) {
|
bool FilterParser::checkOr(const bool step_over) {
|
||||||
|
|
||||||
if (!buf_.isEmpty()) {
|
if (!buf_.isEmpty()) {
|
||||||
if (buf_ == "OR"_L1) {
|
if (buf_.size() == 2 && buf_[0] == u'O' && buf_[1] == u'R') {
|
||||||
if (step_over) {
|
if (step_over) {
|
||||||
buf_.clear();
|
buf_.clear();
|
||||||
advance();
|
advance();
|
||||||
@@ -141,7 +260,8 @@ bool FilterParser::checkOr(const bool step_over) {
|
|||||||
advance();
|
advance();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
buf_ += "OR"_L1;
|
buf_ += u'O';
|
||||||
|
buf_ += u'R';
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -191,6 +311,8 @@ FilterTree *FilterParser::parseSearchTerm() {
|
|||||||
bool in_quotes = false;
|
bool in_quotes = false;
|
||||||
bool previous_char_operator = false;
|
bool previous_char_operator = false;
|
||||||
|
|
||||||
|
buf_.reserve(32);
|
||||||
|
|
||||||
for (; iter_ != end_; ++iter_) {
|
for (; iter_ != end_; ++iter_) {
|
||||||
if (previous_char_operator) {
|
if (previous_char_operator) {
|
||||||
if (iter_->isSpace()) {
|
if (iter_->isSpace()) {
|
||||||
@@ -225,7 +347,7 @@ FilterTree *FilterParser::parseSearchTerm() {
|
|||||||
prefix += *iter_;
|
prefix += *iter_;
|
||||||
previous_char_operator = true;
|
previous_char_operator = true;
|
||||||
}
|
}
|
||||||
else if (prefix != u'=' && *iter_ == u'=') {
|
else if (prefix.size() == 1 && prefix[0] != u'=' && *iter_ == u'=') {
|
||||||
prefix += *iter_;
|
prefix += *iter_;
|
||||||
previous_char_operator = true;
|
previous_char_operator = true;
|
||||||
}
|
}
|
||||||
@@ -252,132 +374,145 @@ FilterTree *FilterParser::createSearchTermTreeNode(const QString &column, const
|
|||||||
return new FilterTreeNop;
|
return new FilterTreeNop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FilterColumn filter_column = FilterColumn::Unknown;
|
||||||
FilterParserSearchTermComparator *cmp = nullptr;
|
FilterParserSearchTermComparator *cmp = nullptr;
|
||||||
|
|
||||||
if (!column.isEmpty()) {
|
if (!column.isEmpty()) {
|
||||||
if (Song::kTextSearchColumns.contains(column, Qt::CaseInsensitive)) {
|
filter_column = GetFilterColumnsMap().value(column, FilterColumn::Unknown);
|
||||||
if (prefix == u'=' || prefix == "=="_L1) {
|
const ColumnType column_type = GetColumnTypesMap().value(filter_column, ColumnType::Unknown);
|
||||||
|
const FilterOperator filter_operator = GetFilterOperatorsMap().value(prefix, FilterOperator::None);
|
||||||
|
switch (column_type) {
|
||||||
|
case ColumnType::Text:{
|
||||||
|
switch (filter_operator) {
|
||||||
|
case FilterOperator::Eq:
|
||||||
cmp = new FilterParserTextEqComparator(value);
|
cmp = new FilterParserTextEqComparator(value);
|
||||||
}
|
break;
|
||||||
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
|
case FilterOperator::Ne:
|
||||||
cmp = new FilterParserTextNeComparator(value);
|
cmp = new FilterParserTextNeComparator(value);
|
||||||
}
|
break;
|
||||||
else {
|
default:
|
||||||
cmp = new FilterParserTextContainsComparator(value);
|
cmp = new FilterParserTextContainsComparator(value);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else if (Song::kIntSearchColumns.contains(column, Qt::CaseInsensitive)) {
|
case ColumnType::Int:{
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
int number = value.toInt(&ok);
|
const int number = value.toInt(&ok);
|
||||||
if (ok) {
|
if (!ok) break;
|
||||||
if (prefix == u'=' || prefix == "=="_L1) {
|
switch (filter_operator) {
|
||||||
|
case FilterOperator::None:
|
||||||
|
case FilterOperator::Eq:
|
||||||
cmp = new FilterParserIntEqComparator(number);
|
cmp = new FilterParserIntEqComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
|
case FilterOperator::Ne:
|
||||||
cmp = new FilterParserIntNeComparator(number);
|
cmp = new FilterParserIntNeComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == u'>') {
|
case FilterOperator::Gt:
|
||||||
cmp = new FilterParserIntGtComparator(number);
|
cmp = new FilterParserIntGtComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == ">="_L1) {
|
case FilterOperator::Ge:
|
||||||
cmp = new FilterParserIntGeComparator(number);
|
cmp = new FilterParserIntGeComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == u'<') {
|
case FilterOperator::Lt:
|
||||||
cmp = new FilterParserIntLtComparator(number);
|
cmp = new FilterParserIntLtComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == "<="_L1) {
|
case FilterOperator::Le:
|
||||||
cmp = new FilterParserIntLeComparator(number);
|
cmp = new FilterParserIntLeComparator(number);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else {
|
break;
|
||||||
cmp = new FilterParserIntEqComparator(number);
|
|
||||||
}
|
}
|
||||||
}
|
case ColumnType::UInt:{
|
||||||
}
|
|
||||||
else if (Song::kUIntSearchColumns.contains(column, Qt::CaseInsensitive)) {
|
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
uint number = value.toUInt(&ok);
|
const uint number = value.toUInt(&ok);
|
||||||
if (ok) {
|
if (!ok) break;
|
||||||
if (prefix == u'=' || prefix == "=="_L1) {
|
switch (filter_operator) {
|
||||||
|
case FilterOperator::None:
|
||||||
|
case FilterOperator::Eq:
|
||||||
cmp = new FilterParserUIntEqComparator(number);
|
cmp = new FilterParserUIntEqComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
|
case FilterOperator::Ne:
|
||||||
cmp = new FilterParserUIntNeComparator(number);
|
cmp = new FilterParserUIntNeComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == u'>') {
|
case FilterOperator::Gt:
|
||||||
cmp = new FilterParserUIntGtComparator(number);
|
cmp = new FilterParserUIntGtComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == ">="_L1) {
|
case FilterOperator::Ge:
|
||||||
cmp = new FilterParserUIntGeComparator(number);
|
cmp = new FilterParserUIntGeComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == u'<') {
|
case FilterOperator::Lt:
|
||||||
cmp = new FilterParserUIntLtComparator(number);
|
cmp = new FilterParserUIntLtComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == "<="_L1) {
|
case FilterOperator::Le:
|
||||||
cmp = new FilterParserUIntLeComparator(number);
|
cmp = new FilterParserUIntLeComparator(number);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else {
|
break;
|
||||||
cmp = new FilterParserUIntEqComparator(number);
|
|
||||||
}
|
}
|
||||||
}
|
case ColumnType::Int64:{
|
||||||
}
|
|
||||||
else if (Song::kInt64SearchColumns.contains(column, Qt::CaseInsensitive)) {
|
|
||||||
qint64 number = 0;
|
qint64 number = 0;
|
||||||
if (column == "length"_L1) {
|
if (filter_column == FilterColumn::Length) {
|
||||||
number = ParseTime(value) * kNsecPerSec;
|
number = ParseTime(value) * kNsecPerSec;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
number = value.toLongLong();
|
number = value.toLongLong();
|
||||||
}
|
}
|
||||||
if (prefix == u'=' || prefix == "=="_L1) {
|
switch (filter_operator) {
|
||||||
|
case FilterOperator::None:
|
||||||
|
case FilterOperator::Eq:
|
||||||
cmp = new FilterParserInt64EqComparator(number);
|
cmp = new FilterParserInt64EqComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
|
case FilterOperator::Ne:
|
||||||
cmp = new FilterParserInt64NeComparator(number);
|
cmp = new FilterParserInt64NeComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == u'>') {
|
case FilterOperator::Gt:
|
||||||
cmp = new FilterParserInt64GtComparator(number);
|
cmp = new FilterParserInt64GtComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == ">="_L1) {
|
case FilterOperator::Ge:
|
||||||
cmp = new FilterParserInt64GeComparator(number);
|
cmp = new FilterParserInt64GeComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == u'<') {
|
case FilterOperator::Lt:
|
||||||
cmp = new FilterParserInt64LtComparator(number);
|
cmp = new FilterParserInt64LtComparator(number);
|
||||||
}
|
break;
|
||||||
else if (prefix == "<="_L1) {
|
case FilterOperator::Le:
|
||||||
cmp = new FilterParserInt64LeComparator(number);
|
cmp = new FilterParserInt64LeComparator(number);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else {
|
break;
|
||||||
cmp = new FilterParserInt64EqComparator(number);
|
|
||||||
}
|
}
|
||||||
}
|
case ColumnType::Float:{
|
||||||
else if (Song::kFloatSearchColumns.contains(column, Qt::CaseInsensitive)) {
|
|
||||||
const float rating = ParseRating(value);
|
const float rating = ParseRating(value);
|
||||||
if (prefix == u'=' || prefix == "=="_L1) {
|
switch (filter_operator) {
|
||||||
|
case FilterOperator::None:
|
||||||
|
case FilterOperator::Eq:
|
||||||
cmp = new FilterParserFloatEqComparator(rating);
|
cmp = new FilterParserFloatEqComparator(rating);
|
||||||
}
|
break;
|
||||||
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
|
case FilterOperator::Ne:
|
||||||
cmp = new FilterParserFloatNeComparator(rating);
|
cmp = new FilterParserFloatNeComparator(rating);
|
||||||
}
|
break;
|
||||||
else if (prefix == u'>') {
|
case FilterOperator::Gt:
|
||||||
cmp = new FilterParserFloatGtComparator(rating);
|
cmp = new FilterParserFloatGtComparator(rating);
|
||||||
}
|
break;
|
||||||
else if (prefix == ">="_L1) {
|
case FilterOperator::Ge:
|
||||||
cmp = new FilterParserFloatGeComparator(rating);
|
cmp = new FilterParserFloatGeComparator(rating);
|
||||||
}
|
break;
|
||||||
else if (prefix == u'<') {
|
case FilterOperator::Lt:
|
||||||
cmp = new FilterParserFloatLtComparator(rating);
|
cmp = new FilterParserFloatLtComparator(rating);
|
||||||
}
|
break;
|
||||||
else if (prefix == "<="_L1) {
|
case FilterOperator::Le:
|
||||||
cmp = new FilterParserFloatLeComparator(rating);
|
cmp = new FilterParserFloatLeComparator(rating);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
else {
|
break;
|
||||||
cmp = new FilterParserFloatEqComparator(rating);
|
|
||||||
}
|
}
|
||||||
|
case ColumnType::Unknown:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmp) {
|
if (filter_column != FilterColumn::Unknown && cmp != nullptr) {
|
||||||
return new FilterTreeColumnTerm(column, cmp);
|
return new FilterTreeColumnTerm(filter_column, cmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new FilterTreeTerm(new FilterParserTextContainsComparator(value));
|
return new FilterTreeTerm(new FilterParserTextContainsComparator(value));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Strawberry Music Player
|
* Strawberry Music Player
|
||||||
* This file was part of Clementine.
|
* This file was part of Clementine.
|
||||||
* Copyright 2012, David Sansome <me@davidsansome.com>
|
* Copyright 2012, David Sansome <me@davidsansome.com>
|
||||||
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
|
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
|
||||||
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
|
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
|
||||||
*
|
*
|
||||||
* Strawberry is free software: you can redistribute it and/or modify
|
* Strawberry is free software: you can redistribute it and/or modify
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Strawberry Music Player
|
* Strawberry Music Player
|
||||||
* This file was part of Clementine.
|
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
|
||||||
* Copyright 2012, David Sansome <me@davidsansome.com>
|
|
||||||
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
|
|
||||||
*
|
*
|
||||||
* Strawberry is free software: you can redistribute it and/or modify
|
* Strawberry is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
@@ -22,7 +20,7 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
#include "filtertree.h"
|
#include "filtertree.h"
|
||||||
|
#include "filtercolumn.h"
|
||||||
#include "core/song.h"
|
#include "core/song.h"
|
||||||
|
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
@@ -30,28 +28,64 @@ using namespace Qt::Literals::StringLiterals;
|
|||||||
FilterTree::FilterTree() = default;
|
FilterTree::FilterTree() = default;
|
||||||
FilterTree::~FilterTree() = default;
|
FilterTree::~FilterTree() = default;
|
||||||
|
|
||||||
QVariant FilterTree::DataFromColumn(const QString &column, const Song &metadata) {
|
QVariant FilterTree::DataFromColumn(const FilterColumn filter_column, const Song &song) {
|
||||||
|
|
||||||
if (column == "albumartist"_L1) return metadata.effective_albumartist();
|
switch (filter_column) {
|
||||||
if (column == "artist"_L1) return metadata.artist();
|
case FilterColumn::AlbumArtist:
|
||||||
if (column == "album"_L1) return metadata.album();
|
return song.effective_albumartist();
|
||||||
if (column == "title"_L1) return metadata.PrettyTitle();
|
case FilterColumn::AlbumArtistSort:
|
||||||
if (column == "composer"_L1) return metadata.composer();
|
return song.effective_albumartistsort();
|
||||||
if (column == "performer"_L1) return metadata.performer();
|
case FilterColumn::Artist:
|
||||||
if (column == "grouping"_L1) return metadata.grouping();
|
return song.artist();
|
||||||
if (column == "genre"_L1) return metadata.genre();
|
case FilterColumn::ArtistSort:
|
||||||
if (column == "comment"_L1) return metadata.comment();
|
return song.effective_artistsort();
|
||||||
if (column == "track"_L1) return metadata.track();
|
case FilterColumn::Album:
|
||||||
if (column == "year"_L1) return metadata.year();
|
return song.album();
|
||||||
if (column == "length"_L1) return metadata.length_nanosec();
|
case FilterColumn::AlbumSort:
|
||||||
if (column == "samplerate"_L1) return metadata.samplerate();
|
return song.effective_albumsort();
|
||||||
if (column == "bitdepth"_L1) return metadata.bitdepth();
|
case FilterColumn::Title:
|
||||||
if (column == "bitrate"_L1) return metadata.bitrate();
|
return song.PrettyTitle();
|
||||||
if (column == "rating"_L1) return metadata.rating();
|
case FilterColumn::TitleSort:
|
||||||
if (column == "playcount"_L1) return metadata.playcount();
|
return song.effective_titlesort();
|
||||||
if (column == "skipcount"_L1) return metadata.skipcount();
|
case FilterColumn::Composer:
|
||||||
if (column == "filename"_L1) return metadata.basefilename();
|
return song.composer();
|
||||||
if (column == "url"_L1) return metadata.effective_url().toString();
|
case FilterColumn::ComposerSort:
|
||||||
|
return song.effective_composersort();
|
||||||
|
case FilterColumn::Performer:
|
||||||
|
return song.performer();
|
||||||
|
case FilterColumn::PerformerSort:
|
||||||
|
return song.effective_performersort();
|
||||||
|
case FilterColumn::Grouping:
|
||||||
|
return song.grouping();
|
||||||
|
case FilterColumn::Genre:
|
||||||
|
return song.genre();
|
||||||
|
case FilterColumn::Comment:
|
||||||
|
return song.comment();
|
||||||
|
case FilterColumn::Track:
|
||||||
|
return song.track();
|
||||||
|
case FilterColumn::Year:
|
||||||
|
return song.year();
|
||||||
|
case FilterColumn::Length:
|
||||||
|
return song.length_nanosec();
|
||||||
|
case FilterColumn::Samplerate:
|
||||||
|
return song.samplerate();
|
||||||
|
case FilterColumn::Bitdepth:
|
||||||
|
return song.bitdepth();
|
||||||
|
case FilterColumn::Bitrate:
|
||||||
|
return song.bitrate();
|
||||||
|
case FilterColumn::Rating:
|
||||||
|
return song.rating();
|
||||||
|
case FilterColumn::Playcount:
|
||||||
|
return song.playcount();
|
||||||
|
case FilterColumn::Skipcount:
|
||||||
|
return song.skipcount();
|
||||||
|
case FilterColumn::Filename:
|
||||||
|
return song.basefilename();
|
||||||
|
case FilterColumn::URL:
|
||||||
|
return song.effective_url().toString();
|
||||||
|
case FilterColumn::Unknown:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return QVariant();
|
return QVariant();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Strawberry Music Player
|
* Strawberry Music Player
|
||||||
* This file was part of Clementine.
|
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
|
||||||
* Copyright 2012, David Sansome <me@davidsansome.com>
|
|
||||||
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
|
|
||||||
*
|
*
|
||||||
* Strawberry is free software: you can redistribute it and/or modify
|
* Strawberry is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
@@ -25,6 +23,7 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
#include "core/song.h"
|
#include "core/song.h"
|
||||||
|
#include "filtercolumn.h"
|
||||||
|
|
||||||
class FilterTree {
|
class FilterTree {
|
||||||
public:
|
public:
|
||||||
@@ -45,7 +44,7 @@ class FilterTree {
|
|||||||
virtual bool accept(const Song &song) const = 0;
|
virtual bool accept(const Song &song) const = 0;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
static QVariant DataFromColumn(const QString &column, const Song &metadata);
|
static QVariant DataFromColumn(const FilterColumn filter_column, const Song &metadata);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Q_DISABLE_COPY(FilterTree)
|
Q_DISABLE_COPY(FilterTree)
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
#include "filtertreecolumnterm.h"
|
#include "filtertreecolumnterm.h"
|
||||||
#include "filterparsersearchtermcomparator.h"
|
#include "filterparsersearchtermcomparator.h"
|
||||||
|
|
||||||
FilterTreeColumnTerm::FilterTreeColumnTerm(const QString &column, FilterParserSearchTermComparator *comparator) : column_(column), cmp_(comparator) {}
|
FilterTreeColumnTerm::FilterTreeColumnTerm(const FilterColumn filter_column, FilterParserSearchTermComparator *comparator) : filter_column_(filter_column), cmp_(comparator) {}
|
||||||
|
|
||||||
bool FilterTreeColumnTerm::accept(const Song &song) const {
|
bool FilterTreeColumnTerm::accept(const Song &song) const {
|
||||||
return cmp_->Matches(DataFromColumn(column_, song));
|
return cmp_->Matches(DataFromColumn(filter_column_, song));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,20 +26,20 @@
|
|||||||
#include <QScopedPointer>
|
#include <QScopedPointer>
|
||||||
|
|
||||||
#include "filtertree.h"
|
#include "filtertree.h"
|
||||||
|
#include "filtercolumn.h"
|
||||||
#include "core/song.h"
|
#include "core/song.h"
|
||||||
|
|
||||||
class FilterParserSearchTermComparator;
|
class FilterParserSearchTermComparator;
|
||||||
|
|
||||||
class FilterTreeColumnTerm : public FilterTree {
|
class FilterTreeColumnTerm : public FilterTree {
|
||||||
public:
|
public:
|
||||||
explicit FilterTreeColumnTerm(const QString &column, FilterParserSearchTermComparator *comparator);
|
explicit FilterTreeColumnTerm(const FilterColumn filter_column, FilterParserSearchTermComparator *comparator);
|
||||||
|
|
||||||
FilterType type() const override { return FilterType::Column; }
|
FilterType type() const override { return FilterType::Column; }
|
||||||
bool accept(const Song &song) const override;
|
bool accept(const Song &song) const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const QString column_;
|
const FilterColumn filter_column_;
|
||||||
QScopedPointer<FilterParserSearchTermComparator> cmp_;
|
QScopedPointer<FilterParserSearchTermComparator> cmp_;
|
||||||
|
|
||||||
Q_DISABLE_COPY(FilterTreeColumnTerm)
|
Q_DISABLE_COPY(FilterTreeColumnTerm)
|
||||||
|
|||||||
@@ -27,11 +27,17 @@ FilterTreeTerm::FilterTreeTerm(FilterParserSearchTermComparator *comparator) : c
|
|||||||
bool FilterTreeTerm::accept(const Song &song) const {
|
bool FilterTreeTerm::accept(const Song &song) const {
|
||||||
|
|
||||||
if (cmp_->Matches(song.PrettyTitle())) return true;
|
if (cmp_->Matches(song.PrettyTitle())) return true;
|
||||||
|
if (cmp_->Matches(song.titlesort())) return true;
|
||||||
if (cmp_->Matches(song.album())) return true;
|
if (cmp_->Matches(song.album())) return true;
|
||||||
|
if (cmp_->Matches(song.albumsort())) return true;
|
||||||
if (cmp_->Matches(song.artist())) return true;
|
if (cmp_->Matches(song.artist())) return true;
|
||||||
|
if (cmp_->Matches(song.artistsort())) return true;
|
||||||
if (cmp_->Matches(song.albumartist())) return true;
|
if (cmp_->Matches(song.albumartist())) return true;
|
||||||
|
if (cmp_->Matches(song.albumartistsort())) return true;
|
||||||
if (cmp_->Matches(song.composer())) return true;
|
if (cmp_->Matches(song.composer())) return true;
|
||||||
|
if (cmp_->Matches(song.composersort())) return true;
|
||||||
if (cmp_->Matches(song.performer())) return true;
|
if (cmp_->Matches(song.performer())) return true;
|
||||||
|
if (cmp_->Matches(song.performersort())) return true;
|
||||||
if (cmp_->Matches(song.grouping())) return true;
|
if (cmp_->Matches(song.grouping())) return true;
|
||||||
if (cmp_->Matches(song.genre())) return true;
|
if (cmp_->Matches(song.genre())) return true;
|
||||||
if (cmp_->Matches(song.comment())) return true;
|
if (cmp_->Matches(song.comment())) return true;
|
||||||
|
|||||||
@@ -474,8 +474,10 @@ bool Playlist::setData(const QModelIndex &idx, const QVariant &value, const int
|
|||||||
QObject::disconnect(*connection);
|
QObject::disconnect(*connection);
|
||||||
}, Qt::QueuedConnection);
|
}, Qt::QueuedConnection);
|
||||||
}
|
}
|
||||||
else if (song.is_radio()) {
|
else if (song.is_stream()) {
|
||||||
item->SetOriginalMetadata(song);
|
item->SetOriginalMetadata(song);
|
||||||
|
Q_EMIT dataChanged(index(row, 0), index(row, ColumnCount - 1));
|
||||||
|
Q_EMIT EditingFinished(id_, idx);
|
||||||
ScheduleSave();
|
ScheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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();
|
album.album = obj_item["title"_L1].toString();
|
||||||
|
|
||||||
|
if (obj_item.contains("genre"_L1)) {
|
||||||
|
QJsonValue value_genre = obj_item["genre"_L1];
|
||||||
|
if (value_genre.isObject()) {
|
||||||
|
QJsonObject obj_genre = value_genre.toObject();
|
||||||
|
if (obj_genre.contains("name"_L1)) {
|
||||||
|
album.genre = obj_genre["name"_L1].toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (album_songs_requests_pending_.contains(album.album_id)) continue;
|
if (album_songs_requests_pending_.contains(album.album_id)) continue;
|
||||||
|
|
||||||
QJsonValue value_artist = obj_item["artist"_L1];
|
QJsonValue value_artist = obj_item["artist"_L1];
|
||||||
@@ -921,6 +931,17 @@ void QobuzRequest::SongsReceived(QNetworkReply *reply, const Artist &artist_requ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract genre from album/get response if not already set
|
||||||
|
if (album.genre.isEmpty() && json_object.contains("genre"_L1)) {
|
||||||
|
QJsonValue value_genre = json_object["genre"_L1];
|
||||||
|
if (value_genre.isObject()) {
|
||||||
|
QJsonObject obj_genre = value_genre.toObject();
|
||||||
|
if (obj_genre.contains("name"_L1)) {
|
||||||
|
album.genre = obj_genre["name"_L1].toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QJsonValue value_tracks = json_object["tracks"_L1];
|
QJsonValue value_tracks = json_object["tracks"_L1];
|
||||||
if (!value_tracks.isObject()) {
|
if (!value_tracks.isObject()) {
|
||||||
Error(u"Json tracks is not an object."_s, json_object);
|
Error(u"Json tracks is not an object."_s, json_object);
|
||||||
@@ -1053,6 +1074,7 @@ void QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
|
|||||||
// bool streamable = json_obj["streamable"].toBool();
|
// bool streamable = json_obj["streamable"].toBool();
|
||||||
QString composer;
|
QString composer;
|
||||||
QString performer;
|
QString performer;
|
||||||
|
QString genre;
|
||||||
|
|
||||||
if (json_obj.contains("media_number"_L1)) {
|
if (json_obj.contains("media_number"_L1)) {
|
||||||
disc = json_obj["media_number"_L1].toInt();
|
disc = json_obj["media_number"_L1].toInt();
|
||||||
@@ -1118,6 +1140,21 @@ void QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
|
|||||||
song_album.cover_url.setUrl(album_image);
|
song_album.cover_url.setUrl(album_image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (obj_album.contains("genre"_L1)) {
|
||||||
|
QJsonValue value_genre = obj_album["genre"_L1];
|
||||||
|
if (value_genre.isObject()) {
|
||||||
|
QJsonObject obj_genre = value_genre.toObject();
|
||||||
|
if (obj_genre.contains("name"_L1)) {
|
||||||
|
genre = obj_genre["name"_L1].toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to genre from the Album struct if not found in the track's album object
|
||||||
|
if (genre.isEmpty() && !album.genre.isEmpty()) {
|
||||||
|
genre = album.genre;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json_obj.contains("composer"_L1)) {
|
if (json_obj.contains("composer"_L1)) {
|
||||||
@@ -1180,6 +1217,7 @@ void QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
|
|||||||
song.set_performer(performer);
|
song.set_performer(performer);
|
||||||
song.set_composer(composer);
|
song.set_composer(composer);
|
||||||
song.set_comment(copyright);
|
song.set_comment(copyright);
|
||||||
|
song.set_genre(genre);
|
||||||
song.set_directory_id(0);
|
song.set_directory_id(0);
|
||||||
song.set_filetype(Song::FileType::Stream);
|
song.set_filetype(Song::FileType::Stream);
|
||||||
song.set_filesize(0);
|
song.set_filesize(0);
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class QobuzRequest : public QobuzBaseRequest {
|
|||||||
QString album;
|
QString album;
|
||||||
QUrl cover_url;
|
QUrl cover_url;
|
||||||
bool album_explicit;
|
bool album_explicit;
|
||||||
|
QString genre;
|
||||||
};
|
};
|
||||||
struct Request {
|
struct Request {
|
||||||
Request() : offset(0), limit(0) {}
|
Request() : offset(0), limit(0) {}
|
||||||
|
|||||||
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_id = object_item["id"_L1].toString();
|
||||||
const QString artist = object_item["name"_L1].toString();
|
const QString artist = object_item["name"_L1].toString();
|
||||||
|
|
||||||
|
QString genre;
|
||||||
|
if (object_item.contains("genres"_L1) && object_item["genres"_L1].isArray()) {
|
||||||
|
const QJsonArray array_genres = object_item["genres"_L1].toArray();
|
||||||
|
if (!array_genres.isEmpty()) {
|
||||||
|
genre = array_genres.first().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (artist_albums_requests_pending_.contains(artist_id)) continue;
|
if (artist_albums_requests_pending_.contains(artist_id)) continue;
|
||||||
|
|
||||||
ArtistAlbumsRequest request;
|
ArtistAlbumsRequest request;
|
||||||
request.artist.artist_id = artist_id;
|
request.artist.artist_id = artist_id;
|
||||||
request.artist.artist = artist;
|
request.artist.artist = artist;
|
||||||
|
request.artist.genre = genre;
|
||||||
artist_albums_requests_pending_.insert(artist_id, request);
|
artist_albums_requests_pending_.insert(artist_id, request);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -715,6 +724,12 @@ void SpotifyRequest::AlbumsReceived(QNetworkReply *reply, const Artist &artist_a
|
|||||||
if (artist.artist_id.isEmpty() || artist.artist_id == artist_artist.artist_id) {
|
if (artist.artist_id.isEmpty() || artist.artist_id == artist_artist.artist_id) {
|
||||||
artist.artist_id = obj_artist["id"_L1].toString();
|
artist.artist_id = obj_artist["id"_L1].toString();
|
||||||
artist.artist = obj_artist["name"_L1].toString();
|
artist.artist = obj_artist["name"_L1].toString();
|
||||||
|
if (obj_artist.contains("genres"_L1) && obj_artist["genres"_L1].isArray()) {
|
||||||
|
const QJsonArray array_genres = obj_artist["genres"_L1].toArray();
|
||||||
|
if (!array_genres.isEmpty()) {
|
||||||
|
album.genre = array_genres.first().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (artist.artist_id == artist_artist.artist_id) {
|
if (artist.artist_id == artist_artist.artist_id) {
|
||||||
artist_matches = true;
|
artist_matches = true;
|
||||||
break;
|
break;
|
||||||
@@ -730,6 +745,11 @@ void SpotifyRequest::AlbumsReceived(QNetworkReply *reply, const Artist &artist_a
|
|||||||
artist = artist_artist;
|
artist = artist_artist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to artist's genre if no genre found in album's artist data
|
||||||
|
if (album.genre.isEmpty() && !artist_artist.genre.isEmpty()) {
|
||||||
|
album.genre = artist_artist.genre;
|
||||||
|
}
|
||||||
|
|
||||||
if (object_item.contains("images"_L1) && object_item["images"_L1].isArray()) {
|
if (object_item.contains("images"_L1) && object_item["images"_L1].isArray()) {
|
||||||
const QJsonArray array_images = object_item["images"_L1].toArray();
|
const QJsonArray array_images = object_item["images"_L1].toArray();
|
||||||
for (const QJsonValue &value : array_images) {
|
for (const QJsonValue &value : array_images) {
|
||||||
@@ -1050,6 +1070,7 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
|
|||||||
|
|
||||||
QString artist_id;
|
QString artist_id;
|
||||||
QString artist_title;
|
QString artist_title;
|
||||||
|
QString genre;
|
||||||
if (json_obj.contains("artists"_L1) && json_obj["artists"_L1].isArray()) {
|
if (json_obj.contains("artists"_L1) && json_obj["artists"_L1].isArray()) {
|
||||||
const QJsonArray array_artists = json_obj["artists"_L1].toArray();
|
const QJsonArray array_artists = json_obj["artists"_L1].toArray();
|
||||||
for (const QJsonValue &value_artist : array_artists) {
|
for (const QJsonValue &value_artist : array_artists) {
|
||||||
@@ -1060,6 +1081,12 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
|
|||||||
}
|
}
|
||||||
artist_id = obj_artist["id"_L1].toString();
|
artist_id = obj_artist["id"_L1].toString();
|
||||||
artist_title = obj_artist["name"_L1].toString();
|
artist_title = obj_artist["name"_L1].toString();
|
||||||
|
if (obj_artist.contains("genres"_L1) && obj_artist["genres"_L1].isArray()) {
|
||||||
|
const QJsonArray array_genres = obj_artist["genres"_L1].toArray();
|
||||||
|
if (!array_genres.isEmpty()) {
|
||||||
|
genre = array_genres.first().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1102,6 +1129,16 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
|
|||||||
cover_url = album.cover_url;
|
cover_url = album.cover_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to genre from the Album struct if not found in the track's artist
|
||||||
|
if (genre.isEmpty() && !album.genre.isEmpty()) {
|
||||||
|
genre = album.genre;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to genre from the Artist struct if still not found
|
||||||
|
if (genre.isEmpty() && !album_artist.genre.isEmpty()) {
|
||||||
|
genre = album_artist.genre;
|
||||||
|
}
|
||||||
|
|
||||||
QString song_id = json_obj["id"_L1].toString();
|
QString song_id = json_obj["id"_L1].toString();
|
||||||
QString title = json_obj["name"_L1].toString();
|
QString title = json_obj["name"_L1].toString();
|
||||||
QString uri = json_obj["uri"_L1].toString();
|
QString uri = json_obj["uri"_L1].toString();
|
||||||
@@ -1130,6 +1167,7 @@ void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Ar
|
|||||||
song.set_url(url);
|
song.set_url(url);
|
||||||
song.set_length_nanosec(duration);
|
song.set_length_nanosec(duration);
|
||||||
song.set_art_automatic(cover_url);
|
song.set_art_automatic(cover_url);
|
||||||
|
song.set_genre(genre);
|
||||||
song.set_directory_id(0);
|
song.set_directory_id(0);
|
||||||
song.set_filetype(Song::FileType::Stream);
|
song.set_filetype(Song::FileType::Stream);
|
||||||
song.set_filesize(0);
|
song.set_filesize(0);
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ class SpotifyRequest : public SpotifyBaseRequest {
|
|||||||
struct Artist {
|
struct Artist {
|
||||||
QString artist_id;
|
QString artist_id;
|
||||||
QString artist;
|
QString artist;
|
||||||
|
QString genre;
|
||||||
};
|
};
|
||||||
struct Album {
|
struct Album {
|
||||||
QString album_id;
|
QString album_id;
|
||||||
QString album;
|
QString album;
|
||||||
QUrl cover_url;
|
QUrl cover_url;
|
||||||
|
QString genre;
|
||||||
};
|
};
|
||||||
struct Request {
|
struct Request {
|
||||||
Request() : offset(0), limit(0) {}
|
Request() : offset(0), limit(0) {}
|
||||||
|
|||||||
@@ -1000,6 +1000,11 @@ void TidalRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
|
|||||||
const bool stream_ready = json_obj["streamReady"_L1].toBool();
|
const bool stream_ready = json_obj["streamReady"_L1].toBool();
|
||||||
const QString copyright = json_obj["copyright"_L1].toString();
|
const QString copyright = json_obj["copyright"_L1].toString();
|
||||||
|
|
||||||
|
QString genre;
|
||||||
|
if (json_obj.contains("genre"_L1)) {
|
||||||
|
genre = json_obj["genre"_L1].toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (!value_artist.isObject()) {
|
if (!value_artist.isObject()) {
|
||||||
Error(u"Invalid Json reply, track artist is not a object."_s, value_artist);
|
Error(u"Invalid Json reply, track artist is not a object."_s, value_artist);
|
||||||
return;
|
return;
|
||||||
@@ -1095,6 +1100,7 @@ void TidalRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Arti
|
|||||||
song.set_art_automatic(cover_url);
|
song.set_art_automatic(cover_url);
|
||||||
}
|
}
|
||||||
song.set_comment(copyright);
|
song.set_comment(copyright);
|
||||||
|
song.set_genre(genre);
|
||||||
song.set_directory_id(0);
|
song.set_directory_id(0);
|
||||||
song.set_filetype(Song::FileType::Stream);
|
song.set_filetype(Song::FileType::Stream);
|
||||||
song.set_filesize(0);
|
song.set_filesize(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user