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
|
/CMakeSettings.json
|
||||||
/dist/scripts/maketarball.sh
|
/dist/scripts/maketarball.sh
|
||||||
/debian/changelog
|
/debian/changelog
|
||||||
|
_codeql_detected_source_root
|
||||||
|
|||||||
@@ -1463,6 +1463,7 @@ optional_source(HAVE_QOBUZ
|
|||||||
src/qobuz/qobuzrequest.cpp
|
src/qobuz/qobuzrequest.cpp
|
||||||
src/qobuz/qobuzstreamurlrequest.cpp
|
src/qobuz/qobuzstreamurlrequest.cpp
|
||||||
src/qobuz/qobuzfavoriterequest.cpp
|
src/qobuz/qobuzfavoriterequest.cpp
|
||||||
|
src/qobuz/qobuzcredentialfetcher.cpp
|
||||||
src/settings/qobuzsettingspage.cpp
|
src/settings/qobuzsettingspage.cpp
|
||||||
src/covermanager/qobuzcoverprovider.cpp
|
src/covermanager/qobuzcoverprovider.cpp
|
||||||
HEADERS
|
HEADERS
|
||||||
@@ -1472,6 +1473,7 @@ optional_source(HAVE_QOBUZ
|
|||||||
src/qobuz/qobuzrequest.h
|
src/qobuz/qobuzrequest.h
|
||||||
src/qobuz/qobuzstreamurlrequest.h
|
src/qobuz/qobuzstreamurlrequest.h
|
||||||
src/qobuz/qobuzfavoriterequest.h
|
src/qobuz/qobuzfavoriterequest.h
|
||||||
|
src/qobuz/qobuzcredentialfetcher.h
|
||||||
src/settings/qobuzsettingspage.h
|
src/settings/qobuzsettingspage.h
|
||||||
src/covermanager/qobuzcoverprovider.h
|
src/covermanager/qobuzcoverprovider.h
|
||||||
UI
|
UI
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set(STRAWBERRY_VERSION_MINOR 2)
|
|||||||
set(STRAWBERRY_VERSION_PATCH 16)
|
set(STRAWBERRY_VERSION_PATCH 16)
|
||||||
#set(STRAWBERRY_VERSION_PRERELEASE rc1)
|
#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}")
|
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.";
|
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.
|
// 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;
|
QString fingerprint;
|
||||||
#ifdef HAVE_SONGFINGERPRINTING
|
#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.
|
else { // Search the DB by fingerprint.
|
||||||
QString fingerprint;
|
QString fingerprint;
|
||||||
|
|||||||
@@ -3318,7 +3318,7 @@ void MainWindow::PlaylistDelete() {
|
|||||||
|
|
||||||
if (DeleteConfirmationDialog::warning(files) != QDialogButtonBox::Yes) return;
|
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();
|
app_->player()->Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkInformation>
|
||||||
|
|
||||||
#include "networkaccessmanager.h"
|
#include "networkaccessmanager.h"
|
||||||
#include "threadsafenetworkdiskcache.h"
|
#include "threadsafenetworkdiskcache.h"
|
||||||
@@ -41,6 +42,22 @@ NetworkAccessManager::NetworkAccessManager(QObject *parent)
|
|||||||
setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
|
setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
setCache(new ThreadSafeNetworkDiskCache(this));
|
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) {
|
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_integrated_loudness_lufs_;
|
||||||
std::optional<double> ebur128_loudness_range_lu_;
|
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 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.
|
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),
|
rating_(-1),
|
||||||
bpm_(-1),
|
bpm_(-1),
|
||||||
|
|
||||||
|
id3v2_version_(0),
|
||||||
|
|
||||||
init_from_file_(false),
|
init_from_file_(false),
|
||||||
suspicious_tags_(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_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_; }
|
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_title() { return &d->title_; }
|
||||||
QString *Song::mutable_album() { return &d->album_; }
|
QString *Song::mutable_album() { return &d->album_; }
|
||||||
QString *Song::mutable_artist() { return &d->artist_; }
|
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_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_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_init_from_file(const bool v) { d->init_from_file_ = v; }
|
||||||
|
|
||||||
void Song::set_stream_url(const QUrl &v) { d->stream_url_ = 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) {
|
int Song::ColumnIndex(const QString &field) {
|
||||||
|
|
||||||
return static_cast<int>(kRowIdColumns.indexOf(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_integrated_loudness_lufs() const;
|
||||||
std::optional<double> ebur128_loudness_range_lu() const;
|
std::optional<double> ebur128_loudness_range_lu() const;
|
||||||
|
|
||||||
|
int id3v2_version() const;
|
||||||
|
|
||||||
QString *mutable_title();
|
QString *mutable_title();
|
||||||
QString *mutable_album();
|
QString *mutable_album();
|
||||||
QString *mutable_artist();
|
QString *mutable_artist();
|
||||||
@@ -349,6 +351,8 @@ class Song {
|
|||||||
void set_ebur128_integrated_loudness_lufs(const std::optional<double> v);
|
void set_ebur128_integrated_loudness_lufs(const std::optional<double> v);
|
||||||
void set_ebur128_loudness_range_lu(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_init_from_file(const bool v);
|
||||||
|
|
||||||
void set_stream_url(const QUrl &v);
|
void set_stream_url(const QUrl &v);
|
||||||
@@ -439,6 +443,8 @@ class Song {
|
|||||||
static bool save_embedded_cover_supported(const FileType filetype);
|
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 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 int ColumnIndex(const QString &field);
|
||||||
static QString JoinSpec(const QString &table);
|
static QString JoinSpec(const QString &table);
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
#include "utilities/coverutils.h"
|
#include "utilities/coverutils.h"
|
||||||
#include "utilities/coveroptions.h"
|
#include "utilities/coveroptions.h"
|
||||||
#include "tagreader/tagreaderclient.h"
|
#include "tagreader/tagreaderclient.h"
|
||||||
|
#include "tagreader/tagid3v2version.h"
|
||||||
#include "widgets/busyindicator.h"
|
#include "widgets/busyindicator.h"
|
||||||
#include "widgets/lineedit.h"
|
#include "widgets/lineedit.h"
|
||||||
#include "collection/collectionbackend.h"
|
#include "collection/collectionbackend.h"
|
||||||
@@ -104,14 +105,29 @@
|
|||||||
using std::make_shared;
|
using std::make_shared;
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
|
|
||||||
|
#ifdef __clang__
|
||||||
|
# pragma clang diagnostic push
|
||||||
|
# pragma clang diagnostic ignored "-Wunused-const-variable"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr char kSettingsGroup[] = "EditTagDialog";
|
constexpr char kSettingsGroup[] = "EditTagDialog";
|
||||||
constexpr int kSmallImageSize = 128;
|
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
|
} // namespace
|
||||||
|
|
||||||
const char EditTagDialog::kTagsDifferentHintText[] = QT_TR_NOOP("(different across multiple songs)");
|
const char EditTagDialog::kTagsDifferentHintText[] = QT_TR_NOOP("(different across multiple songs)");
|
||||||
const char EditTagDialog::kArtDifferentHintText[] = QT_TR_NOOP("Different art 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,
|
EditTagDialog::EditTagDialog(const SharedPtr<NetworkAccessManager> network,
|
||||||
const SharedPtr<TagReaderClient> tagreader_client,
|
const SharedPtr<TagReaderClient> tagreader_client,
|
||||||
const SharedPtr<CollectionBackend> collection_backend,
|
const SharedPtr<CollectionBackend> collection_backend,
|
||||||
@@ -708,6 +724,9 @@ void EditTagDialog::SelectionChanged() {
|
|||||||
bool titlesort_enabled = false;
|
bool titlesort_enabled = false;
|
||||||
bool artistsort_enabled = false;
|
bool artistsort_enabled = false;
|
||||||
bool albumsort_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) {
|
for (const QModelIndex &idx : indexes) {
|
||||||
if (data_.value(idx.row()).cover_action_ == UpdateCoverAction::None) {
|
if (data_.value(idx.row()).cover_action_ == UpdateCoverAction::None) {
|
||||||
data_[idx.row()].cover_result_ = AlbumCoverImageResult();
|
data_[idx.row()].cover_result_ = AlbumCoverImageResult();
|
||||||
@@ -769,6 +788,15 @@ void EditTagDialog::SelectionChanged() {
|
|||||||
if (song.albumsort_supported()) {
|
if (song.albumsort_supported()) {
|
||||||
albumsort_enabled = true;
|
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;
|
QString summary;
|
||||||
@@ -840,6 +868,23 @@ void EditTagDialog::SelectionChanged() {
|
|||||||
ui_->artistsort->setEnabled(artistsort_enabled);
|
ui_->artistsort->setEnabled(artistsort_enabled);
|
||||||
ui_->albumsort->setEnabled(albumsort_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) {
|
void EditTagDialog::UpdateUI(const QModelIndexList &indexes) {
|
||||||
@@ -1371,6 +1416,13 @@ void EditTagDialog::SaveData() {
|
|||||||
}
|
}
|
||||||
save_tag_cover_data.cover_data = ref.cover_result_.image_data;
|
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;
|
TagReaderClient::SaveOptions save_tags_options;
|
||||||
if (save_tags) {
|
if (save_tags) {
|
||||||
save_tags_options |= TagReaderClient::SaveOption::Tags;
|
save_tags_options |= TagReaderClient::SaveOption::Tags;
|
||||||
@@ -1384,7 +1436,7 @@ void EditTagDialog::SaveData() {
|
|||||||
if (save_embedded_cover) {
|
if (save_embedded_cover) {
|
||||||
save_tags_options |= TagReaderClient::SaveOption::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>();
|
SharedPtr<QMetaObject::Connection> connection = make_shared<QMetaObject::Connection>();
|
||||||
*connection = QObject::connect(&*reply, &TagReaderReply::Finished, this, [this, reply, ref, connection]() {
|
*connection = QObject::connect(&*reply, &TagReaderReply::Finished, this, [this, reply, ref, connection]() {
|
||||||
SongSaveTagsComplete(reply, ref.current_.url().toLocalFile(), ref.current_, ref.cover_action_);
|
SongSaveTagsComplete(reply, ref.current_.url().toLocalFile(), ref.current_, ref.cover_action_);
|
||||||
|
|||||||
@@ -650,6 +650,47 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</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>
|
<item>
|
||||||
<spacer name="spacer_albumart_bottom">
|
<spacer name="spacer_albumart_bottom">
|
||||||
<property name="orientation">
|
<property name="orientation">
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
#include "core/enginemetadata.h"
|
#include "core/enginemetadata.h"
|
||||||
#include "constants/timeconstants.h"
|
#include "constants/timeconstants.h"
|
||||||
#include "enginebase.h"
|
#include "enginebase.h"
|
||||||
|
#include "gsturl.h"
|
||||||
#include "gstengine.h"
|
#include "gstengine.h"
|
||||||
#include "gstenginepipeline.h"
|
#include "gstenginepipeline.h"
|
||||||
#include "gstbufferconsumer.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) {
|
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)
|
// No crossfading, so we can just queue the new URL in the existing pipeline and get gapless playback (hopefully)
|
||||||
if (current_pipeline_) {
|
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
|
// Add request to discover the stream
|
||||||
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
|
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
|
||||||
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
|
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.url.constData())) {
|
||||||
qLog(Error) << "Failed to start stream discovery for" << gst_url;
|
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);
|
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));
|
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;
|
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_;
|
GstEnginePipelinePtr old_pipeline = current_pipeline_;
|
||||||
current_pipeline_ = 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
|
// Add request to discover the stream
|
||||||
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
|
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
|
||||||
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
|
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.url.constData())) {
|
||||||
qLog(Error) << "Failed to start stream discovery for" << gst_url;
|
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.
|
// It's a file:// url with a hostname set.
|
||||||
// QUrl::fromLocalFile does this when given a \\host\share\file path on Windows.
|
// QUrl::fromLocalFile does this when given a \\host\share\file path on Windows.
|
||||||
// Munge it back into a path that gstreamer will recognise.
|
// Munge it back into a path that gstreamer will recognise.
|
||||||
if (url.isLocalFile() && !url.host().isEmpty()) {
|
if (url.isLocalFile() && !url.host().isEmpty()) {
|
||||||
QString str = "file:////"_L1 + url.host() + url.path();
|
QString str = "file:////"_L1 + url.host() + url.path();
|
||||||
uri = str.toUtf8();
|
gst_url.url = str.toUtf8();
|
||||||
}
|
}
|
||||||
else if (url.scheme() == "cdda"_L1) {
|
else if (url.scheme() == "cdda"_L1) {
|
||||||
QString str;
|
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
|
// We keep the device in mind, and we will set it later using SourceSetupCallback
|
||||||
QStringList path = url.path().split(u'/');
|
QStringList path = url.path().split(u'/');
|
||||||
str = QStringLiteral("cdda://%1").arg(path.takeLast());
|
str = QStringLiteral("cdda://%1").arg(path.takeLast());
|
||||||
QString device = path.join(u'/');
|
gst_url.source_device = path.join(u'/');
|
||||||
if (current_pipeline_) current_pipeline_->SetSourceDevice(device);
|
|
||||||
}
|
}
|
||||||
uri = str.toUtf8();
|
gst_url.url = str.toUtf8();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
uri = url.toEncoded();
|
gst_url.url = url.toEncoded();
|
||||||
}
|
}
|
||||||
|
|
||||||
return uri;
|
return gst_url;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
|
|
||||||
#include "includes/shared_ptr.h"
|
#include "includes/shared_ptr.h"
|
||||||
#include "enginebase.h"
|
#include "enginebase.h"
|
||||||
|
#include "gsturl.h"
|
||||||
#include "gstenginepipeline.h"
|
#include "gstenginepipeline.h"
|
||||||
#include "gstbufferconsumer.h"
|
#include "gstbufferconsumer.h"
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ class GstEngine : public EngineBase, public GstBufferConsumer {
|
|||||||
void PipelineFinished(const int pipeline_id);
|
void PipelineFinished(const int pipeline_id);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QByteArray FixupUrl(const QUrl &url);
|
GstUrl FixupUrl(const QUrl &url);
|
||||||
|
|
||||||
void StartFadeout(GstEnginePipelinePtr pipeline);
|
void StartFadeout(GstEnginePipelinePtr pipeline);
|
||||||
void StartFadeoutPause();
|
void StartFadeoutPause();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
#include <glib.h>
|
#include <glib.h>
|
||||||
#include <glib-object.h>
|
#include <glib-object.h>
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
#include <QtConcurrentRun>
|
#include <QtConcurrentRun>
|
||||||
|
#include <QThreadPool>
|
||||||
#include <QFuture>
|
#include <QFuture>
|
||||||
#include <QFutureWatcher>
|
#include <QFutureWatcher>
|
||||||
#include <QMutex>
|
#include <QMutex>
|
||||||
@@ -90,6 +92,9 @@ constexpr std::chrono::milliseconds kFaderTimeoutMsec = 3000ms;
|
|||||||
constexpr int kEqBandCount = 10;
|
constexpr int kEqBandCount = 10;
|
||||||
constexpr int kEqBandFrequencies[] = { 60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000 };
|
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
|
} // namespace
|
||||||
|
|
||||||
#ifdef __clang_
|
#ifdef __clang_
|
||||||
@@ -98,6 +103,23 @@ constexpr int kEqBandFrequencies[] = { 60, 170, 310, 600, 1000, 3000, 6000, 1200
|
|||||||
|
|
||||||
int GstEnginePipeline::sId = 1;
|
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)
|
GstEnginePipeline::GstEnginePipeline(QObject *parent)
|
||||||
: QObject(parent),
|
: QObject(parent),
|
||||||
id_(sId++),
|
id_(sId++),
|
||||||
@@ -197,6 +219,23 @@ GstEnginePipeline::~GstEnginePipeline() {
|
|||||||
|
|
||||||
if (pipeline_) {
|
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);
|
gst_element_set_state(pipeline_, GST_STATE_NULL);
|
||||||
|
|
||||||
GstElement *audiobin = nullptr;
|
GstElement *audiobin = nullptr;
|
||||||
@@ -1364,6 +1403,13 @@ void GstEnginePipeline::AboutToFinishCallback(GstPlayBin *playbin, gpointer self
|
|||||||
|
|
||||||
GstEnginePipeline *instance = reinterpret_cast<GstEnginePipeline*>(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_);
|
QMutexLocker l(&instance->mutex_url_);
|
||||||
qLog(Debug) << "Stream from URL" << instance->gst_url_ << "about to finish.";
|
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();
|
const GstState current_state = state();
|
||||||
|
|
||||||
if (percent < 100 && !buffering_.value()) {
|
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";
|
qLog(Debug) << "Buffering started";
|
||||||
buffering_ = true;
|
buffering_ = true;
|
||||||
Q_EMIT BufferingStarted();
|
Q_EMIT BufferingStarted();
|
||||||
@@ -1841,9 +1899,15 @@ QFuture<GstStateChangeReturn> GstEnginePipeline::SetState(const GstState state)
|
|||||||
watcher->deleteLater();
|
watcher->deleteLater();
|
||||||
SetStateFinishedSlot(state, state_change_return);
|
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);
|
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;
|
return future;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1853,6 +1917,12 @@ void GstEnginePipeline::SetStateFinishedSlot(const GstState state, const GstStat
|
|||||||
last_set_state_in_progress_ = GST_STATE_VOID_PENDING;
|
last_set_state_in_progress_ = GST_STATE_VOID_PENDING;
|
||||||
--set_state_in_progress_;
|
--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) {
|
switch (state_change_return) {
|
||||||
case GST_STATE_CHANGE_SUCCESS:
|
case GST_STATE_CHANGE_SUCCESS:
|
||||||
case GST_STATE_CHANGE_ASYNC:
|
case GST_STATE_CHANGE_ASYNC:
|
||||||
|
|||||||
@@ -215,7 +215,8 @@ class GstEnginePipeline : public QObject {
|
|||||||
static int sId;
|
static int sId;
|
||||||
mutex_protected<int> id_;
|
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 playbin3_support_;
|
||||||
bool volume_full_range_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_in_progress_;
|
||||||
mutex_protected<GstState> last_set_state_async_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>;
|
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) {
|
if (dest_type != Song::FileType::Unknown) {
|
||||||
// Get the preset
|
// Get the preset
|
||||||
TranscoderPreset preset = Transcoder::PresetForFileType(dest_type);
|
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_;
|
qLog(Debug) << "Transcoding with" << preset.name_;
|
||||||
|
|
||||||
task.transcoded_filename_ = transcoder_->GetFile(task.song_info_.song_.url().toLocalFile(), preset);
|
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;
|
MusicStorage::CopyJob job;
|
||||||
job.source_ = task.transcoded_filename_.isEmpty() ? task.song_info_.song_.url().toLocalFile() : task.transcoded_filename_;
|
job.source_ = task.transcoded_filename_.isEmpty() ? task.song_info_.song_.url().toLocalFile() : task.transcoded_filename_;
|
||||||
job.destination_ = task.song_info_.new_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 {
|
Song::FileType Organize::CheckTranscode(const Song::FileType original_type) const {
|
||||||
|
|
||||||
if (original_type == Song::FileType::Stream) return Song::FileType::Unknown;
|
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 SetSongProgress(const float progress, const bool transcoded = false);
|
||||||
void UpdateProgress();
|
void UpdateProgress();
|
||||||
Song::FileType CheckTranscode(const Song::FileType original_type) const;
|
Song::FileType CheckTranscode(const Song::FileType original_type) const;
|
||||||
|
bool ShouldSkipFile(const QString &filename) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Task {
|
struct Task {
|
||||||
|
|||||||
@@ -1205,7 +1205,7 @@ void Playlist::InsertItemsWithoutUndo(const PlaylistItemPtrList &items, const in
|
|||||||
queue_->InsertFirst(indexes);
|
queue_->InsertFirst(indexes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auto_sort_) {
|
if (auto_sort_ && !is_loading_) {
|
||||||
sort(static_cast<int>(sort_column_), sort_order_);
|
sort(static_cast<int>(sort_column_), sort_order_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
@@ -33,9 +34,12 @@
|
|||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QActionGroup>
|
#include <QActionGroup>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
|
||||||
#include "core/iconloader.h"
|
#include "core/iconloader.h"
|
||||||
#include "core/settingsprovider.h"
|
#include "core/settingsprovider.h"
|
||||||
|
#include "utilities/screenutils.h"
|
||||||
#include "playlistsequence.h"
|
#include "playlistsequence.h"
|
||||||
#include "ui_playlistsequence.h"
|
#include "ui_playlistsequence.h"
|
||||||
|
|
||||||
@@ -43,7 +47,14 @@ using namespace Qt::Literals::StringLiterals;
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr char kSettingsGroup[] = "PlaylistSequence";
|
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)
|
PlaylistSequence::PlaylistSequence(QWidget *parent, SettingsProvider *settings)
|
||||||
: QWidget(parent),
|
: QWidget(parent),
|
||||||
@@ -60,9 +71,11 @@ PlaylistSequence::PlaylistSequence(QWidget *parent, SettingsProvider *settings)
|
|||||||
// Icons
|
// Icons
|
||||||
ui_->repeat->setIcon(AddDesaturatedIcon(IconLoader::Load(u"media-playlist-repeat"_s)));
|
ui_->repeat->setIcon(AddDesaturatedIcon(IconLoader::Load(u"media-playlist-repeat"_s)));
|
||||||
ui_->shuffle->setIcon(AddDesaturatedIcon(IconLoader::Load(u"media-playlist-shuffle"_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));
|
// Calculate icon size dynamically based on screen resolution, aspect ratio, and scaling
|
||||||
ui_->shuffle->setIconSize(QSize(base_icon_size, base_icon_size));
|
const int icon_size = CalculateIconSize();
|
||||||
|
ui_->repeat->setIconSize(QSize(icon_size, icon_size));
|
||||||
|
ui_->shuffle->setIconSize(QSize(icon_size, icon_size));
|
||||||
|
|
||||||
// Remove arrow indicators
|
// Remove arrow indicators
|
||||||
ui_->repeat->setStyleSheet(u"QToolButton::menu-indicator { image: none; }"_s);
|
ui_->repeat->setStyleSheet(u"QToolButton::menu-indicator { image: none; }"_s);
|
||||||
@@ -99,6 +112,48 @@ PlaylistSequence::~PlaylistSequence() {
|
|||||||
delete ui_;
|
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() {
|
void PlaylistSequence::Load() {
|
||||||
|
|
||||||
loading_ = true; // Stops these setter functions calling Save()
|
loading_ = true; // Stops these setter functions calling Save()
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class PlaylistSequence : public QWidget {
|
|||||||
private:
|
private:
|
||||||
void Load();
|
void Load();
|
||||||
void Save();
|
void Save();
|
||||||
|
int CalculateIconSize();
|
||||||
static QIcon AddDesaturatedIcon(const QIcon &icon);
|
static QIcon AddDesaturatedIcon(const QIcon &icon);
|
||||||
static QPixmap DesaturatedPixmap(const QPixmap &pixmap);
|
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) {
|
void QobuzService::HandleAuthReply(QNetworkReply *reply) {
|
||||||
|
|
||||||
|
if (replies_.contains(reply)) {
|
||||||
|
replies_.removeAll(reply);
|
||||||
|
}
|
||||||
|
QObject::disconnect(reply, nullptr, this, nullptr);
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
|
|
||||||
login_sent_ = false;
|
login_sent_ = false;
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ class QobuzService : public StreamingService {
|
|||||||
bool login_sent() const { return login_sent_; }
|
bool login_sent() const { return login_sent_; }
|
||||||
bool login_attempts() const { return login_attempts_; }
|
bool login_attempts() const { return login_attempts_; }
|
||||||
|
|
||||||
|
SharedPtr<NetworkAccessManager> network() const { return network_; }
|
||||||
|
|
||||||
uint GetStreamURL(const QUrl &url, QString &error);
|
uint GetStreamURL(const QUrl &url, QString &error);
|
||||||
|
|
||||||
SharedPtr<CollectionBackend> artists_collection_backend() override { return artists_collection_backend_; }
|
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());
|
const quint64 timestamp = static_cast<quint64>(QDateTime::currentSecsSinceEpoch());
|
||||||
|
|
||||||
ParamList params_to_sign = ParamList() << Param(u"format_id"_s, QString::number(service_->format()))
|
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_));
|
<< Param(u"track_id"_s, QString::number(song_id_));
|
||||||
|
|
||||||
std::sort(params_to_sign.begin(), params_to_sign.end());
|
std::sort(params_to_sign.begin(), params_to_sign.end());
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
#include "core/settings.h"
|
#include "core/settings.h"
|
||||||
#include "widgets/loginstatewidget.h"
|
#include "widgets/loginstatewidget.h"
|
||||||
#include "qobuz/qobuzservice.h"
|
#include "qobuz/qobuzservice.h"
|
||||||
|
#include "qobuz/qobuzcredentialfetcher.h"
|
||||||
#include "constants/qobuzsettings.h"
|
#include "constants/qobuzsettings.h"
|
||||||
|
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
@@ -46,13 +47,15 @@ using namespace QobuzSettings;
|
|||||||
QobuzSettingsPage::QobuzSettingsPage(SettingsDialog *dialog, const SharedPtr<QobuzService> service, QWidget *parent)
|
QobuzSettingsPage::QobuzSettingsPage(SettingsDialog *dialog, const SharedPtr<QobuzService> service, QWidget *parent)
|
||||||
: SettingsPage(dialog, parent),
|
: SettingsPage(dialog, parent),
|
||||||
ui_(new Ui::QobuzSettingsPage),
|
ui_(new Ui::QobuzSettingsPage),
|
||||||
service_(service) {
|
service_(service),
|
||||||
|
credential_fetcher_(nullptr) {
|
||||||
|
|
||||||
ui_->setupUi(this);
|
ui_->setupUi(this);
|
||||||
setWindowIcon(IconLoader::Load(u"qobuz"_s, true, 0, 32));
|
setWindowIcon(IconLoader::Load(u"qobuz"_s, true, 0, 32));
|
||||||
|
|
||||||
QObject::connect(ui_->button_login, &QPushButton::clicked, this, &QobuzSettingsPage::LoginClicked);
|
QObject::connect(ui_->button_login, &QPushButton::clicked, this, &QobuzSettingsPage::LoginClicked);
|
||||||
QObject::connect(ui_->login_state, &LoginStateWidget::LogoutClicked, this, &QobuzSettingsPage::LogoutClicked);
|
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);
|
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);
|
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 QEvent;
|
||||||
class SettingsDialog;
|
class SettingsDialog;
|
||||||
class QobuzService;
|
class QobuzService;
|
||||||
|
class QobuzCredentialFetcher;
|
||||||
class Ui_QobuzSettingsPage;
|
class Ui_QobuzSettingsPage;
|
||||||
|
|
||||||
class QobuzSettingsPage : public SettingsPage {
|
class QobuzSettingsPage : public SettingsPage {
|
||||||
@@ -55,10 +56,14 @@ class QobuzSettingsPage : public SettingsPage {
|
|||||||
void LogoutClicked();
|
void LogoutClicked();
|
||||||
void LoginSuccess();
|
void LoginSuccess();
|
||||||
void LoginFailure(const QString &failure_reason);
|
void LoginFailure(const QString &failure_reason);
|
||||||
|
void FetchCredentialsClicked();
|
||||||
|
void CredentialsFetched(const QString &app_id, const QString &app_secret);
|
||||||
|
void CredentialsFetchError(const QString &error);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Ui_QobuzSettingsPage *ui_;
|
Ui_QobuzSettingsPage *ui_;
|
||||||
const SharedPtr<QobuzService> service_;
|
const SharedPtr<QobuzService> service_;
|
||||||
|
QobuzCredentialFetcher *credential_fetcher_;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // QOBUZSETTINGSPAGE_H
|
#endif // QOBUZSETTINGSPAGE_H
|
||||||
|
|||||||
@@ -115,6 +115,16 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</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>
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -134,13 +134,10 @@ void SmartPlaylistsModel::Init() {
|
|||||||
|
|
||||||
// Append the new ones
|
// Append the new ones
|
||||||
s.beginWriteArray(collection_backend_->songs_table(), playlist_index + unwritten_defaults);
|
s.beginWriteArray(collection_backend_->songs_table(), playlist_index + unwritten_defaults);
|
||||||
for (; version < default_smart_playlists_.count(); ++version) {
|
WriteDefaultsToSettings(&s, version, playlist_index);
|
||||||
const GeneratorList generators = default_smart_playlists_.value(version);
|
|
||||||
for (PlaylistGeneratorPtr gen : generators) {
|
|
||||||
SaveGenerator(&s, playlist_index++, gen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.endArray();
|
s.endArray();
|
||||||
|
|
||||||
|
version = default_smart_playlists_.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
s.setValue(collection_backend_->songs_table() + u"_version"_s, version);
|
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 {
|
QVariant SmartPlaylistsModel::data(const QModelIndex &idx, const int role) const {
|
||||||
|
|
||||||
if (!idx.isValid()) return QVariant();
|
if (!idx.isValid()) return QVariant();
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class SmartPlaylistsModel : public SimpleTreeModel<SmartPlaylistsItem> {
|
|||||||
void AddGenerator(PlaylistGeneratorPtr gen);
|
void AddGenerator(PlaylistGeneratorPtr gen);
|
||||||
void UpdateGenerator(const QModelIndex &idx, PlaylistGeneratorPtr gen);
|
void UpdateGenerator(const QModelIndex &idx, PlaylistGeneratorPtr gen);
|
||||||
void DeleteGenerator(const QModelIndex &idx);
|
void DeleteGenerator(const QModelIndex &idx);
|
||||||
|
void RestoreDefaults();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
|
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);
|
static void SaveGenerator(Settings *s, const int i, PlaylistGeneratorPtr generator);
|
||||||
void ItemFromSmartPlaylist(const Settings &s, const bool notify);
|
void ItemFromSmartPlaylist(const Settings &s, const bool notify);
|
||||||
|
void WriteDefaultsToSettings(Settings *s, const int start_version, const int start_index);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
SharedPtr<CollectionBackend> collection_backend_;
|
SharedPtr<CollectionBackend> collection_backend_;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QShowEvent>
|
#include <QShowEvent>
|
||||||
|
#include <QMessageBox>
|
||||||
|
|
||||||
#include "core/iconloader.h"
|
#include "core/iconloader.h"
|
||||||
#include "core/mimedata.h"
|
#include "core/mimedata.h"
|
||||||
@@ -60,6 +61,7 @@ SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(const SharedPtr<Player>
|
|||||||
action_new_smart_playlist_(nullptr),
|
action_new_smart_playlist_(nullptr),
|
||||||
action_edit_smart_playlist_(nullptr),
|
action_edit_smart_playlist_(nullptr),
|
||||||
action_delete_smart_playlist_(nullptr),
|
action_delete_smart_playlist_(nullptr),
|
||||||
|
action_restore_defaults_(nullptr),
|
||||||
action_append_to_playlist_(nullptr),
|
action_append_to_playlist_(nullptr),
|
||||||
action_replace_current_playlist_(nullptr),
|
action_replace_current_playlist_(nullptr),
|
||||||
action_open_in_new_playlist_(nullptr),
|
action_open_in_new_playlist_(nullptr),
|
||||||
@@ -74,6 +76,7 @@ SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(const SharedPtr<Player>
|
|||||||
model_->Init();
|
model_->Init();
|
||||||
|
|
||||||
action_new_smart_playlist_ = context_menu_->addAction(IconLoader::Load(u"document-new"_s), tr("New smart playlist..."), this, &SmartPlaylistsViewContainer::NewSmartPlaylist);
|
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_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);
|
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);
|
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_->addSeparator();
|
||||||
|
context_menu_selected_->addAction(action_restore_defaults_);
|
||||||
|
|
||||||
ui_->new_->setDefaultAction(action_new_smart_playlist_);
|
ui_->new_->setDefaultAction(action_new_smart_playlist_);
|
||||||
ui_->edit_->setIcon(IconLoader::Load(u"edit-rename"_s));
|
ui_->edit_->setIcon(IconLoader::Load(u"edit-rename"_s));
|
||||||
ui_->delete_->setIcon(IconLoader::Load(u"edit-delete"_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_->edit_, &QToolButton::clicked, this, &SmartPlaylistsViewContainer::EditSmartPlaylistFromButton);
|
||||||
QObject::connect(ui_->delete_, &QToolButton::clicked, this, &SmartPlaylistsViewContainer::DeleteSmartPlaylistFromButton);
|
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::ItemsSelectedChanged, this, &SmartPlaylistsViewContainer::ItemsSelectedChanged);
|
||||||
QObject::connect(ui_->view, &SmartPlaylistsView::doubleClicked, this, &SmartPlaylistsViewContainer::ItemDoubleClicked);
|
QObject::connect(ui_->view, &SmartPlaylistsView::doubleClicked, this, &SmartPlaylistsViewContainer::ItemDoubleClicked);
|
||||||
@@ -130,6 +136,7 @@ void SmartPlaylistsViewContainer::ReloadSettings() {
|
|||||||
ui_->new_->setIconSize(QSize(iconsize, iconsize));
|
ui_->new_->setIconSize(QSize(iconsize, iconsize));
|
||||||
ui_->delete_->setIconSize(QSize(iconsize, iconsize));
|
ui_->delete_->setIconSize(QSize(iconsize, iconsize));
|
||||||
ui_->edit_->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);
|
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 EditSmartPlaylist(const QModelIndex &idx);
|
||||||
void DeleteSmartPlaylist(const QModelIndex &idx);
|
void DeleteSmartPlaylist(const QModelIndex &idx);
|
||||||
|
void RestoreDefaults();
|
||||||
|
|
||||||
void EditSmartPlaylistFromButton();
|
void EditSmartPlaylistFromButton();
|
||||||
void DeleteSmartPlaylistFromButton();
|
void DeleteSmartPlaylistFromButton();
|
||||||
void EditSmartPlaylistFromContext();
|
void EditSmartPlaylistFromContext();
|
||||||
void DeleteSmartPlaylistFromContext();
|
void DeleteSmartPlaylistFromContext();
|
||||||
|
void RestoreDefaultsFromContext();
|
||||||
|
|
||||||
void NewSmartPlaylistFinished();
|
void NewSmartPlaylistFinished();
|
||||||
void EditSmartPlaylistFinished();
|
void EditSmartPlaylistFinished();
|
||||||
@@ -113,6 +115,7 @@ class SmartPlaylistsViewContainer : public QWidget {
|
|||||||
QAction *action_new_smart_playlist_;
|
QAction *action_new_smart_playlist_;
|
||||||
QAction *action_edit_smart_playlist_;
|
QAction *action_edit_smart_playlist_;
|
||||||
QAction *action_delete_smart_playlist_;
|
QAction *action_delete_smart_playlist_;
|
||||||
|
QAction *action_restore_defaults_;
|
||||||
QAction *action_append_to_playlist_;
|
QAction *action_append_to_playlist_;
|
||||||
QAction *action_replace_current_playlist_;
|
QAction *action_replace_current_playlist_;
|
||||||
QAction *action_open_in_new_playlist_;
|
QAction *action_open_in_new_playlist_;
|
||||||
|
|||||||
@@ -95,6 +95,19 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</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>
|
<item>
|
||||||
<spacer name="spacer_buttons">
|
<spacer name="spacer_buttons">
|
||||||
<property name="orientation">
|
<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 "savetagsoptions.h"
|
||||||
#include "savetagcoverdata.h"
|
#include "savetagcoverdata.h"
|
||||||
#include "albumcovertagdata.h"
|
#include "albumcovertagdata.h"
|
||||||
|
#include "tagid3v2version.h"
|
||||||
|
|
||||||
class TagReaderBase {
|
class TagReaderBase {
|
||||||
public:
|
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;
|
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
|
#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 LoadEmbeddedCover(const QString &filename, QByteArray &data) const = 0;
|
||||||
virtual TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_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 "tagreaderreadstreamreply.h"
|
||||||
#include "tagreaderloadcoverdatareply.h"
|
#include "tagreaderloadcoverdatareply.h"
|
||||||
#include "tagreaderloadcoverimagereply.h"
|
#include "tagreaderloadcoverimagereply.h"
|
||||||
|
#include "tagid3v2version.h"
|
||||||
|
|
||||||
using std::dynamic_pointer_cast;
|
using std::dynamic_pointer_cast;
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
@@ -189,7 +190,7 @@ void TagReaderClient::ProcessRequest(TagReaderRequestPtr request) {
|
|||||||
}
|
}
|
||||||
#endif // HAVE_STREAMTAGREADER
|
#endif // HAVE_STREAMTAGREADER
|
||||||
else if (TagReaderWriteFileRequestPtr write_file_request = dynamic_pointer_cast<TagReaderWriteFileRequest>(request)) {
|
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)) {
|
else if (TagReaderLoadCoverDataRequestPtr load_cover_data_request = dynamic_pointer_cast<TagReaderLoadCoverDataRequest>(request)) {
|
||||||
QByteArray cover_data;
|
QByteArray cover_data;
|
||||||
@@ -303,13 +304,13 @@ TagReaderReadStreamReplyPtr TagReaderClient::ReadStreamAsync(const QUrl &url, co
|
|||||||
}
|
}
|
||||||
#endif // HAVE_STREAMTAGREADER
|
#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());
|
Q_ASSERT(QThread::currentThread() != thread());
|
||||||
|
|
||||||
@@ -321,6 +322,7 @@ TagReaderReplyPtr TagReaderClient::WriteFileAsync(const QString &filename, const
|
|||||||
request->song = song;
|
request->song = song;
|
||||||
request->save_tags_options = save_tags_options;
|
request->save_tags_options = save_tags_options;
|
||||||
request->save_tag_cover_data = save_tag_cover_data;
|
request->save_tag_cover_data = save_tag_cover_data;
|
||||||
|
request->tag_id3v2_version = tag_id3v2_version;
|
||||||
|
|
||||||
EnqueueRequest(request);
|
EnqueueRequest(request);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
#include "tagreaderloadcoverimagereply.h"
|
#include "tagreaderloadcoverimagereply.h"
|
||||||
#include "savetagsoptions.h"
|
#include "savetagsoptions.h"
|
||||||
#include "savetagcoverdata.h"
|
#include "savetagcoverdata.h"
|
||||||
|
#include "tagid3v2version.h"
|
||||||
|
|
||||||
class QThread;
|
class QThread;
|
||||||
class Song;
|
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);
|
[[nodiscard]] TagReaderReadStreamReplyPtr ReadStreamAsync(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
TagReaderResult WriteFileBlocking(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());
|
[[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 LoadCoverDataBlocking(const QString &filename, QByteArray &data);
|
||||||
TagReaderResult LoadCoverImageBlocking(const QString &filename, QImage &image);
|
TagReaderResult LoadCoverImageBlocking(const QString &filename, QImage &image);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
#include "core/logging.h"
|
#include "core/logging.h"
|
||||||
#include "tagreaderbase.h"
|
#include "tagreaderbase.h"
|
||||||
#include "tagreadertaglib.h"
|
#include "tagreadertaglib.h"
|
||||||
|
#include "tagid3v2version.h"
|
||||||
|
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
|
|
||||||
@@ -317,12 +318,13 @@ TagReaderResult TagReaderGME::ReadStream(const QUrl &url, const QString &filenam
|
|||||||
}
|
}
|
||||||
#endif
|
#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(filename);
|
||||||
Q_UNUSED(song);
|
Q_UNUSED(song);
|
||||||
Q_UNUSED(save_tags_options);
|
Q_UNUSED(save_tags_options);
|
||||||
Q_UNUSED(save_tag_cover_data);
|
Q_UNUSED(save_tag_cover_data);
|
||||||
|
Q_UNUSED(id3v2_version);
|
||||||
|
|
||||||
return TagReaderResult::ErrorCode::Unsupported;
|
return TagReaderResult::ErrorCode::Unsupported;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
|
||||||
#include "tagreaderbase.h"
|
#include "tagreaderbase.h"
|
||||||
|
#include "tagid3v2version.h"
|
||||||
|
|
||||||
namespace GME {
|
namespace GME {
|
||||||
bool IsSupportedFormat(const QFileInfo &fileinfo);
|
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;
|
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
|
#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 LoadEmbeddedCover(const QString &filename, QByteArray &data) const override;
|
||||||
TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_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/apeproperties.h>
|
||||||
#include <taglib/id3v2tag.h>
|
#include <taglib/id3v2tag.h>
|
||||||
#include <taglib/id3v2frame.h>
|
#include <taglib/id3v2frame.h>
|
||||||
|
#include <taglib/id3v2header.h>
|
||||||
#include <taglib/attachedpictureframe.h>
|
#include <taglib/attachedpictureframe.h>
|
||||||
#include <taglib/textidentificationframe.h>
|
#include <taglib/textidentificationframe.h>
|
||||||
#include <taglib/unsynchronizedlyricsframe.h>
|
#include <taglib/unsynchronizedlyricsframe.h>
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
#include "constants/timeconstants.h"
|
#include "constants/timeconstants.h"
|
||||||
|
|
||||||
#include "albumcovertagdata.h"
|
#include "albumcovertagdata.h"
|
||||||
|
#include "tagid3v2version.h"
|
||||||
|
|
||||||
using std::make_unique;
|
using std::make_unique;
|
||||||
using namespace Qt::Literals::StringLiterals;
|
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 {
|
void TagReaderTagLib::ParseID3v2Tags(TagLib::ID3v2::Tag *tag, QString *disc, QString *compilation, Song *song) const {
|
||||||
|
|
||||||
|
if (!tag) return;
|
||||||
|
|
||||||
TagLib::ID3v2::FrameListMap map = tag->frameListMap();
|
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_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_Composer)) song->set_composer(map[kID3v2_Composer].front()->toString());
|
||||||
if (map.contains(kID3v2_ComposerSort)) song->set_composersort(map[kID3v2_ComposerSort].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()) {
|
if (filename.isEmpty()) {
|
||||||
return TagReaderResult::ErrorCode::FilenameMissing;
|
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
|
#ifdef Q_OS_LINUX
|
||||||
if (success) {
|
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)
|
// 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 "tagreaderbase.h"
|
||||||
#include "savetagcoverdata.h"
|
#include "savetagcoverdata.h"
|
||||||
|
#include "tagid3v2version.h"
|
||||||
|
|
||||||
#undef TStringToQString
|
#undef TStringToQString
|
||||||
#undef QStringToTString
|
#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;
|
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
|
#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 LoadEmbeddedCover(const QString &filename, QByteArray &data) const override;
|
||||||
TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const override;
|
TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const override;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
#include "tagreaderrequest.h"
|
#include "tagreaderrequest.h"
|
||||||
#include "savetagsoptions.h"
|
#include "savetagsoptions.h"
|
||||||
#include "savetagcoverdata.h"
|
#include "savetagcoverdata.h"
|
||||||
|
#include "tagid3v2version.h"
|
||||||
|
|
||||||
using std::make_shared;
|
using std::make_shared;
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ class TagReaderWriteFileRequest : public TagReaderRequest {
|
|||||||
SaveTagsOptions save_tags_options;
|
SaveTagsOptions save_tags_options;
|
||||||
Song song;
|
Song song;
|
||||||
SaveTagCoverData save_tag_cover_data;
|
SaveTagCoverData save_tag_cover_data;
|
||||||
|
TagID3v2Version tag_id3v2_version;
|
||||||
};
|
};
|
||||||
|
|
||||||
using TagReaderWriteFileRequestPtr = SharedPtr<TagReaderWriteFileRequest>;
|
using TagReaderWriteFileRequestPtr = SharedPtr<TagReaderWriteFileRequest>;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Strawberry Music Player
|
* Strawberry Music Player
|
||||||
* This file was part of Clementine.
|
* This file was part of Clementine.
|
||||||
* Copyright 2010, David Sansome <me@davidsansome.com>
|
* 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
|
* Strawberry is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
@@ -191,3 +191,26 @@ void AutoExpandingTreeView::DownAndFocus() {
|
|||||||
setCurrentIndex(moveCursor(QAbstractItemView::MoveDown, Qt::NoModifier));
|
setCurrentIndex(moveCursor(QAbstractItemView::MoveDown, Qt::NoModifier));
|
||||||
setFocus();
|
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
|
* Strawberry Music Player
|
||||||
* This file was part of Clementine.
|
* This file was part of Clementine.
|
||||||
* Copyright 2010, David Sansome <me@davidsansome.com>
|
* 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
|
* Strawberry is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
@@ -55,6 +55,7 @@ class AutoExpandingTreeView : public QTreeView {
|
|||||||
protected:
|
protected:
|
||||||
// QAbstractItemView
|
// QAbstractItemView
|
||||||
void reset() override;
|
void reset() override;
|
||||||
|
void currentChanged(const QModelIndex ¤t_index, const QModelIndex &previous_index) override;
|
||||||
|
|
||||||
// QWidget
|
// QWidget
|
||||||
void mousePressEvent(QMouseEvent *event) override;
|
void mousePressEvent(QMouseEvent *event) override;
|
||||||
|
|||||||
Reference in New Issue
Block a user