Compare commits

..

1 Commits

Author SHA1 Message Date
Jonas Kvinge
5c57c923d9 Add Dropbox 2025-12-29 00:48:02 +01:00
178 changed files with 7705 additions and 11252 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: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
fedora_version: [ '42', '43', '44' ] fedora_version: [ '41', '42', '43' ]
container: container:
image: fedora:${{matrix.fedora_version}} image: fedora:${{matrix.fedora_version}}
steps: steps:
@@ -542,7 +542,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
ubuntu_version: [ 'noble', 'questing', 'resolute' ] ubuntu_version: [ 'noble', 'plucky', 'questing' ]
container: container:
image: ubuntu:${{matrix.ubuntu_version}} image: ubuntu:${{matrix.ubuntu_version}}
steps: steps:
@@ -596,10 +596,10 @@ jobs:
qt6-l10n-tools qt6-l10n-tools
rapidjson-dev rapidjson-dev
- name: Install KDSingleApplication - name: Install KDSingleApplication
if: matrix.ubuntu_version != 'noble' if: matrix.ubuntu_version != 'noble' && matrix.ubuntu_version != 'plucky'
run: apt install -y libkdsingleapplication-qt6-dev run: apt install -y libkdsingleapplication-qt6-dev
- name: Build and install KDSingleApplication - name: Build and install KDSingleApplication
if: matrix.ubuntu_version == 'noble' if: matrix.ubuntu_version == 'noble' || matrix.ubuntu_version == 'plucky'
run: | run: |
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
cd KDSingleApplication cd KDSingleApplication
@@ -639,7 +639,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
ubuntu_version: [ 'noble', 'questing', 'resolute' ] ubuntu_version: [ 'noble', 'plucky', 'questing' ]
container: container:
image: ubuntu:${{matrix.ubuntu_version}} image: ubuntu:${{matrix.ubuntu_version}}
steps: steps:
@@ -747,7 +747,7 @@ jobs:
df -h df -h
- name: Build FreeBSD - name: Build FreeBSD
id: build-freebsd id: build-freebsd
uses: vmactions/freebsd-vm@v1.3.7 uses: vmactions/freebsd-vm@v1.3.2
with: with:
usesh: true usesh: true
mem: 8192 mem: 8192
@@ -772,7 +772,7 @@ jobs:
submodules: recursive submodules: recursive
- name: Build OpenBSD - name: Build OpenBSD
id: build-openbsd id: build-openbsd
uses: vmactions/openbsd-vm@v1.3.1 uses: vmactions/openbsd-vm@v1.2.9
with: with:
usesh: true usesh: true
mem: 4096 mem: 4096
@@ -845,7 +845,7 @@ jobs:
p12-password: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }} p12-password: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
- name: Download macOS dependencies - 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 - name: Extract macOS dependencies
run: sudo tar -C / -xf strawberry-macos-${{env.arch}}-${{env.buildtype}}.tar.xz run: sudo tar -C / -xf strawberry-macos-${{env.arch}}-${{env.buildtype}}.tar.xz
@@ -898,7 +898,7 @@ jobs:
- name: Manually Codesign - name: Manually Codesign
if: github.repository == 'strawberrymusicplayer/strawberry' && github.event.pull_request.head.repo.fork == false && matrix.runner == 'macos-15-intel' if: github.repository == 'strawberrymusicplayer/strawberry' && github.event.pull_request.head.repo.fork == false && matrix.runner == 'macos-15-intel'
working-directory: build 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 - name: Manually Codesign
if: github.repository == 'strawberrymusicplayer/strawberry' && github.event.pull_request.head.repo.fork == false && matrix.runner == 'macos-15' 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 /bin
/CMakeLists.txt.user /CMakeLists.txt.user
/.qtcreator /.qtcreator
@@ -13,33 +14,3 @@
/dist/scripts/maketarball.sh /dist/scripts/maketarball.sh
/debian/changelog /debian/changelog
_codeql_detected_source_root _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

