Compare commits

..

1 Commits

Author SHA1 Message Date
Jonas Kvinge
784c86aa80 Add network remote WIP 2025-12-29 00:41:07 +01:00
176 changed files with 11010 additions and 9884 deletions

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
github: jonaski
patreon: jonaskvinge
ko_fi: jonaskvinge
custom: https://paypal.me/jonaskvinge

View File

@@ -156,7 +156,7 @@ jobs:
strategy:
fail-fast: false
matrix:
fedora_version: [ '42', '43', '44' ]
fedora_version: [ '41', '42', '43' ]
container:
image: fedora:${{matrix.fedora_version}}
steps:
@@ -542,7 +542,7 @@ jobs:
strategy:
fail-fast: false
matrix:
ubuntu_version: [ 'noble', 'questing', 'resolute' ]
ubuntu_version: [ 'noble', 'plucky', 'questing' ]
container:
image: ubuntu:${{matrix.ubuntu_version}}
steps:
@@ -596,10 +596,10 @@ jobs:
qt6-l10n-tools
rapidjson-dev
- name: Install KDSingleApplication
if: matrix.ubuntu_version != 'noble'
if: matrix.ubuntu_version != 'noble' && matrix.ubuntu_version != 'plucky'
run: apt install -y libkdsingleapplication-qt6-dev
- name: Build and install KDSingleApplication
if: matrix.ubuntu_version == 'noble'
if: matrix.ubuntu_version == 'noble' || matrix.ubuntu_version == 'plucky'
run: |
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
cd KDSingleApplication
@@ -639,7 +639,7 @@ jobs:
strategy:
fail-fast: false
matrix:
ubuntu_version: [ 'noble', 'questing', 'resolute' ]
ubuntu_version: [ 'noble', 'plucky', 'questing' ]
container:
image: ubuntu:${{matrix.ubuntu_version}}
steps:
@@ -747,7 +747,7 @@ jobs:
df -h
- name: Build FreeBSD
id: build-freebsd
uses: vmactions/freebsd-vm@v1.3.7
uses: vmactions/freebsd-vm@v1.3.2
with:
usesh: true
mem: 8192
@@ -772,7 +772,7 @@ jobs:
submodules: recursive
- name: Build OpenBSD
id: build-openbsd
uses: vmactions/openbsd-vm@v1.3.1
uses: vmactions/openbsd-vm@v1.2.9
with:
usesh: true
mem: 4096
@@ -845,7 +845,7 @@ jobs:
p12-password: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
- name: Download macOS dependencies
run: curl -f -O -L https://github.com/strawberrymusicplayer/strawberry-macos-dependencies/releases/latest/download/strawberry-macos-${{env.arch}}-${{env.buildtype}}.tar.xz
run: curl -f -O -L https://github.com/strawberrymusicplayer/strawberry-macos-dependencies$(test "${{env.arch}}" = "x86_64" && echo "-intel" || echo "")/releases/latest/download/strawberry-macos-${{env.arch}}-${{env.buildtype}}.tar.xz
- name: Extract macOS dependencies
run: sudo tar -C / -xf strawberry-macos-${{env.arch}}-${{env.buildtype}}.tar.xz
@@ -898,7 +898,7 @@ jobs:
- name: Manually Codesign
if: github.repository == 'strawberrymusicplayer/strawberry' && github.event.pull_request.head.repo.fork == false && matrix.runner == 'macos-15-intel'
working-directory: build
run: codesign -s 383J84DVB6 -f strawberry.app/Contents/Frameworks/{libpcre2-8.0.dylib,libpcre2-16.0.dylib,libpng16.16.dylib,libfreetype.6.dylib,libzstd.1.dylib,libbrotlicommon.1.dylib,libbrotlienc.1.dylib,libbrotlidec.1.dylib,libsoup-3.0.0.dylib,libnghttp2.14.dylib,libpsl.5.dylib} strawberry.app/Contents/Frameworks/png.framework/png strawberry.app
run: codesign -s 383J84DVB6 -f strawberry.app/Contents/Frameworks/{libpcre2-8.0.dylib,libpcre2-16.0.dylib,libpng16.16.dylib,libfreetype.6.dylib,libzstd.1.dylib,libbrotlicommon.1.dylib,libbrotlienc.1.dylib} strawberry.app/Contents/Frameworks/png.framework/png strawberry.app
- name: Manually Codesign
if: github.repository == 'strawberrymusicplayer/strawberry' && github.event.pull_request.head.repo.fork == false && matrix.runner == 'macos-15'

