Compare commits

..

24 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
0df4afe363 Address code review feedback: remove const_cast and document scaling formula
Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 00:09:12 +00:00
copilot-swe-agent[bot]
350d907e8b Calculate icon size dynamically based on resolution, aspect ratio and DPI scaling
Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 00:08:01 +00:00
copilot-swe-agent[bot]
eaced733bb Increase shuffle/repeat icon size using appearance settings
Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-20 12:54:44 +00:00
copilot-swe-agent[bot]
2abe4576a9 Initial plan 2025-12-20 12:20:12 +00:00
Jonas Kvinge
8d262959c1 GstEnginePipeline: Fix buffering issue near track end during gapless playback
Ignore buffering messages when within 5 seconds of track end and about-to-finish has been signaled. This prevents spurious buffering from blocking playback during track transitions with local files.

Fixes #1725
2025-12-20 01:36:49 +01:00
Jonas Kvinge
b9b70399d8 GstEnginePipeline: Fix possible race condition in pipeline destructor
Wait for ongoing state changes to complete before setting pipeline to NULL.
This prevents race conditions with async state transitions that can cause crashes in GStreamer elements.

Fixes #1875
2025-12-20 01:28:53 +01:00
Jonas Kvinge
527ccd212a SmartPlaylistsViewContainer: Ask for confirmation before resetting smart playlists 2025-12-19 01:03:46 +01:00
Jonas Kvinge
4a5afbeb1e SmartPlaylists: Add option to restore smart playlists to the defaults
Fixes #1848
2025-12-19 00:49:05 +01:00
Jonas Kvinge
63c14e014b EditTagDialog: Ignore unused const variables 2025-12-19 00:47:35 +01:00
Jonas Kvinge
801658c6b9 MainWindow: Check that current is the active playlist
Fixes #1783
2025-12-19 00:38:32 +01:00
Jonas Kvinge
16fe665295 TagReaderTagLib: Remove unused constants 2025-12-19 00:35:02 +01:00
Rob Stanfield
2bb0dbada2 Qobuz: Fix authentication and add automatic credential fetching
Qobuz API now requires intent=stream parameter for stream URL requests,
and the app_secret must be extracted using the Spoofbuz decoding method
from bundle.js rather than plain-text values.

Changes:
- Add intent=stream parameter to stream URL requests
- Add QobuzCredentialFetcher class to extract credentials from web player
- Add "Fetch Credentials" button to Qobuz settings page
- Decode obfuscated app secrets using seed/timezone/info/extras method

This fixes "Invalid Request Signature" errors that prevented playback.
2025-12-18 23:12:52 +01:00
Jonas Kvinge
2cd9498469 Add option to select ID3v2 version
Fixes #1861
2025-12-18 22:18:26 +01:00
Jonas Kvinge
d1ee27fff9 QobuzService: Remove QNetworkReply 2025-12-18 20:39:21 +01:00
Jonas Kvinge
91adf5ba32 NetworkAccessManager: Handle network state changes after system suspend/resume
Fixes #1521
2025-12-18 20:32:07 +01:00
Jonas Kvinge
d68f464269 Playlist: Don't automatically sort playlist before it's fully loaded
Fixes #1690
2025-12-18 20:14:36 +01:00
Jonas Kvinge
c684a95f89 GstEnginePipeline: Fix file descriptor exhaustion by using shared thread pool
Replace per-pipeline QThreadPool with a shared static pool to prevent
file descriptor and thread exhaustion. Each GstEnginePipeline was creating
its own thread pool, leading to resource accumulation during frequent
pipeline creation/destruction (track changes, seeking, crossfade).

The shared pool is limited to 2 threads max since state changes are
typically sequential per pipeline. This prevents the crash in g_wakeup_new()
when creating eventfd for new thread event dispatchers.

Fixes #1687
2025-12-18 19:58:23 +01:00
copilot-swe-agent[bot]
1d03bb2178 GstEnginePipeline: Fix crash in GStreamer decodebin3 when switching tracks
Add guard in AboutToFinishCallback to prevent race condition when pipeline is being torn down. This prevents the callback from trying to set next URL while the pipeline is being destroyed, which caused crashes in GStreamer's decodebin3.

Fixes issue where rapidly switching tracks could cause segmentation fault in gst_decodebin_input_link_to_slot.

See: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/issues/4626

Fixes #1863

Co-Authored-By: Jonas Kvinge <jonas@jkvinge.net>
2025-12-18 19:44:03 +01:00
Jonas Kvinge
39f9128ecf gitignore: Add _codeql_detected_source_root 2025-12-18 19:39:10 +01:00
Jonas Kvinge
ca2e802239 GstEngine: Make sure device is set for pipeline
Fixes #1852
2025-12-18 00:21:00 +01:00
Jonas Kvinge
9a513a9a56 AutoExpandingTreeView: Scroll if cursor is out of visible area
Fixes #1489
2025-12-17 23:14:57 +01:00
Jonas Kvinge
1c2e87b741 Organize: Skip existing files if not overwriting
Fixes #1484
2025-12-17 22:58:17 +01:00
Jonas Kvinge
fe4d9979ce CollectionWatcher: Avoid re-scan of restored songs unless mtime is changed
Fixes #1819
2025-12-17 22:15:21 +01:00
Jonas Kvinge
d8ae790ebf Turn on git revision 2025-12-17 01:05:45 +01:00
44 changed files with 958 additions and 55 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@
/CMakeSettings.json
/dist/scripts/maketarball.sh
/debian/changelog
_codeql_detected_source_root

View File

@@ -1463,6 +1463,7 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.cpp
src/qobuz/qobuzstreamurlrequest.cpp
src/qobuz/qobuzfavoriterequest.cpp
src/qobuz/qobuzcredentialfetcher.cpp
src/settings/qobuzsettingspage.cpp
src/covermanager/qobuzcoverprovider.cpp
HEADERS
@@ -1472,6 +1473,7 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.h
src/qobuz/qobuzstreamurlrequest.h
src/qobuz/qobuzfavoriterequest.h
src/qobuz/qobuzcredentialfetcher.h
src/settings/qobuzsettingspage.h
src/covermanager/qobuzcoverprovider.h
UI

View File

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

View File