@@ -33,20 +33,9 @@ if(APPLE)
target_link_libraries(discord-rpc PRIVATE "-framework AppKit") target_link_libraries(discord-rpc PRIVATE "-framework AppKit")
endif() endif()
# RapidJSON (as packaged by Homebrew and others) can trigger C++17 deprecation
# warnings (e.g. std::iterator) when compiled with AppleClang/libc++.
# Keep the suppression narrowly scoped to this 3rdparty target.
if(APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "Clang")
target_compile_options(discord-rpc PRIVATE -Wno-deprecated-declarations)
endif()
if(WIN32) if(WIN32)
target_link_libraries(discord-rpc PRIVATE psapi advapi32) target_link_libraries(discord-rpc PRIVATE psapi advapi32)
endif() endif()
if(TARGET RapidJSON::RapidJSON) target_include_directories(discord-rpc SYSTEM PRIVATE ${RapidJSON_INCLUDE_DIRS})
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}) target_include_directories(discord-rpc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -1,62 +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; we tap *this repo* via file://.
# Use the Brewfile's directory (repo root) rather than the current working directory,
# so `brew bundle --file /path/to/Brewfile` works no matter where you run it from.
tap "strawberry/local", "file://#{File.expand_path(__dir__)}"
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) enable_language(OBJC OBJCXX)
endif() 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) if(POLICY CMP0054)
cmake_policy(SET CMP0054 NEW) cmake_policy(SET CMP0054 NEW)
endif() endif()
@@ -40,24 +32,6 @@ if(LINUX)
endif() endif()
if(APPLE) 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) include(cmake/Dmg.cmake)
endif() endif()
@@ -110,6 +84,8 @@ if(MSVC)
list(APPEND COMPILE_OPTIONS -MP -W4 -wd4702) list(APPEND COMPILE_OPTIONS -MP -W4 -wd4702)
else() else()
list(APPEND COMPILE_OPTIONS list(APPEND COMPILE_OPTIONS
$<$<COMPILE_LANGUAGE:C>:-std=c11>
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17>
-Wall -Wall
-Wextra -Wextra
-Wpedantic -Wpedantic
@@ -277,6 +253,11 @@ endif()
find_package(KDSingleApplication-qt${QT_VERSION_MAJOR} 1.1.0 REQUIRED) find_package(KDSingleApplication-qt${QT_VERSION_MAJOR} 1.1.0 REQUIRED)
if(APPLE)
find_library(SPARKLE Sparkle)
#find_package(SPMediaKeyTap REQUIRED)
endif()
if(WIN32) if(WIN32)
find_package(getopt NAMES getopt getopt-win unofficial-getopt-win32 REQUIRED) find_package(getopt NAMES getopt getopt-win unofficial-getopt-win32 REQUIRED)
if(TARGET getopt::getopt) if(TARGET getopt::getopt)
@@ -293,7 +274,7 @@ if(WIN32)
endif() endif()
if(APPLE OR WIN32) 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") if(TARGET "qtsparkle-qt${QT_VERSION_MAJOR}::qtsparkle")
set(QTSPARKLE_FOUND ON) set(QTSPARKLE_FOUND ON)
endif() endif()
@@ -398,6 +379,13 @@ optional_component(STREAMTAGREADER ON "Stream tagreader"
optional_component(DISCORD_RPC ON "Discord Rich Presence" optional_component(DISCORD_RPC ON "Discord Rich Presence"
DEPENDS "RapidJSON" RapidJSON_FOUND DEPENDS "RapidJSON" RapidJSON_FOUND
optional_component(DROPBOX ON "Streaming: Dropbox"
DEPENDS "Stream tagreader" HAVE_STREAMTAGREADER
)
optional_component(ONEDRIVE ON "Streaming: OneDrive"
DEPENDS "Stream tagreader" HAVE_STREAMTAGREADER
) )
if(HAVE_SONGFINGERPRINTING OR HAVE_MUSICBRAINZ) if(HAVE_SONGFINGERPRINTING OR HAVE_MUSICBRAINZ)
@@ -795,6 +783,7 @@ set(SOURCES
src/streaming/streamingcollectionviewcontainer.cpp src/streaming/streamingcollectionviewcontainer.cpp
src/streaming/streamingsearchview.cpp src/streaming/streamingsearchview.cpp
src/streaming/streamsongmimedata.cpp src/streaming/streamsongmimedata.cpp
src/streaming/cloudstoragestreamingservice.cpp
src/radios/radioservices.cpp src/radios/radioservices.cpp
src/radios/radiobackend.cpp src/radios/radiobackend.cpp
@@ -842,8 +831,6 @@ set(SOURCES
src/fileview/fileview.cpp src/fileview/fileview.cpp
src/fileview/fileviewlist.cpp src/fileview/fileviewlist.cpp
src/fileview/fileviewtree.cpp
src/fileview/fileviewtreemodel.cpp
src/device/devicemanager.cpp src/device/devicemanager.cpp
src/device/devicelister.cpp src/device/devicelister.cpp
@@ -1093,6 +1080,7 @@ set(HEADERS
src/streaming/streamingtabsview.h src/streaming/streamingtabsview.h
src/streaming/streamingcollectionview.h src/streaming/streamingcollectionview.h
src/streaming/streamingcollectionviewcontainer.h src/streaming/streamingcollectionviewcontainer.h
src/streaming/cloudstoragestreamingservice.h
src/radios/radioservices.h src/radios/radioservices.h
src/radios/radiobackend.h src/radios/radiobackend.h
@@ -1133,8 +1121,6 @@ set(HEADERS
src/fileview/fileview.h src/fileview/fileview.h
src/fileview/fileviewlist.h src/fileview/fileviewlist.h
src/fileview/fileviewtree.h
src/fileview/fileviewtreemodel.h
src/device/devicemanager.h src/device/devicemanager.h
src/device/devicelister.h src/device/devicelister.h
@@ -1237,10 +1223,6 @@ set(UI
src/device/deviceviewcontainer.ui src/device/deviceviewcontainer.ui
) )
if(UNIX)
optional_source(UNIX SOURCES src/core/unixsignalwatcher.cpp HEADERS src/core/unixsignalwatcher.h)
endif()
if(APPLE) if(APPLE)
optional_source(APPLE optional_source(APPLE
SOURCES SOURCES
@@ -1469,7 +1451,6 @@ optional_source(HAVE_SPOTIFY
src/spotify/spotifybaserequest.cpp src/spotify/spotifybaserequest.cpp
src/spotify/spotifyrequest.cpp src/spotify/spotifyrequest.cpp
src/spotify/spotifyfavoriterequest.cpp src/spotify/spotifyfavoriterequest.cpp
src/spotify/spotifymetadatarequest.cpp
src/settings/spotifysettingspage.cpp src/settings/spotifysettingspage.cpp
src/covermanager/spotifycoverprovider.cpp src/covermanager/spotifycoverprovider.cpp
HEADERS HEADERS
@@ -1477,7 +1458,6 @@ optional_source(HAVE_SPOTIFY
src/spotify/spotifybaserequest.h src/spotify/spotifybaserequest.h
src/spotify/spotifyrequest.h src/spotify/spotifyrequest.h
src/spotify/spotifyfavoriterequest.h src/spotify/spotifyfavoriterequest.h
src/spotify/spotifymetadatarequest.h
src/settings/spotifysettingspage.h src/settings/spotifysettingspage.h
src/covermanager/spotifycoverprovider.h src/covermanager/spotifycoverprovider.h
UI UI
@@ -1492,7 +1472,6 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.cpp src/qobuz/qobuzrequest.cpp
src/qobuz/qobuzstreamurlrequest.cpp src/qobuz/qobuzstreamurlrequest.cpp
src/qobuz/qobuzfavoriterequest.cpp src/qobuz/qobuzfavoriterequest.cpp
src/qobuz/qobuzmetadatarequest.cpp
src/qobuz/qobuzcredentialfetcher.cpp src/qobuz/qobuzcredentialfetcher.cpp
src/settings/qobuzsettingspage.cpp src/settings/qobuzsettingspage.cpp
src/covermanager/qobuzcoverprovider.cpp src/covermanager/qobuzcoverprovider.cpp
@@ -1503,7 +1482,6 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.h src/qobuz/qobuzrequest.h
src/qobuz/qobuzstreamurlrequest.h src/qobuz/qobuzstreamurlrequest.h
src/qobuz/qobuzfavoriterequest.h src/qobuz/qobuzfavoriterequest.h
src/qobuz/qobuzmetadatarequest.h
src/qobuz/qobuzcredentialfetcher.h src/qobuz/qobuzcredentialfetcher.h
src/settings/qobuzsettingspage.h src/settings/qobuzsettingspage.h
src/covermanager/qobuzcoverprovider.h src/covermanager/qobuzcoverprovider.h
@@ -1511,27 +1489,31 @@ optional_source(HAVE_QOBUZ
src/settings/qobuzsettingspage.ui src/settings/qobuzsettingspage.ui
) )
optional_source(HAVE_DROPBOX
SOURCES
src/dropbox/dropboxservice.cpp
src/dropbox/dropboxurlhandler.cpp
src/dropbox/dropboxbaserequest.cpp
src/dropbox/dropboxsongsrequest.cpp
src/dropbox/dropboxstreamurlrequest.cpp
src/settings/dropboxsettingspage.cpp
HEADERS
src/dropbox/dropboxservice.h
src/dropbox/dropboxurlhandler.h
src/dropbox/dropboxbaserequest.h
src/dropbox/dropboxsongsrequest.h
src/dropbox/dropboxstreamurlrequest.h
src/settings/dropboxsettingspage.h
UI
src/settings/dropboxsettingspage.ui
)
qt_wrap_cpp(SOURCES ${HEADERS}) qt_wrap_cpp(SOURCES ${HEADERS})
qt_wrap_ui(SOURCES ${UI}) qt_wrap_ui(SOURCES ${UI})
qt_add_resources(SOURCES data/data.qrc data/icons.qrc) qt_add_resources(SOURCES data/data.qrc data/icons.qrc)
add_library(strawberry_lib STATIC ${SOURCES}) add_library(strawberry_lib STATIC ${SOURCES})
# Treat Boost headers as system headers to avoid noisy warnings from 3rdparty
# Boost code (e.g. -Wold-style-cast) when building Strawberry with strict flags.
set(_strawberry_boost_system_includes "")
if(TARGET Boost::headers)
get_target_property(_strawberry_boost_system_includes Boost::headers INTERFACE_INCLUDE_DIRECTORIES)
elseif(TARGET Boost::boost)
get_target_property(_strawberry_boost_system_includes Boost::boost INTERFACE_INCLUDE_DIRECTORIES)
elseif(DEFINED Boost_INCLUDE_DIRS)
set(_strawberry_boost_system_includes "${Boost_INCLUDE_DIRS}")
endif()
if(_strawberry_boost_system_includes)
target_include_directories(strawberry_lib SYSTEM PRIVATE ${_strawberry_boost_system_includes})
endif()
unset(_strawberry_boost_system_includes)
target_sources(strawberry PRIVATE src/main.cpp) target_sources(strawberry PRIVATE src/main.cpp)
if(WIN32) if(WIN32)
@@ -1555,22 +1537,10 @@ if(HAVE_DISCORD_RPC)
endif() endif()
if(HAVE_TRANSLATIONS) 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) 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) 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") 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) 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) if(NOT INSTALL_TRANSLATIONS)
qt_add_resources(strawberry "translations" PREFIX "/i18n" BASE "${CMAKE_CURRENT_BINARY_DIR}/data" FILES "${INSTALL_TRANSLATIONS_FILES}") qt_add_resources(strawberry "translations" PREFIX "/i18n" BASE "${CMAKE_CURRENT_BINARY_DIR}/data" FILES "${INSTALL_TRANSLATIONS_FILES}")
endif() endif()

View File

@@ -2,40 +2,6 @@ Strawberry Music Player
======================= =======================
ChangeLog 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): Version 1.2.16 (2025.12.16):
* Make Discord Rich presence use filename if song title is missing * 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. * 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. * (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 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: Enhancements:
* Improve error messages when connecting and copying to devices. * 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,22 +0,0 @@
class Macdeploycheck < Formula
desc "Sanity checks a macOS .app bundle for accidental Homebrew runtime dependencies"
homepage "https://github.com/strawberrymusicplayer/strawberry"
version "0.1.0"
# Homebrew requires a URL stanza. Use the script shipped in this tap (file://),
# so installs always match the tapped revision.
url "file://#{File.expand_path("../dist/macos/macdeploycheck.sh", __dir__)}"
sha256 "07d361dcecf98af44fa076cc4253af907e23dee273c198a60128dae41b98432d"
license "MIT"
depends_on :macos
def install
bin.install "macdeploycheck.sh" => "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 Supporting open-source developers helps ensure continued maintenance and improvements.
./build_tools/macos/install_brew_deps.sh
```
Build: ---
```bash ## :white_check_mark: Features
./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
- Play and organize your music collection - Play and organize your music collection
- Supports formats: WAV, FLAC, WavPack, Ogg Vorbis, Opus, MPC, TrueAudio, AIFF, MP4, MP3, ASF, and Monkeys Audio - 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**. :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 ## :gear: Requirements
To build Strawberry from source, youll need: 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 ## :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:** **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,421 +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
### Common failure: errSecInternalComponent / chain-to-root warnings
If you see errors like:
- `Warning: unable to build chain to self-signed root for signer "Apple Distribution: ..."`
- `errSecInternalComponent`
This is almost always a **keychain search list / trust chain** issue.
#### Important: do NOT “Always Trust” your Apple Distribution / Installer certs
Setting your leaf signing certificates (e.g. **Apple Distribution** / **3rd Party Mac Developer Installer**) to **Always Trust** can make things worse by overriding the normal trust chain and causing codesign to fail chain building.
If you changed trust settings:
- In **Keychain Access → login → My Certificates**
- open the cert → **Trust**
- set **“When using this certificate” = “Use System Defaults”**
Fix (safe, common): ensure the System keychains are included in the user search list:
```bash
security list-keychains -d user
security list-keychains -d user -s "$HOME/Library/Keychains/login.keychain-db" "/Library/Keychains/System.keychain" "/System/Library/Keychains/SystemRootCertificates.keychain"
```
Then re-run the build/sign script.
#### Install the correct Apple intermediate certificates (WWDR)
If the System keychains are already in the search list and you still get chain errors, youre likely missing an Apple intermediate (commonly **WWDR**).
Download the current Apple WWDR intermediate certificate(s) from Apples official Certificate Authority page:
- `https://www.apple.com/certificateauthority/`
Then import into the **System** keychain (recommended):
- Keychain Access → **System** keychain → File → **Import Items…** → select the downloaded `.cer`
Or via CLI (requires admin):
```bash
sudo security add-certificates -k /Library/Keychains/System.keychain "/path/to/WWDR.cer"
```
Verify its visible:
```bash
security find-certificate -a -c "Apple Worldwide Developer Relations" /Library/Keychains/System.keychain | head -n 10
```
If needed, you can also verify the chain for your distribution cert:
```bash
security verify-cert -c "Apple Distribution: Dry Ark LLC (7628766FL2)" 2>&1 | head -n 80
```
```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
### Where the `.provisionprofile` ends up (newer Xcode/macOS)
Recent Xcode versions store “downloaded manual profiles” under:
- `~/Library/Developer/Xcode/UserData/Provisioning Profiles/`
Older tooling sometimes used:
- `~/Library/MobileDevice/Provisioning Profiles/`
This repos MAS build script does **not** require the profile to be in a specific folder — you can pass the path directly.
To locate and pick the right profile, use:
```bash
./build_tools/macos/find_mas_provisioning_profile.sh --bundle-id com.dryark.strawberry
```
### (Optional) Copy to the legacy folder
If some other tools expect the legacy folder, you can copy it there:
```bash
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp -f "/path/to/profile.provisionprofile" "$HOME/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`
---
## Architecture note — arm64 vs universal (arm64+x86_64)
For Mac App Store uploads, your `.pkg` can contain either:
- **arm64-only** app (Apple Silicon only), or
- **universal** app (arm64 + x86_64), or
- **x86_64-only** app (runs on Apple Silicon under Rosetta 2, but native Intel only otherwise)
Apple does **not** require universal binaries for review. **arm64-only is allowed**, but:
- Intel Macs **cannot** run an arm64-only app.
- If you ship arm64-only, App Store Connect will effectively make the app available only to Apple Silicon Macs (and your listing will reflect that).
### Recommendation
- If you want the broadest compatibility, aim for a **universal build**.
- If youre okay supporting only Apple Silicon Macs, arm64-only is the simplest path.
### Can I upload two different `.pkg`s (one arm64, one x86_64)?
Not in the way you want.
- In App Store Connect you can upload multiple builds over time, but for any given version/submission you ultimately pick **one build** to submit.
- Apple will not “merge” two separate uploads (arm64-only + x86_64-only) into one app for customers.
If you want both Apple Silicon and Intel supported **natively**, you need to produce a **single universal** app bundle and package that into **one** `.pkg`.
If you dont want to deal with universal yet, your practical choices are:
- **arm64-only**: Apple Silicon only.
- **x86_64-only**: runs on Intel natively, and on Apple Silicon under **Rosetta 2** (slower, but widely compatible).
### Practical reality for this repo
This project depends on large native dependency stacks (Qt, GStreamer, plugins). If you build those via Homebrew, you typically end up with **single-architecture** libraries (arm64 under `/opt/homebrew`, x86_64 under `/usr/local`).
A true universal app requires **all bundled native code** (your executable *and* all `.dylib`/plugins/frameworks you ship) to be universal as well.
If you decide you want universal:
- Youll need a universal build of **Qt** and **GStreamer** (and all bundled plugins), or
- Build arm64 and x86_64 bundles separately and combine *matching* binaries where possible (advanced; easy to break signing / plugin loading).
---
## Troubleshooting — `productbuild` fails with CSSM `-60008` (authorization)
If you see something like:
- `SignData failed ... CSSM Exception: -60008 Unable to obtain authorization for this operation`
That means the **Installer** certificate is present, but macOS is not allowing `productbuild` to use the **private key** without additional authorization.
### Fix option A (recommended): set key partition list (CLI)
This is the standard “allow Apple tools to sign without GUI prompts” fix:
```bash
security unlock-keychain "$HOME/Library/Keychains/login.keychain-db"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "<login-keychain-password>" "$HOME/Library/Keychains/login.keychain-db"
```
Note: if your password contains characters like `!` or `$` and you paste it into a command in `zsh`,
the shell can modify it (history/variable expansion) and `security ... -k` may claim its “incorrect”.
Use **single quotes** (or the env var path shown below) to avoid this, e.g.:
```bash
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k 'p@ssw0rd!$' "$HOME/Library/Keychains/login.keychain-db"
```
Then rerun:
```bash
./build_tools/macos/build_mas_pkg.sh --run ...
```
This repos script also supports:
- `--keychain-password <pw>` (or env var `STRAWBERRY_KEYCHAIN_PASSWORD`)
### Fix option B: Keychain Access UI (one-time)
1. Open **Keychain Access**
2. Select **login** keychain → **My Certificates**
3. Find your installer cert (e.g. `3rd Party Mac Developer Installer: ...`) and **expand it**
4. Select the **private key** under it
5. **Get Info → Access Control**
- Add `/usr/bin/productbuild` (and optionally `/usr/bin/pkgbuild`) to the allowed apps
---
## Step 7 — Upload + submit for review
### 7.1 Install Apple “Transporter” (the upload tool)
Apple requires Mac App Store submissions to be uploaded using **Transporter** (a macOS app published by Apple).
Where to get it:
- Install **Transporter** from the **Mac App Store** (search for “Transporter”).
- App Store listing name is typically **“Transporter”** by Apple.
### 7.2 Upload the `.pkg` with Transporter
1. Open **Transporter**
2. Sign in with the Apple ID that has access to **App Store Connect**
3. Click **Add App** (or **+**) and choose your signed upload package:
- `cmake-build-macos-release-mas/strawberry-mas.pkg` (or your custom `--pkg-out` path)
4. Click **Deliver**
5. Wait for upload + server-side validation to complete
Notes:
- Uploading can take a while depending on your connection.
- If Transporter reports an error, the message usually includes the exact App Store Connect requirement you violated (bundle id mismatch, missing entitlements, invalid signature, etc.).
### 7.3 Submit the build in App Store Connect
1. Open **App Store Connect** in your browser and go to **My Apps**
2. Select your app, then go to the **macOS App** platform section
3. Find your uploaded build under **TestFlight** or **Prepare for Submission** (Apples UI wording changes over time)
4. Wait for Apple to finish “Processing” the build
5. Select the build for your version, complete required metadata, then click **Submit for Review**
### (Optional) CLI upload (advanced): `iTMSTransporter`
If you prefer uploading from the command line, Apples underlying uploader is **iTMSTransporter**.
On most systems its available via Xcode command line tools as:
```bash
xcrun iTMSTransporter -help
```
CLI upload requires additional credentials (App Store Connect API key or Apple ID auth) and is easier to get wrong than the Transporter GUI.
For most folks, **Transporter.app is the recommended path**.
---
## Creating a universal Mac App Store upload using two Macs (arm64 + x86_64)
If you have both an Apple Silicon Mac and an Intel Mac, the most reliable way to ship a universal app for this repo is:
1. Build + deploy the **unsigned** MAS app bundle on each machine (arm64 and x86_64).
2. Copy both `.app` bundles to the machine that has your signing keys.
3. Merge them with `lipo` and then **sign + package** once, producing a single universal `.pkg`.
### Step A — Build + deploy (arm64 machine)
On your Apple Silicon Mac:
```bash
./build_tools/macos/build_app.sh --release --clean --mas --deploy --build-dir ./cmake-build-macos-release-mas-arm64
```
This produces (unsigned):
- `cmake-build-macos-release-mas-arm64/strawberry.app`
### Step B — Build + deploy (x86_64 machine)
On your Intel Mac:
```bash
./build_tools/macos/build_app.sh --release --clean --mas --deploy --build-dir ./cmake-build-macos-release-mas-x86_64
```
This produces (unsigned):
- `cmake-build-macos-release-mas-x86_64/strawberry.app`
### Step C — Copy both app bundles to one “packaging” machine
Pick the Mac that has your **Apple Distribution** and **Installer** identities (private keys) installed.
Copy both `.app` bundles onto that Mac, for example:
- `/path/to/inputs/strawberry-arm64.app`
- `/path/to/inputs/strawberry-x86_64.app`
Tip: `rsync` works well for app bundles:
```bash
rsync -a "/path/to/arm64/strawberry.app" "/path/to/inputs/strawberry-arm64.app"
rsync -a "/path/to/x86_64/strawberry.app" "/path/to/inputs/strawberry-x86_64.app"
```
### Step D — Merge + sign + build the universal `.pkg`
On the packaging machine:
```bash
./build_tools/macos/build_mas_universal_pkg.sh --run \
--arm-app "/path/to/inputs/strawberry-arm64.app" \
--x86-app "/path/to/inputs/strawberry-x86_64.app" \
--codesign-identity "Apple Distribution: Your Name (TEAMID)" \
--installer-identity "3rd Party Mac Developer Installer: Your Name (TEAMID)" \
--provisionprofile "/path/to/profile.provisionprofile"
```
Outputs:
- `cmake-build-macos-release-mas-universal/strawberry.app` (universal)
- `cmake-build-macos-release-mas-universal/strawberry-mas-universal.pkg`
### Important constraints (dont skip)
- The two input apps must be built from the **same commit** with the **same enabled features** so the app bundle layouts match.
- Do **not** sign the per-arch apps first; `lipo` invalidates signatures. Sign **only after** merging.

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,324 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Guard: this script must be executed with bash (not sourced into zsh, not run via sh).
if [[ -z "${BASH_VERSION:-}" ]]; then
echo "Error: this script must be run with bash (it uses bash arrays)." >&2
echo "Run:" >&2
echo " bash ./build_tools/macos/build_mas_pkg.sh --run ..." >&2
exit 2
fi
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)"
prepare_login_keychain_for_signing() {
# Some setups require explicitly granting Apple tooling access to the private key(s)
# (otherwise productbuild/codesign can fail with authorization errors like:
# CSSM Exception: -60008 Unable to obtain authorization for this operation
# or "User interaction is not allowed").
#
# This function is optional and only runs if a keychain password is provided.
local keychain_path="$HOME/Library/Keychains/login.keychain-db"
local pw="${1:-}"
if [[ -z "$pw" ]]; then
echo "==> [$(ts)] Note: no keychain password provided; skipping keychain access-control preparation."
echo " If productbuild later fails with -60008 authorization errors, fix it with either:"
echo " - Keychain Access → login → My Certificates → select the *private key* under the Installer cert → Get Info → Access Control → allow productbuild"
echo " - OR (CLI): security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <password> \"$keychain_path\""
return 0
fi
echo "==> [$(ts)] Preparing login keychain for signing (unlock + key partition list)"
# Unlock so Security/Authorization can use keys without prompting.
security unlock-keychain -p "$pw" "$keychain_path" >/dev/null 2>&1 || true
# Allow Apple tools (codesign/productbuild) to access the private key without GUI prompts.
# This is the standard fix used for non-interactive signing.
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$pw" "$keychain_path" >/dev/null 2>&1 || true
}
ensure_keychain_search_list() {
# codesign builds the cert chain using the user keychain search list.
# If the list is missing the System keychain, you can get:
# "unable to build chain to self-signed root" + errSecInternalComponent
local login_kc="$HOME/Library/Keychains/login.keychain-db"
local system_kc="/Library/Keychains/System.keychain"
local roots_kc="/System/Library/Keychains/SystemRootCertificates.keychain"
local current
current="$(security list-keychains -d user 2>/dev/null | tr -d '"' | tr -d ' ' || true)"
if echo "$current" | grep -Fq "$system_kc"; then
return 0
fi
echo "==> [$(ts)] Note: adding System keychains to the user keychain search list (fixes common codesign chain errors)"
echo " (This changes the user keychain search list; run 'security list-keychains -d user' to view.)"
security list-keychains -d user -s "$login_kc" "$system_kc" "$roots_kc" >/dev/null 2>&1 || true
}
diagnose_chain_failure() {
echo "==> [$(ts)] Codesign failed. Common causes on macOS:"
echo " - System keychains not in the user keychain search list"
echo " - Missing/invalid WWDR intermediate certificate"
echo " - Keychain/key access issues"
echo
echo "==> [$(ts)] Keychain search list:"
security list-keychains -d user || true
echo
echo "==> [$(ts)] Checking for Apple WWDR intermediate in System keychain:"
security find-certificate -a -c "Apple Worldwide Developer Relations" /Library/Keychains/System.keychain 2>/dev/null | head -n 5 || true
echo
echo "==> [$(ts)] If WWDR is missing, install the current Apple WWDR intermediate certificate (via Xcode or Apple Developer portal)."
echo "==> [$(ts)] Then re-run this script."
}
preflight_identity() {
local what="$1"
local policy="$2"
local identity="$3"
# NOTE: security expects "-p <policy>" as *two* args; do not pass "-p codesigning" as one string.
if ! security find-identity -p "$policy" -v 2>/dev/null | grep -Fq "$identity"; then
echo "Error: ${what} identity not found/usable in Keychain: $identity" >&2
echo "Run: ./build_tools/macos/check_signing_identities.sh" >&2
exit 2
fi
}
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)
--keychain-password <pw> OPTIONAL: unlock/login keychain + set key partition list for Apple tools
(alternative: set env var STRAWBERRY_KEYCHAIN_PASSWORD)
Examples:
# Tip: if your keychain password contains characters like ! or $, prefer the env var or single quotes.
STRAWBERRY_KEYCHAIN_PASSWORD='your-login-keychain-password' \
./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"
./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=""
keychain_password="${STRAWBERRY_KEYCHAIN_PASSWORD:-}"
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 ;;
--keychain-password) keychain_password="${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"
ensure_keychain_search_list
prepare_login_keychain_for_signing "$keychain_password"
preflight_identity "codesign" "codesigning" "$codesign_identity"
preflight_identity "installer" "basic" "$installer_identity"
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
if ! productbuild \
--component "$app_path" /Applications \
--sign "$installer_identity" \
"$pkg_out"; then
echo "Error: productbuild failed while signing the .pkg." >&2
echo "Common cause: keychain/private-key authorization (e.g. CSSM -60008)." >&2
echo >&2
echo "Fix options:" >&2
echo "1) Keychain Access UI:" >&2
echo " - Keychain Access → login → My Certificates" >&2
echo " - Find: $installer_identity" >&2
echo " - Expand it and select the *private key* under it" >&2
echo " - Get Info → Access Control → allow /usr/bin/productbuild (optionally also allow /usr/bin/pkgbuild)" >&2
echo "2) CLI (recommended for repeatable builds):" >&2
echo " security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <password> \"$HOME/Library/Keychains/login.keychain-db\"" >&2
echo >&2
echo "Tip: you can also rerun this script with:" >&2
echo " --keychain-password <login-keychain-password>" >&2
exit 1
fi
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,241 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Build a universal (arm64+x86_64) Mac App Store upload package by:
# - merging two already-deployed Strawberry.app bundles (arm64 + x86_64) using lipo
# - embedding a Mac App Store provisioning profile
# - codesigning with Apple Distribution (+ entitlements)
# - producing a signed .pkg with productbuild (Installer identity)
#
# Intended workflow with two Macs:
# 1) On Apple Silicon Mac: build+deploy MAS app bundle (unsigned) → copy strawberry.app somewhere
# 2) On Intel Mac: build+deploy MAS app bundle (unsigned) → copy strawberry.app somewhere
# 3) On the Mac that holds your signing keys (either one): run THIS script to merge+sign+package
ts() { date +"%H:%M:%S"; }
if [[ -z "${BASH_VERSION:-}" ]]; then
echo "Error: this script must be run with bash." >&2
exit 2
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/../.." && pwd)"
ensure_keychain_search_list() {
local login_kc="$HOME/Library/Keychains/login.keychain-db"
local system_kc="/Library/Keychains/System.keychain"
local roots_kc="/System/Library/Keychains/SystemRootCertificates.keychain"
local current
current="$(security list-keychains -d user 2>/dev/null | tr -d '"' | tr -d ' ' || true)"
if echo "$current" | grep -Fq "$system_kc"; then
return 0
fi
echo "==> [$(ts)] Note: adding System keychains to the user keychain search list"
security list-keychains -d user -s "$login_kc" "$system_kc" "$roots_kc" >/dev/null 2>&1 || true
}
prepare_login_keychain_for_signing() {
local keychain_path="$HOME/Library/Keychains/login.keychain-db"
local pw="${1:-}"
if [[ -z "$pw" ]]; then
return 0
fi
echo "==> [$(ts)] Preparing login keychain for signing (unlock + key partition list)"
security unlock-keychain -p "$pw" "$keychain_path" >/dev/null 2>&1 || true
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$pw" "$keychain_path" >/dev/null 2>&1 || true
}
preflight_identity() {
local what="$1"
local policy="$2"
local identity="$3"
if ! security find-identity -p "$policy" -v 2>/dev/null | grep -Fq "$identity"; then
echo "Error: ${what} identity not found/usable in Keychain: $identity" >&2
exit 2
fi
}
usage() {
cat <<'EOF'
Usage:
./build_tools/macos/build_mas_universal_pkg.sh --run [options]
Required:
--run
--arm-app <path> Path to arm64 Strawberry.app (already built+deployed, unsigned)
--x86-app <path> Path to x86_64 Strawberry.app (already built+deployed, unsigned)
--codesign-identity "<name>" Apple Distribution: ...
--installer-identity "<name>" 3rd Party Mac Developer Installer: ...
--provisionprofile <path> Mac App Store provisioning profile (*.provisionprofile)
Optional:
--out-dir <path> Output directory (default: cmake-build-macos-release-mas-universal)
--entitlements <plist> Codesign entitlements (default: dist/macos/entitlements.mas.plist)
--pkg-out <path> Output .pkg path (default: <out-dir>/strawberry-mas-universal.pkg)
--bundle-id <id> For display/logging only (does not rewrite Info.plist)
--keychain-password <pw> Or set env var STRAWBERRY_KEYCHAIN_PASSWORD (quote it!)
Notes:
- This script does NOT build Strawberry. It merges two pre-built app bundles.
- After lipo-merging, the app must be re-signed (this script does that).
EOF
}
do_run=0
arm_app=""
x86_app=""
out_dir=""
codesign_identity=""
installer_identity=""
provisionprofile=""
entitlements=""
pkg_out=""
bundle_id=""
keychain_password="${STRAWBERRY_KEYCHAIN_PASSWORD:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--run) do_run=1; shift ;;
--arm-app) arm_app="${2:-}"; shift 2 ;;
--x86-app) x86_app="${2:-}"; shift 2 ;;
--out-dir) out_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 ;;
--pkg-out) pkg_out="${2:-}"; shift 2 ;;
--bundle-id) bundle_id="${2:-}"; shift 2 ;;
--keychain-password) keychain_password="${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
exit 0
fi
if [[ -z "$arm_app" || ! -d "$arm_app" ]]; then
echo "Error: missing/invalid --arm-app: $arm_app" >&2
exit 2
fi
if [[ -z "$x86_app" || ! -d "$x86_app" ]]; then
echo "Error: missing/invalid --x86-app: $x86_app" >&2
exit 2
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 "$out_dir" ]]; then
out_dir="${repo_root}/cmake-build-macos-release-mas-universal"
fi
mkdir -p "$out_dir"
universal_app="${out_dir}/strawberry.app"
if [[ -e "$universal_app" ]]; then
rm -rf "$universal_app"
fi
echo "==> [$(ts)] Repo: $repo_root"
echo "==> [$(ts)] arm app: $arm_app"
echo "==> [$(ts)] x86 app: $x86_app"
echo "==> [$(ts)] out dir: $out_dir"
if [[ -n "$bundle_id" ]]; then
echo "==> [$(ts)] bundle id (expected): $bundle_id"
fi
echo "==> [$(ts)] Creating universal app bundle (lipo merge)"
"${repo_root}/build_tools/macos/make_universal_app.sh" \
--arm-app "$arm_app" \
--x86-app "$x86_app" \
--out-app "$universal_app" \
--clean
echo "==> [$(ts)] Embedding provisioning profile"
cp -f "$provisionprofile" "${universal_app}/Contents/embedded.provisionprofile"
ensure_keychain_search_list
prepare_login_keychain_for_signing "$keychain_password"
preflight_identity "codesign" "codesigning" "$codesign_identity"
preflight_identity "installer" "basic" "$installer_identity"
echo "==> [$(ts)] Codesigning universal app (Mac App Store)"
codesign_args=( --force --timestamp --options runtime --sign "$codesign_identity" --entitlements "$entitlements" )
# Clean up any leftover codesign temp files and xattrs.
find "$universal_app" -name "*.cstemp" -print0 2>/dev/null | while IFS= read -r -d '' f; do rm -f "$f" || true; done
xattr -dr com.apple.provenance "$universal_app" >/dev/null 2>&1 || true
xattr -dr com.apple.quarantine "$universal_app" >/dev/null 2>&1 || true
# Sign nested code first, then frameworks, then the main app bundle.
find "$universal_app" -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
if /usr/bin/file -b "$f" | grep -q "Mach-O"; then
codesign "${codesign_args[@]}" "$f" >/dev/null
fi
done
find "$universal_app" -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 "$universal_app/Contents/Frameworks" "$universal_app/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[@]}" "$universal_app" >/dev/null
echo "==> [$(ts)] Verifying codesign"
codesign --verify --deep --strict --verbose=2 "$universal_app"
if [[ -z "$pkg_out" ]]; then
pkg_out="${out_dir}/strawberry-mas-universal.pkg"
fi
rm -f "$pkg_out" >/dev/null 2>&1 || true
echo "==> [$(ts)] Building signed .pkg for App Store upload"
productbuild \
--component "$universal_app" /Applications \
--sign "$installer_identity" \
"$pkg_out"
echo "==> [$(ts)] Verifying pkg signature"
pkgutil --check-signature "$pkg_out" || true
echo
echo "Done."
echo "Universal app: $universal_app"
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,207 +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_dirs=(
"${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"
"${HOME}/Library/MobileDevice/Provisioning Profiles"
)
any_prof=0
for prof_dir in "${prof_dirs[@]}"; do
if [[ -d "${prof_dir}" ]]; then
any_prof=1
echo " ${prof_dir}"
ls -la "${prof_dir}" | head -n 20
echo
fi
done
if [[ "$any_prof" -eq 0 ]]; then
echo "(no provisioning profile directories found in common locations)"
fi
echo "Tip: to pick the right MAS profile for a bundle id, run:"
echo " ./build_tools/macos/find_mas_provisioning_profile.sh --bundle-id com.dryark.strawberry"
\n\necho\n
echo "==> [$(ts)] Recommended SHA-1 values to use (avoids ambiguity when names are duplicated)"
cat <<'EOF'
When you have multiple identities with the same display name, prefer using the SHA-1 hash in scripts:
--codesign-identity "<SHA1>"
--installer-identity "<SHA1>"
This prevents codesign/productbuild from picking an unexpected identity.
EOF
echo
extract_identities() {
local policy="$1" # codesigning | basic
# Output: SHA1|LABEL
security find-identity -p "$policy" -v 2>/dev/null \
| sed -n 's/^[[:space:]]*[0-9][0-9]*[)] \([0-9A-F]\{40\}\) "\(.*\)"$/\1|\2/p'
}
print_sha_list() {
local title="$1"
local policy="$2"
local label_match="$3"
echo "$title"
local matches
matches="$(extract_identities "$policy" | grep -F "$label_match" || true)"
if [[ -z "$matches" ]]; then
echo " (none found)"
return 0
fi
local first=1
while IFS='|' read -r sha label; do
[[ -z "$sha" || -z "$label" ]] && continue
if [[ $first -eq 1 ]]; then
echo " recommended: $sha ($label)"
first=0
else
echo " alternative: $sha ($label)"
fi
done <<<"$matches"
}
print_sha_list "Mac App Store (app signing) [use with --codesign-identity]:" "codesigning" "Apple Distribution:"
print_sha_list "Mac App Store (pkg signing) [use with --installer-identity]:" "basic" "3rd Party Mac Developer Installer:"
print_sha_list "Developer ID (app signing) [outside App Store]:" "codesigning" "Developer ID Application:"
print_sha_list "Developer ID (pkg signing) [outside App Store]:" "basic" "Developer ID Installer:"