31
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/build
/bin
/CMakeLists.txt.user
/.qtcreator
@@ -13,33 +14,3 @@
/dist/scripts/maketarball.sh
/debian/changelog
_codeql_detected_source_root
# Build output (keep build tooling scripts in /build_tools/ tracked)
/cmake-build*/
/build*/
!/build_tools/
!/build_tools/**
# macOS noise
.DS_Store
/bin
/CMakeLists.txt.user
/.qtcreator
/.kdev4
/strawberry.kdev4
/.vscode
/.code-workspace
/.sublime-workspace
/.idea
/.vs
/out
/CMakeSettings.json
/dist/scripts/maketarball.sh
/debian/changelog
_codeql_detected_source_root
# Build output (keep build tooling scripts in /build_tools/ tracked)
/cmake-build*/
/build*/
!/build_tools/
!/build_tools/**

View File

@@ -37,9 +37,5 @@ if(WIN32)
target_link_libraries(discord-rpc PRIVATE psapi advapi32)
endif()
if(TARGET RapidJSON::RapidJSON)
target_link_libraries(discord-rpc PRIVATE RapidJSON::RapidJSON)
elseif(RapidJSON_INCLUDE_DIRS)
target_include_directories(discord-rpc SYSTEM PRIVATE ${RapidJSON_INCLUDE_DIRS})
endif()
target_include_directories(discord-rpc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -1,63 +0,0 @@
# Strawberry Music Player (macOS) - Homebrew Bundle
#
# Usage:
# brew bundle --file Brewfile
#
# Notes:
# - This is intended for macOS (Apple Silicon or Intel).
# - Some Strawberry features are optional and will auto-disable if deps are missing.
# Build tooling
brew "cmake"
brew "pkg-config"
brew "ninja"
# Optional (developer): unit tests
brew "googletest"
# Core runtime/build dependencies (required by CMakeLists.txt)
brew "qt" # Qt 6 (Core/Gui/Widgets/Network/Sql/Concurrent)
brew "vulkan-headers" # helps Qt6Gui's WrapVulkanHeaders dependency on some setups
brew "boost"
brew "icu4c"
brew "glib" # provides glib-2.0 + gobject-2.0 (via pkg-config)
brew "glib-networking" # TLS + GIO modules (helps macOS bundling via dist/macos/macgstcopy.sh)
brew "sqlite"
brew "taglib"
brew "gstreamer"
# Strawberry requires KDAB's KDSingleApplication (CMake package name: KDSingleApplication-qt6).
# Homebrew core doesn't consistently provide it, so this repo includes a local formula.
# Homebrew requires formulae to be installed from a tap; `brew bundle` will tap *this repo*
# using the current working directory (run `brew bundle` from the repo root).
# If you previously tapped `strawberry/local` before `Formula/` existed, refresh it with:
# brew untap strawberry/local && brew tap strawberry/local "file://$PWD"
tap "strawberry/local", "file://#{Dir.pwd}"
brew "strawberry/local/kdsingleapplication-qt6"
brew "strawberry/local/qtsparkle-qt6" # optional: QtSparkle integration
brew "strawberry/local/sparkle-framework" # optional: Sparkle integration (framework)
brew "strawberry/local/macdeploycheck" # optional: enables CMake target 'deploycheck' (sanity checks deployed .app)
# Recommended GStreamer plugin sets for broad codec support (matches README guidance)
brew "gst-plugins-base"
brew "gst-plugins-good"
brew "gst-plugins-bad"
brew "gst-plugins-ugly"
brew "gst-libav"
# Optional features (silences CMake warnings / enables extra functionality)
brew "rapidjson" # enables Discord Rich Presence (DISCORD_RPC)
brew "google-sparsehash" # enables stream tagreader (STREAMTAGREADER / libsparsehash)
brew "chromaprint" # enables MusicBrainz + song fingerprinting
brew "fftw" # enables Moodbar (fftw3)
brew "libebur128" # enables EBU R 128 loudness normalization
brew "libcdio" # enables Audio CD support
brew "libmtp" # enables MTP device support
brew "strawberry/local/libgpod" # enables iPod classic support (Homebrew core doesn't provide libgpod)
# Helpful for Strawberry's macOS "deploy" target (GStreamer dynamically loads libsoup)
brew "libsoup"
# Optional: enable building the CMake "dmg" target (cmake/Dmg.cmake)
brew "create-dmg"

View File

@@ -6,14 +6,6 @@ if(APPLE)
enable_language(OBJC OBJCXX)
endif()
if(APPLE)
option(BUILD_FOR_MAC_APP_STORE "Build for Mac App Store (MAS): disables Sparkle + any localhost port-listener OAuth redirect server, and uses MAS-focused defaults." OFF)
else()
set(BUILD_FOR_MAC_APP_STORE OFF)
endif()
set(MACOS_BUNDLE_ID "com.dryark.strawberry" CACHE STRING "macOS bundle identifier (CFBundleIdentifier)")
if(POLICY CMP0054)
cmake_policy(SET CMP0054 NEW)
endif()
@@ -40,24 +32,6 @@ if(LINUX)
endif()
if(APPLE)
if(BUILD_FOR_MAC_APP_STORE)
# MAS builds: Sparkle (and QtSparkle) must be disabled.
set(ENABLE_SPARKLE OFF CACHE BOOL "Sparkle integration" FORCE)
set(ENABLE_QTSPARKLE OFF CACHE BOOL "QtSparkle integration" FORCE)
else()
# Find Sparkle early so cmake/Dmg.cmake (deploy target) can bundle it into the app.
# Sparkle is optional; if not found, update functionality is disabled.
find_library(SPARKLE Sparkle
PATHS
/Library/Frameworks
/System/Library/Frameworks
/opt/homebrew/Frameworks
/opt/homebrew/opt/sparkle-framework/Frameworks
/usr/local/Frameworks
/usr/local/opt/sparkle-framework/Frameworks
PATH_SUFFIXES Frameworks
)
endif()
include(cmake/Dmg.cmake)
endif()
@@ -110,6 +84,8 @@ if(MSVC)
list(APPEND COMPILE_OPTIONS -MP -W4 -wd4702)
else()
list(APPEND COMPILE_OPTIONS
$<$<COMPILE_LANGUAGE:C>:-std=c11>
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17>
-Wall
-Wextra
-Wpedantic
@@ -242,7 +218,7 @@ set(QT_VERSION_MAJOR 6)
set(QT_MIN_VERSION 6.4.0)
set(QT_DEFAULT_MAJOR_VERSION ${QT_VERSION_MAJOR})
set(QT_COMPONENTS Core Concurrent Gui Widgets Network Sql)
set(QT_OPTIONAL_COMPONENTS GuiPrivate LinguistTools Test)
set(QT_OPTIONAL_COMPONENTS GuiPrivate LinguistTools Test Protobuf)
if(UNIX AND NOT APPLE)
list(APPEND QT_OPTIONAL_COMPONENTS DBus)
endif()
@@ -277,6 +253,11 @@ endif()
find_package(KDSingleApplication-qt${QT_VERSION_MAJOR} 1.1.0 REQUIRED)
if(APPLE)
find_library(SPARKLE Sparkle)
#find_package(SPMediaKeyTap REQUIRED)
endif()
if(WIN32)
find_package(getopt NAMES getopt getopt-win unofficial-getopt-win32 REQUIRED)
if(TARGET getopt::getopt)
@@ -293,10 +274,11 @@ if(WIN32)
endif()
if(APPLE OR WIN32)
find_package(qtsparkle-qt${QT_VERSION_MAJOR} QUIET)
find_package(qtsparkle-qt${QT_VERSION_MAJOR})
if(TARGET "qtsparkle-qt${QT_VERSION_MAJOR}::qtsparkle")
set(QTSPARKLE_FOUND ON)
endif()
pkg_check_modules(TINYSVCMDNS IMPORTED_TARGET tinysvcmdns)
endif()
if(UNIX AND NOT APPLE)
@@ -400,6 +382,18 @@ optional_component(DISCORD_RPC ON "Discord Rich Presence"
DEPENDS "RapidJSON" RapidJSON_FOUND
)
if(WIN32)
optional_component(NETWORKREMOTE ON "Network remote"
DEPENDS "Qt Protobuf" Qt${QT_VERSION_MAJOR}Protobuf_FOUND
DEPENDS "tinysvcmdns" TINYSVCMDNS_FOUND
)
else()
optional_component(NETWORKREMOTE ON "Network remote"
DEPENDS "Qt Protobuf" Qt${QT_VERSION_MAJOR}Protobuf_FOUND
)
endif()
if(HAVE_SONGFINGERPRINTING OR HAVE_MUSICBRAINZ)
set(HAVE_CHROMAPRINT ON)
endif()
@@ -778,6 +772,7 @@ set(SOURCES
src/widgets/loginstatewidget.cpp
src/widgets/ratingwidget.cpp
src/widgets/resizabletextedit.cpp
src/widgets/filechooserwidget.cpp
src/osd/osdbase.cpp
src/osd/osdpretty.cpp
@@ -842,8 +837,6 @@ set(SOURCES
src/fileview/fileview.cpp
src/fileview/fileviewlist.cpp
src/fileview/fileviewtree.cpp
src/fileview/fileviewtreemodel.cpp
src/device/devicemanager.cpp
src/device/devicelister.cpp
@@ -1078,6 +1071,7 @@ set(HEADERS
src/widgets/ratingwidget.h
src/widgets/forcescrollperpixel.h
src/widgets/resizabletextedit.h
src/widgets/filechooserwidget.h
src/osd/osdbase.h
src/osd/osdpretty.h
@@ -1133,8 +1127,6 @@ set(HEADERS
src/fileview/fileview.h
src/fileview/fileviewlist.h
src/fileview/fileviewtree.h
src/fileview/fileviewtreemodel.h
src/device/devicemanager.h
src/device/devicelister.h
@@ -1237,10 +1229,6 @@ set(UI
src/device/deviceviewcontainer.ui
)
if(UNIX)
optional_source(UNIX SOURCES src/core/unixsignalwatcher.cpp HEADERS src/core/unixsignalwatcher.h)
endif()
if(APPLE)
optional_source(APPLE
SOURCES
@@ -1469,7 +1457,6 @@ optional_source(HAVE_SPOTIFY
src/spotify/spotifybaserequest.cpp
src/spotify/spotifyrequest.cpp
src/spotify/spotifyfavoriterequest.cpp
src/spotify/spotifymetadatarequest.cpp
src/settings/spotifysettingspage.cpp
src/covermanager/spotifycoverprovider.cpp
HEADERS
@@ -1477,7 +1464,6 @@ optional_source(HAVE_SPOTIFY
src/spotify/spotifybaserequest.h
src/spotify/spotifyrequest.h
src/spotify/spotifyfavoriterequest.h
src/spotify/spotifymetadatarequest.h
src/settings/spotifysettingspage.h
src/covermanager/spotifycoverprovider.h
UI
@@ -1492,7 +1478,6 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.cpp
src/qobuz/qobuzstreamurlrequest.cpp
src/qobuz/qobuzfavoriterequest.cpp
src/qobuz/qobuzmetadatarequest.cpp
src/qobuz/qobuzcredentialfetcher.cpp
src/settings/qobuzsettingspage.cpp
src/covermanager/qobuzcoverprovider.cpp
@@ -1503,7 +1488,6 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.h
src/qobuz/qobuzstreamurlrequest.h
src/qobuz/qobuzfavoriterequest.h
src/qobuz/qobuzmetadatarequest.h
src/qobuz/qobuzcredentialfetcher.h
src/settings/qobuzsettingspage.h
src/covermanager/qobuzcoverprovider.h
@@ -1511,6 +1495,57 @@ optional_source(HAVE_QOBUZ
src/settings/qobuzsettingspage.ui
)
if(HAVE_NETWORKREMOTE)
optional_source(HAVE_NETWORKREMOTE
SOURCES
src/core/zeroconf.cpp
src/networkremote/incomingdataparser.cpp
src/networkremote/networkremote.cpp
src/networkremote/outgoingdatacreator.cpp
src/networkremote/networkremoteclient.cpp
src/networkremote/songsender.cpp
src/settings/networkremotesettingspage.cpp
HEADERS
src/networkremote/networkremote.h
src/networkremote/incomingdataparser.h
src/networkremote/outgoingdatacreator.h
src/networkremote/networkremoteclient.h
src/networkremote/songsender.h
src/settings/networkremotesettingspage.h
UI
src/settings/networkremotesettingspage.ui
)
if(UNIX AND NOT APPLE)
get_target_property(QT_DBUSXML2CPP_EXECUTABLE Qt${QT_VERSION_MAJOR}::qdbusxml2cpp LOCATION)
add_custom_command(
OUTPUT
${CMAKE_CURRENT_BINARY_DIR}/avahi/avahiserver.cpp
${CMAKE_CURRENT_BINARY_DIR}/avahi/avahiserver.h
COMMAND ${QT_DBUSXML2CPP_EXECUTABLE}
${CMAKE_SOURCE_DIR}/src/avahi/org.freedesktop.Avahi.Server.xml
-p ${CMAKE_CURRENT_BINARY_DIR}/avahi/avahiserver
-i includes/dbus_metatypes.h
DEPENDS src/avahi/org.freedesktop.Avahi.Server.xml
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
add_custom_command(
OUTPUT
${CMAKE_CURRENT_BINARY_DIR}/avahi/avahientrygroup.cpp
${CMAKE_CURRENT_BINARY_DIR}/avahi/avahientrygroup.h
COMMAND ${QT_DBUSXML2CPP_EXECUTABLE}
${CMAKE_SOURCE_DIR}/src/avahi/org.freedesktop.Avahi.EntryGroup.xml
-p ${CMAKE_CURRENT_BINARY_DIR}/avahi/avahientrygroup
-i includes/dbus_metatypes.h
DEPENDS src/avahi/org.freedesktop.Avahi.EntryGroup.xml
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
list(APPEND SOURCES src/avahi/avahi.cpp ${CMAKE_CURRENT_BINARY_DIR}/avahi/avahientrygroup.cpp ${CMAKE_CURRENT_BINARY_DIR}/avahi/avahiserver.cpp)
list(APPEND HEADERS src/avahi/avahi.h ${CMAKE_CURRENT_BINARY_DIR}/avahi/avahientrygroup.h ${CMAKE_CURRENT_BINARY_DIR}/avahi/avahiserver.h)
endif()
optional_source(APPLE SOURCES src/core/bonjour.mm HEADERS src/core/bonjour.h)
optional_source(WIN32 SOURCES src/core/tinysvcmdns.cpp HEADERS src/core/tinysvcmdns.h)
endif()
qt_wrap_cpp(SOURCES ${HEADERS})
qt_wrap_ui(SOURCES ${UI})
qt_add_resources(SOURCES data/data.qrc data/icons.qrc)
@@ -1540,27 +1575,21 @@ if(HAVE_DISCORD_RPC)
endif()
if(HAVE_TRANSLATIONS)
option(TRANSLATIONS_VERBOSE "Show verbose output while generating .qm translation files" OFF)
# On non-Windows platforms Qt doesn't need a PATH-setup wrapper for tools, but we can
# provide a wrapper to filter non-actionable lrelease noise during normal builds.
if(NOT CMAKE_HOST_WIN32)
set(QT_TOOL_COMMAND_WRAPPER_PATH "${CMAKE_SOURCE_DIR}/cmake/qt_tool_wrapper.sh"
CACHE INTERNAL "Wrapper used when invoking Qt tools from CMake" FORCE
)
endif()
qt_add_lupdate(strawberry_lib TS_FILES "${CMAKE_SOURCE_DIR}/src/translations/strawberry_en_US.ts" OPTIONS -locations none -no-ui-lines -no-obsolete)
file(GLOB_RECURSE ts_files ${CMAKE_SOURCE_DIR}/src/translations/*.ts)
set_source_files_properties(${ts_files} PROPERTIES OUTPUT_LOCATION "${CMAKE_CURRENT_BINARY_DIR}/data")
if(TRANSLATIONS_VERBOSE)
qt_add_lrelease(strawberry TS_FILES ${ts_files} QM_FILES_OUTPUT_VARIABLE INSTALL_TRANSLATIONS_FILES)
else()
qt_add_lrelease(strawberry TS_FILES ${ts_files} QM_FILES_OUTPUT_VARIABLE INSTALL_TRANSLATIONS_FILES OPTIONS -silent)
endif()
if(NOT INSTALL_TRANSLATIONS)
qt_add_resources(strawberry "translations" PREFIX "/i18n" BASE "${CMAKE_CURRENT_BINARY_DIR}/data" FILES "${INSTALL_TRANSLATIONS_FILES}")
endif()
endif()
if(HAVE_NETWORKREMOTE)
qt_add_protobuf(NetworkRemoteMessages
PROTO_FILES src/networkremote/networkremotemessages.proto
)
endif()
target_include_directories(strawberry_lib PUBLIC
${CMAKE_SOURCE_DIR}
${CMAKE_BINARY_DIR}
@@ -1593,6 +1622,7 @@ target_link_libraries(strawberry_lib PUBLIC
Qt${QT_VERSION_MAJOR}::Sql
$<$<BOOL:${HAVE_DBUS}>:Qt${QT_VERSION_MAJOR}::DBus>
$<$<BOOL:${HAVE_QPA_QPLATFORMNATIVEINTERFACE}>:Qt${QT_VERSION_MAJOR}::GuiPrivate>
$<$<BOOL:${HAVE_NETWORKREMOTE}>:Qt${QT_VERSION_MAJOR}::Protobuf>
ICU::uc
ICU::i18n
$<$<BOOL:${HAVE_STREAMTAGREADER}>:PkgConfig::LIBSPARSEHASH>
@@ -1612,6 +1642,7 @@ target_link_libraries(strawberry_lib PUBLIC
$<$<BOOL:${MSVC}>:WindowsApp>
KDAB::kdsingleapplication
$<$<BOOL:${HAVE_DISCORD_RPC}>:discord-rpc>
$<$<BOOL:${HAVE_NETWORKREMOTE}>:NetworkRemoteMessages>
)
if(APPLE)
@@ -1630,6 +1661,10 @@ if(APPLE)
endif()
endif()
if(WIN32 AND HAVE_NETWORKREMOTE)
target_link_libraries(strawberry_lib PUBLIC PkgConfig::TINYSVCMDNS)
endif()
target_link_libraries(strawberry PUBLIC strawberry_lib)
if(NOT APPLE)

View File

@@ -2,40 +2,6 @@ Strawberry Music Player
=======================
ChangeLog
Version 1.2.17 (2026.01.18):
* Avoid re-scan of restored songs unless mtime is changed (#1819)
* Skip existing files when organizing if not overwriting (#1484)
* Fixed cursor highlight disappearing off-screen when using down cursor (#1489)
* Fixed CD playback only working for the first optical drive (#1852)
* Fixed possible race-condition when switching tracks (#1863)
* Fixed possible file descriptor exhaustion by using shared thread pool (#1687)
* Don't automatically sort playlist with the auto sort option before it's fully loaded (#1690)
* Fixed network features stop working after computer suspends and resumes (#1521)
* Fixed crash on exit after Qobuz login
* Added tag editor option to select ID3v2 version (#1861)
* Fixed Qobuz authentication and added automatic credential fetching (#1898)
* Fixed playback stopping after deleting a song from disk via context menu (#1783)
* Added option to restore smart playlists to the defaults (#1848)
* Fixed possible race condition in pipeline destructor (#1875)
* Fixed buffering issue near track end during gapless playback (#1725)
* Fixed duplicate collection entries for the same artist if they have different sort tags (#1899)
* Defer playcount and rating tag writes for currently playing Ogg songs to prevent playback shutter (#1816)
* Fixed tag editing not working for Opus sort tags (#1929)
* Show playlist load errors (#1470)
* Fallback to delete if moving to trash fails (#1679)
* Prefer filenames with "front" or "cover" in the filename for album cover art for songs outside of the collection (#1745)
* Fixed collection enter/return behavior to respect double-click settings (#1691)
* Added tree view mode to files tab (#1922)
* Include .webp in allowed extensions for album covers (#1941)
* Exit gracefully on SIGTERM signal for Unix systems (#1942)
* Optimize the collection scanning process by deferring media file validation from the initial directory scan (#1954)
* Fixed collection scan not finding new directories in the top level collection directory when the mountpoint is restored (#1914)
* Added genre metadata parsing for Tidal, Qobuz and Spotify (#1913)
* Allow editing metadata for stream songs (#1913)
* Optimized collection/playlist filtering
* Added sort tags to collection/playlist filtering (#1966)
Version 1.2.16 (2025.12.16):
* Make Discord Rich presence use filename if song title is missing
@@ -343,7 +309,7 @@ Version 1.1.0 (2024.07.14):
* Only use playbin3 with GStreamer 1.24 and higher, not with GStreamer 1.22 or lower.
* (macOS/Windows) Fixed dash and hls streaming, plugins were missing.
* (Windows) Fixed incorrect colors in smart playlist wizard with Fusion in dark mode (#1399).
* (Windows) Fixed update window blocking startup window on launch.
* (Windows) Fixed update window blocking sponsor window on startup.
Enhancements:
* Improve error messages when connecting and copying to devices.

View File

@@ -1,51 +0,0 @@
class KdsingleapplicationQt6 < Formula
desc "Helper class for single-instance Qt applications (Qt 6 build)"
homepage "https://github.com/KDAB/KDSingleApplication"
url "https://github.com/KDAB/KDSingleApplication/archive/refs/tags/v1.1.0.tar.gz"
sha256 "1f19124c0aa5c6fffee3da174f7d2e091fab6dca1e123da70bb0fe615bfbe3e8"
license "MIT"
depends_on "cmake" => :build
depends_on "ninja" => :build
depends_on "qt"
def install
args = std_cmake_args + %W[
-GNinja
-DKDSingleApplication_QT6=ON
-DKDSingleApplication_TESTS=OFF
-DKDSingleApplication_EXAMPLES=OFF
-DKDSingleApplication_DOCS=OFF
-DKDSingleApplication_DEVELOPER_MODE=OFF
-DKDSingleApplication_STATIC=OFF
]
system "cmake", "-S", ".", "-B", "build", *args
system "cmake", "--build", "build"
system "cmake", "--install", "build"
end
test do
# Verify CMake package is usable via find_package(KDSingleApplication-qt6 CONFIG REQUIRED)
(testpath/"CMakeLists.txt").write <<~CMAKE
cmake_minimum_required(VERSION 3.16)
project(kdsa_test LANGUAGES CXX)
find_package(KDSingleApplication-qt6 CONFIG REQUIRED)
add_executable(test_kdsa main.cpp)
target_link_libraries(test_kdsa PRIVATE KDAB::kdsingleapplication)
CMAKE
(testpath/"main.cpp").write <<~CPP
#include <QCoreApplication>
int main(int argc, char** argv) {
QCoreApplication app(argc, argv);
return 0;
}
CPP
system "cmake", "-S", ".", "-B", "build",
"-DCMAKE_PREFIX_PATH=#{opt_prefix}"
system "cmake", "--build", "build"
end
end

View File

@@ -1,25 +0,0 @@
# kdsingleapplication-qt6 (local Homebrew formula)
This directory exists to keep any supporting files for the local Homebrew formula
next to it (e.g. patches or notes).
## Install (from this Strawberry repo)
From the repo root:
```bash
brew tap strawberry/local "file://$PWD"
brew install strawberry/local/kdsingleapplication-qt6
```
## Why it exists
Strawberrys build requires the CMake package `KDSingleApplication-qt6`, but it is
not consistently available via Homebrew core. Shipping a local formula makes the
dependency easy to install for anyone building this repo on macOS.
## Note for local development
Homebrew taps are Git clones, so the formula must be committed (or pushed to a remote)
to be visible to `brew tap`.

View File

@@ -1,81 +0,0 @@
class Libgpod < Formula
desc "Library to access the contents of classic iPods"
homepage "https://gtkpod.org/libgpod/"
url "https://github.com/neuschaefer/libgpod/archive/0dda196286f5e42be89f0b870abd9278213989a5.tar.gz"
sha256 "a9809f85b2b763196ac7c94903211a927efd37a24ef39c355c21b4a1bed28e52"
license "LGPL-2.0-only"
depends_on "autoconf" => :build
depends_on "automake" => :build
depends_on "libtool" => :build
depends_on "pkg-config" => :build
depends_on "gtk-doc" => :build
depends_on "intltool" => :build
depends_on "glib"
depends_on "gdk-pixbuf"
depends_on "libplist"
depends_on "libxml2"
depends_on "sqlite"
def install
# libgpod's configure.ac checks for pkg-config module name "libplist".
# Homebrew provides "libplist-2.0", so we provide a tiny shim .pc file to
# satisfy the expected name.
(buildpath/"brew-pkgconfig").mkpath
(buildpath/"brew-pkgconfig/libplist.pc").write <<~EOS
prefix=#{Formula["libplist"].opt_prefix}
exec_prefix=${prefix}
libdir=#{Formula["libplist"].opt_lib}
includedir=#{Formula["libplist"].opt_include}
Name: libplist
Description: Apple property list library (Homebrew shim for libgpod)
Version: #{Formula["libplist"].version}
Libs: -L${libdir} -lplist-2.0
Cflags: -I${includedir}
EOS
ENV.prepend_path "PKG_CONFIG_PATH", buildpath/"brew-pkgconfig"
# Ensure pkg-config can find Homebrew keg .pc files during configure.
ENV.prepend_path "PKG_CONFIG_PATH", Formula["libplist"].opt_lib/"pkgconfig"
ENV.prepend_path "PKG_CONFIG_PATH", Formula["sqlite"].opt_lib/"pkgconfig"
ENV.prepend_path "PKG_CONFIG_PATH", Formula["glib"].opt_lib/"pkgconfig"
ENV.prepend_path "PKG_CONFIG_PATH", Formula["glib"].opt_share/"pkgconfig"
ENV.prepend_path "PKG_CONFIG_PATH", Formula["gdk-pixbuf"].opt_lib/"pkgconfig"
# Upstream's autogen.sh is very old and may hardcode ancient automake checks
# (e.g. looking for automake-1.7). Using autoreconf is the standard Homebrew
# way and works with modern autotools.
#
# libgpod's build system expects gtk-doc's makefile snippet to exist (gtk-doc.make),
# which is normally provided by running gtkdocize.
system "gtkdocize", "--copy"
# libgpod also uses intltool's Autoconf macros (IT_PROG_INTLTOOL). If intltoolize
# is not run, the generated ./configure may contain unexpanded macros and fail.
system "intltoolize", "--force", "--copy", "--automake"
system "autoreconf", "-fiv"
system "./configure", *std_configure_args,
"--disable-dependency-tracking",
"--with-hal=no",
"--disable-udev",
"--without-libimobiledevice",
"--with-python=no",
"--with-mono=no",
"--disable-gtk-doc",
"--disable-gtk-doc-html",
"--disable-gtk-doc-pdf",
"--enable-more-warnings=no"
system "make", "install"
end
test do
# Ensure pkg-config can find the expected module name used by Strawberry.
assert_match "libgpod", shell_output("pkg-config --libs libgpod-1.0")
end
end

View File

@@ -1,8 +0,0 @@
# libgpod (local Homebrew formula)
Homebrew core does not currently ship `libgpod`, but Strawberry can optionally use it
to support **classic iPod** devices (via `libgpod-1.0` + `gdk-pixbuf-2.0`).
This formula is pinned to a known-good upstream snapshot and disables Linux-specific
integration (udev/HAL) and language bindings to keep the build reliable on macOS.

View File

@@ -1,93 +0,0 @@
class Macdeploycheck < Formula
desc "Sanity checks a macOS .app bundle for accidental Homebrew runtime dependencies"
homepage "https://github.com/strawberrymusicplayer/strawberry"
url "file://#{__FILE__}"
version "0.1.0"
sha256 :no_check
license "MIT"
depends_on :macos
def install
(bin/"macdeploycheck").write <<~'EOS'
#!/usr/bin/env bash
set -euo pipefail
app="${1:-}"
if [[ -z "$app" ]]; then
echo "Usage: macdeploycheck <path/to/App.app>" >&2
exit 2
fi
if [[ ! -d "$app" ]]; then
echo "Error: app bundle not found: $app" >&2
exit 2
fi
if [[ ! -d "$app/Contents" ]]; then
echo "Error: not a macOS app bundle (missing Contents/): $app" >&2
exit 2
fi
fail=0
tmp="$(mktemp -t macdeploycheck.XXXXXX)"
trap 'rm -f "$tmp"' EXIT
# Collect Mach-O files (executables + dylibs) inside the bundle.
while IFS= read -r -d '' f; do
if file "$f" | grep -q "Mach-O"; then
echo "$f" >>"$tmp"
fi
done < <(find "$app/Contents" -type f \( -perm -111 -o -name "*.dylib" -o -name "*.so" \) -print0 2>/dev/null)
if [[ ! -s "$tmp" ]]; then
echo "Warning: no Mach-O files found under $app/Contents" >&2
exit 0
fi
echo "macdeploycheck: scanning for external (Homebrew) runtime deps..."
while IFS= read -r f; do
# otool -L prints:
# <file>:
# <dep> (compatibility version ..., current version ...)
deps="$(otool -L "$f" 2>/dev/null | tail -n +2 | awk '{print $1}' || true)"
while IFS= read -r dep; do
[[ -z "$dep" ]] && continue
# Ignore system and rpath/loader/executable paths.
case "$dep" in
/System/*|/usr/lib/*|@rpath/*|@loader_path/*|@executable_path/*) continue ;;
esac
# These are the common accidental runtime deps that will break distribution.
if [[ "$dep" == /opt/homebrew/* || "$dep" == /usr/local/* || "$dep" == /opt/local/* ]]; then
echo "ERROR: $f links to external path: $dep" >&2
fail=1
fi
done <<<"$deps"
done <"$tmp"
if [[ "$fail" -ne 0 ]]; then
cat >&2 <<'EOM'
One or more binaries in your .app link to a Homebrew (or MacPorts) path.
That usually means the bundle is not self-contained and will fail on other machines,
or will fail notarization/codesigning validation.
Fix: re-run your deploy step (e.g. macdeployqt) so frameworks/dylibs are bundled and
their install names are rewritten to @rpath/@loader_path.
EOM
exit 1
fi
echo "OK: no external Homebrew/MacPorts runtime deps detected."
exit 0
EOS
chmod 0755, bin/"macdeploycheck"
end
test do
# Basic smoke test: tool runs and prints usage.
system bin/"macdeploycheck"
end
end

View File

@@ -1,36 +0,0 @@
# `macdeploycheck` (local Homebrew formula)
This repository includes a small helper tool called `macdeploycheck`, packaged as a local Homebrew formula.
## What it does
`macdeploycheck` scans a built `.app` bundle and flags common **accidental runtime dependencies** on:
- Homebrew paths like `/opt/homebrew/...` or `/usr/local/...`
- MacPorts paths like `/opt/local/...`
These dependencies usually mean the `.app` is **not self-contained** and may fail to run on other machines or fail notarization validation.
## Install (via this repo's tap)
From the Strawberry repo root:
```bash
brew tap strawberry/local "file://$PWD"
brew install strawberry/local/macdeploycheck
```
Or use the repo `Brewfile`:
```bash
brew bundle --file Brewfile
```
## Use
```bash
macdeploycheck /path/to/Strawberry.app
```
It exits non-zero if it finds external runtime deps.

View File

@@ -1,45 +0,0 @@
class QtsparkleQt6 < Formula
desc "Qt wrapper library for in-app updates (Qt 6 build)"
homepage "https://github.com/strawberrymusicplayer/qtsparkle"
url "https://github.com/strawberrymusicplayer/qtsparkle/archive/95ca3b77a79540d632b29e9a4df9aed30af5f901.tar.gz"
sha256 "945c9e96d2f6175b134a8ccfd6ec1acd268266d31969b5870d4037e8e5877834"
license "GPL-3.0-or-later"
depends_on "cmake" => :build
depends_on "ninja" => :build
depends_on "qt"
def install
args = std_cmake_args + %W[
-GNinja
-DBUILD_WITH_QT6=ON
-DBUILD_WITH_QT5=OFF
-DBUILD_SHARED_LIBS=ON
-DBUILD_STATIC_LIBS=OFF
]
system "cmake", "-S", ".", "-B", "build", *args
system "cmake", "--build", "build"
system "cmake", "--install", "build"
end
test do
# Strawberry expects: find_package(qtsparkle-qt6) and target qtsparkle-qt6::qtsparkle
(testpath/"CMakeLists.txt").write <<~CMAKE
cmake_minimum_required(VERSION 3.16)
project(qtsparkle_test LANGUAGES CXX)
find_package(qtsparkle-qt6 CONFIG REQUIRED)
add_library(dummy STATIC dummy.cpp)
target_link_libraries(dummy PRIVATE qtsparkle-qt6::qtsparkle)
CMAKE
(testpath/"dummy.cpp").write <<~CPP
int dummy() { return 0; }
CPP
system "cmake", "-S", ".", "-B", "build",
"-DCMAKE_PREFIX_PATH=#{opt_prefix}"
system "cmake", "--build", "build"
end
end

View File

@@ -1,10 +0,0 @@
# qtsparkle-qt6 (local Homebrew formula)
This installs Strawberrys Qt updater helper library as a CMake package:
- `find_package(qtsparkle-qt6 CONFIG REQUIRED)`
- target: `qtsparkle-qt6::qtsparkle`
Strawberry will pick it up automatically when present and enable the optional
**QtSparkle integration**.

View File

@@ -1,19 +0,0 @@
class SparkleFramework < Formula
desc "Sparkle.framework for macOS app updates (framework-only packaging)"
homepage "https://sparkle-project.org/"
url "https://github.com/sparkle-project/Sparkle/releases/download/2.8.1/Sparkle-2.8.1.tar.xz"
sha256 "5cddb7695674ef7704268f38eccaee80e3accbf19e61c1689efff5b6116d85be"
license "MIT"
depends_on :macos
def install
frameworks = prefix/"Frameworks"
frameworks.install "Sparkle.framework"
end
test do
assert_predicate prefix/"Frameworks/Sparkle.framework", :exist?
end
end

View File

@@ -1,9 +0,0 @@
# sparkle-framework (local Homebrew formula)
Installs the upstream `Sparkle.framework` into:
- `$(brew --prefix sparkle-framework)/Frameworks/Sparkle.framework`
This is used to enable Strawberrys optional **Sparkle integration** on macOS
(`find_library(SPARKLE Sparkle)` in the main `CMakeLists.txt`).

View File

@@ -1,55 +1,60 @@
# Strawberry (macOS-focused fork)
# :strawberry: Strawberry Music Player [![Build Status](https://github.com/strawberrymusicplayer/strawberry/workflows/Build/badge.svg)](https://github.com/strawberrymusicplayer/strawberry/actions)
[![Sponsor](https://img.shields.io/badge/-Sponsor-green?logo=github)](https://github.com/sponsors/jonaski)
[![Patreon](https://img.shields.io/badge/patreon-donate-green.svg)](https://patreon.com/jonaskvinge)
[![PayPal](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/jonaskvinge)
This repository is a **macOS-focused fork** of upstream Strawberry.
Strawberry is a **music player and music collection organizer**, originally forked from *Clementine* in 2018.
Its written in **C++ using the Qt framework**, designed for **audiophiles and music collectors**.
The goal of this fork is to make Strawberry **build cleanly and repeatably on macOS**, with:
![Screenshot of Strawberry Music Player](https://raw.githubusercontent.com/strawberrymusicplayer/strawberry/master/data/screenshot/screenshot.png)
- Homebrew dependency installation via `Brewfile`
- local Homebrew formulas (tap) for missing dependencies
- build / deploy / signing / notarization helper scripts under `build_tools/`
- Sparkle feed configuration knobs so you can publish your own updates
---
## Upstream vs this fork (macOS distribution)
## :globe_with_meridians: Resources
Upstream Strawberry is where ongoing development happens:
- **Website:** https://www.strawberrymusicplayer.org
- **Wiki:** https://wiki.strawberrymusicplayer.org
- **Forum:** https://forum.strawberrymusicplayer.org
- **GitHub:** https://github.com/strawberrymusicplayer/strawberry
- **Latest builds:** https://builds.strawberrymusicplayer.org
- **openSUSE Build Service:**
- Stable: https://build.opensuse.org/package/show/home:jonaski:strawberry/strawberry
- Unstable: https://build.opensuse.org/package/show/home:jonaski:strawberry-dev/strawberry
- **Ubuntu PPAs:**
- Stable: https://launchpad.net/~jonaski/+archive/ubuntu/strawberry
- Unstable: https://launchpad.net/~jonaski/+archive/ubuntu/strawberry-unstable
- **Translations:** https://crowdin.com/project/strawberrymusicplayer
- Upstream: `https://github.com/strawberrymusicplayer/strawberry`
---
This forks source (the code you are building here):
## :warning: Opening an Issue
- Fork: `https://gitea.dryark.com/dryark/strawberry`
Before creating a new GitHub issue:
This fork is intended for people who want to:
1. **Read the [FAQ](https://wiki.strawberrymusicplayer.org/wiki/FAQ)**.
2. **Search existing issues** to avoid duplicates. If one already exists, comment there with any additional information.
3. **Use the [forum](https://forum.strawberrymusicplayer.org/)** for technical problems, discussions or feature suggestions — its better suited for back-and-forth conversation.
4. **Feature requests are not accepted on GitHub.** Issues created for feature requests will be closed. You can still discuss ideas on the forum.
5. **Flatpak users:** We do **not** maintain the Flatpak package. Report Flatpak-specific issues via [Flatpak support](https://flatpak.org/about/).
- **build from source on macOS** without guesswork
- **produce signed + notarized binaries** themselves (and optionally distribute them)
---
General safety note: whether you use upstream builds, your own builds, or someone elses, only install software from sources you trust and prefer **signed + notarized** releases.
## :moneybag: Sponsoring
## Quick start (macOS)
Strawberry is **free software released under the GPL**.
If you enjoy using it, please consider **supporting development** through sponsorship or donation.
Install Homebrew dependencies:
**Sponsorship options:**
1. [Patreon](https://www.patreon.com/jonaskvinge)
2. [GitHub](https://github.com/sponsors/jonaski)
3. [Ko-fi](https://ko-fi.com/jonaskvinge)
4. [PayPal](https://paypal.me/jonaskvinge)
```bash
./build_tools/macos/install_brew_deps.sh
```
Supporting open-source developers helps ensure continued maintenance and improvements.
Build:
---
```bash
./build_tools/macos/build_app.sh --release --clean
open ./cmake-build-macos-release/strawberry.app
```
Build + deploy + sign + notarize (+ DMG):
```bash
./build_tools/macos/build_sign_notarize.sh --run --release --clean --deploy --dmg \
--identity "Developer ID Application: Your Name (TEAMID)" \
--notary-profile "<profile-name>"
```
## Features
## :white_check_mark: Features
- Play and organize your music collection
- Supports formats: WAV, FLAC, WavPack, Ogg Vorbis, Opus, MPC, TrueAudio, AIFF, MP4, MP3, ASF, and Monkeys Audio
@@ -73,6 +78,11 @@ Build + deploy + sign + notarize (+ DMG):
:white_check_mark: Tested on **Linux**, **OpenBSD**, **FreeBSD**, **macOS**, and **Windows**.
> **Note:** macOS and Windows releases are currently **available to sponsors only**.
> A monthly sponsorship via [Patreon](https://www.patreon.com/jonaskvinge) grants direct access to new releases.
---
## :gear: Requirements
To build Strawberry from source, youll need:
@@ -107,9 +117,9 @@ Also install GStreamer plugins **base**, **good**, and optionally **bad**, **ugl
## :wrench: Build from Source
**Get the code (this fork):**
**Get the code:**
git clone --recursive https://gitea.dryark.com/dryark/strawberry
git clone --recursive https://github.com/strawberrymusicplayer/strawberry
**Build and install:**

View File

@@ -1,124 +0,0 @@
# Build helper scripts
This `build_tools/` directory contains **helper scripts and notes** for building Strawberry.
- It is **not** intended to be your CMake build output directory.
- Recommended CMake build output directories: `cmake-build/`, `build-release/`, etc.
## macOS
- Install dependencies via Homebrew:
```bash
./build_tools/macos/install_brew_deps.sh
```
- Build Strawberry:
```bash
./build_tools/macos/build_app.sh --release
open ./cmake-build-macos-release/strawberry.app
```
## macOS signing + notarization (Developer ID distribution)
This repo includes `build_tools/macos/build_sign_notarize.sh` to automate:
- build → (optional deploy) → codesign → notarize → staple → verify
### One-time setup (Apple Developer)
- **Install certificates**:
- In the Apple Developer portal, create (or download) a **Developer ID Application** certificate.
- Install it into your login keychain (Xcode can manage this via **Xcode → Settings → Accounts**).
- **Provisioning profiles**:
- For **Developer ID distribution (outside the Mac App Store)**, you typically **do not need a provisioning profile**.
- You *do* need profiles if you are building a **Mac App Store**-signed app (not what this repos scripts target).
- **Notarization credentials**:
- Create a `notarytool` keychain profile (recommended) so you dont have to pass secrets on the command line:
```bash
# NOTE: <profile-name> is a positional argument (not a flag).
# Pick any name you want, e.g. "strawberry-notary".
xcrun notarytool store-credentials "<profile-name>" \
--apple-id "<your-apple-id>" \
--team-id "<TEAMID>" \
--password "<app-specific-password>"
```
### Listing whats installed locally
Run with no args to list local signing identities + notarytool profiles:
```bash
./build_tools/macos/build_sign_notarize.sh
```
### Build + sign + notarize
```bash
./build_tools/macos/build_sign_notarize.sh --run --release --clean --deploy \
--identity "Developer ID Application: Your Name (TEAMID)" \
--notary-profile "<profile-name>"
```
### Build + sign + notarize + DMG (recommended for public distribution)
This produces:
- a notarized `strawberry.app` (stapled)
- a notarized `strawberry-notarize.zip` (useful for Sparkle / downloads)
- a notarized `strawberry-*.dmg` (stapled)
```bash
./build_tools/macos/build_sign_notarize.sh --run --release --clean --deploy --dmg \
--identity "Developer ID Application: Your Name (TEAMID)" \
--notary-profile "<profile-name>"
```
## macOS Mac App Store (MAS) build + signed PKG
This repo includes `build_tools/macos/build_mas_pkg.sh` to automate:
- build (MAS mode) → deploy (bundle deps) → embed provisioning profile → codesign → `productbuild` a signed `.pkg`
### Requirements (Apple Developer)
- An App Store Connect app record with bundle id **`com.dryark.strawberry`** (or your own).
- A **Mac App Store provisioning profile** for that App ID.
- Signing identities installed in your Keychain:
- **Apple Distribution** (for the `.app`)
- **3rd Party Mac Developer Installer** (for the `.pkg`)
Tip: list what you have installed:
```bash
security find-identity -p codesigning -v
security find-identity -p basic -v
ls -la "$HOME/Library/MobileDevice/Provisioning Profiles" | head -n 50
```
### Manual setup guide (certificates, Keychain Access, profiles)
See: `build_tools/macos/README_MAS.md`
### Build the signed upload PKG
```bash
./build_tools/macos/build_mas_pkg.sh --run --release --clean \
--codesign-identity "Apple Distribution: Your Name (TEAMID)" \
--installer-identity "3rd Party Mac Developer Installer: Your Name (TEAMID)" \
--provisionprofile "$HOME/Library/MobileDevice/Provisioning Profiles/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.provisionprofile"
```
Output:
- `cmake-build-macos-release-mas/strawberry.app`
- `cmake-build-macos-release-mas/strawberry-mas.pkg`
### Upload + submit for review
- Upload the `.pkg` using Apples **Transporter** app (App Store Connect), or with `iTMSTransporter`.
- In App Store Connect, wait for processing, select the build, then **Submit for Review**.

View File

@@ -1,138 +0,0 @@
# Mac App Store (MAS) submission guide (manual steps)
This repo supports a **Mac App Store build mode** (`BUILD_FOR_MAC_APP_STORE=ON`) and includes scripts to build a signed upload `.pkg`.
If youre blocked because `security find-identity` only shows **Developer ID** and not **Apple Distribution / Installer**, follow the steps below.
---
## Open Keychain Access (macOS “hidden” Utilities)
Any of these work:
- **Spotlight**: press `⌘ + Space` → type **Keychain Access** → Enter
- **Finder**: Applications → Utilities → **Keychain Access**
- **Terminal**:
```bash
open -a "Keychain Access"
```
---
## The core issue: certificate exists but is not a usable identity
If you see certificates like:
- `Apple Distribution: ...`
- `3rd Party Mac Developer Installer: ...`
but `security find-identity` does **not** list them, then the certificate is present but **the private key is missing** (or not paired / in the wrong keychain).
You can confirm with:
```bash
./build_tools/macos/check_signing_identities.sh
```
---
## Step 1 — Create the private keys on this Mac (CSR)
1. Open **Keychain Access**
2. Menu: **Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority…**
3. Fill:
- **User Email Address**: your Apple ID email
- **Common Name**: e.g. `Dry Ark LLC` (any label is fine)
- **CA Email Address**: leave blank
- Select: **Saved to disk**
4. Save the CSR (`.certSigningRequest`) somewhere safe
This CSR step is what creates the **private key** locally in your login keychain.
---
## Step 2 — Create + download the certificates (Apple Developer portal)
In Apple Developer → **Certificates, Identifiers & Profiles****Certificates****+**:
- Create **Apple Distribution** (use the CSR you just made)
- Create **Mac Installer Distribution** (or “3rd Party Mac Developer Installer”, wording varies) (use a CSR)
Download the resulting `.cer` files.
---
## Step 3 — Install certificates into your login keychain
Double-click each downloaded `.cer` to install it.
Then in **Keychain Access → login → My Certificates**:
- Find **Apple Distribution: ...** and **expand it**
- You must see a **private key** under it.
- Find **... Installer ...** and expand it
- You must see a **private key** under it.
If theres no private key under the certificate, it will not be usable for signing on this Mac.
---
## Step 4 — Verify identities from the CLI
```bash
security find-identity -p codesigning -v
security find-identity -p basic -v
./build_tools/macos/check_signing_identities.sh
```
Expected:
- `Apple Distribution: ...` shows up under **codesigning**
- `... Installer ...` shows up as an **installer identity** (used to sign upload `.pkg`)
---
## Step 5 — Create + install the provisioning profile (Mac App Store)
In Apple Developer → **Profiles****+**:
- Platform: **macOS**
- Type: **Mac App Store**
- App ID: `com.dryark.strawberry` (or your own bundle id)
- Select the **Apple Distribution** certificate
- Generate + Download
Install it by double-clicking it, or place it under:
`~/Library/MobileDevice/Provisioning Profiles/`
---
## Step 6 — Build the signed upload package (.pkg)
This repo provides:
- `build_tools/macos/build_mas_pkg.sh` (build → deploy → embed profile → sign → productbuild)
Example:
```bash
./build_tools/macos/build_mas_pkg.sh --run --release --clean \
--codesign-identity "Apple Distribution: Dry Ark LLC (7628766FL2)" \
--installer-identity "3rd Party Mac Developer Installer: Dry Ark LLC (7628766FL2)" \
--provisionprofile "$HOME/Library/MobileDevice/Provisioning Profiles/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.provisionprofile"
```
Outputs:
- `cmake-build-macos-release-mas/strawberry.app`
- `cmake-build-macos-release-mas/strawberry-mas.pkg`
---
## Step 7 — Upload + submit for review
- Upload the `.pkg` using Apples **Transporter** app (App Store Connect).
- In App Store Connect, wait for processing, select the build, then **Submit for Review**.

View File

@@ -1,277 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ts() { date +"%H:%M:%S"; }
lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; }
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/../.." && pwd)"
current_cmd_pid=""
current_hb_pid=""
kill_tree() {
local pid="$1"
[[ -z "${pid}" ]] && return 0
# Recurse into children first (best-effort).
local child
for child in $(pgrep -P "$pid" 2>/dev/null || true); do
kill_tree "$child"
done
kill -TERM "$pid" >/dev/null 2>&1 || true
}
cleanup() {
# Never fail cleanup on errors.
set +e
if [[ -n "${current_hb_pid}" ]]; then
kill "${current_hb_pid}" >/dev/null 2>&1 || true
wait "${current_hb_pid}" >/dev/null 2>&1 || true
current_hb_pid=""
fi
if [[ -n "${current_cmd_pid}" ]]; then
# If still running, terminate process tree.
kill -0 "${current_cmd_pid}" >/dev/null 2>&1 && kill_tree "${current_cmd_pid}"
current_cmd_pid=""
fi
}
trap 'cleanup; exit 130' INT TERM
trap 'cleanup' EXIT
run_with_heartbeat() {
local desc="$1"
shift
local start now elapsed hb_pid
start="$(date +%s)"
echo "==> [$(ts)] ${desc}"
# Run the command in the background so we can reliably clean it up on Ctrl-C.
set +e
"$@" &
local cmd_pid=$!
set -e
current_cmd_pid="$cmd_pid"
(
while kill -0 "$cmd_pid" >/dev/null 2>&1; do
sleep 20
now="$(date +%s)"
elapsed="$((now - start))"
echo " [$(ts)] ... still working (${elapsed}s elapsed) ..."
done
) &
hb_pid="$!"
current_hb_pid="$hb_pid"
set +e
wait "$cmd_pid"
local rc=$?
set -e
# Clear globals before stopping heartbeat to avoid cleanup double-kill.
current_cmd_pid=""
kill "$hb_pid" >/dev/null 2>&1 || true
wait "$hb_pid" >/dev/null 2>&1 || true
current_hb_pid=""
now="$(date +%s)"
elapsed="$((now - start))"
if [[ $rc -ne 0 ]]; then
echo "Error: '${desc}' failed after ${elapsed}s (exit $rc)." >&2
return "$rc"
fi
echo "==> [$(ts)] Done: ${desc} (${elapsed}s)"
}
usage() {
cat <<'EOF'
Usage:
./build_tools/macos/build_app.sh [--debug|--release] [--mas] [--deploy] [--dmg] [--clean] [--build-dir <path>]
What it does:
- Configures and builds Strawberry with CMake + Ninja
- Optional: runs CMake targets 'deploy' (bundle deps) and 'dmg' (create DMG)
Options:
--release Release build (default)
--debug Debug build
--mas Build for Mac App Store (BUILD_FOR_MAC_APP_STORE=ON). Disables Sparkle/QtSparkle and any localhost OAuth redirect listener.
--deploy Run: cmake --build <builddir> --target deploy
--dmg Run: cmake --build <builddir> --target dmg (implies --deploy)
--clean Delete the build dir before configuring
--build-dir Override build directory (default: <repo>/cmake-build-macos-<config>)
-h, --help Show help
EOF
}
config="Release"
do_mas=0
do_deploy=0
do_dmg=0
do_clean=0
build_dir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--release) config="Release"; shift ;;
--debug) config="Debug"; shift ;;
--mas) do_mas=1; shift ;;
--deploy) do_deploy=1; shift ;;
--dmg) do_dmg=1; do_deploy=1; shift ;;
--clean) do_clean=1; shift ;;
--build-dir) build_dir="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
if ! command -v xcode-select >/dev/null 2>&1 || ! xcode-select -p >/dev/null 2>&1; then
echo "Error: Xcode Command Line Tools not found." >&2
echo "Install them first: xcode-select --install" >&2
exit 1
fi
if ! command -v brew >/dev/null 2>&1; then
echo "Error: Homebrew ('brew') not found in PATH." >&2
echo "Install Homebrew first: https://brew.sh/" >&2
exit 1
fi
if ! command -v cmake >/dev/null 2>&1; then
echo "Error: cmake not found. Did you run ./build_tools/macos/install_brew_deps.sh ?" >&2
exit 1
fi
if ! command -v ninja >/dev/null 2>&1; then
echo "Error: ninja not found. Did you run ./build_tools/macos/install_brew_deps.sh ?" >&2
exit 1
fi
brew_prefix="$(brew --prefix)"
qt_prefix="$(brew --prefix qt)"
icu_prefix="$(brew --prefix icu4c || true)"
if [[ -z "$build_dir" ]]; then
build_dir="${repo_root}/cmake-build-macos-$(lower "$config")"
fi
echo "==> [$(ts)] Repo: ${repo_root}"
echo "==> [$(ts)] Build dir: ${build_dir}"
echo "==> [$(ts)] Config: ${config}"
if [[ "$do_mas" -eq 1 ]]; then
echo "==> [$(ts)] MAS: enabled (BUILD_FOR_MAC_APP_STORE=ON)"
fi
if [[ "$do_clean" -eq 1 ]]; then
echo "==> [$(ts)] Cleaning build dir"
# macOS 26+ can apply provenance metadata that blocks deletion even when permissions look normal.
# Clear common xattrs and immutable flags before deleting.
xattr -dr com.apple.provenance "$build_dir" >/dev/null 2>&1 || true
xattr -dr com.apple.quarantine "$build_dir" >/dev/null 2>&1 || true
chflags -R nouchg,noschg "$build_dir" >/dev/null 2>&1 || true
rm -rf "$build_dir" || {
echo "Error: failed to remove build dir: $build_dir" >&2
echo "This is usually due to macOS provenance/flags. Try:" >&2
echo " xattr -dr com.apple.provenance \"$build_dir\"" >&2
echo " chflags -R nouchg,noschg \"$build_dir\"" >&2
echo " rm -rf \"$build_dir\"" >&2
exit 1
}
fi
mkdir -p "$build_dir"
# If you've run a previously-built app directly from the build directory, macOS can apply provenance
# metadata that makes the bundle effectively immutable (even when permissions look normal).
# That breaks CMake because it needs to update strawberry.app/Contents/Info.plist during configure/build.
app_bundle="${build_dir}/strawberry.app"
if [[ -d "${app_bundle}/Contents" ]]; then
# Try to clear provenance/quarantine metadata first (best effort).
xattr -dr com.apple.provenance "${app_bundle}" >/dev/null 2>&1 || true
xattr -dr com.apple.quarantine "${app_bundle}" >/dev/null 2>&1 || true
# If the bundle is still not writable, remove it so CMake can recreate it.
if ! ( : > "${app_bundle}/Contents/.cmake_write_test" ) 2>/dev/null; then
echo "==> [$(ts)] Existing ${app_bundle} is not writable (likely macOS provenance). Removing it."
rm -rf "${app_bundle}"
else
rm -f "${app_bundle}/Contents/.cmake_write_test" >/dev/null 2>&1 || true
fi
fi
# Make pkg-config more reliable with Homebrew.
export PKG_CONFIG_PATH="${brew_prefix}/lib/pkgconfig:${brew_prefix}/share/pkgconfig:${PKG_CONFIG_PATH:-}"
# For dist/CMakeLists.txt Info.plist minimum version logic.
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-12.0}"
cmake_prefix_path="${qt_prefix};${brew_prefix}"
cmake_extra_args=()
# Mac App Store build mode
if [[ "$do_mas" -eq 1 ]]; then
cmake_extra_args+=("-DBUILD_FOR_MAC_APP_STORE=ON")
fi
# Optional: override Sparkle update feed / key for your own published builds.
# Example:
# export SPARKLE_FEED_URL="https://example.com/appcast.xml"
# export SPARKLE_PUBLIC_ED25519_KEY="base64=="
if [[ -n "${SPARKLE_FEED_URL:-}" ]]; then
cmake_extra_args+=("-DSPARKLE_FEED_URL=${SPARKLE_FEED_URL}")
fi
if [[ -n "${SPARKLE_PUBLIC_ED25519_KEY:-}" ]]; then
cmake_extra_args+=("-DSPARKLE_PUBLIC_ED25519_KEY=${SPARKLE_PUBLIC_ED25519_KEY}")
fi
run_with_heartbeat "Configuring (CMAKE_PREFIX_PATH=${cmake_prefix_path})" \
cmake -S "$repo_root" -B "$build_dir" -G Ninja \
-DCMAKE_BUILD_TYPE="$config" \
-DCMAKE_PREFIX_PATH="$cmake_prefix_path" \
-DCMAKE_FRAMEWORK_PATH="${brew_prefix}/Frameworks;${brew_prefix}/opt/sparkle-framework/Frameworks" \
-DOPTIONAL_COMPONENTS_MISSING_DEPS_ARE_FATAL=OFF \
${cmake_extra_args+"${cmake_extra_args[@]}"} \
${icu_prefix:+-DICU_ROOT="$icu_prefix"}
run_with_heartbeat "Building" \
cmake --build "$build_dir" --parallel
if [[ "$do_deploy" -eq 1 ]]; then
echo "==> [$(ts)] Preparing env for 'deploy' target (GIO/GStreamer)"
export GIO_EXTRA_MODULES="${brew_prefix}/lib/gio/modules"
export GST_PLUGIN_SCANNER="${brew_prefix}/opt/gstreamer/libexec/gstreamer-1.0/gst-plugin-scanner"
export GST_PLUGIN_PATH="${brew_prefix}/lib/gstreamer-1.0"
# Optional, but helps dist/macos/macgstcopy.sh bundle libsoup which GStreamer loads dynamically.
libsoup_prefix="$(brew --prefix libsoup 2>/dev/null || true)"
if [[ -n "${libsoup_prefix}" ]]; then
libsoup_dylib="$(ls -1 "${libsoup_prefix}"/lib/libsoup-*.dylib 2>/dev/null | head -n 1 || true)"
if [[ -n "${libsoup_dylib}" ]]; then
export LIBSOUP_LIBRARY_PATH="${libsoup_dylib}"
fi
fi
run_with_heartbeat "Running: deploy" \
cmake --build "$build_dir" --target deploy
fi
if [[ "$do_dmg" -eq 1 ]]; then
run_with_heartbeat "Running: dmg" \
cmake --build "$build_dir" --target dmg
fi
echo "==> [$(ts)] Done"
echo "Built app:"
echo " ${build_dir}/strawberry.app"

View File

@@ -1,205 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ts() { date +"%H:%M:%S"; }
lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; }
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/../.." && pwd)"
usage() {
cat <<'EOF'
Usage:
./build_tools/macos/build_mas_pkg.sh --run [options]
What it does:
- Builds Strawberry in Mac App Store mode (BUILD_FOR_MAC_APP_STORE=ON)
- Runs deploy (macdeployqt + bundling) so the app bundle is self-contained
- Embeds a Mac App Store provisioning profile into the app bundle
- Codesigns the app with an Apple Distribution identity + entitlements
- Builds a signed .pkg suitable for uploading to App Store Connect
Required options:
--run
--codesign-identity "<name>" (e.g. "Apple Distribution: Dry Ark LLC (TEAMID)")
--installer-identity "<name>" (e.g. "3rd Party Mac Developer Installer: Dry Ark LLC (TEAMID)")
--provisionprofile <path> Path to a *Mac App Store* provisioning profile (*.provisionprofile)
Optional:
--release | --debug Build config (default: Release)
--clean Clean build dir before build
--build-dir <path> Override build directory
--entitlements <plist> Codesign entitlements (default: dist/macos/entitlements.mas.plist)
--bundle-id <id> Override CFBundleIdentifier (default: com.dryark.strawberry)
--pkg-out <path> Output .pkg path (default: <build-dir>/strawberry-mas.pkg)
Examples:
./build_tools/macos/build_mas_pkg.sh --run --release --clean \
--codesign-identity "Apple Distribution: Your Name (TEAMID)" \
--installer-identity "3rd Party Mac Developer Installer: Your Name (TEAMID)" \
--provisionprofile "$HOME/Library/MobileDevice/Provisioning Profiles/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.provisionprofile"
Notes:
- Mac App Store submissions do NOT use Developer ID notarization.
- You must create a Mac App Store provisioning profile for your App ID in Apple Developer.
EOF
}
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
do_run=0
config="Release"
do_clean=0
build_dir=""
codesign_identity=""
installer_identity=""
provisionprofile=""
entitlements=""
bundle_id="com.dryark.strawberry"
pkg_out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--run) do_run=1; shift ;;
--release) config="Release"; shift ;;
--debug) config="Debug"; shift ;;
--clean) do_clean=1; shift ;;
--build-dir) build_dir="${2:-}"; shift 2 ;;
--codesign-identity) codesign_identity="${2:-}"; shift 2 ;;
--installer-identity) installer_identity="${2:-}"; shift 2 ;;
--provisionprofile) provisionprofile="${2:-}"; shift 2 ;;
--entitlements) entitlements="${2:-}"; shift 2 ;;
--bundle-id) bundle_id="${2:-}"; shift 2 ;;
--pkg-out) pkg_out="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$do_run" -eq 0 ]]; then
usage
echo
echo "==> [$(ts)] Tip: list available signing identities:"
echo " security find-identity -p codesigning -v"
echo " security find-identity -p basic -v"
exit 0
fi
if [[ -z "$codesign_identity" ]]; then
echo "Error: missing --codesign-identity" >&2
exit 2
fi
if [[ -z "$installer_identity" ]]; then
echo "Error: missing --installer-identity" >&2
exit 2
fi
if [[ -z "$provisionprofile" || ! -f "$provisionprofile" ]]; then
echo "Error: missing/invalid --provisionprofile: $provisionprofile" >&2
exit 2
fi
if [[ -z "$entitlements" ]]; then
entitlements="${repo_root}/dist/macos/entitlements.mas.plist"
fi
if [[ ! -f "$entitlements" ]]; then
echo "Error: entitlements file not found: $entitlements" >&2
exit 2
fi
if [[ -z "$build_dir" ]]; then
build_dir="${repo_root}/cmake-build-macos-$(lower "$config")-mas"
fi
if [[ -z "$pkg_out" ]]; then
pkg_out="${build_dir}/strawberry-mas.pkg"
fi
echo "==> [$(ts)] Repo: ${repo_root}"
echo "==> [$(ts)] Build dir: ${build_dir}"
echo "==> [$(ts)] Config: ${config}"
echo "==> [$(ts)] Bundle ID: ${bundle_id}"
echo "==> [$(ts)] Entitlements: ${entitlements}"
echo "==> [$(ts)] Provisioning profile: ${provisionprofile}"
echo "==> [$(ts)] Output pkg: ${pkg_out}"
echo "==> [$(ts)] Building (Mac App Store mode)"
build_args=( "--release" )
if [[ "$config" == "Debug" ]]; then build_args=( "--debug" ); fi
if [[ "$do_clean" -eq 1 ]]; then build_args+=( "--clean" ); fi
build_args+=( "--build-dir" "$build_dir" "--mas" "--deploy" )
# Provide bundle id via CMake cache variable.
export MACOS_BUNDLE_ID="$bundle_id"
"${repo_root}/build_tools/macos/build_app.sh" "${build_args[@]}"
app_path="${build_dir}/strawberry.app"
bin_path="${app_path}/Contents/MacOS/strawberry"
if [[ ! -x "$bin_path" ]]; then
echo "Error: built app not found at: $app_path" >&2
exit 1
fi
echo "==> [$(ts)] Embedding provisioning profile"
cp -f "$provisionprofile" "${app_path}/Contents/embedded.provisionprofile"
echo "==> [$(ts)] Codesigning app (Mac App Store)"
codesign_args=( --force --timestamp --options runtime --sign "$codesign_identity" --entitlements "$entitlements" )
# Clean up any leftover codesign temp files from previous interrupted runs.
find "$app_path" -name "*.cstemp" -print0 2>/dev/null | while IFS= read -r -d '' f; do
rm -f "$f" || true
done
# Clear macOS provenance/quarantine metadata which can interfere with modifying files in-place.
xattr -dr com.apple.provenance "$app_path" >/dev/null 2>&1 || true
xattr -dr com.apple.quarantine "$app_path" >/dev/null 2>&1 || true
# Sign nested code first, then frameworks, then the main app bundle.
find "$app_path" -type f \( -name "*.dylib" -o -name "*.so" -o -perm -111 \) \
! -name "*.cstemp" \
! -path "*/Contents/Frameworks/*.framework/*" \
! -path "*/Contents/Frameworks/*.app/*" \
! -path "*/Contents/Frameworks/*.xpc/*" \
! -path "*/Contents/PlugIns/*.framework/*" \
! -path "*/Contents/PlugIns/*.app/*" \
! -path "*/Contents/PlugIns/*.xpc/*" \
-print0 | while IFS= read -r -d '' f; do
# Only sign Mach-O binaries.
if file -b "$f" | grep -q "Mach-O"; then
codesign "${codesign_args[@]}" "$f" >/dev/null
fi
done
find "$app_path" -type d \( -name "*.xpc" -o -name "*.app" \) -print0 2>/dev/null | while IFS= read -r -d '' b; do
codesign "${codesign_args[@]}" "$b" >/dev/null
done
find "$app_path/Contents/Frameworks" "$app_path/Contents/PlugIns" -type d -name "*.framework" -print0 2>/dev/null | while IFS= read -r -d '' fw; do
codesign "${codesign_args[@]}" "$fw" >/dev/null
done
codesign "${codesign_args[@]}" "$app_path" >/dev/null
echo "==> [$(ts)] Verifying codesign"
codesign --verify --deep --strict --verbose=2 "$app_path"
echo "==> [$(ts)] Building signed .pkg for App Store upload"
rm -f "$pkg_out" >/dev/null 2>&1 || true
productbuild \
--component "$app_path" /Applications \
--sign "$installer_identity" \
"$pkg_out"
echo "==> [$(ts)] Verifying pkg signature"
pkgutil --check-signature "$pkg_out" || true
echo
echo "Done."
echo "App: $app_path"
echo "PKG: $pkg_out"

View File

@@ -1,309 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ts() { date +"%H:%M:%S"; }
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/../.." && pwd)"
usage() {
cat <<'EOF'
Usage:
./build_tools/macos/build_sign_notarize.sh # list local signing identities + notary profiles
./build_tools/macos/build_sign_notarize.sh --run [options] # build, sign, notarize, staple
Common options:
--run Perform build/sign/notarize (otherwise list identities/profiles)
--release | --debug Build config (default: Release)
--clean Clean build dir before build
--deploy Run CMake 'deploy' target before signing (default: on)
--no-deploy Do not run 'deploy' (not recommended for distribution)
--dmg Build a DMG after app notarization, then notarize+staple the DMG too
--build-dir <path> Override build directory
Signing options:
--identity "<name>" Codesign identity (e.g. "Developer ID Application: Your Name (TEAMID)")
--entitlements <plist> Optional entitlements plist for codesign
Notarization options (recommended):
--notary-profile <name> notarytool keychain profile name (created via `xcrun notarytool store-credentials <name> ...`)
--skip-notarize Skip notarization
Outputs:
- Signed app: <build-dir>/strawberry.app
- Zip for notarization: <build-dir>/strawberry-notarize.zip
- DMG (optional): <build-dir>/strawberry-*.dmg
Notes:
- This script is intended for Developer ID distribution (outside Mac App Store).
- If you want Sparkle updates, you'll typically ship a notarized .zip + an appcast feed.
EOF
}
list_identities_and_profiles() {
echo "==> [$(ts)] macOS code signing identities (Keychain)"
security find-identity -p codesigning -v || true
echo
echo "==> [$(ts)] notarytool credential profiles"
echo "Note: this Xcode notarytool version does not provide a 'list-profiles' command."
echo "If you forgot the profile name you created, check Keychain Access or re-run:"
echo " xcrun notarytool store-credentials \"<profile-name>\" --apple-id \"you@example.com\" --team-id \"TEAMID\""
echo
echo "==> [$(ts)] Provisioning profiles (macOS)"
prof_dir="${HOME}/Library/MobileDevice/Provisioning Profiles"
if [[ -d "${prof_dir}" ]]; then
ls -la "${prof_dir}" | head -n 50
else
echo "(none found at '${prof_dir}')"
fi
}
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
if ! command -v xcode-select >/dev/null 2>&1 || ! xcode-select -p >/dev/null 2>&1; then
echo "Error: Xcode Command Line Tools not found." >&2
echo "Install them first: xcode-select --install" >&2
exit 1
fi
do_run=0
config="Release"
do_clean=0
do_deploy=1
do_dmg=0
build_dir=""
identity=""
entitlements=""
notary_profile=""
skip_notarize=0
while [[ $# -gt 0 ]]; do
case "$1" in
--run) do_run=1; shift ;;
--release) config="Release"; shift ;;
--debug) config="Debug"; shift ;;
--clean) do_clean=1; shift ;;
--deploy) do_deploy=1; shift ;;
--no-deploy) do_deploy=0; shift ;;
--dmg) do_dmg=1; shift ;;
--build-dir) build_dir="${2:-}"; shift 2 ;;
--identity) identity="${2:-}"; shift 2 ;;
--entitlements) entitlements="${2:-}"; shift 2 ;;
--notary-profile) notary_profile="${2:-}"; shift 2 ;;
--skip-notarize) skip_notarize=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$do_run" -eq 0 ]]; then
usage
echo
list_identities_and_profiles
exit 0
fi
if [[ -z "$build_dir" ]]; then
lc_config="$(echo "$config" | tr '[:upper:]' '[:lower:]')"
build_dir="${repo_root}/cmake-build-macos-${lc_config}"
fi
app_path="${build_dir}/strawberry.app"
bin_path="${app_path}/Contents/MacOS/strawberry"
zip_path="${build_dir}/strawberry-notarize.zip"
dmg_path=""
notarize_and_maybe_staple() {
local file_path="$1"
local label="$2"
local do_staple="${3:-1}"
echo "==> [$(ts)] Notarizing ${label}"
local out
out="$(mktemp -t notarytool-submit.XXXXXX)"
xcrun notarytool submit "$file_path" --keychain-profile "$notary_profile" --wait --output-format plist --no-progress >"$out"
local submit_id submit_status
submit_id="$(/usr/bin/plutil -extract id raw -o - "$out" 2>/dev/null || true)"
submit_status="$(/usr/bin/plutil -extract status raw -o - "$out" 2>/dev/null || true)"
rm -f "$out" || true
if [[ -z "$submit_id" ]]; then
echo "Error: could not parse notarization submission id for ${label}." >&2
exit 1
fi
echo "==> [$(ts)] Notary submission id: $submit_id"
echo "==> [$(ts)] Notary status: $submit_status"
if [[ "$submit_status" != "Accepted" ]]; then
echo "Error: notarization failed for ${label} with status '$submit_status'. Fetching log..." >&2
xcrun notarytool log "$submit_id" --keychain-profile "$notary_profile" || true
exit 1
fi
if [[ "$do_staple" -eq 1 ]]; then
echo "==> [$(ts)] Stapling ${label}"
xcrun stapler staple "$file_path"
fi
}
if [[ -z "$identity" ]]; then
echo "Error: Missing --identity (Developer ID Application identity)." >&2
exit 2
fi
if [[ "$skip_notarize" -eq 0 && -z "$notary_profile" ]]; then
echo "Error: Missing --notary-profile (or pass --skip-notarize)." >&2
exit 2
fi
echo "==> [$(ts)] Building Strawberry"
build_args=( "--release" )
if [[ "$config" == "Debug" ]]; then build_args=( "--debug" ); fi
if [[ "$do_clean" -eq 1 ]]; then build_args+=( "--clean" ); fi
if [[ -n "$build_dir" ]]; then build_args+=( "--build-dir" "$build_dir" ); fi
if [[ "$do_deploy" -eq 1 ]]; then build_args+=( "--deploy" ); fi
"${repo_root}/build_tools/macos/build_app.sh" "${build_args[@]}"
if [[ ! -x "$bin_path" ]]; then
echo "Error: built app not found at: $app_path" >&2
exit 1
fi
echo "==> [$(ts)] Codesigning (hardened runtime)"
codesign_args=( --force --timestamp --options runtime --sign "$identity" )
if [[ -n "$entitlements" ]]; then
codesign_args+=( --entitlements "$entitlements" )
fi
# Clean up any leftover codesign temp files from previous interrupted runs.
# codesign may create *.cstemp alongside binaries while updating signatures.
find "$app_path" -name "*.cstemp" -print0 2>/dev/null | while IFS= read -r -d '' f; do
rm -f "$f" || true
done
# Clear macOS provenance/quarantine metadata which can interfere with modifying files in-place.
xattr -dr com.apple.provenance "$app_path" >/dev/null 2>&1 || true
xattr -dr com.apple.quarantine "$app_path" >/dev/null 2>&1 || true
# Sign nested code first, then frameworks, then the main app bundle.
#
# Important: do NOT codesign individual files *inside* a .framework bundle (e.g. Sparkle.framework/Sparkle),
# because codesign expects frameworks to be signed as bundles and may error with
# "bundle format is ambiguous (could be app or framework)".
# 1) Sign dylibs and standalone executables that are NOT inside a .framework/.app/.xpc bundle.
find "$app_path" -type f \( -name "*.dylib" -o -name "*.so" -o -perm -111 \) \
! -name "*.cstemp" \
! -path "*/Contents/Frameworks/*.framework/*" \
! -path "*/Contents/Frameworks/*.app/*" \
! -path "*/Contents/Frameworks/*.xpc/*" \
! -path "*/Contents/PlugIns/*.framework/*" \
! -path "*/Contents/PlugIns/*.app/*" \
! -path "*/Contents/PlugIns/*.xpc/*" \
-print0 | while IFS= read -r -d '' f; do
codesign "${codesign_args[@]}" "$f" >/dev/null
done
# 2) Sign nested helper apps and XPC services (Sparkle ships these inside its framework).
find "$app_path" -type d \( -name "*.xpc" -o -name "*.app" \) -print0 2>/dev/null | while IFS= read -r -d '' b; do
codesign "${codesign_args[@]}" "$b" >/dev/null
done
# 2b) Sparkle.framework contains a standalone helper executable "Autoupdate" under Versions/* that is
# not inside an .app or .xpc bundle. Notarization requires it be signed with Developer ID + timestamp.
sparkle_fw="$app_path/Contents/Frameworks/Sparkle.framework"
if [[ -d "$sparkle_fw" ]]; then
find "$sparkle_fw/Versions" -type f -perm -111 \
! -name "*.cstemp" \
! -path "*/_CodeSignature/*" \
-print0 2>/dev/null | while IFS= read -r -d '' f; do
codesign "${codesign_args[@]}" "$f" >/dev/null
done
fi
# 3) Sign frameworks as bundles.
find "$app_path/Contents/Frameworks" "$app_path/Contents/PlugIns" -type d -name "*.framework" -print0 2>/dev/null | while IFS= read -r -d '' fw; do
codesign "${codesign_args[@]}" "$fw" >/dev/null
done
# 4) Finally sign the main app.
codesign "${codesign_args[@]}" "$app_path" >/dev/null
echo "==> [$(ts)] Verifying codesign"
codesign --verify --deep --strict --verbose=2 "$app_path"
echo "==> [$(ts)] Creating zip for notarization"
rm -f "$zip_path"
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$zip_path"
if [[ "$skip_notarize" -eq 0 ]]; then
# ZIP archives cannot be stapled; notarization is still useful for distribution and Sparkle.
notarize_and_maybe_staple "$zip_path" "ZIP" 0
echo "==> [$(ts)] Stapling app"
xcrun stapler staple "$app_path"
fi
if [[ "$do_dmg" -eq 1 ]]; then
echo "==> [$(ts)] Building DMG (from already-signed app; no redeploy)"
if ! command -v create-dmg >/dev/null 2>&1; then
echo "Error: create-dmg not found. Install it with Homebrew (it's in Brewfile):" >&2
echo " ./build_tools/macos/install_brew_deps.sh" >&2
exit 1
fi
# Build a versioned DMG name using Info.plist (falls back to Strawberry version constant).
plist="${app_path}/Contents/Info.plist"
bundle_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist" 2>/dev/null || true)"
if [[ -z "${bundle_version}" ]]; then
bundle_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist" 2>/dev/null || true)"
fi
if [[ -z "${bundle_version}" ]]; then
bundle_version="unknown"
fi
arch="$(uname -m)"
dmg_path="${build_dir}/strawberry-${bundle_version}-${arch}.dmg"
rm -f "$dmg_path"
(
cd "$build_dir"
create-dmg \
--volname strawberry \
--background "${repo_root}/dist/macos/dmg_background.png" \
--app-drop-link 450 218 \
--icon strawberry.app 150 218 \
--window-size 600 450 \
"$(basename "$dmg_path")" \
strawberry.app
)
if [[ -z "$dmg_path" ]]; then
echo "Error: DMG was not created in $build_dir" >&2
exit 1
fi
echo "==> [$(ts)] Codesigning DMG"
codesign --force --timestamp --sign "$identity" "$dmg_path"
if [[ "$skip_notarize" -eq 0 ]]; then
notarize_and_maybe_staple "$dmg_path" "DMG" 1
fi
fi
echo "==> [$(ts)] Gatekeeper assessment"
spctl -a -vv --type execute "$app_path" || true
echo
echo "Done."
echo "App: $app_path"
echo "Zip: $zip_path"
if [[ -n "${dmg_path}" ]]; then
echo "DMG: $dmg_path"
fi

View File

@@ -1,146 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# macOS signing identity sanity check for:
# - Developer ID (outside Mac App Store)
# - Mac App Store (Apple Distribution + 3rd Party Mac Developer Installer)
ts() { date +"%H:%M:%S"; }
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
echo "==> [$(ts)] Strawberry macOS signing identity check"
echo "==> [$(ts)] Host: $(sw_vers -productName 2>/dev/null || true) $(sw_vers -productVersion 2>/dev/null || true)"
echo
echo "==> [$(ts)] Keychains searched by 'security' (user)"
security list-keychains -d user || true
echo
echo "==> [$(ts)] Valid code signing identities (must include private key)"
codesigning_out="$(security find-identity -p codesigning -v 2>&1 || true)"
echo "$codesigning_out"
echo
echo "==> [$(ts)] Valid installer/pkg identities (must include private key)"
basic_out="$(security find-identity -p basic -v 2>&1 || true)"
echo "$basic_out"
echo
echo "==> [$(ts)] Note"
cat <<'EOF'
- Apple uses multiple certificate types. The "basic" identity list can include certificates that are not usable
for signing a Mac App Store upload package.
- For App Store Connect uploads via .pkg, you typically need an *Installer* identity (e.g. "3rd Party Mac Developer Installer"
or "Mac Installer Distribution") and it must have a private key on this Mac.
EOF
echo
list_cert_labels() {
local query="$1"
# Extract "labl" lines like: "labl"<blob>="Apple Distribution: ..."
security find-certificate -a -c "$query" 2>/dev/null \
| sed -n 's/.*"labl"<blob>="\(.*\)".*/\1/p' \
| sort -u
}
check_label_in_identities() {
local label="$1"
local out="$2"
if echo "$out" | grep -Fq "$label"; then
echo "YES"
else
echo "NO"
fi
}
check_label_in_installer_identities() {
local label="$1"
local out="$2"
# Only treat as installer-capable if the cert label itself is an installer cert.
case "$label" in
*Installer*|*installer*) ;;
*) echo "NO"; return 0 ;;
esac
if echo "$out" | grep -Fq "$label"; then
echo "YES"
else
echo "NO"
fi
}
print_section() {
local title="$1"
shift
local queries=("$@")
echo "==> [$(ts)] ${title}"
local any=0
local q
for q in "${queries[@]}"; do
local labels
labels="$(list_cert_labels "$q" || true)"
if [[ -z "$labels" ]]; then
continue
fi
any=1
while IFS= read -r label; do
[[ -z "$label" ]] && continue
local in_codesign in_basic
in_codesign="$(check_label_in_identities "$label" "$codesigning_out")"
in_basic="$(check_label_in_installer_identities "$label" "$basic_out")"
printf -- "- %s\n" "$label"
printf -- " - codesigning identity: %s\n" "$in_codesign"
printf -- " - installer identity: %s\n" "$in_basic"
if [[ "$in_codesign" == "NO" && "$in_basic" == "NO" ]]; then
printf -- " - note: certificate exists, but it is NOT a usable identity on this Mac (almost always missing private key)\n"
fi
done <<<"$labels"
done
if [[ "$any" -eq 0 ]]; then
echo "(no matching certificates found)"
fi
echo
}
print_section "Expected for Developer ID (outside Mac App Store)" \
"Developer ID Application" \
"Developer ID Installer"
print_section "Expected for Mac App Store submissions" \
"Apple Distribution" \
"Mac App Distribution" \
"3rd Party Mac Developer Application" \
"3rd Party Mac Developer Installer" \
"Mac Installer Distribution"
echo "==> [$(ts)] Quick interpretation"
cat <<'EOF'
- If a certificate label appears above, but both:
- codesigning identity: NO
- installer identity: NO
then the certificate is present but NOT usable for signing on this Mac.
The most common cause is: the private key is missing.
Fix:
- Open Keychain Access → login → "My Certificates"
- Expand the certificate. You must see a private key underneath it.
- If there is no private key:
- Recreate the certificate on this Mac via Xcode (Accounts → Manage Certificates), OR
- Import a .p12 that includes the private key from the machine where it was created.
EOF
echo
echo "==> [$(ts)] Provisioning profiles (Mac App Store builds require one)"
prof_dir="${HOME}/Library/MobileDevice/Provisioning Profiles"
if [[ -d "${prof_dir}" ]]; then
ls -la "${prof_dir}" | head -n 50
else
echo "(none found at '${prof_dir}')"
fi

