Compare commits
24 Commits
1.2.16
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df4afe363 | ||
|
|
350d907e8b | ||
|
|
eaced733bb | ||
|
|
2abe4576a9 | ||
|
|
8d262959c1 | ||
|
|
b9b70399d8 | ||
|
|
527ccd212a | ||
|
|
4a5afbeb1e | ||
|
|
63c14e014b | ||
|
|
801658c6b9 | ||
|
|
16fe665295 | ||
|
|
2bb0dbada2 | ||
|
|
2cd9498469 | ||
|
|
d1ee27fff9 | ||
|
|
91adf5ba32 | ||
|
|
d68f464269 | ||
|
|
c684a95f89 | ||
|
|
1d03bb2178 | ||
|
|
39f9128ecf | ||
|
|
ca2e802239 | ||
|
|
9a513a9a56 | ||
|
|
1c2e87b741 | ||
|
|
fe4d9979ce | ||
|
|
d8ae790ebf |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@
|
||||
/CMakeSettings.json
|
||||
/dist/scripts/maketarball.sh
|
||||
/debian/changelog
|
||||
_codeql_detected_source_root
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
32
src/engine/gsturl.h
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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_);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
277
src/qobuz/qobuzcredentialfetcher.cpp
Normal file
277
src/qobuz/qobuzcredentialfetcher.cpp
Normal 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();
|
||||
|
||||
}
|
||||
59
src/qobuz/qobuzcredentialfetcher.h
Normal file
59
src/qobuz/qobuzcredentialfetcher.h
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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">
|
||||
|
||||
29
src/tagreader/tagid3v2version.h
Normal file
29
src/tagreader/tagid3v2version.h
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 ¤t_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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 ¤t_index, const QModelIndex &previous_index) override;
|
||||
|
||||
// QWidget
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
|
||||
Reference in New Issue
Block a user