View File

@@ -1,26 +0,0 @@
Encryption Export Compliance Statement (EAR)
App Name: Strawberry
Bundle ID: com.dryark.strawberry
Version: 0.0.0
Developer: Dry Ark LLC
Date: 2026-01-22
Contact: support@example.com
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,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,210 +0,0 @@
#!/usr/bin/env python3
import argparse
import datetime as dt
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import plistlib
@dataclass
class ProfileInfo:
path: Path
uuid: str
name: str
team_id: str
expiration: Optional[dt.datetime]
app_id: str
platforms: List[str]
def run(cmd: List[str]) -> Tuple[int, bytes, bytes]:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate()
return p.returncode, out, err
def decode_profile_plist_bytes(profile_path: Path) -> Optional[bytes]:
# Provisioning profiles are typically CMS/PKCS#7 SignedData blobs whose payload is a plist.
# However, some tools store them as plain XML plists already. Also, LibreSSL/OpenSSL behavior
# differs: LibreSSL usually requires an explicit '-verify' to emit the embedded content.
data = profile_path.read_bytes()
# Fast path: already a plist (XML).
if b"<plist" in data:
return data
# Decode CMS/PKCS7 to extract embedded plist payload.
# Try a small matrix of commands/inform formats for compatibility.
candidates: List[List[str]] = []
for inform in ("DER", "PEM"):
candidates.append(["/usr/bin/openssl", "cms", "-verify", "-noverify", "-inform", inform, "-in", str(profile_path)])
candidates.append(["/usr/bin/openssl", "smime", "-verify", "-noverify", "-inform", inform, "-in", str(profile_path)])
for cmd in candidates:
rc, out, _err = run(cmd)
if rc == 0 and b"<plist" in out:
return out
return None
def parse_plist(plist_bytes: bytes) -> Dict[str, Any]:
return plistlib.loads(plist_bytes)
def iso(dt_obj: Optional[dt.datetime]) -> str:
if not dt_obj:
return "(unknown)"
# Force UTC-ish display if tz-aware, otherwise as-is.
try:
return dt_obj.isoformat().replace("+00:00", "Z")
except Exception:
return str(dt_obj)
def safe_str(v: Any) -> str:
if v is None:
return ""
if isinstance(v, bytes):
try:
return v.decode("utf-8", errors="replace")
except Exception:
return repr(v)
return str(v)
def profile_info_from_plist(path: Path, p: Dict[str, Any]) -> ProfileInfo:
uuid = safe_str(p.get("UUID", "")) or "(unknown)"
name = safe_str(p.get("Name", "")) or "(unknown)"
team_ids = p.get("TeamIdentifier") or []
team_id = safe_str(team_ids[0]) if isinstance(team_ids, list) and team_ids else ""
if not team_id:
prefixes = p.get("ApplicationIdentifierPrefix") or []
team_id = safe_str(prefixes[0]) if isinstance(prefixes, list) and prefixes else "(unknown)"
exp = p.get("ExpirationDate")
expiration = exp if isinstance(exp, dt.datetime) else None
ent = p.get("Entitlements") or {}
app_id = safe_str(ent.get("application-identifier") or ent.get("com.apple.application-identifier") or "") or "(unknown)"
platforms = p.get("Platform") or []
if isinstance(platforms, str):
platforms = [platforms]
platforms = [safe_str(x) for x in platforms if x is not None]
return ProfileInfo(path=path, uuid=uuid, name=name, team_id=team_id or "(unknown)", expiration=expiration, app_id=app_id, platforms=platforms)
def score(profile: ProfileInfo, bundle_id: str, now: dt.datetime) -> Tuple[int, str]:
# Prefer non-expired.
if profile.expiration and profile.expiration < now:
return (-1, "expired")
score = 0
reason = []
# Prefer exact app id match TEAMID.bundle_id
if profile.team_id != "(unknown)" and profile.app_id != "(unknown)":
exact = f"{profile.team_id}.{bundle_id}"
if profile.app_id == exact:
score += 100
reason.append(f"exact {profile.app_id}")
elif profile.app_id.endswith(f".{bundle_id}"):
score += 60
reason.append(f"endswith {profile.app_id}")
elif "*" in profile.app_id and profile.app_id.startswith(f"{profile.team_id}."):
score += 40
reason.append(f"wildcard {profile.app_id}")
# Heuristic: name suggests MAS.
n = profile.name.lower()
if "mac app store" in n or "app store" in n or "appstore" in n:
score += 5
reason.append("name looks like MAS")
# Prefer macOS platform if present.
plats = [p.lower() for p in profile.platforms]
if any("macos" in p for p in plats):
score += 2
reason.append("platform macos")
return (score, ", ".join(reason) if reason else "")
def find_profiles() -> List[Path]:
dirs = [
Path.home() / "Library" / "Developer" / "Xcode" / "UserData" / "Provisioning Profiles",
Path.home() / "Library" / "MobileDevice" / "Provisioning Profiles",
]
out: List[Path] = []
for d in dirs:
if not d.is_dir():
continue
for p in d.iterdir():
if p.is_file() and (p.name.endswith(".provisionprofile") or p.name.endswith(".mobileprovision")):
out.append(p)
return sorted(out)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--bundle-id", required=True)
args = ap.parse_args()
bundle_id = args.bundle_id
if not Path("/usr/bin/openssl").exists():
print("Error: /usr/bin/openssl not found.", file=sys.stderr)
return 1
candidates = find_profiles()
if not candidates:
print("No provisioning profiles found in common locations.", file=sys.stderr)
return 1
print(f"Scanning {len(candidates)} provisioning profile(s) for bundle id: {bundle_id}")
print()
print(f"{'No.':<4} {'UUID':<36} {'TeamID':<10} {'Expires':<25} {'AppID':<45} Path")
print(f"{'-'*4} {'-'*36} {'-'*10} {'-'*25} {'-'*45} ----")
infos: List[ProfileInfo] = []
for i, p in enumerate(candidates, start=1):
plist_bytes = decode_profile_plist_bytes(p)
if not plist_bytes:
continue
try:
pl = parse_plist(plist_bytes)
info = profile_info_from_plist(p, pl)
infos.append(info)
print(f"{i:<4} {info.uuid:<36} {info.team_id:<10} {iso(info.expiration):<25} {info.app_id:<45} {info.path}")
except Exception:
continue
if not infos:
print("\nCould not decode any provisioning profiles with openssl cms.", file=sys.stderr)
return 2
now = dt.datetime.now(dt.timezone.utc)
best: Optional[Tuple[int, str, ProfileInfo]] = None
for info in infos:
sc, why = score(info, bundle_id, now)
if best is None or sc > best[0]:
best = (sc, why, info)
print()
if best is None or best[0] <= 0:
print(f"Could not confidently auto-select a profile for {bundle_id}.", file=sys.stderr)
print("Pick the profile whose AppID is TEAMID.<bundle-id> and is a macOS Mac App Store profile.", file=sys.stderr)
return 2
_, why, info = best
print("Recommended profile:")
print(f" {info.path}")
print(f" reason: {why}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,160 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ts() { date +"%H:%M:%S"; }
usage() {
cat <<'EOF'
Usage:
./build_tools/macos/find_mas_provisioning_profile.sh --bundle-id com.dryark.strawberry
What it does:
- Scans common macOS provisioning profile locations (new Xcode + legacy)
- Uses Apple's `security cms -D` to decode each *.provisionprofile into a plist
- Prints a readable table and recommends a best match for the given bundle id
Notes:
- A provisioning profile is required for Mac App Store signing.
- This script only helps you *find* the right profile file.
EOF
}
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
bundle_id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-id) bundle_id="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ -z "$bundle_id" ]]; then
echo "Error: missing --bundle-id" >&2
usage
exit 2
fi
if ! command -v security >/dev/null 2>&1; then
echo "Error: 'security' not found." >&2
exit 1
fi
plistbuddy_print() {
local keypath="$1"
local plist="$2"
/usr/libexec/PlistBuddy -c "Print :${keypath}" "$plist" 2>/dev/null || true
}
plutil_extract() {
local keypath="$1"
local plist="$2"
/usr/bin/plutil -extract "$keypath" raw -o - "$plist" 2>/dev/null || true
}
find_profiles_in_dir() {
local dir="$1"
if [[ -d "$dir" ]]; then
find "$dir" -maxdepth 1 -type f \( -name "*.provisionprofile" -o -name "*.mobileprovision" \) 2>/dev/null || true
fi
}
declare -a candidates
candidates=()
while IFS= read -r f; do candidates+=("$f"); done < <(find_profiles_in_dir "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles")
while IFS= read -r f; do candidates+=("$f"); done < <(find_profiles_in_dir "$HOME/Library/MobileDevice/Provisioning Profiles")
if [[ ${#candidates[@]} -eq 0 ]]; then
echo "==> [$(ts)] No provisioning profiles found in common locations."
exit 1
fi
echo "==> [$(ts)] Scanning ${#candidates[@]} provisioning profile(s) for bundle id: ${bundle_id}"
echo
printf "%-4s %-36s %-10s %-25s %-45s %s\n" "No." "UUID" "TeamID" "Expires" "AppID" "Path"
printf "%s\n" "---- ------------------------------------ ---------- ------------------------- --------------------------------------------- ----"
best_score=-1
best_path=""
best_reason=""
idx=0
for f in "${candidates[@]}"; do
idx=$((idx + 1))
tmp="$(mktemp -t strawberry-profile.XXXXXX.plist)"
if ! security cms -D -i "$f" >"$tmp" 2>/dev/null; then
rm -f "$tmp" >/dev/null 2>&1 || true
continue
fi
uuid="$(plutil_extract UUID "$tmp")"
name="$(plutil_extract Name "$tmp")"
teamid="$(plutil_extract 'TeamIdentifier.0' "$tmp")"
if [[ -z "$teamid" ]]; then
teamid="$(plutil_extract 'ApplicationIdentifierPrefix.0' "$tmp")"
fi
exp="$(plutil_extract ExpirationDate "$tmp")"
# App identifier lives under Entitlements; use PlistBuddy because some key names contain dots.
appid="$(plistbuddy_print 'Entitlements:application-identifier' "$tmp")"
if [[ -z "$appid" ]]; then
appid="$(plistbuddy_print 'Entitlements:com.apple.application-identifier' "$tmp")"
fi
rm -f "$tmp" >/dev/null 2>&1 || true
[[ -z "$uuid" ]] && uuid="(unknown)"
[[ -z "$teamid" ]] && teamid="(unknown)"
[[ -z "$exp" ]] && exp="(unknown)"
[[ -z "$appid" ]] && appid="(unknown)"
printf "%-4s %-36s %-10s %-25s %-45s %s\n" "$idx" "$uuid" "$teamid" "$exp" "$appid" "$f"
score=0
reason=""
if [[ "$appid" != "(unknown)" && "$teamid" != "(unknown)" ]]; then
if [[ "$appid" == "${teamid}.${bundle_id}" ]]; then
score=100
reason="exact match (${appid})"
elif [[ "$appid" == *".${bundle_id}" ]]; then
score=50
reason="endswith match (${appid})"
elif [[ "$appid" == "${teamid}."* && "$appid" == *"*"* ]]; then
score=40
reason="wildcard match (${appid})"
fi
fi
if [[ "$score" -gt 0 && -n "$name" ]]; then
case "$name" in
*Mac\ App\ Store*|*App\ Store*|*appstore*|*AppStore*)
score=$((score + 5))
reason="${reason}, name looks like MAS"
;;
esac
fi
if [[ "$score" -gt "$best_score" ]]; then
best_score="$score"
best_path="$f"
best_reason="$reason"
fi
done
echo
if [[ "$best_score" -le 0 ]]; then
echo "==> [$(ts)] Could not confidently auto-select a profile for ${bundle_id}."
echo "Pick the profile whose AppID is TEAMID.${bundle_id} and is a macOS Mac App Store profile."
exit 2
fi
echo "==> [$(ts)] Recommended profile:"
echo " $best_path"
echo " reason: $best_reason"

View File

@@ -1,141 +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
# Brewfile support (`brew bundle`) is built into modern Homebrew. The historical
# tap `homebrew/bundle` has been deprecated and may be empty on newer Homebrew.
# If `brew bundle` is missing, the fix is to update Homebrew itself.
if ! brew bundle --help >/dev/null 2>&1; then
run_with_heartbeat "Updating Homebrew (required for 'brew bundle')" bash -lc "brew update"
if ! brew bundle --help >/dev/null 2>&1; then
echo "Error: This Homebrew installation does not provide 'brew bundle'." >&2
echo "Update Homebrew (e.g. 'brew update') or reinstall Homebrew, then re-run this script." >&2
exit 1
fi
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"
echo "==> [$(date +\"%H:%M:%S\")] strawberry/local tap repo: $tap_repo"
echo " tap HEAD: $(git rev-parse --short HEAD)"
echo " origin: $(git remote get-url origin)"
# If the source repo is a git repo, also print its HEAD for debugging.
if git -C "'"$repo_root"'" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo " src HEAD: $(git -C "'"$repo_root"'" rev-parse --short HEAD)"
fi
'
for f in kdsingleapplication-qt6 qtsparkle-qt6 sparkle-framework libgpod macdeploycheck; do
if ! info_out="$(brew info "strawberry/local/${f}" 2>&1 >/dev/null)"; then
echo "Error: Unable to load formula strawberry/local/${f} from the tapped repo (brew info failed)." >&2
echo "Details (brew info):" >&2
echo "$info_out" >&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,170 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Create a universal (arm64+x86_64) .app by merging two already-deployed app bundles
# that have identical layouts, one built on Apple Silicon and one built on Intel.
#
# Usage:
# ./build_tools/macos/make_universal_app.sh \
# --arm-app /path/to/arm64/strawberry.app \
# --x86-app /path/to/x86_64/strawberry.app \
# --out-app /path/to/output/strawberry.app \
# [--clean]
#
# Notes:
# - Do NOT sign the per-arch apps first; signatures will be invalidated by lipo anyway.
# - Both inputs must be the same app version/config with the same enabled features,
# so the file lists match.
ts() { date +"%H:%M:%S"; }
usage() {
cat <<'EOF'
Usage:
./build_tools/macos/make_universal_app.sh --arm-app <path> --x86-app <path> --out-app <path> [--clean]
What it does:
- Copies the arm64 app to --out-app
- For every Mach-O file in the copied app, finds the corresponding file in the x86_64 app
- Uses lipo to combine the two slices into a universal binary at the same relative path
Required:
--arm-app <path> Path to arm64 Strawberry.app (built+deployed on Apple Silicon)
--x86-app <path> Path to x86_64 Strawberry.app (built+deployed on Intel)
--out-app <path> Output path for universal Strawberry.app
Optional:
--clean Delete --out-app if it already exists
EOF
}
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
arm_app=""
x86_app=""
out_app=""
do_clean=0
while [[ $# -gt 0 ]]; do
case "$1" in
--arm-app) arm_app="${2:-}"; shift 2 ;;
--x86-app) x86_app="${2:-}"; shift 2 ;;
--out-app) out_app="${2:-}"; shift 2 ;;
--clean) do_clean=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ -z "$arm_app" || -z "$x86_app" || -z "$out_app" ]]; then
echo "Error: missing required args." >&2
usage
exit 2
fi
if [[ ! -d "$arm_app" ]]; then
echo "Error: --arm-app not found: $arm_app" >&2
exit 2
fi
if [[ ! -d "$x86_app" ]]; then
echo "Error: --x86-app not found: $x86_app" >&2
exit 2
fi
for cmd in /usr/bin/file /usr/bin/lipo /usr/bin/ditto; do
if [[ ! -x "$cmd" ]]; then
echo "Error: required tool not found: $cmd" >&2
exit 1
fi
done
out_parent="$(cd -- "$(dirname -- "$out_app")" && pwd)"
out_name="$(basename -- "$out_app")"
out_app="${out_parent}/${out_name}"
if [[ -e "$out_app" && "$do_clean" -eq 1 ]]; then
echo "==> [$(ts)] Removing existing output app: $out_app"
rm -rf "$out_app"
fi
if [[ -e "$out_app" ]]; then
echo "Error: output already exists: $out_app (use --clean to overwrite)" >&2
exit 2
fi
echo "==> [$(ts)] Copying arm64 app to output"
/usr/bin/ditto "$arm_app" "$out_app"
# Remove any existing signatures in the copied app; we'll re-sign after creating universal binaries.
echo "==> [$(ts)] Removing existing code signature metadata (will be re-signed later)"
find "$out_app" -type d -name "_CodeSignature" -print0 2>/dev/null | while IFS= read -r -d '' d; do
rm -rf "$d" || true
done
echo "==> [$(ts)] Merging Mach-O files with lipo"
merged=0
skipped=0
# Traverse output app and lipo-merge any Mach-O file with its counterpart in the x86 app.
while IFS= read -r -d '' f; do
# Only operate on regular files.
if [[ ! -f "$f" ]]; then
continue
fi
ft="$(/usr/bin/file -b "$f" 2>/dev/null || true)"
if [[ "$ft" != *"Mach-O"* ]]; then
skipped=$((skipped + 1))
continue
fi
rel="${f#"$out_app"/}"
other="${x86_app}/${rel}"
if [[ ! -f "$other" ]]; then
echo "Error: missing matching file in x86 app for:" >&2
echo " $rel" >&2
echo "Expected at:" >&2
echo " $other" >&2
exit 1
fi
other_ft="$(/usr/bin/file -b "$other" 2>/dev/null || true)"
if [[ "$other_ft" != *"Mach-O"* ]]; then
echo "Error: file is Mach-O in arm app but not Mach-O in x86 app:" >&2
echo " $rel" >&2
echo "arm64: $ft" >&2
echo "x86_64: $other_ft" >&2
exit 1
fi
# Validate architectures.
arm_archs="$(/usr/bin/lipo -archs "$f" 2>/dev/null || true)"
x86_archs="$(/usr/bin/lipo -archs "$other" 2>/dev/null || true)"
if [[ "$arm_archs" != *"arm64"* ]]; then
echo "Error: expected arm64 slice in arm app file:" >&2
echo " $rel" >&2
echo " archs: $arm_archs" >&2
exit 1
fi
if [[ "$x86_archs" != *"x86_64"* ]]; then
echo "Error: expected x86_64 slice in x86 app file:" >&2
echo " $rel" >&2
echo " archs: $x86_archs" >&2
exit 1
fi
tmp="$(mktemp -t strawberry-universal.XXXXXX)"
/usr/bin/lipo -create "$f" "$other" -output "$tmp"
chmod --reference="$f" "$tmp" 2>/dev/null || true
mv -f "$tmp" "$f"
merged=$((merged + 1))
done < <(find "$out_app" -type f -print0 2>/dev/null)
echo "==> [$(ts)] Done"
echo "Merged Mach-O files: $merged"
echo "Non-Mach-O files skipped: $skipped"
echo "Output app:"
echo " $out_app"

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ts() { date +"%H:%M:%S"; }
usage() {
cat <<'EOF'
Usage:
./build_tools/macos/print_mas_build_cmd.sh [--bundle-id com.dryark.strawberry] [--profile <path>]
What it does:
- Tries to auto-pick a provisioning profile for the bundle id
- Prints an exact build command you can copy/paste for build_mas_pkg.sh
Notes:
- This helper intentionally does NOT try to auto-pick signing identities by parsing Apple tool output.
Use SHA-1 identities from:
./build_tools/macos/check_signing_identities.sh
EOF
}
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: This script is for macOS only." >&2
exit 1
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/../.." && pwd)"
bundle_id="com.dryark.strawberry"
profile_path=""
codesign_identity=""
installer_identity=""
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-id) bundle_id="${2:-}"; shift 2 ;;
--profile) profile_path="${2:-}"; shift 2 ;;
--codesign-identity) codesign_identity="${2:-}"; shift 2 ;;
--installer-identity) installer_identity="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ -z "$profile_path" ]]; then
# Attempt to auto-select profile using the finder script.
finder="${repo_root}/build_tools/macos/find_mas_provisioning_profile.sh"
if [[ -x "$finder" ]]; then
out="$("$finder" --bundle-id "$bundle_id" 2>/dev/null || true)"
# Parse the line after "Recommended profile:"
profile_path="$(printf '%s\n' "$out" | awk 'found{print $1; exit} /^Recommended profile:/{found=1} found && $0 ~ /^ \\// {print $1; exit}' | sed 's/^[[:space:]]*//')"
fi
fi
echo "==> [$(ts)] Recommended build command:"
echo
echo "./build_tools/macos/build_mas_pkg.sh --run --release --clean \\"
echo " --codesign-identity \"${codesign_identity:-<SHA1 from check_signing_identities.sh>}\" \\"
echo " --installer-identity \"${installer_identity:-<SHA1 from check_signing_identities.sh>}\" \\"
if [[ -n "$profile_path" ]]; then
echo " --provisionprofile \"${profile_path}\""
else
echo " --provisionprofile \"</path/to/profile.provisionprofile>\""
echo
echo "Note: could not auto-pick a provisioning profile for bundle id '${bundle_id}'."
echo "Run:"
echo " ./build_tools/macos/find_mas_provisioning_profile.sh --bundle-id ${bundle_id}"
fi