View File

@@ -1,26 +0,0 @@
Encryption Export Compliance Statement (EAR)
App Name: Strawberry
Bundle ID: @BUNDLE_ID@
Version: @VERSION@
Developer: @DEVELOPER@
Date: @DATE@
Contact: @CONTACT@
Statement
---------
This app uses encryption only for standard network communications security (for example, TLS/HTTPS connections to web services).
The app does not implement proprietary or non-standard encryption algorithms, and it does not ship its own cryptographic library in place of Apples operating system encryption.
The app uses only encryption provided by Apples operating system (e.g., Apple-provided TLS stacks accessed through system frameworks used by the app and its dependencies).
The app is not a VPN client/server, does not provide end-to-end encrypted messaging, and does not provide user-controlled key management or custom cryptographic functionality beyond standard transport security.
Accordingly, the apps use of encryption is believed to qualify as exempt under U.S. export regulations for mass-market software using standard OS-provided encryption.
Reference
---------
Apple: Complying with Encryption Export Regulations
https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations

View File

@@ -1,32 +0,0 @@
# Export compliance (encryption)
Apple may require an "Export Compliance" statement upload when submitting to the Mac App Store.
This folder contains:
- `EXPORT_COMPLIANCE.txt`: a template statement (fill-in placeholders)
- `make_pdf.sh`: a helper to fill the template and generate a PDF you can upload
## Generate the PDF
From the repo root:
```bash
./build_tools/macos/export_compliance/make_pdf.sh \
--bundle-id com.dryark.strawberry \
--version 1.2.3 \
--developer "Dry Ark LLC" \
--contact "support@example.com"
```
Outputs:
- `build_tools/macos/export_compliance/EXPORT_COMPLIANCE.filled.txt`
- `build_tools/macos/export_compliance/EXPORT_COMPLIANCE.pdf`
## Important
This template assumes the app uses **only standard OS-provided encryption** (e.g. TLS/HTTPS via system frameworks) and does **not** ship proprietary or standalone crypto libraries.
If you bundle your own crypto library (e.g. OpenSSL) or implement custom encryption, you likely need different answers/documentation.

View File

@@ -1,96 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./build_tools/macos/export_compliance/make_pdf.sh \
--bundle-id com.dryark.strawberry \
--version 1.2.3 \
--developer "Dry Ark LLC" \
--contact "support@example.com"
Outputs (in the same folder as this script):
- EXPORT_COMPLIANCE.filled.txt
- EXPORT_COMPLIANCE.pdf
Notes:
- Uses macOS built-in /usr/sbin/cupsfilter to generate the PDF from plain text.
EOF
}
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
template="${script_dir}/EXPORT_COMPLIANCE.txt"
filled="${script_dir}/EXPORT_COMPLIANCE.filled.txt"
pdf="${script_dir}/EXPORT_COMPLIANCE.pdf"
tmp_html="${script_dir}/EXPORT_COMPLIANCE.tmp.html"
bundle_id=""
version=""
developer=""
contact=""
date_str="$(date +%Y-%m-%d)"
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-id) bundle_id="${2:-}"; shift 2 ;;
--version) version="${2:-}"; shift 2 ;;
--developer) developer="${2:-}"; shift 2 ;;
--contact) contact="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ -z "$bundle_id" || -z "$version" || -z "$developer" || -z "$contact" ]]; then
echo "Error: missing required args." >&2
usage
exit 2
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only (uses textutil)." >&2
exit 1
fi
if [[ ! -f "$template" ]]; then
echo "Error: missing template file: $template" >&2
exit 1
fi
if [[ ! -x /usr/sbin/cupsfilter ]]; then
echo "Error: /usr/sbin/cupsfilter not found. This should exist on macOS." >&2
exit 1
fi
escape_sed_repl() {
# Escape characters that are special in sed replacement strings: \ & and delimiter |
# bash 3.2 compatible
echo "$1" | sed -e 's/[\\&|]/\\&/g'
}
bundle_id_esc="$(escape_sed_repl "$bundle_id")"
version_esc="$(escape_sed_repl "$version")"
developer_esc="$(escape_sed_repl "$developer")"
contact_esc="$(escape_sed_repl "$contact")"
date_esc="$(escape_sed_repl "$date_str")"
sed \
-e "s|@BUNDLE_ID@|${bundle_id_esc}|g" \
-e "s|@VERSION@|${version_esc}|g" \
-e "s|@DEVELOPER@|${developer_esc}|g" \
-e "s|@CONTACT@|${contact_esc}|g" \
-e "s|@DATE@|${date_esc}|g" \
"$template" > "$filled"
rm -f "$pdf" >/dev/null 2>&1 || true
rm -f "$tmp_html" >/dev/null 2>&1 || true
# Convert plain text to PDF. cupsfilter writes PDF to stdout.
# Suppress noisy DEBUG output from cupsfilter on stderr.
/usr/sbin/cupsfilter -i text/plain -m application/pdf "$filled" > "$pdf" 2>/dev/null
echo "Wrote:"
echo " $filled"
echo " $pdf"

View File

@@ -1,119 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/../.." && pwd)"
ts() { date +"%H:%M:%S"; }
run_with_heartbeat() {
local desc="$1"
shift
local start now elapsed hb_pid
start="$(date +%s)"
echo "==> [$(ts)] ${desc}"
# Heartbeat: print elapsed time periodically in case the underlying command is quiet
(
while true; do
sleep 20
now="$(date +%s)"
elapsed="$((now - start))"
echo " [$(ts)] ... still working (${elapsed}s elapsed) ..."
done
) &
hb_pid="$!"
set +e
"$@"
local rc=$?
set -e
kill "$hb_pid" >/dev/null 2>&1 || true
wait "$hb_pid" >/dev/null 2>&1 || true
now="$(date +%s)"
elapsed="$((now - start))"
if [[ $rc -ne 0 ]]; then
echo "Error: '${desc}' failed after ${elapsed}s (exit $rc)." >&2
return "$rc"
fi
echo "==> [$(ts)] Done: ${desc} (${elapsed}s)"
}
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
if ! command -v brew >/dev/null 2>&1; then
echo "Error: Homebrew ('brew') not found in PATH." >&2
echo "Install Homebrew first: https://brew.sh/" >&2
exit 1
fi
# Homebrew taps are git clones; local formula changes must be committed to be visible.
if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if git -C "$repo_root" status --porcelain Formula/ | grep -q .; then
echo "Error: You have uncommitted changes under Formula/." >&2
echo "Homebrew taps are git clones, so uncommitted formulae won't be visible to 'brew tap'." >&2
echo "Commit your changes, then re-run this script." >&2
exit 1
fi
fi
# Optional: disable auto-update for faster, more predictable runs.
export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}"
cd "$repo_root"
echo "==> [$(ts)] Using repo: $repo_root"
# Strawberry includes local Homebrew formulae under Formula/.
# Homebrew requires formulae to be in a tap; we tap this repo via file:// and then
# update the tap clone to the latest commit (without untapping, since Homebrew may
# refuse to untap when formulae from this tap are installed).
run_with_heartbeat "Ensuring local tap exists: strawberry/local" bash -lc \
"brew tap | grep -q '^strawberry/local$' || brew tap strawberry/local 'file://$repo_root' >/dev/null"
run_with_heartbeat "Refreshing strawberry/local tap clone" bash -lc '
tap_repo="$(brew --repo strawberry/local)"
cd "$tap_repo"
# Make sure the remote points at the current local repo path.
git remote set-url origin "file://'"$repo_root"'"
git fetch -q origin
default_ref="$(git symbolic-ref -q --short refs/remotes/origin/HEAD || true)"
if [ -z "$default_ref" ]; then
default_ref="origin/master"
fi
git reset --hard -q "$default_ref"
'
for f in kdsingleapplication-qt6 qtsparkle-qt6 sparkle-framework libgpod macdeploycheck; do
if ! brew info "strawberry/local/${f}" >/dev/null 2>&1; then
echo "Error: Missing formula strawberry/local/${f} in the tapped repo." >&2
echo "If you recently added/changed formulae, ensure they are committed, then refresh the tap:" >&2
echo " git -C \"$(brew --repo strawberry/local)\" pull --ff-only" >&2
exit 1
fi
done
run_with_heartbeat "Installing dependencies from Brewfile" \
brew bundle install --file "$repo_root/Brewfile" --verbose
cat <<EOF
Done.
Notes for packaging (optional):
- The CMake target 'deploy' expects these env vars for bundling GIO + GStreamer bits:
export GIO_EXTRA_MODULES="\$(brew --prefix)/lib/gio/modules"
export GST_PLUGIN_SCANNER="\$(brew --prefix gstreamer)/libexec/gstreamer-1.0/gst-plugin-scanner"
export GST_PLUGIN_PATH="\$(brew --prefix)/lib/gstreamer-1.0"
EOF

View File

@@ -1,104 +1,43 @@
# NOTE: Packaging helpers should not be REQUIRED at configure time.
# Missing tools should simply disable the related custom targets.
find_program(MACDEPLOYQT_EXECUTABLE NAMES macdeployqt PATHS /usr/bin /usr/local/bin /opt/local/bin /usr/local/opt/qt6/bin)
find_program(MACDEPLOYQT_EXECUTABLE NAMES macdeployqt PATHS /usr/bin /usr/local/bin /opt/local/bin /usr/local/opt/qt6/bin REQUIRED)
if(MACDEPLOYQT_EXECUTABLE)
message(STATUS "Found macdeployqt: ${MACDEPLOYQT_EXECUTABLE}")
else()
message(WARNING "Missing macdeployqt executable.")
endif()
find_program(MACDEPLOYCHECK_EXECUTABLE NAMES macdeploycheck PATHS /usr/bin /usr/local/bin /opt/local/bin /usr/local/opt/qt6/bin)
find_program(MACDEPLOYCHECK_EXECUTABLE NAMES macdeploycheck PATHS /usr/bin /usr/local/bin /opt/local/bin /usr/local/opt/qt6/bin REQUIRED)
if(MACDEPLOYCHECK_EXECUTABLE)
message(STATUS "Found macdeploycheck: ${MACDEPLOYCHECK_EXECUTABLE}")
else()
message(STATUS "macdeploycheck not found (optional): 'deploycheck' target will be unavailable.")
message(WARNING "Missing macdeploycheck executable.")
endif()
find_program(CREATEDMG_EXECUTABLE NAMES create-dmg)
find_program(CREATEDMG_EXECUTABLE NAMES create-dmg REQUIRED)
if(CREATEDMG_EXECUTABLE)
message(STATUS "Found create-dmg: ${CREATEDMG_EXECUTABLE}")
else()
message(WARNING "Missing create-dmg executable.")
endif()
set(_SPARKLE_FRAMEWORK_DIR "")
set(_SPARKLE_ORIGINAL_BIN_LINK "")
set(_SPARKLE_ORIGINAL_BIN_REAL "")
if(SPARKLE)
# SPARKLE may be either the framework directory or the framework binary path.
get_filename_component(_sparkle_link "${SPARKLE}" ABSOLUTE)
get_filename_component(_sparkle_real "${SPARKLE}" REALPATH)
if(_sparkle_link MATCHES "Sparkle\\.framework$")
set(_SPARKLE_FRAMEWORK_DIR "${_sparkle_real}")
set(_SPARKLE_ORIGINAL_BIN_LINK "${_sparkle_link}/Versions/B/Sparkle")
set(_SPARKLE_ORIGINAL_BIN_REAL "${_sparkle_real}/Versions/B/Sparkle")
else()
# Assume it's the framework binary path:
# .../Sparkle.framework/Versions/B/Sparkle
set(_SPARKLE_ORIGINAL_BIN_LINK "${_sparkle_link}")
set(_SPARKLE_ORIGINAL_BIN_REAL "${_sparkle_real}")
get_filename_component(_sparkle_b_dir "${_SPARKLE_ORIGINAL_BIN_REAL}" DIRECTORY) # .../Versions/B
get_filename_component(_sparkle_versions_dir "${_sparkle_b_dir}" DIRECTORY) # .../Versions
get_filename_component(_SPARKLE_FRAMEWORK_DIR "${_sparkle_versions_dir}" DIRECTORY) # .../Sparkle.framework
endif()
if(NOT EXISTS "${_SPARKLE_FRAMEWORK_DIR}" OR NOT EXISTS "${_SPARKLE_ORIGINAL_BIN_REAL}")
set(_SPARKLE_FRAMEWORK_DIR "")
set(_SPARKLE_ORIGINAL_BIN_LINK "")
set(_SPARKLE_ORIGINAL_BIN_REAL "")
else()
message(STATUS "Sparkle.framework found: ${_SPARKLE_FRAMEWORK_DIR}")
endif()
endif()
if(MACDEPLOYQT_EXECUTABLE)
# Note: We intentionally do NOT codesign during the CMake 'deploy'/'dmg' targets.
# macdeployqt can optionally sign, but passing identities safely through Ninja's /bin/sh wrapper is brittle.
# This repo's signing/notarization pipeline is handled in build_tools/macos/build_sign_notarize.sh instead.
if(APPLE_DEVELOPER_ID)
set(MACDEPLOYQT_CODESIGN -codesign=${APPLE_DEVELOPER_ID})
set(CREATEDMG_CODESIGN --codesign ${APPLE_DEVELOPER_ID})
endif()
if(CREATEDMG_SKIP_JENKINS)
set(CREATEDMG_SKIP_JENKINS_ARG "--skip-jenkins")
endif()
set(_deploy_commands
COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/Frameworks
COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/Resources
add_custom_target(deploy
COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/{Frameworks,Resources}
COMMAND cp -v ${CMAKE_BINARY_DIR}/dist/macos/Info.plist ${CMAKE_BINARY_DIR}/strawberry.app/Contents/
COMMAND cp -v ${CMAKE_SOURCE_DIR}/dist/macos/strawberry.icns ${CMAKE_BINARY_DIR}/strawberry.app/Contents/Resources/
)
if(_SPARKLE_FRAMEWORK_DIR)
list(APPEND _deploy_commands
COMMAND ${CMAKE_SOURCE_DIR}/dist/macos/bundle_sparkle.sh ${CMAKE_BINARY_DIR}/strawberry.app ${_SPARKLE_FRAMEWORK_DIR} ${_SPARKLE_ORIGINAL_BIN_LINK} ${_SPARKLE_ORIGINAL_BIN_REAL}
)
endif()
list(APPEND _deploy_commands
COMMAND ${CMAKE_SOURCE_DIR}/dist/macos/macgstcopy.sh ${CMAKE_BINARY_DIR}/strawberry.app
COMMAND ${MACDEPLOYQT_EXECUTABLE} strawberry.app -verbose=3 -executable=${CMAKE_BINARY_DIR}/strawberry.app/Contents/PlugIns/gst-plugin-scanner
)
# Make 'deploy' incremental:
# - add_custom_target() is always out-of-date, so it reruns every time.
# - using a stamp file makes Ninja/Make skip deploy when inputs haven't changed.
set(_deploy_stamp "${CMAKE_BINARY_DIR}/deploy_app_bundle.stamp")
add_custom_command(
OUTPUT "${_deploy_stamp}"
${_deploy_commands}
COMMAND ${CMAKE_COMMAND} -E touch "${_deploy_stamp}"
COMMENT "Deploying app bundle (bundling Sparkle/GStreamer + macdeployqt)"
COMMAND ${MACDEPLOYQT_EXECUTABLE} strawberry.app -verbose=3 -executable=${CMAKE_BINARY_DIR}/strawberry.app/Contents/PlugIns/gst-plugin-scanner ${MACDEPLOYQT_CODESIGN}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
DEPENDS
strawberry
"${CMAKE_BINARY_DIR}/dist/macos/Info.plist"
"${CMAKE_SOURCE_DIR}/dist/macos/strawberry.icns"
"${CMAKE_SOURCE_DIR}/dist/macos/macgstcopy.sh"
"${CMAKE_SOURCE_DIR}/dist/macos/bundle_sparkle.sh"
DEPENDS strawberry
)
add_custom_target(deploy DEPENDS "${_deploy_stamp}")
if(MACDEPLOYCHECK_EXECUTABLE)
add_custom_target(deploycheck
COMMAND ${MACDEPLOYCHECK_EXECUTABLE} strawberry.app
@@ -106,9 +45,8 @@ if(MACDEPLOYQT_EXECUTABLE)
endif()
if(CREATEDMG_EXECUTABLE)
add_custom_target(dmg
COMMAND ${CREATEDMG_EXECUTABLE} --volname strawberry --background "${CMAKE_SOURCE_DIR}/dist/macos/dmg_background.png" --app-drop-link 450 218 --icon strawberry.app 150 218 --window-size 600 450 ${CREATEDMG_SKIP_JENKINS_ARG} strawberry-${STRAWBERRY_VERSION_PACKAGE}-${CMAKE_HOST_SYSTEM_PROCESSOR}.dmg strawberry.app
COMMAND ${CREATEDMG_EXECUTABLE} --volname strawberry --background "${CMAKE_SOURCE_DIR}/dist/macos/dmg_background.png" --app-drop-link 450 218 --icon strawberry.app 150 218 --window-size 600 450 ${CREATEDMG_CODESIGN} ${CREATEDMG_SKIP_JENKINS_ARG} strawberry-${STRAWBERRY_VERSION_PACKAGE}-${CMAKE_HOST_SYSTEM_PROCESSOR}.dmg strawberry.app
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
DEPENDS deploy
)
endif()
endif()

View File

@@ -1,31 +0,0 @@
# Try to find RapidJSON (header-only).
#
# This project uses `find_package(RapidJSON)` and expects:
# - RapidJSON_FOUND
# - RapidJSON_INCLUDE_DIRS
#
# Homebrew's `rapidjson` formula commonly installs headers to:
# /opt/homebrew/include/rapidjson
# but does not always ship a `RapidJSONConfig.cmake`, so we provide this
# Find-module fallback.
find_path(RapidJSON_INCLUDE_DIR
NAMES rapidjson/document.h
)
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(RapidJSON
REQUIRED_VARS RapidJSON_INCLUDE_DIR
)
if(RapidJSON_FOUND)
set(RapidJSON_INCLUDE_DIRS "${RapidJSON_INCLUDE_DIR}")
endif()
if(RapidJSON_FOUND AND NOT TARGET RapidJSON::RapidJSON)
add_library(RapidJSON::RapidJSON INTERFACE IMPORTED)
set_target_properties(RapidJSON::RapidJSON PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES "${RapidJSON_INCLUDE_DIR}"
)
endif()

View File

@@ -1,23 +1,6 @@
set(summary_willbuild "")
set(summary_willnotbuild "")
# On some platforms (notably macOS via Homebrew), many "optional" dependencies are
# not installed by default. Historically, Strawberry treated missing optional deps
# as a hard error when the option defaulted to ON, which makes first-time builds
# frustrating.
#
# This toggle controls that behavior:
# - ON => missing optional deps abort the configure (packager/CI-friendly)
# - OFF => missing optional deps auto-disable the component (dev-friendly)
set(_optional_components_fatal_default ON)
if(APPLE)
set(_optional_components_fatal_default OFF)
endif()
option(OPTIONAL_COMPONENTS_MISSING_DEPS_ARE_FATAL
"If ON, missing optional component dependencies are fatal (otherwise components auto-disable)"
${_optional_components_fatal_default}
)
macro(optional_component_summary_add name test)
if (${test})
list(APPEND summary_willbuild ${name})
@@ -97,13 +80,8 @@ function(optional_component name default description)
set(text "${description} (missing ${deplist_text})")
set(summary_willnotbuild "${summary_willnotbuild};${text}" PARENT_SCOPE)
if(OPTIONAL_COMPONENTS_MISSING_DEPS_ARE_FATAL)
message(FATAL_ERROR "${text}, to disable this optional feature, pass -D${option_variable}=OFF to CMake")
else()
message(STATUS "${text} - disabling ${option_variable}")
set(${option_variable} OFF CACHE BOOL "${description}" FORCE)
return()
endif()
else()
set(${have_variable} ON PARENT_SCOPE)

View File

@@ -1,6 +1,6 @@
set(STRAWBERRY_VERSION_MAJOR 1)
set(STRAWBERRY_VERSION_MINOR 2)
set(STRAWBERRY_VERSION_PATCH 17)
set(STRAWBERRY_VERSION_PATCH 16)
#set(STRAWBERRY_VERSION_PRERELEASE rc1)
set(INCLUDE_GIT_REVISION ON)

View File

@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
tool="${1:-}"
shift || true
if [[ -z "$tool" ]]; then
echo "qt_tool_wrapper.sh: missing tool argument" >&2
exit 2
fi
base="$(basename "$tool")"
# Qt LinguistTools (lrelease) prints some noisy informational lines to stderr that
# are not actionable during normal builds (e.g. "Removed plural forms...").
# We filter only those specific messages.
if [[ "$base" == "lrelease" ]]; then
"$tool" "$@" 2>&1 | sed \
-e '/^Removed plural forms as the target language has less forms\.$/d' \
-e '/^If this sounds wrong, possibly the target language is not set or recognized\.$/d'
exit "${PIPESTATUS[0]}"
fi
exec "$tool" "$@"

20
dist/CMakeLists.txt vendored
View File

@@ -9,28 +9,8 @@ if(APPLE)
else()
set(LSMinimumSystemVersion 12.0)
endif()
if(BUILD_FOR_MAC_APP_STORE)
# MAS builds must not embed Sparkle update configuration in Info.plist.
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.mas.plist.in ${CMAKE_CURRENT_BINARY_DIR}/macos/Info.plist)
else()
# Sparkle (macOS updates)
# These values are embedded into Info.plist and control where the app checks for updates.
# Downstream builders can override on the CMake command line:
# -DSPARKLE_FEED_URL="https://example.com/appcast.xml"
# -DSPARKLE_PUBLIC_ED25519_KEY="base64=="
#
# Defaults preserve upstream behavior, but are intentionally configurable for third-party builds.
if(NOT DEFINED SPARKLE_FEED_URL)
set(SPARKLE_FEED_URL "https://www.strawberrymusicplayer.org/sparkle-macos-@ARCH@")
endif()
if(NOT DEFINED SPARKLE_PUBLIC_ED25519_KEY)
set(SPARKLE_PUBLIC_ED25519_KEY "/OydhYVfypuO2Mf7G6DUqVZWW9G19eFV74qaDCBTOUk=")
endif()
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist.in ${CMAKE_CURRENT_BINARY_DIR}/macos/Info.plist)
endif()
endif()
if(WIN32)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/windows/windres.rc.in ${CMAKE_BINARY_DIR}/windres.rc)

View File