@@ -706,8 +706,14 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const CollectionSu
qLog(Debug) << file << "is missing EBU R 128 loudness characteristics.";
}
// If the song is unavailable and nothing has changed, just mark it as available without re-scanning
// For CUE files with multiple sections, all sections share the same file and would have the same availability status
if (matching_song.unavailable() && !changed && !missing_fingerprint && !missing_loudness_characteristics) {
qLog(Debug) << "Unavailable song" << file << "restored without re-scanning.";
t->readded_songs << matching_songs;
}
// The song's changed or missing fingerprint - create fingerprint and reread the metadata from file.
if (t->ignores_mtime() || changed || missing_fingerprint || missing_loudness_characteristics) {
else if (t->ignores_mtime() || changed || missing_fingerprint || missing_loudness_characteristics) {
QString fingerprint;
#ifdef HAVE_SONGFINGERPRINTING
@@ -728,12 +734,6 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const CollectionSu
}
}
// Nothing has changed - mark the song available without re-scanning
else if (matching_song.unavailable()) {
qLog(Debug) << "Unavailable song" << file << "restored.";
t->readded_songs << matching_songs;
}
}
else { // Search the DB by fingerprint.
QString fingerprint;

View File

@@ -3318,7 +3318,7 @@ void MainWindow::PlaylistDelete() {
if (DeleteConfirmationDialog::warning(files) != QDialogButtonBox::Yes) return;
if (app_->player()->GetState() == EngineBase::State::Playing && app_->playlist_manager()->current()->rowCount() == selected_songs.count()) {
if (app_->player()->GetState() == EngineBase::State::Playing && app_->playlist_manager()->current() == app_->playlist_manager()->active() && app_->playlist_manager()->current()->rowCount() == selected_songs.count()) {
app_->player()->Stop();
}

View File

@@ -29,6 +29,7 @@
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QNetworkInformation>
#include "networkaccessmanager.h"
#include "threadsafenetworkdiskcache.h"
@@ -41,6 +42,22 @@ NetworkAccessManager::NetworkAccessManager(QObject *parent)
setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
setCache(new ThreadSafeNetworkDiskCache(this));
// Handle network state changes after system suspend/resume
// QNetworkInformation provides cross-platform network reachability monitoring in Qt 6
if (QNetworkInformation::loadDefaultBackend()) {
QNetworkInformation *network_info = QNetworkInformation::instance();
if (network_info) {
QObject::connect(network_info, &QNetworkInformation::reachabilityChanged, this, [this](QNetworkInformation::Reachability reachability) {
if (reachability == QNetworkInformation::Reachability::Online) {
// Clear connection cache to force reconnection after network becomes available
// This fixes issues after system suspend/resume
clearConnectionCache();
clearAccessCache();
}
});
}
}
}
QNetworkReply *NetworkAccessManager::createRequest(Operation op, const QNetworkRequest &network_request, QIODevice *outgoing_data) {

View File

@@ -353,6 +353,8 @@ struct Song::Private : public QSharedData {
std::optional<double> ebur128_integrated_loudness_lufs_;
std::optional<double> ebur128_loudness_range_lu_;
int id3v2_version_; // ID3v2 tag version (3 or 4), 0 if not applicable or unknown
bool init_from_file_; // Whether this song was loaded from a file using taglib.
bool suspicious_tags_; // Whether our encoding guesser thinks these tags might be incorrectly encoded.
@@ -400,6 +402,8 @@ Song::Private::Private(const Source source)
rating_(-1),
bpm_(-1),
id3v2_version_(0),
init_from_file_(false),
suspicious_tags_(false)
@@ -510,6 +514,8 @@ const QString &Song::musicbrainz_work_id() const { return d->musicbrainz_work_id
std::optional<double> Song::ebur128_integrated_loudness_lufs() const { return d->ebur128_integrated_loudness_lufs_; }
std::optional<double> Song::ebur128_loudness_range_lu() const { return d->ebur128_loudness_range_lu_; }
int Song::id3v2_version() const { return d->id3v2_version_; }
QString *Song::mutable_title() { return &d->title_; }
QString *Song::mutable_album() { return &d->album_; }
QString *Song::mutable_artist() { return &d->artist_; }
@@ -624,6 +630,8 @@ void Song::set_musicbrainz_work_id(const QString &v) { d->musicbrainz_work_id_ =
void Song::set_ebur128_integrated_loudness_lufs(const std::optional<double> v) { d->ebur128_integrated_loudness_lufs_ = v; }
void Song::set_ebur128_loudness_range_lu(const std::optional<double> v) { d->ebur128_loudness_range_lu_ = v; }
void Song::set_id3v2_version(const int v) { d->id3v2_version_ = v; }
void Song::set_init_from_file(const bool v) { d->init_from_file_ = v; }
void Song::set_stream_url(const QUrl &v) { d->stream_url_ = v; }
@@ -833,6 +841,10 @@ bool Song::save_embedded_cover_supported(const FileType filetype) {
}
bool Song::id3v2_tags_supported() const {
return d->filetype_ == FileType::MPEG || d->filetype_ == FileType::WAV || d->filetype_ == FileType::AIFF;
}
int Song::ColumnIndex(const QString &field) {
return static_cast<int>(kRowIdColumns.indexOf(field));

View File

@@ -234,6 +234,8 @@ class Song {
std::optional<double> ebur128_integrated_loudness_lufs() const;
std::optional<double> ebur128_loudness_range_lu() const;
int id3v2_version() const;
QString *mutable_title();
QString *mutable_album();
QString *mutable_artist();
@@ -349,6 +351,8 @@ class Song {
void set_ebur128_integrated_loudness_lufs(const std::optional<double> v);
void set_ebur128_loudness_range_lu(const std::optional<double> v);
void set_id3v2_version(const int v);
void set_init_from_file(const bool v);
void set_stream_url(const QUrl &v);
@@ -439,6 +443,8 @@ class Song {
static bool save_embedded_cover_supported(const FileType filetype);
bool save_embedded_cover_supported() const { return url().isLocalFile() && save_embedded_cover_supported(filetype()) && !has_cue(); };
bool id3v2_tags_supported() const;
static int ColumnIndex(const QString &field);
static QString JoinSpec(const QString &table);

View File

@@ -81,6 +81,7 @@
#include "utilities/coverutils.h"
#include "utilities/coveroptions.h"
#include "tagreader/tagreaderclient.h"
#include "tagreader/tagid3v2version.h"
#include "widgets/busyindicator.h"
#include "widgets/lineedit.h"
#include "collection/collectionbackend.h"
@@ -104,14 +105,29 @@
using std::make_shared;
using namespace Qt::Literals::StringLiterals;
#ifdef __clang__
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wunused-const-variable"
#endif
namespace {
constexpr char kSettingsGroup[] = "EditTagDialog";
constexpr int kSmallImageSize = 128;
// ID3v2 version constants
constexpr int kID3v2_Version_3 = 3;
constexpr int kID3v2_Version_4 = 4;
constexpr int kComboBoxIndex_ID3v2_3 = 0;
constexpr int kComboBoxIndex_ID3v2_4 = 1;
} // namespace
const char EditTagDialog::kTagsDifferentHintText[] = QT_TR_NOOP("(different across multiple songs)");
const char EditTagDialog::kArtDifferentHintText[] = QT_TR_NOOP("Different art across multiple songs.");
#ifdef __clang_
# pragma clang diagnostic pop
#endif
EditTagDialog::EditTagDialog(const SharedPtr<NetworkAccessManager> network,
const SharedPtr<TagReaderClient> tagreader_client,
const SharedPtr<CollectionBackend> collection_backend,
@@ -708,6 +724,9 @@ void EditTagDialog::SelectionChanged() {
bool titlesort_enabled = false;
bool artistsort_enabled = false;
bool albumsort_enabled = false;
bool has_id3v2_support = false;
int id3v2_version = 0;
bool id3v2_version_different = false;
for (const QModelIndex &idx : indexes) {
if (data_.value(idx.row()).cover_action_ == UpdateCoverAction::None) {
data_[idx.row()].cover_result_ = AlbumCoverImageResult();
@@ -769,6 +788,15 @@ void EditTagDialog::SelectionChanged() {
if (song.albumsort_supported()) {
albumsort_enabled = true;
}
if (song.id3v2_tags_supported()) {
has_id3v2_support = true;
if (id3v2_version == 0) {
id3v2_version = song.id3v2_version();
}
else if (id3v2_version != song.id3v2_version()) {
id3v2_version_different = true;
}
}
}
QString summary;
@@ -840,6 +868,23 @@ void EditTagDialog::SelectionChanged() {
ui_->artistsort->setEnabled(artistsort_enabled);
ui_->albumsort->setEnabled(albumsort_enabled);
ui_->label_id3v2_version->setVisible(has_id3v2_support);
ui_->combobox_id3v2_version->setVisible(has_id3v2_support);
if (has_id3v2_support) {
// Set default based on existing version(s)
if (id3v2_version_different || id3v2_version == 0) {
// Mixed versions or unknown - default to ID3v2.4
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_4);
}
else if (id3v2_version == kID3v2_Version_3) {
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_3);
}
else {
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_4);
}
}
}
void EditTagDialog::UpdateUI(const QModelIndexList &indexes) {
@@ -1371,6 +1416,13 @@ void EditTagDialog::SaveData() {
}
save_tag_cover_data.cover_data = ref.cover_result_.image_data;
}
// Determine ID3v2 version based on user selection
TagID3v2Version tag_id3v2_version = TagID3v2Version::Default;
if (ref.current_.filetype() == Song::FileType::MPEG || ref.current_.filetype() == Song::FileType::WAV || ref.current_.filetype() == Song::FileType::AIFF) {
tag_id3v2_version = ui_->combobox_id3v2_version->currentIndex() == kComboBoxIndex_ID3v2_3 ? TagID3v2Version::V3 : TagID3v2Version::V4;
}
TagReaderClient::SaveOptions save_tags_options;
if (save_tags) {
save_tags_options |= TagReaderClient::SaveOption::Tags;
@@ -1384,7 +1436,7 @@ void EditTagDialog::SaveData() {
if (save_embedded_cover) {
save_tags_options |= TagReaderClient::SaveOption::Cover;
}
TagReaderReplyPtr reply = tagreader_client_->WriteFileAsync(ref.current_.url().toLocalFile(), ref.current_, save_tags_options, save_tag_cover_data);
TagReaderReplyPtr reply = tagreader_client_->WriteFileAsync(ref.current_.url().toLocalFile(), ref.current_, save_tags_options, save_tag_cover_data, tag_id3v2_version);
SharedPtr<QMetaObject::Connection> connection = make_shared<QMetaObject::Connection>();
*connection = QObject::connect(&*reply, &TagReaderReply::Finished, this, [this, reply, ref, connection]() {
SongSaveTagsComplete(reply, ref.current_.url().toLocalFile(), ref.current_, ref.cover_action_);

View File

@@ -650,6 +650,47 @@
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="layout_id3v2_version">
<item>
<widget class="QLabel" name="label_id3v2_version">
<property name="text">
<string>ID3v2 version:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="combobox_id3v2_version">
<property name="enabled">
<bool>true</bool>
</property>
<item>
<property name="text">
<string>2.3</string>
</property>
</item>
<item>
<property name="text">
<string>2.4</string>
</property>
</item>
</widget>
</item>
<item>
<spacer name="spacer_id3v2_version">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<spacer name="spacer_albumart_bottom">
<property name="orientation">

View File

@@ -57,6 +57,7 @@
#include "core/enginemetadata.h"
#include "constants/timeconstants.h"
#include "enginebase.h"
#include "gsturl.h"
#include "gstengine.h"
#include "gstenginepipeline.h"
#include "gstbufferconsumer.h"
@@ -179,15 +180,18 @@ EngineBase::State GstEngine::state() const {
void GstEngine::StartPreloading(const QUrl &media_url, const QUrl &stream_url, const bool force_stop_at_end, const qint64 beginning_offset_nanosec, const qint64 end_offset_nanosec) {
const QByteArray gst_url = FixupUrl(stream_url);
const GstUrl gst_url = FixupUrl(stream_url);
// No crossfading, so we can just queue the new URL in the existing pipeline and get gapless playback (hopefully)
if (current_pipeline_) {
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url, beginning_offset_nanosec, force_stop_at_end ? end_offset_nanosec : 0);
if (!gst_url.source_device.isEmpty()) {
current_pipeline_->SetSourceDevice(gst_url.source_device);
}
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url.url, beginning_offset_nanosec, force_stop_at_end ? end_offset_nanosec : 0);
// Add request to discover the stream
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url.url;
}
}
}
@@ -198,7 +202,7 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
EngineBase::Load(media_url, stream_url, change, force_stop_at_end, beginning_offset_nanosec, end_offset_nanosec, ebur128_integrated_loudness_lufs);
const QByteArray gst_url = FixupUrl(stream_url);
const GstUrl gst_url = FixupUrl(stream_url);
bool crossfade = current_pipeline_ && ((crossfade_enabled_ && change & EngineBase::TrackChangeType::Manual) || (autocrossfade_enabled_ && change & EngineBase::TrackChangeType::Auto) || ((crossfade_enabled_ || autocrossfade_enabled_) && change & EngineBase::TrackChangeType::Intro));
@@ -215,9 +219,14 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
}
}
GstEnginePipelinePtr pipeline = CreatePipeline(media_url, stream_url, gst_url, static_cast<qint64>(beginning_offset_nanosec), force_stop_at_end ? end_offset_nanosec : 0, ebur128_loudness_normalizing_gain_db_);
GstEnginePipelinePtr pipeline = CreatePipeline(media_url, stream_url, gst_url.url, static_cast<qint64>(beginning_offset_nanosec), force_stop_at_end ? end_offset_nanosec : 0, ebur128_loudness_normalizing_gain_db_);
if (!pipeline) return false;
// Set the source device if one was extracted from the URL
if (!gst_url.source_device.isEmpty()) {
pipeline->SetSourceDevice(gst_url.source_device);
}
GstEnginePipelinePtr old_pipeline = current_pipeline_;
current_pipeline_ = pipeline;
@@ -253,8 +262,8 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
// Add request to discover the stream
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url.url;
}
}
@@ -814,16 +823,16 @@ void GstEngine::BufferingFinished() {
}
QByteArray GstEngine::FixupUrl(const QUrl &url) {
GstUrl GstEngine::FixupUrl(const QUrl &url) {
QByteArray uri;
GstUrl gst_url;
// It's a file:// url with a hostname set.
// QUrl::fromLocalFile does this when given a \\host\share\file path on Windows.
// Munge it back into a path that gstreamer will recognise.
if (url.isLocalFile() && !url.host().isEmpty()) {
QString str = "file:////"_L1 + url.host() + url.path();
uri = str.toUtf8();
gst_url.url = str.toUtf8();
}
else if (url.scheme() == "cdda"_L1) {
QString str;
@@ -837,16 +846,15 @@ QByteArray GstEngine::FixupUrl(const QUrl &url) {
// We keep the device in mind, and we will set it later using SourceSetupCallback
QStringList path = url.path().split(u'/');
str = QStringLiteral("cdda://%1").arg(path.takeLast());
QString device = path.join(u'/');
if (current_pipeline_) current_pipeline_->SetSourceDevice(device);
gst_url.source_device = path.join(u'/');
}
uri = str.toUtf8();
gst_url.url = str.toUtf8();
}
else {
uri = url.toEncoded();
gst_url.url = url.toEncoded();
}
return uri;
return gst_url;
}

View File

@@ -41,6 +41,7 @@
#include "includes/shared_ptr.h"
#include "enginebase.h"
#include "gsturl.h"
#include "gstenginepipeline.h"
#include "gstbufferconsumer.h"
@@ -123,7 +124,7 @@ class GstEngine : public EngineBase, public GstBufferConsumer {
void PipelineFinished(const int pipeline_id);
private:
QByteArray FixupUrl(const QUrl &url);
GstUrl FixupUrl(const QUrl &url);
void StartFadeout(GstEnginePipelinePtr pipeline);
void StartFadeoutPause();

View File

@@ -26,6 +26,7 @@
#include <cstdint>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <glib.h>
#include <glib-object.h>
@@ -42,6 +43,7 @@
#include <QObject>
#include <QCoreApplication>
#include <QtConcurrentRun>
#include <QThreadPool>
#include <QFuture>
#include <QFutureWatcher>
#include <QMutex>
@@ -90,6 +92,9 @@ constexpr std::chrono::milliseconds kFaderTimeoutMsec = 3000ms;
constexpr int kEqBandCount = 10;
constexpr int kEqBandFrequencies[] = { 60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000 };
// When within this many seconds of track end during gapless playback, ignore buffering messages
constexpr int kIgnoreBufferingNearEndSeconds = 5;
} // namespace
#ifdef __clang_
@@ -98,6 +103,23 @@ constexpr int kEqBandFrequencies[] = { 60, 170, 310, 600, 1000, 3000, 6000, 1200
int GstEnginePipeline::sId = 1;
QThreadPool *GstEnginePipeline::shared_state_threadpool() {
// C++11 guarantees thread-safe initialization of static local variables
static QThreadPool pool;
static const auto init = []() {
// Limit the number of threads to prevent resource exhaustion
// Use 2 threads max since state changes are typically sequential per pipeline
pool.setMaxThreadCount(2);
return true;
}();
Q_UNUSED(init);
return &pool;
}
GstEnginePipeline::GstEnginePipeline(QObject *parent)
: QObject(parent),
id_(sId++),
@@ -197,6 +219,23 @@ GstEnginePipeline::~GstEnginePipeline() {
if (pipeline_) {
// Wait for any ongoing state changes for this pipeline to complete before setting to NULL.
// This prevents race conditions with async state transitions.
{
// Copy futures to local list to avoid holding mutex during waitForFinished()
QList<QFuture<GstStateChangeReturn>> futures_to_wait;
{
QMutexLocker locker(&mutex_pending_state_changes_);
futures_to_wait = pending_state_changes_;
pending_state_changes_.clear();
}
// Wait for all pending futures to complete
for (QFuture<GstStateChangeReturn> &future : futures_to_wait) {
future.waitForFinished();
}
}
gst_element_set_state(pipeline_, GST_STATE_NULL);
GstElement *audiobin = nullptr;
@@ -1364,6 +1403,13 @@ void GstEnginePipeline::AboutToFinishCallback(GstPlayBin *playbin, gpointer self
GstEnginePipeline *instance = reinterpret_cast<GstEnginePipeline*>(self);
// Ignore about-to-finish if we're in the process of tearing down the pipeline
// This prevents race conditions in GStreamer's decodebin3 when rapidly switching tracks
// See: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/issues/4626
if (instance->finish_requested_.value()) {
return;
}
{
QMutexLocker l(&instance->mutex_url_);
qLog(Debug) << "Stream from URL" << instance->gst_url_ << "about to finish.";
@@ -1740,6 +1786,18 @@ void GstEnginePipeline::BufferingMessageReceived(GstMessage *msg) {
const GstState current_state = state();
if (percent < 100 && !buffering_.value()) {
// If we're near the end of the track and about-to-finish has been signaled, ignore buffering messages to prevent getting stuck in buffering state.
// This can happen with local files where spurious buffering messages appear near the end while the next track is being prepared for gapless playback.
if (about_to_finish_.value()) {
const qint64 current_position = position();
const qint64 track_length = length();
// Ignore buffering if we're within kIgnoreBufferingNearEndSeconds of the end
if (track_length > 0 && current_position > 0 && (track_length - current_position) < kIgnoreBufferingNearEndSeconds * kNsecPerSec) {
qLog(Debug) << "Ignoring buffering message near end of track (position:" << current_position << "length:" << track_length << ")";
return;
}
}
qLog(Debug) << "Buffering started";
buffering_ = true;
Q_EMIT BufferingStarted();
@@ -1841,9 +1899,15 @@ QFuture<GstStateChangeReturn> GstEnginePipeline::SetState(const GstState state)
watcher->deleteLater();
SetStateFinishedSlot(state, state_change_return);
});
QFuture<GstStateChangeReturn> future = QtConcurrent::run(&set_state_threadpool_, &gst_element_set_state, pipeline_, state);
QFuture<GstStateChangeReturn> future = QtConcurrent::run(shared_state_threadpool(), &gst_element_set_state, pipeline_, state);
watcher->setFuture(future);
// Track this future so destructor can wait for it
{
QMutexLocker locker(&mutex_pending_state_changes_);
pending_state_changes_.append(future);
}
return future;
}
@@ -1853,6 +1917,12 @@ void GstEnginePipeline::SetStateFinishedSlot(const GstState state, const GstStat
last_set_state_in_progress_ = GST_STATE_VOID_PENDING;
--set_state_in_progress_;
// Remove finished futures from tracking list to prevent unbounded growth
{
QMutexLocker locker(&mutex_pending_state_changes_);
pending_state_changes_.erase(std::remove_if(pending_state_changes_.begin(), pending_state_changes_.end(), [](const QFuture<GstStateChangeReturn> &f) { return f.isFinished(); }), pending_state_changes_.end());
}
switch (state_change_return) {
case GST_STATE_CHANGE_SUCCESS:
case GST_STATE_CHANGE_ASYNC:

View File

@@ -215,7 +215,8 @@ class GstEnginePipeline : public QObject {
static int sId;
mutex_protected<int> id_;
QThreadPool set_state_threadpool_;
// Shared thread pool for all pipeline state changes to prevent thread/FD exhaustion
static QThreadPool *shared_state_threadpool();
bool playbin3_support_;
bool volume_full_range_support_;
@@ -384,6 +385,10 @@ class GstEnginePipeline : public QObject {
mutex_protected<GstState> last_set_state_in_progress_;
mutex_protected<GstState> last_set_state_async_in_progress_;
// Track futures for this pipeline's state changes to allow waiting for them in destructor
QList<QFuture<GstStateChangeReturn>> pending_state_changes_;
QMutex mutex_pending_state_changes_;
};
using GstEnginePipelinePtr = QSharedPointer<GstEnginePipeline>;

32
src/engine/gsturl.h Normal file
View File

@@ -0,0 +1,32 @@
/*
* Strawberry Music Player
* Copyright 2025, 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 GSTURL_H
#define GSTURL_H
#include <QByteArray>
#include <QString>
class GstUrl {
public:
QByteArray url;
QString source_device;
};
#endif // GSTURL_H

View File

@@ -206,6 +206,15 @@ void Organize::ProcessSomeFiles() {
if (dest_type != Song::FileType::Unknown) {
// Get the preset
TranscoderPreset preset = Transcoder::PresetForFileType(dest_type);
// Check if the destination file already exists and we're not allowed to overwrite
const QString dest_filename_with_new_ext = Utilities::FiddleFileExtension(task.song_info_.new_filename_, preset.extension_);
if (ShouldSkipFile(dest_filename_with_new_ext)) {
qLog(Debug) << "Skipping" << task.song_info_.song_.url().toLocalFile() << ", destination file already exists";
tasks_complete_++;
continue;
}
qLog(Debug) << "Transcoding with" << preset.name_;
task.transcoded_filename_ = transcoder_->GetFile(task.song_info_.song_.url().toLocalFile(), preset);
@@ -222,6 +231,13 @@ void Organize::ProcessSomeFiles() {
}
}
// Check if the destination file already exists and we're not allowed to overwrite
if (ShouldSkipFile(task.song_info_.new_filename_)) {
qLog(Debug) << "Skipping" << task.song_info_.song_.url().toLocalFile() << ", destination file already exists";
tasks_complete_++;
continue;
}
MusicStorage::CopyJob job;
job.source_ = task.transcoded_filename_.isEmpty() ? task.song_info_.song_.url().toLocalFile() : task.transcoded_filename_;
job.destination_ = task.song_info_.new_filename_;
@@ -292,6 +308,16 @@ void Organize::ProcessSomeFiles() {
}
bool Organize::ShouldSkipFile(const QString &filename) const {
if (overwrite_) {
return false;
}
return QFile::exists(destination_->LocalPath() + QLatin1Char('/') + filename);
}
Song::FileType Organize::CheckTranscode(const Song::FileType original_type) const {
if (original_type == Song::FileType::Stream) return Song::FileType::Unknown;

View File

@@ -94,6 +94,7 @@ class Organize : public QObject {
void SetSongProgress(const float progress, const bool transcoded = false);
void UpdateProgress();
Song::FileType CheckTranscode(const Song::FileType original_type) const;
bool ShouldSkipFile(const QString &filename) const;
private:
struct Task {

View File

@@ -1205,7 +1205,7 @@ void Playlist::InsertItemsWithoutUndo(const PlaylistItemPtrList &items, const in
queue_->InsertFirst(indexes);
}
if (auto_sort_) {
if (auto_sort_ && !is_loading_) {
sort(static_cast<int>(sort_column_), sort_order_);
}

View File

@@ -22,6 +22,7 @@
#include "config.h"
#include <utility>
#include <algorithm>
#include <QWidget>
#include <QVariant>
@@ -33,9 +34,12 @@
#include <QAction>
#include <QActionGroup>
#include <QToolButton>
#include <QScreen>
#include <QGuiApplication>
#include "core/iconloader.h"
#include "core/settingsprovider.h"
#include "utilities/screenutils.h"
#include "playlistsequence.h"
#include "ui_playlistsequence.h"
@@ -43,7 +47,14 @@ using namespace Qt::Literals::StringLiterals;
namespace {
constexpr char kSettingsGroup[] = "PlaylistSequence";
}
// Base icon size for reference resolution (1920x1080 at 1.0 scale)
constexpr int kBaseIconSize = 20;
constexpr int kReferenceWidth = 1920;
constexpr int kReferenceHeight = 1080;
constexpr qreal kReferenceDevicePixelRatio = 1.0;
} // namespace
PlaylistSequence::PlaylistSequence(QWidget *parent, SettingsProvider *settings)
: QWidget(parent),
@@ -60,9 +71,11 @@ PlaylistSequence::PlaylistSequence(QWidget *parent, SettingsProvider *settings)
// Icons
ui_->repeat->setIcon(AddDesaturatedIcon(IconLoader::Load(u"media-playlist-repeat"_s)));
ui_->shuffle->setIcon(AddDesaturatedIcon(IconLoader::Load(u"media-playlist-shuffle"_s)));
const int base_icon_size = static_cast<int>(fontMetrics().height() * 1.2);
ui_->repeat->setIconSize(QSize(base_icon_size, base_icon_size));
ui_->shuffle->setIconSize(QSize(base_icon_size, base_icon_size));
// Calculate icon size dynamically based on screen resolution, aspect ratio, and scaling
const int icon_size = CalculateIconSize();
ui_->repeat->setIconSize(QSize(icon_size, icon_size));
ui_->shuffle->setIconSize(QSize(icon_size, icon_size));
// Remove arrow indicators
ui_->repeat->setStyleSheet(u"QToolButton::menu-indicator { image: none; }"_s);
@@ -99,6 +112,48 @@ PlaylistSequence::~PlaylistSequence() {
delete ui_;
}
int PlaylistSequence::CalculateIconSize() {
// Get screen information for the widget
QScreen *screen = Utilities::GetScreen(this);
if (!screen) {
screen = QGuiApplication::primaryScreen();
}
if (!screen) {
// Fallback to a reasonable default if no screen is available
return kBaseIconSize;
}
// Get screen properties
const QSize screen_size = screen->size();
const qreal device_pixel_ratio = screen->devicePixelRatio();
const int screen_width = screen_size.width();
const int screen_height = screen_size.height();
// Calculate scaling factors based on resolution
// Use the smaller dimension to handle both landscape and portrait orientations
const int min_dimension = std::min(screen_width, screen_height);
const int ref_min_dimension = std::min(kReferenceWidth, kReferenceHeight);
const qreal resolution_factor = static_cast<qreal>(min_dimension) / static_cast<qreal>(ref_min_dimension);
// Apply device pixel ratio (for high-DPI displays)
const qreal dpi_factor = device_pixel_ratio / kReferenceDevicePixelRatio;
// Calculate final icon size with combined scaling
// Formula: 50% from resolution scaling + 50% from DPI scaling + 50% base multiplier
// The 0.5 base ensures icons scale up appropriately across different displays
// Without it, icons would be too small on average displays
const qreal combined_factor = (resolution_factor * 0.5) + (dpi_factor * 0.5) + 0.5;
int calculated_size = static_cast<int>(kBaseIconSize * combined_factor);
// Clamp to reasonable bounds (minimum 16px, maximum 48px)
calculated_size = std::max(16, std::min(48, calculated_size));
return calculated_size;
}
void PlaylistSequence::Load() {
loading_ = true; // Stops these setter functions calling Save()

View File

@@ -83,6 +83,7 @@ class PlaylistSequence : public QWidget {
private:
void Load();
void Save();
int CalculateIconSize();
static QIcon AddDesaturatedIcon(const QIcon &icon);
static QPixmap DesaturatedPixmap(const QPixmap &pixmap);

View File

@@ -0,0 +1,277 @@
/*
* Strawberry Music Player
* Copyright 2019-2025, 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 <QByteArray>
#include <QMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QRegularExpressionMatchIterator>
#include "includes/shared_ptr.h"
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "qobuzcredentialfetcher.h"
using namespace Qt::Literals::StringLiterals;
namespace {
constexpr char kLoginPageUrl[] = "https://play.qobuz.com/login";
constexpr char kPlayQobuzUrl[] = "https://play.qobuz.com";
} // namespace
QobuzCredentialFetcher::QobuzCredentialFetcher(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: QObject(parent),
network_(network),
login_page_reply_(nullptr),
bundle_reply_(nullptr) {}
void QobuzCredentialFetcher::FetchCredentials() {
qLog(Debug) << "Qobuz: Fetching credentials from web player";
QNetworkRequest request(QUrl(QString::fromLatin1(kLoginPageUrl)));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s);
login_page_reply_ = network_->get(request);
QObject::connect(login_page_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::LoginPageReceived);
}
void QobuzCredentialFetcher::LoginPageReceived() {
if (!login_page_reply_) return;
QNetworkReply *reply = login_page_reply_;
login_page_reply_ = nullptr;
if (reply->error() != QNetworkReply::NoError) {
QString error = QStringLiteral("Failed to fetch login page: %1").arg(reply->errorString());
qLog(Error) << "Qobuz:" << error;
reply->deleteLater();
Q_EMIT CredentialsFetchError(error);
return;
}
const QString login_page = QString::fromUtf8(reply->readAll());
reply->deleteLater();
// Extract bundle.js URL from the login page
// Pattern: <script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>
static const QRegularExpression bundle_url_regex(u"<script src=\"(/resources/[\\d.]+-[a-z]\\d+/bundle\\.js)\"></script>"_s);
const QRegularExpressionMatch bundle_match = bundle_url_regex.match(login_page);
if (!bundle_match.hasMatch()) {
QString error = u"Failed to find bundle.js URL in login page"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
bundle_url_ = bundle_match.captured(1);
qLog(Debug) << "Qobuz: Found bundle URL:" << bundle_url_;
// Fetch the bundle.js
QNetworkRequest request(QUrl(QString::fromLatin1(kPlayQobuzUrl) + bundle_url_));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s);
bundle_reply_ = network_->get(request);
QObject::connect(bundle_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::BundleReceived);
}
void QobuzCredentialFetcher::BundleReceived() {
if (!bundle_reply_) return;
QNetworkReply *reply = bundle_reply_;
bundle_reply_ = nullptr;
if (reply->error() != QNetworkReply::NoError) {
QString error = QStringLiteral("Failed to fetch bundle.js: %1").arg(reply->errorString());
qLog(Error) << "Qobuz:" << error;
reply->deleteLater();
Q_EMIT CredentialsFetchError(error);
return;
}
const QString bundle = QString::fromUtf8(reply->readAll());
reply->deleteLater();
qLog(Debug) << "Qobuz: Bundle size:" << bundle.length();
const QString app_id = ExtractAppId(bundle);
if (app_id.isEmpty()) {
QString error = u"Failed to extract app_id from bundle"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
const QString app_secret = ExtractAppSecret(bundle);
if (app_secret.isEmpty()) {
QString error = u"Failed to extract app_secret from bundle"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
qLog(Debug) << "Qobuz: Successfully extracted credentials - app_id:" << app_id;
Q_EMIT CredentialsFetched(app_id, app_secret);
}
QString QobuzCredentialFetcher::ExtractAppId(const QString &bundle) {
// Pattern: production:{api:{appId:"(\d+)"
static const QRegularExpression app_id_regex(u"production:\\{api:\\{appId:\"(\\d+)\""_s);
const QRegularExpressionMatch app_id_match = app_id_regex.match(bundle);
if (app_id_match.hasMatch()) {
return app_id_match.captured(1);
}
return QString();
}
QString QobuzCredentialFetcher::ExtractAppSecret(const QString &bundle) {
// The plain-text appSecret in the bundle doesn't work for API requests.
// We need to use the Spoofbuz method to extract the real secrets:
// 1. Find seed/timezone pairs
// 2. Find info/extras for each timezone
// 3. Combine seed + info + extras, remove last 44 chars, base64 decode
// Pattern to find seed and timezone: [a-z].initialSeed("seed",window.utimezone.timezone)
static const QRegularExpression seed_regex(u"[a-z]\\.initialSeed\\(\"([\\w=]+)\",window\\.utimezone\\.([a-z]+)\\)"_s);
QMap<QString, QString> seeds; // timezone -> seed
QRegularExpressionMatchIterator seed_iter = seed_regex.globalMatch(bundle);
while (seed_iter.hasNext()) {
const QRegularExpressionMatch seed_match = seed_iter.next();
const QString seed = seed_match.captured(1);
const QString tz = seed_match.captured(2);
seeds[tz] = seed;
qLog(Debug) << "Qobuz: Found seed for timezone" << tz;
}
if (seeds.isEmpty()) {
qLog(Error) << "Qobuz: No seed/timezone pairs found in bundle";
return QString();
}
// Try each timezone - Berlin was confirmed working
const QStringList preferred_order = {u"berlin"_s, u"london"_s, u"abidjan"_s};
for (const QString &tz : preferred_order) {
if (!seeds.contains(tz)) {
continue;
}
// Pattern to find info and extras for this timezone
// name:"xxx/Berlin",info:"...",extras:"..."
const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1);
const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz);
const QRegularExpression info_regex(info_pattern);
const QRegularExpressionMatch info_match = info_regex.match(bundle);
if (!info_match.hasMatch()) {
qLog(Debug) << "Qobuz: No info/extras found for timezone" << tz;
continue;
}
const QString seed = seeds[tz];
const QString info = info_match.captured(1);
const QString extras = info_match.captured(2);
qLog(Debug) << "Qobuz: Decoding secret for timezone" << tz;
// Combine seed + info + extras
const QString combined = seed + info + extras;
// Remove last 44 characters
if (combined.length() <= 44) {
qLog(Debug) << "Qobuz: Combined string too short for timezone" << tz;
continue;
}
const QString trimmed = combined.left(combined.length() - 44);
// Base64 decode
const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1());
const QString secret = QString::fromLatin1(decoded);
// Validate: should be 32 hex characters
static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s);
if (hex_regex.match(secret).hasMatch()) {
qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz;
return secret;
}
qLog(Debug) << "Qobuz: Decoded secret invalid for timezone" << tz;
}
// Try any remaining timezones not in preferred order
for (auto it = seeds.constBegin(); it != seeds.constEnd(); ++it) {
const QString &tz = it.key();
if (preferred_order.contains(tz)) {
continue; // Already tried
}
const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1);
const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz);
const QRegularExpression info_regex(info_pattern);
const QRegularExpressionMatch info_match = info_regex.match(bundle);
if (!info_match.hasMatch()) {
continue;
}
const QString seed = it.value();
const QString info = info_match.captured(1);
const QString extras = info_match.captured(2);
const QString combined = seed + info + extras;
if (combined.length() <= 44) {
continue;
}
const QString trimmed = combined.left(combined.length() - 44);
const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1());
const QString secret = QString::fromLatin1(decoded);
static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s);
if (hex_regex.match(secret).hasMatch()) {
qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz;
return secret;
}
}
qLog(Error) << "Qobuz: Failed to decode any valid app_secret from bundle";
return QString();
}

View File

@@ -0,0 +1,59 @@
/*
* Strawberry Music Player
* Copyright 2019-2025, 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 QOBUZCREDENTIALFETCHER_H
#define QOBUZCREDENTIALFETCHER_H
#include "config.h"
#include <QObject>
#include <QString>
#include "includes/shared_ptr.h"
class QNetworkReply;
class NetworkAccessManager;
class QobuzCredentialFetcher : public QObject {
Q_OBJECT
public:
explicit QobuzCredentialFetcher(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
void FetchCredentials();
Q_SIGNALS:
void CredentialsFetched(const QString &app_id, const QString &app_secret);
void CredentialsFetchError(const QString &error);
private Q_SLOTS:
void LoginPageReceived();
void BundleReceived();
private:
QString ExtractAppId(const QString &bundle);
QString ExtractAppSecret(const QString &bundle);
const SharedPtr<NetworkAccessManager> network_;
QNetworkReply *login_page_reply_;
QNetworkReply *bundle_reply_;
QString bundle_url_;
};
#endif // QOBUZCREDENTIALFETCHER_H

View File

@@ -310,6 +310,10 @@ void QobuzService::HandleLoginSSLErrors(const QList<QSslError> &ssl_errors) {
void QobuzService::HandleAuthReply(QNetworkReply *reply) {
if (replies_.contains(reply)) {
replies_.removeAll(reply);
}
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
login_sent_ = false;

View File

@@ -106,6 +106,8 @@ class QobuzService : public StreamingService {
bool login_sent() const { return login_sent_; }
bool login_attempts() const { return login_attempts_; }
SharedPtr<NetworkAccessManager> network() const { return network_; }
uint GetStreamURL(const QUrl &url, QString &error);
SharedPtr<CollectionBackend> artists_collection_backend() override { return artists_collection_backend_; }

View File

@@ -115,6 +115,7 @@ void QobuzStreamURLRequest::GetStreamURL() {
const quint64 timestamp = static_cast<quint64>(QDateTime::currentSecsSinceEpoch());
ParamList params_to_sign = ParamList() << Param(u"format_id"_s, QString::number(service_->format()))
<< Param(u"intent"_s, u"stream"_s)
<< Param(u"track_id"_s, QString::number(song_id_));
std::sort(params_to_sign.begin(), params_to_sign.end());

View File

@@ -38,6 +38,7 @@
#include "core/settings.h"
#include "widgets/loginstatewidget.h"
#include "qobuz/qobuzservice.h"
#include "qobuz/qobuzcredentialfetcher.h"
#include "constants/qobuzsettings.h"
using namespace Qt::Literals::StringLiterals;
@@ -46,13 +47,15 @@ using namespace QobuzSettings;
QobuzSettingsPage::QobuzSettingsPage(SettingsDialog *dialog, const SharedPtr<QobuzService> service, QWidget *parent)
: SettingsPage(dialog, parent),
ui_(new Ui::QobuzSettingsPage),
service_(service) {
service_(service),
credential_fetcher_(nullptr) {
ui_->setupUi(this);
setWindowIcon(IconLoader::Load(u"qobuz"_s, true, 0, 32));
QObject::connect(ui_->button_login, &QPushButton::clicked, this, &QobuzSettingsPage::LoginClicked);
QObject::connect(ui_->login_state, &LoginStateWidget::LogoutClicked, this, &QobuzSettingsPage::LogoutClicked);
QObject::connect(ui_->button_fetch_credentials, &QPushButton::clicked, this, &QobuzSettingsPage::FetchCredentialsClicked);
QObject::connect(this, &QobuzSettingsPage::Login, &*service_, &StreamingService::LoginWithCredentials);
@@ -186,3 +189,40 @@ void QobuzSettingsPage::LoginFailure(const QString &failure_reason) {
QMessageBox::warning(this, tr("Authentication failed"), failure_reason);
}
void QobuzSettingsPage::FetchCredentialsClicked() {
ui_->button_fetch_credentials->setEnabled(false);
ui_->button_fetch_credentials->setText(tr("Fetching..."));
if (!credential_fetcher_) {
credential_fetcher_ = new QobuzCredentialFetcher(service_->network(), this);
QObject::connect(credential_fetcher_, &QobuzCredentialFetcher::CredentialsFetched, this, &QobuzSettingsPage::CredentialsFetched);
QObject::connect(credential_fetcher_, &QobuzCredentialFetcher::CredentialsFetchError, this, &QobuzSettingsPage::CredentialsFetchError);
}
credential_fetcher_->FetchCredentials();
}
void QobuzSettingsPage::CredentialsFetched(const QString &app_id, const QString &app_secret) {
ui_->app_id->setText(app_id);
ui_->app_secret->setText(app_secret);
ui_->checkbox_base64_secret->setChecked(false);
ui_->button_fetch_credentials->setEnabled(true);
ui_->button_fetch_credentials->setText(tr("Fetch Credentials"));
QMessageBox::information(this, tr("Credentials fetched"), tr("App ID and secret have been successfully fetched from the Qobuz web player."));
}
void QobuzSettingsPage::CredentialsFetchError(const QString &error) {
ui_->button_fetch_credentials->setEnabled(true);
ui_->button_fetch_credentials->setText(tr("Fetch Credentials"));
QMessageBox::warning(this, tr("Credential fetch failed"), error);
}

View File

@@ -30,6 +30,7 @@ class QShowEvent;
class QEvent;
class SettingsDialog;
class QobuzService;
class QobuzCredentialFetcher;
class Ui_QobuzSettingsPage;
class QobuzSettingsPage : public SettingsPage {
@@ -55,10 +56,14 @@ class QobuzSettingsPage : public SettingsPage {
void LogoutClicked();
void LoginSuccess();
void LoginFailure(const QString &failure_reason);
void FetchCredentialsClicked();
void CredentialsFetched(const QString &app_id, const QString &app_secret);
void CredentialsFetchError(const QString &error);
private:
Ui_QobuzSettingsPage *ui_;
const SharedPtr<QobuzService> service_;
QobuzCredentialFetcher *credential_fetcher_;
};
#endif // QOBUZSETTINGSPAGE_H

View File

@@ -115,6 +115,16 @@
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="button_fetch_credentials">
<property name="text">
<string>Fetch Credentials</string>
</property>
<property name="toolTip">
<string>Automatically fetch app ID and secret from Qobuz web player</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>

View File

@@ -134,13 +134,10 @@ void SmartPlaylistsModel::Init() {
// Append the new ones
s.beginWriteArray(collection_backend_->songs_table(), playlist_index + unwritten_defaults);
for (; version < default_smart_playlists_.count(); ++version) {
const GeneratorList generators = default_smart_playlists_.value(version);
for (PlaylistGeneratorPtr gen : generators) {
SaveGenerator(&s, playlist_index++, gen);
}
}
WriteDefaultsToSettings(&s, version, playlist_index);
s.endArray();
version = default_smart_playlists_.count();
}
s.setValue(collection_backend_->songs_table() + u"_version"_s, version);
@@ -269,6 +266,46 @@ PlaylistGeneratorPtr SmartPlaylistsModel::CreateGenerator(const QModelIndex &idx
}
void SmartPlaylistsModel::WriteDefaultsToSettings(Settings *s, const int start_version, const int start_index) {
int playlist_index = start_index;
for (int version = start_version; version < default_smart_playlists_.count(); ++version) {
const GeneratorList generators = default_smart_playlists_.value(version);
for (PlaylistGeneratorPtr gen : generators) {
SaveGenerator(s, playlist_index++, gen);
}
}
}
void SmartPlaylistsModel::RestoreDefaults() {
root_->ClearNotify();
Settings s;
s.beginGroup(kSettingsGroup);
int total_defaults = 0;
for (const GeneratorList &generators : default_smart_playlists_) {
total_defaults += static_cast<int>(generators.count());
}
s.beginWriteArray(collection_backend_->songs_table(), total_defaults);
WriteDefaultsToSettings(&s, 0, 0);
s.endArray();
s.setValue(collection_backend_->songs_table() + u"_version"_s, default_smart_playlists_.count());
const int count = s.beginReadArray(collection_backend_->songs_table());
for (int i = 0; i < count; ++i) {
s.setArrayIndex(i);
ItemFromSmartPlaylist(s, true);
}
s.endArray();
s.endGroup();
}
QVariant SmartPlaylistsModel::data(const QModelIndex &idx, const int role) const {
if (!idx.isValid()) return QVariant();

View File

@@ -66,6 +66,7 @@ class SmartPlaylistsModel : public SimpleTreeModel<SmartPlaylistsItem> {
void AddGenerator(PlaylistGeneratorPtr gen);
void UpdateGenerator(const QModelIndex &idx, PlaylistGeneratorPtr gen);
void DeleteGenerator(const QModelIndex &idx);
void RestoreDefaults();
private:
QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
@@ -79,6 +80,7 @@ class SmartPlaylistsModel : public SimpleTreeModel<SmartPlaylistsItem> {
static void SaveGenerator(Settings *s, const int i, PlaylistGeneratorPtr generator);
void ItemFromSmartPlaylist(const Settings &s, const bool notify);
void WriteDefaultsToSettings(Settings *s, const int start_version, const int start_index);
private:
SharedPtr<CollectionBackend> collection_backend_;

View File

@@ -23,6 +23,7 @@
#include <QMenu>
#include <QSettings>
#include <QShowEvent>
#include <QMessageBox>
#include "core/iconloader.h"
#include "core/mimedata.h"
@@ -60,6 +61,7 @@ SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(const SharedPtr<Player>
action_new_smart_playlist_(nullptr),
action_edit_smart_playlist_(nullptr),
action_delete_smart_playlist_(nullptr),
action_restore_defaults_(nullptr),
action_append_to_playlist_(nullptr),
action_replace_current_playlist_(nullptr),
action_open_in_new_playlist_(nullptr),
@@ -74,6 +76,7 @@ SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(const SharedPtr<Player>
model_->Init();
action_new_smart_playlist_ = context_menu_->addAction(IconLoader::Load(u"document-new"_s), tr("New smart playlist..."), this, &SmartPlaylistsViewContainer::NewSmartPlaylist);
action_restore_defaults_ = context_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Restore defaults"), this, &SmartPlaylistsViewContainer::RestoreDefaultsFromContext);
action_append_to_playlist_ = context_menu_selected_->addAction(IconLoader::Load(u"media-playback-start"_s), tr("Append to current playlist"), this, &SmartPlaylistsViewContainer::AppendToPlaylist);
action_replace_current_playlist_ = context_menu_selected_->addAction(IconLoader::Load(u"media-playback-start"_s), tr("Replace current playlist"), this, &SmartPlaylistsViewContainer::ReplaceCurrentPlaylist);
@@ -90,13 +93,16 @@ SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(const SharedPtr<Player>
action_delete_smart_playlist_ = context_menu_selected_->addAction(IconLoader::Load(u"edit-delete"_s), tr("Delete smart playlist"), this, &SmartPlaylistsViewContainer::DeleteSmartPlaylistFromContext);
context_menu_selected_->addSeparator();
context_menu_selected_->addAction(action_restore_defaults_);
ui_->new_->setDefaultAction(action_new_smart_playlist_);
ui_->edit_->setIcon(IconLoader::Load(u"edit-rename"_s));
ui_->delete_->setIcon(IconLoader::Load(u"edit-delete"_s));
ui_->restore_->setIcon(IconLoader::Load(u"view-refresh"_s));
QObject::connect(ui_->edit_, &QToolButton::clicked, this, &SmartPlaylistsViewContainer::EditSmartPlaylistFromButton);
QObject::connect(ui_->delete_, &QToolButton::clicked, this, &SmartPlaylistsViewContainer::DeleteSmartPlaylistFromButton);
QObject::connect(ui_->restore_, &QToolButton::clicked, this, &SmartPlaylistsViewContainer::RestoreDefaults);
QObject::connect(ui_->view, &SmartPlaylistsView::ItemsSelectedChanged, this, &SmartPlaylistsViewContainer::ItemsSelectedChanged);
QObject::connect(ui_->view, &SmartPlaylistsView::doubleClicked, this, &SmartPlaylistsViewContainer::ItemDoubleClicked);
@@ -130,6 +136,7 @@ void SmartPlaylistsViewContainer::ReloadSettings() {
ui_->new_->setIconSize(QSize(iconsize, iconsize));
ui_->delete_->setIconSize(QSize(iconsize, iconsize));
ui_->edit_->setIconSize(QSize(iconsize, iconsize));
ui_->restore_->setIconSize(QSize(iconsize, iconsize));
}
@@ -304,3 +311,18 @@ void SmartPlaylistsViewContainer::ItemDoubleClicked(const QModelIndex &idx) {
Q_EMIT AddToPlaylist(q_mimedata);
}
void SmartPlaylistsViewContainer::RestoreDefaultsFromContext() {
RestoreDefaults();
}
void SmartPlaylistsViewContainer::RestoreDefaults() {
const QMessageBox::StandardButton messagebox_answer = QMessageBox::question(this, tr("Restore defaults"), tr("Are you sure you want to restore the default smart playlists? This will remove all custom smart playlists"), QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (messagebox_answer == QMessageBox::Yes) {
model_->RestoreDefaults();
}
}

View File

@@ -83,11 +83,13 @@ class SmartPlaylistsViewContainer : public QWidget {
void EditSmartPlaylist(const QModelIndex &idx);
void DeleteSmartPlaylist(const QModelIndex &idx);
void RestoreDefaults();
void EditSmartPlaylistFromButton();
void DeleteSmartPlaylistFromButton();
void EditSmartPlaylistFromContext();
void DeleteSmartPlaylistFromContext();
void RestoreDefaultsFromContext();
void NewSmartPlaylistFinished();
void EditSmartPlaylistFinished();
@@ -113,6 +115,7 @@ class SmartPlaylistsViewContainer : public QWidget {
QAction *action_new_smart_playlist_;
QAction *action_edit_smart_playlist_;
QAction *action_delete_smart_playlist_;
QAction *action_restore_defaults_;
QAction *action_append_to_playlist_;
QAction *action_replace_current_playlist_;
QAction *action_open_in_new_playlist_;

View File

@@ -95,6 +95,19 @@
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="restore_">
<property name="toolTip">
<string>Restore defaults</string>
</property>
<property name="iconSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
</widget>
</item>
<item>
<spacer name="spacer_buttons">
<property name="orientation">

View File

@@ -0,0 +1,29 @@
/*
* Strawberry Music Player
* Copyright 2025, 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 TAGID3V2VERSION_H
#define TAGID3V2VERSION_H
enum class TagID3v2Version {
Default = 0, // Use existing version or library default
V3 = 3,
V4 = 4
};
#endif // TAGID3V2VERSION_H

View File

@@ -32,6 +32,7 @@
#include "savetagsoptions.h"
#include "savetagcoverdata.h"
#include "albumcovertagdata.h"
#include "tagid3v2version.h"
class TagReaderBase {
public:
@@ -45,7 +46,7 @@ class TagReaderBase {
virtual TagReaderResult ReadStream(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token, Song *song) const = 0;
#endif
virtual TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const = 0;
virtual TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version id3v2_version) const = 0;
virtual TagReaderResult LoadEmbeddedCover(const QString &filename, QByteArray &data) const = 0;
virtual TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const = 0;

View File

@@ -50,6 +50,7 @@
#include "tagreaderreadstreamreply.h"
#include "tagreaderloadcoverdatareply.h"
#include "tagreaderloadcoverimagereply.h"
#include "tagid3v2version.h"
using std::dynamic_pointer_cast;
using namespace Qt::Literals::StringLiterals;
@@ -189,7 +190,7 @@ void TagReaderClient::ProcessRequest(TagReaderRequestPtr request) {
}
#endif // HAVE_STREAMTAGREADER
else if (TagReaderWriteFileRequestPtr write_file_request = dynamic_pointer_cast<TagReaderWriteFileRequest>(request)) {
result = WriteFileBlocking(write_file_request->filename, write_file_request->song, write_file_request->save_tags_options, write_file_request->save_tag_cover_data);
result = WriteFileBlocking(write_file_request->filename, write_file_request->song, write_file_request->save_tags_options, write_file_request->save_tag_cover_data, write_file_request->tag_id3v2_version);
}
else if (TagReaderLoadCoverDataRequestPtr load_cover_data_request = dynamic_pointer_cast<TagReaderLoadCoverDataRequest>(request)) {
QByteArray cover_data;
@@ -303,13 +304,13 @@ TagReaderReadStreamReplyPtr TagReaderClient::ReadStreamAsync(const QUrl &url, co
}
#endif // HAVE_STREAMTAGREADER
TagReaderResult TagReaderClient::WriteFileBlocking(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) {
TagReaderResult TagReaderClient::WriteFileBlocking(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) {
return tagreader_.WriteFile(filename, song, save_tags_options, save_tag_cover_data);
return tagreader_.WriteFile(filename, song, save_tags_options, save_tag_cover_data, tag_id3v2_version);
}
TagReaderReplyPtr TagReaderClient::WriteFileAsync(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) {
TagReaderReplyPtr TagReaderClient::WriteFileAsync(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) {
Q_ASSERT(QThread::currentThread() != thread());
@@ -321,6 +322,7 @@ TagReaderReplyPtr TagReaderClient::WriteFileAsync(const QString &filename, const
request->song = song;
request->save_tags_options = save_tags_options;
request->save_tag_cover_data = save_tag_cover_data;
request->tag_id3v2_version = tag_id3v2_version;
EnqueueRequest(request);

View File

@@ -43,6 +43,7 @@
#include "tagreaderloadcoverimagereply.h"
#include "savetagsoptions.h"
#include "savetagcoverdata.h"
#include "tagid3v2version.h"
class QThread;
class Song;
@@ -72,8 +73,8 @@ class TagReaderClient : public QObject {
[[nodiscard]] TagReaderReadStreamReplyPtr ReadStreamAsync(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token);
#endif
TagReaderResult WriteFileBlocking(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options = SaveTagsOption::Tags, const SaveTagCoverData &save_tag_cover_data = SaveTagCoverData());
[[nodiscard]] TagReaderReplyPtr WriteFileAsync(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options = SaveTagsOption::Tags, const SaveTagCoverData &save_tag_cover_data = SaveTagCoverData());
TagReaderResult WriteFileBlocking(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options = SaveTagsOption::Tags, const SaveTagCoverData &save_tag_cover_data = SaveTagCoverData(), const TagID3v2Version tag_id3v2_version = TagID3v2Version::Default);
[[nodiscard]] TagReaderReplyPtr WriteFileAsync(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options = SaveTagsOption::Tags, const SaveTagCoverData &save_tag_cover_data = SaveTagCoverData(), const TagID3v2Version tag_id3v2_version = TagID3v2Version::Default);
TagReaderResult LoadCoverDataBlocking(const QString &filename, QByteArray &data);
TagReaderResult LoadCoverImageBlocking(const QString &filename, QImage &image);

View File

@@ -32,6 +32,7 @@
#include "core/logging.h"
#include "tagreaderbase.h"
#include "tagreadertaglib.h"
#include "tagid3v2version.h"
using namespace Qt::Literals::StringLiterals;
@@ -317,12 +318,13 @@ TagReaderResult TagReaderGME::ReadStream(const QUrl &url, const QString &filenam
}
#endif
TagReaderResult TagReaderGME::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const {
TagReaderResult TagReaderGME::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version id3v2_version) const {
Q_UNUSED(filename);
Q_UNUSED(song);
Q_UNUSED(save_tags_options);
Q_UNUSED(save_tag_cover_data);
Q_UNUSED(id3v2_version);
return TagReaderResult::ErrorCode::Unsupported;

View File

@@ -25,6 +25,7 @@
#include <QFileInfo>
#include "tagreaderbase.h"
#include "tagid3v2version.h"
namespace GME {
bool IsSupportedFormat(const QFileInfo &fileinfo);
@@ -107,7 +108,7 @@ class TagReaderGME : public TagReaderBase {
TagReaderResult ReadStream(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token, Song *song) const override;
#endif
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const override;
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version id3v2_version) const override;
TagReaderResult LoadEmbeddedCover(const QString &filename, QByteArray &data) const override;
TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const override;

View File

@@ -46,6 +46,7 @@
#include <taglib/apeproperties.h>
#include <taglib/id3v2tag.h>
#include <taglib/id3v2frame.h>
#include <taglib/id3v2header.h>
#include <taglib/attachedpictureframe.h>
#include <taglib/textidentificationframe.h>
#include <taglib/unsynchronizedlyricsframe.h>
@@ -104,6 +105,7 @@
#include "constants/timeconstants.h"
#include "albumcovertagdata.h"
#include "tagid3v2version.h"
using std::make_unique;
using namespace Qt::Literals::StringLiterals;
@@ -604,8 +606,14 @@ TagReaderResult TagReaderTagLib::ReadStream(const QUrl &url,
void TagReaderTagLib::ParseID3v2Tags(TagLib::ID3v2::Tag *tag, QString *disc, QString *compilation, Song *song) const {
if (!tag) return;
TagLib::ID3v2::FrameListMap map = tag->frameListMap();
if (tag->header()) {
song->set_id3v2_version(tag->header()->majorVersion());
}
if (map.contains(kID3v2_Disc)) *disc = TagLibStringToQString(map[kID3v2_Disc].front()->toString()).trimmed();
if (map.contains(kID3v2_Composer)) song->set_composer(map[kID3v2_Composer].front()->toString());
if (map.contains(kID3v2_ComposerSort)) song->set_composersort(map[kID3v2_ComposerSort].front()->toString());
@@ -1042,7 +1050,7 @@ void TagReaderTagLib::ParseASFAttribute(const TagLib::ASF::AttributeListMap &att
}
TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const {
TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) const {
if (filename.isEmpty()) {
return TagReaderResult::ErrorCode::FilenameMissing;
@@ -1265,7 +1273,34 @@ TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &
}
}
const bool success = fileref->save();
// Determine ID3v2 version to use and convert to TagLib type
TagLib::ID3v2::Version taglib_id3v2_version = TagLib::ID3v2::v4;
if (tag_id3v2_version == TagID3v2Version::V3) {
taglib_id3v2_version = TagLib::ID3v2::v3;
}
else if (tag_id3v2_version == TagID3v2Version::V4) {
taglib_id3v2_version = TagLib::ID3v2::v4;
}
bool success = false;
// For MPEG files, use save with ID3v2 version parameter
if (TagLib::MPEG::File *file_mpeg = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
success = file_mpeg->save(TagLib::MPEG::File::AllTags, TagLib::File::StripOthers, taglib_id3v2_version);
}
// For WAV files with ID3v2 tags
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
success = file_wav->save(TagLib::RIFF::WAV::File::AllTags, TagLib::File::StripOthers, taglib_id3v2_version);
}
// For AIFF files with ID3v2 tags
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
success = file_aiff->save(taglib_id3v2_version);
}
// For all other file types, use default save
else {
success = fileref->save();
}
#ifdef Q_OS_LINUX
if (success) {
// Linux: inotify doesn't seem to notice the change to the file unless we change the timestamps as well. (this is what touch does)

View File

@@ -46,6 +46,7 @@
#include "tagreaderbase.h"
#include "savetagcoverdata.h"
#include "tagid3v2version.h"
#undef TStringToQString
#undef QStringToTString
@@ -72,7 +73,7 @@ class TagReaderTagLib : public TagReaderBase {
TagReaderResult ReadStream(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token, Song *song) const override;
#endif
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const override;
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) const override;
TagReaderResult LoadEmbeddedCover(const QString &filename, QByteArray &data) const override;
TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const override;

View File

@@ -27,6 +27,7 @@
#include "tagreaderrequest.h"
#include "savetagsoptions.h"
#include "savetagcoverdata.h"
#include "tagid3v2version.h"
using std::make_shared;
@@ -39,6 +40,7 @@ class TagReaderWriteFileRequest : public TagReaderRequest {
SaveTagsOptions save_tags_options;
Song song;
SaveTagCoverData save_tag_cover_data;
TagID3v2Version tag_id3v2_version;
};
using TagReaderWriteFileRequestPtr = SharedPtr<TagReaderWriteFileRequest>;

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, 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
@@ -191,3 +191,26 @@ void AutoExpandingTreeView::DownAndFocus() {
setCurrentIndex(moveCursor(QAbstractItemView::MoveDown, Qt::NoModifier));
setFocus();
}
void AutoExpandingTreeView::currentChanged(const QModelIndex &current_index, const QModelIndex &previous_index) {
QTreeView::currentChanged(current_index, previous_index);
// Ensure the newly selected item is visible after keyboard navigation.
// This fixes the issue where the cursor highlight disappears off-screen when using arrow keys to navigate through expanded lists.
if (current_index.isValid()) {
const QRect current_index_rect = visualRect(current_index);
const QRect viewport_rect = viewport()->rect();
// Calculate if we need to scroll to keep the item visible
// If the item extends below the viewport, scroll it to the bottom
// If the item extends above the viewport, scroll it to the top
if (current_index_rect.bottom() > viewport_rect.bottom()) {
scrollTo(current_index, QAbstractItemView::PositionAtBottom);
}
else if (current_index_rect.top() < viewport_rect.top()) {
scrollTo(current_index, QAbstractItemView::PositionAtTop);
}
}
}

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, 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
@@ -55,6 +55,7 @@ class AutoExpandingTreeView : public QTreeView {
protected:
// QAbstractItemView
void reset() override;
void currentChanged(const QModelIndex &current_index, const QModelIndex &previous_index) override;
// QWidget
void mousePressEvent(QMouseEvent *event) override;