View File

@@ -1,104 +1,43 @@
# NOTE: Packaging helpers should not be REQUIRED at configure time. find_program(MACDEPLOYQT_EXECUTABLE NAMES macdeployqt PATHS /usr/bin /usr/local/bin /opt/local/bin /usr/local/opt/qt6/bin REQUIRED)
# 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)
if(MACDEPLOYQT_EXECUTABLE) if(MACDEPLOYQT_EXECUTABLE)
message(STATUS "Found macdeployqt: ${MACDEPLOYQT_EXECUTABLE}") message(STATUS "Found macdeployqt: ${MACDEPLOYQT_EXECUTABLE}")
else() else()
message(WARNING "Missing macdeployqt executable.") message(WARNING "Missing macdeployqt executable.")
endif() 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) if(MACDEPLOYCHECK_EXECUTABLE)
message(STATUS "Found macdeploycheck: ${MACDEPLOYCHECK_EXECUTABLE}") message(STATUS "Found macdeploycheck: ${MACDEPLOYCHECK_EXECUTABLE}")
else() else()
message(STATUS "macdeploycheck not found (optional): 'deploycheck' target will be unavailable.") message(WARNING "Missing macdeploycheck executable.")
endif() endif()
find_program(CREATEDMG_EXECUTABLE NAMES create-dmg) find_program(CREATEDMG_EXECUTABLE NAMES create-dmg REQUIRED)
if(CREATEDMG_EXECUTABLE) if(CREATEDMG_EXECUTABLE)
message(STATUS "Found create-dmg: ${CREATEDMG_EXECUTABLE}") message(STATUS "Found create-dmg: ${CREATEDMG_EXECUTABLE}")
else() else()
message(WARNING "Missing create-dmg executable.") message(WARNING "Missing create-dmg executable.")
endif() 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) if(MACDEPLOYQT_EXECUTABLE)
# Note: We intentionally do NOT codesign during the CMake 'deploy'/'dmg' targets. if(APPLE_DEVELOPER_ID)
# macdeployqt can optionally sign, but passing identities safely through Ninja's /bin/sh wrapper is brittle. set(MACDEPLOYQT_CODESIGN -codesign=${APPLE_DEVELOPER_ID})
# This repo's signing/notarization pipeline is handled in build_tools/macos/build_sign_notarize.sh instead. set(CREATEDMG_CODESIGN --codesign ${APPLE_DEVELOPER_ID})
endif()
if(CREATEDMG_SKIP_JENKINS) if(CREATEDMG_SKIP_JENKINS)
set(CREATEDMG_SKIP_JENKINS_ARG "--skip-jenkins") set(CREATEDMG_SKIP_JENKINS_ARG "--skip-jenkins")
endif() endif()
set(_deploy_commands add_custom_target(deploy
COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/Frameworks COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/{Frameworks,Resources}
COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/Resources
COMMAND cp -v ${CMAKE_BINARY_DIR}/dist/macos/Info.plist ${CMAKE_BINARY_DIR}/strawberry.app/Contents/ 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/ 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 ${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 COMMAND ${MACDEPLOYQT_EXECUTABLE} strawberry.app -verbose=3 -executable=${CMAKE_BINARY_DIR}/strawberry.app/Contents/PlugIns/gst-plugin-scanner ${MACDEPLOYQT_CODESIGN}
)
# 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)"
WORKING_DIRECTORY ${CMAKE_BINARY_DIR} WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
DEPENDS DEPENDS strawberry
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"
) )
add_custom_target(deploy DEPENDS "${_deploy_stamp}")
if(MACDEPLOYCHECK_EXECUTABLE) if(MACDEPLOYCHECK_EXECUTABLE)
add_custom_target(deploycheck add_custom_target(deploycheck
COMMAND ${MACDEPLOYCHECK_EXECUTABLE} strawberry.app COMMAND ${MACDEPLOYCHECK_EXECUTABLE} strawberry.app
@@ -106,9 +45,8 @@ if(MACDEPLOYQT_EXECUTABLE)
endif() endif()
if(CREATEDMG_EXECUTABLE) if(CREATEDMG_EXECUTABLE)
add_custom_target(dmg 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} WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
DEPENDS deploy
) )
endif() endif()
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_willbuild "")
set(summary_willnotbuild "") 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) macro(optional_component_summary_add name test)
if (${test}) if (${test})
list(APPEND summary_willbuild ${name}) list(APPEND summary_willbuild ${name})
@@ -97,13 +80,8 @@ function(optional_component name default description)
set(text "${description} (missing ${deplist_text})") set(text "${description} (missing ${deplist_text})")
set(summary_willnotbuild "${summary_willnotbuild};${text}" PARENT_SCOPE) 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") 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() else()
set(${have_variable} ON PARENT_SCOPE) set(${have_variable} ON PARENT_SCOPE)

View File

@@ -1,6 +1,6 @@
set(STRAWBERRY_VERSION_MAJOR 1) set(STRAWBERRY_VERSION_MAJOR 1)
set(STRAWBERRY_VERSION_MINOR 2) set(STRAWBERRY_VERSION_MINOR 2)
set(STRAWBERRY_VERSION_PATCH 17) set(STRAWBERRY_VERSION_PATCH 16)
#set(STRAWBERRY_VERSION_PRERELEASE rc1) #set(STRAWBERRY_VERSION_PRERELEASE rc1)
set(INCLUDE_GIT_REVISION ON) 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" "$@"

View File

@@ -98,6 +98,7 @@
<file>icons/128x128/somafm.png</file> <file>icons/128x128/somafm.png</file>
<file>icons/128x128/radioparadise.png</file> <file>icons/128x128/radioparadise.png</file>
<file>icons/128x128/musicbrainz.png</file> <file>icons/128x128/musicbrainz.png</file>
<file>icons/128x128/dropbox.png</file>
<file>icons/64x64/albums.png</file> <file>icons/64x64/albums.png</file>
<file>icons/64x64/alsa.png</file> <file>icons/64x64/alsa.png</file>
<file>icons/64x64/application-exit.png</file> <file>icons/64x64/application-exit.png</file>
@@ -197,6 +198,7 @@
<file>icons/64x64/somafm.png</file> <file>icons/64x64/somafm.png</file>
<file>icons/64x64/radioparadise.png</file> <file>icons/64x64/radioparadise.png</file>
<file>icons/64x64/musicbrainz.png</file> <file>icons/64x64/musicbrainz.png</file>
<file>icons/64x64/dropbox.png</file>
<file>icons/48x48/albums.png</file> <file>icons/48x48/albums.png</file>
<file>icons/48x48/alsa.png</file> <file>icons/48x48/alsa.png</file>
<file>icons/48x48/application-exit.png</file> <file>icons/48x48/application-exit.png</file>
@@ -300,6 +302,7 @@
<file>icons/48x48/somafm.png</file> <file>icons/48x48/somafm.png</file>
<file>icons/48x48/radioparadise.png</file> <file>icons/48x48/radioparadise.png</file>
<file>icons/48x48/musicbrainz.png</file> <file>icons/48x48/musicbrainz.png</file>
<file>icons/48x48/dropbox.png</file>
<file>icons/32x32/albums.png</file> <file>icons/32x32/albums.png</file>
<file>icons/32x32/alsa.png</file> <file>icons/32x32/alsa.png</file>
<file>icons/32x32/application-exit.png</file> <file>icons/32x32/application-exit.png</file>
@@ -403,6 +406,7 @@
<file>icons/32x32/somafm.png</file> <file>icons/32x32/somafm.png</file>
<file>icons/32x32/radioparadise.png</file> <file>icons/32x32/radioparadise.png</file>
<file>icons/32x32/musicbrainz.png</file> <file>icons/32x32/musicbrainz.png</file>
<file>icons/32x32/dropbox.png</file>
<file>icons/22x22/albums.png</file> <file>icons/22x22/albums.png</file>
<file>icons/22x22/alsa.png</file> <file>icons/22x22/alsa.png</file>
<file>icons/22x22/application-exit.png</file> <file>icons/22x22/application-exit.png</file>
@@ -506,5 +510,6 @@
<file>icons/22x22/somafm.png</file> <file>icons/22x22/somafm.png</file>
<file>icons/22x22/radioparadise.png</file> <file>icons/22x22/radioparadise.png</file>
<file>icons/22x22/musicbrainz.png</file> <file>icons/22x22/musicbrainz.png</file>
<file>icons/22x22/dropbox.png</file>
</qresource> </qresource>
</RCC> </RCC>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
data/icons/full/dropbox.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

82
data/schema/schema-22.sql Normal file
View File

@@ -0,0 +1,82 @@
CREATE TABLE IF NOT EXISTS dropbox_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
UPDATE schema_version SET version=22;

View File

@@ -1018,6 +1018,87 @@ CREATE TABLE IF NOT EXISTS qobuz_songs (
); );
CREATE TABLE IF NOT EXISTS dropbox_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE TABLE IF NOT EXISTS playlists ( CREATE TABLE IF NOT EXISTS playlists (
name TEXT NOT NULL, name TEXT NOT NULL,

20
dist/CMakeLists.txt vendored
View File

@@ -9,27 +9,7 @@ if(APPLE)
else() else()
set(LSMinimumSystemVersion 12.0) set(LSMinimumSystemVersion 12.0)
endif() 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) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist.in ${CMAKE_CURRENT_BINARY_DIR}/macos/Info.plist)
endif()
endif() endif()
if(WIN32) if(WIN32)

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> <key>CFBundleIconFile</key>
<string>strawberry.icns</string> <string>strawberry.icns</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>@MACOS_BUNDLE_ID@</string> <string>org.strawberrymusicplayer.strawberry</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleLongVersionString</key> <key>CFBundleLongVersionString</key>
@@ -34,24 +34,17 @@
<string>public.app-category.music</string> <string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>@LSMinimumSystemVersion@</string> <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> <key>SUFeedURL</key>
<string>@SPARKLE_FEED_URL@</string> <string>https://www.strawberrymusicplayer.org/sparkle-macos-@ARCH@</string>
<key>SUPublicEDKey</key> <key>SUPublicEDKey</key>
<string>@SPARKLE_PUBLIC_ED25519_KEY@</string> <string>/OydhYVfypuO2Mf7G6DUqVZWW9G19eFV74qaDCBTOUk=</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleTypeRole</key> <key>CFBundleTypeRole</key>
<string>Viewer</string> <string>Viewer</string>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>@MACOS_BUNDLE_ID@</string> <string>org.strawberrymusicplayer.strawberry</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>tidal</string> <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,73 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# macdeploycheck: sanity check a deployed macOS .app bundle for accidental runtime deps
# on Homebrew/MacPorts paths (which break distribution / App Store / notarization).
#
# Usage:
# macdeploycheck /path/to/App.app
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/MacPorts) runtime deps..."
while IFS= read -r f; do
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
# 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.
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

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> </screenshots>
<update_contact>eclipseo@fedoraproject.org</update_contact> <update_contact>eclipseo@fedoraproject.org</update_contact>
<releases> <releases>
<release version="1.2.17" date="2026-01-18"/>
<release version="1.2.16" date="2025-12-16"/> <release version="1.2.16" date="2025-12-16"/>
<release version="1.2.15" date="2025-11-25"/> <release version="1.2.15" date="2025-11-25"/>
<release version="1.2.14" date="2025-10-25"/> <release version="1.2.14" date="2025-10-25"/>

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player * Strawberry Music Player
* This file was part of Clementine. * This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com> * Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-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 * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -537,6 +537,18 @@ void CollectionBackend::AddOrUpdateSubdirs(const CollectionSubdirectoryList &sub
ScopedTransaction transaction(&db); ScopedTransaction transaction(&db);
for (const CollectionSubdirectory &subdir : subdirs) { 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 // See if this subdirectory already exists in the database
bool exists = false; 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(); transaction.Commit();

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player * Strawberry Music Player
* This file was part of Clementine. * This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com> * Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-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 * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -252,7 +252,6 @@ class CollectionBackend : public CollectionBackendInterface {
void DeleteSongsByUrls(const QList<QUrl> &url); void DeleteSongsByUrls(const QList<QUrl> &url);
void MarkSongsUnavailable(const SongList &songs, const bool unavailable = true); void MarkSongsUnavailable(const SongList &songs, const bool unavailable = true);
void AddOrUpdateSubdirs(const CollectionSubdirectoryList &subdirs); void AddOrUpdateSubdirs(const CollectionSubdirectoryList &subdirs);
void DeleteSubdirs(const CollectionSubdirectoryList &subdirs);
void CompilationsNeedUpdating(); void CompilationsNeedUpdating();
void UpdateEmbeddedAlbumArt(const QString &effective_albumartist, const QString &album, const bool art_embedded); 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); 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::SongsReadded, &*backend_, &CollectionBackend::MarkSongsUnavailable);
QObject::connect(watcher_, &CollectionWatcher::SubdirsDiscovered, &*backend_, &CollectionBackend::AddOrUpdateSubdirs); QObject::connect(watcher_, &CollectionWatcher::SubdirsDiscovered, &*backend_, &CollectionBackend::AddOrUpdateSubdirs);
QObject::connect(watcher_, &CollectionWatcher::SubdirsMTimeUpdated, &*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::CompilationsNeedUpdating, &*backend_, &CollectionBackend::CompilationsNeedUpdating);
QObject::connect(watcher_, &CollectionWatcher::UpdateLastSeen, &*backend_, &CollectionBackend::UpdateLastSeen); QObject::connect(watcher_, &CollectionWatcher::UpdateLastSeen, &*backend_, &CollectionBackend::UpdateLastSeen);

View File

@@ -41,9 +41,12 @@ bool CollectionPlaylistItem::InitFromQuery(const SqlRow &query) {
case Song::Source::Collection: case Song::Source::Collection:
col = 0; col = 0;
break; break;
default: case Song::Source::Dropbox:
col = static_cast<int>(Song::kRowIdColumns.count()); col = static_cast<int>(Song::kRowIdColumns.count());
break; break;
default:
col = static_cast<int>(Song::kRowIdColumns.count() * 2);
break;
} }
song_.InitFromQuery(query, true, col); song_.InitFromQuery(query, true, col);