@@ -1,253 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>strawberry</string>
<key>CFBundleGetInfoString</key>
<string>Strawberry ${STRAWBERRY_VERSION_DISPLAY}</string>
<key>CFBundleIconFile</key>
<string>strawberry.icns</string>
<key>CFBundleIdentifier</key>
<string>@MACOS_BUNDLE_ID@</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLongVersionString</key>
<string>${STRAWBERRY_VERSION_DISPLAY}</string>
<key>CFBundleName</key>
<string>Strawberry</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${STRAWBERRY_VERSION_DISPLAY}</string>
<key>CFBundleVersion</key>
<string>${STRAWBERRY_VERSION_PACKAGE}</string>
<key>CSResourcesFileMapped</key>
<true/>
<key>LSRequiresCarbon</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>@LSMinimumSystemVersion@</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>@MACOS_BUNDLE_ID@</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tidal</string>
</array>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeOSTypes</key>
<array>
<string>****</string>
<string>fold</string>
<string>disk</string>
</array>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>xspf</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>Generic.icns</string>
<key>CFBundleTypeMIMETypes</key>
<array>
<string>application/xspf+xml</string>
</array>
<key>CFBundleTypeName</key>
<string>XSPF Playlist</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>wav</string>
</array>
<key>CFBundleTypeMIMETypes</key>
<array>
<string>audio/x-wav</string>
</array>
<key>CFBundleTypeName</key>
<string>WAVE Audio File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>pls</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>pls.icns</string>
<key>CFBundleTypeName</key>
<string>Shoutcast playlist</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>m3u</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>m3u.icns</string>
<key>CFBundleTypeMIMETypes</key>
<array>
<string>audio/x-mpegurl</string>
</array>
<key>CFBundleTypeName</key>
<string>Playlist file</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>aac</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>mpeg4.icns</string>
<key>CFBundleTypeName</key>
<string>AAC file</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>ogg</string>
<string>ogx</string>
<string>ogm</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>ogg.icns</string>
<key>CFBundleTypeMIMETypes</key>
<array>
<string>audio/ogg</string>
</array>
<key>CFBundleTypeName</key>
<string>Ogg Vorbis File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>oga</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>ogg.icns</string>
<key>CFBundleTypeMIMETypes</key>
<array>
<string>audio/ogg</string>
</array>
<key>CFBundleTypeName</key>
<string>Ogg Audio File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>wma</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>wma.icns</string>
<key>CFBundleTypeName</key>
<string>WIndows Media Audio</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>mp3</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>mp3.icns</string>
<key>CFBundleTypeMIMETypes</key>
<array>
<string>audio/mpeg</string>
</array>
<key>CFBundleTypeName</key>
<string>MPEG Audio Layer 3</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>3gp</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>generic.icns</string>
<key>CFBundleTypeName</key>
<string>3GPP File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>m4a</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>mpeg4.icns</string>
<key>CFBundleTypeName</key>
<string>MPEG-4 Audio File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>mpc</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>generic.icns</string>
<key>CFBundleTypeName</key>
<string>Musepack Audio File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>flac</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>generic.icns</string>
<key>CFBundleTypeMIMETypes</key>
<array>
<string>audio/flac</string>
</array>
<key>CFBundleTypeName</key>
<string>FLAC Audio File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
</array>
</dict>
</plist>

View File

@@ -13,7 +13,7 @@
<key>CFBundleIconFile</key>
<string>strawberry.icns</string>
<key>CFBundleIdentifier</key>
<string>@MACOS_BUNDLE_ID@</string>
<string>org.strawberrymusicplayer.strawberry</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLongVersionString</key>
@@ -34,24 +34,17 @@
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>@LSMinimumSystemVersion@</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Default to manual update checks unless the user explicitly enables automatic checking. -->
<key>SUEnableAutomaticChecks</key>
<false/>
<key>SUAutomaticallyUpdate</key>
<false/>
<key>SUFeedURL</key>
<string>@SPARKLE_FEED_URL@</string>
<string>https://www.strawberrymusicplayer.org/sparkle-macos-@ARCH@</string>
<key>SUPublicEDKey</key>
<string>@SPARKLE_PUBLIC_ED25519_KEY@</string>
<string>/OydhYVfypuO2Mf7G6DUqVZWW9G19eFV74qaDCBTOUk=</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>@MACOS_BUNDLE_ID@</string>
<string>org.strawberrymusicplayer.strawberry</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tidal</string>

View File

@@ -1,85 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
bundledir="${1:-}"
sparkle_framework_dir="${2:-}"
sparkle_bin_link="${3:-}"
sparkle_bin_real="${4:-}"
if [[ -z "$bundledir" || -z "$sparkle_framework_dir" ]]; then
echo "Usage: $0 <bundledir> <sparkle_framework_dir> [sparkle_bin_link] [sparkle_bin_real]" >&2
exit 2
fi
if [[ ! -d "$sparkle_framework_dir" ]]; then
echo "Sparkle.framework dir not found: $sparkle_framework_dir" >&2
exit 1
fi
src_framework_dir="$sparkle_framework_dir"
# Homebrew often provides /opt/homebrew/Frameworks/Sparkle.framework where Versions/* are symlinks
# pointing back into the Cellar. Copying that verbatim breaks inside an app bundle.
# Resolve to the real Cellar framework root via Versions/Current.
if [[ -e "${sparkle_framework_dir}/Versions/Current" ]]; then
current_real="$(cd "${sparkle_framework_dir}/Versions/Current" && pwd -P)"
# current_real is .../Sparkle.framework/Versions/B (or similar)
src_framework_dir="$(cd "${current_real}/../.." && pwd -P)"
fi
dst_framework="${bundledir}/Contents/Frameworks/Sparkle.framework"
main_bin="${bundledir}/Contents/MacOS/strawberry"
qtsparkle_dylib="${bundledir}/Contents/Frameworks/libqtsparkle-qt6.dylib"
mkdir -p "${bundledir}/Contents/Frameworks"
echo "Bundling Sparkle.framework -> ${dst_framework}"
rm -rf "${dst_framework}"
# Use ditto to preserve the framework's internal symlinks/structure.
ditto "${src_framework_dir}" "${dst_framework}"
# Prefer the canonical framework binary path.
dst_bin="${dst_framework}/Versions/Current/Sparkle"
if [[ ! -e "${dst_bin}" ]]; then
echo "Error: Sparkle binary missing at ${dst_bin}" >&2
exit 1
fi
sparkle_rpath="@rpath/Sparkle.framework/Versions/Current/Sparkle"
# Sanity check: top-level Sparkle entry should be a symlink (not a copied Mach-O file).
if [[ -e "${dst_framework}/Sparkle" && ! -L "${dst_framework}/Sparkle" ]]; then
echo "Warning: ${dst_framework}/Sparkle is not a symlink (unexpected). This can confuse codesign." >&2
fi
echo "Fixing Sparkle.framework install name"
install_name_tool -id "${sparkle_rpath}" "${dst_bin}"
echo "Ensuring main binary has Frameworks rpath"
install_name_tool -add_rpath "@executable_path/../Frameworks" "${main_bin}" || true
echo "Rewriting Sparkle.framework references to @rpath"
# Try to rewrite a few common Homebrew Sparkle install names as well, because the
# recorded install name may differ from the path returned by CMake's find_library.
old_candidates=(
"${sparkle_bin_link}"
"${sparkle_bin_real}"
"/opt/homebrew/opt/sparkle-framework/Frameworks/Sparkle.framework/Versions/A/Sparkle"
"/opt/homebrew/opt/sparkle-framework/Frameworks/Sparkle.framework/Versions/B/Sparkle"
"/opt/homebrew/Frameworks/Sparkle.framework/Versions/A/Sparkle"
"/opt/homebrew/Frameworks/Sparkle.framework/Versions/B/Sparkle"
"/usr/local/opt/sparkle-framework/Frameworks/Sparkle.framework/Versions/A/Sparkle"
"/usr/local/opt/sparkle-framework/Frameworks/Sparkle.framework/Versions/B/Sparkle"
"/usr/local/Frameworks/Sparkle.framework/Versions/A/Sparkle"
"/usr/local/Frameworks/Sparkle.framework/Versions/B/Sparkle"
)
for old in "${old_candidates[@]}"; do
if [[ -n "${old}" ]]; then
install_name_tool -change "${old}" "${sparkle_rpath}" "${main_bin}" || true
if [[ -f "${qtsparkle_dylib}" ]]; then
install_name_tool -change "${old}" "${sparkle_rpath}" "${qtsparkle_dylib}" || true
fi
fi
done

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Enable the App Sandbox (required for Mac App Store). -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Strawberry is a client app that needs outbound network access (streaming/scrobbling/etc). -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Allow access to user-selected music folders/files (via NSOpenPanel security-scoped bookmarks). -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- If iPod classic / other device access is rejected, we'll adjust entitlements after App Review feedback. -->
</dict>
</plist>

View File

