Compare commits

..

33 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
d2afa8fd66 Fix STA thread blocking crash on album art loading
Replace async GetFileFromPathAsync().get() with synchronous CreateFromUri() to avoid blocking STA thread. This prevents the "sta thread blocking wait" assertion failure.

Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 21:51:28 +00:00
copilot-swe-agent[bot]
4da4c9e267 Address code review feedback
- Fix album cover URL: use result.album_cover.cover_url instead of result.cover_url
- Convert WINDOWS_MEDIA_CONTROLS to optional_component for better configuration management

Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 21:42:26 +00:00
copilot-swe-agent[bot]
02c1596ff4 Fix WinRT apartment initialization crash
Handle case where COM/WinRT apartment is already initialized by Qt or other components. Track whether we initialized the apartment to avoid uninitializing it if we didn't initialize it.

Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 19:31:43 +00:00
copilot-swe-agent[bot]
597f983c92 Fix WinRT activation factory usage in WindowsMediaController
Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 19:46:21 +01:00
copilot-swe-agent[bot]
0e0117b19b Refactor WindowsMediaController with proper WinRT interop
Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 19:46:21 +01:00
copilot-swe-agent[bot]
4e0cc1c0da Add HAVE_WINDOWS_MEDIA_CONTROLS configuration flag
Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 19:46:21 +01:00
copilot-swe-agent[bot]
f77e92d634 Add Windows SystemMediaTransportControls support for MSVC
Co-authored-by: jonaski <10343810+jonaski@users.noreply.github.com>
2025-12-28 19:46:21 +01:00
copilot-swe-agent[bot]
f25cdb3431 Initial plan 2025-12-28 19:46:21 +01:00
dependabot[bot]
604dd2dbde Bump vmactions/freebsd-vm from 1.3.1 to 1.3.2
Bumps [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) from 1.3.1 to 1.3.2.
- [Release notes](https://github.com/vmactions/freebsd-vm/releases)
- [Commits](https://github.com/vmactions/freebsd-vm/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: vmactions/freebsd-vm
  dependency-version: 1.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-28 19:00:35 +01:00
Stickman
25065ba98f Song: Include Opus for supported sort tags 2025-12-28 18:57:52 +01:00
Jonas Kvinge
7b16ec62bb Defer playcount and rating tag writes for currently playing Ogg songs
Fixes #1816
2025-12-28 18:33:49 +01:00
Jonas Kvinge
d8f31592b9 Remove settings member variables 2025-12-28 00:39:22 +01:00
Jonas Kvinge
80bb0f476d CollectionModel: Remove sort tags from container keys
Fixes #1899
2025-12-27 21:25:54 +01:00
dependabot[bot]
b7222ac85c Bump vmactions/openbsd-vm from 1.2.8 to 1.2.9
Bumps [vmactions/openbsd-vm](https://github.com/vmactions/openbsd-vm) from 1.2.8 to 1.2.9.
- [Release notes](https://github.com/vmactions/openbsd-vm/releases)
- [Commits](https://github.com/vmactions/openbsd-vm/compare/v1.2.8...v1.2.9)

---
updated-dependencies:
- dependency-name: vmactions/openbsd-vm
  dependency-version: 1.2.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-27 20:02:32 +01:00
dependabot[bot]
241bca0828 Bump vmactions/openbsd-vm from 1.2.7 to 1.2.8
Bumps [vmactions/openbsd-vm](https://github.com/vmactions/openbsd-vm) from 1.2.7 to 1.2.8.
- [Release notes](https://github.com/vmactions/openbsd-vm/releases)
- [Commits](https://github.com/vmactions/openbsd-vm/compare/v1.2.7...v1.2.8)

---
updated-dependencies:
- dependency-name: vmactions/openbsd-vm
  dependency-version: 1.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-25 19:51:35 +01:00
dependabot[bot]
90d86b10a3 Bump vmactions/freebsd-vm from 1.3.0 to 1.3.1
Bumps [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/vmactions/freebsd-vm/releases)
- [Commits](https://github.com/vmactions/freebsd-vm/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: vmactions/freebsd-vm
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-25 16:51:53 +01:00
dependabot[bot]
4130c6670f Bump vmactions/openbsd-vm from 1.2.5 to 1.2.7
Bumps [vmactions/openbsd-vm](https://github.com/vmactions/openbsd-vm) from 1.2.5 to 1.2.7.
- [Release notes](https://github.com/vmactions/openbsd-vm/releases)
- [Commits](https://github.com/vmactions/openbsd-vm/compare/v1.2.5...v1.2.7)

---
updated-dependencies:
- dependency-name: vmactions/openbsd-vm
  dependency-version: 1.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 21:10:30 +01:00
Jonas Kvinge
8d262959c1 GstEnginePipeline: Fix buffering issue near track end during gapless playback
Ignore buffering messages when within 5 seconds of track end and about-to-finish has been signaled. This prevents spurious buffering from blocking playback during track transitions with local files.

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

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

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

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

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

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

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

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

Fixes #1863

Co-Authored-By: Jonas Kvinge <jonas@jkvinge.net>
2025-12-18 19:44:03 +01:00
Jonas Kvinge
39f9128ecf gitignore: Add _codeql_detected_source_root 2025-12-18 19:39:10 +01:00
Jonas Kvinge
ca2e802239 GstEngine: Make sure device is set for pipeline
Fixes #1852
2025-12-18 00:21:00 +01:00
51 changed files with 1777 additions and 575 deletions

View File

@@ -739,12 +739,18 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
- name: Free disk space
run: |
df -h
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo apt-get clean
df -h
- name: Build FreeBSD
id: build-freebsd
uses: vmactions/freebsd-vm@v1.3.0
uses: vmactions/freebsd-vm@v1.3.2
with:
usesh: true
mem: 4096
mem: 8192
prepare: pkg install -y git cmake pkgconf boost-libs alsa-lib glib qt6-base qt6-tools sqlite gstreamer1 gstreamer1-plugins chromaprint libebur128 taglib libcdio libmtp gdk-pixbuf2 libgpod fftw3 icu kdsingleapplication googletest pulseaudio sparsehash rapidjson
run: |
set -e
@@ -766,7 +772,7 @@ jobs:
submodules: recursive
- name: Build OpenBSD
id: build-openbsd
uses: vmactions/openbsd-vm@v1.2.5
uses: vmactions/openbsd-vm@v1.2.9
with:
usesh: true
mem: 4096

1
.gitignore vendored
View File

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

View File

@@ -295,6 +295,12 @@ if(UNIX AND NOT APPLE)
)
endif()
if(MSVC)
optional_component(WINDOWS_MEDIA_CONTROLS ON "Windows Media Transport Controls"
DEPENDS "MSVC compiler" MSVC
)
endif()
optional_component(SONGFINGERPRINTING ON "Song fingerprinting and tracking"
DEPENDS "chromaprint" CHROMAPRINT_FOUND
)
@@ -1294,6 +1300,7 @@ endif()
optional_source(HAVE_ALSA SOURCES src/engine/alsadevicefinder.cpp src/engine/alsapcmdevicefinder.cpp)
optional_source(HAVE_PULSE SOURCES src/engine/pulsedevicefinder.cpp)
optional_source(MSVC SOURCES src/engine/uwpdevicefinder.cpp src/engine/asiodevicefinder.cpp)
optional_source(HAVE_WINDOWS_MEDIA_CONTROLS SOURCES src/core/windowsmediacontroller.cpp HEADERS src/core/windowsmediacontroller.h)
optional_source(HAVE_CHROMAPRINT SOURCES src/engine/chromaprinter.cpp)
optional_source(HAVE_MUSICBRAINZ
@@ -1463,6 +1470,7 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.cpp
src/qobuz/qobuzstreamurlrequest.cpp
src/qobuz/qobuzfavoriterequest.cpp
src/qobuz/qobuzcredentialfetcher.cpp
src/settings/qobuzsettingspage.cpp
src/covermanager/qobuzcoverprovider.cpp
HEADERS
@@ -1472,6 +1480,7 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.h
src/qobuz/qobuzstreamurlrequest.h
src/qobuz/qobuzfavoriterequest.h
src/qobuz/qobuzcredentialfetcher.h
src/settings/qobuzsettingspage.h
src/covermanager/qobuzcoverprovider.h
UI

View File

@@ -1 +0,0 @@
.

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -189,6 +189,26 @@ void CollectionLibrary::ReloadSettings() {
}
void CollectionLibrary::CurrentSongChanged(const Song &song) {
current_song_url_ = song.url();
if (!pending_song_saves_.isEmpty()) {
SavePendingPlaycountsAndRatings();
}
}
void CollectionLibrary::Stopped() {
current_song_url_ = QUrl();
if (!pending_song_saves_.isEmpty()) {
SavePendingPlaycountsAndRatings();
}
}
void CollectionLibrary::SyncPlaycountAndRatingToFilesAsync() {
(void)QtConcurrent::run(&CollectionLibrary::SyncPlaycountAndRatingToFiles, this);
@@ -212,18 +232,85 @@ void CollectionLibrary::SyncPlaycountAndRatingToFiles() {
}
void CollectionLibrary::SongsPlaycountChanged(const SongList &songs, const bool save_tags) const {
void CollectionLibrary::SongsPlaycountChanged(const SongList &songs, const bool save_tags) {
if (save_tags || save_playcounts_to_files_) {
tagreader_client_->SaveSongsPlaycountAsync(songs);
SongList songs_to_save_now;
for (const Song &song : songs) {
if (song.url().isLocalFile() && song.url() == current_song_url_ &&
(song.filetype() == Song::FileType::OggFlac || song.filetype() == Song::FileType::OggVorbis || song.filetype() == Song::FileType::OggOpus)) {
qLog(Debug) << "Deferring playcount save for currently playing file" << song.url().toLocalFile();
if (pending_song_saves_.contains(song.url())) {
SharedPtr<PendingSongSave> pending_song_save = pending_song_saves_[song.url()];
pending_song_save->save_playcount = true;
pending_song_save->song.set_playcount(song.playcount());
}
else {
SharedPtr<PendingSongSave> pending_song_save = make_shared<PendingSongSave>();
pending_song_save->save_playcount = true;
pending_song_save->song = song;
pending_song_saves_.insert(song.url(), pending_song_save);
}
}
else {
songs_to_save_now << song;
}
}
if (!songs_to_save_now.isEmpty()) {
tagreader_client_->SaveSongsPlaycountAsync(songs_to_save_now);
}
}
}
void CollectionLibrary::SongsRatingChanged(const SongList &songs, const bool save_tags) const {
void CollectionLibrary::SongsRatingChanged(const SongList &songs, const bool save_tags) {
if (save_tags || save_ratings_to_files_) {
tagreader_client_->SaveSongsRatingAsync(songs);
SongList songs_to_save_now;
for (const Song &song : songs) {
if (song.url().isLocalFile() && song.url() == current_song_url_ &&
(song.filetype() == Song::FileType::OggFlac || song.filetype() == Song::FileType::OggVorbis || song.filetype() == Song::FileType::OggOpus)) {
qLog(Debug) << "Deferring rating save for currently playing file" << song.url().toLocalFile();
if (pending_song_saves_.contains(song.url())) {
SharedPtr<PendingSongSave> pending_song_save = pending_song_saves_[song.url()];
pending_song_save->save_rating = true;
pending_song_save->song.set_rating(song.rating());
}
else {
SharedPtr<PendingSongSave> pending_song_save = make_shared<PendingSongSave>();
pending_song_save->save_rating = true;
pending_song_save->song = song;
pending_song_saves_.insert(song.url(), pending_song_save);
}
}
else {
songs_to_save_now << song;
}
}
if (!songs_to_save_now.isEmpty()) {
tagreader_client_->SaveSongsRatingAsync(songs_to_save_now);
}
}
}
void CollectionLibrary::SavePendingPlaycountsAndRatings() {
for (auto it = pending_song_saves_.constBegin(); it != pending_song_saves_.constEnd();) {
const QUrl url = it.key();
SharedPtr<PendingSongSave> pending_song_save = it.value();
if (url == current_song_url_) {
++it;
continue;
}
qLog(Debug) << "Saving deferred playcount/rating for" << url.toLocalFile();
if (pending_song_save->save_playcount) {
tagreader_client_->SaveSongsPlaycountAsync(SongList() << pending_song_save->song);
}
if (pending_song_save->save_rating) {
tagreader_client_->SaveSongsRatingAsync(SongList() << pending_song_save->song);
}
it = pending_song_saves_.erase(it);
}
}

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -27,6 +27,7 @@
#include <QObject>
#include <QList>
#include <QHash>
#include <QMap>
#include <QString>
#include "includes/shared_ptr.h"
@@ -71,6 +72,7 @@ class CollectionLibrary : public QObject {
private:
void SyncPlaycountAndRatingToFiles();
void SavePendingPlaycountsAndRatings();
public Q_SLOTS:
void ReloadSettings();
@@ -84,16 +86,26 @@ class CollectionLibrary : public QObject {
void IncrementalScan();
void CurrentSongChanged(const Song &song);
void Stopped();
private Q_SLOTS:
void ExitReceived();
void SongsPlaycountChanged(const SongList &songs, const bool save_tags = false) const;
void SongsRatingChanged(const SongList &songs, const bool save_tags = false) const;
void SongsPlaycountChanged(const SongList &songs, const bool save_tags = false);
void SongsRatingChanged(const SongList &songs, const bool save_tags = false);
Q_SIGNALS:
void Error(const QString &error);
void ExitFinished();
private:
class PendingSongSave {
public:
Song song;
bool save_playcount = false;
bool save_rating = false;
};
const SharedPtr<TaskManager> task_manager_;
const SharedPtr<TagReaderClient> tagreader_client_;
@@ -111,6 +123,10 @@ class CollectionLibrary : public QObject {
bool save_playcounts_to_files_;
bool save_ratings_to_files_;
QUrl current_song_url_;
QMap<QUrl, SharedPtr<PendingSongSave>> pending_song_saves_;
};
#endif

View File

@@ -1209,49 +1209,41 @@ QString CollectionModel::ContainerKey(const GroupBy group_by, const Song &song,
switch (group_by) {
case GroupBy::AlbumArtist:
key = TextOrUnknown(song.effective_albumartist());
if (!song.effective_albumartistsort().isEmpty() && song.effective_albumartistsort() != song.effective_albumartist()) key.append(QLatin1Char('-') + TextOrUnknown(song.effective_albumartistsort()));
has_unique_album_identifier = true;
break;
case GroupBy::Artist:
key = TextOrUnknown(song.artist());
if (!song.artistsort().isEmpty() && song.artistsort() != song.artist()) key.append(QLatin1Char('-') + TextOrUnknown(song.artistsort()));
has_unique_album_identifier = true;
break;
case GroupBy::Album:
key = TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::AlbumDisc:
key = TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::YearAlbum:
key = SortTextForYear(song.year()) + QLatin1Char('-') + TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::YearAlbumDisc:
key = SortTextForYear(song.year()) + QLatin1Char('-') + TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::OriginalYearAlbum:
key = SortTextForYear(song.effective_originalyear()) + QLatin1Char('-') + TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::OriginalYearAlbumDisc:
key = SortTextForYear(song.effective_originalyear()) + QLatin1Char('-') + TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
@@ -1270,12 +1262,10 @@ QString CollectionModel::ContainerKey(const GroupBy group_by, const Song &song,
break;
case GroupBy::Composer:
key = TextOrUnknown(song.composer());
if (!song.composersort().isEmpty() && song.composersort() != song.composer()) key.append(QLatin1Char('-') + song.composersort());
has_unique_album_identifier = true;
break;
case GroupBy::Performer:
key = TextOrUnknown(song.performer());
if (!song.performersort().isEmpty() && song.performersort() != song.performer()) key.append(QLatin1Char('-') + song.performersort());
has_unique_album_identifier = true;
break;
case GroupBy::Grouping:

View File

@@ -14,6 +14,7 @@
#cmakedefine HAVE_GIO_UNIX
#cmakedefine HAVE_DBUS
#cmakedefine HAVE_MPRIS2
#cmakedefine HAVE_WINDOWS_MEDIA_CONTROLS
#cmakedefine HAVE_UDISKS2
#cmakedefine HAVE_AUDIOCD
#cmakedefine HAVE_MTP

View File

@@ -58,7 +58,6 @@
#include <QShortcut>
#include <QMessageBox>
#include <QErrorMessage>
#include <QSettings>
#include <QColor>
#include <QFrame>
#include <QItemSelectionModel>
@@ -697,6 +696,9 @@ MainWindow::MainWindow(Application *app,
QObject::connect(&*app_->task_manager(), &TaskManager::PauseCollectionWatchers, &*app_->collection(), &CollectionLibrary::PauseWatcher);
QObject::connect(&*app_->task_manager(), &TaskManager::ResumeCollectionWatchers, &*app_->collection(), &CollectionLibrary::ResumeWatcher);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, &*app_->collection(), &CollectionLibrary::CurrentSongChanged);
QObject::connect(&*app_->player(), &Player::Stopped, &*app_->collection(), &CollectionLibrary::Stopped);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, &*app_->current_albumcover_loader(), &CurrentAlbumCoverLoader::LoadAlbumCover);
QObject::connect(&*app_->current_albumcover_loader(), &CurrentAlbumCoverLoader::AlbumCoverLoaded, this, &MainWindow::AlbumCoverLoaded);
QObject::connect(album_cover_choice_controller_, &AlbumCoverChoiceController::Error, this, &MainWindow::ShowErrorDialog);
@@ -977,27 +979,28 @@ MainWindow::MainWindow(Application *app,
// Load settings
qLog(Debug) << "Loading settings";
settings_.beginGroup(MainWindowSettings::kSettingsGroup);
Settings settings;
settings.beginGroup(MainWindowSettings::kSettingsGroup);
// Set last used geometry to position window on the correct monitor
// Set window state only if the window was last maximized
if (settings_.contains("geometry")) {
restoreGeometry(settings_.value("geometry").toByteArray());
if (settings.contains("geometry")) {
restoreGeometry(settings.value("geometry").toByteArray());
}
if (!settings_.contains(MainWindowSettings::kSplitterState) || !ui_->splitter->restoreState(settings_.value(MainWindowSettings::kSplitterState).toByteArray())) {
if (!settings.contains(MainWindowSettings::kSplitterState) || !ui_->splitter->restoreState(settings.value(MainWindowSettings::kSplitterState).toByteArray())) {
ui_->splitter->setSizes(QList<int>() << 20 << (width() - 20));
}
ui_->tabs->setCurrentIndex(settings_.value("current_tab", 1).toInt());
ui_->tabs->setCurrentIndex(settings.value("current_tab", 1).toInt());
FancyTabWidget::Mode default_mode = FancyTabWidget::Mode::LargeSidebar;
FancyTabWidget::Mode tab_mode = static_cast<FancyTabWidget::Mode>(settings_.value("tab_mode", static_cast<int>(default_mode)).toInt());
FancyTabWidget::Mode tab_mode = static_cast<FancyTabWidget::Mode>(settings.value("tab_mode", static_cast<int>(default_mode)).toInt());
if (tab_mode == FancyTabWidget::Mode::None) tab_mode = default_mode;
ui_->tabs->SetMode(tab_mode);
TabSwitched();
file_view_->SetPath(settings_.value("file_path", QDir::homePath()).toString());
file_view_->SetPath(settings.value("file_path", QDir::homePath()).toString());
// Users often collapse one side of the splitter by mistake and don't know how to restore it. This must be set after the state is restored above.
ui_->splitter->setChildrenCollapsible(false);
@@ -1040,13 +1043,13 @@ MainWindow::MainWindow(Application *app,
case BehaviourSettings::StartupBehaviour::Remember:
default:{
was_maximized_ = settings_.value(MainWindowSettings::kMaximized, true).toBool();
was_maximized_ = settings.value(MainWindowSettings::kMaximized, true).toBool();
if (was_maximized_) setWindowState(windowState() | Qt::WindowMaximized);
was_minimized_ = settings_.value(MainWindowSettings::kMinimized, false).toBool();
was_minimized_ = settings.value(MainWindowSettings::kMinimized, false).toBool();
if (was_minimized_) setWindowState(windowState() | Qt::WindowMinimized);
if (!systemtrayicon_->IsSystemTrayAvailable() || !systemtrayicon_->isVisible() || !settings_.value(MainWindowSettings::kHidden, false).toBool()) {
if (!systemtrayicon_->IsSystemTrayAvailable() || !systemtrayicon_->isVisible() || !settings.value(MainWindowSettings::kHidden, false).toBool()) {
show();
}
break;
@@ -1054,7 +1057,7 @@ MainWindow::MainWindow(Application *app,
}
#endif
bool show_sidebar = settings_.value(MainWindowSettings::kShowSidebar, true).toBool();
bool show_sidebar = settings.value(MainWindowSettings::kShowSidebar, true).toBool();
ui_->sidebar_layout->setVisible(show_sidebar);
ui_->action_toggle_show_sidebar->setChecked(show_sidebar);
@@ -1225,7 +1228,9 @@ void MainWindow::ReloadSettings() {
osd_->ReloadSettings();
album_cover_choice_controller_->search_cover_auto_action()->setChecked(settings_.value(MainWindowSettings::kSearchForCoverAuto, true).toBool());
s.beginGroup(MainWindowSettings::kSettingsGroup);
album_cover_choice_controller_->search_cover_auto_action()->setChecked(s.value(MainWindowSettings::kSearchForCoverAuto, true).toBool());
s.endGroup();
#ifdef HAVE_SUBSONIC
s.beginGroup(SubsonicSettings::kSettingsGroup);
@@ -1342,8 +1347,11 @@ void MainWindow::SaveSettings() {
ui_->playlist->view()->SaveSettings();
app_->scrobbler()->WriteCache();
settings_.setValue(MainWindowSettings::kShowSidebar, ui_->action_toggle_show_sidebar->isChecked());
settings_.setValue(MainWindowSettings::kSearchForCoverAuto, album_cover_choice_controller_->search_cover_auto_action()->isChecked());
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue(MainWindowSettings::kShowSidebar, ui_->action_toggle_show_sidebar->isChecked());
s.setValue(MainWindowSettings::kSearchForCoverAuto, album_cover_choice_controller_->search_cover_auto_action()->isChecked());
s.endGroup();
}
@@ -1584,23 +1592,35 @@ void MainWindow::ToggleSidebar(const bool checked) {
ui_->sidebar_layout->setVisible(checked);
TabSwitched();
settings_.setValue(MainWindowSettings::kShowSidebar, checked);
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue(MainWindowSettings::kShowSidebar, checked);
s.endGroup();
}
void MainWindow::ToggleSearchCoverAuto(const bool checked) {
settings_.setValue(MainWindowSettings::kSearchForCoverAuto, checked);
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue(MainWindowSettings::kSearchForCoverAuto, checked);
s.endGroup();
}
void MainWindow::SaveGeometry() {
if (!initialized_) return;
settings_.setValue(MainWindowSettings::kMaximized, isMaximized());
settings_.setValue(MainWindowSettings::kMinimized, isMinimized());
settings_.setValue(MainWindowSettings::kHidden, isHidden());
settings_.setValue(MainWindowSettings::kGeometry, saveGeometry());
settings_.setValue(MainWindowSettings::kSplitterState, ui_->splitter->saveState());
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue(MainWindowSettings::kMaximized, isMaximized());
s.setValue(MainWindowSettings::kMinimized, isMinimized());
s.setValue(MainWindowSettings::kHidden, isHidden());
s.setValue(MainWindowSettings::kGeometry, saveGeometry());
s.setValue(MainWindowSettings::kSplitterState, ui_->splitter->saveState());
s.endGroup();
}
@@ -1742,7 +1762,12 @@ void MainWindow::SetHiddenInTray(const bool hidden) {
}
void MainWindow::FilePathChanged(const QString &path) {
settings_.setValue("file_path", path);
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue("file_path", path);
s.endGroup();
}
void MainWindow::Seeked(const qint64 microseconds) {
@@ -2324,7 +2349,9 @@ void MainWindow::EditValue() {
void MainWindow::AddFile() {
// Last used directory
QString directory = settings_.value("add_media_path", QDir::currentPath()).toString();
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
QString directory = s.value("add_media_path", QDir::currentPath()).toString();
PlaylistParser parser(app_->tagreader_client(), app_->collection_backend());
@@ -2334,7 +2361,7 @@ void MainWindow::AddFile() {
if (filenames.isEmpty()) return;
// Save last used directory
settings_.setValue("add_media_path", filenames[0]);
s.setValue("add_media_path", filenames[0]);
// Convert to URLs
QList<QUrl> urls;
@@ -2352,14 +2379,16 @@ void MainWindow::AddFile() {
void MainWindow::AddFolder() {
// Last used directory
QString directory = settings_.value("add_folder_path", QDir::currentPath()).toString();
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
QString directory = s.value("add_folder_path", QDir::currentPath()).toString();
// Show dialog
directory = QFileDialog::getExistingDirectory(this, tr("Add folder"), directory);
if (directory.isEmpty()) return;
// Save last used directory
settings_.setValue("add_folder_path", directory);
s.setValue("add_folder_path", directory);
// Add media
MimeData *mimedata = new MimeData;
@@ -3318,7 +3347,7 @@ void MainWindow::PlaylistDelete() {
if (DeleteConfirmationDialog::warning(files) != QDialogButtonBox::Yes) return;
if (app_->player()->GetState() == EngineBase::State::Playing && app_->playlist_manager()->current()->rowCount() == selected_songs.count()) {
if (app_->player()->GetState() == EngineBase::State::Playing && app_->playlist_manager()->current() == app_->playlist_manager()->active() && app_->playlist_manager()->current()->rowCount() == selected_songs.count()) {
app_->player()->Stop();
}

View File

@@ -45,7 +45,6 @@
#include <QImage>
#include <QPixmap>
#include <QTimer>
#include <QSettings>
#include <QtEvents>
#include "includes/scoped_ptr.h"
@@ -53,7 +52,6 @@
#include "includes/lazy.h"
#include "core/platforminterface.h"
#include "core/song.h"
#include "core/settings.h"
#include "core/commandlineoptions.h"
#include "tagreader/tagreaderclient.h"
#include "osd/osdbase.h"
@@ -390,7 +388,6 @@ class MainWindow : public QMainWindow, public PlatformInterface {
QTimer *track_position_timer_;
QTimer *track_slider_timer_;
Settings settings_;
bool keep_running_;
bool playing_widget_;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,303 @@
/*
* 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/>.
*
*/
#include "config.h"
#include <windows.h>
#include <QObject>
#include <QString>
#include <QUrl>
// Undefine 'interface' macro from windows.h before including WinRT headers
#pragma push_macro("interface")
#undef interface
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Media.h>
#include <winrt/Windows.Storage.h>
#include <winrt/Windows.Storage.Streams.h>
#pragma pop_macro("interface")
// Include the interop header for ISystemMediaTransportControlsInterop
#include <systemmediatransportcontrolsinterop.h>
#include "core/logging.h"
#include "windowsmediacontroller.h"
#include "core/song.h"
#include "core/player.h"
#include "engine/enginebase.h"
#include "playlist/playlistmanager.h"
#include "covermanager/currentalbumcoverloader.h"
#include "covermanager/albumcoverloaderresult.h"
using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::Media;
using namespace Windows::Storage;
using namespace Windows::Storage::Streams;
// Helper struct to hold the WinRT object
struct WindowsMediaControllerPrivate {
SystemMediaTransportControls smtc{nullptr};
};
WindowsMediaController::WindowsMediaController(HWND hwnd,
const SharedPtr<Player> player,
const SharedPtr<PlaylistManager> playlist_manager,
const SharedPtr<CurrentAlbumCoverLoader> current_albumcover_loader,
QObject *parent)
: QObject(parent),
player_(player),
playlist_manager_(playlist_manager),
current_albumcover_loader_(current_albumcover_loader),
smtc_(nullptr),
apartment_initialized_(false) {
try {
// Initialize WinRT apartment if not already initialized
// Qt or other components may have already initialized it
try {
winrt::init_apartment(winrt::apartment_type::single_threaded);
apartment_initialized_ = true;
}
catch (const hresult_error &e) {
// Apartment already initialized - this is fine, continue
if (e.code() != RPC_E_CHANGED_MODE) {
throw;
}
}
// Create private implementation
auto *priv = new WindowsMediaControllerPrivate();
smtc_ = priv;
// Get the SystemMediaTransportControls instance for this window
// Use the interop interface
auto interop = winrt::get_activation_factory<SystemMediaTransportControls, ISystemMediaTransportControlsInterop>();
if (!interop) {
qLog(Warning) << "Failed to get ISystemMediaTransportControlsInterop";
delete priv;
smtc_ = nullptr;
return;
}
// Get SMTC for the window
winrt::com_ptr<IInspectable> inspectable;
HRESULT hr = interop->GetForWindow(hwnd, winrt::guid_of<SystemMediaTransportControls>(), inspectable.put_void());
if (FAILED(hr) || !inspectable) {
qLog(Warning) << "Failed to get SystemMediaTransportControls for window, HRESULT:" << Qt::hex << static_cast<unsigned int>(hr);
delete priv;
smtc_ = nullptr;
return;
}
// Convert to SystemMediaTransportControls
priv->smtc = inspectable.as<SystemMediaTransportControls>();
if (!priv->smtc) {
qLog(Warning) << "Failed to cast to SystemMediaTransportControls";
delete priv;
smtc_ = nullptr;
return;
}
// Enable the controls
priv->smtc.IsEnabled(true);
priv->smtc.IsPlayEnabled(true);
priv->smtc.IsPauseEnabled(true);
priv->smtc.IsStopEnabled(true);
priv->smtc.IsNextEnabled(true);
priv->smtc.IsPreviousEnabled(true);
// Setup button handlers
SetupButtonHandlers();
// Connect signals from Player
QObject::connect(&*player_->engine(), &EngineBase::StateChanged, this, &WindowsMediaController::EngineStateChanged);
QObject::connect(&*playlist_manager_, &PlaylistManager::CurrentSongChanged, this, &WindowsMediaController::CurrentSongChanged);
QObject::connect(&*current_albumcover_loader_, &CurrentAlbumCoverLoader::AlbumCoverLoaded, this, &WindowsMediaController::AlbumCoverLoaded);
qLog(Info) << "Windows Media Transport Controls initialized successfully";
}
catch (const hresult_error &e) {
qLog(Warning) << "Failed to initialize Windows Media Transport Controls:" << QString::fromWCharArray(e.message().c_str());
if (smtc_) {
delete static_cast<WindowsMediaControllerPrivate*>(smtc_);
smtc_ = nullptr;
}
}
catch (...) {
qLog(Warning) << "Failed to initialize Windows Media Transport Controls: unknown error";
if (smtc_) {
delete static_cast<WindowsMediaControllerPrivate*>(smtc_);
smtc_ = nullptr;
}
}
}
WindowsMediaController::~WindowsMediaController() {
if (smtc_) {
auto *priv = static_cast<WindowsMediaControllerPrivate*>(smtc_);
if (priv->smtc) {
priv->smtc.IsEnabled(false);
}
delete priv;
smtc_ = nullptr;
}
// Only uninit if we initialized the apartment
if (apartment_initialized_) {
winrt::uninit_apartment();
}
}
void WindowsMediaController::SetupButtonHandlers() {
if (!smtc_) return;
auto *priv = static_cast<WindowsMediaControllerPrivate*>(smtc_);
if (!priv->smtc) return;
// Handle button pressed events
priv->smtc.ButtonPressed([this](const SystemMediaTransportControls &, const SystemMediaTransportControlsButtonPressedEventArgs &args) {
switch (args.Button()) {
case SystemMediaTransportControlsButton::Play:
player_->Play();
break;
case SystemMediaTransportControlsButton::Pause:
player_->Pause();
break;
case SystemMediaTransportControlsButton::Stop:
player_->Stop();
break;
case SystemMediaTransportControlsButton::Next:
player_->Next();
break;
case SystemMediaTransportControlsButton::Previous:
player_->Previous();
break;
default:
break;
}
});
}
void WindowsMediaController::EngineStateChanged(EngineBase::State newState) {
UpdatePlaybackStatus(newState);
}
void WindowsMediaController::UpdatePlaybackStatus(EngineBase::State state) {
if (!smtc_) return;
auto *priv = static_cast<WindowsMediaControllerPrivate*>(smtc_);
if (!priv->smtc) return;
try {
switch (state) {
case EngineBase::State::Playing:
priv->smtc.PlaybackStatus(MediaPlaybackStatus::Playing);
break;
case EngineBase::State::Paused:
priv->smtc.PlaybackStatus(MediaPlaybackStatus::Paused);
break;
case EngineBase::State::Empty:
case EngineBase::State::Idle:
priv->smtc.PlaybackStatus(MediaPlaybackStatus::Stopped);
break;
}
}
catch (const hresult_error &e) {
qLog(Warning) << "Failed to update playback status:" << QString::fromWCharArray(e.message().c_str());
}
}
void WindowsMediaController::CurrentSongChanged(const Song &song) {
if (!song.is_valid()) {
return;
}
// Update metadata immediately with what we have
UpdateMetadata(song, QUrl());
// Album cover will be updated via AlbumCoverLoaded signal
}
void WindowsMediaController::AlbumCoverLoaded(const Song &song, const AlbumCoverLoaderResult &result) {
if (!song.is_valid()) {
return;
}
// Update metadata with album cover
UpdateMetadata(song, result.temp_cover_url.isEmpty() ? result.album_cover.cover_url : result.temp_cover_url);
}
void WindowsMediaController::UpdateMetadata(const Song &song, const QUrl &art_url) {
if (!smtc_) return;
auto *priv = static_cast<WindowsMediaControllerPrivate*>(smtc_);
if (!priv->smtc) return;
try {
// Get the updater
SystemMediaTransportControlsDisplayUpdater updater = priv->smtc.DisplayUpdater();
updater.Type(MediaPlaybackType::Music);
// Get the music properties
auto musicProperties = updater.MusicProperties();
// Set basic metadata
if (!song.title().isEmpty()) {
musicProperties.Title(winrt::hstring(song.title().toStdWString()));
}
if (!song.artist().isEmpty()) {
musicProperties.Artist(winrt::hstring(song.artist().toStdWString()));
}
if (!song.album().isEmpty()) {
musicProperties.AlbumTitle(winrt::hstring(song.album().toStdWString()));
}
// Set album art if available
if (art_url.isValid() && art_url.isLocalFile()) {
QString artPath = art_url.toLocalFile();
if (!artPath.isEmpty()) {
try {
// Use file:// URI to avoid async blocking in STA thread
QString fileUri = QUrl::fromLocalFile(artPath).toString();
auto thumbnailStream = RandomAccessStreamReference::CreateFromUri(
winrt::Windows::Foundation::Uri(winrt::hstring(fileUri.toStdWString()))
);
updater.Thumbnail(thumbnailStream);
current_song_art_url_ = artPath;
}
catch (const hresult_error &e) {
qLog(Debug) << "Failed to set album art:" << QString::fromWCharArray(e.message().c_str());
}
}
}
// Update the display
updater.Update();
}
catch (const hresult_error &e) {
qLog(Warning) << "Failed to update metadata:" << QString::fromWCharArray(e.message().c_str());
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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 WINDOWSMEDIACONTROLLER_H
#define WINDOWSMEDIACONTROLLER_H
#include "config.h"
#include <windows.h>
#include <QObject>
#include <QString>
#include "includes/shared_ptr.h"
#include "engine/enginebase.h"
#include "covermanager/albumcoverloaderresult.h"
class Player;
class PlaylistManager;
class CurrentAlbumCoverLoader;
class Song;
class WindowsMediaController : public QObject {
Q_OBJECT
public:
explicit WindowsMediaController(HWND hwnd,
const SharedPtr<Player> player,
const SharedPtr<PlaylistManager> playlist_manager,
const SharedPtr<CurrentAlbumCoverLoader> current_albumcover_loader,
QObject *parent = nullptr);
~WindowsMediaController() override;
private Q_SLOTS:
void AlbumCoverLoaded(const Song &song, const AlbumCoverLoaderResult &result = AlbumCoverLoaderResult());
void EngineStateChanged(EngineBase::State newState);
void CurrentSongChanged(const Song &song);
private:
void UpdatePlaybackStatus(EngineBase::State state);
void UpdateMetadata(const Song &song, const QUrl &art_url);
void SetupButtonHandlers();
private:
const SharedPtr<Player> player_;
const SharedPtr<PlaylistManager> playlist_manager_;
const SharedPtr<CurrentAlbumCoverLoader> current_albumcover_loader_;
void *smtc_; // Pointer to SystemMediaTransportControls (opaque to avoid WinRT headers in public header)
QString current_song_art_url_;
bool apartment_initialized_; // Track if we initialized the WinRT apartment
};
#endif // WINDOWSMEDIACONTROLLER_H

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,32 @@
/*
* Strawberry Music Player
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GSTURL_H
#define GSTURL_H
#include <QByteArray>
#include <QString>
class GstUrl {
public:
QByteArray url;
QString source_device;
};
#endif // GSTURL_H

View File

@@ -32,6 +32,7 @@
#include <QKeySequence>
#include "core/logging.h"
#include "core/settings.h"
#include "globalshortcutsmanager.h"
#include "globalshortcutsbackend.h"
@@ -58,29 +59,32 @@ using namespace Qt::Literals::StringLiterals;
GlobalShortcutsManager::GlobalShortcutsManager(QWidget *parent) : QWidget(parent) {
settings_.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
Settings s;
s.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
// Create actions
AddShortcut(u"play"_s, tr("Play"), std::bind(&GlobalShortcutsManager::Play, this));
AddShortcut(u"pause"_s, tr("Pause"), std::bind(&GlobalShortcutsManager::Pause, this));
AddShortcut(u"play_pause"_s, tr("Play/Pause"), std::bind(&GlobalShortcutsManager::PlayPause, this), QKeySequence(Qt::Key_MediaPlay));
AddShortcut(u"stop"_s, tr("Stop"), std::bind(&GlobalShortcutsManager::Stop, this), QKeySequence(Qt::Key_MediaStop));
AddShortcut(u"stop_after"_s, tr("Stop playing after current track"), std::bind(&GlobalShortcutsManager::StopAfter, this));
AddShortcut(u"next_track"_s, tr("Next track"), std::bind(&GlobalShortcutsManager::Next, this), QKeySequence(Qt::Key_MediaNext));
AddShortcut(u"prev_track"_s, tr("Previous track"), std::bind(&GlobalShortcutsManager::Previous, this), QKeySequence(Qt::Key_MediaPrevious));
AddShortcut(u"restart_or_prev_track"_s, tr("Restart or previous track"), std::bind(&GlobalShortcutsManager::RestartOrPrevious, this));
AddShortcut(u"inc_volume"_s, tr("Increase volume"), std::bind(&GlobalShortcutsManager::IncVolume, this));
AddShortcut(u"dec_volume"_s, tr("Decrease volume"), std::bind(&GlobalShortcutsManager::DecVolume, this));
AddShortcut(u"mute"_s, tr("Mute"), std::bind(&GlobalShortcutsManager::Mute, this));
AddShortcut(u"seek_forward"_s, tr("Seek forward"), std::bind(&GlobalShortcutsManager::SeekForward, this));
AddShortcut(u"seek_backward"_s, tr("Seek backward"), std::bind(&GlobalShortcutsManager::SeekBackward, this));
AddShortcut(u"show_hide"_s, tr("Show/Hide"), std::bind(&GlobalShortcutsManager::ShowHide, this));
AddShortcut(u"show_osd"_s, tr("Show OSD"), std::bind(&GlobalShortcutsManager::ShowOSD, this));
AddShortcut(u"toggle_pretty_osd"_s, tr("Toggle Pretty OSD"), std::bind(&GlobalShortcutsManager::TogglePrettyOSD, this)); // Toggling possible only for pretty OSD
AddShortcut(u"shuffle_mode"_s, tr("Change shuffle mode"), std::bind(&GlobalShortcutsManager::CycleShuffleMode, this));
AddShortcut(u"repeat_mode"_s, tr("Change repeat mode"), std::bind(&GlobalShortcutsManager::CycleRepeatMode, this));
AddShortcut(u"toggle_scrobbling"_s, tr("Enable/disable scrobbling"), std::bind(&GlobalShortcutsManager::ToggleScrobbling, this));
AddShortcut(u"love"_s, tr("Love"), std::bind(&GlobalShortcutsManager::Love, this));
AddShortcut(s, u"play"_s, tr("Play"), std::bind(&GlobalShortcutsManager::Play, this));
AddShortcut(s, u"pause"_s, tr("Pause"), std::bind(&GlobalShortcutsManager::Pause, this));
AddShortcut(s, u"play_pause"_s, tr("Play/Pause"), std::bind(&GlobalShortcutsManager::PlayPause, this), QKeySequence(Qt::Key_MediaPlay));
AddShortcut(s, u"stop"_s, tr("Stop"), std::bind(&GlobalShortcutsManager::Stop, this), QKeySequence(Qt::Key_MediaStop));
AddShortcut(s, u"stop_after"_s, tr("Stop playing after current track"), std::bind(&GlobalShortcutsManager::StopAfter, this));
AddShortcut(s, u"next_track"_s, tr("Next track"), std::bind(&GlobalShortcutsManager::Next, this), QKeySequence(Qt::Key_MediaNext));
AddShortcut(s, u"prev_track"_s, tr("Previous track"), std::bind(&GlobalShortcutsManager::Previous, this), QKeySequence(Qt::Key_MediaPrevious));
AddShortcut(s, u"restart_or_prev_track"_s, tr("Restart or previous track"), std::bind(&GlobalShortcutsManager::RestartOrPrevious, this));
AddShortcut(s, u"inc_volume"_s, tr("Increase volume"), std::bind(&GlobalShortcutsManager::IncVolume, this));
AddShortcut(s, u"dec_volume"_s, tr("Decrease volume"), std::bind(&GlobalShortcutsManager::DecVolume, this));
AddShortcut(s, u"mute"_s, tr("Mute"), std::bind(&GlobalShortcutsManager::Mute, this));
AddShortcut(s, u"seek_forward"_s, tr("Seek forward"), std::bind(&GlobalShortcutsManager::SeekForward, this));
AddShortcut(s, u"seek_backward"_s, tr("Seek backward"), std::bind(&GlobalShortcutsManager::SeekBackward, this));
AddShortcut(s, u"show_hide"_s, tr("Show/Hide"), std::bind(&GlobalShortcutsManager::ShowHide, this));
AddShortcut(s, u"show_osd"_s, tr("Show OSD"), std::bind(&GlobalShortcutsManager::ShowOSD, this));
AddShortcut(s, u"toggle_pretty_osd"_s, tr("Toggle Pretty OSD"), std::bind(&GlobalShortcutsManager::TogglePrettyOSD, this)); // Toggling possible only for pretty OSD
AddShortcut(s, u"shuffle_mode"_s, tr("Change shuffle mode"), std::bind(&GlobalShortcutsManager::CycleShuffleMode, this));
AddShortcut(s, u"repeat_mode"_s, tr("Change repeat mode"), std::bind(&GlobalShortcutsManager::CycleRepeatMode, this));
AddShortcut(s, u"toggle_scrobbling"_s, tr("Enable/disable scrobbling"), std::bind(&GlobalShortcutsManager::ToggleScrobbling, this));
AddShortcut(s, u"love"_s, tr("Love"), std::bind(&GlobalShortcutsManager::Love, this));
s.endGroup();
// Create backends - these do the actual shortcut registration
@@ -116,35 +120,39 @@ void GlobalShortcutsManager::ReloadSettings() {
backends_enabled_ << GlobalShortcutsBackend::Type::Win;
#endif
{
Settings s;
s.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
#ifdef HAVE_KGLOBALACCEL_GLOBALSHORTCUTS
if (settings_.value(GlobalShortcutsSettings::kUseKGlobalAccel, true).toBool()) {
backends_enabled_ << GlobalShortcutsBackend::Type::KGlobalAccel;
}
if (s.value(GlobalShortcutsSettings::kUseKGlobalAccel, true).toBool()) {
backends_enabled_ << GlobalShortcutsBackend::Type::KGlobalAccel;
}
#endif
#ifdef HAVE_X11_GLOBALSHORTCUTS
if (settings_.value(GlobalShortcutsSettings::kUseX11, false).toBool()) {
backends_enabled_ << GlobalShortcutsBackend::Type::X11;
}
if (s.value(GlobalShortcutsSettings::kUseX11, false).toBool()) {
backends_enabled_ << GlobalShortcutsBackend::Type::X11;
}
#endif
s.endGroup();
}
Unregister();
Register();
}
void GlobalShortcutsManager::AddShortcut(const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key) { // clazy:exclude=function-args-by-ref
void GlobalShortcutsManager::AddShortcut(Settings &s, const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key) { // clazy:exclude=function-args-by-ref
Shortcut shortcut = AddShortcut(id, name, default_key);
Shortcut shortcut = AddShortcut(s, id, name, default_key);
QObject::connect(shortcut.action, &QAction::triggered, this, signal);
}
GlobalShortcutsManager::Shortcut GlobalShortcutsManager::AddShortcut(const QString &id, const QString &name, const QKeySequence &default_key) {
GlobalShortcutsManager::Shortcut GlobalShortcutsManager::AddShortcut(Settings &s, const QString &id, const QString &name, const QKeySequence &default_key) {
Shortcut shortcut;
shortcut.action = new QAction(name, this);
QKeySequence key_sequence = QKeySequence::fromString(settings_.value(id, default_key.toString()).toString());
QKeySequence key_sequence = QKeySequence::fromString(s.value(id, default_key.toString()).toString());
shortcut.action->setShortcut(key_sequence);
shortcut.id = id;
shortcut.default_key = default_key;

View File

@@ -32,15 +32,14 @@
#include <QMap>
#include <QString>
#include <QKeySequence>
#include <QSettings>
#include "globalshortcutsbackend.h"
#include "core/settings.h"
class QShortcut;
class QAction;
class Settings;
class GlobalShortcutsManager : public QWidget {
Q_OBJECT
@@ -99,12 +98,11 @@ class GlobalShortcutsManager : public QWidget {
void Love();
private:
void AddShortcut(const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key = QKeySequence(0));
Shortcut AddShortcut(const QString &id, const QString &name, const QKeySequence &default_key);
void AddShortcut(Settings &s, const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key = QKeySequence(0));
Shortcut AddShortcut(Settings &s, const QString &id, const QString &name, const QKeySequence &default_key);
private:
QList<GlobalShortcutsBackend*> backends_;
Settings settings_;
QList<GlobalShortcutsBackend::Type> backends_enabled_;
QMap<QString, Shortcut> shortcuts_;
};

View File

@@ -93,6 +93,10 @@
# include "discord/richpresence.h"
#endif
#ifdef HAVE_WINDOWS_MEDIA_CONTROLS
# include "core/windowsmediacontroller.h"
#endif
#include "core/iconloader.h"
#include "core/commandlineoptions.h"
#include "core/networkproxyfactory.h"
@@ -365,6 +369,11 @@ int main(int argc, char *argv[]) {
#endif
options);
#ifdef HAVE_WINDOWS_MEDIA_CONTROLS
// Initialize Windows Media Transport Controls
WindowsMediaController windows_media_controller(reinterpret_cast<HWND>(w.winId()), app.player(), app.playlist_manager(), app.current_albumcover_loader());
#endif
#ifdef Q_OS_MACOS
mac::EnableFullScreen(w);
#endif // Q_OS_MACOS

View File

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

View File

@@ -106,8 +106,6 @@ PlaylistContainer::PlaylistContainer(QWidget *parent)
no_matches_font.setBold(true);
no_matches_label_->setFont(no_matches_font);
settings_.beginGroup(kSettingsGroup);
// Tab bar
ui_->tab_bar->setExpanding(false);
ui_->tab_bar->setMovable(true);
@@ -257,7 +255,11 @@ void PlaylistContainer::ReloadSettings() {
ui_->redo->setIconSize(QSize(iconsize, iconsize));
ui_->search_field->setIconSize(iconsize);
bool playlist_clear = settings_.value("playlist_clear", true).toBool();
s.beginGroup(kSettingsGroup);
const bool playlist_clear = s.value("playlist_clear", true).toBool();
const bool show_toolbar = s.value("show_toolbar", true).toBool();
s.endGroup();
if (playlist_clear) {
ui_->clear->show();
}
@@ -265,7 +267,6 @@ void PlaylistContainer::ReloadSettings() {
ui_->clear->hide();
}
bool show_toolbar = settings_.value("show_toolbar", true).toBool();
ui_->toolbar->setVisible(show_toolbar);
if (!show_toolbar) ui_->search_field->clear();
@@ -308,7 +309,12 @@ void PlaylistContainer::PlaylistAdded(const int id, const QString &name, const b
ui_->tab_bar->InsertTab(id, index, name, favorite);
// Are we start up, should we select this tab?
if (starting_up_ && settings_.value("current_playlist", 1).toInt() == id) {
Settings s;
s.beginGroup(kSettingsGroup);
const int current_playlist = s.value("current_playlist", 1).toInt();
s.endGroup();
if (starting_up_ && current_playlist == id) {
starting_up_ = false;
ui_->tab_bar->set_current_id(id);
}
@@ -347,12 +353,14 @@ void PlaylistContainer::NewPlaylist() { manager_->New(tr("Playlist")); }
void PlaylistContainer::LoadPlaylist() {
QString filename = settings_.value("last_load_playlist").toString();
Settings s;
s.beginGroup(kSettingsGroup);
QString filename = s.value("last_load_playlist").toString();
filename = QFileDialog::getOpenFileName(this, tr("Load playlist"), filename, manager_->parser()->filters(PlaylistParser::Type::Load));
if (filename.isNull()) return;
settings_.setValue("last_load_playlist", filename);
s.setValue("last_load_playlist", filename);
manager_->Load(filename);
@@ -391,7 +399,10 @@ void PlaylistContainer::Save() {
if (starting_up_) return;
settings_.setValue("current_playlist", ui_->tab_bar->current_id());
Settings s;
s.beginGroup(kSettingsGroup);
s.setValue("current_playlist", ui_->tab_bar->current_id());
s.endGroup();
}

View File

@@ -28,7 +28,6 @@
#include <QWidget>
#include <QString>
#include <QIcon>
#include <QSettings>
class QTimer;
class QTimeLine;
@@ -45,7 +44,6 @@ class PlaylistView;
class Ui_PlaylistContainer;
#include "includes/shared_ptr.h"
#include "core/settings.h"
class PlaylistContainer : public QWidget {
Q_OBJECT
@@ -118,7 +116,6 @@ class PlaylistContainer : public QWidget {
QAction *redo_;
Playlist *playlist_;
Settings settings_;
bool starting_up_;
bool tab_bar_visible_;

View File

@@ -57,7 +57,6 @@
#include <QLinearGradient>
#include <QScrollBar>
#include <QtEvents>
#include <QSettings>
#include <QDrag>
#include "includes/qt_blurimage.h"

View File

@@ -0,0 +1,277 @@
/*
* Strawberry Music Player
* Copyright 2019-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QByteArray>
#include <QMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QRegularExpressionMatchIterator>
#include "includes/shared_ptr.h"
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "qobuzcredentialfetcher.h"
using namespace Qt::Literals::StringLiterals;
namespace {
constexpr char kLoginPageUrl[] = "https://play.qobuz.com/login";
constexpr char kPlayQobuzUrl[] = "https://play.qobuz.com";
} // namespace
QobuzCredentialFetcher::QobuzCredentialFetcher(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: QObject(parent),
network_(network),
login_page_reply_(nullptr),
bundle_reply_(nullptr) {}
void QobuzCredentialFetcher::FetchCredentials() {
qLog(Debug) << "Qobuz: Fetching credentials from web player";
QNetworkRequest request(QUrl(QString::fromLatin1(kLoginPageUrl)));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s);
login_page_reply_ = network_->get(request);
QObject::connect(login_page_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::LoginPageReceived);
}
void QobuzCredentialFetcher::LoginPageReceived() {
if (!login_page_reply_) return;
QNetworkReply *reply = login_page_reply_;
login_page_reply_ = nullptr;
if (reply->error() != QNetworkReply::NoError) {
QString error = QStringLiteral("Failed to fetch login page: %1").arg(reply->errorString());
qLog(Error) << "Qobuz:" << error;
reply->deleteLater();
Q_EMIT CredentialsFetchError(error);
return;
}
const QString login_page = QString::fromUtf8(reply->readAll());
reply->deleteLater();
// Extract bundle.js URL from the login page
// Pattern: <script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>
static const QRegularExpression bundle_url_regex(u"<script src=\"(/resources/[\\d.]+-[a-z]\\d+/bundle\\.js)\"></script>"_s);
const QRegularExpressionMatch bundle_match = bundle_url_regex.match(login_page);
if (!bundle_match.hasMatch()) {
QString error = u"Failed to find bundle.js URL in login page"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
bundle_url_ = bundle_match.captured(1);
qLog(Debug) << "Qobuz: Found bundle URL:" << bundle_url_;
// Fetch the bundle.js
QNetworkRequest request(QUrl(QString::fromLatin1(kPlayQobuzUrl) + bundle_url_));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s);
bundle_reply_ = network_->get(request);
QObject::connect(bundle_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::BundleReceived);
}
void QobuzCredentialFetcher::BundleReceived() {
if (!bundle_reply_) return;
QNetworkReply *reply = bundle_reply_;
bundle_reply_ = nullptr;
if (reply->error() != QNetworkReply::NoError) {
QString error = QStringLiteral("Failed to fetch bundle.js: %1").arg(reply->errorString());
qLog(Error) << "Qobuz:" << error;
reply->deleteLater();
Q_EMIT CredentialsFetchError(error);
return;
}
const QString bundle = QString::fromUtf8(reply->readAll());
reply->deleteLater();
qLog(Debug) << "Qobuz: Bundle size:" << bundle.length();
const QString app_id = ExtractAppId(bundle);
if (app_id.isEmpty()) {
QString error = u"Failed to extract app_id from bundle"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
const QString app_secret = ExtractAppSecret(bundle);
if (app_secret.isEmpty()) {
QString error = u"Failed to extract app_secret from bundle"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
qLog(Debug) << "Qobuz: Successfully extracted credentials - app_id:" << app_id;
Q_EMIT CredentialsFetched(app_id, app_secret);
}
QString QobuzCredentialFetcher::ExtractAppId(const QString &bundle) {
// Pattern: production:{api:{appId:"(\d+)"
static const QRegularExpression app_id_regex(u"production:\\{api:\\{appId:\"(\\d+)\""_s);
const QRegularExpressionMatch app_id_match = app_id_regex.match(bundle);
if (app_id_match.hasMatch()) {
return app_id_match.captured(1);
}
return QString();
}
QString QobuzCredentialFetcher::ExtractAppSecret(const QString &bundle) {
// The plain-text appSecret in the bundle doesn't work for API requests.
// We need to use the Spoofbuz method to extract the real secrets:
// 1. Find seed/timezone pairs
// 2. Find info/extras for each timezone
// 3. Combine seed + info + extras, remove last 44 chars, base64 decode
// Pattern to find seed and timezone: [a-z].initialSeed("seed",window.utimezone.timezone)
static const QRegularExpression seed_regex(u"[a-z]\\.initialSeed\\(\"([\\w=]+)\",window\\.utimezone\\.([a-z]+)\\)"_s);
QMap<QString, QString> seeds; // timezone -> seed
QRegularExpressionMatchIterator seed_iter = seed_regex.globalMatch(bundle);
while (seed_iter.hasNext()) {
const QRegularExpressionMatch seed_match = seed_iter.next();
const QString seed = seed_match.captured(1);
const QString tz = seed_match.captured(2);
seeds[tz] = seed;
qLog(Debug) << "Qobuz: Found seed for timezone" << tz;
}
if (seeds.isEmpty()) {
qLog(Error) << "Qobuz: No seed/timezone pairs found in bundle";
return QString();
}
// Try each timezone - Berlin was confirmed working
const QStringList preferred_order = {u"berlin"_s, u"london"_s, u"abidjan"_s};
for (const QString &tz : preferred_order) {
if (!seeds.contains(tz)) {
continue;
}
// Pattern to find info and extras for this timezone
// name:"xxx/Berlin",info:"...",extras:"..."
const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1);
const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz);
const QRegularExpression info_regex(info_pattern);
const QRegularExpressionMatch info_match = info_regex.match(bundle);
if (!info_match.hasMatch()) {
qLog(Debug) << "Qobuz: No info/extras found for timezone" << tz;
continue;
}
const QString seed = seeds[tz];
const QString info = info_match.captured(1);
const QString extras = info_match.captured(2);
qLog(Debug) << "Qobuz: Decoding secret for timezone" << tz;
// Combine seed + info + extras
const QString combined = seed + info + extras;
// Remove last 44 characters
if (combined.length() <= 44) {
qLog(Debug) << "Qobuz: Combined string too short for timezone" << tz;
continue;
}
const QString trimmed = combined.left(combined.length() - 44);
// Base64 decode
const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1());
const QString secret = QString::fromLatin1(decoded);
// Validate: should be 32 hex characters
static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s);
if (hex_regex.match(secret).hasMatch()) {
qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz;
return secret;
}
qLog(Debug) << "Qobuz: Decoded secret invalid for timezone" << tz;
}
// Try any remaining timezones not in preferred order
for (auto it = seeds.constBegin(); it != seeds.constEnd(); ++it) {
const QString &tz = it.key();
if (preferred_order.contains(tz)) {
continue; // Already tried
}
const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1);
const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz);
const QRegularExpression info_regex(info_pattern);
const QRegularExpressionMatch info_match = info_regex.match(bundle);
if (!info_match.hasMatch()) {
continue;
}
const QString seed = it.value();
const QString info = info_match.captured(1);
const QString extras = info_match.captured(2);
const QString combined = seed + info + extras;
if (combined.length() <= 44) {
continue;
}
const QString trimmed = combined.left(combined.length() - 44);
const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1());
const QString secret = QString::fromLatin1(decoded);
static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s);
if (hex_regex.match(secret).hasMatch()) {
qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz;
return secret;
}
}
qLog(Error) << "Qobuz: Failed to decode any valid app_secret from bundle";
return QString();
}

View File

@@ -0,0 +1,59 @@
/*
* Strawberry Music Player
* Copyright 2019-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef QOBUZCREDENTIALFETCHER_H
#define QOBUZCREDENTIALFETCHER_H
#include "config.h"
#include <QObject>
#include <QString>
#include "includes/shared_ptr.h"
class QNetworkReply;
class NetworkAccessManager;
class QobuzCredentialFetcher : public QObject {
Q_OBJECT
public:
explicit QobuzCredentialFetcher(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
void FetchCredentials();
Q_SIGNALS:
void CredentialsFetched(const QString &app_id, const QString &app_secret);
void CredentialsFetchError(const QString &error);
private Q_SLOTS:
void LoginPageReceived();
void BundleReceived();
private:
QString ExtractAppId(const QString &bundle);
QString ExtractAppSecret(const QString &bundle);
const SharedPtr<NetworkAccessManager> network_;
QNetworkReply *login_page_reply_;
QNetworkReply *bundle_reply_;
QString bundle_url_;
};
#endif // QOBUZCREDENTIALFETCHER_H

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
/*
* Strawberry Music Player
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef TAGID3V2VERSION_H
#define TAGID3V2VERSION_H
enum class TagID3v2Version {
Default = 0, // Use existing version or library default
V3 = 3,
V4 = 4
};
#endif // TAGID3V2VERSION_H

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,6 +46,7 @@
#include <taglib/apeproperties.h>
#include <taglib/id3v2tag.h>
#include <taglib/id3v2frame.h>
#include <taglib/id3v2header.h>
#include <taglib/attachedpictureframe.h>
#include <taglib/textidentificationframe.h>
#include <taglib/unsynchronizedlyricsframe.h>
@@ -94,8 +95,6 @@
#include <QStringList>
#include <QFile>
#include <QFileInfo>
#include <QDir>
#include <QTemporaryFile>
#include <QUrl>
#include <QDateTime>
#include <QtDebug>
@@ -106,6 +105,7 @@
#include "constants/timeconstants.h"
#include "albumcovertagdata.h"
#include "tagid3v2version.h"
using std::make_unique;
using namespace Qt::Literals::StringLiterals;
@@ -296,100 +296,6 @@ TagReaderTagLib::~TagReaderTagLib() {
delete factory_;
}
bool TagReaderTagLib::SaveFileWithFallback(const QString &filename, const std::function<bool(TagLib::FileRef*)> &save_function) const {
// First, try the normal save operation directly on the file
{
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
if (!fileref || fileref->isNull()) {
qLog(Error) << "TagLib could not open file for saving" << filename;
return false;
}
// Apply modifications using the callback
if (!save_function(fileref.get())) {
qLog(Error) << "Failed to apply modifications to file" << filename;
return false;
}
// Try direct save first
if (fileref->save()) {
qLog(Debug) << "Successfully saved file directly" << filename;
return true;
}
}
qLog(Warning) << "Direct save failed, trying atomic write workaround for" << filename;
// If direct save fails (common on GVFS mounts), use atomic write workaround:
// 1. Copy file to temporary location in the same directory
// 2. Re-apply modifications and save to the temporary file
// 3. Replace original file with the temporary file
const QFileInfo file_info(filename);
const QString temp_pattern = file_info.dir().absoluteFilePath(file_info.fileName() + u".XXXXXX"_s);
QTemporaryFile temp_file(temp_pattern);
temp_file.setAutoRemove(false); // We'll handle removal manually
if (!temp_file.open()) {
qLog(Error) << "Could not create temporary file for atomic write:" << temp_file.fileName();
return false;
}
const QString temp_filename = temp_file.fileName();
temp_file.close();
// Copy original file to temporary location
if (!QFile::copy(filename, temp_filename)) {
qLog(Error) << "Could not copy file to temporary location:" << filename << "->" << temp_filename;
QFile::remove(temp_filename);
return false;
}
// Try to modify and save the temporary file
{
ScopedPtr<TagLib::FileRef> temp_fileref(factory_->GetFileRef(temp_filename));
if (!temp_fileref || temp_fileref->isNull()) {
qLog(Error) << "TagLib could not open temporary file" << temp_filename;
QFile::remove(temp_filename);
return false;
}
// Apply modifications using the callback
if (!save_function(temp_fileref.get())) {
qLog(Error) << "Failed to apply modifications to temporary file" << temp_filename;
QFile::remove(temp_filename);
return false;
}
// Save the temporary file
if (!temp_fileref->save()) {
qLog(Error) << "Failed to save temporary file" << temp_filename;
QFile::remove(temp_filename);
return false;
}
}
// Replace the original file with the temporary file
// First remove the original, then rename temp to original
if (!QFile::remove(filename)) {
qLog(Error) << "Could not remove original file for replacement:" << filename;
QFile::remove(temp_filename);
return false;
}
if (!QFile::rename(temp_filename, filename)) {
qLog(Error) << "Could not rename temporary file to original:" << temp_filename << "->" << filename;
// This is a critical error - original file was removed but rename failed
return false;
}
qLog(Debug) << "Successfully saved file using atomic write workaround" << filename;
return true;
}
TagReaderResult TagReaderTagLib::IsMediaFile(const QString &filename) const {
qLog(Debug) << "Checking for valid file" << filename;
@@ -700,8 +606,14 @@ TagReaderResult TagReaderTagLib::ReadStream(const QUrl &url,
void TagReaderTagLib::ParseID3v2Tags(TagLib::ID3v2::Tag *tag, QString *disc, QString *compilation, Song *song) const {
if (!tag) return;
TagLib::ID3v2::FrameListMap map = tag->frameListMap();
if (tag->header()) {
song->set_id3v2_version(tag->header()->majorVersion());
}
if (map.contains(kID3v2_Disc)) *disc = TagLibStringToQString(map[kID3v2_Disc].front()->toString()).trimmed();
if (map.contains(kID3v2_Composer)) song->set_composer(map[kID3v2_Composer].front()->toString());
if (map.contains(kID3v2_ComposerSort)) song->set_composersort(map[kID3v2_ComposerSort].front()->toString());
@@ -1138,7 +1050,7 @@ void TagReaderTagLib::ParseASFAttribute(const TagLib::ASF::AttributeListMap &att
}
TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const {
TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) const {
if (filename.isEmpty()) {
return TagReaderResult::ErrorCode::FilenameMissing;
@@ -1175,26 +1087,175 @@ TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &
cover = LoadAlbumCoverTagData(filename, save_tag_cover_data);
}
// Lambda function that applies all tag modifications to a FileRef
auto apply_modifications = [&](TagLib::FileRef *fileref) -> bool {
if (!fileref || fileref->isNull()) {
return false;
}
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
if (!fileref || fileref->isNull()) {
qLog(Error) << "TagLib could not open file" << filename;
return TagReaderResult::ErrorCode::FileOpenError;
}
if (save_tags) {
fileref->tag()->setTitle(song.title().isEmpty() ? TagLib::String() : QStringToTagLibString(song.title()));
fileref->tag()->setArtist(song.artist().isEmpty() ? TagLib::String() : QStringToTagLibString(song.artist()));
fileref->tag()->setAlbum(song.album().isEmpty() ? TagLib::String() : QStringToTagLibString(song.album()));
fileref->tag()->setGenre(song.genre().isEmpty() ? TagLib::String() : QStringToTagLibString(song.genre()));
fileref->tag()->setComment(song.comment().isEmpty() ? TagLib::String() : QStringToTagLibString(song.comment()));
fileref->tag()->setYear(song.year() <= 0 ? 0 : static_cast<uint>(song.year()));
fileref->tag()->setTrack(song.track() <= 0 ? 0 : static_cast<uint>(song.track()));
}
if (save_tags) {
fileref->tag()->setTitle(song.title().isEmpty() ? TagLib::String() : QStringToTagLibString(song.title()));
fileref->tag()->setArtist(song.artist().isEmpty() ? TagLib::String() : QStringToTagLibString(song.artist()));
fileref->tag()->setAlbum(song.album().isEmpty() ? TagLib::String() : QStringToTagLibString(song.album()));
fileref->tag()->setGenre(song.genre().isEmpty() ? TagLib::String() : QStringToTagLibString(song.genre()));
fileref->tag()->setComment(song.comment().isEmpty() ? TagLib::String() : QStringToTagLibString(song.comment()));
fileref->tag()->setYear(song.year() <= 0 ? 0 : static_cast<uint>(song.year()));
fileref->tag()->setTrack(song.track() <= 0 ? 0 : static_cast<uint>(song.track()));
}
bool is_flac = false;
if (TagLib::FLAC::File *file_flac = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
is_flac = true;
TagLib::Ogg::XiphComment *vorbis_comment = file_flac->xiphComment(true);
bool is_flac = false;
if (TagLib::FLAC::File *file_flac = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
is_flac = true;
TagLib::Ogg::XiphComment *vorbis_comment = file_flac->xiphComment(true);
if (vorbis_comment) {
if (save_tags) {
SetVorbisComments(vorbis_comment, song);
}
if (save_playcount) {
SetPlaycount(vorbis_comment, song.playcount());
}
if (save_rating) {
SetRating(vorbis_comment, song.rating());
}
if (save_cover) {
SetEmbeddedCover(file_flac, vorbis_comment, cover.data, cover.mimetype);
}
}
}
else if (TagLib::WavPack::File *file_wavpack = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
TagLib::APE::Tag *tag = file_wavpack->APETag(true);
if (tag) {
if (save_tags) {
SetAPETag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
}
}
else if (TagLib::APE::File *file_ape = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
TagLib::APE::Tag *tag = file_ape->APETag(true);
if (tag) {
if (save_tags) {
SetAPETag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
}
}
else if (TagLib::MPC::File *file_mpc = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
TagLib::APE::Tag *tag = file_mpc->APETag(true);
if (tag) {
if (save_tags) {
SetAPETag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
}
}
else if (TagLib::MPEG::File *file_mpeg = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = file_mpeg->ID3v2Tag(true);
if (tag) {
if (save_tags) {
SetID3v2Tag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
if (save_cover) {
SetEmbeddedCover(tag, cover.data, cover.mimetype);
}
}
}
else if (TagLib::MP4::File *file_mp4 = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
TagLib::MP4::Tag *tag = file_mp4->tag();
if (tag) {
if (save_tags) {
tag->setItem(kMP4_Disc, TagLib::MP4::Item(song.disc() <= 0 - 1 ? 0 : song.disc(), 0));
tag->setItem(kMP4_Composer, TagLib::StringList(QStringToTagLibString(song.composer())));
tag->setItem(kMP4_Grouping, TagLib::StringList(QStringToTagLibString(song.grouping())));
tag->setItem(kMP4_Lyrics, TagLib::StringList(QStringToTagLibString(song.lyrics())));
tag->setItem(kMP4_AlbumArtist, TagLib::StringList(QStringToTagLibString(song.albumartist())));
tag->setItem(kMP4_Compilation, TagLib::MP4::Item(song.compilation()));
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
if (save_cover) {
SetEmbeddedCover(file_mp4, tag, cover.data, cover.mimetype);
}
}
}
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = file_wav->ID3v2Tag();
if (tag) {
if (save_tags) {
SetID3v2Tag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
if (save_cover) {
SetEmbeddedCover(tag, cover.data, cover.mimetype);
}
}
}
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = file_aiff->tag();
if (tag) {
if (save_tags) {
SetID3v2Tag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
if (save_cover) {
SetEmbeddedCover(tag, cover.data, cover.mimetype);
}
}
}
else if (TagLib::ASF::File *file_asf = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
TagLib::ASF::Tag *tag = file_asf->tag();
if (tag) {
SetASFTag(tag, song);
}
}
// Handle all the files which have VorbisComments (Ogg, OPUS, ...) in the same way;
// apart, so we keep specific behavior for some formats by adding another "else if" block above.
if (!is_flac) {
if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
if (vorbis_comment) {
if (save_tags) {
SetVorbisComments(vorbis_comment, song);
@@ -1206,166 +1267,39 @@ TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &
SetRating(vorbis_comment, song.rating());
}
if (save_cover) {
SetEmbeddedCover(file_flac, vorbis_comment, cover.data, cover.mimetype);
SetEmbeddedCover(vorbis_comment, cover.data, cover.mimetype);
}
}
}
}
else if (TagLib::WavPack::File *file_wavpack = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
TagLib::APE::Tag *tag = file_wavpack->APETag(true);
if (tag) {
if (save_tags) {
SetAPETag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
}
}
// 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;
}
else if (TagLib::APE::File *file_ape = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
TagLib::APE::Tag *tag = file_ape->APETag(true);
if (tag) {
if (save_tags) {
SetAPETag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
}
}
bool success = false;
else if (TagLib::MPC::File *file_mpc = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
TagLib::APE::Tag *tag = file_mpc->APETag(true);
if (tag) {
if (save_tags) {
SetAPETag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
}
}
else if (TagLib::MPEG::File *file_mpeg = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = file_mpeg->ID3v2Tag(true);
if (tag) {
if (save_tags) {
SetID3v2Tag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
if (save_cover) {
SetEmbeddedCover(tag, cover.data, cover.mimetype);
}
}
}
else if (TagLib::MP4::File *file_mp4 = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
TagLib::MP4::Tag *tag = file_mp4->tag();
if (tag) {
if (save_tags) {
tag->setItem(kMP4_Disc, TagLib::MP4::Item(song.disc() <= 0 - 1 ? 0 : song.disc(), 0));
tag->setItem(kMP4_Composer, TagLib::StringList(QStringToTagLibString(song.composer())));
tag->setItem(kMP4_Grouping, TagLib::StringList(QStringToTagLibString(song.grouping())));
tag->setItem(kMP4_Lyrics, TagLib::StringList(QStringToTagLibString(song.lyrics())));
tag->setItem(kMP4_AlbumArtist, TagLib::StringList(QStringToTagLibString(song.albumartist())));
tag->setItem(kMP4_Compilation, TagLib::MP4::Item(song.compilation()));
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
if (save_cover) {
SetEmbeddedCover(file_mp4, tag, cover.data, cover.mimetype);
}
}
}
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = file_wav->ID3v2Tag();
if (tag) {
if (save_tags) {
SetID3v2Tag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
if (save_cover) {
SetEmbeddedCover(tag, cover.data, cover.mimetype);
}
}
}
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = file_aiff->tag();
if (tag) {
if (save_tags) {
SetID3v2Tag(tag, song);
}
if (save_playcount) {
SetPlaycount(tag, song.playcount());
}
if (save_rating) {
SetRating(tag, song.rating());
}
if (save_cover) {
SetEmbeddedCover(tag, cover.data, cover.mimetype);
}
}
}
else if (TagLib::ASF::File *file_asf = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
TagLib::ASF::Tag *tag = file_asf->tag();
if (tag) {
SetASFTag(tag, song);
}
}
// Handle all the files which have VorbisComments (Ogg, OPUS, ...) in the same way;
// apart, so we keep specific behavior for some formats by adding another "else if" block above.
if (!is_flac) {
if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
if (vorbis_comment) {
if (save_tags) {
SetVorbisComments(vorbis_comment, song);
}
if (save_playcount) {
SetPlaycount(vorbis_comment, song.playcount());
}
if (save_rating) {
SetRating(vorbis_comment, song.rating());
}
if (save_cover) {
SetEmbeddedCover(vorbis_comment, cover.data, cover.mimetype);
}
}
}
}
return true;
};
// Use SaveFileWithFallback which tries direct save first, then atomic write workaround
const bool success = SaveFileWithFallback(filename, apply_modifications);
// For MPEG files, use save with ID3v2 version parameter
if (TagLib::MPEG::File *file_mpeg = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
success = file_mpeg->save(TagLib::MPEG::File::AllTags, TagLib::File::StripOthers, taglib_id3v2_version);
}
// For WAV files with ID3v2 tags
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
success = file_wav->save(TagLib::RIFF::WAV::File::AllTags, TagLib::File::StripOthers, taglib_id3v2_version);
}
// For AIFF files with ID3v2 tags
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
success = file_aiff->save(taglib_id3v2_version);
}
// For all other file types, use default save
else {
success = fileref->save();
}
#ifdef Q_OS_LINUX
if (success) {
@@ -1819,69 +1753,64 @@ TagReaderResult TagReaderTagLib::SaveEmbeddedCover(const QString &filename, cons
return TagReaderResult::ErrorCode::FileDoesNotExist;
}
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
if (!fileref || fileref->isNull()) {
qLog(Error) << "TagLib could not open file" << filename;
return TagReaderResult::ErrorCode::FileOpenError;
}
const AlbumCoverTagData cover = LoadAlbumCoverTagData(filename, save_tag_cover_data);
// Lambda function that applies cover modifications to a FileRef
auto apply_modifications = [&](TagLib::FileRef *fileref) -> bool {
if (!fileref || fileref->isNull()) {
return false;
// FLAC
if (TagLib::FLAC::File *file_flac = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
TagLib::Ogg::XiphComment *vorbis_comment = file_flac->xiphComment(true);
if (vorbis_comment) {
SetEmbeddedCover(file_flac, vorbis_comment, cover.data, cover.mimetype);
}
}
// FLAC
if (TagLib::FLAC::File *file_flac = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
TagLib::Ogg::XiphComment *vorbis_comment = file_flac->xiphComment(true);
if (vorbis_comment) {
SetEmbeddedCover(file_flac, vorbis_comment, cover.data, cover.mimetype);
}
// Ogg Vorbis / Opus / Speex
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
SetEmbeddedCover(vorbis_comment, cover.data, cover.mimetype);
}
// MP3
else if (TagLib::MPEG::File *file_mp3 = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = file_mp3->ID3v2Tag();
if (tag) {
SetEmbeddedCover(tag, cover.data, cover.mimetype);
}
}
// Ogg Vorbis / Opus / Speex
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
SetEmbeddedCover(vorbis_comment, cover.data, cover.mimetype);
// MP4/AAC
else if (TagLib::MP4::File *file_aac = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
TagLib::MP4::Tag *tag = file_aac->tag();
if (tag) {
SetEmbeddedCover(file_aac, tag, cover.data, cover.mimetype);
}
}
// MP3
else if (TagLib::MPEG::File *file_mp3 = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = file_mp3->ID3v2Tag();
if (tag) {
SetEmbeddedCover(tag, cover.data, cover.mimetype);
}
// WAV
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
if (file_wav->ID3v2Tag()) {
SetEmbeddedCover(file_wav->ID3v2Tag(), cover.data, cover.mimetype);
}
}
// MP4/AAC
else if (TagLib::MP4::File *file_aac = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
TagLib::MP4::Tag *tag = file_aac->tag();
if (tag) {
SetEmbeddedCover(file_aac, tag, cover.data, cover.mimetype);
}
// AIFF
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
if (file_aiff->tag()) {
SetEmbeddedCover(file_aiff->tag(), cover.data, cover.mimetype);
}
}
// WAV
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
if (file_wav->ID3v2Tag()) {
SetEmbeddedCover(file_wav->ID3v2Tag(), cover.data, cover.mimetype);
}
}
// AIFF
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
if (file_aiff->tag()) {
SetEmbeddedCover(file_aiff->tag(), cover.data, cover.mimetype);
}
}
// Not supported.
else {
qLog(Error) << "Saving embedded art is not supported for %1" << filename;
return false;
}
return true;
};
// Use SaveFileWithFallback which tries direct save first, then atomic write workaround
const bool success = SaveFileWithFallback(filename, apply_modifications);
// Not supported.
else {
qLog(Error) << "Saving embedded art is not supported for %1" << filename;
return TagReaderResult::ErrorCode::Unsupported;
}
const bool success = fileref->file()->save();
#ifdef Q_OS_LINUX
if (success) {
// Linux: inotify doesn't seem to notice the change to the file unless we change the timestamps as well. (this is what touch does)
@@ -1978,69 +1907,64 @@ TagReaderResult TagReaderTagLib::SaveSongPlaycount(const QString &filename, cons
return TagReaderResult::ErrorCode::FileDoesNotExist;
}
// Lambda function that applies playcount modifications to a FileRef
auto apply_modifications = [&](TagLib::FileRef *fileref) -> bool {
if (!fileref || fileref->isNull()) {
return false;
}
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
if (!fileref || fileref->isNull()) {
qLog(Error) << "TagLib could not open file" << filename;
return TagReaderResult::ErrorCode::FileOpenError;
}
if (TagLib::FLAC::File *flac_file = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
TagLib::Ogg::XiphComment *vorbis_comment = flac_file->xiphComment(true);
if (vorbis_comment) {
SetPlaycount(vorbis_comment, playcount);
}
if (TagLib::FLAC::File *flac_file = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
TagLib::Ogg::XiphComment *vorbis_comment = flac_file->xiphComment(true);
if (vorbis_comment) {
SetPlaycount(vorbis_comment, playcount);
}
else if (TagLib::WavPack::File *wavpack_file = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
TagLib::APE::Tag *tag = wavpack_file->APETag(true);
if (tag) {
SetPlaycount(tag, playcount);
}
}
else if (TagLib::WavPack::File *wavpack_file = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
TagLib::APE::Tag *tag = wavpack_file->APETag(true);
if (tag) {
SetPlaycount(tag, playcount);
}
else if (TagLib::APE::File *ape_file = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
TagLib::APE::Tag *tag = ape_file->APETag(true);
if (tag) {
SetPlaycount(tag, playcount);
}
}
else if (TagLib::APE::File *ape_file = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
TagLib::APE::Tag *tag = ape_file->APETag(true);
if (tag) {
SetPlaycount(tag, playcount);
}
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
if (vorbis_comment) {
SetPlaycount(vorbis_comment, playcount);
}
}
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
if (vorbis_comment) {
SetPlaycount(vorbis_comment, playcount);
}
else if (TagLib::MPEG::File *mpeg_file = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = mpeg_file->ID3v2Tag(true);
if (tag) {
SetPlaycount(tag, playcount);
}
}
else if (TagLib::MPEG::File *mpeg_file = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = mpeg_file->ID3v2Tag(true);
if (tag) {
SetPlaycount(tag, playcount);
}
else if (TagLib::MP4::File *mp4_file = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
TagLib::MP4::Tag *tag = mp4_file->tag();
if (tag) {
SetPlaycount(tag, playcount);
}
}
else if (TagLib::MP4::File *mp4_file = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
TagLib::MP4::Tag *tag = mp4_file->tag();
if (tag) {
SetPlaycount(tag, playcount);
}
else if (TagLib::MPC::File *mpc_file = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
TagLib::APE::Tag *tag = mpc_file->APETag(true);
if (tag) {
SetPlaycount(tag, playcount);
}
}
else if (TagLib::MPC::File *mpc_file = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
TagLib::APE::Tag *tag = mpc_file->APETag(true);
if (tag) {
SetPlaycount(tag, playcount);
}
else if (TagLib::ASF::File *asf_file = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
TagLib::ASF::Tag *tag = asf_file->tag();
if (tag && playcount > 0) {
tag->addAttribute(kASF_FMPS_Playcount, TagLib::ASF::Attribute(QStringToTagLibString(QString::number(playcount))));
}
}
else {
return false;
}
else if (TagLib::ASF::File *asf_file = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
TagLib::ASF::Tag *tag = asf_file->tag();
if (tag && playcount > 0) {
tag->addAttribute(kASF_FMPS_Playcount, TagLib::ASF::Attribute(QStringToTagLibString(QString::number(playcount))));
}
}
else {
return TagReaderResult::ErrorCode::Unsupported;
}
return true;
};
// Use SaveFileWithFallback which tries direct save first, then atomic write workaround
const bool success = SaveFileWithFallback(filename, apply_modifications);
const bool success = fileref->save();
#ifdef Q_OS_LINUX
if (success) {
// Linux: inotify doesn't seem to notice the change to the file unless we change the timestamps as well. (this is what touch does)
@@ -2113,68 +2037,63 @@ TagReaderResult TagReaderTagLib::SaveSongRating(const QString &filename, const f
return TagReaderResult::ErrorCode::Success;
}
// Lambda function that applies rating modifications to a FileRef
auto apply_modifications = [&](TagLib::FileRef *fileref) -> bool {
if (!fileref || fileref->isNull()) {
return false;
}
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
if (!fileref || fileref->isNull()) {
qLog(Error) << "TagLib could not open file" << filename;
return TagReaderResult::ErrorCode::FileOpenError;
}
if (TagLib::FLAC::File *flac_file = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
TagLib::Ogg::XiphComment *vorbis_comment = flac_file->xiphComment(true);
if (vorbis_comment) {
SetRating(vorbis_comment, rating);
}
}
else if (TagLib::WavPack::File *wavpack_file = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
TagLib::APE::Tag *tag = wavpack_file->APETag(true);
if (tag) {
SetRating(tag, rating);
}
}
else if (TagLib::APE::File *ape_file = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
TagLib::APE::Tag *tag = ape_file->APETag(true);
if (tag) {
SetRating(tag, rating);
}
}
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
if (TagLib::FLAC::File *flac_file = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
TagLib::Ogg::XiphComment *vorbis_comment = flac_file->xiphComment(true);
if (vorbis_comment) {
SetRating(vorbis_comment, rating);
}
else if (TagLib::MPEG::File *mpeg_file = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = mpeg_file->ID3v2Tag(true);
if (tag) {
SetRating(tag, rating);
}
}
else if (TagLib::WavPack::File *wavpack_file = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
TagLib::APE::Tag *tag = wavpack_file->APETag(true);
if (tag) {
SetRating(tag, rating);
}
else if (TagLib::MP4::File *mp4_file = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
TagLib::MP4::Tag *tag = mp4_file->tag();
if (tag) {
SetRating(tag, rating);
}
}
else if (TagLib::APE::File *ape_file = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
TagLib::APE::Tag *tag = ape_file->APETag(true);
if (tag) {
SetRating(tag, rating);
}
else if (TagLib::ASF::File *asf_file = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
TagLib::ASF::Tag *tag = asf_file->tag();
if (tag) {
SetRating(tag, rating);
}
}
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
SetRating(vorbis_comment, rating);
}
else if (TagLib::MPEG::File *mpeg_file = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
TagLib::ID3v2::Tag *tag = mpeg_file->ID3v2Tag(true);
if (tag) {
SetRating(tag, rating);
}
else if (TagLib::MPC::File *mpc_file = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
TagLib::APE::Tag *tag = mpc_file->APETag(true);
if (tag) {
SetRating(tag, rating);
}
}
else if (TagLib::MP4::File *mp4_file = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
TagLib::MP4::Tag *tag = mp4_file->tag();
if (tag) {
SetRating(tag, rating);
}
else {
qLog(Error) << "Unsupported file for saving rating for" << filename;
return false;
}
else if (TagLib::ASF::File *asf_file = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
TagLib::ASF::Tag *tag = asf_file->tag();
if (tag) {
SetRating(tag, rating);
}
}
else if (TagLib::MPC::File *mpc_file = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
TagLib::APE::Tag *tag = mpc_file->APETag(true);
if (tag) {
SetRating(tag, rating);
}
}
else {
qLog(Error) << "Unsupported file for saving rating for" << filename;
return TagReaderResult::ErrorCode::Unsupported;
}
return true;
};
// Use SaveFileWithFallback which tries direct save first, then atomic write workaround
const bool success = SaveFileWithFallback(filename, apply_modifications);
const bool success = fileref->save();
#ifdef Q_OS_LINUX
if (success) {
// Linux: inotify doesn't seem to notice the change to the file unless we change the timestamps as well. (this is what touch does)

View File

@@ -23,8 +23,6 @@
#include "config.h"
#include <functional>
#include <QByteArray>
#include <QString>
@@ -48,6 +46,7 @@
#include "tagreaderbase.h"
#include "savetagcoverdata.h"
#include "tagid3v2version.h"
#undef TStringToQString
#undef QStringToTString
@@ -74,7 +73,7 @@ class TagReaderTagLib : public TagReaderBase {
TagReaderResult ReadStream(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token, Song *song) const override;
#endif
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const override;
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) const override;
TagReaderResult LoadEmbeddedCover(const QString &filename, QByteArray &data) const override;
TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const override;
@@ -128,8 +127,6 @@ class TagReaderTagLib : public TagReaderBase {
static TagLib::String TagLibStringListToSlashSeparatedString(const TagLib::StringList &taglib_string_list, const uint begin_index = 0);
bool SaveFileWithFallback(const QString &filename, const std::function<bool(TagLib::FileRef*)> &save_function) const;
private:
FileRefFactory *factory_;

View File

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