View File

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

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player * Strawberry Music Player
* This file was part of Clementine. * This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com> * Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-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 * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -75,7 +75,7 @@
using namespace std::chrono_literals; using namespace std::chrono_literals;
using namespace Qt::Literals::StringLiterals; 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, CollectionWatcher::CollectionWatcher(const Song::Source source,
const SharedPtr<TaskManager> task_manager, 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) CollectionWatcher::ScanTransaction::ScanTransaction(CollectionWatcher *watcher, const int dir, const bool incremental, const bool ignores_mtime, const bool mark_songs_unavailable)
: progress_(0), : progress_(0),
progress_max_(0), progress_max_(0),
dir_id_(dir), dir_(dir),
incremental_(incremental), incremental_(incremental),
ignores_mtime_(ignores_mtime), ignores_mtime_(ignores_mtime),
mark_songs_unavailable_(mark_songs_unavailable), mark_songs_unavailable_(mark_songs_unavailable),
@@ -313,19 +313,6 @@ void CollectionWatcher::ScanTransaction::AddToProgressMax(const quint64 n) {
void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() { 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 (!deleted_songs.isEmpty()) {
if (mark_songs_unavailable_ && watcher_->source() == Song::Source::Collection) { if (mark_songs_unavailable_ && watcher_->source() == Song::Source::Collection) {
Q_EMIT watcher_->SongsUnavailable(deleted_songs); Q_EMIT watcher_->SongsUnavailable(deleted_songs);
@@ -351,24 +338,34 @@ void CollectionWatcher::ScanTransaction::CommitNewOrUpdatedSongs() {
readded_songs.clear(); 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)) { for (const CollectionSubdirectory &subdir : std::as_const(deleted_subdirs)) {
if (watcher_->watched_dirs_.contains(dir_id_)) { if (watcher_->watched_dirs_.contains(dir_)) {
watcher_->RemoveWatch(watcher_->watched_dirs_[dir_id_], subdir); watcher_->RemoveWatch(watcher_->watched_dirs_[dir_], subdir);
} }
} }
deleted_subdirs.clear(); deleted_subdirs.clear();
if (watcher_->monitor_) { if (watcher_->monitor_) {
// Watch the new subdirectories
for (const CollectionSubdirectory &subdir : std::as_const(new_subdirs)) { for (const CollectionSubdirectory &subdir : std::as_const(new_subdirs)) {
if (watcher_->watched_dirs_.contains(dir_id_)) { if (watcher_->watched_dirs_.contains(dir_)) {
watcher_->AddWatch(watcher_->watched_dirs_[dir_id_], subdir.path); watcher_->AddWatch(watcher_->watched_dirs_[dir_], subdir.path);
} }
} }
} }
new_subdirs.clear(); new_subdirs.clear();
if (incremental_ || ignores_mtime_) { 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) { SongList CollectionWatcher::ScanTransaction::FindSongsInSubdirectory(const QString &path) {
if (cached_songs_dirty_) { if (cached_songs_dirty_) {
const SongList songs = watcher_->backend_->FindSongsInDirectory(dir_id_); const SongList songs = watcher_->backend_->FindSongsInDirectory(dir_);
for (const Song &song : songs) { for (const Song &song : songs) {
const QString p = song.url().toLocalFile().section(u'/', 0, -2); const QString p = song.url().toLocalFile().section(u'/', 0, -2);
cached_songs_.insert(p, song); cached_songs_.insert(p, song);
@@ -396,7 +393,7 @@ SongList CollectionWatcher::ScanTransaction::FindSongsInSubdirectory(const QStri
bool CollectionWatcher::ScanTransaction::HasSongsWithMissingFingerprint(const QString &path) { bool CollectionWatcher::ScanTransaction::HasSongsWithMissingFingerprint(const QString &path) {
if (cached_songs_missing_fingerprint_dirty_) { 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) { for (const Song &song : songs) {
const QString p = song.url().toLocalFile().section(u'/', 0, -2); const QString p = song.url().toLocalFile().section(u'/', 0, -2);
cached_songs_missing_fingerprint_.insert(p, song); cached_songs_missing_fingerprint_.insert(p, song);
@@ -411,7 +408,7 @@ bool CollectionWatcher::ScanTransaction::HasSongsWithMissingFingerprint(const QS
bool CollectionWatcher::ScanTransaction::HasSongsWithMissingLoudnessCharacteristics(const QString &path) { bool CollectionWatcher::ScanTransaction::HasSongsWithMissingLoudnessCharacteristics(const QString &path) {
if (cached_songs_missing_loudness_characteristics_dirty_) { 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) { for (const Song &song : songs) {
const QString p = song.url().toLocalFile().section(u'/', 0, -2); const QString p = song.url().toLocalFile().section(u'/', 0, -2);
cached_songs_missing_loudness_characteristics_.insert(p, song); 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) { bool CollectionWatcher::ScanTransaction::HasSeenSubdir(const QString &path) {
if (known_subdirs_dirty_) { 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; }); 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) { CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetImmediateSubdirs(const QString &path) {
if (known_subdirs_dirty_) { if (known_subdirs_dirty_) {
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_id_)); SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_));
} }
CollectionSubdirectoryList ret; CollectionSubdirectoryList ret;
@@ -460,7 +457,7 @@ CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetImmediateSubdi
CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetAllSubdirs() { CollectionSubdirectoryList CollectionWatcher::ScanTransaction::GetAllSubdirs() {
if (known_subdirs_dirty_) { if (known_subdirs_dirty_) {
SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_id_)); SetKnownSubdirs(watcher_->backend_->SubdirsInDirectory(dir_));
} }
return known_subdirs_; return known_subdirs_;
@@ -497,7 +494,7 @@ void CollectionWatcher::AddDirectory(const CollectionDirectory &dir, const Colle
const quint64 files_count = FilesCountForPath(&transaction, dir.path); const quint64 files_count = FilesCountForPath(&transaction, dir.path);
transaction.SetKnownSubdirs(subdirs); transaction.SetKnownSubdirs(subdirs);
transaction.AddToProgressMax(files_count); transaction.AddToProgressMax(files_count);
ScanSubdirectory(dir, dir.path, CollectionSubdirectory(), files_count, &transaction); ScanSubdirectory(dir.path, CollectionSubdirectory(), files_count, &transaction);
last_scan_time_ = QDateTime::currentSecsSinceEpoch(); last_scan_time_ = QDateTime::currentSecsSinceEpoch();
} }
else { else {
@@ -515,7 +512,7 @@ void CollectionWatcher::AddDirectory(const CollectionDirectory &dir, const Colle
transaction.AddToProgressMax(files_count); transaction.AddToProgressMax(files_count);
for (const CollectionSubdirectory &subdir : subdirs) { for (const CollectionSubdirectory &subdir : subdirs) {
if (stop_or_abort_requested()) break; 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()) { if (!stop_or_abort_requested()) {
last_scan_time_ = QDateTime::currentSecsSinceEpoch(); 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 QFileInfo path_info(path);
const qint64 path_mtime = path_info.exists() && path_info.lastModified().isValid() ? path_info.lastModified().toSecsSinceEpoch() : 0;
if (path_info.isSymLink()) { if (path_info.isSymLink()) {
const QString real_path = path_info.symLinkTarget(); const QString real_path = path_info.symLinkTarget();
@@ -540,8 +536,8 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
return; return;
} }
// Do not scan symlinked dirs that are already in collection // Do not scan symlinked dirs that are already in collection
for (const CollectionDirectory &i : std::as_const(watched_dirs_)) { for (const CollectionDirectory &dir : std::as_const(watched_dirs_)) {
if (real_path.startsWith(i.path)) { if (real_path.startsWith(dir.path)) {
return; return;
} }
} }
@@ -567,7 +563,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
} }
#endif #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 // The directory hasn't changed since last time
t->AddToProgress(files_count); t->AddToProgress(files_count);
return; return;
@@ -582,12 +578,11 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const CollectionSubdirectoryList previous_subdirs = t->GetImmediateSubdirs(path); const CollectionSubdirectoryList previous_subdirs = t->GetImmediateSubdirs(path);
for (const CollectionSubdirectory &prev_subdir : previous_subdirs) { for (const CollectionSubdirectory &prev_subdir : previous_subdirs) {
if (!QFile::exists(prev_subdir.path) && prev_subdir.path != path) { 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. // 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); QDirIterator it(path, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot);
while (it.hasNext()) { while (it.hasNext()) {
@@ -597,7 +592,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const QFileInfo child_fileinfo(child_filepath); const QFileInfo child_fileinfo(child_filepath);
if (child_fileinfo.isSymLink()) { if (child_fileinfo.isSymLink()) {
const QStorageInfo storage_info(child_fileinfo.symLinkTarget()); QStorageInfo storage_info(child_fileinfo.symLinkTarget());
if (kRejectedFileSystems.contains(storage_info.fileSystemType())) { 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(); qLog(Warning) << "Ignoring symbolic link" << child_filepath << "which links to" << child_fileinfo.symLinkTarget() << "with rejected filesystem type" << storage_info.fileSystemType();
continue; continue;
@@ -610,14 +605,14 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
CollectionSubdirectory new_subdir; CollectionSubdirectory new_subdir;
new_subdir.directory_id = -1; new_subdir.directory_id = -1;
new_subdir.path = child_filepath; 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; my_new_subdirs << new_subdir;
} }
t->AddToProgress(1); t->AddToProgress(1);
} }
else { else {
const QString ext_part = ExtensionPart(child_filepath); QString ext_part(ExtensionPart(child_filepath));
const QString dir_part = DirectoryPart(child_filepath); QString dir_part(DirectoryPart(child_filepath));
if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) { if (Song::kRejectedExtensions.contains(child_fileinfo.suffix(), Qt::CaseInsensitive) || child_fileinfo.baseName() == "qt_temp"_L1) {
t->AddToProgress(1); t->AddToProgress(1);
} }
@@ -625,9 +620,11 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
album_art[dir_part] << child_filepath; album_art[dir_part] << child_filepath;
t->AddToProgress(1); t->AddToProgress(1);
} }
else { else if (tagreader_client_->IsMediaFileBlocking(child_filepath)) {
files_on_disk << 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; if (stop_or_abort_requested()) return;
// Ask the database for a list of files in this directory // 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; QSet<QString> cues_processed;
// Now compare the list from the database with the list of files on disk // 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) { for (const QString &file : files_on_disk_copy) {
if (stop_or_abort_requested()) return; if (stop_or_abort_requested()) return;
// Associated CUE // Associated CUE
const QString new_cue = CueParser::FindCueFilename(file); QString new_cue = CueParser::FindCueFilename(file);
SongList matching_songs; SongList matching_songs;
if (FindSongsByPath(songs_in_db, file, &matching_songs)) { // Found matching song in DB by path. 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. // The song is in the database and still on disk.
// Check the mtime to see if it's been changed since it was added. // Check the mtime to see if it's been changed since it was added.
const QFileInfo fileinfo(file); QFileInfo fileinfo(file);
if (!fileinfo.exists()) { if (!fileinfo.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now. // 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 #endif
if (new_cue.isEmpty() || new_cue_mtime == 0) { // If no CUE or it's about to lose it. 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)) { UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, art_automatic, cue_deleted, t);
files_on_disk.removeAll(file);
}
} }
else { // If CUE associated. else { // If CUE associated.
UpdateCueAssociatedSongs(file, path, fingerprint, new_cue, art_automatic, matching_songs, t); 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. // The song is in the database and still on disk.
// Check the mtime to see if it's been changed since it was added. // Check the mtime to see if it's been changed since it was added.
const QFileInfo fileinfo(file); QFileInfo fileinfo(file);
if (!fileinfo.exists()) { if (!fileinfo.exists()) {
// Partially fixes race condition - if file was removed between being added to the list and now. // Partially fixes race condition - if file was removed between being added to the list and now.
files_on_disk.removeAll(file); 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. // Make sure the songs aren't deleted, as they still exist elsewhere with a different file path.
bool matching_songs_has_cue = false; bool matching_songs_has_cue = false;
for (const Song &matching_song : std::as_const(matching_songs)) { 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)) { if (!t->files_changed_path_.contains(matching_filename)) {
t->files_changed_path_ << matching_filename; t->files_changed_path_ << matching_filename;
qLog(Debug) << matching_filename << "has changed path to" << file; 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); 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 (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)) { UpdateNonCueAssociatedSong(file, fingerprint, matching_songs, art_automatic, matching_songs_has_cue && new_cue_mtime == 0, t);
files_on_disk.removeAll(file);
}
} }
else { // If CUE associated. else { // If CUE associated.
UpdateCueAssociatedSongs(file, path, fingerprint, new_cue, art_automatic, matching_songs, t); 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); const SongList songs = ScanNewFile(file, path, fingerprint, new_cue, &cues_processed);
if (songs.isEmpty()) { if (songs.isEmpty()) {
files_on_disk.removeAll(file);
t->AddToProgress(1); t->AddToProgress(1);
continue; continue;
} }
@@ -813,7 +805,7 @@ void CollectionWatcher::ScanSubdirectory(const CollectionDirectory &dir, const Q
const QUrl art_automatic = ArtForSong(file, album_art); const QUrl art_automatic = ArtForSong(file, album_art);
for (Song song : songs) { 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); if (song.art_automatic().isEmpty()) song.set_art_automatic(art_automatic);
t->new_songs << song; 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; CollectionSubdirectory updated_subdir;
updated_subdir.directory_id = t->dir_id(); updated_subdir.directory_id = t->dir();
updated_subdir.mtime = path_mtime; updated_subdir.mtime = path_info.exists() ? path_info.lastModified().toSecsSinceEpoch() : 0;
updated_subdir.path = path; updated_subdir.path = path;
if (!path_info.exists() && updated_subdir.path != dir.path) { if (subdir.directory_id == -1) {
t->deleted_subdirs << updated_subdir;
}
else if (subdir.directory_id == -1) {
t->new_subdirs << updated_subdir; t->new_subdirs << updated_subdir;
} }
else if (subdir.mtime != updated_subdir.mtime) { else {
t->touched_subdirs << updated_subdir; 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 // Recurse into the new subdirs that we found
for (const CollectionSubdirectory &my_new_subdir : std::as_const(my_new_subdirs)) { for (const CollectionSubdirectory &my_new_subdir : std::as_const(my_new_subdirs)) {
if (stop_or_abort_requested()) return; 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; QSet<int> used_ids;
for (Song new_cue_song : songs) { for (Song new_cue_song : songs) {
new_cue_song.set_source(source_); 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); PerformEBUR128Analysis(new_cue_song);
new_cue_song.set_fingerprint(fingerprint); 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 QString &fingerprint,
const SongList &matching_songs, const SongList &matching_songs,
const QUrl &art_automatic, const QUrl &art_automatic,
@@ -929,7 +922,7 @@ bool CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file,
const TagReaderResult result = tagreader_client_->ReadFileBlocking(file, &song_on_disk); const TagReaderResult result = tagreader_client_->ReadFileBlocking(file, &song_on_disk);
if (result.success() && song_on_disk.is_valid()) { if (result.success() && song_on_disk.is_valid()) {
song_on_disk.set_source(source_); 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()); song_on_disk.set_id(matching_song.id());
PerformEBUR128Analysis(song_on_disk); PerformEBUR128Analysis(song_on_disk);
song_on_disk.set_fingerprint(fingerprint); song_on_disk.set_fingerprint(fingerprint);
@@ -938,8 +931,6 @@ bool CollectionWatcher::UpdateNonCueAssociatedSong(const QString &file,
AddChangedSong(file, matching_song, song_on_disk, t); 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 { 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() { void CollectionWatcher::RescanPathsNow() {
const QList<int> dir_ids = rescan_queue_.keys(); const QList<int> dirs = rescan_queue_.keys();
for (const int dir_id : dir_ids) { for (const int dir : dirs) {
if (stop_or_abort_requested()) break; 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; QMap<QString, quint64> subdir_files_count;
for (const QString &path : paths) { for (const QString &path : paths) {
@@ -1225,14 +1215,11 @@ void CollectionWatcher::RescanPathsNow() {
for (const QString &path : paths) { for (const QString &path : paths) {
if (stop_or_abort_requested()) break; if (stop_or_abort_requested()) break;
if (!subdir_mapping_.contains(path)) {
continue;
}
CollectionSubdirectory subdir; CollectionSubdirectory subdir;
subdir.directory_id = dir_id; subdir.directory_id = dir;
subdir.mtime = 0; subdir.mtime = 0;
subdir.path = path; 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_); ScanTransaction transaction(this, dir.id, incremental, ignore_mtimes, mark_songs_unavailable_);
CollectionSubdirectoryList subdirs = transaction.GetAllSubdirs(); 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 (subdirs.isEmpty()) {
if (!has_collection_root_dir) { qLog(Debug) << "Collection directory wasn't in subdir list.";
qLog(Debug) << "Collection directory wasn't in subdir list, re-adding";
CollectionSubdirectory subdir; CollectionSubdirectory subdir;
subdir.directory_id = dir.id;
subdir.path = dir.path; subdir.path = dir.path;
subdir.mtime = 0; subdir.directory_id = dir.id;
subdirs << subdir; subdirs << subdir;
} }
@@ -1373,7 +1358,7 @@ void CollectionWatcher::PerformScan(const bool incremental, const bool ignore_mt
for (const CollectionSubdirectory &subdir : std::as_const(subdirs)) { for (const CollectionSubdirectory &subdir : std::as_const(subdirs)) {
if (stop_or_abort_requested()) break; 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; QStringList scanned_paths;
for (const Song &song : songs) { for (const Song &song : songs) {
if (stop_or_abort_requested()) break; 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); const QString song_path = song.url().toLocalFile().section(u'/', 0, -2);
if (scanned_paths.contains(song_path)) continue; if (scanned_paths.contains(song_path)) continue;
ScanTransaction transaction(this, song.directory_id(), false, true, mark_songs_unavailable_); 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; if (subdir.path != song_path) continue;
qLog(Debug) << "Rescan for directory ID" << song.directory_id() << "directory" << subdir.path; qLog(Debug) << "Rescan for directory ID" << song.directory_id() << "directory" << subdir.path;
const quint64 files_count = FilesCountForPath(&transaction, 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; scanned_paths << subdir.path;
} }
} }

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player * Strawberry Music Player
* This file was part of Clementine. * This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com> * Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-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 * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -85,7 +85,6 @@ class CollectionWatcher : public QObject {
void SongsReadded(const SongList &songs, const bool unavailable = false); void SongsReadded(const SongList &songs, const bool unavailable = false);
void SubdirsDiscovered(const CollectionSubdirectoryList &subdirs); void SubdirsDiscovered(const CollectionSubdirectoryList &subdirs);
void SubdirsMTimeUpdated(const CollectionSubdirectoryList &subdirs); void SubdirsMTimeUpdated(const CollectionSubdirectoryList &subdirs);
void SubdirsDeleted(const CollectionSubdirectoryList &subdirs);
void CompilationsNeedUpdating(); void CompilationsNeedUpdating();
void UpdateLastSeen(const int directory_id, const int expire_unavailable_songs_days); void UpdateLastSeen(const int directory_id, const int expire_unavailable_songs_days);
void ExitFinished(); 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. // Emits the signals for new & deleted songs etc and clears the lists. This causes the new stuff to be updated on UI.
void CommitNewOrUpdatedSongs(); void CommitNewOrUpdatedSongs();
int dir_id() const { return dir_id_; } int dir() const { return dir_; }
bool is_incremental() const { return incremental_; } bool is_incremental() const { return incremental_; }
bool ignores_mtime() const { return ignores_mtime_; } bool ignores_mtime() const { return ignores_mtime_; }
@@ -144,7 +143,7 @@ class CollectionWatcher : public QObject {
quint64 progress_; quint64 progress_;
quint64 progress_max_; quint64 progress_max_;
int dir_id_; int dir_;
// Incremental scan enters a directory only if it has changed since the last scan. // Incremental scan enters a directory only if it has changed since the last scan.
bool incremental_; bool incremental_;
// This type of scan updates every file in a folder that's being scanned. // 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 IncrementalScanNow();
void FullScanNow(); void FullScanNow();
void RescanPathsNow(); 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); void RescanSongs(const SongList &songs);
private: 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. // 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; 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. // 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. // 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). // 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; 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 USE_INSTALL_PREFIX
#cmakedefine BUILD_FOR_MAC_APP_STORE
#cmakedefine HAVE_BACKTRACE #cmakedefine HAVE_BACKTRACE
#cmakedefine HAVE_ALSA #cmakedefine HAVE_ALSA
#cmakedefine HAVE_PULSE #cmakedefine HAVE_PULSE
@@ -35,6 +33,8 @@
#cmakedefine HAVE_SPOTIFY #cmakedefine HAVE_SPOTIFY
#cmakedefine HAVE_QOBUZ #cmakedefine HAVE_QOBUZ
#cmakedefine HAVE_DISCORD_RPC #cmakedefine HAVE_DISCORD_RPC
#cmakedefine HAVE_DROPBOX
#cmakedefine HAVE_ONEDRIVE
#cmakedefine HAVE_TAGLIB_DSFFILE #cmakedefine HAVE_TAGLIB_DSFFILE
#cmakedefine HAVE_TAGLIB_DSDIFFFILE #cmakedefine HAVE_TAGLIB_DSDIFFFILE

View File

@@ -0,0 +1,30 @@
/*
* 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 DROPBOXCONSTANTS_H
#define DROPBOXCONSTANTS_H
namespace DropboxConstants {
constexpr char kApiUrl[] = "https://api.dropboxapi.com";
constexpr char kNotifyApiUrl[] = "https://notify.dropboxapi.com";
} // namespace
#endif // DROPBOXCONSTANTS_H

View File

@@ -0,0 +1,46 @@
/*
* 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 DROPBOXSETTINGS_H
#define DROPBOXSETTINGS_H
namespace DropboxSettings {
constexpr char kSettingsGroup[] = "Dropbox";
constexpr char kEnabled[] = "enabled";
constexpr char kSearchDelay[] = "searchdelay";
constexpr char kArtistsSearchLimit[] = "artistssearchlimit";
constexpr char kAlbumsSearchLimit[] = "albumssearchlimit";
constexpr char kSongsSearchLimit[] = "songssearchlimit";
constexpr char kFetchAlbums[] = "fetchalbums";
constexpr char kDownloadAlbumCovers[] = "downloadalbumcovers";
constexpr char kTokenType[] = "token_type";
constexpr char kAccessToken[] = "access_token";
constexpr char kRefreshToken[] = "refresh_token";
constexpr char kExpiresIn[] = "expires_in";
constexpr char kLoginTime[] = "login_time";
constexpr char kApiUrl[] = "https://api.dropboxapi.com";
constexpr char kNotifyApiUrl[] = "https://notify.dropboxapi.com";
} // namespace
#endif // DROPBOXSETTINGS_H

View File

@@ -33,7 +33,7 @@ constexpr char kFileFilter[] =
"*.mod *.s3m *.xm *.it " "*.mod *.s3m *.xm *.it "
"*.spc *.vgm"; "*.spc *.vgm";
constexpr char kLoadImageFileFilter[] = QT_TRANSLATE_NOOP("FileFilter", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.xpm *.pbm *.pgm *.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 *.webp)"); constexpr char kSaveImageFileFilter[] = QT_TRANSLATE_NOOP("FileFilter", "Images (*.png *.jpg *.jpeg *.bmp *.xpm *.pbm *.ppm *.xbm)");
#endif // FILEFILTERCONSTANTS_H #endif // FILEFILTERCONSTANTS_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_ = new QAction(tr("Automatically search for song lyrics"), this);
action_search_lyrics_->setCheckable(true); 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_album_);
menu_options_->addAction(action_show_data_); 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_album_->setChecked(s.value(ContextSettings::kAlbum, true).toBool());
action_show_data_->setChecked(s.value(ContextSettings::kTechnicalData, false).toBool()); action_show_data_->setChecked(s.value(ContextSettings::kTechnicalData, false).toBool());
action_show_lyrics_->setChecked(s.value(ContextSettings::kSongLyrics, true).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_.setFamily(s.value(ContextSettings::kFontHeadline, default_font).toString());
font_headline_.setPointSizeF(s.value(ContextSettings::kFontSizeHeadline, ContextSettings::kDefaultFontSizeHeadline).toReal()); font_headline_.setPointSizeF(s.value(ContextSettings::kFontSizeHeadline, ContextSettings::kDefaultFontSizeHeadline).toReal());
font_nosong_.setFamily(font_headline_.family()); font_nosong_.setFamily(font_headline_.family());

View File

@@ -105,6 +105,10 @@
# include "covermanager/qobuzcoverprovider.h" # include "covermanager/qobuzcoverprovider.h"
#endif #endif
#ifdef HAVE_DROPBOX
# include "dropbox/dropboxservice.h"
#endif
#ifdef HAVE_MOODBAR #ifdef HAVE_MOODBAR
# include "moodbar/moodbarcontroller.h" # include "moodbar/moodbarcontroller.h"
# include "moodbar/moodbarloader.h" # include "moodbar/moodbarloader.h"
@@ -200,6 +204,9 @@ class ApplicationImpl {
#endif #endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
streaming_services->AddService(make_shared<QobuzService>(app->task_manager(), app->database(), app->network(), app->url_handlers(), app->albumcover_loader())); streaming_services->AddService(make_shared<QobuzService>(app->task_manager(), app->database(), app->network(), app->url_handlers(), app->albumcover_loader()));
#endif
#ifdef HAVE_DROPBOX
streaming_services->AddService(make_shared<DropboxService>(app->task_manager(), app->database(), app->network(), app->url_handlers(), app->tagreader_client(), app->albumcover_loader()));
#endif #endif
return streaming_services; return streaming_services;
}), }),

View File

@@ -52,12 +52,6 @@ LocalRedirectServer::~LocalRedirectServer() {
bool LocalRedirectServer::Listen() { 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_))) { if (!listen(QHostAddress::LocalHost, static_cast<quint64>(port_))) {
success_ = false; success_ = false;
error_ = errorString(); error_ = errorString();

View File

@@ -173,14 +173,14 @@
#endif #endif
#ifdef HAVE_SPOTIFY #ifdef HAVE_SPOTIFY
# include "spotify/spotifyservice.h" # include "spotify/spotifyservice.h"
# include "spotify/spotifymetadatarequest.h"
# include "constants/spotifysettings.h" # include "constants/spotifysettings.h"
#endif #endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
# include "qobuz/qobuzservice.h"
# include "qobuz/qobuzmetadatarequest.h"
# include "constants/qobuzsettings.h" # include "constants/qobuzsettings.h"
#endif #endif
#ifdef HAVE_DROPBOX
# include "constants/dropboxsettings.h"
#endif
#include "streaming/streamingservices.h" #include "streaming/streamingservices.h"
#include "streaming/streamingservice.h" #include "streaming/streamingservice.h"
@@ -358,6 +358,9 @@ MainWindow::MainWindow(Application *app,
#endif #endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
qobuz_view_(new StreamingTabsView(app->streaming_services()->ServiceBySource(Song::Source::Qobuz), app->albumcover_loader(), QLatin1String(QobuzSettings::kSettingsGroup), this)), qobuz_view_(new StreamingTabsView(app->streaming_services()->ServiceBySource(Song::Source::Qobuz), app->albumcover_loader(), QLatin1String(QobuzSettings::kSettingsGroup), this)),
#endif
#ifdef HAVE_DROPBOX
dropbox_view_(new StreamingSongsView(app->streaming_services()->ServiceBySource(Song::Source::Dropbox), QLatin1String(DropboxSettings::kSettingsGroup), this)),
#endif #endif
radio_view_(new RadioViewContainer(this)), radio_view_(new RadioViewContainer(this)),
lastfm_import_dialog_(new LastFMImportDialog(app_->lastfm_import(), this)), lastfm_import_dialog_(new LastFMImportDialog(app_->lastfm_import(), this)),
@@ -382,10 +385,8 @@ MainWindow::MainWindow(Application *app,
playlist_add_to_another_(nullptr), playlist_add_to_another_(nullptr),
playlistitem_actions_separator_(nullptr), playlistitem_actions_separator_(nullptr),
playlist_rescan_songs_(nullptr), playlist_rescan_songs_(nullptr),
playlist_fetch_metadata_(nullptr),
track_position_timer_(new QTimer(this)), track_position_timer_(new QTimer(this)),
track_slider_timer_(new QTimer(this)), track_slider_timer_(new QTimer(this)),
metadata_queue_timer_(new QTimer(this)),
keep_running_(false), keep_running_(false),
playing_widget_(true), playing_widget_(true),
#ifdef HAVE_DBUS #ifdef HAVE_DBUS
@@ -446,6 +447,9 @@ MainWindow::MainWindow(Application *app,
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
ui_->tabs->AddTab(qobuz_view_, u"qobuz"_s, IconLoader::Load(u"qobuz"_s, true, 0, 32), tr("Qobuz")); ui_->tabs->AddTab(qobuz_view_, u"qobuz"_s, IconLoader::Load(u"qobuz"_s, true, 0, 32), tr("Qobuz"));
#endif #endif
#ifdef HAVE_DROPBOX
ui_->tabs->AddTab(dropbox_view_, u"dropbox"_s, IconLoader::Load(u"dropbox"_s, true, 0, 32), tr("Dropbox"));
#endif
// Add the playing widget to the fancy tab widget // Add the playing widget to the fancy tab widget
ui_->tabs->AddBottomWidget(ui_->widget_playing); ui_->tabs->AddBottomWidget(ui_->widget_playing);
@@ -457,10 +461,6 @@ MainWindow::MainWindow(Application *app,
track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs); track_slider_timer_->setInterval(kTrackSliderUpdateTimeMs);
QObject::connect(track_slider_timer_, &QTimer::timeout, this, &MainWindow::UpdateTrackSliderPosition); 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 // Start initializing the player
qLog(Debug) << "Initializing player"; qLog(Debug) << "Initializing player";
app_->player()->SetAnalyzer(ui_->analyzer); app_->player()->SetAnalyzer(ui_->analyzer);
@@ -791,6 +791,12 @@ MainWindow::MainWindow(Application *app,
} }
#endif #endif
#ifdef HAVE_DROPBOX
QObject::connect(dropbox_view_, &StreamingSongsView::ShowErrorDialog, this, &MainWindow::ShowErrorDialog);
QObject::connect(dropbox_view_, &StreamingSongsView::OpenSettingsDialog, this, &MainWindow::OpenServiceSettingsDialog);
QObject::connect(dropbox_view_->view(), &StreamingCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
#endif
QObject::connect(radio_view_, &RadioViewContainer::Refresh, &*app_->radio_services(), &RadioServices::RefreshChannels); QObject::connect(radio_view_, &RadioViewContainer::Refresh, &*app_->radio_services(), &RadioServices::RefreshChannels);
QObject::connect(radio_view_->view(), &RadioView::GetChannels, &*app_->radio_services(), &RadioServices::GetChannels); QObject::connect(radio_view_->view(), &RadioView::GetChannels, &*app_->radio_services(), &RadioServices::GetChannels);
QObject::connect(radio_view_->view(), &RadioView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist); QObject::connect(radio_view_->view(), &RadioView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
@@ -821,8 +827,6 @@ MainWindow::MainWindow(Application *app,
#endif #endif
playlist_rescan_songs_ = playlist_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Rescan song(s)..."), this, &MainWindow::RescanSongs); 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_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_->addAction(ui_->action_add_files_to_transcoder);
playlist_menu_->addSeparator(); playlist_menu_->addSeparator();
playlist_copy_url_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy URL(s)..."), this, &MainWindow::PlaylistCopyUrl); playlist_copy_url_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy URL(s)..."), this, &MainWindow::PlaylistCopyUrl);
@@ -1142,7 +1146,18 @@ MainWindow::MainWindow(Application *app,
asked_permission = s.value("asked_permission", false).toBool(); asked_permission = s.value("asked_permission", false).toBool();
s.endGroup(); s.endGroup();
#endif #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(); qLog(Debug) << "Started" << QThread::currentThread();
@@ -1229,7 +1244,7 @@ void MainWindow::ReloadSettings() {
osd_->ReloadSettings(); osd_->ReloadSettings();
s.beginGroup(MainWindowSettings::kSettingsGroup); 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(); s.endGroup();
#ifdef HAVE_SUBSONIC #ifdef HAVE_SUBSONIC
@@ -1280,6 +1295,18 @@ void MainWindow::ReloadSettings() {
} }
#endif #endif
#ifdef HAVE_DROPBOX
s.beginGroup(DropboxSettings::kSettingsGroup);
const bool enable_dropbox = s.value(DropboxSettings::kEnabled, false).toBool();
s.endGroup();
if (enable_dropbox) {
ui_->tabs->EnableTab(dropbox_view_);
}
else {
ui_->tabs->DisableTab(dropbox_view_);
}
#endif
ui_->tabs->ReloadSettings(); ui_->tabs->ReloadSettings();
} }
@@ -1326,10 +1353,12 @@ void MainWindow::ReloadAllSettings() {
qobuz_view_->ReloadSettings(); qobuz_view_->ReloadSettings();
qobuz_view_->search_view()->ReloadSettings(); qobuz_view_->search_view()->ReloadSettings();
#endif #endif
#ifdef HAVE_DROPBOX
dropbox_view_->ReloadSettings();
#endif
#ifdef HAVE_DISCORD_RPC #ifdef HAVE_DISCORD_RPC
discord_rich_presence_->ReloadSettings(); discord_rich_presence_->ReloadSettings();
#endif #endif
} }
void MainWindow::RefreshStyleSheet() { void MainWindow::RefreshStyleSheet() {
@@ -1995,7 +2024,6 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
int in_skipped = 0; int in_skipped = 0;
int not_in_skipped = 0; int not_in_skipped = 0;
int local_songs = 0; int local_songs = 0;
int streaming_songs = 0;
for (const QModelIndex &idx : selection) { for (const QModelIndex &idx : selection) {
@@ -2005,13 +2033,7 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
PlaylistItemPtr item = app_->playlist_manager()->current()->item_at(src_idx.row()); PlaylistItemPtr item = app_->playlist_manager()->current()->item_at(src_idx.row());
if (!item) continue; if (!item) continue;
if (item->EffectiveMetadata().url().isLocalFile()) { if (item->EffectiveMetadata().url().isLocalFile()) ++local_songs;
++local_songs;
}
if (item->EffectiveMetadata().is_stream_service()) {
++streaming_songs;
}
if (item->EffectiveMetadata().has_cue()) { if (item->EffectiveMetadata().has_cue()) {
cue_selected = true; cue_selected = true;
@@ -2039,9 +2061,6 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
playlist_rescan_songs_->setEnabled(local_songs > 0 && editable > 0); playlist_rescan_songs_->setEnabled(local_songs > 0 && editable > 0);
playlist_rescan_songs_->setVisible(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->setEnabled(local_songs > 0 && editable > 0);
ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0); ui_->action_add_files_to_transcoder->setVisible(local_songs > 0 && editable > 0);
@@ -2253,23 +2272,9 @@ void MainWindow::EditTracks() {
void MainWindow::EditTagDialogAccepted() { void MainWindow::EditTagDialogAccepted() {
const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items(); const PlaylistItemPtrList items = edit_tag_dialog_->playlist_items();
const SongList songs = edit_tag_dialog_->songs(); for (PlaylistItemPtr item : items) {
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 {
item->Reload(); item->Reload();
} }
}
// FIXME: This is really lame but we don't know what rows have changed. // FIXME: This is really lame but we don't know what rows have changed.
ui_->playlist->view()->update(); ui_->playlist->view()->update();
@@ -2343,8 +2348,8 @@ void MainWindow::SelectionSetValue() {
QObject::disconnect(*connection); QObject::disconnect(*connection);
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
} }
else if (song.is_stream()) { else if (song.source() == Song::Source::Stream) {
app_->playlist_manager()->current()->setData(source_index.sibling(source_index.row(), static_cast<int>(column)), column_value, 0); app_->playlist_manager()->current()->setData(source_index, column_value, 0);
} }
} }
@@ -2741,6 +2746,9 @@ void MainWindow::OpenServiceSettingsDialog(const Song::Source source) {
case Song::Source::Spotify: case Song::Source::Spotify:
settings_dialog_->OpenAtPage(SettingsDialog::Page::Spotify); settings_dialog_->OpenAtPage(SettingsDialog::Page::Spotify);
break; break;
case Song::Source::Dropbox:
settings_dialog_->OpenAtPage(SettingsDialog::Page::Dropbox);
break;
default: default:
break; break;
} }
@@ -3422,178 +3430,14 @@ void MainWindow::FocusSearchField() {
else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(qobuz_view_) && !qobuz_view_->SearchFieldHasFocus()) { else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(qobuz_view_) && !qobuz_view_->SearchFieldHasFocus()) {
qobuz_view_->FocusSearchField(); qobuz_view_->FocusSearchField();
} }
#endif
#ifdef HAVE_DROPBOX
else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(dropbox_view_) && !dropbox_view_->SearchFieldHasFocus()) {
dropbox_view_->FocusSearchField();
}
#endif #endif
else if (!ui_->playlist->SearchFieldHasFocus()) { else if (!ui_->playlist->SearchFieldHasFocus()) {
ui_->playlist->FocusSearchField(); ui_->playlist->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 <QString>
#include <QUrl> #include <QUrl>
#include <QImage> #include <QImage>
#include <QPixmap>
#include <QTimer> #include <QTimer>
#include <QtEvents> #include <QtEvents>
@@ -245,6 +246,7 @@ class MainWindow : public QMainWindow, public PlatformInterface {
void ToggleSearchCoverAuto(const bool checked); void ToggleSearchCoverAuto(const bool checked);
void SaveGeometry(); void SaveGeometry();
void Exit();
void DoExit(); void DoExit();
void HandleNotificationPreview(const OSDSettings::Type type, const QString &line1, const QString &line2); 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 DeleteFilesFinished(const SongList &songs_with_errors);
void FetchStreamingMetadata();
void ProcessMetadataQueue();
public Q_SLOTS: public Q_SLOTS:
void CommandlineOptionsReceived(const QByteArray &string_options); void CommandlineOptionsReceived(const QByteArray &string_options);
void Raise(); void Raise();
void Exit();
private: private:
void SaveSettings(); void SaveSettings();
@@ -292,6 +290,9 @@ class MainWindow : public QMainWindow, public PlatformInterface {
void CheckFullRescanRevisions(); 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 GetCoverAutomatically();
void SetToggleScrobblingIcon(const bool value); void SetToggleScrobblingIcon(const bool value);
@@ -354,6 +355,9 @@ class MainWindow : public QMainWindow, public PlatformInterface {
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
StreamingTabsView *qobuz_view_; StreamingTabsView *qobuz_view_;
#endif #endif
#ifdef HAVE_DROPBOX
StreamingSongsView *dropbox_view_;
#endif
RadioViewContainer *radio_view_; RadioViewContainer *radio_view_;
@@ -382,13 +386,11 @@ class MainWindow : public QMainWindow, public PlatformInterface {
QList<QAction*> playlistitem_actions_; QList<QAction*> playlistitem_actions_;
QAction *playlistitem_actions_separator_; QAction *playlistitem_actions_separator_;
QAction *playlist_rescan_songs_; QAction *playlist_rescan_songs_;
QAction *playlist_fetch_metadata_;
QModelIndex playlist_menu_index_; QModelIndex playlist_menu_index_;
QTimer *track_position_timer_; QTimer *track_position_timer_;
QTimer *track_slider_timer_; QTimer *track_slider_timer_;
QTimer *metadata_queue_timer_;
bool keep_running_; bool keep_running_;
bool playing_widget_; bool playing_widget_;
@@ -412,14 +414,6 @@ class MainWindow : public QMainWindow, public PlatformInterface {
bool playlists_loaded_; bool playlists_loaded_;
bool delete_files_; bool delete_files_;
std::optional<CommandlineOptions> options_; std::optional<CommandlineOptions> options_;
class MetadataQueueEntry {
public:
Song::Source source;
QString track_id;
QPersistentModelIndex persistent_index;
};
QList<MetadataQueueEntry> metadata_queue_;
}; };
#endif // MAINWINDOW_H #endif // MAINWINDOW_H

View File

@@ -34,13 +34,6 @@
#include "mergedproxymodel.h" #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/bidir_node_iterator.hpp>
#include <boost/multi_index/detail/hash_index_iterator.hpp> #include <boost/multi_index/detail/hash_index_iterator.hpp>
#include <boost/multi_index/hashed_index.hpp> #include <boost/multi_index/hashed_index.hpp>
@@ -52,10 +45,6 @@
#include <boost/multi_index_container.hpp> #include <boost/multi_index_container.hpp>
#include <boost/operators.hpp> #include <boost/operators.hpp>
#ifdef __GNUC__
#pragma GCC diagnostic pop
#endif
using boost::multi_index::hashed_unique; using boost::multi_index::hashed_unique;
using boost::multi_index::identity; using boost::multi_index::identity;
using boost::multi_index::indexed_by; using boost::multi_index::indexed_by;

View File

@@ -236,14 +236,6 @@ void OAuthenticator::Authenticate() {
return; 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_); QUrl redirect_url(redirect_url_);
if (use_local_redirect_server_) { 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_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_local_collection_song() const { return d->source_ == Source::Collection; }
bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); } 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_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_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::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::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(); } bool Song::has_cue() const { return !d->cue_path_.isEmpty(); }
@@ -957,7 +956,7 @@ QString Song::PrettyRating() const {
} }
bool Song::IsEditable() 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 { bool Song::IsFileInfoEqual(const Song &other) const {
@@ -1164,6 +1163,8 @@ QString Song::TextForSource(const Source source) {
case Source::Qobuz: return u"qobuz"_s; case Source::Qobuz: return u"qobuz"_s;
case Source::SomaFM: return u"somafm"_s; case Source::SomaFM: return u"somafm"_s;
case Source::RadioParadise: return u"radioparadise"_s; case Source::RadioParadise: return u"radioparadise"_s;
case Source::Dropbox: return u"dropbox"_s;
case Source::OneDrive: return u"onedrive"_s;
case Source::Unknown: return u"unknown"_s; case Source::Unknown: return u"unknown"_s;
} }
return u"unknown"_s; return u"unknown"_s;
@@ -1184,6 +1185,8 @@ QString Song::DescriptionForSource(const Source source) {
case Source::Qobuz: return u"Qobuz"_s; case Source::Qobuz: return u"Qobuz"_s;
case Source::SomaFM: return u"SomaFM"_s; case Source::SomaFM: return u"SomaFM"_s;
case Source::RadioParadise: return u"Radio Paradise"_s; case Source::RadioParadise: return u"Radio Paradise"_s;
case Source::Dropbox: return u"Dropbox"_s;
case Source::OneDrive: return u"OneDrive"_s;
case Source::Unknown: return u"Unknown"_s; case Source::Unknown: return u"Unknown"_s;
} }
return u"unknown"_s; return u"unknown"_s;
@@ -1203,6 +1206,8 @@ Song::Source Song::SourceFromText(const QString &source) {
if (source.compare("qobuz"_L1, Qt::CaseInsensitive) == 0) return Source::Qobuz; if (source.compare("qobuz"_L1, Qt::CaseInsensitive) == 0) return Source::Qobuz;
if (source.compare("somafm"_L1, Qt::CaseInsensitive) == 0) return Source::SomaFM; if (source.compare("somafm"_L1, Qt::CaseInsensitive) == 0) return Source::SomaFM;
if (source.compare("radioparadise"_L1, Qt::CaseInsensitive) == 0) return Source::RadioParadise; if (source.compare("radioparadise"_L1, Qt::CaseInsensitive) == 0) return Source::RadioParadise;
if (source.compare("dropbox"_L1, Qt::CaseInsensitive) == 0) return Source::Dropbox;
if (source.compare("onedrive"_L1, Qt::CaseInsensitive) == 0) return Source::OneDrive;
return Source::Unknown; return Source::Unknown;
@@ -1222,6 +1227,8 @@ QIcon Song::IconForSource(const Source source) {
case Source::Qobuz: return IconLoader::Load(u"qobuz"_s); case Source::Qobuz: return IconLoader::Load(u"qobuz"_s);
case Source::SomaFM: return IconLoader::Load(u"somafm"_s); case Source::SomaFM: return IconLoader::Load(u"somafm"_s);
case Source::RadioParadise: return IconLoader::Load(u"radioparadise"_s); case Source::RadioParadise: return IconLoader::Load(u"radioparadise"_s);
case Source::Dropbox: return IconLoader::Load(u"dropbox"_s);
case Source::OneDrive: return IconLoader::Load(u"onedrive"_s);
case Source::Unknown: return IconLoader::Load(u"edit-delete"_s); case Source::Unknown: return IconLoader::Load(u"edit-delete"_s);
} }
return IconLoader::Load(u"edit-delete"_s); return IconLoader::Load(u"edit-delete"_s);
@@ -1471,7 +1478,7 @@ Song::FileType Song::FiletypeByExtension(const QString &ext) {
bool Song::IsLinkedCollectionSource(const Source source) { bool Song::IsLinkedCollectionSource(const Source source) {
return source == Source::Collection; return source == Source::Collection || source == Source::Dropbox;
} }
@@ -1490,11 +1497,14 @@ QString Song::ImageCacheDir(const Source source) {
return StandardPaths::WritableLocation(StandardPaths::StandardLocation::AppLocalDataLocation) + u"/qobuzalbumcovers"_s; return StandardPaths::WritableLocation(StandardPaths::StandardLocation::AppLocalDataLocation) + u"/qobuzalbumcovers"_s;
case Source::Device: case Source::Device:
return StandardPaths::WritableLocation(StandardPaths::StandardLocation::AppLocalDataLocation) + u"/devicealbumcovers"_s; return StandardPaths::WritableLocation(StandardPaths::StandardLocation::AppLocalDataLocation) + u"/devicealbumcovers"_s;
case Source::Dropbox:
return StandardPaths::WritableLocation(StandardPaths::StandardLocation::AppLocalDataLocation) + u"/dropboxalbumcovers"_s;
case Source::LocalFile: case Source::LocalFile:
case Source::CDDA: case Source::CDDA:
case Source::Stream: case Source::Stream:
case Source::SomaFM: case Source::SomaFM:
case Source::RadioParadise: case Source::RadioParadise:
case Source::OneDrive:
case Source::Unknown: case Source::Unknown:
return StandardPaths::WritableLocation(StandardPaths::StandardLocation::AppLocalDataLocation) + u"/albumcovers"_s; return StandardPaths::WritableLocation(StandardPaths::StandardLocation::AppLocalDataLocation) + u"/albumcovers"_s;
} }
@@ -1669,24 +1679,12 @@ void Song::InitArtManual() {
void Song::InitArtAutomatic() { void Song::InitArtAutomatic() {
if (d->art_automatic_.isEmpty() && d->source_ == Source::LocalFile && d->url_.isLocalFile()) { if (d->art_automatic_.isEmpty() && d->source_ == Source::LocalFile && d->url_.isLocalFile()) {
const QFileInfo fileinfo(d->url_.toLocalFile()); // Pick the first image file in the album directory.
const QDir dir(fileinfo.path()); QFileInfo file(d->url_.toLocalFile());
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); QDir dir(file.path());
QString best_cover_file; QStringList files = dir.entryList(QStringList() << u"*.jpg"_s << u"*.png"_s << u"*.gif"_s << u"*.jpeg"_s, QDir::Files|QDir::Readable, QDir::Name);
for (const QString &cover_file : cover_files) { if (files.count() > 0) {
if (cover_file.contains("back"_L1, Qt::CaseInsensitive)) { d->art_automatic_ = QUrl::fromLocalFile(file.path() + QDir::separator() + files.first());
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);
} }
} }

View File

@@ -76,7 +76,9 @@ class Song {
Qobuz = 8, Qobuz = 8,
SomaFM = 9, SomaFM = 9,
RadioParadise = 10, RadioParadise = 10,
Spotify = 11 Spotify = 11,
Dropbox = 12,
OneDrive = 13,
}; };
static const int kSourceCount = 16; static const int kSourceCount = 16;
@@ -407,9 +409,8 @@ class Song {
bool is_metadata_good() const; bool is_metadata_good() const;
bool is_local_collection_song() const; bool is_local_collection_song() const;
bool is_linked_collection_song() const; bool is_linked_collection_song() const;
bool is_radio() const;
bool is_stream_service() const;
bool is_stream() const; bool is_stream() const;
bool is_radio() const;
bool is_cdda() const; bool is_cdda() const;
bool is_compilation() const; bool is_compilation() const;
bool stream_url_can_expire() const; bool stream_url_can_expire() const;

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

View File

@@ -589,6 +589,8 @@ void AlbumCoverChoiceController::SaveArtManualToSong(Song *song, const QUrl &art
case Song::Source::Tidal: case Song::Source::Tidal:
case Song::Source::Spotify: case Song::Source::Spotify:
case Song::Source::Qobuz: case Song::Source::Qobuz:
case Song::Source::Dropbox:
case Song::Source::OneDrive:
StreamingServicePtr service = streaming_services_->ServiceBySource(song->source()); StreamingServicePtr service = streaming_services_->ServiceBySource(song->source());
if (!service) break; if (!service) break;
if (service->artists_collection_backend()) { if (service->artists_collection_backend()) {

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::SongsReadded, &*collection_backend_, &CollectionBackend::MarkSongsUnavailable);
QObject::connect(collection_watcher_, &CollectionWatcher::SubdirsDiscovered, &*collection_backend_, &CollectionBackend::AddOrUpdateSubdirs); 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::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::CompilationsNeedUpdating, &*collection_backend_, &CollectionBackend::CompilationsNeedUpdating);
QObject::connect(collection_watcher_, &CollectionWatcher::UpdateLastSeen, &*collection_backend_, &CollectionBackend::UpdateLastSeen); QObject::connect(collection_watcher_, &CollectionWatcher::UpdateLastSeen, &*collection_backend_, &CollectionBackend::UpdateLastSeen);
QObject::connect(collection_watcher_, &CollectionWatcher::ScanStarted, this, &FilesystemDevice::TaskStarted); QObject::connect(collection_watcher_, &CollectionWatcher::ScanStarted, this, &FilesystemDevice::TaskStarted);

View File

@@ -31,6 +31,7 @@
#include <QLabel> #include <QLabel>
#include <QPushButton> #include <QPushButton>
#include <QKeySequence> #include <QKeySequence>
#include <QTextBrowser>
#include "about.h" #include "about.h"
#include "ui_about.h" #include "ui_about.h"
@@ -43,6 +44,52 @@ About::About(QWidget *parent) : QDialog(parent), ui_{} {
setWindowFlags(windowFlags()|Qt::WindowStaysOnTopHint); setWindowFlags(windowFlags()|Qt::WindowStaysOnTopHint);
setWindowTitle(tr("About Strawberry")); 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; QFont title_font;
title_font.setBold(true); title_font.setBold(true);
title_font.setPointSize(title_font.pointSize() + 4); 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->setFont(title_font);
ui_.label_title->setText(windowTitle()); ui_.label_title->setText(windowTitle());
ui_.label_text->setText(MainHtml()); 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); ui_.buttonBox->button(QDialogButtonBox::Close)->setShortcut(QKeySequence::Close);
@@ -64,17 +113,94 @@ QString About::MainHtml() const {
ret += "</p>"_L1; ret += "</p>"_L1;
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 += "<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 += "<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; ret += "</p>"_L1;
return ret; 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 <QObject>
#include <QDialog> #include <QDialog>
#include <QList>
#include <QString> #include <QString>
#include "ui_about.h" #include "ui_about.h"
@@ -39,10 +40,25 @@ class About : public QDialog {
explicit About(QWidget *parent = nullptr); explicit About(QWidget *parent = nullptr);
private: 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 MainHtml() const;
QString ContributorsHtml() const;
static QString PersonToHtml(const Person &person);
private: private:
Ui::About ui_; 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 #endif // ABOUT_H

View File

@@ -7,7 +7,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>500</width> <width>500</width>
<height>320</height> <height>500</height>
</rect> </rect>
</property> </property>
<property name="focusPolicy"> <property name="focusPolicy">
@@ -149,6 +149,19 @@
</property> </property>
</spacer> </spacer>
</item> </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> <item>
<spacer name="spacer_bottom"> <spacer name="spacer_bottom">
<property name="orientation"> <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) { bool EditTagDialog::SetLoading(const QString &message) {
const bool loading = !message.isEmpty(); const bool loading = !message.isEmpty();
@@ -1410,12 +1399,6 @@ void EditTagDialog::SaveData() {
} }
if (save_tags || save_playcount || save_rating || save_embedded_cover) { 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. // Not to confuse the collection model.
if (ref.current_.track() <= 0) { ref.current_.set_track(-1); } if (ref.current_.track() <= 0) { ref.current_.set_track(-1); }
if (ref.current_.disc() <= 0) { ref.current_.set_disc(-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()); void SetSongs(const SongList &songs, const PlaylistItemPtrList &items = PlaylistItemPtrList());
PlaylistItemPtrList playlist_items() const { return playlist_items_; } PlaylistItemPtrList playlist_items() const { return playlist_items_; }
SongList songs() const;
void accept() override; void accept() override;
Q_SIGNALS: Q_SIGNALS:

View File

@@ -0,0 +1,132 @@
/*
* Strawberry Music Player
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QNetworkReply>
#include <QJsonObject>
#include "constants/dropboxconstants.h"
#include "core/networkaccessmanager.h"
#include "dropboxservice.h"
#include "dropboxbaserequest.h"
using namespace Qt::Literals::StringLiterals;
using namespace DropboxConstants;
DropboxBaseRequest::DropboxBaseRequest(const SharedPtr<NetworkAccessManager> network, DropboxService *service, QObject *parent)
: JsonBaseRequest(network, parent),
service_(service) {}
QString DropboxBaseRequest::service_name() const {
return service_->name();
}
bool DropboxBaseRequest::authentication_required() const {
return true;
}
bool DropboxBaseRequest::authenticated() const {
return service_->authenticated();
}
bool DropboxBaseRequest::use_authorization_header() const {
return true;
}
QByteArray DropboxBaseRequest::authorization_header() const {
return service_->authorization_header();
}
QNetworkReply *DropboxBaseRequest::GetTemporaryLink(const QUrl &url) {
QJsonObject json_object;
json_object.insert("path"_L1, url.path());
return CreatePostRequest(QUrl(QLatin1String(kApiUrl) + "/2/files/get_temporary_link"_L1), json_object);
}
JsonBaseRequest::JsonObjectResult DropboxBaseRequest::ParseJsonObject(QNetworkReply *reply) {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
JsonObjectResult result(ErrorCode::Success);
result.network_error = reply->error();
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
result.http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_parse_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error == QJsonParseError::NoError) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("error"_L1) && json_object["error"_L1].isObject()) {
const QJsonObject object_error = json_object["error"_L1].toObject();
if (object_error.contains("status"_L1) && object_error.contains("message"_L1)) {
const int status = object_error["status"_L1].toInt();
const QString message = object_error["message"_L1].toString();
result.error_code = ErrorCode::APIError;
result.error_message = QStringLiteral("%1 (%2)").arg(message).arg(status);
}
}
else {
result.json_object = json_document.object();
}
}
else {
result.error_code = ErrorCode::ParseError;
result.error_message = json_parse_error.errorString();
}
}
if (result.error_code != ErrorCode::APIError) {
if (reply->error() != QNetworkReply::NoError) {
result.error_code = ErrorCode::NetworkError;
result.error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else if (result.http_status_code != 200) {
result.error_code = ErrorCode::HttpError;
result.error_message = QStringLiteral("Received HTTP code %1").arg(result.http_status_code);
}
}
if (reply->error() == QNetworkReply::AuthenticationRequiredError) {
service_->ClearSession();
}
return result;
}

View File

@@ -1,6 +1,6 @@
/* /*
* Strawberry Music Player * Strawberry Music Player
* Copyright 2025-2026, Jonas Kvinge <jonas@jkvinge.net> * Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
* *
* Strawberry is free software: you can redistribute it and/or modify * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -17,39 +17,43 @@
* *
*/ */
#ifndef QOBUZMETADATAREQUEST_H #ifndef DROPBOXBASEREQUEST_H
#define QOBUZMETADATAREQUEST_H #define DROPBOXBASEREQUEST_H
#include "config.h" #include "config.h"
#include <QObject> #include <QByteArray>
#include <QString> #include <QString>
#include <QUrl>
#include "includes/shared_ptr.h" #include "includes/shared_ptr.h"
#include "core/song.h" #include "core/jsonbaserequest.h"
#include "qobuzbaserequest.h"
class QNetworkReply; class QNetworkReply;
class NetworkAccessManager; class NetworkAccessManager;
class QobuzService; class DropboxService;
class QobuzMetadataRequest : public QobuzBaseRequest { class DropboxBaseRequest : public JsonBaseRequest {
Q_OBJECT Q_OBJECT
public: public:
explicit QobuzMetadataRequest(QobuzService *service, const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr); explicit DropboxBaseRequest(const SharedPtr<NetworkAccessManager> network, DropboxService *service, QObject *parent = nullptr);
void FetchTrackMetadata(const QString &track_id); QString service_name() const override;
bool authentication_required() const override;
bool authenticated() const override;
bool use_authorization_header() const override;
QByteArray authorization_header() const override;
protected:
QNetworkReply *GetTemporaryLink(const QUrl &url);
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
Q_SIGNALS: Q_SIGNALS:
void MetadataReceived(QString track_id, Song song); void ShowErrorDialog(const QString &error);
void MetadataFailure(QString track_id, QString error);
private Q_SLOTS:
void TrackMetadataReceived(QNetworkReply *reply, const QString &track_id);
private: private:
void Error(const QString &error_message, const QVariant &debug_output = QVariant()) override; DropboxService *service_;
}; };
#endif // QOBUZMETADATAREQUEST_H #endif // DROPBOXBASEREQUEST_H

View File

@@ -0,0 +1,190 @@
/*
* 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 <QString>
#include <QUrl>
#include <QTimer>
#include "constants/dropboxsettings.h"
#include "core/logging.h"
#include "core/settings.h"
#include "core/database.h"
#include "core/urlhandlers.h"
#include "core/networkaccessmanager.h"
#include "core/oauthenticator.h"
#include "collection/collectionbackend.h"
#include "collection/collectionmodel.h"
#include "streaming/cloudstoragestreamingservice.h"
#include "dropboxservice.h"
#include "dropboxurlhandler.h"
#include "dropboxsongsrequest.h"
#include "dropboxstreamurlrequest.h"
using namespace Qt::Literals::StringLiterals;
using namespace DropboxSettings;
const Song::Source DropboxService::kSource = Song::Source::Dropbox;
namespace {
constexpr char kClientIDB64[] = "Zmx0b2EyYzRwaGo2eHlw";
constexpr char kClientSecretB64[] = "emo3em5jNnNpM3Ftd2s3";
constexpr char kOAuthRedirectUrl[] = "http://localhost/";
constexpr char kOAuthAuthorizeUrl[] = "https://www.dropbox.com/1/oauth2/authorize";
constexpr char kOAuthAccessTokenUrl[] = "https://api.dropboxapi.com/1/oauth2/token";
} // namespace
DropboxService::DropboxService(const SharedPtr<TaskManager> task_manager,
const SharedPtr<Database> database,
const SharedPtr<NetworkAccessManager> network,
const SharedPtr<UrlHandlers> url_handlers,
const SharedPtr<TagReaderClient> tagreader_client,
const SharedPtr<AlbumCoverLoader> albumcover_loader,
QObject *parent)
: CloudStorageStreamingService(task_manager, database, tagreader_client, albumcover_loader, Song::Source::Dropbox, u"Dropbox"_s, u"dropbox"_s, QLatin1String(kSettingsGroup), parent),
network_(network),
oauth_(new OAuthenticator(network, this)),
songs_request_(new DropboxSongsRequest(network, collection_backend_, this, this)),
enabled_(false),
next_stream_url_request_id_(0) {
url_handlers->Register(new DropboxUrlHandler(task_manager, this, this));
oauth_->set_settings_group(QLatin1String(kSettingsGroup));
oauth_->set_type(OAuthenticator::Type::Authorization_Code);
oauth_->set_authorize_url(QUrl(QLatin1String(kOAuthAuthorizeUrl)));
oauth_->set_redirect_url(QUrl(QLatin1String(kOAuthRedirectUrl)));
oauth_->set_access_token_url(QUrl(QLatin1String(kOAuthAccessTokenUrl)));
oauth_->set_client_id(QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)));
oauth_->set_client_secret(QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64)));
oauth_->set_use_local_redirect_server(true);
oauth_->set_random_port(true);
QObject::connect(oauth_, &OAuthenticator::AuthenticationFinished, this, &DropboxService::OAuthFinished);
DropboxService::ReloadSettings();
oauth_->LoadSession();
}
bool DropboxService::authenticated() const {
return oauth_->authenticated();
}
void DropboxService::Exit() {
wait_for_exit_ << &*collection_backend_;
QObject::connect(&*collection_backend_, &CollectionBackend::ExitFinished, this, &DropboxService::ExitReceived);
collection_backend_->ExitAsync();
}
void DropboxService::ExitReceived() {
QObject *obj = sender();
QObject::disconnect(obj, nullptr, this, nullptr);
qLog(Debug) << obj << "successfully exited.";
wait_for_exit_.removeAll(obj);
if (wait_for_exit_.isEmpty()) Q_EMIT ExitFinished();
}
void DropboxService::ReloadSettings() {
Settings s;
s.beginGroup(kSettingsGroup);
enabled_ = s.value(kEnabled, false).toBool();
s.endGroup();
}
void DropboxService::Authenticate() {
oauth_->Authenticate();
}
void DropboxService::ClearSession() {
oauth_->ClearSession();
}
void DropboxService::OAuthFinished(const bool success, const QString &error) {
if (success) {
Q_EMIT LoginFinished(true);
Q_EMIT LoginSuccess();
}
else {
Q_EMIT LoginFailure(error);
Q_EMIT LoginFinished(false);
}
}
QByteArray DropboxService::authorization_header() const {
return oauth_->authorization_header();
}
void DropboxService::Start() {
songs_request_->GetFolderList();
}
void DropboxService::Reset() {
collection_backend_->DeleteAll();
Settings s;
s.beginGroup(kSettingsGroup);
s.remove("cursor");
s.endGroup();
if (authenticated()) {
Start();
}
}
uint DropboxService::GetStreamURL(const QUrl &url, QString &error) {
if (!authenticated()) {
error = tr("Not authenticated with Dropbox.");
return 0;
}
uint id = 0;
while (id == 0) id = ++next_stream_url_request_id_;
DropboxStreamURLRequestPtr stream_url_request = DropboxStreamURLRequestPtr(new DropboxStreamURLRequest(network_, this, id, url));
stream_url_requests_.insert(id, stream_url_request);
QObject::connect(&*stream_url_request, &DropboxStreamURLRequest::StreamURLRequestFinished, this, &DropboxService::StreamURLRequestFinishedSlot);
stream_url_request->Process();
return id;
}
void DropboxService::StreamURLRequestFinishedSlot(const uint id, const QUrl &media_url, const bool success, const QUrl &stream_url, const QString &error) {
if (!stream_url_requests_.contains(id)) return;
DropboxStreamURLRequestPtr stream_url_request = stream_url_requests_.take(id);
Q_EMIT StreamURLRequestFinished(id, media_url, success, stream_url, error);
}

View File

@@ -0,0 +1,93 @@
/*
* 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 DROPBOXSERVICE_H
#define DROPBOXSERVICE_H
#include <QList>
#include <QString>
#include <QUrl>
#include <QSharedPointer>
#include "core/song.h"
#include "streaming/cloudstoragestreamingservice.h"
class QNetworkReply;
class TaskManager;
class Database;
class NetworkAccessManager;
class UrlHandlers;
class TagReaderClient;
class AlbumCoverLoader;
class OAuthenticator;
class DropboxSongsRequest;
class DropboxStreamURLRequest;
class DropboxService : public CloudStorageStreamingService {
Q_OBJECT
public:
explicit DropboxService(const SharedPtr<TaskManager> task_manager,
const SharedPtr<Database> database,
const SharedPtr<NetworkAccessManager> network,
const SharedPtr<UrlHandlers> url_handlers,
const SharedPtr<TagReaderClient> tagreader_client,
const SharedPtr<AlbumCoverLoader> albumcover_loader,
QObject *parent = nullptr);
static const Song::Source kSource;
bool oauth() const override { return true; }
bool authenticated() const override;
bool show_progress() const override { return false; }
bool enable_refresh_button() const override { return false; }
void Exit() override;
void ReloadSettings() override;
void Authenticate();
void ClearSession();
void Start();
void Reset();
uint GetStreamURL(const QUrl &url, QString &error);
QByteArray authorization_header() const;
Q_SIGNALS:
void StreamURLRequestFinished(const uint id, const QUrl &media_url, const bool success, const QUrl &stream_url, const QString &error = QString());
private Q_SLOTS:
void ExitReceived();
void OAuthFinished(const bool success, const QString &error = QString());
void StreamURLRequestFinishedSlot(const uint id, const QUrl &media_url, const bool success, const QUrl &stream_url, const QString &error = QString());
private:
const SharedPtr<NetworkAccessManager> network_;
OAuthenticator *oauth_;
DropboxSongsRequest *songs_request_;
bool enabled_;
QList<QObject*> wait_for_exit_;
bool finished_;
uint next_stream_url_request_id_;
QMap<uint, QSharedPointer<DropboxStreamURLRequest>> stream_url_requests_;
};
#endif // DROPBOXSERVICE_H

View File

@@ -0,0 +1,244 @@
/*
* Strawberry Music Player
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QDateTime>
#include <QTimer>
#include <QNetworkReply>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include "constants/dropboxsettings.h"
#include "core/logging.h"
#include "core/settings.h"
#include "core/networkaccessmanager.h"
#include "collection/collectionbackend.h"
#include "dropboxservice.h"
#include "dropboxbaserequest.h"
#include "dropboxsongsrequest.h"
using namespace Qt::Literals::StringLiterals;
using namespace DropboxSettings;
DropboxSongsRequest::DropboxSongsRequest(const SharedPtr<NetworkAccessManager> network, const SharedPtr<CollectionBackend> collection_backend, DropboxService *service, QObject *parent)
: DropboxBaseRequest(network, service, parent),
network_(network),
collection_backend_(collection_backend),
service_(service) {}
void DropboxSongsRequest::GetFolderList() {
Settings s;
s.beginGroup(kSettingsGroup);
QString cursor = s.value("cursor").toString();
s.endGroup();
QUrl url(QLatin1String(kApiUrl) + "/2/files/list_folder"_L1);
QJsonObject json_object;
if (cursor.isEmpty()) {
json_object.insert("path"_L1, ""_L1);
json_object.insert("recursive"_L1, true);
json_object.insert("include_deleted"_L1, true);
}
else {
url.setUrl(QLatin1String(kApiUrl) + "/2/files/list_folder/continue"_L1);
json_object.insert("cursor"_L1, cursor);
}
QNetworkReply *reply = CreatePostRequest(url, json_object);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply] { GetFolderListFinished(reply); });
}
void DropboxSongsRequest::GetFolderListFinished(QNetworkReply *reply) {
reply->deleteLater();
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
if (json_object.contains("reset"_L1) && json_object["reset"_L1].toBool()) {
qLog(Debug) << "Resetting Dropbox database";
collection_backend_->DeleteAll();
}
{
Settings s;
s.beginGroup(kSettingsGroup);
s.setValue("cursor", json_object["cursor"_L1].toString());
s.endGroup();
}
const QJsonArray entires = json_object["entries"_L1].toArray();
qLog(Debug) << "File list found:" << entires.size();
QList<QUrl> urls_deleted;
for (const QJsonValue &value_entry : entires) {
if (!value_entry.isObject()) {
continue;
}
const QJsonObject object_entry = value_entry.toObject();
const QString tag = object_entry[".tag"_L1].toString();
const QString path = object_entry["path_lower"_L1].toString();
const qint64 size = object_entry["size"_L1].toInt();
const QString server_modified = object_entry["server_modified"_L1].toString();
QUrl url;
url.setScheme(service_->url_scheme());
url.setPath(path);
if (tag == "deleted"_L1) {
qLog(Debug) << "Deleting song with URL" << url;
urls_deleted << url;
continue;
}
if (tag == "folder"_L1) {
continue;
}
if (DropboxService::IsSupportedFiletype(path)) {
GetStreamURL(url, path, size, QDateTime::fromString(server_modified, Qt::ISODate).toSecsSinceEpoch());
}
}
if (!urls_deleted.isEmpty()) {
collection_backend_->DeleteSongsByUrlsAsync(urls_deleted);
}
if (json_object.contains("has_more"_L1) && json_object["has_more"_L1].isBool() && json_object["has_more"_L1].toBool()) {
Settings s;
s.beginGroup(kSettingsGroup);
s.setValue("cursor", json_object["cursor"_L1].toVariant());
s.endGroup();
GetFolderList();
}
else {
// Long-poll wait for changes.
LongPollDelta();
}
}
void DropboxSongsRequest::LongPollDelta() {
if (!service_->authenticated()) {
return;
}
Settings s;
s.beginGroup(kSettingsGroup);
const QString cursor = s.value("cursor").toString();
s.endGroup();
QJsonObject json_object;
json_object.insert("cursor"_L1, cursor);
json_object.insert("timeout"_L1, 30);
QNetworkReply *reply = CreatePostRequest(QUrl(QLatin1String(kNotifyApiUrl) + "/2/files/list_folder/longpoll"_L1), json_object);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply] { LongPollDeltaFinished(reply); });
}
void DropboxSongsRequest::LongPollDeltaFinished(QNetworkReply *reply) {
reply->deleteLater();
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (json_object["changes"_L1].toBool()) {
qLog(Debug) << "Dropbox: Received changes...";
GetFolderList();
}
else {
bool ok = false;
int backoff = json_object["backoff"_L1].toString().toInt(&ok);
if (!ok) {
backoff = 10;
}
QTimer::singleShot(backoff * 1000, this, &DropboxSongsRequest::LongPollDelta);
}
}
void DropboxSongsRequest::GetStreamURL(const QUrl &url, const QString &path, const qint64 size, const qint64 mtime) {
QNetworkReply *reply = GetTemporaryLink(url);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, path, size, mtime]() {
GetStreamUrlFinished(reply, path, size, mtime);
});
}
void DropboxSongsRequest::GetStreamUrlFinished(QNetworkReply *reply, const QString &filename, const qint64 size, const qint64 mtime) {
reply->deleteLater();
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
if (!json_object.contains("link"_L1)) {
Error(u"Missing link"_s);
return;
}
const QUrl url = QUrl::fromEncoded(json_object["link"_L1].toVariant().toByteArray());
service_->MaybeAddFileToDatabase(url, filename, size, mtime);
}
void DropboxSongsRequest::Error(const QString &error_message, const QVariant &debug_output) {
qLog(Error) << service_name() << error_message;
if (debug_output.isValid()) {
qLog(Debug) << debug_output;
}
Q_EMIT ShowErrorDialog(error_message);
}

View File

@@ -0,0 +1,67 @@
/*
* 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 DROPBOXSONGSREQUEST_H
#define DROPBOXSONGSREQUEST_H
#include "config.h"
#include <QList>
#include <QString>
#include <QUrl>
#include "dropboxbaserequest.h"
class NetworkAccessManager;
class CollectionBackend;
class QNetworkReply;
class DropboxService;
class DropboxSongsRequest : public DropboxBaseRequest {
Q_OBJECT
public:
explicit DropboxSongsRequest(const SharedPtr<NetworkAccessManager> network, const SharedPtr<CollectionBackend> collection_backend, DropboxService *service, QObject *parent = nullptr);
void ReloadSettings();
void GetFolderList();
Q_SIGNALS:
void ShowErrorDialog(const QString &error);
private:
void LongPollDelta();
void GetStreamURL(const QUrl &url, const QString &path, const qint64 size, const qint64 mtime);
protected:
void Error(const QString &error_message, const QVariant &debug_output = QVariant()) override;
private Q_SLOTS:
void GetFolderListFinished(QNetworkReply *reply);
void LongPollDeltaFinished(QNetworkReply *reply);
void GetStreamUrlFinished(QNetworkReply *reply, const QString &filename, const qint64 size, const qint64 mtime);
private:
const SharedPtr<NetworkAccessManager> network_;
const SharedPtr<CollectionBackend> collection_backend_;
DropboxService *service_;
};
#endif // DROPBOXSONGSREQUEST_H

View File

@@ -0,0 +1,129 @@
/*
* Strawberry Music Player
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QNetworkReply>
#include <QJsonObject>
#include "includes/shared_ptr.h"
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "dropboxservice.h"
#include "dropboxbaserequest.h"
#include "dropboxstreamurlrequest.h"
using namespace Qt::Literals::StringLiterals;
DropboxStreamURLRequest::DropboxStreamURLRequest(const SharedPtr<NetworkAccessManager> network, DropboxService *service, const uint id, const QUrl &media_url, QObject *parent)
: DropboxBaseRequest(network, service, parent),
network_(network),
service_(service),
id_(id),
media_url_(media_url),
reply_(nullptr) {}
DropboxStreamURLRequest::~DropboxStreamURLRequest() {
if (reply_) {
QObject::disconnect(reply_, nullptr, this, nullptr);
if (reply_->isRunning()) reply_->abort();
reply_->deleteLater();
reply_ = nullptr;
}
}
void DropboxStreamURLRequest::Cancel() {
if (reply_ && reply_->isRunning()) {
reply_->abort();
}
}
void DropboxStreamURLRequest::Process() {
GetStreamURL();
}
void DropboxStreamURLRequest::GetStreamURL() {
if (reply_) {
QObject::disconnect(reply_, nullptr, this, nullptr);
if (reply_->isRunning()) reply_->abort();
reply_->deleteLater();
}
reply_ = GetTemporaryLink(media_url_);
QObject::connect(reply_, &QNetworkReply::finished, this, &DropboxStreamURLRequest::StreamURLReceived);
}
void DropboxStreamURLRequest::StreamURLReceived() {
const QScopeGuard finish = qScopeGuard([this]() { Finish(); });
if (!reply_) return;
Q_ASSERT(replies_.contains(reply_));
replies_.removeAll(reply_);
const JsonObjectResult json_object_result = ParseJsonObject(reply_).json_object;
QObject::disconnect(reply_, nullptr, this, nullptr);
reply_->deleteLater();
reply_ = nullptr;
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.isEmpty() || !json_object.contains("link"_L1)) {
Error(u"Could not parse stream URL"_s);
return;
}
stream_url_ = QUrl::fromEncoded(json_object["link"_L1].toVariant().toByteArray());
success_ = stream_url_.isValid();
}
void DropboxStreamURLRequest::Error(const QString &error_message, const QVariant &debug_output) {
qLog(Error) << service_name() << error_message;
if (debug_output.isValid()) {
qLog(Debug) << debug_output;
}
error_ = error_message;
}
void DropboxStreamURLRequest::Finish() {
Q_EMIT StreamURLRequestFinished(id_, media_url_, success_, stream_url_, error_);
}

View File

@@ -0,0 +1,71 @@
/*
* 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 DROPBOXSTREAMURLREQUEST_H
#define DROPBOXSTREAMURLREQUEST_H
#include "config.h"
#include <QVariant>
#include <QString>
#include <QUrl>
#include <QSharedPointer>
#include "includes/shared_ptr.h"
#include "dropboxservice.h"
#include "dropboxbaserequest.h"
class QNetworkReply;
class NetworkAccessManager;
class DropboxStreamURLRequest : public DropboxBaseRequest {
Q_OBJECT
public:
explicit DropboxStreamURLRequest(const SharedPtr<NetworkAccessManager> network, DropboxService *service, const uint id, const QUrl &media_url, QObject *parent = nullptr);
~DropboxStreamURLRequest() override;
void Process();
void Cancel();
Q_SIGNALS:
void StreamURLRequestFinished(const uint id, const QUrl &media_url, const bool success, const QUrl &stream_url, const QString &error = QString());
private Q_SLOTS:
void StreamURLReceived();
private:
void GetStreamURL();
void Error(const QString &error_message, const QVariant &debug_output = QVariant()) override;
void Finish();
private:
const SharedPtr<NetworkAccessManager> network_;
DropboxService *service_;
uint id_;
QUrl media_url_;
QUrl stream_url_;
QNetworkReply *reply_;
bool success_;
QString error_;
};
using DropboxStreamURLRequestPtr = QSharedPointer<DropboxStreamURLRequest>;
#endif // DROPBOXSTREAMURLREQUEST_H

View File

@@ -0,0 +1,76 @@
/*
* 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 <QString>
#include <QUrl>
#include "includes/shared_ptr.h"
#include "core/taskmanager.h"
#include "dropboxurlhandler.h"
#include "dropboxservice.h"
DropboxUrlHandler::DropboxUrlHandler(const SharedPtr<TaskManager> task_manager, DropboxService *service, QObject *parent)
: UrlHandler(parent),
task_manager_(task_manager),
service_(service) {
QObject::connect(service, &DropboxService::StreamURLRequestFinished, this, &DropboxUrlHandler::StreamURLRequestFinished);
}
QString DropboxUrlHandler::scheme() const { return service_->url_scheme(); }
UrlHandler::LoadResult DropboxUrlHandler::StartLoading(const QUrl &url) {
Request request;
request.task_id = task_manager_->StartTask(QStringLiteral("Loading %1 stream...").arg(url.scheme()));
QString error;
request.id = service_->GetStreamURL(url, error);
if (request.id == 0) {
CancelTask(request.task_id);
return LoadResult(url, LoadResult::Type::Error, error);
}
requests_.insert(request.id, request);
LoadResult load_result(url);
load_result.type_ = LoadResult::Type::WillLoadAsynchronously;
return load_result;
}
void DropboxUrlHandler::StreamURLRequestFinished(const uint id, const QUrl &media_url, const bool success, const QUrl &stream_url, const QString &error) {
if (!requests_.contains(id)) return;
const Request request = requests_.take(id);
CancelTask(request.task_id);
if (success) {
Q_EMIT AsyncLoadComplete(LoadResult(media_url, LoadResult::Type::TrackAvailable, stream_url));
}
else {
Q_EMIT AsyncLoadComplete(LoadResult(media_url, LoadResult::Type::Error, error));
}
}
void DropboxUrlHandler::CancelTask(const int task_id) {
task_manager_->SetTaskFinished(task_id);
}

View File

@@ -0,0 +1,56 @@
/*
* 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 DROPBOXURLHANDLER_H
#define DROPBOXURLHANDLER_H
#include "includes/shared_ptr.h"
#include "core/urlhandler.h"
class TaskManager;
class DropboxService;
class DropboxUrlHandler : public UrlHandler {
Q_OBJECT
public:
explicit DropboxUrlHandler(const SharedPtr<TaskManager> task_manager, DropboxService *service, QObject *parent = nullptr);
QString scheme() const override;
LoadResult StartLoading(const QUrl &url) override;
private:
void CancelTask(const int task_id);
private Q_SLOTS:
void StreamURLRequestFinished(const uint id, const QUrl &media_url, const bool success, const QUrl &stream_url, const QString &error = QString());
private:
class Request {
public:
explicit Request() : id(0), task_id(-1) {}
uint id;
int task_id;
};
const SharedPtr<TaskManager> task_manager_;
DropboxService *service_;
QMap<uint, Request> requests_;
};
#endif // DROPBOXURLHANDLER_H

View File

@@ -29,17 +29,13 @@
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QUrl> #include <QUrl>
#include <QStandardPaths> #include <QSettings>
#include <QMessageBox> #include <QMessageBox>
#include <QScrollBar> #include <QScrollBar>
#include <QLineEdit> #include <QLineEdit>
#include <QToolButton> #include <QToolButton>
#include <QFileDialog>
#include <QSpacerItem>
#include <QtEvents> #include <QtEvents>
#include "constants/appearancesettings.h"
#include "constants/filefilterconstants.h"
#include "includes/shared_ptr.h" #include "includes/shared_ptr.h"
#include "core/deletefiles.h" #include "core/deletefiles.h"
#include "core/filesystemmusicstorage.h" #include "core/filesystemmusicstorage.h"
@@ -49,11 +45,10 @@
#include "dialogs/deleteconfirmationdialog.h" #include "dialogs/deleteconfirmationdialog.h"
#include "fileview.h" #include "fileview.h"
#include "fileviewlist.h" #include "fileviewlist.h"
#include "fileviewtree.h"
#include "fileviewtreemodel.h"
#include "fileviewtreeitem.h"
#include "ui_fileview.h" #include "ui_fileview.h"
#include "organize/organizeerrordialog.h" #include "organize/organizeerrordialog.h"
#include "constants/appearancesettings.h"
#include "constants/filefilterconstants.h"
using std::make_unique; using std::make_unique;
using namespace Qt::Literals::StringLiterals; using namespace Qt::Literals::StringLiterals;
@@ -62,12 +57,9 @@ FileView::FileView(QWidget *parent)
: QWidget(parent), : QWidget(parent),
ui_(new Ui_FileView), ui_(new Ui_FileView),
model_(nullptr), model_(nullptr),
tree_model_(nullptr),
undo_stack_(new QUndoStack(this)), undo_stack_(new QUndoStack(this)),
task_manager_(nullptr), task_manager_(nullptr),
storage_(new FilesystemMusicStorage(Song::Source::LocalFile, u"/"_s)), storage_(new FilesystemMusicStorage(Song::Source::LocalFile, u"/"_s)) {
tree_view_active_(false),
view_mode_spacer_(nullptr) {
ui_->setupUi(this); ui_->setupUi(this);
@@ -76,14 +68,12 @@ FileView::FileView(QWidget *parent)
ui_->forward->setIcon(IconLoader::Load(u"go-next"_s)); ui_->forward->setIcon(IconLoader::Load(u"go-next"_s));
ui_->home->setIcon(IconLoader::Load(u"go-home"_s)); ui_->home->setIcon(IconLoader::Load(u"go-home"_s));
ui_->up->setIcon(IconLoader::Load(u"go-up"_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_->back, &QToolButton::clicked, undo_stack_, &QUndoStack::undo);
QObject::connect(ui_->forward, &QToolButton::clicked, undo_stack_, &QUndoStack::redo); QObject::connect(ui_->forward, &QToolButton::clicked, undo_stack_, &QUndoStack::redo);
QObject::connect(ui_->home, &QToolButton::clicked, this, &FileView::FileHome); QObject::connect(ui_->home, &QToolButton::clicked, this, &FileView::FileHome);
QObject::connect(ui_->up, &QToolButton::clicked, this, &FileView::FileUp); QObject::connect(ui_->up, &QToolButton::clicked, this, &FileView::FileUp);
QObject::connect(ui_->path, &QLineEdit::textChanged, this, &FileView::ChangeFilePath); 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::canUndoChanged, ui_->back, &FileView::setEnabled);
QObject::connect(undo_stack_, &QUndoStack::canRedoChanged, ui_->forward, &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::Delete, this, &FileView::Delete);
QObject::connect(ui_->list, &FileViewList::EditTags, this, &FileView::EditTags); 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); QString filter = QLatin1String(kFileFilter);
filter_list_ << filter.split(u' '); filter_list_ << filter.split(u' ');
@@ -135,19 +109,6 @@ void FileView::ReloadSettings() {
ui_->forward->setIconSize(QSize(iconsize, iconsize)); ui_->forward->setIconSize(QSize(iconsize, iconsize));
ui_->home->setIconSize(QSize(iconsize, iconsize)); ui_->home->setIconSize(QSize(iconsize, iconsize));
ui_->up->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) { void FileView::ItemActivated(const QModelIndex &idx) {
// Only handle activation for list view (not tree view) if (model_->isDir(idx))
if (!tree_view_active_ && model_->isDir(idx)) {
ChangeFilePath(model_->filePath(idx)); ChangeFilePath(model_->filePath(idx));
}
} }
void FileView::ItemDoubleClick(const QModelIndex &idx) { void FileView::ItemDoubleClick(const QModelIndex &idx) {
QString file_path; if (model_->isDir(idx)) {
bool is_file = false; return;
// 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;
}
} }
// Add file to playlist if it's a valid file QString file_path = model_->filePath(idx);
if (is_file && !file_path.isEmpty()) {
MimeData *mimedata = new MimeData; MimeData *mimedata = new MimeData;
mimedata->from_doubleclick_ = true; mimedata->from_doubleclick_ = true;
mimedata->setUrls(QList<QUrl>() << QUrl::fromLocalFile(file_path)); mimedata->setUrls(QList<QUrl>() << QUrl::fromLocalFile(file_path));
mimedata->name_for_new_playlist_ = file_path; mimedata->name_for_new_playlist_ = file_path;
Q_EMIT AddToPlaylist(mimedata); Q_EMIT AddToPlaylist(mimedata);
}
} }
@@ -333,156 +272,12 @@ void FileView::showEvent(QShowEvent *e) {
model_->setNameFilterDisables(false); model_->setNameFilterDisables(false);
ui_->list->setModel(model_); ui_->list->setModel(model_);
// Create tree model
tree_model_ = new FileViewTreeModel(this);
tree_model_->SetNameFilters(filter_list_);
SetupTreeView();
ChangeFilePathWithoutUndo(QDir::homePath()); ChangeFilePathWithoutUndo(QDir::homePath());
if (!lazy_set_path_.isEmpty()) ChangeFilePathWithoutUndo(lazy_set_path_); 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) { void FileView::keyPressEvent(QKeyEvent *e) {
switch (e->key()) { switch (e->key()) {

View File

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

View File

@@ -95,78 +95,8 @@
<item> <item>
<widget class="QLineEdit" name="path"/> <widget class="QLineEdit" name="path"/>
</item> </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> </layout>
</item> </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> <item>
<widget class="FileViewList" name="list"> <widget class="FileViewList" name="list">
<property name="dragEnabled"> <property name="dragEnabled">
@@ -191,62 +121,12 @@
</item> </item>
</layout> </layout>
</widget> </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> <customwidgets>
<customwidget> <customwidget>
<class>FileViewList</class> <class>FileViewList</class>
<extends>QListView</extends> <extends>QListView</extends>
<header>fileview/fileviewlist.h</header> <header>fileview/fileviewlist.h</header>
</customwidget> </customwidget>
<customwidget>
<class>FileViewTree</class>
<extends>QTreeView</extends>
<header>fileview/fileviewtree.h</header>
</customwidget>
</customwidgets> </customwidgets>
<resources/> <resources/>
<connections/> <connections/>

View File

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

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