@@ -1,80 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Strawberry Music Player — Privacy Policy</title>
<style>
:root { color-scheme: light dark; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 24px; line-height: 1.45; }
main { max-width: 900px; margin: 0 auto; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
h1,h2 { line-height: 1.15; }
.muted { opacity: 0.75; }
ul { padding-left: 20px; }
</style>
</head>
<body>
<main>
<h1>Privacy Policy</h1>
<p class="muted">Last updated: 2026-01-22</p>
<h2>Summary</h2>
<ul>
<li><strong>No analytics / tracking</strong>: This app does not include advertising SDKs, analytics SDKs, or tracking pixels.</li>
<li><strong>No data selling</strong>: We do not sell personal data.</li>
<li><strong>Optional online features</strong>: If you enable online features (lyrics lookup, cover art search, scrobbling, streaming services, radio, update checks), the app will contact third-party services and send the minimum data needed to provide the feature.</li>
</ul>
<h2>What data is stored on your device</h2>
<p>Strawberry stores data locally on your device, such as:</p>
<ul>
<li>Library database and playlists (file paths, track metadata, play counts, ratings).</li>
<li>App settings and preferences.</li>
<li>Optional service credentials/tokens you configure (for example scrobbling or streaming accounts), stored locally.</li>
</ul>
<h2>What data is sent over the network (and when)</h2>
<p>Strawberry does not “phone home” just to run, but it will make network requests when you use or enable specific features. When the app contacts a third-party service, that service will receive standard network information such as your IP address, user-agent, and the request data described below.</p>
<h3>Album cover art search (optional)</h3>
<p>If you use album cover search (or enable “search automatically”), the app may send artist/album/track metadata to configured cover providers to find images.</p>
<h3>Lyrics lookup (optional)</h3>
<p>If you search for lyrics (or enable “search automatically”), the app may send artist/title/album/duration to configured lyrics providers to retrieve lyrics.</p>
<h3>Scrobbling (optional)</h3>
<p>If you enable scrobbling (for example Last.fm or ListenBrainz) the app sends “now playing” and/or listen history data to the configured scrobbling service, including track/artist/album metadata and timestamps. You can disable scrobbling at any time in Settings.</p>
<h3>Streaming services (optional)</h3>
<p>If you enable and sign into a streaming service (for example Tidal, Spotify, Qobuz, Subsonic-compatible servers), the app will communicate with that service to authenticate, browse, and play music. Requests may include account identifiers/tokens and media metadata required by the service.</p>
<h3>Internet radio (optional)</h3>
<p>If you use internet radio features, the app will contact the selected station/provider to retrieve station lists and stream audio.</p>
<h3>Discord Rich Presence (optional)</h3>
<p>If you enable Discord Rich Presence, the app shares currently playing track/artist/album information with the locally-running Discord client so it can be displayed on your Discord profile. You can disable this in Settings.</p>
<h3>Software updates</h3>
<p>The Mac App Store version of Strawberry is updated through Apples App Store.</p>
<h3>OAuth / local redirect server (optional)</h3>
<p>Some providers use an OAuth login flow that may open your browser and (in some cases) start a temporary local <code>http://localhost</code> redirect listener to complete authentication. This listener is local-only (not exposed on the internet) and only used during authentication. (Mac App Store builds may disable this flow.)</p>
<h2>Data sharing</h2>
<p>We do not share your personal data with third parties except as necessary to provide features you explicitly use or enable (for example, sending track metadata to a lyrics provider when you request lyrics).</p>
<h2>Your choices</h2>
<ul>
<li>Disable Discord Rich Presence in Settings.</li>
<li>Disable scrobbling services in Settings.</li>
<li>Disable automatic cover/lyrics searching in Settings.</li>
<li>Avoid signing into streaming services if you dont want those network requests.</li>
</ul>
<h2>Contact</h2>
<p>If you have questions about this policy, contact: <strong>privacy@dryark.com</strong></p>
</main>
</body>
</html>

View File

@@ -51,7 +51,6 @@
</screenshots>
<update_contact>eclipseo@fedoraproject.org</update_contact>
<releases>
<release version="1.2.17" date="2026-01-18"/>
<release version="1.2.16" date="2025-12-16"/>
<release version="1.2.15" date="2025-11-25"/>
<release version="1.2.14" date="2025-10-25"/>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" standalone='no'?><!--*-nxml-*-->
<?xml-stylesheet type="text/xsl" href="introspect.xsl"?>
<!DOCTYPE node SYSTEM "introspect.dtd">
<!--
This file is part of avahi.
avahi is free software; you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as
published by the Free Software Foundation; either version 2 of the
License, or (at your option) any later version.
avahi 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 Lesser General Public
License along with avahi; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
02111-1307 USA.
-->
<node>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect">
<arg name="data" type="s" direction="out"/>
</method>
</interface>
<interface name="org.freedesktop.Avahi.EntryGroup">
<method name="Free"/>
<method name="Commit"/>
<method name="Reset"/>
<method name="GetState">
<arg name="state" type="i" direction="out"/>
</method>
<signal name="StateChanged">
<arg name="state" type="i"/>
<arg name="error" type="s"/>
</signal>
<method name="IsEmpty">
<arg name="empty" type="b" direction="out"/>
</method>
<method name="AddService">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="host" type="s" direction="in"/>
<arg name="port" type="q" direction="in"/>
<arg name="txt" type="aay" direction="in"/>
</method>
<method name="AddServiceSubtype">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="subtype" type="s" direction="in"/>
</method>
<method name="UpdateServiceTxt">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="txt" type="aay" direction="in"/>
</method>
<method name="AddAddress">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="address" type="s" direction="in"/>
</method>
<method name="AddRecord">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="clazz" type="q" direction="in"/>
<arg name="type" type="q" direction="in"/>
<arg name="ttl" type="u" direction="in"/>
<arg name="rdata" type="ay" direction="in"/>
</method>
</interface>
</node>

View File

@@ -0,0 +1,405 @@
<?xml version="1.0" standalone='no'?><!--*-nxml-*-->
<?xml-stylesheet type="text/xsl" href="introspect.xsl"?>
<!DOCTYPE node SYSTEM "introspect.dtd">
<!--
This file is part of avahi.
avahi is free software; you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as
published by the Free Software Foundation; either version 2 of the
License, or (at your option) any later version.
avahi 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 Lesser General Public
License along with avahi; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
02111-1307 USA.
-->
<node>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect">
<arg name="data" type="s" direction="out"/>
</method>
</interface>
<interface name="org.freedesktop.Avahi.Server">
<method name="GetVersionString">
<arg name="version" type="s" direction="out"/>
</method>
<method name="GetAPIVersion">
<arg name="version" type="u" direction="out"/>
</method>
<method name="GetHostName">
<arg name="name" type="s" direction="out"/>
</method>
<method name="SetHostName">
<arg name="name" type="s" direction="in"/>
</method>
<method name="GetHostNameFqdn">
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetDomainName">
<arg name="name" type="s" direction="out"/>
</method>
<method name="IsNSSSupportAvailable">
<arg name="yes" type="b" direction="out"/>
</method>
<method name="GetState">
<arg name="state" type="i" direction="out"/>
</method>
<signal name="StateChanged">
<arg name="state" type="i"/>
<arg name="error" type="s"/>
</signal>
<method name="GetLocalServiceCookie">
<arg name="cookie" type="u" direction="out"/>
</method>
<method name="GetAlternativeHostName">
<arg name="name" type="s" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetAlternativeServiceName">
<arg name="name" type="s" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetNetworkInterfaceNameByIndex">
<arg name="index" type="i" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetNetworkInterfaceIndexByName">
<arg name="name" type="s" direction="in"/>
<arg name="index" type="i" direction="out"/>
</method>
<method name="ResolveHostName">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="ResolveAddress">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="address" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="ResolveService">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="type" type="s" direction="out"/>
<arg name="domain" type="s" direction="out"/>
<arg name="host" type="s" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="port" type="q" direction="out"/>
<arg name="txt" type="aay" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="EntryGroupNew">
<arg name="path" type="o" direction="out"/>
</method>
<method name="DomainBrowserNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="btype" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceTypeBrowserNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceBrowserNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceResolverNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="HostNameResolverNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="AddressResolverNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="address" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="RecordBrowserNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="clazz" type="q" direction="in"/>
<arg name="type" type="q" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
</interface>
<interface name="org.freedesktop.Avahi.Server2">
<method name="GetVersionString">
<arg name="version" type="s" direction="out"/>
</method>
<method name="GetAPIVersion">
<arg name="version" type="u" direction="out"/>
</method>
<method name="GetHostName">
<arg name="name" type="s" direction="out"/>
</method>
<method name="SetHostName">
<arg name="name" type="s" direction="in"/>
</method>
<method name="GetHostNameFqdn">
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetDomainName">
<arg name="name" type="s" direction="out"/>
</method>
<method name="IsNSSSupportAvailable">
<arg name="yes" type="b" direction="out"/>
</method>
<method name="GetState">
<arg name="state" type="i" direction="out"/>
</method>
<signal name="StateChanged">
<arg name="state" type="i"/>
<arg name="error" type="s"/>
</signal>
<method name="GetLocalServiceCookie">
<arg name="cookie" type="u" direction="out"/>
</method>
<method name="GetAlternativeHostName">
<arg name="name" type="s" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetAlternativeServiceName">
<arg name="name" type="s" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetNetworkInterfaceNameByIndex">
<arg name="index" type="i" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetNetworkInterfaceIndexByName">
<arg name="name" type="s" direction="in"/>
<arg name="index" type="i" direction="out"/>
</method>
<method name="ResolveHostName">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="ResolveAddress">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="address" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="ResolveService">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="type" type="s" direction="out"/>
<arg name="domain" type="s" direction="out"/>
<arg name="host" type="s" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="port" type="q" direction="out"/>
<arg name="txt" type="aay" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="EntryGroupNew">
<arg name="path" type="o" direction="out"/>
</method>
<method name="DomainBrowserPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="btype" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceTypeBrowserPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceBrowserPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceResolverPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="HostNameResolverPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="AddressResolverPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="address" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="RecordBrowserPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="clazz" type="q" direction="in"/>
<arg name="type" type="q" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
</interface>
</node>

114
src/avahi/avahi.cpp Normal file
View File

@@ -0,0 +1,114 @@
/*
* Strawberry Music Player
* Copyright 2024, 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 <QByteArray>
#include <QString>
#include <QDBusConnection>
#include <QDBusPendingReply>
#include <QDBusPendingCallWatcher>
#include "core/logging.h"
#include "avahi.h"
#include "avahi/avahiserver.h"
#include "avahi/avahientrygroup.h"
using namespace Qt::StringLiterals;
Avahi::Avahi(QObject *parent) : Zeroconf(parent), port_(0), entry_group_interface_(nullptr) {}
void Avahi::PublishInternal(const QString &domain, const QString &type, const QByteArray &name, quint16 port) {
domain_ = domain;
type_ = type;
name_ = name;
port_ = port;
OrgFreedesktopAvahiServerInterface server_interface(u"org.freedesktop.Avahi"_s, u"/"_s, QDBusConnection::systemBus());
QDBusPendingReply<QDBusObjectPath> reply = server_interface.EntryGroupNew();
QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply);
QObject::connect(watcher, &QDBusPendingCallWatcher::finished, this, &Avahi::PublishInternalFinished);
}
void Avahi::PublishInternalFinished(QDBusPendingCallWatcher *watcher) {
const QDBusPendingReply<QDBusObjectPath> path_reply = watcher->reply();
watcher->deleteLater();
if (path_reply.isError()) {
qLog(Error) << "Failed to create Avahi entry group:" << path_reply.error();
qLog(Info) << "This might be because 'disable-user-service-publishing'" << "is set to 'yes' in avahi-daemon.conf";
return;
}
AddService(path_reply.reply().path());
}
void Avahi::AddService(const QString &path) {
entry_group_interface_ = new OrgFreedesktopAvahiEntryGroupInterface(u"org.freedesktop.Avahi"_s, path, QDBusConnection::systemBus());
QDBusPendingReply<> reply = entry_group_interface_->AddService(-1, -1, 0, QString::fromUtf8(name_.constData(), name_.size()), type_, domain_, QString(), port_);
QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply);
QObject::connect(watcher, &QDBusPendingCallWatcher::finished, this, &Avahi::AddServiceFinished);
}
void Avahi::AddServiceFinished(QDBusPendingCallWatcher *watcher) {
const QDBusPendingReply<QDBusObjectPath> path_reply = watcher->reply();
watcher->deleteLater();
if (path_reply.isError()) {
qLog(Error) << "Failed to add Avahi service:" << path_reply.error();
return;
}
Commit();
}
void Avahi::Commit() {
QDBusPendingReply<> reply = entry_group_interface_->Commit();
QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply);
QObject::connect(watcher, &QDBusPendingCallWatcher::finished, this, &Avahi::CommitFinished);
}
void Avahi::CommitFinished(QDBusPendingCallWatcher *watcher) {
const QDBusPendingReply<QDBusObjectPath> path_reply = watcher->reply();
watcher->deleteLater();
entry_group_interface_->deleteLater();
entry_group_interface_ = nullptr;
if (path_reply.isError()) {
qLog(Debug) << "Commit error:" << path_reply.error();
}
else {
qLog(Debug) << "Remote interface published on Avahi";
}
}

58
src/avahi/avahi.h Normal file
View File

@@ -0,0 +1,58 @@
/*
* Strawberry Music Player
* Copyright 2024, 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 AVAHI_H
#define AVAHI_H
#include <QObject>
#include <QByteArray>
#include <QString>
#include "core/zeroconf.h"
class QDBusPendingCallWatcher;
class OrgFreedesktopAvahiEntryGroupInterface;
class Avahi : public Zeroconf {
Q_OBJECT
public:
explicit Avahi(QObject *parent = nullptr);
private:
void AddService(const QString &path);
void Commit();
private Q_SLOTS:
void PublishInternalFinished(QDBusPendingCallWatcher *watcher);
void AddServiceFinished(QDBusPendingCallWatcher *watcher);
void CommitFinished(QDBusPendingCallWatcher *watcher);
protected:
virtual void PublishInternal(const QString &domain, const QString &type, const QByteArray &name, quint16 port) override;
private:
QString domain_;
QString type_;
QByteArray name_;
quint16 port_;
OrgFreedesktopAvahiEntryGroupInterface *entry_group_interface_;
};
#endif // AVAHI_H

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" standalone='no'?><!--*-nxml-*-->
<!DOCTYPE node SYSTEM "introspect.dtd">
<!--
This file is part of avahi.
avahi is free software; you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as
published by the Free Software Foundation; either version 2 of the
License, or (at your option) any later version.
avahi 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 Lesser General Public
License along with avahi; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
02111-1307 USA.
-->
<node>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect">
<arg name="data" type="s" direction="out"/>
</method>
</interface>
<interface name="org.freedesktop.Avahi.EntryGroup">
<method name="Free"/>
<method name="Commit"/>
<method name="Reset"/>
<method name="GetState">
<arg name="state" type="i" direction="out"/>
</method>
<signal name="StateChanged">
<arg name="state" type="i"/>
<arg name="error" type="s"/>
</signal>
<method name="IsEmpty">
<arg name="empty" type="b" direction="out"/>
</method>
<method name="AddService">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="host" type="s" direction="in"/>
<arg name="port" type="q" direction="in"/>
</method>
<method name="AddServiceSubtype">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="subtype" type="s" direction="in"/>
</method>
<method name="UpdateServiceTxt">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
</method>
<method name="AddAddress">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="address" type="s" direction="in"/>
</method>
<method name="AddRecord">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="clazz" type="q" direction="in"/>
<arg name="type" type="q" direction="in"/>
<arg name="ttl" type="u" direction="in"/>
<arg name="rdata" type="ay" direction="in"/>
</method>
</interface>
</node>

View File

@@ -0,0 +1,396 @@
<?xml version="1.0" standalone='no'?><!--*-nxml-*-->
<!DOCTYPE node SYSTEM "introspect.dtd">
<!--
This file is part of avahi.
avahi is free software; you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as
published by the Free Software Foundation; either version 2 of the
License, or (at your option) any later version.
avahi 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 Lesser General Public
License along with avahi; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
02111-1307 USA.
-->
<node>
<interface name="org.freedesktop.Avahi.Server">
<method name="GetVersionString">
<arg name="version" type="s" direction="out"/>
</method>
<method name="GetAPIVersion">
<arg name="version" type="u" direction="out"/>
</method>
<method name="GetHostName">
<arg name="name" type="s" direction="out"/>
</method>
<method name="SetHostName">
<arg name="name" type="s" direction="in"/>
</method>
<method name="GetHostNameFqdn">
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetDomainName">
<arg name="name" type="s" direction="out"/>
</method>
<method name="IsNSSSupportAvailable">
<arg name="yes" type="b" direction="out"/>
</method>
<method name="GetState">
<arg name="state" type="i" direction="out"/>
</method>
<signal name="StateChanged">
<arg name="state" type="i"/>
<arg name="error" type="s"/>
</signal>
<method name="GetLocalServiceCookie">
<arg name="cookie" type="u" direction="out"/>
</method>
<method name="GetAlternativeHostName">
<arg name="name" type="s" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetAlternativeServiceName">
<arg name="name" type="s" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetNetworkInterfaceNameByIndex">
<arg name="index" type="i" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetNetworkInterfaceIndexByName">
<arg name="name" type="s" direction="in"/>
<arg name="index" type="i" direction="out"/>
</method>
<method name="ResolveHostName">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="ResolveAddress">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="address" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="ResolveService">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="type" type="s" direction="out"/>
<arg name="domain" type="s" direction="out"/>
<arg name="host" type="s" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="port" type="q" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="EntryGroupNew">
<arg name="path" type="o" direction="out"/>
</method>
<method name="DomainBrowserNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="btype" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceTypeBrowserNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceBrowserNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceResolverNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="HostNameResolverNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="AddressResolverNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="address" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="RecordBrowserNew">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="clazz" type="q" direction="in"/>
<arg name="type" type="q" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
</interface>
<interface name="org.freedesktop.Avahi.Server2">
<method name="GetVersionString">
<arg name="version" type="s" direction="out"/>
</method>
<method name="GetAPIVersion">
<arg name="version" type="u" direction="out"/>
</method>
<method name="GetHostName">
<arg name="name" type="s" direction="out"/>
</method>
<method name="SetHostName">
<arg name="name" type="s" direction="in"/>
</method>
<method name="GetHostNameFqdn">
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetDomainName">
<arg name="name" type="s" direction="out"/>
</method>
<method name="IsNSSSupportAvailable">
<arg name="yes" type="b" direction="out"/>
</method>
<method name="GetState">
<arg name="state" type="i" direction="out"/>
</method>
<signal name="StateChanged">
<arg name="state" type="i"/>
<arg name="error" type="s"/>
</signal>
<method name="GetLocalServiceCookie">
<arg name="cookie" type="u" direction="out"/>
</method>
<method name="GetAlternativeHostName">
<arg name="name" type="s" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetAlternativeServiceName">
<arg name="name" type="s" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetNetworkInterfaceNameByIndex">
<arg name="index" type="i" direction="in"/>
<arg name="name" type="s" direction="out"/>
</method>
<method name="GetNetworkInterfaceIndexByName">
<arg name="name" type="s" direction="in"/>
<arg name="index" type="i" direction="out"/>
</method>
<method name="ResolveHostName">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="ResolveAddress">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="address" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="ResolveService">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="interface" type="i" direction="out"/>
<arg name="protocol" type="i" direction="out"/>
<arg name="name" type="s" direction="out"/>
<arg name="type" type="s" direction="out"/>
<arg name="domain" type="s" direction="out"/>
<arg name="host" type="s" direction="out"/>
<arg name="aprotocol" type="i" direction="out"/>
<arg name="address" type="s" direction="out"/>
<arg name="port" type="q" direction="out"/>
<arg name="flags" type="u" direction="out"/>
</method>
<method name="EntryGroupNew">
<arg name="path" type="o" direction="out"/>
</method>
<method name="DomainBrowserPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="btype" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceTypeBrowserPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceBrowserPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="ServiceResolverPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="type" type="s" direction="in"/>
<arg name="domain" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="HostNameResolverPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="aprotocol" type="i" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="AddressResolverPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="address" type="s" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
<method name="RecordBrowserPrepare">
<arg name="interface" type="i" direction="in"/>
<arg name="protocol" type="i" direction="in"/>
<arg name="name" type="s" direction="in"/>
<arg name="clazz" type="q" direction="in"/>
<arg name="type" type="q" direction="in"/>
<arg name="flags" type="u" direction="in"/>
<arg name="path" type="o" direction="out"/>
</method>
</interface>
</node>

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2026, 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
@@ -537,6 +537,18 @@ void CollectionBackend::AddOrUpdateSubdirs(const CollectionSubdirectoryList &sub
ScopedTransaction transaction(&db);
for (const CollectionSubdirectory &subdir : subdirs) {
if (subdir.mtime == 0) {
// Delete the subdirectory
SqlQuery q(db);
q.prepare(QStringLiteral("DELETE FROM %1 WHERE directory_id = :id AND path = :path").arg(subdirs_table_));
q.BindValue(u":id"_s, subdir.directory_id);
q.BindValue(u":path"_s, subdir.path);
if (!q.Exec()) {
db_->ReportErrors(q);
return;
}
}
else {
// See if this subdirectory already exists in the database
bool exists = false;
{
@@ -574,26 +586,6 @@ void CollectionBackend::AddOrUpdateSubdirs(const CollectionSubdirectoryList &sub
}
}
}
transaction.Commit();
}
void CollectionBackend::DeleteSubdirs(const CollectionSubdirectoryList &subdirs) {
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
ScopedTransaction transaction(&db);
for (const CollectionSubdirectory &subdir : subdirs) {
SqlQuery q(db);
q.prepare(QStringLiteral("DELETE FROM %1 WHERE directory_id = :id AND path = :path").arg(subdirs_table_));
q.BindValue(u":id"_s, subdir.directory_id);
q.BindValue(u":path"_s, subdir.path);
if (!q.Exec()) {
db_->ReportErrors(q);
return;
}
}
transaction.Commit();

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2026, 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
@@ -252,7 +252,6 @@ class CollectionBackend : public CollectionBackendInterface {
void DeleteSongsByUrls(const QList<QUrl> &url);
void MarkSongsUnavailable(const SongList &songs, const bool unavailable = true);
void AddOrUpdateSubdirs(const CollectionSubdirectoryList &subdirs);
void DeleteSubdirs(const CollectionSubdirectoryList &subdirs);
void CompilationsNeedUpdating();
void UpdateEmbeddedAlbumArt(const QString &effective_albumartist, const QString &album, const bool art_embedded);
void UpdateManualAlbumArt(const QString &effective_albumartist, const QString &album, const QUrl &art_manual);

View File

@@ -124,7 +124,6 @@ void CollectionLibrary::Init() {
QObject::connect(watcher_, &CollectionWatcher::SongsReadded, &*backend_, &CollectionBackend::MarkSongsUnavailable);
QObject::connect(watcher_, &CollectionWatcher::SubdirsDiscovered, &*backend_, &CollectionBackend::AddOrUpdateSubdirs);
QObject::connect(watcher_, &CollectionWatcher::SubdirsMTimeUpdated, &*backend_, &CollectionBackend::AddOrUpdateSubdirs);
QObject::connect(watcher_, &CollectionWatcher::SubdirsDeleted, &*backend_, &CollectionBackend::DeleteSubdirs);
QObject::connect(watcher_, &CollectionWatcher::CompilationsNeedUpdating, &*backend_, &CollectionBackend::CompilationsNeedUpdating);
QObject::connect(watcher_, &CollectionWatcher::UpdateLastSeen, &*backend_, &CollectionBackend::UpdateLastSeen);

View File

@@ -383,7 +383,7 @@ void CollectionView::keyPressEvent(QKeyEvent *e) {
case Qt::Key_Enter:
case Qt::Key_Return:
if (currentIndex().isValid()) {
Q_EMIT doubleClicked(currentIndex());
AddToPlaylist();
}
e->accept();
break;

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2026, 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
@@ -75,7 +75,7 @@
using namespace std::chrono_literals;
using namespace Qt::Literals::StringLiterals;
QStringList CollectionWatcher::sValidImages = QStringList() << u"jpg"_s << u"jpeg"_s << u"jp2"_s << u"png"_s << u"gif"_s << u"tiff"_s << u"tif"_s << u"webp"_s;
QStringList CollectionWatcher::sValidImages = QStringList() << u"jpg"_s << u"png"_s << u"gif"_s << u"jpeg"_s;
CollectionWatcher::CollectionWatcher(const Song::Source source,
const SharedPtr<TaskManager> task_manager,
@@ -261,7 +261,7 @@ void CollectionWatcher::ReloadSettings() {
CollectionWatcher::ScanTransaction::ScanTransaction(CollectionWatcher *watcher, const int dir, const bool incremental, const bool ignores_mtime, const bool mark_songs_unavailable)
: progress_(0),
progress_max_(0),
dir_id_(dir),
dir_(dir),
incremental_(incremental),
ignores_mtime_(ignores_mtime),
mark_songs_unavailable_(mark_songs_unavailable),
@@ -313,19 +313,6 @@ void CollectionWatcher::ScanTransaction::AddToProgressMax(const quint64 n) {
void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() {
if (!deleted_subdirs.isEmpty()) {
Q_EMIT watcher_->SubdirsDeleted(deleted_subdirs);
}
if (!new_subdirs.isEmpty()) {
Q_EMIT watcher_->SubdirsDiscovered(new_subdirs);
}
if (!touched_subdirs.isEmpty()) {
Q_EMIT watcher_->SubdirsMTimeUpdated(touched_subdirs);
touched_subdirs.clear();
}
if (!deleted_songs.isEmpty()) {
if (mark_songs_unavailable_ && watcher_->source() == Song::Source::Collection) {
Q_EMIT watcher_->SongsUnavailable(deleted_songs);
@@ -351,24 +338,34 @@ void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() {
readded_songs.clear();
}
if (!new_subdirs.isEmpty()) {
Q_EMIT watcher_->SubdirsDiscovered(new_subdirs);
}
if (!touched_subdirs.isEmpty()) {
Q_EMIT watcher_->SubdirsMTimeUpdated(touched_subdirs);
touched_subdirs.clear();
}
for (const CollectionSubdirectory &subdir : std::as_const(deleted_subdirs)) {
if (watcher_->watched_dirs_.contains(dir_id_)) {
watcher_->RemoveWatch(watcher_->watched_dirs_[dir_id_], subdir);
if (watcher_->watched_dirs_.contains(dir_)) {
watcher_->RemoveWatch(watcher_->watched_dirs_[dir_], subdir);
}
}
deleted_subdirs.clear();
if (watcher_->monitor_) {
// Watch the new subdirectories
for (const CollectionSubdirectory &subdir : std::as_const(new_subdirs)) {
if (watcher_->watched_dirs_.contains(dir_id_)) {
watcher_->AddWatch(watcher_->watched_dirs_[dir_id_], subdir.path);
if (watcher_->watched_dirs_.contains(dir_)) {
watcher_->AddWatch(watcher_->watched_dirs_[dir_], subdir.path);
}
}
}
new_subdirs.clear();
if (incremental_ || ignores_mtime_) {
Q_EMIT watcher_->UpdateLastSeen(dir_id_, expire_unavailable_songs_days_);
Q_EMIT watcher_->UpdateLastSeen(dir_, expire_unavailable_songs_days_);
}
}
@@ -377,7 +374,7 @@ void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() {
SongList CollectionWatcher::ScanTransaction::FindSongsInSubdirectory(const QString &path) {
if (cached_songs_dirty_) {
const SongList songs = watcher_->backend_->FindSongsInDirectory(dir_id_);
const SongList songs = watcher_->backend_->FindSongsInDirectory(dir_);
for (const Song &song : songs) {
const QString p = song.url().toLocalFile().section(u'/', 0, -2);
cached_songs_.insert(p, song);
@@ -396,7 +393,7 @@ SongList CollectionWatcher::ScanTransaction::FindSongsInSubdirectory(const QStri
bool CollectionWatcher::ScanTransaction::HasSongsWithMissingFingerprint(const QString &path) {
if (cached_songs_missing_fingerprint_dirty_) {
const SongList songs = watcher_->backend_->SongsWithMissingFingerprint(dir_id_);
const SongList songs = watcher_->backend_->SongsWithMissingFingerprint(dir_);
for (const Song &song : songs) {
const QString p = song.url().toLocalFile().section(u'/', 0, -2);
cached_songs_missing_fingerprint_.insert(p, song);
@@ -411,7 +408,7 @@ bool CollectionWatcher::ScanTransaction::HasSongsWithMissingFingerprint(const QS
bool CollectionWatcher::ScanTransaction::HasSongsWithMissingLoudnessCharacteristics(const QString &path) {
if (cached_songs_missing_loudness_characteristics_dirty_) {
const SongList songs = watcher_->backend_->SongsWithMissingLoudnessCharacteristics(dir_id_);
const SongList songs = watcher_->backend_->SongsWithMissingLoudnessCharacteristics(dir_);
for (const Song &song : songs) {
const QString p = song.url().toLocalFile().section(u'/', 0, -2);
cached_songs_missing_loudness_characteristics_.insert(p, song);
@@ -433,7 +430,7 @@ void CollectionWatcher::ScanTransaction::SetKnownSubdirs(const CollectionSubdire
bool CollectionWatcher::ScanTransaction::HasSeenSubdir(const QString &path) {
if (known_subdirs_dirty_) {
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_id_));
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_));
}
return std::any_of(known_subdirs_.begin(), known_subdirs_.end(), [path](const CollectionSubdirectory &subdir) { return subdir.path == path && subdir.mtime != 0; });
@@ -443,7 +440,7 @@ bool CollectionWatcher::ScanTransaction::HasSeenSubdir(const QString &path) {
CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetImmediateSubdirs(const QString &path) {
if (known_subdirs_dirty_) {
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_id_));
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_));
}
CollectionSubdirectoryList ret;
@@ -460,7 +457,7 @@ CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetImmediateSubdi
CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetAllSubdirs() {
if (known_subdirs_dirty_) {
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_id_));
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_));
}
return known_subdirs_;
@@ -497,7 +494,7 @@ void CollectionWatcher::AddDirectory(const CollectionDirectory &dir, const Colle
const quint64 files_count = FilesCountForPath(&transaction, dir.path);
transaction.SetKnownSubdirs(subdirs);
transaction.AddToProgressMax(files_count);
ScanSubdirectory(dir, dir.path, CollectionSubdirectory(), files_count, &transaction);
ScanSubdirectory(dir.path, CollectionSubdirectory(), files_count, &transaction);
last_scan_time_ = QDateTime::currentSecsSinceEpoch();
}
else {
@@ -515,7 +512,7 @@ void CollectionWatcher::AddDirectory(const CollectionDirectory &dir, const Colle
transaction.AddToProgressMax(files_count);
for (const CollectionSubdirectory &subdir : subdirs) {
if (stop_or_abort_requested()) break;
ScanSubdirectory(dir, subdir.path, subdir, subdir_files_count[subdir.path], &transaction);
ScanSubdirectory(subdir.path, subdir, subdir_files_count[subdir.path], &transaction);
}
if (!stop_or_abort_requested()) {
last_scan_time_ = QDateTime::currentSecsSinceEpoch();
@@ -527,10 +524,9 @@ void CollectionWatcher::AddDirectory(const CollectionDirectory &dir, const Colle
}
void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, ScanTransaction *t, const bool force_noincremental) {
void CollectionWatcher::ScanSubdirectory(const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, ScanTransaction *t, const bool force_noincremental) {
const QFileInfo path_info(path);
const qint64 path_mtime = path_info.exists() && path_info.lastModified().isValid() ? path_info.lastModified().toSecsSinceEpoch() : 0;
if (path_info.isSymLink()) {
const QString real_path = path_info.symLinkTarget();
@@ -540,8 +536,8 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
return;
}
// Do not scan symlinked dirs that are already in collection
for (const CollectionDirectory &i : std::as_const(watched_dirs_)) {
if (real_path.startsWith(i.path)) {
for (const CollectionDirectory &dir : std::as_const(watched_dirs_)) {
if (real_path.startsWith(dir.path)) {
return;
}
}
@@ -567,7 +563,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
}
#endif
if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && path_mtime != 0 && subdir.mtime == path_mtime && !songs_missing_fingerprint && !songs_missing_loudness_characteristics) {
if (!t->ignores_mtime() && !force_noincremental && t->is_incremental() && subdir.mtime == path_info.lastModified().toSecsSinceEpoch() && !songs_missing_fingerprint && !songs_missing_loudness_characteristics) {
// The directory hasn't changed since last time
t->AddToProgress(files_count);
return;
@@ -582,12 +578,11 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const CollectionSubdirectoryList previous_subdirs = t->GetImmediateSubdirs(path);
for (const CollectionSubdirectory &prev_subdir : previous_subdirs) {
if (!QFile::exists(prev_subdir.path) && prev_subdir.path != path) {
ScanSubdirectory(dir, prev_subdir.path, prev_subdir, 0, t, true);
ScanSubdirectory(prev_subdir.path, prev_subdir, 0, t, true);
}
}
// First we "quickly" get a list of the files in the directory that we think might be music. While we're here, we also look for new subdirectories and possible album artwork.
if (path_info.exists()) {
QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
while (it.hasNext()) {
@@ -597,7 +592,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const QFileInfo child_fileinfo(child_filepath);
if (child_fileinfo.isSymLink()) {
const QStorageInfo storage_info(child_fileinfo.symLinkTarget());
QStorageInfo storage_info(child_fileinfo.symLinkTarget());
if (kRejectedFileSystems.contains(storage_info.fileSystemType())) {
qLog(Warning) << "Ignoring symbolic link" << child_filepath << "which links to" << child_fileinfo.symLinkTarget() << "with rejected filesystem type" << storage_info.fileSystemType();
continue;
@@ -610,14 +605,14 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
CollectionSubdirectory new_subdir;
new_subdir.directory_id = -1;
new_subdir.path = child_filepath;
new_subdir.mtime = child_fileinfo.exists() && child_fileinfo.lastModified().isValid() ? child_fileinfo.lastModified().toSecsSinceEpoch() : 0;
new_subdir.mtime = child_fileinfo.lastModified().toSecsSinceEpoch();
my_new_subdirs << new_subdir;
}
t->AddToProgress(1);
}
else {
const QString ext_part = ExtensionPart(child_filepath);
const QString dir_part = DirectoryPart(child_filepath);
QString ext_part(ExtensionPart(child_filepath));
QString dir_part(DirectoryPart(child_filepath));
if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) {
t->AddToProgress(1);
}
@@ -625,9 +620,11 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
album_art[dir_part] << child_filepath;
t->AddToProgress(1);
}
else {
else if (tagreader_client_->IsMediaFileBlocking(child_filepath)) {
files_on_disk << child_filepath;
}
else {
t->AddToProgress(1);
}
}
}
@@ -635,27 +632,27 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
if (stop_or_abort_requested()) return;
// Ask the database for a list of files in this directory
const SongList songs_in_db = t->FindSongsInSubdirectory(path);
SongList songs_in_db = t->FindSongsInSubdirectory(path);
QSet<QString> cues_processed;
// Now compare the list from the database with the list of files on disk
const QStringList files_on_disk_copy = files_on_disk;
QStringList files_on_disk_copy = files_on_disk;
for (const QString &file : files_on_disk_copy) {
if (stop_or_abort_requested()) return;
// Associated CUE
const QString new_cue = CueParser::FindCueFilename(file);
QString new_cue = CueParser::FindCueFilename(file);
SongList matching_songs;
if (FindSongsByPath(songs_in_db, file, &matching_songs)) { // Found matching song in DB by path.
const Song matching_song = matching_songs.first();
Song matching_song = matching_songs.first();
// The song is in the database and still on disk.
// Check the mtime to see if it's been changed since it was added.
const QFileInfo fileinfo(file);
QFileInfo fileinfo(file);
if (!fileinfo.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now.
@@ -730,9 +727,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
#endif
if (new_cue.isEmpty() || new_cue_mtime == 0) { // If no CUE or it's about to lose it.
if (!UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, art_automatic, cue_deleted, t)) {
files_on_disk.removeAll(file);
}
UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, art_automatic, cue_deleted, t);
}
else { // If CUE associated.
UpdateCueAssociatedSongs(file, path, fingerprint, new_cue, art_automatic, matching_songs, t);
@@ -755,7 +750,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
// The song is in the database and still on disk.
// Check the mtime to see if it's been changed since it was added.
const QFileInfo fileinfo(file);
QFileInfo fileinfo(file);
if (!fileinfo.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now.
files_on_disk.removeAll(file);
@@ -766,7 +761,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
// Make sure the songs aren't deleted, as they still exist elsewhere with a different file path.
bool matching_songs_has_cue = false;
for (const Song &matching_song : std::as_const(matching_songs)) {
const QString matching_filename = matching_song.url().toLocalFile();
QString matching_filename = matching_song.url().toLocalFile();
if (!t->files_changed_path_.contains(matching_filename)) {
t->files_changed_path_ << matching_filename;
qLog(Debug) << matching_filename << "has changed path to" << file;
@@ -789,9 +784,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const QUrl art_automatic = ArtForSong(file, album_art);
if (new_cue.isEmpty() || new_cue_mtime == 0) { // If no CUE or it's about to lose it.
if (!UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, art_automatic, matching_songs_has_cue && new_cue_mtime == 0, t)) {
files_on_disk.removeAll(file);
}
UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, art_automatic, matching_songs_has_cue && new_cue_mtime == 0, t);
}
else { // If CUE associated.
UpdateCueAssociatedSongs(file, path, fingerprint, new_cue, art_automatic, matching_songs, t);
@@ -802,7 +795,6 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const SongList songs = ScanNewFile(file, path, fingerprint, new_cue, &cues_processed);
if (songs.isEmpty()) {
files_on_disk.removeAll(file);
t->AddToProgress(1);
continue;
}
@@ -813,7 +805,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const QUrl art_automatic = ArtForSong(file, album_art);
for (Song song : songs) {
song.set_directory_id(t->dir_id());
song.set_directory_id(t->dir());
if (song.art_automatic().isEmpty()) song.set_art_automatic(art_automatic);
t->new_songs << song;
}
@@ -831,26 +823,27 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
}
}
// Add, update or delete subdir
// Add this subdir to the new or touched list
CollectionSubdirectory updated_subdir;
updated_subdir.directory_id = t->dir_id();
updated_subdir.mtime = path_mtime;
updated_subdir.directory_id = t->dir();
updated_subdir.mtime = path_info.exists() ? path_info.lastModified().toSecsSinceEpoch() : 0;
updated_subdir.path = path;
if (!path_info.exists() && updated_subdir.path != dir.path) {
t->deleted_subdirs << updated_subdir;
}
else if (subdir.directory_id == -1) {
if (subdir.directory_id == -1) {
t->new_subdirs << updated_subdir;
}
else if (subdir.mtime != updated_subdir.mtime) {
else {
t->touched_subdirs << updated_subdir;
}
if (updated_subdir.mtime == 0) { // CollectionSubdirectory deleted, mark it for removal from the watcher.
t->deleted_subdirs << updated_subdir;
}
// Recurse into the new subdirs that we found
for (const CollectionSubdirectory &my_new_subdir : std::as_const(my_new_subdirs)) {
if (stop_or_abort_requested()) return;
ScanSubdirectory(dir, my_new_subdir.path, my_new_subdir, 0, t, true);
ScanSubdirectory(my_new_subdir.path, my_new_subdir, 0, t, true);
}
}
@@ -882,7 +875,7 @@ void CollectionWatcher::UpdateCueAssociatedSongs(const QString &file,
QSet<int> used_ids;
for (Song new_cue_song : songs) {
new_cue_song.set_source(source_);
new_cue_song.set_directory_id(t->dir_id());
new_cue_song.set_directory_id(t->dir());
PerformEBUR128Analysis(new_cue_song);
new_cue_song.set_fingerprint(fingerprint);
@@ -908,7 +901,7 @@ void CollectionWatcher::UpdateCueAssociatedSongs(const QString &file,
}
bool CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file,
void CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file,
const QString &fingerprint,
const SongList &matching_songs,
const QUrl &art_automatic,
@@ -929,7 +922,7 @@ bool CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file,
const TagReaderResult result = tagreader_client_->ReadFileBlocking(file, &song_on_disk);
if (result.success() && song_on_disk.is_valid()) {
song_on_disk.set_source(source_);
song_on_disk.set_directory_id(t->dir_id());
song_on_disk.set_directory_id(t->dir());
song_on_disk.set_id(matching_song.id());
PerformEBUR128Analysis(song_on_disk);
song_on_disk.set_fingerprint(fingerprint);
@@ -938,8 +931,6 @@ bool CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file,
AddChangedSong(file, matching_song, song_on_disk, t);
}
return result.success() && song_on_disk.is_valid();
}
SongList CollectionWatcher::ScanNewFile(const QString &file, const QString &path, const QString &fingerprint, const QString &matching_cue, QSet<QString> *cues_processed) const {
@@ -1208,13 +1199,12 @@ void CollectionWatcher::DirectoryChanged(const QString &subdir) {
void CollectionWatcher::RescanPathsNow() {
const QList<int> dir_ids = rescan_queue_.keys();
for (const int dir_id : dir_ids) {
const QList<int> dirs = rescan_queue_.keys();
for (const int dir : dirs) {
if (stop_or_abort_requested()) break;
ScanTransaction transaction(this, dir_id, false, false, mark_songs_unavailable_);
ScanTransaction transaction(this, dir, false, false, mark_songs_unavailable_);
const QStringList paths = rescan_queue_.value(dir_id);
const QStringList paths = rescan_queue_.value(dir);
QMap<QString, quint64> subdir_files_count;
for (const QString &path : paths) {
@@ -1225,14 +1215,11 @@ void CollectionWatcher::RescanPathsNow() {
for (const QString &path : paths) {
if (stop_or_abort_requested()) break;
if (!subdir_mapping_.contains(path)) {
continue;
}
CollectionSubdirectory subdir;
subdir.directory_id = dir_id;
subdir.directory_id = dir;
subdir.mtime = 0;
subdir.path = path;
ScanSubdirectory(subdir_mapping_[path], path, subdir, subdir_files_count[path], &transaction);
ScanSubdirectory(path, subdir, subdir_files_count[path], &transaction);
}
}
@@ -1357,13 +1344,11 @@ void CollectionWatcher::PerformScan(const bool incremental, const bool ignore_mt
ScanTransaction transaction(this, dir.id, incremental, ignore_mtimes, mark_songs_unavailable_);
CollectionSubdirectoryList subdirs = transaction.GetAllSubdirs();
const bool has_collection_root_dir = std::any_of(subdirs.begin(), subdirs.end(), [&dir](const CollectionSubdirectory &subdir) { return subdir.path == dir.path; });
if (!has_collection_root_dir) {
qLog(Debug) << "Collection directory wasn't in subdir list, re-adding";
if (subdirs.isEmpty()) {
qLog(Debug) << "Collection directory wasn't in subdir list.";
CollectionSubdirectory subdir;
subdir.directory_id = dir.id;
subdir.path = dir.path;
subdir.mtime = 0;
subdir.directory_id = dir.id;
subdirs << subdir;
}
@@ -1373,7 +1358,7 @@ void CollectionWatcher::PerformScan(const bool incremental, const bool ignore_mt
for (const CollectionSubdirectory &subdir : std::as_const(subdirs)) {
if (stop_or_abort_requested()) break;
ScanSubdirectory(dir, subdir.path, subdir, subdir_files_count[subdir.path], &transaction);
ScanSubdirectory(subdir.path, subdir, subdir_files_count[subdir.path], &transaction);
}
}
@@ -1474,8 +1459,6 @@ void CollectionWatcher::RescanSongs(const SongList &songs) {
QStringList scanned_paths;
for (const Song &song : songs) {
if (stop_or_abort_requested()) break;
if (!watched_dirs_.contains(song.directory_id())) continue;
const CollectionDirectory dir = watched_dirs_[song.directory_id()];
const QString song_path = song.url().toLocalFile().section(u'/', 0, -2);
if (scanned_paths.contains(song_path)) continue;
ScanTransaction transaction(this, song.directory_id(), false, true, mark_songs_unavailable_);
@@ -1485,7 +1468,7 @@ void CollectionWatcher::RescanSongs(const SongList &songs) {
if (subdir.path != song_path) continue;
qLog(Debug) << "Rescan for directory ID" << song.directory_id() << "directory" << subdir.path;
const quint64 files_count = FilesCountForPath(&transaction, subdir.path);
ScanSubdirectory(dir, song_path, subdir, files_count, &transaction);
ScanSubdirectory(song_path, subdir, files_count, &transaction);
scanned_paths << subdir.path;
}
}

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2026, 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
@@ -85,7 +85,6 @@ class CollectionWatcher : public QObject {
void SongsReadded(const SongList &songs, const bool unavailable = false);
void SubdirsDiscovered(const CollectionSubdirectoryList &subdirs);
void SubdirsMTimeUpdated(const CollectionSubdirectoryList &subdirs);
void SubdirsDeleted(const CollectionSubdirectoryList &subdirs);
void CompilationsNeedUpdating();
void UpdateLastSeen(const int directory_id, const int expire_unavailable_songs_days);
void ExitFinished();
@@ -123,7 +122,7 @@ class CollectionWatcher : public QObject {
// Emits the signals for new & deleted songs etc and clears the lists. This causes the new stuff to be updated on UI.
void CommitNewOrUpdatedSongs();
int dir_id() const { return dir_id_; }
int dir() const { return dir_; }
bool is_incremental() const { return incremental_; }
bool ignores_mtime() const { return ignores_mtime_; }
@@ -144,7 +143,7 @@ class CollectionWatcher : public QObject {
quint64 progress_;
quint64 progress_max_;
int dir_id_;
int dir_;
// Incremental scan enters a directory only if it has changed since the last scan.
bool incremental_;
// This type of scan updates every file in a folder that's being scanned.
@@ -180,7 +179,7 @@ class CollectionWatcher : public QObject {
void IncrementalScanNow();
void FullScanNow();
void RescanPathsNow();
void ScanSubdirectory(const CollectionDirectory &dir, const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, CollectionWatcher::ScanTransaction *t, const bool force_noincremental = false);
void ScanSubdirectory(const QString &path, const CollectionSubdirectory &subdir, const quint64 files_count, CollectionWatcher::ScanTransaction *t, const bool force_noincremental = false);
void RescanSongs(const SongList &songs);
private:
@@ -203,7 +202,7 @@ class CollectionWatcher : public QObject {
// Updates the sections of a cue associated and altered (according to mtime) media file during a scan.
void UpdateCueAssociatedSongs(const QString &file, const QString &path, const QString &fingerprint, const QString &matching_cue, const QUrl &art_automatic, const SongList &old_cue_songs, ScanTransaction *t) const;
// Updates a single non-cue associated and altered (according to mtime) song during a scan.
bool UpdateNonCueAssociatedSong(const QString &file, const QString &fingerprint, const SongList &matching_songs, const QUrl &art_automatic, const bool cue_deleted, ScanTransaction *t);
void UpdateNonCueAssociatedSong(const QString &file, const QString &fingerprint, const SongList &matching_songs, const QUrl &art_automatic, const bool cue_deleted, ScanTransaction *t);
// Scans a single media file that's present on the disk but not yet in the collection.
// It may result in a multiple files added to the collection when the media file has many sections (like a CUE related media file).
SongList ScanNewFile(const QString &file, const QString &path, const QString &fingerprint, const QString &matching_cue, QSet<QString> *cues_processed) const;

View File

@@ -7,8 +7,6 @@
#cmakedefine USE_INSTALL_PREFIX
#cmakedefine BUILD_FOR_MAC_APP_STORE
#cmakedefine HAVE_BACKTRACE
#cmakedefine HAVE_ALSA
#cmakedefine HAVE_PULSE
@@ -35,6 +33,7 @@
#cmakedefine HAVE_SPOTIFY
#cmakedefine HAVE_QOBUZ
#cmakedefine HAVE_DISCORD_RPC
#cmakedefine HAVE_NETWORKREMOTE
#cmakedefine HAVE_TAGLIB_DSFFILE
#cmakedefine HAVE_TAGLIB_DSDIFFFILE

View File

@@ -33,7 +33,7 @@ constexpr char kFileFilter[] =
"*.mod *.s3m *.xm *.it "
"*.spc *.vgm";
constexpr char kLoadImageFileFilter[] = QT_TRANSLATE_NOOP("FileFilter", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.xpm *.pbm *.pgm *.ppm *.xbm *.webp)");
constexpr char kSaveImageFileFilter[] = QT_TRANSLATE_NOOP("FileFilter", "Images (*.png *.jpg *.jpeg *.bmp *.xpm *.pbm *.ppm *.xbm *.webp)");
constexpr char kLoadImageFileFilter[] = QT_TRANSLATE_NOOP("FileFilter", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.xpm *.pbm *.pgm *.ppm *.xbm)");
constexpr char kSaveImageFileFilter[] = QT_TRANSLATE_NOOP("FileFilter", "Images (*.png *.jpg *.jpeg *.bmp *.xpm *.pbm *.ppm *.xbm)");
#endif // FILEFILTERCONSTANTS_H

View File

@@ -0,0 +1,36 @@
/*
* Strawberry Music Player
* Copyright 2024, 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 NETWORKREMOTECONSTANTS_H
#define NETWORKREMOTECONSTANTS_H
#include <QStringList>
using namespace Qt::Literals::StringLiterals;
namespace NetworkRemoteConstants {
const QStringList kDefaultMusicExtensionsAllowedRemotely = { u"aac"_s, u"alac"_s, u"flac"_s, u"m3u"_s, u"m4a"_s, u"mp3"_s, u"ogg"_s, u"wav"_s, u"wmv"_s };
constexpr quint16 kDefaultServerPort = 5500;
constexpr char kTranscoderSettingPostfix[] = "/NetworkRemote";
constexpr quint32 kFileChunkSize = 100000;
} // namespace NetworkRemoteConstants
#endif // NETWORKREMOTECONSTANTS_H

View File

@@ -0,0 +1,35 @@
/*
* Strawberry Music Player
* Copyright 2024, 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 NETWORKREMOTESETTINGSCONSTANTS_H
#define NETWORKREMOTESETTINGSCONSTANTS_H
namespace NetworkRemoteSettingsConstants {
constexpr char kSettingsGroup[] = "NetworkRemote";
constexpr char kEnabled[] = "enabled";
constexpr char kPort[] = "port";
constexpr char kAllowPublicAccess[] = "allow_public_access";
constexpr char kUseAuthCode[] = "use_authcode";
constexpr char kAuthCode[] = "authcode";
constexpr char kFilesRootFolder[] = "files_root_folder";
} // namespace NetworkRemoteSettingsConstants
#endif // NETWORKREMOTESETTINGSCONSTANTS_H

View File

@@ -253,7 +253,7 @@ void ContextView::AddActions() {
action_search_lyrics_ = new QAction(tr("Automatically search for song lyrics"), this);
action_search_lyrics_->setCheckable(true);
action_search_lyrics_->setChecked(false);
action_search_lyrics_->setChecked(true);
menu_options_->addAction(action_show_album_);
menu_options_->addAction(action_show_data_);
@@ -287,7 +287,7 @@ void ContextView::ReloadSettings() {
action_show_album_->setChecked(s.value(ContextSettings::kAlbum, true).toBool());
action_show_data_->setChecked(s.value(ContextSettings::kTechnicalData, false).toBool());
action_show_lyrics_->setChecked(s.value(ContextSettings::kSongLyrics, true).toBool());
action_search_lyrics_->setChecked(s.value(ContextSettings::kSearchLyrics, false).toBool());
action_search_lyrics_->setChecked(s.value(ContextSettings::kSearchLyrics, true).toBool());
font_headline_.setFamily(s.value(ContextSettings::kFontHeadline, default_font).toString());
font_headline_.setPointSizeF(s.value(ContextSettings::kFontSizeHeadline, ContextSettings::kDefaultFontSizeHeadline).toReal());
font_nosong_.setFamily(font_headline_.family());

View File

@@ -110,6 +110,10 @@
# include "moodbar/moodbarloader.h"
#endif
#ifdef HAVE_NETWORKREMOTE
# include "networkremote/networkremote.h"
#endif
#include "radios/radioservices.h"
#include "radios/radiobackend.h"
@@ -216,6 +220,13 @@ class ApplicationImpl {
#ifdef HAVE_MOODBAR
moodbar_loader_([app]() { return new MoodbarLoader(app); }),
moodbar_controller_([app]() { return new MoodbarController(app->player(), app->moodbar_loader()); }),
#endif
#ifdef HAVE_NETWORKREMOTE
network_remote_([app]() {
NetworkRemote *networkremote = new NetworkRemote(app->database(), app->player(), app->collection_backend(), app->playlist_manager(), app->playlist_backend(), app->current_albumcover_loader(), app->scrobbler());
app->MoveToNewThread(networkremote);
return networkremote;
}),
#endif
lastfm_import_([app]() { return new LastFMImport(app->network()); })
{}
@@ -241,6 +252,9 @@ class ApplicationImpl {
#ifdef HAVE_MOODBAR
Lazy<MoodbarLoader> moodbar_loader_;
Lazy<MoodbarController> moodbar_controller_;
#endif
#ifdef HAVE_NETWORKREMOTE
Lazy<NetworkRemote> network_remote_;
#endif
Lazy<LastFMImport> lastfm_import_;
@@ -390,3 +404,6 @@ SharedPtr<LastFMImport> Application::lastfm_import() const { return p_->lastfm_i
SharedPtr<MoodbarController> Application::moodbar_controller() const { return p_->moodbar_controller_.ptr(); }
SharedPtr<MoodbarLoader> Application::moodbar_loader() const { return p_->moodbar_loader_.ptr(); }
#endif
#ifdef HAVE_NETWORKREMOTE
SharedPtr<NetworkRemote> Application::network_remote() const { return p_->network_remote_.ptr(); }
#endif

View File

@@ -63,6 +63,9 @@ class RadioServices;
class MoodbarController;
class MoodbarLoader;
#endif
#ifdef HAVE_NETWORKREMOTE
class NetworkRemote;
#endif
class Application : public QObject {
Q_OBJECT
@@ -103,6 +106,10 @@ class Application : public QObject {
SharedPtr<MoodbarLoader> moodbar_loader() const;
#endif
#ifdef HAVE_NETWORKREMOTE
SharedPtr<NetworkRemote> network_remote() const;
#endif
SharedPtr<LastFMImport> lastfm_import() const;
void Exit();

24
src/core/bonjour.h Normal file
View File

@@ -0,0 +1,24 @@
#ifndef BONJOUR_H
#define BONJOUR_H
#include "zeroconf.h"
#ifdef __OBJC__
@class NetServicePublicationDelegate;
#else
class NetServicePublicationDelegate;
#endif // __OBJC__
class Bonjour : public Zeroconf {
public:
explicit Bonjour();
virtual ~Bonjour();
protected:
virtual void PublishInternal(const QString &domain, const QString &type, const QByteArray &name, const quint16 port);
private:
NetServicePublicationDelegate *delegate_;
};
#endif // BONJOUR_H

57
src/core/bonjour.mm Normal file
View File

@@ -0,0 +1,57 @@
#include "bonjour.h"
#import <Foundation/NSNetServices.h>
#import <Foundation/NSString.h>
#include "core/logging.h"
#include "core/scoped_nsautorelease_pool.h"
@interface NetServicePublicationDelegate : NSObject <NSNetServiceDelegate> {}
- (void)netServiceWillPublish:(NSNetService*)netService;
- (void)netService:(NSNetService*)netService didNotPublish:(NSDictionary*)errorDict;
- (void)netServiceDidStop:(NSNetService*)netService;
@end
@implementation NetServicePublicationDelegate
- (void)netServiceWillPublish:(NSNetService*)netService {
qLog(Debug) << "Publishing:" << [[netService name] UTF8String];
}
- (void)netService:(NSNetService*)netServie didNotPublish:(NSDictionary*)errorDict {
qLog(Debug) << "Failed to publish remote service with Bonjour";
NSLog(@"%@", errorDict);
}
- (void)netServiceDidStop:(NSNetService*)netService {
qLog(Debug) << "Unpublished:" << [[netService name] UTF8String];
}
@end
namespace {
NSString* NSStringFromQString(const QString& s) {
return [[NSString alloc] initWithUTF8String:s.toUtf8().constData()];
}
}
Bonjour::Bonjour() : delegate_([[NetServicePublicationDelegate alloc] init]) {}
Bonjour::~Bonjour() { [delegate_ release]; }
void Bonjour::PublishInternal(const QString& domain, const QString& type, const QByteArray& name, const quint16 port) {
ScopedNSAutoreleasePool pool;
NSNetService* service =
[[NSNetService alloc] initWithDomain:NSStringFromQString(domain)
type:NSStringFromQString(type)
name:[NSString stringWithUTF8String:name.constData()]
port:port];
if (service) {
[service setDelegate:delegate_];
[service publish];
}
}

View File

@@ -52,12 +52,6 @@ LocalRedirectServer::~LocalRedirectServer() {
bool LocalRedirectServer::Listen() {
#ifdef BUILD_FOR_MAC_APP_STORE
success_ = false;
error_ = "Local redirect server is disabled in Mac App Store builds."_L1;
return false;
#endif
if (!listen(QHostAddress::LocalHost, static_cast<quint64>(port_))) {
success_ = false;
error_ = errorString();

View File

@@ -173,12 +173,9 @@
#endif
#ifdef HAVE_SPOTIFY
# include "spotify/spotifyservice.h"
# include "spotify/spotifymetadatarequest.h"
# include "constants/spotifysettings.h"
#endif
#ifdef HAVE_QOBUZ
# include "qobuz/qobuzservice.h"
# include "qobuz/qobuzmetadatarequest.h"
# include "constants/qobuzsettings.h"
#endif
@@ -382,10 +379,8 @@ MainWindow::MainWindow(Application *app,
playlist_add_to_another_(nullptr),
playlistitem_actions_separator_(nullptr),
playlist_rescan_songs_(nullptr),
playlist_fetch_metadata_(nullptr),
track_position_timer_(new QTimer(this)),
track_slider_timer_(new QTimer(this)),
metadata_queue_timer_(new QTimer(this)),
keep_running_(false),
playing_widget_(true),
#ifdef HAVE_DBUS
@@ -457,10 +452,6 @@ MainWindow::MainWindow(Application *app,
track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs);
QObject::connect(track_slider_timer_, &QTimer::timeout, this, &MainWindow::UpdateTrackSliderPosition);
metadata_queue_timer_->setInterval(200ms); // 200ms between requests to avoid rate limiting
metadata_queue_timer_->setSingleShot(true);
QObject::connect(metadata_queue_timer_, &QTimer::timeout, this, &MainWindow::ProcessMetadataQueue);
// Start initializing the player
qLog(Debug) << "Initializing player";
app_->player()->SetAnalyzer(ui_->analyzer);
@@ -821,8 +812,6 @@ MainWindow::MainWindow(Application *app,
#endif
playlist_rescan_songs_ = playlist_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Rescan song(s)..."), this, &MainWindow::RescanSongs);
playlist_menu_->addAction(playlist_rescan_songs_);
playlist_fetch_metadata_ = playlist_menu_->addAction(IconLoader::Load(u"download"_s), tr("Fetch metadata from service"), this, &MainWindow::FetchStreamingMetadata);
playlist_menu_->addAction(playlist_fetch_metadata_);
playlist_menu_->addAction(ui_->action_add_files_to_transcoder);
playlist_menu_->addSeparator();
playlist_copy_url_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy URL(s)..."), this, &MainWindow::PlaylistCopyUrl);
@@ -988,6 +977,10 @@ MainWindow::MainWindow(Application *app,
ui_->action_open_cd->setVisible(false);
#endif
#ifdef HAVE_NETWORKREMOTE
app_->network_remote();
#endif
// Load settings
qLog(Debug) << "Loading settings";
Settings settings;
@@ -1142,7 +1135,18 @@ MainWindow::MainWindow(Application *app,
asked_permission = s.value("asked_permission", false).toBool();
s.endGroup();
#endif
Q_UNUSED(asked_permission)
if (asked_permission) {
s.beginGroup(MainWindowSettings::kSettingsGroup);
const bool do_not_show_sponsor_message = s.value(MainWindowSettings::kDoNotShowSponsorMessage, false).toBool();
s.endGroup();
if (!do_not_show_sponsor_message) {
MessageDialog *sponsor_message = new MessageDialog(this);
sponsor_message->set_settings_group(QLatin1String(MainWindowSettings::kSettingsGroup));
sponsor_message->set_do_not_show_message_again(QLatin1String(MainWindowSettings::kDoNotShowSponsorMessage));
sponsor_message->setAttribute(Qt::WA_DeleteOnClose);
sponsor_message->ShowMessage(tr("Sponsoring Strawberry"), tr("Strawberry is free and open source software. If you like Strawberry, please consider sponsoring the project. For more information about sponsorship see our website %1").arg(u"<a href= \"https://www.strawberrymusicplayer.org/\">www.strawberrymusicplayer.org</a>"_s), IconLoader::Load(u"dialog-information"_s));
}
}
}
qLog(Debug) << "Started" << QThread::currentThread();
@@ -1229,7 +1233,7 @@ void MainWindow::ReloadSettings() {
osd_->ReloadSettings();
s.beginGroup(MainWindowSettings::kSettingsGroup);
album_cover_choice_controller_->search_cover_auto_action()->setChecked(s.value(MainWindowSettings::kSearchForCoverAuto, false).toBool());
album_cover_choice_controller_->search_cover_auto_action()->setChecked(s.value(MainWindowSettings::kSearchForCoverAuto, true).toBool());
s.endGroup();
#ifdef HAVE_SUBSONIC
@@ -1995,7 +1999,6 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
int in_skipped = 0;
int not_in_skipped = 0;
int local_songs = 0;
int streaming_songs = 0;
for (const QModelIndex &idx : selection) {
@@ -2005,13 +2008,7 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
PlaylistItemPtr item = app_->playlist_manager()->current()->item_at(src_idx.row());
if (!item) continue;
if (item->EffectiveMetadata().url().isLocalFile()) {
++local_songs;
}
if (item->EffectiveMetadata().is_stream_service()) {
++streaming_songs;
}
if (item->EffectiveMetadata().url().isLocalFile()) ++local_songs;
if (item->EffectiveMetadata().has_cue()) {
cue_selected = true;
@@ -2039,9 +2036,6 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
playlist_rescan_songs_->setEnabled(local_songs > 0 && editable > 0);
playlist_rescan_songs_->setVisible(local_songs > 0 && editable > 0);
playlist_fetch_metadata_->setEnabled(streaming_songs > 0);
playlist_fetch_metadata_->setVisible(streaming_songs > 0);
ui_->action_add_files_to_transcoder->setEnabled(local_songs > 0 && editable > 0);
ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0);
@@ -2253,23 +2247,9 @@ void MainWindow::EditTracks() {
void MainWindow::EditTagDialogAccepted() {
const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items();
const SongList songs = edit_tag_dialog_->songs();
if (items.count() != songs.count()) {
return;
}
for (int i = 0; i < items.count(); ++i) {
PlaylistItemPtr item = items[i];
const Song &updated_song = songs[i];
// For stream tracks, apply the metadata directly since there's no file to reload from
if (updated_song.is_stream_service()) {
item->SetOriginalMetadata(updated_song);
}
else {
for (PlaylistItemPtr item : items) {
item->Reload();
}
}
// FIXME: This is really lame but we don't know what rows have changed.
ui_->playlist->view()->update();
@@ -2343,8 +2323,8 @@ void MainWindow::SelectionSetValue() {
QObject::disconnect(*connection);
}, Qt::QueuedConnection);
}
else if (song.is_stream()) {
app_->playlist_manager()->current()->setData(source_index.sibling(source_index.row(), static_cast<int>(column)), column_value, 0);
else if (song.source() == Song::Source::Stream) {
app_->playlist_manager()->current()->setData(source_index, column_value, 0);
}
}
@@ -3428,172 +3408,3 @@ void MainWindow::FocusSearchField() {
}
}
void MainWindow::FetchStreamingMetadata() {
const QModelIndexList proxy_indexes = ui_->playlist->view()->selectionModel()->selectedRows();
for (const QModelIndex &proxy_index : proxy_indexes) {
const QModelIndex source_index = app_->playlist_manager()->current()->filter()->mapToSource(proxy_index);
if (!source_index.isValid()) continue;
PlaylistItemPtr item(app_->playlist_manager()->current()->item_at(source_index.row()));
if (!item) continue;
const Song &song = item->EffectiveMetadata();
const QPersistentModelIndex persistent_index = QPersistentModelIndex(source_index);
QString track_id;
#ifdef HAVE_QOBUZ
if (song.source() == Song::Source::Qobuz) {
track_id = song.song_id();
// song_id() may be empty if not persisted, fall back to URL path
if (track_id.isEmpty()) {
track_id = song.url().path();
}
if (track_id.isEmpty()) {
qLog(Error) << "Failed to fetch Qobuz metadata: No track ID";
continue;
}
}
#endif
#ifdef HAVE_SPOTIFY
if (song.source() == Song::Source::Spotify) {
track_id = song.song_id();
// song_id() may be empty if not persisted, fall back to parsing URL
if (track_id.isEmpty() && song.url().scheme() == "spotify"_L1 && song.url().path().startsWith(u"track:"_s)) {
track_id = song.url().path().mid(6);
}
if (track_id.isEmpty()) {
qLog(Error) << "Failed to fetch Spotify metadata: No track ID";
continue;
}
}
#endif
if (!track_id.isEmpty()) {
metadata_queue_.append({song.source(), track_id, persistent_index});
}
}
// Start processing the queue if it's not already running
if (!metadata_queue_.isEmpty() && !metadata_queue_timer_->isActive()) {
ProcessMetadataQueue();
}
}
void MainWindow::ProcessMetadataQueue() {
if (metadata_queue_.isEmpty()) {
return;
}
const MetadataQueueEntry metadata_queue_entry = metadata_queue_.takeFirst();
#ifdef HAVE_QOBUZ
if (metadata_queue_entry.source == Song::Source::Qobuz) {
if (QobuzServicePtr qobuz_service = app_->streaming_services()->Service<QobuzService>()) {
QobuzMetadataRequest *request = new QobuzMetadataRequest(&*qobuz_service, qobuz_service->network());
QObject::connect(request, &QobuzMetadataRequest::MetadataReceived, this, [this, metadata_queue_entry, request](const QString &received_track_id, const Song &fetched_song) {
Q_UNUSED(received_track_id);
if (metadata_queue_entry.persistent_index.isValid() && fetched_song.is_valid()) {
PlaylistItemPtr playlist_item = app_->playlist_manager()->current()->item_at(metadata_queue_entry.persistent_index.row());
if (playlist_item) {
const Song old_song = playlist_item->OriginalMetadata();
Song updated_song = old_song;
// Update all metadata fields from the fetched song
if (!fetched_song.title().isEmpty()) updated_song.set_title(fetched_song.title());
if (!fetched_song.artist().isEmpty()) updated_song.set_artist(fetched_song.artist());
if (!fetched_song.album().isEmpty()) updated_song.set_album(fetched_song.album());
if (!fetched_song.albumartist().isEmpty()) updated_song.set_albumartist(fetched_song.albumartist());
if (!fetched_song.genre().isEmpty()) updated_song.set_genre(fetched_song.genre());
if (!fetched_song.composer().isEmpty()) updated_song.set_composer(fetched_song.composer());
if (!fetched_song.performer().isEmpty()) updated_song.set_performer(fetched_song.performer());
if (!fetched_song.comment().isEmpty()) updated_song.set_comment(fetched_song.comment());
if (fetched_song.track() > 0) updated_song.set_track(fetched_song.track());
if (fetched_song.disc() > 0) updated_song.set_disc(fetched_song.disc());
if (fetched_song.year() > 0) updated_song.set_year(fetched_song.year());
if (fetched_song.length_nanosec() > 0) updated_song.set_length_nanosec(fetched_song.length_nanosec());
if (fetched_song.art_automatic().isValid()) updated_song.set_art_automatic(fetched_song.art_automatic());
playlist_item->SetOriginalMetadata(updated_song);
app_->playlist_manager()->current()->ItemReload(metadata_queue_entry.persistent_index, old_song, false);
}
}
request->deleteLater();
// Process next item in queue
if (!metadata_queue_.isEmpty()) {
metadata_queue_timer_->start();
}
});
QObject::connect(request, &QobuzMetadataRequest::MetadataFailure, this, [this, request](const QString &failed_track_id, const QString &error) {
Q_UNUSED(failed_track_id);
qLog(Error) << "Failed to fetch Qobuz metadata:" << error;
request->deleteLater();
// Process next item in queue
if (!metadata_queue_.isEmpty()) {
metadata_queue_timer_->start();
}
});
request->FetchTrackMetadata(metadata_queue_entry.track_id);
return;
}
}
#endif
#ifdef HAVE_SPOTIFY
if (metadata_queue_entry.source == Song::Source::Spotify) {
if (SpotifyServicePtr spotify_service = app_->streaming_services()->Service<SpotifyService>()) {
SpotifyMetadataRequest *request = new SpotifyMetadataRequest(&*spotify_service, app_->network());
QObject::connect(request, &SpotifyMetadataRequest::MetadataReceived, this, [this, metadata_queue_entry, request](const QString &received_track_id, const Song &fetched_song) {
Q_UNUSED(received_track_id);
if (metadata_queue_entry.persistent_index.isValid() && fetched_song.is_valid()) {
PlaylistItemPtr playlist_item = app_->playlist_manager()->current()->item_at(metadata_queue_entry.persistent_index.row());
if (playlist_item) {
const Song old_song = playlist_item->OriginalMetadata();
Song updated_song = old_song;
// Update all metadata fields from the fetched song
if (!fetched_song.title().isEmpty()) updated_song.set_title(fetched_song.title());
if (!fetched_song.artist().isEmpty()) updated_song.set_artist(fetched_song.artist());
if (!fetched_song.album().isEmpty()) updated_song.set_album(fetched_song.album());
if (!fetched_song.albumartist().isEmpty()) updated_song.set_albumartist(fetched_song.albumartist());
if (!fetched_song.genre().isEmpty()) updated_song.set_genre(fetched_song.genre());
if (!fetched_song.composer().isEmpty()) updated_song.set_composer(fetched_song.composer());
if (!fetched_song.performer().isEmpty()) updated_song.set_performer(fetched_song.performer());
if (!fetched_song.comment().isEmpty()) updated_song.set_comment(fetched_song.comment());
if (fetched_song.track() > 0) updated_song.set_track(fetched_song.track());
if (fetched_song.disc() > 0) updated_song.set_disc(fetched_song.disc());
if (fetched_song.year() > 0) updated_song.set_year(fetched_song.year());
if (fetched_song.length_nanosec() > 0) updated_song.set_length_nanosec(fetched_song.length_nanosec());
if (fetched_song.art_automatic().isValid()) updated_song.set_art_automatic(fetched_song.art_automatic());
playlist_item->SetOriginalMetadata(updated_song);
app_->playlist_manager()->current()->ItemReload(metadata_queue_entry.persistent_index, old_song, false);
}
}
request->deleteLater();
// Process next item in queue
if (!metadata_queue_.isEmpty()) {
metadata_queue_timer_->start();
}
});
QObject::connect(request, &SpotifyMetadataRequest::MetadataFailure, this, [this, request](const QString &failed_track_id, const QString &error) {
Q_UNUSED(failed_track_id);
qLog(Error) << "Failed to fetch Spotify metadata:" << error;
request->deleteLater();
// Process next item in queue
if (!metadata_queue_.isEmpty()) {
metadata_queue_timer_->start();
}
});
request->FetchTrackMetadata(metadata_queue_entry.track_id);
return;
}
}
#endif
// If we get here, the source wasn't handled - try the next item
if (!metadata_queue_.isEmpty()) {
metadata_queue_timer_->start();
}
}

View File

@@ -43,6 +43,7 @@
#include <QString>
#include <QUrl>
#include <QImage>
#include <QPixmap>
#include <QTimer>
#include <QtEvents>
@@ -245,6 +246,7 @@ class MainWindow : public QMainWindow, public PlatformInterface {
void ToggleSearchCoverAuto(const bool checked);
void SaveGeometry();
void Exit();
void DoExit();
void HandleNotificationPreview(const OSDSettings::Type type, const QString &line1, const QString &line2);
@@ -276,13 +278,9 @@ class MainWindow : public QMainWindow, public PlatformInterface {
void DeleteFilesFinished(const SongList &songs_with_errors);
void FetchStreamingMetadata();
void ProcessMetadataQueue();
public Q_SLOTS:
void CommandlineOptionsReceived(const QByteArray &string_options);
void Raise();
void Exit();
private:
void SaveSettings();
@@ -292,6 +290,9 @@ class MainWindow : public QMainWindow, public PlatformInterface {
void CheckFullRescanRevisions();
// creates the icon by painting the full one depending on the current position
QPixmap CreateOverlayedIcon(const int position, const int scrobble_point);
void GetCoverAutomatically();
void SetToggleScrobblingIcon(const bool value);
@@ -382,13 +383,11 @@ class MainWindow : public QMainWindow, public PlatformInterface {
QList<QAction*> playlistitem_actions_;
QAction *playlistitem_actions_separator_;
QAction *playlist_rescan_songs_;
QAction *playlist_fetch_metadata_;
QModelIndex playlist_menu_index_;
QTimer *track_position_timer_;
QTimer *track_slider_timer_;
QTimer *metadata_queue_timer_;
bool keep_running_;
bool playing_widget_;
@@ -412,14 +411,6 @@ class MainWindow : public QMainWindow, public PlatformInterface {
bool playlists_loaded_;
bool delete_files_;
std::optional<CommandlineOptions> options_;
class MetadataQueueEntry {
public:
Song::Source source;
QString track_id;
QPersistentModelIndex persistent_index;
};
QList<MetadataQueueEntry> metadata_queue_;
};
#endif // MAINWINDOW_H

View File

@@ -34,13 +34,6 @@
#include "mergedproxymodel.h"
#ifdef __GNUC__
#pragma GCC diagnostic push
#if __GNUC__ >= 16
#pragma GCC diagnostic ignored "-Wstringop-overflow"
#endif
#endif
#include <boost/multi_index/detail/bidir_node_iterator.hpp>
#include <boost/multi_index/detail/hash_index_iterator.hpp>
#include <boost/multi_index/hashed_index.hpp>
@@ -52,10 +45,6 @@
#include <boost/multi_index_container.hpp>
#include <boost/operators.hpp>
#ifdef __GNUC__
#pragma GCC diagnostic pop
#endif
using boost::multi_index::hashed_unique;
using boost::multi_index::identity;
using boost::multi_index::indexed_by;

View File

@@ -25,14 +25,15 @@
#include "mimedata.h"
MimeData::MimeData(const bool clear, const bool play_now, const bool enqueue, const bool enqueue_next_now, const bool open_in_new_playlist, QObject *parent)
MimeData::MimeData(const bool clear, const bool play_now, const bool enqueue, const bool enqueue_next_now, const bool open_in_new_playlist, const int playlist_id, QObject *parent)
: override_user_settings_(false),
clear_first_(clear),
play_now_(play_now),
enqueue_now_(enqueue),
enqueue_next_now_(enqueue_next_now),
open_in_new_playlist_(open_in_new_playlist),
from_doubleclick_(false) {
from_doubleclick_(false),
playlist_id_(playlist_id) {
Q_UNUSED(parent);

View File

@@ -29,7 +29,7 @@ class MimeData : public QMimeData {
Q_OBJECT
public:
explicit MimeData(const bool clear = false, const bool play_now = false, const bool enqueue = false, const bool enqueue_next_now = false, const bool open_in_new_playlist = false, QObject *parent = nullptr);
explicit MimeData(const bool clear = false, const bool play_now = false, const bool enqueue = false, const bool enqueue_next_now = false, const bool open_in_new_playlist = false, const int playlist_id = -1, QObject *parent = nullptr);
// If this is set then MainWindow will not touch any of the other flags.
bool override_user_settings_;
@@ -57,6 +57,9 @@ class MimeData : public QMimeData {
// The MainWindow will set the above flags to the defaults set by the user.
bool from_doubleclick_;
// The Network Remote can use this MimeData to drop songs on another playlist than the one currently opened on the server
int playlist_id_;
// Returns a pretty name for a playlist containing songs described by this MimeData object.
// By pretty name we mean the value of 'name_for_new_playlist_' or generic "Playlist" string if the 'name_for_new_playlist_' attribute is empty.
QString get_name_for_new_playlist() const;

View File

@@ -236,14 +236,6 @@ void OAuthenticator::Authenticate() {
return;
}
// Mac App Store builds: do not start any localhost listening redirect server.
#ifdef BUILD_FOR_MAC_APP_STORE
if (use_local_redirect_server_) {
Q_EMIT AuthenticationFinished(false, tr("This authentication flow is disabled in Mac App Store builds."));
return;
}
#endif
QUrl redirect_url(redirect_url_);
if (use_local_redirect_server_) {

View File

@@ -686,12 +686,11 @@ const QString &Song::playlist_effective_albumartistsort() const { return is_comp
bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); }
bool Song::is_local_collection_song() const { return d->source_ == Source::Collection; }
bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); }
bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; }
bool Song::is_stream_service() const { return d->source_ == Source::Subsonic || d->source_ == Source::Tidal || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_stream() const { return is_radio() || is_stream_service(); }
bool Song::is_cdda() const { return d->source_ == Source::CDDA; }
bool Song::is_compilation() const { return (d->compilation_ || d->compilation_detected_ || d->compilation_on_) && !d->compilation_off_; }
bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz; }
bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_module_music() const { return d->filetype_ == FileType::MOD || d->filetype_ == FileType::S3M || d->filetype_ == FileType::XM || d->filetype_ == FileType::IT; }
bool Song::has_cue() const { return !d->cue_path_.isEmpty(); }
@@ -957,7 +956,7 @@ QString Song::PrettyRating() const {
}
bool Song::IsEditable() const {
return d->valid_ && d->url_.isValid() && ((d->url_.isLocalFile() && write_tags_supported() && !has_cue()) || is_stream());
return d->valid_ && d->url_.isValid() && ((d->url_.isLocalFile() && write_tags_supported() && !has_cue()) || d->source_ == Source::Stream);
}
bool Song::IsFileInfoEqual(const Song &other) const {
@@ -1669,24 +1668,12 @@ void Song::InitArtManual() {
void Song::InitArtAutomatic() {
if (d->art_automatic_.isEmpty() && d->source_ == Source::LocalFile && d->url_.isLocalFile()) {
const QFileInfo fileinfo(d->url_.toLocalFile());
const QDir dir(fileinfo.path());
const QStringList cover_files = dir.entryList(QStringList() << u"*.jpg"_s << u"*.png"_s << u"*.gif"_s << u"*.jpeg"_s, QDir::Files|QDir::Readable, QDir::Name);
QString best_cover_file;
for (const QString &cover_file : cover_files) {
if (cover_file.contains("back"_L1, Qt::CaseInsensitive)) {
continue;
}
if (cover_file.contains("front"_L1, Qt::CaseInsensitive) || cover_file.startsWith("cover"_L1, Qt::CaseInsensitive)) {
best_cover_file = cover_file;
break;
}
if (best_cover_file.isEmpty()) {
best_cover_file = cover_file;
}
}
if (!best_cover_file.isEmpty()) {
d->art_automatic_ = QUrl::fromLocalFile(fileinfo.path() + QDir::separator() + best_cover_file);
// Pick the first image file in the album directory.
QFileInfo file(d->url_.toLocalFile());
QDir dir(file.path());
QStringList files = dir.entryList(QStringList() << u"*.jpg"_s << u"*.png"_s << u"*.gif"_s << u"*.jpeg"_s, QDir::Files|QDir::Readable, QDir::Name);
if (files.count() > 0) {
d->art_automatic_ = QUrl::fromLocalFile(file.path() + QDir::separator() + files.first());
}
}

View File

@@ -407,9 +407,8 @@ class Song {
bool is_metadata_good() const;
bool is_local_collection_song() const;
bool is_linked_collection_song() const;
bool is_radio() const;
bool is_stream_service() const;
bool is_stream() const;
bool is_radio() const;
bool is_cdda() const;
bool is_compilation() const;
bool stream_url_can_expire() const;

86
src/core/tinysvcmdns.cpp Normal file
View File

@@ -0,0 +1,86 @@
extern "C" {
#include "mdnsd.h"
}
#include <QObject>
#include <QList>
#include <QString>
#include <QHostInfo>
#include <QNetworkInterface>
#include <QtEndian>
#include "tinysvcmdns.h"
#include "core/logging.h"
using namespace Qt::Literals::StringLiterals;
TinySVCMDNS::TinySVCMDNS(QObject *parent) : Zeroconf(parent) {
// Get all network interfaces
const QList<QNetworkInterface> network_interfaces = QNetworkInterface::allInterfaces();
for (const QNetworkInterface &network_interface : network_interfaces) {
// Only use up and non loopback interfaces
if (network_interface.flags().testFlag(network_interface.IsUp) && !network_interface.flags().testFlag(network_interface.IsLoopBack)) {
qLog(Debug) << "Interface" << network_interface.humanReadableName();
uint32_t ipv4 = 0;
QString ipv6;
// Now check all network addresses for this device
QList<QNetworkAddressEntry> network_address_entries = network_interface.addressEntries();
for (QNetworkAddressEntry network_address_entry : network_address_entries) {
QHostAddress host_address = network_address_entry.ip();
if (host_address.protocol() == QAbstractSocket::IPv4Protocol) {
ipv4 = qToBigEndian(host_address.toIPv4Address());
qLog(Debug) << " ipv4:" << host_address.toString();
}
else if (host_address.protocol() == QAbstractSocket::IPv6Protocol) {
ipv6 = host_address.toString();
qLog(Debug) << " ipv6:" << host_address.toString();
}
}
// Now start the service
CreateMdnsd(ipv4, ipv6);
}
}
}
TinySVCMDNS::~TinySVCMDNS() {
for (mdnsd *mdnsd : std::as_const(mdnsd_)) {
mdnsd_stop(mdnsd);
}
}
void TinySVCMDNS::CreateMdnsd(const uint32_t ipv4, const QString &ipv6) {
const QString host = QHostInfo::localHostName();
// Start the service
mdnsd *mdnsd = mdnsd_start();
// Set our hostname
const QString fullhostname = host + ".local"_L1;
mdnsd_set_hostname(mdnsd, fullhostname.toUtf8().constData(), ipv4);
// Add to the list
mdnsd_.append(mdnsd);
}
void TinySVCMDNS::PublishInternal(const QString &domain, const QString &type, const QByteArray &name, const quint16 port) {
// Some pointless text, so tinymDNS publishes the service correctly.
const char *txt[] = { "cat=nyan", nullptr };
for (mdnsd *mdnsd : mdnsd_) {
const QString fulltype = type + ".local"_L1;
mdnsd_register_svc(mdnsd, name.constData(), fulltype.toUtf8().constData(), port, nullptr, txt);
}
}

26
src/core/tinysvcmdns.h Normal file
View File

@@ -0,0 +1,26 @@
#ifndef TINYSVCMDNS_H
#define TINYSVCMDNS_H
#include <QList>
#include <QByteArray>
#include <QString>
#include "zeroconf.h"
struct mdnsd;
class TinySVCMDNS : public Zeroconf {
public:
explicit TinySVCMDNS(QObject *parent = nullptr);
virtual ~TinySVCMDNS();
protected:
virtual void PublishInternal(const QString &domain, const QString &type, const QByteArray &name, const quint16 port) override;
private:
void CreateMdnsd(const uint32_t ipv4, const QString &ipv6);
QList<mdnsd*> mdnsd_;
};
#endif // TINYSVCMDNS_H

View File

@@ -1,175 +0,0 @@
/*
* Strawberry Music Player
* Copyright 2026, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <cstring>
#include <sys/socket.h>
#include <unistd.h>
#include <csignal>
#include <cerrno>
#include <fcntl.h>
#include <QSocketNotifier>
#include "core/logging.h"
#include "unixsignalwatcher.h"
UnixSignalWatcher *UnixSignalWatcher::sInstance = nullptr;
UnixSignalWatcher::UnixSignalWatcher(QObject *parent)
: QObject(parent),
signal_fd_{-1, -1},
socket_notifier_(nullptr) {
Q_ASSERT(!sInstance);
// Create a socket pair for the self-pipe trick
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, signal_fd_) != 0) {
qLog(Error) << "Failed to create socket pair for signal handling:" << ::strerror(errno);
return;
}
Q_ASSERT(signal_fd_[0] != -1);
// Set the read end to non-blocking mode
// Non-blocking mode is important to prevent HandleSignalNotification from blocking
int flags = ::fcntl(signal_fd_[0], F_GETFL, 0);
if (flags == -1) {
qLog(Error) << "Failed to get socket flags:" << ::strerror(errno);
}
else if (::fcntl(signal_fd_[0], F_SETFL, flags | O_NONBLOCK) == -1) {
qLog(Error) << "Failed to set socket to non-blocking:" << ::strerror(errno);
}
// Set the write end to non-blocking mode as well (used in signal handler)
// Non-blocking mode prevents the signal handler from blocking if buffer is full
flags = ::fcntl(signal_fd_[1], F_GETFL, 0);
if (flags == -1) {
qLog(Error) << "Failed to get socket flags for write end:" << ::strerror(errno);
}
else if (::fcntl(signal_fd_[1], F_SETFL, flags | O_NONBLOCK) == -1) {
qLog(Error) << "Failed to set write end of socket to non-blocking:" << ::strerror(errno);
}
// Set up QSocketNotifier to monitor the read end of the socket
socket_notifier_ = new QSocketNotifier(signal_fd_[0], QSocketNotifier::Read, this);
QObject::connect(socket_notifier_, &QSocketNotifier::activated, this, &UnixSignalWatcher::HandleSignalNotification);
sInstance = this;
}
UnixSignalWatcher::~UnixSignalWatcher() {
if (socket_notifier_) {
socket_notifier_->setEnabled(false);
}
// Restore original signal handlers
for (int i = 0; i < watched_signals_.size(); ++i) {
if (::sigaction(watched_signals_[i], &original_signal_actions_[i], nullptr) != 0) {
qLog(Error) << "Failed to restore signal handler for signal" << watched_signals_[i] << ":" << ::strerror(errno);
}
}
if (signal_fd_[0] != -1) {
::close(signal_fd_[0]);
signal_fd_[0] = -1;
}
if (signal_fd_[1] != -1) {
::close(signal_fd_[1]);
signal_fd_[1] = -1;
}
sInstance = nullptr;
}
void UnixSignalWatcher::WatchForSignal(const int signal) {
// Check if socket pair was created successfully
if (signal_fd_[0] == -1 || signal_fd_[1] == -1) {
qLog(Error) << "Cannot watch for signal: socket pair not initialized";
return;
}
if (watched_signals_.contains(signal)) {
qLog(Error) << "Already watching for signal" << signal;
return;
}
struct sigaction signal_action{};
::memset(&signal_action, 0, sizeof(signal_action));
sigemptyset(&signal_action.sa_mask);
signal_action.sa_handler = UnixSignalWatcher::SignalHandler;
signal_action.sa_flags = SA_RESTART;
struct sigaction old_signal_action{};
::memset(&old_signal_action, 0, sizeof(old_signal_action));
if (::sigaction(signal, &signal_action, &old_signal_action) != 0) {
qLog(Error) << "sigaction error:" << ::strerror(errno);
return;
}
watched_signals_ << signal;
original_signal_actions_ << old_signal_action;
}
void UnixSignalWatcher::SignalHandler(const int signal) {
if (!sInstance || sInstance->signal_fd_[1] == -1) {
return;
}
// Write the signal number to the socket pair (async-signal-safe)
// This is the only operation we perform in the signal handler
// Ignore errors as there's nothing we can safely do about them in a signal handler
#ifdef __GNUC__
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-result"
#endif
(void)::write(sInstance->signal_fd_[1], &signal, sizeof(signal));
#ifdef __GNUC__
#pragma GCC diagnostic pop
#endif
}
void UnixSignalWatcher::HandleSignalNotification() {
// Read all pending signals from the socket
// Multiple signals could arrive before the notifier triggers
while (true) {
int signal = 0;
const ssize_t bytes_read = ::read(signal_fd_[0], &signal, sizeof(signal));
if (bytes_read == sizeof(signal)) {
qLog(Debug) << "Caught signal:" << signal;
Q_EMIT UnixSignal(signal);
}
else if (bytes_read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// No more data available (expected with non-blocking socket)
break;
}
else {
// Error occurred or partial read
break;
}
}
}

View File

@@ -1,53 +0,0 @@
/*
* Strawberry Music Player
* Copyright 2026, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef UNIXSIGNALWATCHER_H
#define UNIXSIGNALWATCHER_H
#include <csignal>
#include <QObject>
#include <QList>
class QSocketNotifier;
class UnixSignalWatcher : public QObject {
Q_OBJECT
public:
explicit UnixSignalWatcher(QObject *parent = nullptr);
~UnixSignalWatcher() override;
void WatchForSignal(const int signal);
Q_SIGNALS:
void UnixSignal(const int signal);
private:
static void SignalHandler(const int signal);
void HandleSignalNotification();
static UnixSignalWatcher *sInstance;
int signal_fd_[2];
QSocketNotifier *socket_notifier_;
QList<int> watched_signals_;
QList<struct sigaction> original_signal_actions_;
};
#endif // UNIXSIGNALWATCHER_H

69
src/core/zeroconf.cpp Normal file
View File

@@ -0,0 +1,69 @@
#include "config.h"
#include <QObject>
#include <QByteArray>
#include <QString>
#ifdef HAVE_DBUS
# include "avahi/avahi.h"
#endif
#ifdef Q_OS_DARWIN
# include "bonjour.h"
#endif
#ifdef Q_OS_WIN32
# include "tinysvcmdns.h"
#endif
#include "zeroconf.h"
Zeroconf *Zeroconf::sInstance = nullptr;
Zeroconf::Zeroconf(QObject *parent) : QObject(parent) {}
Zeroconf::~Zeroconf() = default;
Zeroconf *Zeroconf::GetZeroconf() {
if (!sInstance) {
#ifdef HAVE_DBUS
sInstance = new Avahi;
#endif // HAVE_DBUS
#ifdef Q_OS_DARWIN
sInstance = new Bonjour;
#endif
#ifdef Q_OS_WIN32
sInstance = new TinySVCMDNS;
#endif
}
return sInstance;
}
QByteArray Zeroconf::TruncateName(const QString &name) {
QByteArray truncated_utf8;
for (const QChar c : name) {
if (truncated_utf8.size() + 1 >= 63) {
break;
}
truncated_utf8 += c.toLatin1();
}
// NULL-terminate the string.
truncated_utf8.append('\0');
return truncated_utf8;
}
void Zeroconf::Publish(const QString &domain, const QString &type, const QString &name, quint16 port) {
const QByteArray truncated_name = TruncateName(name);
PublishInternal(domain, type, truncated_name, port);
}

28
src/core/zeroconf.h Normal file
View File

@@ -0,0 +1,28 @@
#ifndef ZEROCONF_H
#define ZEROCONF_H
#include <QObject>
#include <QByteArray>
#include <QString>
class Zeroconf : public QObject {
public:
explicit Zeroconf(QObject *parent);
virtual ~Zeroconf();
void Publish(const QString &domain, const QString &type, const QString &name, quint16 port);
static Zeroconf *GetZeroconf();
// Truncate a QString to 63 bytes of UTF-8.
static QByteArray TruncateName(const QString &name);
protected:
virtual void PublishInternal(const QString &domain, const QString &type, const QByteArray &name, quint16 port) = 0;
private:
static Zeroconf *sInstance;
};
#endif // ZEROCONF_H

View File

@@ -75,7 +75,6 @@ FilesystemDevice::FilesystemDevice(const QUrl &url,
QObject::connect(collection_watcher_, &CollectionWatcher::SongsReadded, &*collection_backend_, &CollectionBackend::MarkSongsUnavailable);
QObject::connect(collection_watcher_, &CollectionWatcher::SubdirsDiscovered, &*collection_backend_, &CollectionBackend::AddOrUpdateSubdirs);
QObject::connect(collection_watcher_, &CollectionWatcher::SubdirsMTimeUpdated, &*collection_backend_, &CollectionBackend::AddOrUpdateSubdirs);
QObject::connect(collection_watcher_, &CollectionWatcher::SubdirsDeleted, &*collection_backend_, &CollectionBackend::DeleteSubdirs);
QObject::connect(collection_watcher_, &CollectionWatcher::CompilationsNeedUpdating, &*collection_backend_, &CollectionBackend::CompilationsNeedUpdating);
QObject::connect(collection_watcher_, &CollectionWatcher::UpdateLastSeen, &*collection_backend_, &CollectionBackend::UpdateLastSeen);
QObject::connect(collection_watcher_, &CollectionWatcher::ScanStarted, this, &FilesystemDevice::TaskStarted);

View File

@@ -31,6 +31,7 @@
#include <QLabel>
#include <QPushButton>
#include <QKeySequence>
#include <QTextBrowser>
#include "about.h"
#include "ui_about.h"
@@ -43,6 +44,52 @@ About::About(QWidget *parent) : QDialog(parent), ui_{} {
setWindowFlags(windowFlags()|Qt::WindowStaysOnTopHint);
setWindowTitle(tr("About Strawberry"));
strawberry_authors_ \
<< Person(u"Jonas Kvinge"_s);
strawberry_contributors_ \
<< Person(u"Gavin D. Howard"_s)
<< Person(u"Martin Delille"_s)
<< Person(u"Roman Lebedev"_s)
<< Person(u"Daniel Ostertag"_s)
<< Person(u"Gustavo L Conte"_s);
clementine_authors_
<< Person(u"David Sansome"_s)
<< Person(u"John Maguire"_s)
<< Person(u"Paweł Bara"_s)
<< Person(u"Arnaud Bienner"_s);
clementine_contributors_ \
<< Person(u"Jakub Stachowski"_s)
<< Person(u"Paul Cifarelli"_s)
<< Person(u"Felipe Rivera"_s)
<< Person(u"Alexander Peitz"_s)
<< Person(u"Andreas Muttscheller"_s)
<< Person(u"Mark Furneaux"_s)
<< Person(u"Florian Bigard"_s)
<< Person(u"Alex Bikadorov"_s)
<< Person(u"Mattias Andersson"_s)
<< Person(u"Alan Briolat"_s)
<< Person(u"Arun Narayanankutty"_s)
<< Person(u"Bartłomiej Burdukiewicz"_s)
<< Person(u"Andre Siviero"_s)
<< Person(u"Santiago Gil"_s)
<< Person(u"Tyler Rhodes"_s)
<< Person(u"Vikram Ambrose"_s)
<< Person(u"David Guillen"_s)
<< Person(u"Krzysztof Sobiecki"_s)
<< Person(u"Valeriy Malov"_s)
<< Person(u"Nick Lanham"_s);
strawberry_thanks_ \
<< Person(u"Mark Kretschmann"_s)
<< Person(u"Max Howell"_s)
<< Person(u"Artur Rona"_s)
<< Person(u"Robert-André Mauchin"_s)
<< Person(u"Thomas Pierson"_s)
<< Person(u"Fabio Loli"_s);
QFont title_font;
title_font.setBold(true);
title_font.setPointSize(title_font.pointSize() + 4);
@@ -50,6 +97,8 @@ About::About(QWidget *parent) : QDialog(parent), ui_{} {
ui_.label_title->setFont(title_font);
ui_.label_title->setText(windowTitle());
ui_.label_text->setText(MainHtml());
ui_.text_contributors->document()->setDefaultStyleSheet(QStringLiteral("a {color: %1; }").arg(palette().text().color().name()));
ui_.text_contributors->setText(ContributorsHtml());
ui_.buttonBox->button(QDialogButtonBox::Close)->setShortcut(QKeySequence::Close);
@@ -64,17 +113,94 @@ QString About::MainHtml() const {
ret += "</p>"_L1;
ret += "<p>"_L1;
ret += tr("Fork of %1.").arg(QStringLiteral("<a style=\"color:%1;\" href=\"https://github.com/clementine-player/Clementine\">Clementine</a>").arg(palette().text().color().name()));
ret += tr("Strawberry is a music player and music collection organizer.");
ret += "<br />"_L1;
ret += tr("It is a fork of Clementine released in 2018 aimed at music collectors and audiophiles.");
ret += "</p>"_L1;
ret += "<p>"_L1;
ret += tr("Source code: %1").arg(QStringLiteral("<a style=\"color:%1;\" href=\"https://gitea.dryark.com/dryark/strawberry\">gitea.dryark.com/dryark/strawberry</a>").arg(palette().text().color().name()));
ret += tr("Strawberry is free software released under GPL. The source code is available on %1").arg(QStringLiteral("<a style=\"color:%1;\" href=\"https://github.com/strawberrymusicplayer/strawberry\">GitHub</a>.").arg(palette().text().color().name()));
ret += "<br />"_L1;
ret += tr("You should have received a copy of the GNU General Public License along with this program. If not, see %1").arg(QStringLiteral("<a style=\"color:%1;\" href=\"http://www.gnu.org/licenses/\">http://www.gnu.org/licenses/</a>").arg(palette().text().color().name()));
ret += "</p>"_L1;
ret += "<p>"_L1;
ret += tr("License: %1").arg(QStringLiteral("<a style=\"color:%1;\" href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GPLv3</a>").arg(palette().text().color().name()));
ret += tr("If you like Strawberry and can make use of it, consider sponsoring or donating.");
ret += "<br />"_L1;
ret += tr("You can sponsor the author on %1 or %2. You can also make a one-time payment through %3.").arg(
QStringLiteral("<a style=\"color:%1;\" href=\"https://www.patreon.com/jonaskvinge\">Patreon</a>").arg(palette().text().color().name()),
QStringLiteral("<a style=\"color:%1;\" href=\"https://github.com/sponsors/jonaski\">GitHub</a>").arg(palette().text().color().name()),
QStringLiteral("<a style=\"color:%1;\" href=\"https://paypal.me/jonaskvinge\">paypal.me/jonaskvinge</a>").arg(palette().text().color().name())
);
ret += "</p>"_L1;
return ret;
}
QString About::ContributorsHtml() const {
QString ret;
ret += "<p>"_L1;
ret += "<b>"_L1;
ret += tr("Author and maintainer");
ret += "</b>"_L1;
for (const Person &person : strawberry_authors_) {
ret += "<br />"_L1 + PersonToHtml(person);
}
ret += "</p>"_L1;
ret += "<p>"_L1;
ret += "<b>"_L1;
ret += tr("Contributors");
ret += "</b>"_L1;
for (const Person &person : strawberry_contributors_) {
ret += "<br />"_L1 + PersonToHtml(person);
}
ret += "</p>"_L1;
ret += "<p>"_L1;
ret += "<b>"_L1;
ret += tr("Clementine authors");
ret += "</b>"_L1;
for (const Person &person : clementine_authors_) {
ret += "<br />"_L1 + PersonToHtml(person);
}
ret += "</p>"_L1;
ret += "<p>"_L1;
ret += "<b>"_L1;
ret += tr("Clementine contributors");
ret += "</b>"_L1;
for (const Person &person : clementine_contributors_) {
ret += "<br />"_L1 + PersonToHtml(person);
}
ret += "</p>"_L1;
ret += "<p>"_L1;
ret += "<b>"_L1;
ret += tr("Thanks to");
ret += "</b>"_L1;
for (const Person &person : strawberry_thanks_) {
ret += "<br />"_L1 + PersonToHtml(person);
}
ret += "</p>"_L1;
ret += "<p>"_L1;
ret += tr("Thanks to all the other Amarok and Clementine contributors.");
ret += "</p>"_L1;
return ret;
}
QString About::PersonToHtml(const Person &person) {
if (person.email.isEmpty()) {
return person.name;
}
return QStringLiteral("%1 &lt;<a href=\"mailto:%2\">%3</a>&gt;").arg(person.name, person.email, person.email);
}

View File

@@ -26,6 +26,7 @@
#include <QObject>
#include <QDialog>
#include <QList>
#include <QString>
#include "ui_about.h"
@@ -39,10 +40,25 @@ class About : public QDialog {
explicit About(QWidget *parent = nullptr);
private:
struct Person {
explicit Person(const QString &n, const QString &e = QString()) : name(n), email(e) {}
bool operator<(const Person &other) const { return name < other.name; }
QString name;
QString email;
};
QString MainHtml() const;
QString ContributorsHtml() const;
static QString PersonToHtml(const Person &person);
private:
Ui::About ui_;
QList<Person> strawberry_authors_;
QList<Person> strawberry_contributors_;
QList<Person> strawberry_thanks_;
QList<Person> clementine_authors_;
QList<Person> clementine_contributors_;
};
#endif // ABOUT_H

View File

@@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>500</width>
<height>320</height>
<height>500</height>
</rect>
</property>
<property name="focusPolicy">
@@ -149,6 +149,19 @@
</property>
</spacer>
</item>
<item>
<widget class="QTextBrowser" name="text_contributors">
<property name="minimumSize">
<size>
<width>0</width>
<height>200</height>
</size>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="spacer_bottom">
<property name="orientation">

View File

@@ -411,17 +411,6 @@ bool EditTagDialog::eventFilter(QObject *o, QEvent *e) {
}
SongList EditTagDialog::songs() const {
SongList result;
for (const Data &d : data_) {
result << d.current_;
}
return result;
}
bool EditTagDialog::SetLoading(const QString &message) {
const bool loading = !message.isEmpty();
@@ -1410,12 +1399,6 @@ void EditTagDialog::SaveData() {
}
if (save_tags || save_playcount || save_rating || save_embedded_cover) {
// For streaming tracks, skip tag writing since there's no local file.
// The metadata will be applied directly to the playlist item in MainWindow::EditTagDialogAccepted.
if (ref.current_.is_stream()) {
continue;
}
// Not to confuse the collection model.
if (ref.current_.track() <= 0) { ref.current_.set_track(-1); }
if (ref.current_.disc() <= 0) { ref.current_.set_disc(-1); }

View File

@@ -85,7 +85,7 @@ class EditTagDialog : public QDialog {
void SetSongs(const SongList &songs, const PlaylistItemPtrList &items = PlaylistItemPtrList());
PlaylistItemPtrList playlist_items() const { return playlist_items_; }
SongList songs() const;
void accept() override;
Q_SIGNALS:

View File

@@ -29,17 +29,13 @@
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QStandardPaths>
#include <QSettings>
#include <QMessageBox>
#include <QScrollBar>
#include <QLineEdit>
#include <QToolButton>
#include <QFileDialog>
#include <QSpacerItem>
#include <QtEvents>
#include "constants/appearancesettings.h"
#include "constants/filefilterconstants.h"
#include "includes/shared_ptr.h"
#include "core/deletefiles.h"
#include "core/filesystemmusicstorage.h"
@@ -49,11 +45,10 @@
#include "dialogs/deleteconfirmationdialog.h"
#include "fileview.h"
#include "fileviewlist.h"
#include "fileviewtree.h"
#include "fileviewtreemodel.h"
#include "fileviewtreeitem.h"
#include "ui_fileview.h"
#include "organize/organizeerrordialog.h"
#include "constants/appearancesettings.h"
#include "constants/filefilterconstants.h"
using std::make_unique;
using namespace Qt::Literals::StringLiterals;
@@ -62,12 +57,9 @@ FileView::FileView(QWidget *parent)
: QWidget(parent),
ui_(new Ui_FileView),
model_(nullptr),
tree_model_(nullptr),
undo_stack_(new QUndoStack(this)),
task_manager_(nullptr),
storage_(new FilesystemMusicStorage(Song::Source::LocalFile, u"/"_s)),
tree_view_active_(false),
view_mode_spacer_(nullptr) {
storage_(new FilesystemMusicStorage(Song::Source::LocalFile, u"/"_s)) {
ui_->setupUi(this);
@@ -76,14 +68,12 @@ FileView::FileView(QWidget *parent)
ui_->forward->setIcon(IconLoader::Load(u"go-next"_s));
ui_->home->setIcon(IconLoader::Load(u"go-home"_s));
ui_->up->setIcon(IconLoader::Load(u"go-up"_s));
ui_->toggle_view->setIcon(IconLoader::Load(u"view-choose"_s));
QObject::connect(ui_->back, &QToolButton::clicked, undo_stack_, &QUndoStack::undo);
QObject::connect(ui_->forward, &QToolButton::clicked, undo_stack_, &QUndoStack::redo);
QObject::connect(ui_->home, &QToolButton::clicked, this, &FileView::FileHome);
QObject::connect(ui_->up, &QToolButton::clicked, this, &FileView::FileUp);
QObject::connect(ui_->path, &QLineEdit::textChanged, this, &FileView::ChangeFilePath);
QObject::connect(ui_->toggle_view, &QToolButton::clicked, this, &FileView::ToggleViewMode);
QObject::connect(undo_stack_, &QUndoStack::canUndoChanged, ui_->back, &FileView::setEnabled);
QObject::connect(undo_stack_, &QUndoStack::canRedoChanged, ui_->forward, &FileView::setEnabled);
@@ -97,22 +87,6 @@ FileView::FileView(QWidget *parent)
QObject::connect(ui_->list, &FileViewList::Delete, this, &FileView::Delete);
QObject::connect(ui_->list, &FileViewList::EditTags, this, &FileView::EditTags);
// Connect tree view signals
QObject::connect(ui_->tree, &FileViewTree::AddToPlaylist, this, &FileView::AddToPlaylist);
QObject::connect(ui_->tree, &FileViewTree::CopyToCollection, this, &FileView::CopyToCollection);
QObject::connect(ui_->tree, &FileViewTree::MoveToCollection, this, &FileView::MoveToCollection);
QObject::connect(ui_->tree, &FileViewTree::CopyToDevice, this, &FileView::CopyToDevice);
QObject::connect(ui_->tree, &FileViewTree::Delete, this, &FileView::Delete);
QObject::connect(ui_->tree, &FileViewTree::EditTags, this, &FileView::EditTags);
QObject::connect(ui_->tree, &FileViewTree::activated, this, &FileView::ItemActivated);
QObject::connect(ui_->tree, &FileViewTree::doubleClicked, this, &FileView::ItemDoubleClick);
// Setup tree root management buttons
ui_->add_tree_root->setIcon(IconLoader::Load(u"folder-new"_s));
ui_->remove_tree_root->setIcon(IconLoader::Load(u"list-remove"_s));
QObject::connect(ui_->add_tree_root, &QToolButton::clicked, this, &FileView::AddRootButtonClicked);
QObject::connect(ui_->remove_tree_root, &QToolButton::clicked, this, &FileView::RemoveRootButtonClicked);
QString filter = QLatin1String(kFileFilter);
filter_list_ << filter.split(u' ');
@@ -135,19 +109,6 @@ void FileView::ReloadSettings() {
ui_->forward->setIconSize(QSize(iconsize, iconsize));
ui_->home->setIconSize(QSize(iconsize, iconsize));
ui_->up->setIconSize(QSize(iconsize, iconsize));
ui_->toggle_view->setIconSize(QSize(iconsize, iconsize));
ui_->add_tree_root->setIconSize(QSize(iconsize, iconsize));
ui_->remove_tree_root->setIconSize(QSize(iconsize, iconsize));
// Load tree root paths setting
Settings file_settings;
file_settings.beginGroup(u"FileView"_s);
tree_root_paths_ = file_settings.value(u"tree_root_paths"_s, QStandardPaths::standardLocations(QStandardPaths::StandardLocation::MusicLocation)).toStringList();
tree_view_active_ = file_settings.value(u"tree_view_active"_s, false).toBool();
file_settings.endGroup();
// Set initial view mode
UpdateViewModeUI();
}
@@ -219,46 +180,24 @@ void FileView::ChangeFilePathWithoutUndo(const QString &new_path) {
}
void FileView::ItemActivated(const QModelIndex &idx) {
// Only handle activation for list view (not tree view)
if (!tree_view_active_ && model_->isDir(idx)) {
if (model_->isDir(idx))
ChangeFilePath(model_->filePath(idx));
}
}
void FileView::ItemDoubleClick(const QModelIndex &idx) {
QString file_path;
bool is_file = false;
// Handle tree view with virtual roots
if (tree_view_active_ && tree_model_) {
QVariant type_var = tree_model_->data(idx, FileViewTreeModel::Role_Type);
if (type_var.isValid()) {
FileViewTreeItem::Type item_type = type_var.value<FileViewTreeItem::Type>();
// Only handle files, ignore directories and virtual roots
if (item_type == FileViewTreeItem::Type::File) {
file_path = tree_model_->data(idx, FileViewTreeModel::Role_FilePath).toString();
is_file = true;
}
}
}
// Handle list view with filesystem model
else if (!tree_view_active_ && model_) {
if (!model_->isDir(idx)) {
file_path = model_->filePath(idx);
is_file = true;
}
if (model_->isDir(idx)) {
return;
}
// Add file to playlist if it's a valid file
if (is_file && !file_path.isEmpty()) {
QString file_path = model_->filePath(idx);
MimeData *mimedata = new MimeData;
mimedata->from_doubleclick_ = true;
mimedata->setUrls(QList<QUrl>() << QUrl::fromLocalFile(file_path));
mimedata->name_for_new_playlist_ = file_path;
Q_EMIT AddToPlaylist(mimedata);
}
}
@@ -333,156 +272,12 @@ void FileView::showEvent(QShowEvent *e) {
model_->setNameFilterDisables(false);
ui_->list->setModel(model_);
// Create tree model
tree_model_ = new FileViewTreeModel(this);
tree_model_->SetNameFilters(filter_list_);
SetupTreeView();
ChangeFilePathWithoutUndo(QDir::homePath());
if (!lazy_set_path_.isEmpty()) ChangeFilePathWithoutUndo(lazy_set_path_);
}
void FileView::SetupTreeView() {
// Use the new tree model with virtual roots
ui_->tree->setModel(tree_model_);
// Set the root paths in the model
tree_model_->SetRootPaths(tree_root_paths_);
// No need to set root index - the model handles virtual roots
}
void FileView::ToggleViewMode() {
tree_view_active_ = !tree_view_active_;
UpdateViewModeUI();
// Save the preference
Settings s;
s.beginGroup(u"FileView"_s);
s.setValue(u"tree_view_active"_s, tree_view_active_);
s.endGroup();
}
void FileView::UpdateViewModeUI() {
if (tree_view_active_) {
ui_->view_stack->setCurrentWidget(ui_->tree_page);
// Hide navigation controls in tree view mode
ui_->back->setVisible(false);
ui_->forward->setVisible(false);
ui_->up->setVisible(false);
ui_->home->setVisible(false);
ui_->path->setVisible(false);
// Show tree root management buttons
ui_->add_tree_root->setVisible(true);
ui_->remove_tree_root->setVisible(true);
// Insert spacer in tree view if not already present
if (!view_mode_spacer_) {
view_mode_spacer_ = new QSpacerItem(0, 0, QSizePolicy::Expanding, QSizePolicy::Minimum);
ui_->horizontalLayout->insertSpacerItem(ui_->horizontalLayout->indexOf(ui_->toggle_view), view_mode_spacer_);
}
}
else {
ui_->view_stack->setCurrentWidget(ui_->list_page);
// Show navigation controls in list view mode
ui_->back->setVisible(true);
ui_->forward->setVisible(true);
ui_->up->setVisible(true);
ui_->home->setVisible(true);
ui_->path->setVisible(true);
// Hide tree root management buttons in list view
ui_->add_tree_root->setVisible(false);
ui_->remove_tree_root->setVisible(false);
// Remove spacer in list view
if (view_mode_spacer_) {
ui_->horizontalLayout->removeItem(view_mode_spacer_);
delete view_mode_spacer_;
view_mode_spacer_ = nullptr;
}
}
}
void FileView::AddTreeRootPath(const QString &path) {
if (!tree_root_paths_.contains(path)) {
tree_root_paths_.append(path);
SaveTreeRootPaths();
// Refresh the tree view to show the new root
if (tree_model_) {
SetupTreeView();
}
}
}
void FileView::RemoveTreeRootPath(const QString &path) {
tree_root_paths_.removeAll(path);
SaveTreeRootPaths();
// Refresh the tree view
if (tree_model_) {
SetupTreeView();
}
}
void FileView::SaveTreeRootPaths() {
Settings s;
s.beginGroup(u"FileView"_s);
s.setValue(u"tree_root_paths"_s, tree_root_paths_);
s.endGroup();
}
void FileView::AddRootButtonClicked() {
const QString dir = QFileDialog::getExistingDirectory(this, tr("Select folder to add as tree root"), tree_root_paths_.isEmpty() ? QDir::homePath() : tree_root_paths_.first(), QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
if (!dir.isEmpty()) {
AddTreeRootPath(dir);
}
}
void FileView::RemoveRootButtonClicked() {
// Get currently selected item in tree
QModelIndex current = ui_->tree->currentIndex();
if (!current.isValid()) return;
QString path;
// Get the file path from the appropriate model
if (tree_model_) {
path = tree_model_->data(current, FileViewTreeModel::Role_FilePath).toString();
}
if (path.isEmpty()) return;
const QString clean_path = QDir::cleanPath(path);
// Check if this path or any parent is a configured root
for (const QString &root : std::as_const(tree_root_paths_)) {
const QString clean_root = QDir::cleanPath(root);
if (clean_path == clean_root || clean_path.startsWith(clean_root + QDir::separator())) {
RemoveTreeRootPath(root);
return;
}
}
}
void FileView::keyPressEvent(QKeyEvent *e) {
switch (e->key()) {

View File

@@ -40,12 +40,10 @@ class QFileIconProvider;
class QUndoStack;
class QKeyEvent;
class QShowEvent;
class QSpacerItem;
class MusicStorage;
class TaskManager;
class Ui_FileView;
class FileViewTreeModel;
class FileView : public QWidget {
Q_OBJECT
@@ -78,22 +76,12 @@ class FileView : public QWidget {
void ChangeFilePath(const QString &new_path);
void ItemActivated(const QModelIndex &idx);
void ItemDoubleClick(const QModelIndex &idx);
void ToggleViewMode();
void Delete(const QStringList &filenames);
void DeleteFinished(const SongList &songs_with_errors);
public Q_SLOTS:
void AddTreeRootPath(const QString &path);
void RemoveTreeRootPath(const QString &path);
private:
void ChangeFilePathWithoutUndo(const QString &new_path);
void SetupTreeView();
void SaveTreeRootPaths();
void AddRootButtonClicked();
void RemoveRootButtonClicked();
void UpdateViewModeUI();
private:
class UndoCommand : public QUndoCommand {
@@ -122,21 +110,16 @@ class FileView : public QWidget {
Ui_FileView *ui_;
QFileSystemModel *model_;
FileViewTreeModel *tree_model_;
QUndoStack *undo_stack_;
SharedPtr<TaskManager> task_manager_;
SharedPtr<MusicStorage> storage_;
QString lazy_set_path_;
QStringList tree_root_paths_;
QStringList filter_list_;
ScopedPtr<QFileIconProvider> file_icon_provider_;
bool tree_view_active_;
QSpacerItem *view_mode_spacer_;
};
#endif // FILEVIEW_H

View File

@@ -95,78 +95,8 @@
<item>
<widget class="QLineEdit" name="path"/>
</item>
<item>
<widget class="QToolButton" name="add_tree_root">
<property name="toolTip">
<string>Add root directory</string>
</property>
<property name="iconSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="remove_tree_root">
<property name="toolTip">
<string>Remove selected root directory</string>
</property>
<property name="iconSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="toggle_view">
<property name="iconSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
<property name="toolTip">
<string>Toggle between list and tree view</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QStackedWidget" name="view_stack">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="list_page">
<layout class="QVBoxLayout" name="list_layout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="FileViewList" name="list">
<property name="dragEnabled">
@@ -191,62 +121,12 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="tree_page">
<layout class="QVBoxLayout" name="tree_layout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="FileViewTree" name="tree">
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragOnly</enum>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="iconSize">
<size>
<width>16</width>
<height>16</height>
</size>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>FileViewList</class>
<extends>QListView</extends>
<header>fileview/fileviewlist.h</header>
</customwidget>
<customwidget>
<class>FileViewTree</class>
<extends>QTreeView</extends>
<header>fileview/fileviewtree.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>

View File

@@ -99,7 +99,7 @@ MimeData *FileViewList::MimeDataFromSelection() const {
const QStringList filenames = FilenamesFromSelection();
// If just one folder selected - use its path as the new playlist's name
// if just one folder selected - use its path as the new playlist's name
if (filenames.size() == 1 && QFileInfo(filenames.first()).isDir()) {
if (filenames.first().length() > 20) {
mimedata->name_for_new_playlist_ = QDir(filenames.first()).dirName();
@@ -108,7 +108,7 @@ MimeData *FileViewList::MimeDataFromSelection() const {
mimedata->name_for_new_playlist_ = filenames.first();
}
}
// Otherwise, use the current root path
// otherwise, use the current root path
else {
QString path = qobject_cast<QFileSystemModel*>(model())->rootPath();
if (path.length() > 20) {
@@ -196,11 +196,11 @@ void FileViewList::mousePressEvent(QMouseEvent *e) {
case Qt::XButton2:
Q_EMIT Forward();
break;
// Enqueue to playlist with middleClick
// enqueue to playlist with middleClick
case Qt::MiddleButton:{
QListView::mousePressEvent(e);
// We need to update the menu selection
// we need to update the menu selection
menu_selection_ = selectionModel()->selection();
MimeData *mimedata = new MimeData;

View File

@@ -1,205 +0,0 @@
/*
* 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 <algorithm>
#include <utility>
#include <QWidget>
#include <QAbstractItemModel>
#include <QFileInfo>
#include <QDir>
#include <QMenu>
#include <QUrl>
#include <QCollator>
#include <QtEvents>
#include "core/iconloader.h"
#include "core/mimedata.h"
#include "utilities/filemanagerutils.h"
#include "fileviewtree.h"
#include "fileviewtreemodel.h"
using namespace Qt::Literals::StringLiterals;
FileViewTree::FileViewTree(QWidget *parent)
: QTreeView(parent),
menu_(new QMenu(this)) {
menu_->addAction(IconLoader::Load(u"media-playback-start"_s), tr("Append to current playlist"), this, &FileViewTree::AddToPlaylistSlot);
menu_->addAction(IconLoader::Load(u"media-playback-start"_s), tr("Replace current playlist"), this, &FileViewTree::LoadSlot);
menu_->addAction(IconLoader::Load(u"document-new"_s), tr("Open in new playlist"), this, &FileViewTree::OpenInNewPlaylistSlot);
menu_->addSeparator();
menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy to collection..."), this, &FileViewTree::CopyToCollectionSlot);
menu_->addAction(IconLoader::Load(u"go-jump"_s), tr("Move to collection..."), this, &FileViewTree::MoveToCollectionSlot);
menu_->addAction(IconLoader::Load(u"device"_s), tr("Copy to device..."), this, &FileViewTree::CopyToDeviceSlot);
menu_->addAction(IconLoader::Load(u"edit-delete"_s), tr("Delete from disk..."), this, &FileViewTree::DeleteSlot);
menu_->addSeparator();
menu_->addAction(IconLoader::Load(u"edit-rename"_s), tr("Edit track information..."), this, &FileViewTree::EditTagsSlot);
menu_->addAction(IconLoader::Load(u"document-open-folder"_s), tr("Show in file browser..."), this, &FileViewTree::ShowInBrowser);
setAttribute(Qt::WA_MacShowFocusRect, false);
setHeaderHidden(true);
setUniformRowHeights(true);
}
void FileViewTree::contextMenuEvent(QContextMenuEvent *e) {
menu_selection_ = selectionModel()->selection();
menu_->popup(e->globalPos());
e->accept();
}
QStringList FileViewTree::FilenamesFromSelection() const {
QStringList filenames;
const QModelIndexList indexes = menu_selection_.indexes();
FileViewTreeModel *tree_model = qobject_cast<FileViewTreeModel*>(model());
if (tree_model) {
for (const QModelIndex &index : indexes) {
if (index.column() == 0) {
QString path = tree_model->data(index, FileViewTreeModel::Role_FilePath).toString();
if (!path.isEmpty()) {
filenames << path;
}
}
}
}
QCollator collator;
collator.setNumericMode(true);
std::sort(filenames.begin(), filenames.end(), collator);
return filenames;
}
QList<QUrl> FileViewTree::UrlListFromSelection() const {
QList<QUrl> urls;
const QStringList filenames = FilenamesFromSelection();
urls.reserve(filenames.count());
for (const QString &filename : std::as_const(filenames)) {
urls << QUrl::fromLocalFile(filename);
}
return urls;
}
MimeData *FileViewTree::MimeDataFromSelection() const {
MimeData *mimedata = new MimeData;
mimedata->setUrls(UrlListFromSelection());
const QStringList filenames = FilenamesFromSelection();
// if just one folder selected - use its path as the new playlist's name
if (filenames.size() == 1 && QFileInfo(filenames.first()).isDir()) {
if (filenames.first().length() > 20) {
mimedata->name_for_new_playlist_ = QDir(filenames.first()).dirName();
}
else {
mimedata->name_for_new_playlist_ = filenames.first();
}
}
// otherwise, use "Files" as default
else {
mimedata->name_for_new_playlist_ = tr("Files");
}
return mimedata;
}
void FileViewTree::LoadSlot() {
MimeData *mimedata = MimeDataFromSelection();
mimedata->clear_first_ = true;
Q_EMIT AddToPlaylist(mimedata);
}
void FileViewTree::AddToPlaylistSlot() {
Q_EMIT AddToPlaylist(MimeDataFromSelection());
}
void FileViewTree::OpenInNewPlaylistSlot() {
MimeData *mimedata = MimeDataFromSelection();
mimedata->open_in_new_playlist_ = true;
Q_EMIT AddToPlaylist(mimedata);
}
void FileViewTree::CopyToCollectionSlot() {
Q_EMIT CopyToCollection(UrlListFromSelection());
}
void FileViewTree::MoveToCollectionSlot() {
Q_EMIT MoveToCollection(UrlListFromSelection());
}
void FileViewTree::CopyToDeviceSlot() {
Q_EMIT CopyToDevice(UrlListFromSelection());
}
void FileViewTree::DeleteSlot() {
Q_EMIT Delete(FilenamesFromSelection());
}
void FileViewTree::EditTagsSlot() {
Q_EMIT EditTags(UrlListFromSelection());
}
void FileViewTree::mousePressEvent(QMouseEvent *e) {
switch (e->button()) {
// Enqueue to playlist with middleClick
case Qt::MiddleButton:{
QTreeView::mousePressEvent(e);
// We need to update the menu selection
QItemSelectionModel *selection_model = selectionModel();
if (!selection_model) {
e->ignore();
return;
}
menu_selection_ = selection_model->selection();
MimeData *mimedata = new MimeData;
mimedata->setUrls(UrlListFromSelection());
mimedata->enqueue_now_ = true;
Q_EMIT AddToPlaylist(mimedata);
break;
}
default:
QTreeView::mousePressEvent(e);
break;
}
}
void FileViewTree::ShowInBrowser() {
Utilities::OpenInFileBrowser(UrlListFromSelection());
}

View File

@@ -1,78 +0,0 @@
/*
* 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 FILEVIEWTREE_H
#define FILEVIEWTREE_H
#include <QObject>
#include <QTreeView>
#include <QList>
#include <QUrl>
#include <QString>
#include <QStringList>
class QWidget;
class QMimeData;
class QMenu;
class QMouseEvent;
class QContextMenuEvent;
class MimeData;
class FileViewTree : public QTreeView {
Q_OBJECT
public:
explicit FileViewTree(QWidget *parent = nullptr);
void mousePressEvent(QMouseEvent *e) override;
Q_SIGNALS:
void AddToPlaylist(QMimeData *data);
void CopyToCollection(const QList<QUrl> &urls);
void MoveToCollection(const QList<QUrl> &urls);
void CopyToDevice(const QList<QUrl> &urls);
void Delete(const QStringList &filenames);
void EditTags(const QList<QUrl> &urls);
protected:
void contextMenuEvent(QContextMenuEvent *e) override;
private:
QStringList FilenamesFromSelection() const;
QList<QUrl> UrlListFromSelection() const;
MimeData *MimeDataFromSelection() const;
private Q_SLOTS:
void LoadSlot();
void AddToPlaylistSlot();
void OpenInNewPlaylistSlot();
void CopyToCollectionSlot();
void MoveToCollectionSlot();
void CopyToDeviceSlot();
void DeleteSlot();
void EditTagsSlot();
void ShowInBrowser();
private:
QMenu *menu_;
QItemSelection menu_selection_;
};
#endif // FILEVIEWTREE_H

View File

@@ -1,52 +0,0 @@
/*
* 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 FILEVIEWTREEITEM_H
#define FILEVIEWTREEITEM_H
#include "config.h"
#include <QFileInfo>
#include "core/simpletreeitem.h"
class FileViewTreeItem : public SimpleTreeItem<FileViewTreeItem> {
public:
enum class Type {
Root, // Hidden root
VirtualRoot, // User-configured root paths
Directory, // File system directory
File // File system file
};
explicit FileViewTreeItem(SimpleTreeModel<FileViewTreeItem> *_model) : SimpleTreeItem<FileViewTreeItem>(_model), type(Type::Root), lazy_loaded(false) {}
explicit FileViewTreeItem(const Type _type, FileViewTreeItem *_parent = nullptr) : SimpleTreeItem<FileViewTreeItem>(_parent), type(_type), lazy_loaded(false) {}
Type type;
QString file_path; // Absolute file system path
QFileInfo file_info; // Cached file info
bool lazy_loaded; // Whether children have been loaded
private:
Q_DISABLE_COPY(FileViewTreeItem)
};
Q_DECLARE_METATYPE(FileViewTreeItem::Type)
#endif // FILEVIEWTREEITEM_H

View File

@@ -1,246 +0,0 @@
/*
* 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 <QObject>
#include <QVariant>
#include <QString>
#include <QStringList>
#include <QList>
#include <QMap>
#include <QDir>
#include <QFileInfo>
#include <QFileIconProvider>
#include <QMimeData>
#include <QUrl>
#include <QIcon>
#include "core/simpletreemodel.h"
#include "core/logging.h"
#include "fileviewtreemodel.h"
#include "fileviewtreeitem.h"
using namespace Qt::Literals::StringLiterals;
FileViewTreeModel::FileViewTreeModel(QObject *parent)
: SimpleTreeModel<FileViewTreeItem>(new FileViewTreeItem(this), parent),
icon_provider_(new QFileIconProvider()) {
}
FileViewTreeModel::~FileViewTreeModel() {
delete root_;
delete icon_provider_;
}
Qt::ItemFlags FileViewTreeModel::flags(const QModelIndex &idx) const {
const FileViewTreeItem *item = IndexToItem(idx);
if (!item) return Qt::NoItemFlags;
switch (item->type) {
case FileViewTreeItem::Type::VirtualRoot:
case FileViewTreeItem::Type::Directory:
case FileViewTreeItem::Type::File:
return Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsDragEnabled;
case FileViewTreeItem::Type::Root:
default:
return Qt::ItemIsEnabled;
}
}
QVariant FileViewTreeModel::data(const QModelIndex &idx, const int role) const {
if (!idx.isValid()) return QVariant();
const FileViewTreeItem *item = IndexToItem(idx);
if (!item) return QVariant();
switch (role) {
case Qt::DisplayRole:
if (item->type == FileViewTreeItem::Type::VirtualRoot) {
return item->display_text.isEmpty() ? item->file_path : item->display_text;
}
return item->file_info.fileName();
case Qt::DecorationRole:
return GetIcon(item);
case Role_Type:
return QVariant::fromValue(item->type);
case Role_FilePath:
return item->file_path;
case Role_FileName:
return item->file_info.fileName();
default:
return QVariant();
}
}
bool FileViewTreeModel::hasChildren(const QModelIndex &parent) const {
const FileViewTreeItem *item = IndexToItem(parent);
if (!item) return false;
// Root and VirtualRoot always have children (or can have them)
if (item->type == FileViewTreeItem::Type::Root) return true;
if (item->type == FileViewTreeItem::Type::VirtualRoot) return true;
// Directories can have children
if (item->type == FileViewTreeItem::Type::Directory) {
return true;
}
// Files don't have children
return false;
}
bool FileViewTreeModel::canFetchMore(const QModelIndex &parent) const {
const FileViewTreeItem *item = IndexToItem(parent);
if (!item) return false;
// Can fetch more if not yet lazy loaded
return !item->lazy_loaded && (item->type == FileViewTreeItem::Type::VirtualRoot || item->type == FileViewTreeItem::Type::Directory);
}
void FileViewTreeModel::fetchMore(const QModelIndex &parent) {
FileViewTreeItem *item = IndexToItem(parent);
if (!item || item->lazy_loaded) return;
LazyLoad(item);
}
void FileViewTreeModel::LazyLoad(FileViewTreeItem *item) {
if (item->lazy_loaded) return;
QDir dir(item->file_path);
if (!dir.exists()) {
item->lazy_loaded = true;
return;
}
// Apply name filters
const QDir::Filters filters = QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot;
if (!name_filters_.isEmpty()) {
dir.setNameFilters(name_filters_);
}
const QFileInfoList entries = dir.entryInfoList(filters, QDir::Name | QDir::DirsFirst);
if (!entries.isEmpty()) {
BeginInsert(item, 0, static_cast<int>(entries.count()) - 1);
for (const QFileInfo &entry : entries) {
FileViewTreeItem *child = new FileViewTreeItem(
entry.isDir() ? FileViewTreeItem::Type::Directory : FileViewTreeItem::Type::File,
item
);
child->file_path = entry.absoluteFilePath();
child->file_info = entry;
child->lazy_loaded = false;
child->display_text = entry.fileName();
}
EndInsert();
}
item->lazy_loaded = true;
}
QIcon FileViewTreeModel::GetIcon(const FileViewTreeItem *item) const {
if (!item) return QIcon();
switch (item->type) {
case FileViewTreeItem::Type::VirtualRoot:
case FileViewTreeItem::Type::Directory:
return icon_provider_->icon(QFileIconProvider::Folder);
case FileViewTreeItem::Type::File:
return icon_provider_->icon(item->file_info);
default:
return QIcon();
}
}
QStringList FileViewTreeModel::mimeTypes() const {
return QStringList() << u"text/uri-list"_s;
}
QMimeData *FileViewTreeModel::mimeData(const QModelIndexList &indexes) const {
if (indexes.isEmpty()) return nullptr;
QList<QUrl> urls;
for (const QModelIndex &idx : indexes) {
const FileViewTreeItem *item = IndexToItem(idx);
if (item && (item->type == FileViewTreeItem::Type::File || item->type == FileViewTreeItem::Type::Directory || item->type == FileViewTreeItem::Type::VirtualRoot)) {
urls << QUrl::fromLocalFile(item->file_path);
}
}
if (urls.isEmpty()) return nullptr;
QMimeData *data = new QMimeData();
data->setUrls(urls);
return data;
}
void FileViewTreeModel::SetRootPaths(const QStringList &paths) {
Reset();
for (const QString &path : paths) {
QFileInfo info(path);
if (!info.exists() || !info.isDir()) continue;
FileViewTreeItem *virtual_root = new FileViewTreeItem(FileViewTreeItem::Type::VirtualRoot, root_);
virtual_root->file_path = info.absoluteFilePath();
virtual_root->file_info = info;
virtual_root->display_text = info.absoluteFilePath();
virtual_root->lazy_loaded = false;
}
}
void FileViewTreeModel::SetNameFilters(const QStringList &filters) {
name_filters_ = filters;
}
void FileViewTreeModel::Reset() {
beginResetModel();
// Clear children without notifications since we're in a reset
qDeleteAll(root_->children);
root_->children.clear();
endResetModel();
}

View File

@@ -1,72 +0,0 @@
/*
* 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 FILEVIEWTREEMODEL_H
#define FILEVIEWTREEMODEL_H
#include "config.h"
#include <QObject>
#include <QVariant>
#include <QStringList>
#include <QIcon>
#include "core/simpletreemodel.h"
#include "fileviewtreeitem.h"
class QFileIconProvider;
class QMimeData;
class FileViewTreeModel : public SimpleTreeModel<FileViewTreeItem> {
Q_OBJECT
public:
explicit FileViewTreeModel(QObject *parent = nullptr);
~FileViewTreeModel() override;
enum Role {
Role_Type = Qt::UserRole + 1,
Role_FilePath,
Role_FileName,
RoleCount
};
// QAbstractItemModel
Qt::ItemFlags flags(const QModelIndex &idx) const override;
QVariant data(const QModelIndex &idx, const int role) const override;
bool hasChildren(const QModelIndex &parent) const override;
bool canFetchMore(const QModelIndex &parent) const override;
void fetchMore(const QModelIndex &parent) override;
QStringList mimeTypes() const override;
QMimeData *mimeData(const QModelIndexList &indexes) const override;
void SetRootPaths(const QStringList &paths);
void SetNameFilters(const QStringList &filters);
private:
void Reset();
void LazyLoad(FileViewTreeItem *item);
QIcon GetIcon(const FileViewTreeItem *item) const;
private:
QFileIconProvider *icon_provider_;
QStringList name_filters_;
};
#endif // FILEVIEWTREEMODEL_H

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
*
* Strawberry is free software: you can redistribute it and/or modify
@@ -21,10 +21,8 @@
*/
#include <QString>
#include <QMap>
#include "constants/timeconstants.h"
#include "core/song.h"
#include "filterparser.h"
#include "filtertreenop.h"
#include "filtertreeand.h"
@@ -33,126 +31,9 @@
#include "filtertreeterm.h"
#include "filtertreecolumnterm.h"
#include "filterparsersearchcomparators.h"
#include "filtercolumn.h"
using namespace Qt::Literals::StringLiterals;
namespace {
enum class FilterOperator {
None,
Eq,
Ne,
Gt,
Ge,
Lt,
Le
};
const QMap<QString, FilterOperator> &GetFilterOperatorsMap() {
static const QMap<QString, FilterOperator> filter_operators_map_ = []() {
QMap<QString, FilterOperator> filter_operators_map;
filter_operators_map.insert(u"="_s, FilterOperator::Eq);
filter_operators_map.insert(u"=="_s, FilterOperator::Eq);
filter_operators_map.insert(u"!="_s, FilterOperator::Ne);
filter_operators_map.insert(u"<>"_s, FilterOperator::Ne);
filter_operators_map.insert(u">"_s, FilterOperator::Gt);
filter_operators_map.insert(u">="_s, FilterOperator::Ge);
filter_operators_map.insert(u"<"_s, FilterOperator::Lt);
filter_operators_map.insert(u"<="_s, FilterOperator::Le);
return filter_operators_map;
}();
return filter_operators_map_;
}
enum class ColumnType {
Unknown,
Text,
Int,
UInt,
Int64,
Float
};
const QMap<QString, FilterColumn> &GetFilterColumnsMap() {
static const QMap<QString, FilterColumn> filter_columns_map_ = []() {
QMap<QString, FilterColumn> filter_columns_map;
filter_columns_map.insert(u"albumartist"_s, FilterColumn::AlbumArtist);
filter_columns_map.insert(u"albumartistsort"_s, FilterColumn::AlbumArtistSort);
filter_columns_map.insert(u"artist"_s, FilterColumn::Artist);
filter_columns_map.insert(u"artistsort"_s, FilterColumn::ArtistSort);
filter_columns_map.insert(u"album"_s, FilterColumn::Album);
filter_columns_map.insert(u"albumsort"_s, FilterColumn::AlbumSort);
filter_columns_map.insert(u"title"_s, FilterColumn::Title);
filter_columns_map.insert(u"titlesort"_s, FilterColumn::TitleSort);
filter_columns_map.insert(u"composer"_s, FilterColumn::Composer);
filter_columns_map.insert(u"composersort"_s, FilterColumn::ComposerSort);
filter_columns_map.insert(u"performer"_s, FilterColumn::Performer);
filter_columns_map.insert(u"performersort"_s, FilterColumn::PerformerSort);
filter_columns_map.insert(u"grouping"_s, FilterColumn::Grouping);
filter_columns_map.insert(u"genre"_s, FilterColumn::Genre);
filter_columns_map.insert(u"comment"_s, FilterColumn::Comment);
filter_columns_map.insert(u"filename"_s, FilterColumn::Filename);
filter_columns_map.insert(u"url"_s, FilterColumn::URL);
filter_columns_map.insert(u"track"_s, FilterColumn::Track);
filter_columns_map.insert(u"year"_s, FilterColumn::Year);
filter_columns_map.insert(u"samplerate"_s, FilterColumn::Samplerate);
filter_columns_map.insert(u"bitdepth"_s, FilterColumn::Bitdepth);
filter_columns_map.insert(u"bitrate"_s, FilterColumn::Bitrate);
filter_columns_map.insert(u"playcount"_s, FilterColumn::Playcount);
filter_columns_map.insert(u"skipcount"_s, FilterColumn::Skipcount);
filter_columns_map.insert(u"length"_s, FilterColumn::Length);
filter_columns_map.insert(u"rating"_s, FilterColumn::Rating);
return filter_columns_map;
}();
return filter_columns_map_;
}
const QMap<FilterColumn, ColumnType> &GetColumnTypesMap() {
static const QMap<FilterColumn, ColumnType> column_types_map_ = []() {
QMap<FilterColumn, ColumnType> column_types_map;
column_types_map.insert(FilterColumn::AlbumArtist, ColumnType::Text);
column_types_map.insert(FilterColumn::AlbumArtistSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Artist, ColumnType::Text);
column_types_map.insert(FilterColumn::ArtistSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Album, ColumnType::Text);
column_types_map.insert(FilterColumn::AlbumSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Title, ColumnType::Text);
column_types_map.insert(FilterColumn::TitleSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Composer, ColumnType::Text);
column_types_map.insert(FilterColumn::ComposerSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Performer, ColumnType::Text);
column_types_map.insert(FilterColumn::PerformerSort, ColumnType::Text);
column_types_map.insert(FilterColumn::Grouping, ColumnType::Text);
column_types_map.insert(FilterColumn::Genre, ColumnType::Text);
column_types_map.insert(FilterColumn::Comment, ColumnType::Text);
column_types_map.insert(FilterColumn::Filename, ColumnType::Text);
column_types_map.insert(FilterColumn::URL, ColumnType::Text);
column_types_map.insert(FilterColumn::Track, ColumnType::Int);
column_types_map.insert(FilterColumn::Year, ColumnType::Int);
column_types_map.insert(FilterColumn::Samplerate, ColumnType::Int);
column_types_map.insert(FilterColumn::Bitdepth, ColumnType::Int);
column_types_map.insert(FilterColumn::Bitrate, ColumnType::Int);
column_types_map.insert(FilterColumn::Playcount, ColumnType::UInt);
column_types_map.insert(FilterColumn::Skipcount, ColumnType::UInt);
column_types_map.insert(FilterColumn::Length, ColumnType::Int64);
column_types_map.insert(FilterColumn::Rating, ColumnType::Float);
return column_types_map;
}();
return column_types_map_;
}
} // namespace
FilterParser::FilterParser(const QString &filter_string) : filter_string_(filter_string), iter_{}, end_{} {}
FilterTree *FilterParser::parse() {
@@ -238,7 +119,7 @@ bool FilterParser::checkAnd() {
bool FilterParser::checkOr(const bool step_over) {
if (!buf_.isEmpty()) {
if (buf_.size() == 2 && buf_[0] == u'O' && buf_[1] == u'R') {
if (buf_ == "OR"_L1) {
if (step_over) {
buf_.clear();
advance();
@@ -260,8 +141,7 @@ bool FilterParser::checkOr(const bool step_over) {
advance();
}
else {
buf_ += u'O';
buf_ += u'R';
buf_ += "OR"_L1;
}
return true;
}
@@ -311,8 +191,6 @@ FilterTree *FilterParser::parseSearchTerm() {
bool in_quotes = false;
bool previous_char_operator = false;
buf_.reserve(32);
for (; iter_ != end_; ++iter_) {
if (previous_char_operator) {
if (iter_->isSpace()) {
@@ -347,7 +225,7 @@ FilterTree *FilterParser::parseSearchTerm() {
prefix += *iter_;
previous_char_operator = true;
}
else if (prefix.size() == 1 && prefix[0] != u'=' && *iter_ == u'=') {
else if (prefix != u'=' && *iter_ == u'=') {
prefix += *iter_;
previous_char_operator = true;
}
@@ -374,145 +252,132 @@ FilterTree *FilterParser::createSearchTermTreeNode(const QString &column, const
return new FilterTreeNop;
}
FilterColumn filter_column = FilterColumn::Unknown;
FilterParserSearchTermComparator *cmp = nullptr;
if (!column.isEmpty()) {
filter_column = GetFilterColumnsMap().value(column, FilterColumn::Unknown);
const ColumnType column_type = GetColumnTypesMap().value(filter_column, ColumnType::Unknown);
const FilterOperator filter_operator = GetFilterOperatorsMap().value(prefix, FilterOperator::None);
switch (column_type) {
case ColumnType::Text:{
switch (filter_operator) {
case FilterOperator::Eq:
if (Song::kTextSearchColumns.contains(column, Qt::CaseInsensitive)) {
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserTextEqComparator(value);
break;
case FilterOperator::Ne:
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserTextNeComparator(value);
break;
default:
}
else {
cmp = new FilterParserTextContainsComparator(value);
break;
}
break;
}
case ColumnType::Int:{
else if (Song::kIntSearchColumns.contains(column, Qt::CaseInsensitive)) {
bool ok = false;
const int number = value.toInt(&ok);
if (!ok) break;
switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
int number = value.toInt(&ok);
if (ok) {
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserIntEqComparator(number);
break;
case FilterOperator::Ne:
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserIntNeComparator(number);
break;
case FilterOperator::Gt:
}
else if (prefix == u'>') {
cmp = new FilterParserIntGtComparator(number);
break;
case FilterOperator::Ge:
}
else if (prefix == ">="_L1) {
cmp = new FilterParserIntGeComparator(number);
break;
case FilterOperator::Lt:
}
else if (prefix == u'<') {
cmp = new FilterParserIntLtComparator(number);
break;
case FilterOperator::Le:
}
else if (prefix == "<="_L1) {
cmp = new FilterParserIntLeComparator(number);
break;
}
break;
else {
cmp = new FilterParserIntEqComparator(number);
}
case ColumnType::UInt:{
}
}
else if (Song::kUIntSearchColumns.contains(column, Qt::CaseInsensitive)) {
bool ok = false;
const uint number = value.toUInt(&ok);
if (!ok) break;
switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
uint number = value.toUInt(&ok);
if (ok) {
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserUIntEqComparator(number);
break;
case FilterOperator::Ne:
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserUIntNeComparator(number);
break;
case FilterOperator::Gt:
}
else if (prefix == u'>') {
cmp = new FilterParserUIntGtComparator(number);
break;
case FilterOperator::Ge:
}
else if (prefix == ">="_L1) {
cmp = new FilterParserUIntGeComparator(number);
break;
case FilterOperator::Lt:
}
else if (prefix == u'<') {
cmp = new FilterParserUIntLtComparator(number);
break;
case FilterOperator::Le:
}
else if (prefix == "<="_L1) {
cmp = new FilterParserUIntLeComparator(number);
break;
}
break;
else {
cmp = new FilterParserUIntEqComparator(number);
}
case ColumnType::Int64:{
}
}
else if (Song::kInt64SearchColumns.contains(column, Qt::CaseInsensitive)) {
qint64 number = 0;
if (filter_column == FilterColumn::Length) {
if (column == "length"_L1) {
number = ParseTime(value) * kNsecPerSec;
}
else {
number = value.toLongLong();
}
switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserInt64EqComparator(number);
break;
case FilterOperator::Ne:
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserInt64NeComparator(number);
break;
case FilterOperator::Gt:
}
else if (prefix == u'>') {
cmp = new FilterParserInt64GtComparator(number);
break;
case FilterOperator::Ge:
}
else if (prefix == ">="_L1) {
cmp = new FilterParserInt64GeComparator(number);
break;
case FilterOperator::Lt:
}
else if (prefix == u'<') {
cmp = new FilterParserInt64LtComparator(number);
break;
case FilterOperator::Le:
}
else if (prefix == "<="_L1) {
cmp = new FilterParserInt64LeComparator(number);
break;
}
break;
else {
cmp = new FilterParserInt64EqComparator(number);
}
case ColumnType::Float:{
}
else if (Song::kFloatSearchColumns.contains(column, Qt::CaseInsensitive)) {
const float rating = ParseRating(value);
switch (filter_operator) {
case FilterOperator::None:
case FilterOperator::Eq:
if (prefix == u'=' || prefix == "=="_L1) {
cmp = new FilterParserFloatEqComparator(rating);
break;
case FilterOperator::Ne:
}
else if (prefix == "!="_L1 || prefix == "<>"_L1) {
cmp = new FilterParserFloatNeComparator(rating);
break;
case FilterOperator::Gt:
}
else if (prefix == u'>') {
cmp = new FilterParserFloatGtComparator(rating);
break;
case FilterOperator::Ge:
}
else if (prefix == ">="_L1) {
cmp = new FilterParserFloatGeComparator(rating);
break;
case FilterOperator::Lt:
}
else if (prefix == u'<') {
cmp = new FilterParserFloatLtComparator(rating);
break;
case FilterOperator::Le:
}
else if (prefix == "<="_L1) {
cmp = new FilterParserFloatLeComparator(rating);
break;
}
break;
else {
cmp = new FilterParserFloatEqComparator(rating);
}
case ColumnType::Unknown:
break;
}
}
if (filter_column != FilterColumn::Unknown && cmp != nullptr) {
return new FilterTreeColumnTerm(filter_column, cmp);
if (cmp) {
return new FilterTreeColumnTerm(column, cmp);
}
return new FilterTreeTerm(new FilterParserTextContainsComparator(value));

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
*
* Strawberry is free software: you can redistribute it and/or modify

View File

@@ -1,6 +1,8 @@
/*
* Strawberry Music Player
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -20,7 +22,7 @@
#include <QString>
#include "filtertree.h"
#include "filtercolumn.h"
#include "core/song.h"
using namespace Qt::Literals::StringLiterals;
@@ -28,64 +30,28 @@ using namespace Qt::Literals::StringLiterals;
FilterTree::FilterTree() = default;
FilterTree::~FilterTree() = default;
QVariant FilterTree::DataFromColumn(const FilterColumn filter_column, const Song &song) {
QVariant FilterTree::DataFromColumn(const QString &column, const Song &metadata) {
switch (filter_column) {
case FilterColumn::AlbumArtist:
return song.effective_albumartist();
case FilterColumn::AlbumArtistSort:
return song.effective_albumartistsort();
case FilterColumn::Artist:
return song.artist();
case FilterColumn::ArtistSort:
return song.effective_artistsort();
case FilterColumn::Album:
return song.album();
case FilterColumn::AlbumSort:
return song.effective_albumsort();
case FilterColumn::Title:
return song.PrettyTitle();
case FilterColumn::TitleSort:
return song.effective_titlesort();
case FilterColumn::Composer:
return song.composer();
case FilterColumn::ComposerSort:
return song.effective_composersort();
case FilterColumn::Performer:
return song.performer();
case FilterColumn::PerformerSort:
return song.effective_performersort();
case FilterColumn::Grouping:
return song.grouping();
case FilterColumn::Genre:
return song.genre();
case FilterColumn::Comment:
return song.comment();
case FilterColumn::Track:
return song.track();
case FilterColumn::Year:
return song.year();
case FilterColumn::Length:
return song.length_nanosec();
case FilterColumn::Samplerate:
return song.samplerate();
case FilterColumn::Bitdepth:
return song.bitdepth();
case FilterColumn::Bitrate:
return song.bitrate();
case FilterColumn::Rating:
return song.rating();
case FilterColumn::Playcount:
return song.playcount();
case FilterColumn::Skipcount:
return song.skipcount();
case FilterColumn::Filename:
return song.basefilename();
case FilterColumn::URL:
return song.effective_url().toString();
case FilterColumn::Unknown:
break;
}
if (column == "albumartist"_L1) return metadata.effective_albumartist();
if (column == "artist"_L1) return metadata.artist();
if (column == "album"_L1) return metadata.album();
if (column == "title"_L1) return metadata.PrettyTitle();
if (column == "composer"_L1) return metadata.composer();
if (column == "performer"_L1) return metadata.performer();
if (column == "grouping"_L1) return metadata.grouping();
if (column == "genre"_L1) return metadata.genre();
if (column == "comment"_L1) return metadata.comment();
if (column == "track"_L1) return metadata.track();
if (column == "year"_L1) return metadata.year();
if (column == "length"_L1) return metadata.length_nanosec();
if (column == "samplerate"_L1) return metadata.samplerate();
if (column == "bitdepth"_L1) return metadata.bitdepth();
if (column == "bitrate"_L1) return metadata.bitrate();
if (column == "rating"_L1) return metadata.rating();
if (column == "playcount"_L1) return metadata.playcount();
if (column == "skipcount"_L1) return metadata.skipcount();
if (column == "filename"_L1) return metadata.basefilename();
if (column == "url"_L1) return metadata.effective_url().toString();
return QVariant();

View File

@@ -1,6 +1,8 @@
/*
* Strawberry Music Player
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -23,7 +25,6 @@
#include <QString>
#include "core/song.h"
#include "filtercolumn.h"
class FilterTree {
public:
@@ -44,7 +45,7 @@ class FilterTree {
virtual bool accept(const Song &song) const = 0;
protected:
static QVariant DataFromColumn(const FilterColumn filter_column, const Song &metadata);
static QVariant DataFromColumn(const QString &column, const Song &metadata);
private:
Q_DISABLE_COPY(FilterTree)

View File

@@ -24,8 +24,8 @@
#include "filtertreecolumnterm.h"
#include "filterparsersearchtermcomparator.h"
FilterTreeColumnTerm::FilterTreeColumnTerm(const FilterColumn filter_column, FilterParserSearchTermComparator *comparator) : filter_column_(filter_column), cmp_(comparator) {}
FilterTreeColumnTerm::FilterTreeColumnTerm(const QString &column, FilterParserSearchTermComparator *comparator) : column_(column), cmp_(comparator) {}
bool FilterTreeColumnTerm::accept(const Song &song) const {
return cmp_->Matches(DataFromColumn(filter_column_, song));
return cmp_->Matches(DataFromColumn(column_, song));
}

View File

@@ -26,20 +26,20 @@
#include <QScopedPointer>
#include "filtertree.h"
#include "filtercolumn.h"
#include "core/song.h"
class FilterParserSearchTermComparator;
class FilterTreeColumnTerm : public FilterTree {
public:
explicit FilterTreeColumnTerm(const FilterColumn filter_column, FilterParserSearchTermComparator *comparator);
explicit FilterTreeColumnTerm(const QString &column, FilterParserSearchTermComparator *comparator);
FilterType type() const override { return FilterType::Column; }
bool accept(const Song &song) const override;
private:
const FilterColumn filter_column_;
const QString column_;
QScopedPointer<FilterParserSearchTermComparator> cmp_;
Q_DISABLE_COPY(FilterTreeColumnTerm)

View File

@@ -27,17 +27,11 @@ FilterTreeTerm::FilterTreeTerm(FilterParserSearchTermComparator *comparator) : c
bool FilterTreeTerm::accept(const Song &song) const {
if (cmp_->Matches(song.PrettyTitle())) return true;
if (cmp_->Matches(song.titlesort())) return true;
if (cmp_->Matches(song.album())) return true;
if (cmp_->Matches(song.albumsort())) return true;
if (cmp_->Matches(song.artist())) return true;
if (cmp_->Matches(song.artistsort())) return true;
if (cmp_->Matches(song.albumartist())) return true;
if (cmp_->Matches(song.albumartistsort())) return true;
if (cmp_->Matches(song.composer())) return true;
if (cmp_->Matches(song.composersort())) return true;
if (cmp_->Matches(song.performer())) return true;
if (cmp_->Matches(song.performersort())) return true;
if (cmp_->Matches(song.grouping())) return true;
if (cmp_->Matches(song.genre())) return true;
if (cmp_->Matches(song.comment())) return true;

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2026, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2021, 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
@@ -76,10 +76,6 @@
#include <kdsingleapplication.h>
#ifdef Q_OS_UNIX
#include "core/unixsignalwatcher.h"
#endif
#ifdef HAVE_QTSPARKLE
# include <qtsparkle-qt6/Updater>
#endif // HAVE_QTSPARKLE
@@ -283,13 +279,6 @@ int main(int argc, char *argv[]) {
}
}
// Default to English unless the user explicitly selected a language (CLI or settings).
// This makes the first-run experience deterministic across system locales.
if (languages.isEmpty()) {
languages << u"en_US"_s;
languages << u"en"_s;
}
// Use system UI languages
if (languages.isEmpty()) {
# if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
@@ -376,12 +365,6 @@ int main(int argc, char *argv[]) {
#endif
options);
#ifdef Q_OS_UNIX
UnixSignalWatcher unix_signal_watcher;
unix_signal_watcher.WatchForSignal(SIGTERM);
QObject::connect(&unix_signal_watcher, &UnixSignalWatcher::UnixSignal, &w, &MainWindow::Exit);
#endif
#ifdef Q_OS_MACOS
mac::EnableFullScreen(w);
#endif // Q_OS_MACOS

View File

@@ -0,0 +1,478 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, Andreas Muttscheller <asfa194@gmail.com>
* Copyright 2024, 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 <algorithm>
#include <QString>
#include <QUrl>
#include <QDir>
#include <QSettings>
#include "core/logging.h"
#include "core/mimedata.h"
#include "constants/timeconstants.h"
#include "engine/enginebase.h"
#include "playlist/playlist.h"
#include "playlist/playlistmanager.h"
#include "playlist/playlistsequence.h"
#include "incomingdataparser.h"
#include "scrobbler/audioscrobbler.h"
#include "constants/mainwindowsettings.h"
using namespace Qt::Literals::StringLiterals;
IncomingDataParser::IncomingDataParser(const SharedPtr<Player> player,
const SharedPtr<PlaylistManager> playlist_manager,
const SharedPtr<AudioScrobbler> scrobbler,
QObject *parent)
: QObject(parent),
player_(player),
playlist_manager_(playlist_manager),
scrobbler_(scrobbler),
close_connection_(false),
doubleclick_playlist_addmode_(BehaviourSettings::PlaylistAddBehaviour::Enqueue) {
ReloadSettings();
QObject::connect(this, &IncomingDataParser::Play, &*player_, &Player::PlayHelper);
QObject::connect(this, &IncomingDataParser::PlayPause, &*player_, &Player::PlayPauseHelper);
QObject::connect(this, &IncomingDataParser::Pause, &*player_, &Player::Pause);
QObject::connect(this, &IncomingDataParser::Stop, &*player_, &Player::Stop);
QObject::connect(this, &IncomingDataParser::StopAfterCurrent, &*player_, &Player::StopAfterCurrent);
QObject::connect(this, &IncomingDataParser::Next, &*player_, &Player::Next);
QObject::connect(this, &IncomingDataParser::Previous, &*player_, &Player::Previous);
QObject::connect(this, &IncomingDataParser::SetVolume, &*player_, &Player::SetVolume);
QObject::connect(this, &IncomingDataParser::PlayAt, &*player_, &Player::PlayAt);
QObject::connect(this, &IncomingDataParser::SeekTo, &*player_, &Player::SeekTo);
QObject::connect(this, &IncomingDataParser::Enqueue, &*playlist_manager_, &PlaylistManager::Enqueue);
QObject::connect(this, &IncomingDataParser::SetActivePlaylist, &*playlist_manager_, &PlaylistManager::SetActivePlaylist);
QObject::connect(this, &IncomingDataParser::ShuffleCurrent, &*playlist_manager_, &PlaylistManager::ShuffleCurrent);
QObject::connect(this, &IncomingDataParser::InsertUrls, &*playlist_manager_, &PlaylistManager::InsertUrls);
QObject::connect(this, &IncomingDataParser::InsertSongs, &*playlist_manager_, &PlaylistManager::InsertSongs);
QObject::connect(this, &IncomingDataParser::RemoveSongs, &*playlist_manager_, &PlaylistManager::RemoveItemsWithoutUndo);
QObject::connect(this, &IncomingDataParser::New, &*playlist_manager_, &PlaylistManager::New);
QObject::connect(this, &IncomingDataParser::Open, &*playlist_manager_, &PlaylistManager::Open);
QObject::connect(this, &IncomingDataParser::Close, &*playlist_manager_, &PlaylistManager::Close);
QObject::connect(this, &IncomingDataParser::Clear, &*playlist_manager_, &PlaylistManager::Clear);
QObject::connect(this, &IncomingDataParser::Rename, &*playlist_manager_, &PlaylistManager::Rename);
QObject::connect(this, &IncomingDataParser::Favorite, &*playlist_manager_, &PlaylistManager::Favorite);
QObject::connect(this, &IncomingDataParser::SetRepeatMode, &*playlist_manager_->sequence(), &PlaylistSequence::SetRepeatMode);
QObject::connect(this, &IncomingDataParser::SetShuffleMode, &*playlist_manager_->sequence(), &PlaylistSequence::SetShuffleMode);
QObject::connect(this, &IncomingDataParser::RateCurrentSong, &*playlist_manager_, &PlaylistManager::RateCurrentSong);
QObject::connect(this, &IncomingDataParser::Love, &*scrobbler_, &AudioScrobbler::Love);
}
IncomingDataParser::~IncomingDataParser() = default;
void IncomingDataParser::ReloadSettings() {
QSettings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
doubleclick_playlist_addmode_ = static_cast<BehaviourSettings::PlaylistAddBehaviour>(s.value(BehaviourSettings::kDoubleClickPlaylistAddMode, static_cast<int>(BehaviourSettings::PlaylistAddBehaviour::Enqueue)).toInt());
s.endGroup();
}
bool IncomingDataParser::close_connection() const { return close_connection_; }
void IncomingDataParser::SetRemoteRootFiles(const QString &files_root_folder) {
files_root_folder_ = files_root_folder;
}
Song IncomingDataParser::SongFromPbSongMetadata(const networkremote::SongMetadata &pb_song_metadata) const {
Song song;
song.Init(pb_song_metadata.title(), pb_song_metadata.artist(), pb_song_metadata.album(), pb_song_metadata.length() * kNsecPerSec);
song.set_albumartist(pb_song_metadata.albumartist());
song.set_genre(pb_song_metadata.genre());
song.set_year(pb_song_metadata.prettyYear().toInt());
song.set_track(pb_song_metadata.track());
song.set_disc(pb_song_metadata.disc());
song.set_url(QUrl(pb_song_metadata.url()));
song.set_filesize(pb_song_metadata.fileSize());
song.set_rating(pb_song_metadata.rating());
song.set_basefilename(pb_song_metadata.filename());
song.set_art_automatic(QUrl(pb_song_metadata.artAutomatic()));
song.set_art_manual(QUrl(pb_song_metadata.artManual()));
song.set_filetype(static_cast<Song::FileType>(pb_song_metadata.filetype()));
return song;
}
void IncomingDataParser::Parse(const networkremote::Message &msg) {
close_connection_ = false;
NetworkRemoteClient *client = qobject_cast<NetworkRemoteClient*>(sender());
switch (msg.type()) {
case networkremote::MsgTypeGadget::MsgType::CONNECT:
ClientConnect(msg, client);
break;
case networkremote::MsgTypeGadget::MsgType::DISCONNECT:
close_connection_ = true;
break;
case networkremote::MsgTypeGadget::MsgType::GET_COLLECTION:
Q_EMIT SendCollection(client);
break;
case networkremote::MsgTypeGadget::MsgType::GET_PLAYLISTS:
ParseSendPlaylists(msg);
break;
case networkremote::MsgTypeGadget::MsgType::GET_PLAYLIST_SONGS:
ParseGetPlaylistSongs(msg);
break;
case networkremote::MsgTypeGadget::MsgType::SET_VOLUME:
Q_EMIT SetVolume(msg.requestSetVolume().volume());
break;
case networkremote::MsgTypeGadget::MsgType::PLAY:
Q_EMIT Play();
break;
case networkremote::MsgTypeGadget::MsgType::PLAYPAUSE:
Q_EMIT PlayPause();
break;
case networkremote::MsgTypeGadget::MsgType::PAUSE:
Q_EMIT Pause();
break;
case networkremote::MsgTypeGadget::MsgType::STOP:
Q_EMIT Stop();
break;
case networkremote::MsgTypeGadget::MsgType::STOP_AFTER:
Q_EMIT StopAfterCurrent();
break;
case networkremote::MsgTypeGadget::MsgType::NEXT:
Q_EMIT Next();
break;
case networkremote::MsgTypeGadget::MsgType::PREVIOUS:
Q_EMIT Previous();
break;
case networkremote::MsgTypeGadget::MsgType::CHANGE_SONG:
ParseChangeSong(msg);
break;
case networkremote::MsgTypeGadget::MsgType::SHUFFLE_PLAYLIST:
Q_EMIT ShuffleCurrent();
break;
case networkremote::MsgTypeGadget::MsgType::REPEAT:
ParseSetRepeatMode(msg.repeat());
break;
case networkremote::MsgTypeGadget::MsgType::SHUFFLE:
ParseSetShuffleMode(msg.shuffle());
break;
case networkremote::MsgTypeGadget::MsgType::SET_TRACK_POSITION:
Q_EMIT SeekTo(msg.requestSetTrackPosition().position());
break;
case networkremote::MsgTypeGadget::MsgType::PLAYLIST_INSERT_URLS:
ParseInsertUrls(msg);
break;
case networkremote::MsgTypeGadget::MsgType::REMOVE_PLAYLIST_SONGS:
ParseRemoveSongs(msg);
break;
case networkremote::MsgTypeGadget::MsgType::OPEN_PLAYLIST:
ParseOpenPlaylist(msg);
break;
case networkremote::MsgTypeGadget::MsgType::CLOSE_PLAYLIST:
ParseClosePlaylist(msg);
break;
case networkremote::MsgTypeGadget::MsgType::UPDATE_PLAYLIST:
ParseUpdatePlaylist(msg);
break;
case networkremote::MsgTypeGadget::MsgType::LOVE:
Q_EMIT Love();
break;
case networkremote::MsgTypeGadget::MsgType::GET_LYRICS:
Q_EMIT GetLyrics();
break;
case networkremote::MsgTypeGadget::MsgType::DOWNLOAD_SONGS:
client->song_sender()->SendSongs(msg.requestDownloadSongs());
break;
case networkremote::MsgTypeGadget::MsgType::SONG_OFFER_RESPONSE:
client->song_sender()->ResponseSongOffer(msg.responseSongOffer().accepted());
break;
case networkremote::MsgTypeGadget::MsgType::RATE_SONG:
ParseRateSong(msg);
break;
case networkremote::MsgTypeGadget::MsgType::REQUEST_FILES:
Q_EMIT SendListFiles(msg.requestListFiles().relativePath(), client);
break;
case networkremote::MsgTypeGadget::MsgType::APPEND_FILES:
ParseAppendFilesToPlaylist(msg);
break;
default:
break;
}
}
void IncomingDataParser::ClientConnect(const networkremote::Message &msg, NetworkRemoteClient *client) {
Q_EMIT SendInfo();
if (!client->isDownloader()) {
if (!msg.requestConnect().hasSendPlaylistSongs() || msg.requestConnect().sendPlaylistSongs()) {
Q_EMIT SendFirstData(true);
}
else {
Q_EMIT SendFirstData(false);
}
}
}
void IncomingDataParser::ParseGetPlaylistSongs(const networkremote::Message &msg) {
Q_EMIT SendPlaylistSongs(msg.requestPlaylistSongs().playlistId());
}
void IncomingDataParser::ParseChangeSong(const networkremote::Message &msg) {
// Get the first entry and check if there is a song
const networkremote::RequestChangeSong &request = msg.requestChangeSong();
// Check if we need to change the playlist
if (request.playlistId() != playlist_manager_->active_id()) {
Q_EMIT SetActivePlaylist(request.playlistId());
}
switch (doubleclick_playlist_addmode_) {
case BehaviourSettings::PlaylistAddBehaviour::Play:{
Q_EMIT PlayAt(request.songIndex(), false, 0, EngineBase::TrackChangeType::Manual, Playlist::AutoScroll::Maybe, false, false);
break;
}
case BehaviourSettings::PlaylistAddBehaviour::Enqueue:{
Q_EMIT Enqueue(request.playlistId(), request.songIndex());
if (player_->GetState() != EngineBase::State::Playing) {
Q_EMIT PlayAt(request.songIndex(), false, 0, EngineBase::TrackChangeType::Manual, Playlist::AutoScroll::Maybe, false, false);
}
break;
}
}
}
void IncomingDataParser::ParseSetRepeatMode(const networkremote::Repeat &repeat) {
switch (repeat.repeatMode()) {
case networkremote::RepeatModeGadget::RepeatMode::RepeatMode_Off:
Q_EMIT SetRepeatMode(PlaylistSequence::RepeatMode::Off);
break;
case networkremote::RepeatModeGadget::RepeatMode::RepeatMode_Track:
Q_EMIT SetRepeatMode(PlaylistSequence::RepeatMode::Track);
break;
case networkremote::RepeatModeGadget::RepeatMode::RepeatMode_Album:
Q_EMIT SetRepeatMode(PlaylistSequence::RepeatMode::Album);
break;
case networkremote::RepeatModeGadget::RepeatMode::RepeatMode_Playlist:
Q_EMIT SetRepeatMode(PlaylistSequence::RepeatMode::Playlist);
break;
default:
break;
}
}
void IncomingDataParser::ParseSetShuffleMode(const networkremote::Shuffle &shuffle) {
switch (shuffle.shuffleMode()) {
case networkremote::ShuffleModeGadget::ShuffleMode::ShuffleMode_Off:
Q_EMIT SetShuffleMode(PlaylistSequence::ShuffleMode::Off);
break;
case networkremote::ShuffleModeGadget::ShuffleMode::ShuffleMode_All:
Q_EMIT SetShuffleMode(PlaylistSequence::ShuffleMode::All);
break;
case networkremote::ShuffleModeGadget::ShuffleMode::ShuffleMode_InsideAlbum:
Q_EMIT SetShuffleMode(PlaylistSequence::ShuffleMode::InsideAlbum);
break;
case networkremote::ShuffleModeGadget::ShuffleMode::ShuffleMode_Albums:
Q_EMIT SetShuffleMode(PlaylistSequence::ShuffleMode::Albums);
break;
default:
break;
}
}
void IncomingDataParser::ParseInsertUrls(const networkremote::Message &msg) {
const networkremote::RequestInsertUrls &request = msg.requestInsertUrls();
int playlist_id = request.playlistId();
// Insert plain urls without metadata
if (!request.urls().empty()) {
QList<QUrl> urls;
for (auto it = request.urls().begin(); it != request.urls().end(); ++it) {
const QString s = *it;
urls << QUrl(s);
}
if (request.hasNewPlaylistName()) {
playlist_id = playlist_manager_->New(request.newPlaylistName());
}
// Insert the urls
Q_EMIT InsertUrls(playlist_id, urls, request.position(), request.playNow(), request.enqueue());
}
// Add songs with metadata if present
if (!request.songs().empty()) {
SongList songs;
for (int i = 0; i < request.songs().size(); i++) {
songs << SongFromPbSongMetadata(request.songs().at(i));
}
// Create a new playlist if required and not already done above by InsertUrls
if (request.hasNewPlaylistName() && playlist_id == request.playlistId()) {
playlist_id = playlist_manager_->New(request.newPlaylistName());
}
Q_EMIT InsertSongs(request.playlistId(), songs, request.position(), request.playNow(), request.enqueue());
}
}
void IncomingDataParser::ParseRemoveSongs(const networkremote::Message &msg) {
const networkremote::RequestRemoveSongs &request = msg.requestRemoveSongs();
QList<int> songs;
songs.reserve(request.songs().size());
for (int i = 0; i < request.songs().size(); i++) {
songs.append(request.songs().at(i));
}
Q_EMIT RemoveSongs(request.playlistId(), songs);
}
void IncomingDataParser::ParseSendPlaylists(const networkremote::Message &msg) {
if (!msg.hasRequestPlaylistSongs() || !msg.requestPlaylists().includeClosed()) {
Q_EMIT SendAllActivePlaylists();
}
else {
Q_EMIT SendAllPlaylists();
}
}
void IncomingDataParser::ParseOpenPlaylist(const networkremote::Message &msg) {
Q_EMIT Open(msg.requestOpenPlaylist().playlistId());
}
void IncomingDataParser::ParseClosePlaylist(const networkremote::Message &msg) {
Q_EMIT Close(msg.requestClosePlaylist().playlistId());
}
void IncomingDataParser::ParseUpdatePlaylist(const networkremote::Message &msg) {
const networkremote::RequestUpdatePlaylist &req_update = msg.requestUpdatePlaylist();
if (req_update.hasCreateNewPlaylist() && req_update.createNewPlaylist()) {
Q_EMIT New(req_update.hasNewPlaylistName() ? req_update.newPlaylistName() : u"New Playlist"_s);
return;
}
if (req_update.hasClearPlaylist() && req_update.clearPlaylist()) {
Q_EMIT Clear(req_update.playlistId());
return;
}
if (req_update.hasNewPlaylistName() && !req_update.newPlaylistName().isEmpty()) {
Q_EMIT Rename(req_update.playlistId(), req_update.newPlaylistName());
}
if (req_update.hasFavorite()) {
Q_EMIT Favorite(req_update.playlistId(), req_update.favorite());
}
}
void IncomingDataParser::ParseRateSong(const networkremote::Message &msg) {
Q_EMIT RateCurrentSong(msg.requestRateSong().rating());
}
void IncomingDataParser::ParseAppendFilesToPlaylist(const networkremote::Message &msg) {
if (files_root_folder_.isEmpty()) {
qLog(Warning) << "Remote root dir is not set although receiving APPEND_FILES request...";
return;
}
QDir root_dir(files_root_folder_);
if (!root_dir.exists()) {
qLog(Warning) << "Remote root dir doesn't exist...";
return;
}
const networkremote::RequestAppendFiles &req_append = msg.requestAppendFiles();
QString relative_path = req_append.relativePath();
if (relative_path.startsWith("/"_L1)) relative_path.remove(0, 1);
QFileInfo fi_folder(root_dir, relative_path);
if (!fi_folder.exists()) {
qLog(Warning) << "Remote relative path " << relative_path << " doesn't exist...";
return;
}
else if (!fi_folder.isDir()) {
qLog(Warning) << "Remote relative path " << relative_path << " is not a directory...";
return;
}
else if (root_dir.relativeFilePath(fi_folder.absoluteFilePath()).startsWith("../"_L1)) {
qLog(Warning) << "Remote relative path " << relative_path << " should not be accessed...";
return;
}
QList<QUrl> urls;
QDir dir(fi_folder.absoluteFilePath());
for (const auto &file : req_append.files()) {
QFileInfo fi(dir, file);
if (fi.exists()) urls << QUrl::fromLocalFile(fi.canonicalFilePath());
}
if (!urls.isEmpty()) {
MimeData *data = new MimeData;
data->setUrls(urls);
if (req_append.hasPlayNow()) {
data->play_now_ = req_append.playNow();
}
if (req_append.hasClearFirst()) {
data->clear_first_ = req_append.clearFirst();
}
if (req_append.hasNewPlaylistName()) {
QString playlist_name = req_append.newPlaylistName();
if (!playlist_name.isEmpty()) {
data->open_in_new_playlist_ = true;
data->name_for_new_playlist_ = playlist_name;
}
}
else if (req_append.hasPlaylistId()) {
// If playing we will drop the files in another playlist
if (player_->GetState() == EngineBase::State::Playing) {
data->playlist_id_ = req_append.playlistId();
}
else {
// As we may play the song, we change the current playlist
Q_EMIT SetCurrentPlaylist(req_append.playlistId());
}
}
Q_EMIT AddToPlaylistSignal(data);
}
}

Some files were not shown because too many files have changed in this diff Show More